diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 30f52edddf..bf91365caa 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3915,6 +3915,7 @@ fn walk_ability( copy_count_status: _, forward_result: _, distribution: _, + distribute: _, // announcement unit, no state read/write axis chosen_x: _, cost_paid_object: _, noted_mana_payment: _, // concrete captured payment snapshot, no read/write effect @@ -7072,6 +7073,18 @@ mod tests { fn ra(effect: Effect) -> ResolvedAbility { ResolvedAbility::new(effect, vec![], ObjectId(1), PlayerId(0)) } + + #[test] + fn unassigned_distribution_unit_is_rw_inert() { + let base = ra(Effect::NoOp); + let mut divided = base.clone(); + divided.distribute = Some(crate::types::game_state::DistributionUnit::Damage); + + assert_eq!( + format!("{:?}", ability_rw_profile(&base)), + format!("{:?}", ability_rw_profile(÷d)) + ); + } fn cond(mut a: ResolvedAbility, c: AbilityCondition) -> ResolvedAbility { a.condition = Some(c); a diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 797bd56ebc..53f5063b8d 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -264,6 +264,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes { replacement_applied: _, // replacement provenance set, no dynamic read sub_link: _, // SubAbilityLink kind tag sibling_condition: _, // SiblingCondition replication marker, no dynamic read + distribute: _, // announcement unit tag/string, no resolution-time dynamic read parent_target_missing_reason: _, // seam flag } = a; @@ -6388,6 +6389,24 @@ mod tests { ) } + #[test] + fn unassigned_distribution_unit_adds_no_dynamic_read_axis() { + let base = fixed_drain(); + let mut divided = base.clone(); + divided.distribute = Some(crate::types::game_state::DistributionUnit::Life); + + let base_axes = resolved_ability_axes(&base, ScanMode::Conservative); + let divided_axes = resolved_ability_axes(÷d, ScanMode::Conservative); + assert_eq!( + (base_axes.event, base_axes.sibling, base_axes.projected), + ( + divided_axes.event, + divided_axes.sibling, + divided_axes.projected + ) + ); + } + // ---- P0/P2: the ScanMode split + descending object-growth firewall ---- /// A read-free vanilla token (Presence of Gond's "1/1 green Elf Warrior"): diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index c18b104874..a4b33bf2e5 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -143,6 +143,9 @@ pub fn build_resolved_from_def_with_targets( resolved.description = def.description.clone(); resolved.forward_result = def.forward_result; resolved.unless_pay = def.unless_pay.clone(); + // CR 601.2d + CR 603.3d: Preserve the unassigned division unit until the + // ordinary stack-announcement authority assigns concrete portions. + resolved.distribute = def.distribute.clone(); resolved.player_scope = def.player_scope.clone(); // CR 101.4 + CR 800.4: Propagate the turn-order override for `player_scope` // iteration. The iteration driver in `effects/mod.rs` reads this and calls @@ -169,7 +172,7 @@ pub fn build_resolved_from_def_with_targets( // keyword list collapses after the first false gate. resolved.sibling_condition = def.sibling_condition; // CR 700.2b + CR 603.3c: Carry the reflexive modal choice + per-mode abilities - // through so try_begin_reflexive_target_selection can route a gated modal + // through so try_materialize_reflexive_trigger can route a gated modal // trigger (Caesar) to AbilityModeChoice instead of resolving the modes // unconditionally. resolved.modal = def.modal.clone(); @@ -193,7 +196,8 @@ pub fn build_resolved_from_def_with_targets( /// Fields from `sub`: effect, duration, sub_ability, else_ability, /// player_scope, optional, optional_for, optional_targeting, multi_target, /// target_constraints, target_choice_timing, description, repeat_for, -/// min_x_value, forward_result, unless_pay, distribution, target_selection_mode. +/// min_x_value, forward_result, unless_pay, distribution, distribute, +/// target_selection_mode. /// /// Fields preserved from `parent`: controller, source_id, kind, context, /// original_controller, scoped_player, chosen_x, cost_paid_object, @@ -246,6 +250,7 @@ pub(crate) fn apply_instead_swap( overridden.forward_result = sub.forward_result; overridden.unless_pay = sub.unless_pay.clone(); overridden.distribution = sub.distribution.clone(); + overridden.distribute = sub.distribute.clone(); overridden.target_selection_mode = sub.target_selection_mode; overridden.target_chooser = sub.target_chooser.clone(); // CR 608.2b + CR 601.2c: a swapped-in effect with its own declared target @@ -901,6 +906,35 @@ pub fn compute_unavailable_modes( unavailable } +/// CR 700.2a / CR 700.2e: every player the modal's `chooser` admits, in APNAP +/// order. +/// +/// `PlayerFilter::Controller` — every standard modal and the `you choose —` +/// alias — is the controller alone, without consulting +/// `effects::matches_player_scope`. Any other filter (CR 700.2e, "an opponent +/// chooses …") is resolved through that canonical authority over APNAP order. +/// +/// Spell announcement wants only the first admitted player, which is what +/// `casting::resolve_modal_chooser` takes; trigger construction needs the whole +/// set, because more than one non-controller candidate makes the controller's +/// CR 700.2e chooser selection a real choice rather than a derivation. +pub(crate) fn modal_chooser_candidates( + state: &GameState, + modal: &ModalChoice, + controller: PlayerId, + source_id: ObjectId, +) -> Vec { + if modal.chooser == PlayerFilter::Controller { + return vec![controller]; + } + players::apnap_order(state) + .into_iter() + .filter(|&p| { + super::effects::matches_player_scope(state, p, &modal.chooser, controller, source_id) + }) + .collect() +} + /// CR 700.2a-b: Mode indices a modal spell cannot choose — repeat constraints /// plus modes whose targeting requirements have no legal assignment. pub fn spell_modal_unavailable_modes( @@ -7755,6 +7789,75 @@ mod tests { use super::*; use crate::game::zones::create_object; + /// CR 700.2a / CR 700.2e: `modal_chooser_candidates` is the one authority + /// both spell announcement and trigger construction read. + /// + /// Announcement is single-valued and takes `.first()`, so this row proves + /// the head of the returned order is byte-identical to the historic + /// `resolve_modal_chooser` result on both branches, and that the tail — the + /// part only trigger construction consumes — really is the complete + /// admitted set rather than that same single value. A regression that + /// truncates the extraction back to one candidate fails the three-player + /// length assertion while leaving both head assertions green. + #[test] + fn modal_chooser_candidates_are_the_complete_admitted_set_in_apnap_order() { + let mut state = GameState::new(crate::types::format::FormatConfig::free_for_all(), 3, 42); + state.active_player = PlayerId(0); + let source = create_object( + &mut state, + crate::types::identifiers::CardId(1), + PlayerId(0), + "Modal chooser source".to_string(), + Zone::Battlefield, + ); + + let mut modal = ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }; + + // CR 700.2a: the controller branch never consults `matches_player_scope` + // and never admits anyone else, in any seat count. + modal.chooser = PlayerFilter::Controller; + assert_eq!( + modal_chooser_candidates(&state, &modal, PlayerId(1), source), + vec![PlayerId(1)], + "the controller branch is the controller alone" + ); + + // CR 700.2e: "an opponent chooses —" with two opponents is a real + // choice, and APNAP order decides which one announcement would take. + modal.chooser = PlayerFilter::Opponent; + let candidates = modal_chooser_candidates(&state, &modal, PlayerId(0), source); + assert_eq!( + candidates, + vec![PlayerId(1), PlayerId(2)], + "every opponent is admitted, in APNAP order" + ); + assert_eq!( + candidates.first().copied(), + Some(PlayerId(1)), + "announcement's single-valued head is the first APNAP opponent" + ); + + // Two-player: the same authority collapses to the unambiguous opponent. + let mut two = GameState::new_two_player(42); + two.active_player = PlayerId(0); + let two_source = create_object( + &mut two, + crate::types::identifiers::CardId(1), + PlayerId(0), + "Modal chooser source".to_string(), + Zone::Battlefield, + ); + assert_eq!( + modal_chooser_candidates(&two, &modal, PlayerId(0), two_source), + vec![PlayerId(1)] + ); + } + /// Matrix rows 5 + 6 — the slot/spec mirror must agree in COUNT **and** /// ORDER, and the context-ref skip must agree between the two sites. /// @@ -9132,6 +9235,7 @@ mod tests { sub.player_scope = Some(crate::types::ability::PlayerFilter::Opponent); sub.optional = true; sub.description = Some("override description".to_string()); + sub.distribute = Some(crate::types::game_state::DistributionUnit::Damage); let swapped = apply_instead_swap(&parent, &sub); @@ -9147,6 +9251,11 @@ mod tests { ); assert!(swapped.optional, "swap must preserve sub.optional"); assert_eq!(swapped.description.as_deref(), Some("override description")); + assert_eq!( + swapped.distribute, + Some(crate::types::game_state::DistributionUnit::Damage), + "swap must preserve the sub-ability's unassigned distribution unit" + ); // Identity / runtime-context fields come from parent. assert_eq!( swapped.controller, @@ -9260,6 +9369,25 @@ mod tests { ); } + #[test] + fn build_resolved_from_def_preserves_unassigned_distribution_unit() { + let mut def = AbilityDefinition::new( + AbilityKind::Database, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 4 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ); + def.distribute = Some(crate::types::game_state::DistributionUnit::Damage); + + let resolved = build_resolved_from_def(&def, ObjectId(1), PlayerId(0)); + + assert_eq!(resolved.distribute, def.distribute); + assert!(resolved.distribution.is_none()); + } + #[test] fn build_resolved_from_def_preserves_unless_pay_modifier() { let modifier = UnlessPayModifier { diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 8596fb81dc..0e32716b87 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -13352,26 +13352,20 @@ fn continue_with_prepared( /// `effects::matches_player_scope` authority filtered over APNAP order. In the /// 2-player engine this is unambiguous. Falls back to the controller if no /// player matches (defensive — cannot happen in a live 2-player game). +/// +/// Spell announcement is single-valued by construction: it opens exactly one +/// `WaitingFor::ModeChoice`, so it takes the first admitted candidate from the +/// shared `ability_utils::modal_chooser_candidates` authority. Trigger +/// construction consumes that same authority's full set. fn resolve_modal_chooser( state: &GameState, modal: &crate::types::ability::ModalChoice, controller: PlayerId, source_id: ObjectId, ) -> PlayerId { - if modal.chooser == crate::types::ability::PlayerFilter::Controller { - return controller; - } - crate::game::players::apnap_order(state) - .into_iter() - .find(|&p| { - crate::game::effects::matches_player_scope( - state, - p, - &modal.chooser, - controller, - source_id, - ) - }) + super::ability_utils::modal_chooser_candidates(state, modal, controller, source_id) + .first() + .copied() .unwrap_or(controller) } diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 196ca80426..172b67abed 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -819,6 +819,11 @@ fn client_state_wire_value( root.remove("stack_trigger_firings"); root.remove("resolving_trigger_firing"); root.remove("resolved_rules_journal"); + // CR 605.4a + CR 117.3c: Defense in depth for direct `ClientGameStateRef` + // callers that did not first run `visibility::filter_state_for_viewer`. + // Both are trusted persistence authorities, never client schema. + root.remove("pending_triggered_mana_resume"); + root.remove("pending_trigger_construction_priority_recipient"); redact_private_trigger_firing(&mut value); @@ -6175,4 +6180,134 @@ mod tests { loyalty, the battlefield set and the `∞` store — zero new information" ); } + + /// CR 605.4a + CR 117.3c (plan Step 6, boundary 2): `client_state_wire_value` + /// is the defence-in-depth boundary for direct `ClientGameStateRef::wrap` + /// callers that never ran `visibility::filter_state_for_viewer`. + /// + /// All four projections — direct `wrap` for the prompt owner and for an + /// opponent, and `wrap_filtered` over both filtered states — must omit both + /// root keys and every private sentinel, while the public prompt survives. + /// The structural half fails if either field is renamed without updating + /// `client_state_wire_value`: the trusted root must contain exactly the + /// snake-case key, the client root must not. + #[test] + fn triggered_mana_sidecar_and_construction_recipient_never_reach_the_client_envelope() { + use crate::types::ability::QuantityExpr; + use crate::types::game_state::{ + ManaTriggerFixedPointResume, TriggeredManaResume, TriggeredManaStage, + }; + use crate::types::resolved_commands::{RulesExecutionNodeRef, SettlementNodeOrdinal}; + + const MARKER: &str = "WIRE-PRIVATE-ORACLE-SENTINEL"; + + let mut state = GameState::new(FormatConfig::standard(), 3, 42); + let hidden = create_object( + &mut state, + CardId(70_601), + PlayerId(0), + "Hidden Wire Source".to_string(), + Zone::Battlefield, + ); + let mut pending = PendingTrigger::ordinary( + hidden, + PlayerId(0), + None, + Box::new(ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + Vec::new(), + hidden, + PlayerId(0), + )), + 1, + ); + pending.description = Some(MARKER.to_string()); + state.pending_triggered_mana_resume = Some(Box::new(TriggeredManaResume { + current: Box::new(PendingTriggerContext::single(pending)), + current_override: None, + rules_execution_node: RulesExecutionNodeRef::TriggeredMana(SettlementNodeOrdinal(5)), + accepted_tail: Vec::new(), + collected_batches: Vec::new(), + outer_resume: ManaTriggerFixedPointResume::Parent, + stage: TriggeredManaStage::ChoosingModes, + })); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + state.waiting_for = WaitingFor::AbilityModeChoice { + player: PlayerId(2), + modal: ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }, + source_id: hidden, + mode_abilities: Vec::new(), + is_activated: false, + ability_index: None, + ability_cost: None, + unavailable_modes: Vec::new(), + }; + + let trusted = serde_json::to_value(&state).expect("serialize trusted state"); + let trusted_root = trusted.as_object().expect("trusted root object"); + assert!( + trusted_root.contains_key("pending_triggered_mana_resume") + && trusted_root.contains_key("pending_trigger_construction_priority_recipient"), + "test precondition: trusted persistence keeps both authorities under their exact \ + snake-case keys" + ); + + let owner_view = crate::game::visibility::filter_state_for_viewer(&state, PlayerId(2)); + let opponent_view = crate::game::visibility::filter_state_for_viewer(&state, PlayerId(1)); + let projections = [ + serde_json::to_value(ClientGameStateRef::wrap(&state, Some(PlayerId(2)))) + .expect("direct owner wrap serializes"), + serde_json::to_value(ClientGameStateRef::wrap(&state, Some(PlayerId(1)))) + .expect("direct opponent wrap serializes"), + serde_json::to_value(ClientGameStateRef::wrap_filtered( + &state, + &owner_view, + Some(PlayerId(2)), + )) + .expect("filtered owner wrap serializes"), + serde_json::to_value(ClientGameStateRef::wrap_filtered( + &state, + &opponent_view, + Some(PlayerId(1)), + )) + .expect("filtered opponent wrap serializes"), + ]; + + for (index, projection) in projections.iter().enumerate() { + let client_state = &projection["state"]; + assert!( + client_state.get("pending_triggered_mana_resume").is_none(), + "projection {index} leaked the triggered-mana continuation key" + ); + assert!( + client_state + .get("pending_trigger_construction_priority_recipient") + .is_none(), + "projection {index} leaked the construction recipient key" + ); + let text = serde_json::to_string(projection).expect("projection serializes"); + assert!( + !text.contains(MARKER), + "projection {index} leaked the private sidecar payload" + ); + assert!( + client_state["waiting_for"] != serde_json::Value::Null, + "projection {index} must retain the public prompt" + ); + } + + assert!( + state.pending_triggered_mana_resume.is_some() + && state.pending_trigger_construction_priority_recipient == Some(PlayerId(1)), + "projection must not alter the authoritative carriers" + ); + } } diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index b5a2554305..70db43ea8e 100644 --- a/crates/engine/src/game/effects/additional_phase.rs +++ b/crates/engine/src/game/effects/additional_phase.rs @@ -307,6 +307,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/double.rs b/crates/engine/src/game/effects/double.rs index 0c7d856f1c..debaedbdf6 100644 --- a/crates/engine/src/game/effects/double.rs +++ b/crates/engine/src/game/effects/double.rs @@ -367,6 +367,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/extra_turn.rs b/crates/engine/src/game/effects/extra_turn.rs index 2de6a6c7d8..69c33aec85 100644 --- a/crates/engine/src/game/effects/extra_turn.rs +++ b/crates/engine/src/game/effects/extra_turn.rs @@ -112,6 +112,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs index 7f8542b864..fd90c87dad 100644 --- a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs +++ b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs @@ -130,6 +130,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index 91840a2972..2a08ed57e0 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -188,10 +188,12 @@ pub fn resolve( // "first player target" quantity resolution cannot read the recipient. let count_ability = count_scoped_ability(state, ability, mana_role.as_ref()); let count_ability = &count_ability; - let is_triggered_mana_inline = crate::game::mana_abilities::is_triggered_mana_ability( - ability, - state.current_trigger_event.as_ref(), - ); + // CR 605.4a: read back the acceptance decision for the occurrence that is + // actually executing rather than re-answering CR 605.1b from a clone the + // resolver may already have bound a context referent onto. With no accepted + // occurrence live this is byte-for-byte the baseline raw classifier call. + let is_triggered_mana_inline = + crate::game::mana_abilities::is_resolving_triggered_mana(state, ability); let mana_choice = (!is_triggered_mana_inline) .then(|| { crate::game::mana_abilities::mana_choice_prompt( diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 58bc01f49b..a3b006412d 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2248,10 +2248,90 @@ fn try_begin_deferred_else_branch_target_selection( Ok(false) } -/// CR 603.12: Begin reflexive target selection for a `WhenYouDo` / -/// `QuantityCheck` ability whose targets were deferred to resolution time. -/// Returns `true` when `WaitingFor::TriggerTargetSelection` (or inline random -/// resolution) was entered. +/// CR 603.12: Consume the `WhenYouDo` creation gate across a whole reflexive +/// BODY — the root plus every `sub_ability` / `else_ability` clause the created +/// trigger carries with it. +/// +/// The parser stamps `WhenYouDo` on EVERY clause of a reflexive body, not only +/// its first sentence: it is a membership marker for "this instruction belongs +/// to the reflexive ability", which is why Ratonhnhaké꞉ton's +/// `ChangeZone{forward_result} -> Attach{LastCreated}` body carries the +/// condition on both links. CR 603.12 creates ONE triggered ability from that +/// whole body, so consuming only the root would leave each later sentence as a +/// live creation gate: resolving the stack object would materialize a SECOND +/// trigger for its own tail, which both violates CR 603.12a occurrence +/// semantics and severs the intra-body `forward_result` linkage the tail +/// depends on (the Equipment returns but attaches to nothing). +/// +/// Every OTHER chain condition — `EffectOutcome`, `QuantityCheck`, intervening +/// `if` gates — is left intact and is still re-checked at resolution (CR 603.4). +fn consume_reflexive_creation_gate(ability: &mut ResolvedAbility) { + if ability.condition == Some(AbilityCondition::WhenYouDo) { + ability.condition = None; + } + if let Some(sub) = ability.sub_ability.as_deref_mut() { + consume_reflexive_creation_gate(sub); + } + if let Some(alt) = ability.else_ability.as_deref_mut() { + consume_reflexive_creation_gate(alt); + } +} + +/// CR 603.12 + CR 603.3: Build one complete synthetic reflexive trigger from an +/// already context-bound runtime chain. The `WhenYouDo` creation gate is +/// consumed across the whole body on the clone so resolving the resulting stack +/// object cannot create itself again; every other chain condition remains +/// intact. +fn build_reflexive_pending_trigger( + state: &mut GameState, + reflexive: &ResolvedAbility, + parent: Option<&ResolvedAbility>, +) -> crate::game::triggers::PendingTrigger { + let mut ability = reflexive.clone(); + // The modal router accepts a `QuantityCheck` resolution gate as well as the + // `WhenYouDo` creation gate (both dispatch here at the call sites); the + // consume below strips only `WhenYouDo`, so a `QuantityCheck` survives onto + // the stack object and is re-checked at resolution per CR 603.4. + debug_assert!( + matches!( + ability.condition, + Some(AbilityCondition::WhenYouDo) | Some(AbilityCondition::QuantityCheck { .. }) + ), + "build_reflexive_pending_trigger requires a WhenYouDo or QuantityCheck gate" + ); + consume_reflexive_creation_gate(&mut ability); + + let source_id = parent.map_or(ability.source_id, |parent| parent.source_id); + let controller = parent.map_or(ability.controller, |parent| parent.controller); + let description = ability + .description + .clone() + .or_else(|| parent.and_then(|parent| parent.description.clone())); + // CR 603.3b: a reflexive joins the same APNAP ordering machinery as every + // other pending trigger, so it needs its own live ordering timestamp. + let timestamp = u32::try_from(state.next_timestamp()).unwrap_or(u32::MAX); + crate::game::triggers::PendingTrigger { + source_id, + controller, + condition: None, + target_constraints: ability.target_constraints.clone(), + distribute: ability.distribute.clone(), + trigger_event: state.current_trigger_event.clone(), + modal: ability.modal.clone(), + mode_abilities: ability.mode_abilities.clone(), + description, + may_trigger_origin: ability.may_trigger_origin.clone(), + subject_match_count: freeze_reflexive_event_count(state, controller, source_id), + die_result: state.die_result_this_resolution, + provenance: None, + ability: Box::new(ability), + timestamp, + } +} + +/// CR 603.12: Materialize a `WhenYouDo` reflexive trigger, or preserve the +/// existing target-selection behavior for a `QuantityCheck` resolution gate. +/// Returns `true` when the reflexive was queued/pushed or a target choice began. /// /// The whole body runs inside `with_reflexive_resolution_scope` so every /// `EventContextAmount` read reached from here — via `build_target_slots`, @@ -2261,7 +2341,7 @@ fn try_begin_deferred_else_branch_target_selection( /// this scope a paused enclosing trigger (whose event is restored on a /// `PendingContinuation` resume) would leak its own "that many" into the /// reflexive ability's target-slot count. -fn try_begin_reflexive_target_selection( +fn try_materialize_reflexive_trigger( state: &mut GameState, reflexive: &ResolvedAbility, parent: Option<&ResolvedAbility>, @@ -2270,7 +2350,7 @@ fn try_begin_reflexive_target_selection( depth: u32, ) -> Result { crate::game::quantity::with_reflexive_resolution_scope(|| { - try_begin_reflexive_target_selection_inner( + try_materialize_reflexive_trigger_inner( state, reflexive, parent, @@ -2281,7 +2361,7 @@ fn try_begin_reflexive_target_selection( }) } -fn try_begin_reflexive_target_selection_inner( +fn try_materialize_reflexive_trigger_inner( state: &mut GameState, reflexive: &ResolvedAbility, parent: Option<&ResolvedAbility>, @@ -2289,7 +2369,8 @@ fn try_begin_reflexive_target_selection_inner( events: &mut Vec, depth: u32, ) -> Result { - if !reflexive.targets.is_empty() { + let creates_reflexive_trigger = reflexive.condition == Some(AbilityCondition::WhenYouDo); + if !creates_reflexive_trigger && !reflexive.targets.is_empty() { return Ok(false); } @@ -2298,9 +2379,48 @@ fn try_begin_reflexive_target_selection_inner( // the same resolution-scoped referents that the pending trigger will later // carry. Stamp one clone before `build_target_slots` so chosen-player and // amassed-Army filters enumerate legal targets from the actual parent event. + let mut propagated_parent_targets = false; let reflexive_context_owned; let reflexive = if let Some(parent) = parent { let mut owned = reflexive.clone(); + // CR 608.2c + CR 603.12: an INHERITED referent ("…, exile that card") + // carries no declared target slot of its own — it reads the parent's + // bound target through `TargetFilter::ParentTarget`. The generic + // sub-chain descent below this function binds that referent with + // `should_propagate_parent_targets` immediately before it resolves the + // sub, and at HEAD every inherited-referent reflexive reached the chain + // that way because the slot-less shape returned `Ok(false)` here. + // + // A materialized reflexive resolves LATER, from its own stack object, + // where no parent frame exists — so the referent must be bound NOW, on + // the clone the pending trigger carries, using the SAME authority the + // descent uses. Without this, Superior Spider-Man's "exile that card" + // and the `BecomeCopy` post-replacement rider both resolve against an + // empty target list and silently exile nothing. + // + // Gated three ways, and each gate is load-bearing: + // * `creates_reflexive_trigger` — the `QuantityCheck` gate still + // returns `Ok(false)` for slot-less shapes and reaches the descent's + // own propagation, so binding here would double-bind it. + // * `ability_refs_parent_target` — bind ONLY a rider that actually + // reads the referent. The descent propagates to every slot-less sub + // because it resolves that sub immediately, inside the parent's own + // frame, where a stray `targets` entry is inert for an effect that + // never reads it. A materialized trigger CARRIES its targets onto + // the stack, where they are no longer inert: The Fourteenth Doctor's + // "it gains haste" rider (a `GenericEffect` whose static is + // `affected: SelfRef`) would sail to the stack holding the copy + // SOURCE in the graveyard as a target. The declined-branch + // dispatcher above ANDs the same predicate for the same reason. + // * `should_propagate_parent_targets` — the shared authority, so the + // `ExiledBySource` / resolution-timing carve-outs stay in one place. + if creates_reflexive_trigger + && ability_refs_parent_target(&owned) + && should_propagate_parent_targets(parent, &owned) + { + owned.targets = parent.targets.clone(); + propagated_parent_targets = true; + } apply_parent_chain_context(&mut owned, parent, effect_context_object, state); reflexive_context_owned = owned; &reflexive_context_owned @@ -2316,35 +2436,13 @@ fn try_begin_reflexive_target_selection_inner( // pending trigger carrying the modal + per-mode abilities, then defer to the // shared modal-trigger router, which prompts `WaitingFor::AbilityModeChoice` // and only then collects each chosen mode's targets. + // NOT gated on `creates_reflexive_trigger`: a target-less modal marker + // behind a `QuantityCheck` resolution gate must also route through the + // modal-trigger router (as it did before deferral), or the slot-less + // fallback below would return `Ok(false)` and the descent would resolve + // every mode without a `WaitingFor::AbilityModeChoice`. if reflexive.modal.is_some() && !reflexive.mode_abilities.is_empty() { - let reflexive_clone = reflexive.clone(); - let trigger_description = reflexive_clone - .description - .clone() - .or_else(|| parent.and_then(|p| p.description.clone())); - let source_id = parent.map(|p| p.source_id).unwrap_or(reflexive.source_id); - let controller = parent.map(|p| p.controller).unwrap_or(reflexive.controller); - - let pending = crate::game::triggers::PendingTrigger { - source_id, - controller, - condition: None, - ability: Box::new(reflexive_clone), - timestamp: state.turn_number, - target_constraints: reflexive.target_constraints.clone(), - distribute: None, - trigger_event: state.current_trigger_event.clone(), - modal: reflexive.modal.clone(), - mode_abilities: reflexive.mode_abilities.clone(), - description: trigger_description, - may_trigger_origin: None, - // CR 603.12 + CR 601.2c: freeze the live event count (e.g. number - // sacrificed) so an "up to that many target ..." bound survives - // into the later fresh-`apply()` target-assign. - subject_match_count: freeze_reflexive_event_count(state, controller, source_id), - die_result: state.die_result_this_resolution, - provenance: None, - }; + let pending = build_reflexive_pending_trigger(state, reflexive, parent); let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending); let pending_for_state = pending.clone(); @@ -2372,9 +2470,32 @@ fn try_begin_reflexive_target_selection_inner( } } - let target_slots = crate::game::ability_utils::build_target_slots(state, reflexive) - .map_err(|e| EffectError::InvalidParam(e.to_string()))?; + let target_slots = match crate::game::ability_utils::build_target_slots(state, reflexive) { + Ok(slots) => slots, + Err(_) if creates_reflexive_trigger => { + let pending = build_reflexive_pending_trigger(state, reflexive, parent); + crate::game::triggers::defer_pending_trigger(state, pending); + return Ok(true); + } + Err(error) => return Err(EffectError::InvalidParam(error.to_string())), + }; + // A parent-referent rider carries no declared slot of its own (that is the + // `ability_refs_parent_target` shape), so propagated targets and non-empty + // slots are mutually exclusive. If a future ability both reads a parent + // referent AND declares its own slot, the fresh stack-time selection below + // would clobber the bound referent — make that a counted event here rather + // than a silent exile-of-nothing at resolution. + debug_assert!( + !propagated_parent_targets || target_slots.is_empty(), + "a reflexive with propagated parent targets must be slot-less; \ + a declared slot would re-prompt and clobber the bound referent" + ); if target_slots.is_empty() { + if creates_reflexive_trigger { + let pending = build_reflexive_pending_trigger(state, reflexive, parent); + crate::game::triggers::defer_pending_trigger(state, pending); + return Ok(true); + } return Ok(false); } @@ -2382,6 +2503,11 @@ fn try_begin_reflexive_target_selection_inner( reflexive.target_selection_mode, crate::types::ability::TargetSelectionMode::Random ) { + if creates_reflexive_trigger { + let pending = build_reflexive_pending_trigger(state, reflexive, parent); + crate::game::triggers::defer_pending_trigger(state, pending); + return Ok(true); + } // CR 115.1d + CR 603.12: Random-mode reflexive triggers still choose // the targets for the reflexive triggered ability; the seeded RNG // supplies that choice without entering an interactive prompt. @@ -2398,6 +2524,29 @@ fn try_begin_reflexive_target_selection_inner( return Ok(true); } + if creates_reflexive_trigger { + let pending = build_reflexive_pending_trigger(state, reflexive, parent); + let trigger_events = + crate::game::triggers::take_pending_trigger_event_batch(state, &pending); + let pending_for_state = pending.clone(); + let entry_id = crate::game::triggers::push_pending_trigger_to_stack_with_event_batch( + state, + pending, + trigger_events, + events, + ); + state.pending_trigger = Some(Box::new(pending_for_state)); + state.pending_trigger_firing = Some(crate::types::identifiers::TriggerFiring::Ordinary); + state.pending_trigger_entry = Some(entry_id); + if let Some(waiting_for) = + crate::game::engine::begin_pending_trigger_target_selection(state) + .map_err(|error| EffectError::InvalidParam(error.to_string()))? + { + state.waiting_for = waiting_for; + } + return Ok(true); + } + let selection = crate::game::ability_utils::begin_target_selection_for_ability( state, reflexive, @@ -9901,7 +10050,7 @@ fn resolve_chain_body( if matches!( condition, AbilityCondition::WhenYouDo | AbilityCondition::QuantityCheck { .. } - ) && try_begin_reflexive_target_selection(state, ability, None, None, events, depth)? + ) && try_materialize_reflexive_trigger(state, ability, None, None, events, depth)? { return Ok(()); } @@ -11551,7 +11700,7 @@ fn resolve_chain_body( if matches!( condition, AbilityCondition::WhenYouDo | AbilityCondition::QuantityCheck { .. } - ) && try_begin_reflexive_target_selection( + ) && try_materialize_reflexive_trigger( state, sub, Some(ability), @@ -13651,6 +13800,236 @@ mod tests { use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; + fn reflexive_test_creature( + state: &mut GameState, + controller: PlayerId, + name: &str, + ) -> ObjectId { + let id = create_object( + state, + CardId(state.next_object_id), + controller, + name.to_string(), + Zone::Battlefield, + ); + let object = state.objects.get_mut(&id).unwrap(); + object.card_types.core_types.push(CoreType::Creature); + object.base_power = Some(2); + object.base_toughness = Some(2); + object.power = Some(2); + object.toughness = Some(2); + id + } + + fn reflexive_counter_ability(source_id: ObjectId) -> ResolvedAbility { + ResolvedAbility::new( + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter::creature()), + }, + Vec::new(), + source_id, + PlayerId(0), + ) + .condition(AbilityCondition::WhenYouDo) + } + + #[test] + fn targetless_reflexive_is_deferred_and_root_gate_is_consumed() { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let mut reflexive = ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 2 }, + target: None, + }, + Vec::new(), + ObjectId(100), + PlayerId(0), + ) + .condition(AbilityCondition::WhenYouDo); + reflexive.player_scope = Some(PlayerFilter::Opponent); + let life_before = state.players[1].life; + let mut events = Vec::new(); + + assert!(try_materialize_reflexive_trigger( + &mut state, + &reflexive, + None, + None, + &mut events, + 0, + ) + .unwrap()); + assert_eq!(state.players[1].life, life_before); + assert!(state.stack.is_empty()); + assert_eq!(state.deferred_triggers.len(), 1); + assert!(state.deferred_triggers[0] + .pending + .ability + .condition + .is_none()); + + assert!( + crate::game::triggers::drain_deferred_trigger_queue(&mut state, &mut events).is_none() + ); + assert_eq!(state.stack.len(), 1); + assert_eq!(state.players[1].life, life_before); + + let mut safety = 4; + while !state.stack.is_empty() && safety > 0 { + crate::game::engine::apply_as_current(&mut state, GameAction::PassPriority) + .expect("resolve reflexive trigger through priority"); + safety -= 1; + } + assert_eq!(state.players[1].life, life_before - 2); + } + + #[test] + fn random_reflexive_materializes_on_stack_before_effect() { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let a = reflexive_test_creature(&mut state, PlayerId(0), "A"); + let b = reflexive_test_creature(&mut state, PlayerId(0), "B"); + let mut reflexive = reflexive_counter_ability(ObjectId(100)); + reflexive.target_selection_mode = TargetSelectionMode::Random; + let mut events = Vec::new(); + + try_materialize_reflexive_trigger(&mut state, &reflexive, None, None, &mut events, 0) + .unwrap(); + crate::game::triggers::drain_deferred_trigger_queue(&mut state, &mut events); + + assert_eq!(state.stack.len(), 1); + assert!(state.objects[&a].counters.is_empty()); + assert!(state.objects[&b].counters.is_empty()); + let StackEntryKind::TriggeredAbility { ability, .. } = &state.stack[0].kind else { + panic!("expected random reflexive trigger on stack"); + }; + assert_eq!(ability.targets.len(), 1); + } + + #[test] + fn reflexive_with_no_legal_required_target_is_dropped_by_shared_dispatch() { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let reflexive = reflexive_counter_ability(ObjectId(100)); + let mut events = Vec::new(); + + let materialized = + try_materialize_reflexive_trigger(&mut state, &reflexive, None, None, &mut events, 0) + .unwrap(); + + // Positive reach guard: the empty-state assertions below only prove the + // shared-dispatch DROP if the reflexive actually took the deferral path. + // An `Ok(false)` fall-through would leave the same empty state without + // exercising the dispatch at all. + assert!( + materialized, + "the reflexive must materialize into the deferral path before dispatch can drop it" + ); + crate::game::triggers::drain_deferred_trigger_queue(&mut state, &mut events); + + assert!(state.deferred_triggers.is_empty()); + assert!(state.stack.is_empty()); + } + + #[test] + fn reflexive_target_chooser_differs_from_stack_controller() { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let target = reflexive_test_creature(&mut state, PlayerId(0), "Only target"); + let mut reflexive = reflexive_counter_ability(ObjectId(100)); + // CR 601.2c + CR 603.3d: `Opponent` is the ONLY non-`ScopedPlayer` chooser + // the parser ever stamps (`oracle_target.rs:3540`, "of an opponent's + // choice"), and it is the shape `resolve_effect_player_ref` resolves + // through the shared authority. A synthetic `SpecificPlayer` chooser is + // unreachable from any card and falls into that resolver's event-context + // catch-all, which returns `None` with no trigger event — the prompt would + // then fall back to the controller and the row would prove nothing about + // the chooser seam. + reflexive.target_chooser = Some(TargetFilter::Opponent); + let mut events = Vec::new(); + + try_materialize_reflexive_trigger(&mut state, &reflexive, None, None, &mut events, 0) + .unwrap(); + assert!(matches!( + state.waiting_for, + WaitingFor::TriggerTargetSelection { + player: PlayerId(1), + trigger_controller: Some(PlayerId(0)), + .. + } + )); + assert!( + crate::game::engine::apply( + &mut state, + PlayerId(0), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + ) + .is_err(), + "the controller cannot answer another player's target prompt" + ); + crate::game::engine::apply( + &mut state, + PlayerId(1), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + ) + .expect("the designated chooser may select the target"); + + let StackEntryKind::TriggeredAbility { ability, .. } = &state.stack[0].kind else { + panic!("expected reflexive trigger entry"); + }; + assert_eq!(state.stack[0].controller, PlayerId(0)); + assert_eq!(ability.targets, [TargetRef::Object(target)]); + } + + #[test] + fn reflexive_constructor_preserves_unassigned_distribution_metadata() { + let mut state = GameState::new_two_player(42); + state.turn_number = 7; + state.next_timestamp = 41; + let mut reflexive = reflexive_counter_ability(ObjectId(100)); + reflexive.distribute = Some(crate::types::game_state::DistributionUnit::Counters( + "+1/+1".to_string(), + )); + + let pending = build_reflexive_pending_trigger(&mut state, &reflexive, None); + + assert_eq!(pending.distribute, reflexive.distribute); + assert_eq!(pending.ability.distribute, reflexive.distribute); + assert!(pending.ability.condition.is_none()); + assert_eq!(pending.timestamp, 41); + assert_eq!(state.next_timestamp, 42); + } + + #[test] + fn reflexive_construction_preserves_same_controller_ordering_timestamps() { + let mut state = GameState::new_two_player(42); + state.next_timestamp = 41; + let reflexive = reflexive_counter_ability(ObjectId(100)); + + let first = build_reflexive_pending_trigger(&mut state, &reflexive, None); + let second = build_reflexive_pending_trigger(&mut state, &reflexive, None); + + assert!( + first.timestamp < second.timestamp, + "CR 603.3b same-controller ordering must retain the distinct live timestamps the trigger sorter consumes" + ); + } + // CR 608.2h (#6486): Volcanic Vision — "Return target instant or sorcery card // from your graveyard to your hand. ~ deals damage equal to that card's mana // value to each creature your opponents control. Exile ~." The card is @@ -18117,9 +18496,22 @@ mod tests { /// Issue #418 (Guide of Souls): when the embedded `{E}{E}{E}` cost IS paid, /// the `WhenYouDo` reflexive sub-ability runs and energy is deducted. + /// + /// CR 603.12 + CR 603.3b: the rider is a REFLEXIVE TRIGGERED ABILITY, so + /// "runs" means it is CREATED during this resolution and put on the stack at + /// the next priority point — not applied inline inside the parent's event + /// vector. This row therefore asserts the cost half inline (energy is spent + /// during `resolve_ability_chain`, exactly as before) and the rider half + /// after the trigger actually resolves, with an explicit no-inline-effect + /// assertion in between so the deferral itself is discriminated: reverting + /// the materializer to inline resolution puts the counters on before the + /// drain and fails the middle assertion. #[test] fn when_you_do_runs_when_embedded_energy_cost_paid() { let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; // Controller has exactly enough energy to pay {E}{E}{E}. state.players[0].energy = 3; @@ -18177,15 +18569,40 @@ mod tests { .any(|e| matches!(e, GameEvent::EnergyChanged { delta: -3, .. })), "energy payment must emit EnergyChanged delta -3" ); + + // CR 603.2: the reflexive ability triggered, and "does nothing at this + // time" — it is queued, not applied. + assert_eq!( + state.deferred_triggers.len(), + 1, + "the paid cost must CREATE the reflexive trigger" + ); assert!( - events.iter().any(|e| matches!( - e, - GameEvent::CounterAdded { - counter_type: CounterType::Plus1Plus1, - count: 2, - .. - } - )), + state.objects[&target].counters.is_empty(), + "the reflexive must not put its counters on during the parent resolution" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, GameEvent::CounterAdded { .. })), + "no CounterAdded may be emitted into the parent's event vector" + ); + + // CR 603.3b: it goes on the stack at the next priority point, then resolves. + crate::game::triggers::drain_deferred_trigger_queue(&mut state, &mut events); + assert_eq!(state.stack.len(), 1, "the reflexive is a stack object"); + let mut safety = 4; + while !state.stack.is_empty() && safety > 0 { + crate::game::engine::apply_as_current(&mut state, GameAction::PassPriority) + .expect("resolve the reflexive trigger through priority"); + safety -= 1; + } + assert_eq!( + state.objects[&target] + .counters + .get(&CounterType::Plus1Plus1) + .copied(), + Some(2), "WhenYouDo reflexive sub-ability must run when the embedded cost was paid" ); } diff --git a/crates/engine/src/game/effects/player_counter.rs b/crates/engine/src/game/effects/player_counter.rs index 26af405939..6a1c903cf7 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -480,6 +480,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, @@ -678,6 +679,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/reverse_turn_order.rs b/crates/engine/src/game/effects/reverse_turn_order.rs index 48e429ce4c..c93a8df6d6 100644 --- a/crates/engine/src/game/effects/reverse_turn_order.rs +++ b/crates/engine/src/game/effects/reverse_turn_order.rs @@ -85,6 +85,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/skip_next_step.rs b/crates/engine/src/game/effects/skip_next_step.rs index 33549edca1..4789783f5e 100644 --- a/crates/engine/src/game/effects/skip_next_step.rs +++ b/crates/engine/src/game/effects/skip_next_step.rs @@ -148,6 +148,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/skip_next_turn.rs b/crates/engine/src/game/effects/skip_next_turn.rs index 608b7c2caf..a6f1f3a23c 100644 --- a/crates/engine/src/game/effects/skip_next_turn.rs +++ b/crates/engine/src/game/effects/skip_next_turn.rs @@ -126,6 +126,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: crate::types::ability::TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None, diff --git a/crates/engine/src/game/effects/vote.rs b/crates/engine/src/game/effects/vote.rs index 0bcb8d2ee9..c372bcda49 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -30,6 +30,7 @@ use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; use super::resolve_ability_chain; +use crate::game::ability_utils::build_resolved_from_def; /// CR 701.38a + CR 101.4: Initiate a vote. Builds the APNAP voter queue /// starting from `starting_with` (resolved against the ability controller), @@ -348,66 +349,7 @@ pub fn resolve_tally( if per_choice_player_scope.is_some() { // player_scope path — single dispatch, fan-out handled by // resolve_ability_chain's player_scope driver. - let chain = ResolvedAbility { - effect: (*per_choice_effect[idx].effect).clone(), - targets: Vec::new(), - source_id, - source_incarnation: None, - trigger_source: None, - trigger_definition_ref: None, - force_block_attacker: None, - target_incarnations: Vec::new(), - selected_target_incarnations: Vec::new(), - controller, - original_controller: None, - scoped_player: None, - target_chooser: None, - kind: per_choice_effect[idx].kind, - sub_ability: per_choice_effect[idx] - .sub_ability - .as_ref() - .map(|sub| Box::new(resolved_from_def(sub, source_id, controller))), - else_ability: None, - duration: per_choice_effect[idx].duration.clone(), - condition: per_choice_effect[idx].condition.clone(), - context: Default::default(), - optional_targeting: per_choice_effect[idx].optional_targeting, - optional: per_choice_effect[idx].optional, - optional_player: per_choice_effect[idx].optional_player.clone(), - optional_for: None, - multi_target: None, - target_constraints: Vec::new(), - target_choice_timing: per_choice_effect[idx].target_choice_timing, - description: per_choice_effect[idx].description.clone(), - selected_mode_labels: Vec::new(), - repeat_for: None, - min_x_value: per_choice_effect[idx].min_x_value, - announced_x: per_choice_effect[idx].announced_x.clone(), - cant_be_copied: per_choice_effect[idx].cant_be_copied, - copy_count_status: crate::types::ability::CopyCountStatus::Pending, - forward_result: per_choice_effect[idx].forward_result, - unless_pay: None, - distribution: None, - player_scope: per_choice_player_scope, - starting_with: per_choice_effect[idx].starting_with.clone(), - chosen_x: None, - cost_paid_object: None, - noted_mana_payment: None, - cost_paid_object_ids: Vec::new(), - effect_context_object: None, - amassed_army_object: None, - ability_index: None, - may_trigger_origin: None, - target_selection_mode: per_choice_effect[idx].target_selection_mode, - chosen_players: Vec::new(), - repeat_until: None, - replacement_applied: Default::default(), - sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, - sibling_condition: crate::types::ability::SiblingCondition::Dependent, - modal: None, - mode_abilities: vec![], - parent_target_missing_reason: None, - }; + let chain = build_resolved_from_def(&per_choice_effect[idx], source_id, controller); resolve_ability_chain(state, &chain, events, 1)?; } else if per_choice_effect[idx] .effect @@ -419,66 +361,7 @@ pub fn resolve_tally( // `QuantityRef::VoteCount`, so the effect resolves as ONE aggregate // event whose `resolve_ref` sums the full tally — do NOT repeat it // per ballot, which would multiply the tally by itself. - let chain = ResolvedAbility { - effect: (*per_choice_effect[idx].effect).clone(), - targets: Vec::new(), - source_id, - source_incarnation: None, - trigger_source: None, - trigger_definition_ref: None, - force_block_attacker: None, - target_incarnations: Vec::new(), - selected_target_incarnations: Vec::new(), - controller, - original_controller: None, - scoped_player: None, - target_chooser: None, - kind: per_choice_effect[idx].kind, - sub_ability: per_choice_effect[idx] - .sub_ability - .as_ref() - .map(|sub| Box::new(resolved_from_def(sub, source_id, controller))), - else_ability: None, - duration: per_choice_effect[idx].duration.clone(), - condition: per_choice_effect[idx].condition.clone(), - context: Default::default(), - optional_targeting: per_choice_effect[idx].optional_targeting, - optional: per_choice_effect[idx].optional, - optional_player: per_choice_effect[idx].optional_player.clone(), - optional_for: None, - multi_target: None, - target_constraints: Vec::new(), - target_choice_timing: per_choice_effect[idx].target_choice_timing, - description: per_choice_effect[idx].description.clone(), - selected_mode_labels: Vec::new(), - repeat_for: None, - min_x_value: per_choice_effect[idx].min_x_value, - announced_x: per_choice_effect[idx].announced_x.clone(), - cant_be_copied: per_choice_effect[idx].cant_be_copied, - copy_count_status: crate::types::ability::CopyCountStatus::Pending, - forward_result: per_choice_effect[idx].forward_result, - unless_pay: None, - distribution: None, - player_scope: None, - starting_with: per_choice_effect[idx].starting_with.clone(), - chosen_x: None, - cost_paid_object: None, - noted_mana_payment: None, - cost_paid_object_ids: Vec::new(), - effect_context_object: None, - amassed_army_object: None, - ability_index: None, - may_trigger_origin: None, - target_selection_mode: per_choice_effect[idx].target_selection_mode, - chosen_players: Vec::new(), - repeat_until: None, - replacement_applied: Default::default(), - sub_link: crate::types::ability::SubAbilityLink::ContinuationStep, - sibling_condition: crate::types::ability::SiblingCondition::Dependent, - modal: None, - mode_abilities: vec![], - parent_target_missing_reason: None, - }; + let chain = build_resolved_from_def(&per_choice_effect[idx], source_id, controller); resolve_ability_chain(state, &chain, events, 1)?; } else { // CR 701.38d + CR 608.2c: Per-ballot iteration. Each ballot that @@ -597,7 +480,7 @@ fn resolve_top_votes_tally( // winner is exiled (not a battlefield rescan). Some(template) => { if let Some(&winner_obj) = candidate_objects.get(winner as usize) { - let mut chain = resolved_from_def(template, source_id, controller); + let mut chain = build_resolved_from_def(template, source_id, controller); chain.targets = vec![TargetRef::Object(winner_obj)]; resolve_ability_chain(state, &chain, events, 1)?; } @@ -605,7 +488,7 @@ fn resolve_top_votes_tally( // Named vote: `per_choice_effect` is populated. None => { if let Some(winning_effect) = per_choice_effect.get(winner as usize) { - let chain = resolved_from_def(winning_effect, source_id, controller); + let chain = build_resolved_from_def(winning_effect, source_id, controller); resolve_ability_chain(state, &chain, events, 1)?; } } @@ -630,7 +513,8 @@ fn resolve_top_votes_tally( // rescan). Some(template) => { if let Some(&winner_obj) = candidate_objects.get(idx) { - let mut chain = resolved_from_def(template, source_id, controller); + let mut chain = + build_resolved_from_def(template, source_id, controller); chain.targets = vec![TargetRef::Object(winner_obj)]; resolve_ability_chain(state, &chain, events, 1)?; } @@ -639,7 +523,7 @@ fn resolve_top_votes_tally( None => { if let Some(winning_effect) = per_choice_effect.get(idx) { let chain = - resolved_from_def(winning_effect, source_id, controller); + build_resolved_from_def(winning_effect, source_id, controller); resolve_ability_chain(state, &chain, events, 1)?; } } @@ -657,82 +541,6 @@ fn resolve_top_votes_tally( Ok(()) } -/// Convert a stored `AbilityDefinition` (typically a sub-effect) into a -/// `ResolvedAbility` carrying the same source/controller as the parent Vote. -fn resolved_from_def( - def: &AbilityDefinition, - source_id: crate::types::identifiers::ObjectId, - controller: PlayerId, -) -> ResolvedAbility { - ResolvedAbility { - effect: (*def.effect).clone(), - targets: Vec::new(), - source_id, - source_incarnation: None, - trigger_source: None, - trigger_definition_ref: None, - force_block_attacker: None, - target_incarnations: Vec::new(), - selected_target_incarnations: Vec::new(), - controller, - original_controller: None, - scoped_player: None, - target_chooser: None, - kind: def.kind, - sub_ability: def - .sub_ability - .as_ref() - .map(|sub| Box::new(resolved_from_def(sub, source_id, controller))), - else_ability: None, - duration: def.duration.clone(), - condition: def.condition.clone(), - context: Default::default(), - optional_targeting: def.optional_targeting, - optional: def.optional, - optional_player: def.optional_player.clone(), - optional_for: None, - multi_target: None, - target_constraints: Vec::new(), - target_choice_timing: def.target_choice_timing, - description: def.description.clone(), - selected_mode_labels: Vec::new(), - repeat_for: None, - min_x_value: def.min_x_value, - announced_x: def.announced_x.clone(), - cant_be_copied: def.cant_be_copied, - copy_count_status: crate::types::ability::CopyCountStatus::Pending, - forward_result: def.forward_result, - unless_pay: None, - distribution: None, - player_scope: None, - // CR 101.4 + CR 800.4: Carry through the parent def's turn-order - // override so vote sub-effects resolve with consistent iteration - // semantics. None for non-Join-Forces vote chains. - starting_with: def.starting_with.clone(), - chosen_x: None, - cost_paid_object: None, - noted_mana_payment: None, - cost_paid_object_ids: Vec::new(), - effect_context_object: None, - amassed_army_object: None, - ability_index: None, - may_trigger_origin: None, - target_selection_mode: def.target_selection_mode, - chosen_players: Vec::new(), - repeat_until: None, - replacement_applied: Default::default(), - // CR 608.2c: Carry the parent-link kind through to the resolved ability. - sub_link: def.sub_link, - // CR 608.2c: Carry the replication marker through (Dependent for vote sub-effects). - sibling_condition: def.sibling_condition, - // CR 700.2b + CR 603.3c: Carry the reflexive modal choice + per-mode - // abilities through (None for vote sub-effects). - modal: def.modal.clone(), - mode_abilities: def.mode_abilities.clone(), - parent_target_missing_reason: None, - } -} - /// CR 701.38a: Resolve `ControllerRef::You` (and friends) to the concrete /// starting voter PlayerId. Falls back to `controller` if the ref doesn't /// resolve to a non-eliminated player. @@ -823,7 +631,7 @@ fn build_per_ballot_ability( source_id: crate::types::identifiers::ObjectId, controller: PlayerId, ) -> ResolvedAbility { - let mut ability = resolved_from_def(template, source_id, controller); + let mut ability = build_resolved_from_def(template, source_id, controller); ability.scoped_player = Some(voter); ability.original_controller = Some(controller); ability @@ -911,6 +719,47 @@ mod tests { use crate::types::resolution::{FrameKind, ResolutionFrame}; use crate::types::zones::Zone; + #[test] + fn vote_definition_routes_preserve_unassigned_distribution_unit() { + let source_id = ObjectId(1); + let controller = PlayerId(0); + + let mut player_scope = AbilityDefinition::new(AbilityKind::Database, Effect::NoOp) + .player_scope(crate::types::ability::PlayerFilter::VotedFor { choice_index: 0 }); + player_scope.distribute = Some(crate::types::game_state::DistributionUnit::Damage); + let player_scope_runtime = build_resolved_from_def(&player_scope, source_id, controller); + + let mut aggregate = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Ref { + qty: crate::types::ability::QuantityRef::VoteCount { choice_index: 0 }, + }, + player: TargetFilter::Controller, + }, + ); + aggregate.distribute = Some(crate::types::game_state::DistributionUnit::Life); + let aggregate_runtime = build_resolved_from_def(&aggregate, source_id, controller); + + let mut per_ballot = AbilityDefinition::new(AbilityKind::Database, Effect::NoOp); + per_ballot.distribute = Some(crate::types::game_state::DistributionUnit::Counters( + "charge".to_string(), + )); + let per_ballot_runtime = + build_per_ballot_ability(&per_ballot, PlayerId(1), source_id, controller); + + assert_eq!(player_scope_runtime.distribute, player_scope.distribute); + assert_eq!(aggregate_runtime.distribute, aggregate.distribute); + assert_eq!(per_ballot_runtime.distribute, per_ballot.distribute); + + let ordinary = build_resolved_from_def( + &AbilityDefinition::new(AbilityKind::Database, Effect::NoOp), + source_id, + controller, + ); + assert!(ordinary.distribute.is_none()); + } + /// CR 701.38a + CR 101.4: Initiating a Vote sets `WaitingFor::VoteChoice` /// for the controller, queuing the opponent next, with no extra-vote /// granters present (so each player gets exactly 1 vote). @@ -967,6 +816,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, player_scope: None, starting_with: None, chosen_x: None, @@ -1079,6 +929,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, player_scope: None, starting_with: None, chosen_x: None, @@ -1516,6 +1367,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, player_scope: None, starting_with: None, chosen_x: None, @@ -1685,6 +1537,7 @@ mod tests { forward_result: false, unless_pay: None, distribution: None, + distribute: None, player_scope: None, starting_with: None, chosen_x: None, @@ -2386,7 +2239,7 @@ mod tests { .core_types .push(CoreType::Creature); - let ability = resolved_from_def(&vote_def, source_id, controller); + let ability = build_resolved_from_def(&vote_def, source_id, controller); let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); @@ -2463,7 +2316,7 @@ mod tests { let mut state = GameState::new_two_player(42); let controller = state.players[0].id; let opp = state.players[1].id; - let ability = resolved_from_def(&def, ObjectId(1), controller); + let ability = build_resolved_from_def(&def, ObjectId(1), controller); let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); @@ -2565,7 +2418,7 @@ mod tests { .core_types .push(CoreType::Creature); - let ability = resolved_from_def(&vote_def, source_id, controller); + let ability = build_resolved_from_def(&vote_def, source_id, controller); let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); let voter = match &state.waiting_for { @@ -2643,7 +2496,7 @@ mod tests { let opp_b = make_creature(&mut state, 3, "Bear B"); let opp_c = make_creature(&mut state, 4, "Bear C"); - let ability = resolved_from_def(&vote_def, source_id, controller); + let ability = build_resolved_from_def(&vote_def, source_id, controller); let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); @@ -2765,7 +2618,7 @@ mod tests { } let last_bear = *opp_ids.last().unwrap(); - let ability = resolved_from_def(&vote_def, source_id, controller); + let ability = build_resolved_from_def(&vote_def, source_id, controller); let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 227a2da5ab..42e8a9e1a0 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -253,6 +253,13 @@ pub fn eliminate_players_simultaneously( state.pending_trigger = None; state.pending_trigger_entry = None; state.pending_trigger_event_batch.clear(); + // CR 117.3c: The construction priority recipient is scheduling state for + // a batch that no longer exists. Leaving it installed would durably + // serialize a departed player into a terminal `GameOver` snapshot — the + // exact leak the surrounding comment already calls out for the reused + // singleton engine. The terminal arm needs no re-point, because there is + // no later construction to route. + state.pending_trigger_construction_priority_recipient = None; for firing in terminal_firings { crate::game::lifecycle::record_delayed_terminal( firing, @@ -334,6 +341,20 @@ pub fn eliminate_players_simultaneously( state.waiting_for = WaitingFor::Priority { player: next }; } } + + // CR 800.4a: A live trigger-construction batch can carry a priority + // recipient who is not the prompt's controller, so neither cursor- + // clearing site above fires when that recipient alone leaves. Priority + // passes to the next player in turn order who is still in the game + // (`docs/MagicCompRules.txt:6424`), so the carried recipient is + // re-pointed rather than stranded — the same authority and remedy the + // `waiting_for` re-point directly above uses for a dead acting player. + if let Some(recipient) = state.pending_trigger_construction_priority_recipient { + if !players::is_alive(state, recipient) { + state.pending_trigger_construction_priority_recipient = + Some(players::next_player_in_turn_order(state, recipient)); + } + } } } @@ -929,6 +950,12 @@ fn do_eliminate( state.pending_trigger_entry = None; state.pending_trigger = None; state.pending_trigger_event_batch.clear(); + // CR 117.3c: The batch this recipient was scheduled for has ceased with + // its tracked entry. Clear it with the cursors — unconditionally, not + // only when the departing player happens to be the recipient — so the + // next construction cannot consume a stale carrier and mis-route its + // terminal priority. + state.pending_trigger_construction_priority_recipient = None; } // CR 800.4a + CR 616.1 + CR 704.4: Abandon a parked replacement choice this @@ -3629,4 +3656,272 @@ mod tests { "an unrelated control by a living controller survives (non-vacuous)" ); } + + /// Put a real in-construction triggered-ability entry on the stack for + /// `controller`, park the construction cursors on it, and open a live + /// `AbilityModeChoice` prompt for that same player. Returns the entry id. + fn open_live_trigger_construction_prompt( + state: &mut GameState, + controller: PlayerId, + ) -> ObjectId { + let source = create_object( + state, + CardId(state.next_object_id), + controller, + "Construction prompt source".to_string(), + crate::types::zones::Zone::Battlefield, + ); + let trigger = crate::game::triggers::PendingTrigger::ordinary( + source, + controller, + None, + Box::new(ResolvedAbility::new( + Effect::Draw { + count: crate::types::ability::QuantityExpr::Fixed { value: 1 }, + target: crate::types::ability::TargetFilter::Controller, + }, + Vec::new(), + source, + controller, + )), + state.turn_number, + ); + let mut events = Vec::new(); + let entry = crate::game::triggers::push_pending_trigger_to_stack( + state, + trigger.clone(), + &mut events, + ); + state.pending_trigger = Some(Box::new(trigger)); + state.pending_trigger_entry = Some(entry); + state.pending_trigger_firing = Some(crate::types::identifiers::TriggerFiring::Ordinary); + state.waiting_for = WaitingFor::AbilityModeChoice { + player: controller, + modal: crate::types::ability::ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }, + source_id: source, + mode_abilities: Vec::new(), + is_activated: false, + ability_index: None, + ability_cost: None, + unavailable_modes: Vec::new(), + }; + entry + } + + fn concede(state: &mut GameState, player_id: PlayerId) { + crate::game::engine::apply( + state, + player_id, + crate::types::actions::GameAction::Concede { player_id }, + ) + .expect("concession is always legal"); + } + + /// CR 800.4a (plan Step 5, elimination site 1): `do_eliminate`'s + /// tracked-entry-gone cleanup clears the construction priority recipient + /// beside the three construction cursors it already clears — and it does so + /// **with the cursors**, not only when the departing player happens to be + /// the carried recipient. Both clones below exercise the same site. + /// + /// Revert discriminator: with only this site's clearing removed, the + /// uncleared `Some(P1)` survives into the game-continues arm, where the + /// re-point branch sees a no-longer-alive P1 and installs `Some(P2)` — so + /// the `== None` assertion reads `Some(P2)` and fails. + #[test] + fn tracked_entry_cleanup_clears_the_construction_priority_recipient() { + // Clone A: the leaver is both the prompt controller and the recipient. + let mut state = setup_three_player(); + let entry = open_live_trigger_construction_prompt(&mut state, PlayerId(1)); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + assert!( + state.stack.iter().any(|e| e.id == entry), + "positive reach guard: the tracked entry is really on the stack" + ); + + concede(&mut state, PlayerId(1)); + + assert!( + !state.stack.iter().any(|e| e.id == entry), + "the leaver's trigger entry ceases to exist (CR 800.4a)" + ); + assert_eq!(state.pending_trigger, None); + assert_eq!(state.pending_trigger_entry, None); + assert!(state.pending_trigger_event_batch.is_empty()); + assert_eq!( + state.pending_trigger_construction_priority_recipient, None, + "the recipient is cleared beside the three construction cursors" + ); + let restored: GameState = + serde_json::from_value(serde_json::to_value(&state).expect("serialize")) + .expect("trusted round trip"); + assert_eq!( + restored.pending_trigger_construction_priority_recipient, None, + "the cleared recipient must survive trusted serde" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { player } if player != PlayerId(1)), + "CR 800.4a hands the wait to a surviving player, got {:?}", + state.waiting_for + ); + + // Clone B (non-carrier): the eliminated prompt controller is NOT the + // carried recipient, so the site must still clear with the cursors. + let mut state = setup_three_player(); + let entry = open_live_trigger_construction_prompt(&mut state, PlayerId(2)); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + assert!( + state.stack.iter().any(|e| e.id == entry), + "positive reach guard: the tracked entry is really on the stack" + ); + assert!( + players::is_alive(&state, PlayerId(1)), + "positive reach guard: the carried recipient is a DIFFERENT, still-living \ + player, so any clearing observed below came from this site rather than \ + from the departed-recipient re-point" + ); + + concede(&mut state, PlayerId(2)); + + assert!(!state.stack.iter().any(|e| e.id == entry)); + assert_eq!(state.pending_trigger_entry, None); + assert_eq!( + state.pending_trigger_construction_priority_recipient, None, + "the site clears the recipient with the cursors, not only when the two coincide" + ); + } + + /// CR 800.4a (plan Step 5, elimination site 2): the terminal `GameOver` + /// cleanup clears the recipient beside the same three cursors, in a fixture + /// where **no leaving player controls the tracked entry** — so this site is + /// provably the only one that can clear it. + /// + /// Revert discriminator: with only this site's clearing removed, a player + /// who has left the game stays installed in a serialized `GameOver` state. + /// No other branch masks it — site 1 never fires here, and the + /// game-continues re-point cannot run on the terminal path. + #[test] + fn terminal_game_over_cleanup_clears_the_construction_priority_recipient() { + let mut state = setup_three_player(); + let entry = open_live_trigger_construction_prompt(&mut state, PlayerId(0)); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + + concede(&mut state, PlayerId(2)); + + // Pre-terminal reach guard: P1 controls no stack entry, so the + // tracked-entry-gone site is false and this row provably exercises the + // terminal branch rather than site 1. + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)), + "a still-living recipient is neither cleared nor re-pointed" + ); + assert_eq!(state.pending_trigger_entry, Some(entry)); + assert!( + state.stack.iter().any(|e| e.id == entry), + "the tracked entry is still on the stack before the final concession" + ); + + concede(&mut state, PlayerId(1)); + + assert!( + matches!( + state.waiting_for, + WaitingFor::GameOver { + winner: Some(PlayerId(0)) + } + ), + "the single game-over check crowns P0, got {:?}", + state.waiting_for + ); + assert_eq!(state.pending_trigger, None); + assert_eq!(state.pending_trigger_entry, None); + assert!(state.pending_trigger_event_batch.is_empty()); + assert_eq!( + state.pending_trigger_construction_priority_recipient, None, + "a departed player must not stay installed in a terminal snapshot" + ); + let restored: GameState = + serde_json::from_value(serde_json::to_value(&state).expect("serialize")) + .expect("trusted round trip"); + assert_eq!( + restored.pending_trigger_construction_priority_recipient, + None + ); + } + + /// CR 800.4a (plan Step 5, elimination site 3 — the new game-continues + /// re-point): when the carried recipient alone leaves and neither cursor- + /// clearing site fires, priority passes to the next player still in the + /// game rather than stranding a departed recipient. + /// + /// Revert discriminator: without the re-point, `Some(P1)` stays installed + /// with P1 out of the game, and the finisher would later return + /// `WaitingFor::Priority { player: P1 }` for a departed player. Neither + /// cursor-clearing row detects this; both still pass. + #[test] + fn game_continues_repoints_a_departed_construction_priority_recipient() { + let mut state = setup_three_player(); + let entry = open_live_trigger_construction_prompt(&mut state, PlayerId(0)); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + + concede(&mut state, PlayerId(1)); + + // Neither clearing site fired: the tracked entry is P0's and survives, + // and the game continues so the terminal arm was never entered. + assert!( + state.stack.iter().any(|e| e.id == entry), + "P0's tracked entry survives an opponent's departure" + ); + assert_eq!(state.pending_trigger_entry, Some(entry)); + assert!( + matches!(state.waiting_for, WaitingFor::AbilityModeChoice { player, .. } if player == PlayerId(0)), + "the construction prompt is still live and unchanged for P0, got {:?}", + state.waiting_for + ); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(2)), + "the departed recipient is re-pointed to the next living player, not stranded" + ); + let restored: GameState = + serde_json::from_value(serde_json::to_value(&state).expect("serialize")) + .expect("trusted round trip"); + assert_eq!( + restored.pending_trigger_construction_priority_recipient, + Some(PlayerId(2)), + "the re-pointed recipient must survive trusted serde" + ); + } + + /// CR 800.4a + CR 101.4: the re-point follows the CURRENT turn-order + /// direction, not fixed seating. Under `TurnDirection::Reversed` the next + /// player in turn order after a departed P1 is P0, not seat-forward P2. + /// + /// Revert discriminator: `players::next_player` (seat-forward) re-points to + /// P2 here; `players::next_player_in_turn_order` re-points to P0. + #[test] + fn game_continues_repoints_a_departed_recipient_in_reversed_turn_order() { + let mut state = setup_three_player(); + state.turn_direction = crate::types::phase::TurnDirection::Reversed; + let entry = open_live_trigger_construction_prompt(&mut state, PlayerId(0)); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + + concede(&mut state, PlayerId(1)); + + assert!( + state.stack.iter().any(|e| e.id == entry), + "P0's tracked entry survives an opponent's departure" + ); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(0)), + "under reversed turn order the departed recipient re-points backward \ + through seating (the next player in TURN order), not seat-forward" + ); + } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fd5e1969a0..6617467c17 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -7575,6 +7575,11 @@ fn apply_action( let mut events = Vec::new(); let mut triggers_processed_inline = false; let skip_deferred_trigger_drain = false; + // The trigger-construction finisher runs at most once per reducer action, at + // the outermost handler return of its enumerated seams. Reset the witness + // here rather than at the outer boundary so a direct `apply_action` caller + // (drive loops, injector harnesses) is measured per action too. + state.trigger_construction_finisher_ran_this_action = false; // CancelAutoPass works from any WaitingFor state (player may cancel during // interactive choices). Routed by `actor` — previously used @@ -9186,6 +9191,17 @@ fn apply_action( }, ) => { let events_before = events.len(); + // CR 605.4a: which typed half of the colour seam this action is. + // A `ManaAbility` choice is a completed mana frame and has already + // recorded its exact occurrences through + // `mana_abilities::collect_completed_mana_frame_events` (the + // original source in `handle_choose_mana_color`, each sibling on its + // own finish path). A `ResolvingEffect` choice is not a mana frame + // at all and keeps its historical immediate scan. + let is_completed_mana_frame = matches!( + context, + crate::types::game_state::ManaChoiceContext::ManaAbility(_) + ); let wf = match context { crate::types::game_state::ManaChoiceContext::ManaAbility(pending_mana_ability) => { // CR 605.3a: validate the requested batch size BEFORE any mana @@ -9253,15 +9269,22 @@ fn apply_action( )? } }; - // CR 603.2c + CR 605.4a: A mana color choice produces mana inline. - // Scan its events for TapsForMana mana multipliers and for - // cost-payment triggers HERE, because for `ManaPayment` / - // `UnlessPayment` resumes the post-action pipeline is skipped - // (it is guarded by `matches!(waiting_for, WaitingFor::Priority)`), - // so this is the only scan site — and CR 605.4a requires the bonus - // mana to enter the pool before the spell's payment step continues. - // Do NOT "simplify" this scan away for non-Priority resumes. - if events.len() > events_before { + // CR 603.2c + CR 605.4a: A NON-mana `ResolvingEffect` colour choice + // produces mana inline. Scan its events for TapsForMana mana + // multipliers and for cost-payment triggers HERE, because for + // `ManaPayment` / `UnlessPayment` resumes the post-action pipeline is + // skipped (it is guarded by `matches!(waiting_for, + // WaitingFor::Priority)`), so this is the only scan site — and + // CR 605.4a requires the bonus mana to enter the pool before the + // spell's payment step continues. Do NOT "simplify" this scan away + // for non-Priority resumes. + // + // The `ManaAbility` half owns no aggregate scan: every source and + // sibling already recorded exact occurrences through the typed + // completed-frame seam, so a second scan here would rediscover + // events the frame already claimed and dispatch an ordinary cost + // observer separately from that frame's synthetic reflexive. + if !is_completed_mana_frame && events.len() > events_before { let mana_events: Vec<_> = events[events_before..].to_vec(); super::triggers::process_triggers(state, &mana_events); } @@ -9286,7 +9309,12 @@ fn apply_action( // Claim the scan via `triggers_processed_inline` — the same // mechanism `DeclareAttackers` uses — so the pipeline runs SBAs, // delayed/state triggers, and layers but skips the trigger re-scan. - if matches!(wf, WaitingFor::Priority { .. }) { + // + // The `ManaAbility` half must NOT use this broad suppression: its + // occurrence journal already narrows the pipeline's scan to exactly + // the events the mana frames did not claim, and the pipeline's + // guarded deferred drain is what releases their queued contexts once. + if !is_completed_mana_frame && matches!(wf, WaitingFor::Priority { .. }) { triggers_processed_inline = true; } wf @@ -10099,7 +10127,6 @@ fn apply_action( if ability_index < obj.abilities.len() && mana_abilities::is_mana_ability(&obj.abilities[ability_index]) { - let events_before = events.len(); let ability_def = obj.abilities[ability_index].clone(); let wf = mana_abilities::activate_mana_ability( state, @@ -10114,18 +10141,14 @@ fn apply_action( }, None, )?; - // CR 605.1b: Process TapsForMana triggers inline during mana payment - // (same rationale as the TapLandForMana arm below). - // CR 605.3b + CR 616.1 + CR 603.3b: A paused costed mana - // ability serializes its unscanned events in its typed cursor. - // The cursor is their single settlement authority, so do not - // scan them here and again when the replacement choice resumes. - if events.len() > events_before - && !casting::mana_ability_cost_payment_is_paused(state) - { - let mana_events: Vec<_> = events[events_before..].to_vec(); - super::triggers::process_triggers(state, &mana_events); - } + // CR 605.4a: no outer scan. `activate_mana_ability` + // builds a real typed cursor here, and its completed frame has + // already run `collect_completed_mana_frame_events` — collecting + // TapsForMana multipliers and ordinary cost observers together + // and journaling the exact live occurrences it claimed. Rescanning + // the same range would rediscover them. A paused costed mana + // ability keeps owning its unscanned events in that cursor, and + // still settles them when the replacement choice resumes. if let Some(order_wf) = super::triggers::preserve_order_triggers_resume(state, wf.clone()) { @@ -10161,21 +10184,39 @@ fn apply_action( }, &mut events, )?; - super::triggers::resolve_tap_mana_triggers_inline( - state, - &mut events, - events_before, - ); - // CR 605.1b: TapsForMana triggered mana abilities (Wild Growth, Vorinclex, - // Fertile Ground, Mana Flare class) must resolve inline when mana is - // produced during cost payment. The ManaPayment path does not flow through - // run_post_action_pipeline, so process triggers explicitly here so the - // bonus mana reaches the pool before the payment check. - if events.len() > events_before - && !casting::mana_ability_cost_payment_is_paused(state) - { - let mana_events: Vec<_> = events[events_before..].to_vec(); - super::triggers::process_triggers(state, &mana_events); + // CR 605.1b + CR 605.4a: the manual land tap has no cursor wrapper + // of its own — `activate_mana_source_option`'s no-ability branch + // taps and produces directly — so this arm IS its completed mana + // frame and runs the same typed empty-ledger preparation the cursor + // path runs internally. It resolves the TapsForMana triggered mana + // abilities inline (Wild Growth, Vorinclex, Fertile Ground, Mana + // Flare class) so the bonus mana reaches the pool before the payment + // check, and collects the ordinary tap observers into the same + // release group instead of dispatching them separately. The + // `ManaPayment` path does not flow through + // `run_post_action_pipeline`, so this remains the only seam. Frames + // whose ability branch already collected are protected by the + // helper's own consumed-occurrence filter. + if !casting::mana_ability_cost_payment_is_paused(state) { + if let Some(pause) = mana_abilities::collect_completed_mana_frame_events( + state, + Vec::new(), + &mut events, + events_before, + crate::types::game_state::ManaTriggerFixedPointResume::Root { + player: *player, + resume: Box::new(ManaAbilityResume::ManaPayment { + outer_player: Some(*player), + convoke_mode: *convoke_mode, + }), + }, + ) { + return Ok(ActionResult { + events, + waiting_for: pause, + log_entries: vec![], + }); + } } if let Some(order_wf) = super::triggers::preserve_order_triggers_resume(state, wf.clone()) @@ -10615,7 +10656,12 @@ fn apply_action( // `actor` is already authorized as the prompted player by // `check_actor_authorization` (via `WaitingFor::acting_player`). (WaitingFor::OrderTriggers { .. }, GameAction::OrderTriggers { order }) => { - triggers::handle_order_triggers(state, order)? + // Round-20 seam 1: this arm is the outermost handler return for the + // whole ordered batch, so it is where the construction finisher runs + // — covering the multi-group re-prompt, both early returns after + // `pending_trigger_order.take()`, and the terminal resume. + let produced = triggers::handle_order_triggers(state, order)?; + triggers::finish_trigger_construction_action(state, &mut events, produced) } // CR 707.9: Player chose a permanent to copy for "enter as a copy of" replacement. ( @@ -12092,7 +12138,10 @@ fn apply_action( // `pending_trigger_entry` so the resolver may now fire it. pending_trigger.ability.distribution = Some(distribution.iter().map(|(t, a)| (t.clone(), *a)).collect()); - if !triggers::finalize_pending_trigger_entry(state, &pending_trigger.ability) { + let produced = if !triggers::finalize_pending_trigger_entry( + state, + &pending_trigger.ability, + ) { // Unexpected dangling cursor: the entry is no longer on the // stack. Recover per CR 608.2b / CR 800.4a (a stack object // that has left the stack does not resolve) — record the @@ -12118,7 +12167,13 @@ fn apply_action( } else { WaitingFor::Priority { player: p } } - } + }; + // Round-20 seam 4: the trigger-owned division arm's produced + // wait — dangling recovery, deferred sibling, or success — goes + // through the construction finisher exactly once. The + // resolution-time and cast-time distribution arms below are not + // trigger-owned and are deliberately untouched. + triggers::finish_trigger_construction_action(state, &mut events, produced) } else { // Resolution-time distribution continuation path. state.waiting_for = WaitingFor::Priority { player: p }; @@ -18108,20 +18163,38 @@ mod stage2_injector_tests { assert_eq!( producers.len() + readers.len() + in_test, - 38, + 41, "CR 603.5 prompt census drifted. A new PRODUCER must have its recipient bound \ somewhere — the mint's conjunct (a) covers exactly ONE of them. A new READER is \ the benign case (U4's own consumption arm was one): adjudicate it in this doc and \ name the site, do not merely move the number.\n\ producers={producers:#?}\nreaders={readers:#?}" ); + // TEST-FIXTURE DRIFT, adjudicated on THIS branch (plan-v23 Step 5, session 4), + // `27 ⇒ 28`. One new line, again in the third (benign) partition: + // `game/triggers.rs::approved_construction_prompts` — the fixture that enumerates one + // instance of each of the six approved trigger-construction prompts, which the + // construction-finisher contract rows iterate. It mints nothing in production and + // reads `state.waiting_for` nowhere. The PRODUCER half is unchanged at 5 and the + // READER half unchanged at 7, both with byte-identical per-file lists. + // TEST-FIXTURE DRIFT, adjudicated on THIS branch (plan-v23 Steps 6/7), `25 ⇒ 27`. + // Both new lines are `#[cfg(test)]` fixture waits, i.e. the third partition — the + // benign class. The PRODUCER half is unchanged at 5 with a byte-identical per-file + // list, and the READER half is unchanged at 7, which is what this row's claim is + // about. The two lines are: + // - `game/triggers.rs::observer_helper_fixture` — the paused wait the + // `park_observer_triggers_if_paused` rows need in order to reach that helper's + // collecting branch at all; + // - `game/visibility.rs::triggered_mana_projection_fixture` — the live public + // prompt the Step-6 redaction rows assert survives viewer filtering. + // Neither mints a prompt in production; neither reads `state.waiting_for` in + // production. Adjudicated, not relaxed: a sixth PRODUCER would still red the + // partition assert below before this total could absorb it. assert_eq!( (producers.len(), readers.len(), in_test), - (5, 8, 25), + (5, 8, 28), "the partition, not just the total: five PRODUCTION producers, eight PRODUCTION \ - readers (they read `state.waiting_for` and never write it — the seventh is U4's \ - `inject_pinned_answer` arm, the eighth is C1's journalling `apply_action` arm), \ - 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ + readers (they read `state.waiting_for` and never write it), 28 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ readers={readers:#?}" ); assert_eq!( @@ -18600,9 +18673,9 @@ mod stage2_injector_tests { // trust looks like — and it is why the two prose entries are BOTH kept // rather than one overwriting the other: they are separate witnesses, not // duplicates. - "game/effects/mod.rs:6774".to_string(), - "game/effects/mod.rs:6851".to_string(), - "game/effects/mod.rs:10089".to_string(), + "game/effects/mod.rs:6923".to_string(), + "game/effects/mod.rs:7000".to_string(), + "game/effects/mod.rs:10238".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. @@ -19288,7 +19361,7 @@ mod stage2_injector_tests { // `origin/main:crates/engine/src/game/engine.rs:12773`, and its offset from // `begin_pending_trigger_target_selection` (`:12662`) is STILL 134 — the // control that caught this row's one historical SILENT drift. - "game/engine.rs:12796".to_string(), + "game/engine.rs:12851".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_modes.rs b/crates/engine/src/game/engine_modes.rs index 0afbbebe86..1dbdf5a0b2 100644 --- a/crates/engine/src/game/engine_modes.rs +++ b/crates/engine/src/game/engine_modes.rs @@ -61,7 +61,12 @@ pub(super) fn handle_ability_mode_choice( events, ) } else { - handle_triggered_mode_choice( + // Round-20 seam 2: the finisher wraps the result HERE, at the public + // `SelectModes` entry, and never inside `handle_triggered_mode_choice`. + // That function is re-entered inside trigger dispatch (via + // `resolve_random_modal_trigger`), where a `Priority` result is + // discarded — consuming the recipient there would lose it mid-batch. + let produced = handle_triggered_mode_choice( state, TriggeredModeChoice { player, @@ -72,7 +77,10 @@ pub(super) fn handle_ability_mode_choice( indices, }, events, - ) + )?; + Ok(triggers::finish_trigger_construction_action( + state, events, produced, + )) }?; Ok(waiting_for) diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index 16f05c53b6..d0ad8a2df8 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -28,10 +28,39 @@ use super::engine_priority; use super::mana_abilities; use super::zone_pipeline::{self, ZoneMoveRequest, ZoneMoveResult}; +/// CR 605.4a: the sidecar-aware wrapper around the unmodified baseline handler. +/// +/// The baseline body — including its `park_observer_triggers_if_paused` call, +/// which fails closed under the typed ownership guard — runs inside the resumed +/// occurrence's own node/marker/production-override scope. Readiness runs only +/// after that scope has restored, so combined collection and child discovery see +/// the ambient authority they would have seen synchronously. With no live +/// carrier both hooks are exact no-ops and this is baseline byte-for-byte. pub(super) fn handle_optional_effect_choice( state: &mut GameState, accept: bool, events: &mut Vec, +) -> Result { + let events_before = events.len(); + let produced = super::triggers::with_accepted_triggered_mana_action_scope(state, |state| { + handle_optional_effect_choice_inner(state, accept, events) + })?; + if let super::triggers::TriggeredManaReadiness::Resumed { wait, .. } = + super::triggers::finish_accepted_triggered_mana_action(state, events, events_before)? + { + // This arm falls through to the reducer's ordinary epilogue, which owns + // release for the resumed frame; no settled-Priority convergence here. + let wait = *wait; + state.waiting_for = wait.clone(); + return Ok(wait); + } + Ok(produced) +} + +fn handle_optional_effect_choice_inner( + state: &mut GameState, + accept: bool, + events: &mut Vec, ) -> Result { let events_before = events.len(); state.cost_payment_failed_flag = false; @@ -102,22 +131,32 @@ pub(super) fn handle_optional_effect_choice( // CR 608.2c + CR 700.2b: Optional triggered modal ("you may choose N") — // the decline/accept gate runs before mode selection while the stack // entry is still mid-construction. - if accept { + let produced = if accept { super::engine::clear_pending_trigger_optional(state); if let Some(waiting) = super::engine::begin_pending_trigger_target_selection(state)? { - state.waiting_for = waiting; + waiting } else { - state.waiting_for = WaitingFor::Priority { + WaitingFor::Priority { player: state.active_player, - }; + } } } else { super::engine::drop_mid_construction_pending_trigger(state); - state.waiting_for = WaitingFor::Priority { + WaitingFor::Priority { player: state.active_player, - }; - } + } + }; + // Round-20 seam 5: the optional-modal branch's assigned wait goes + // through the construction finisher BEFORE + // `resume_pending_continuation_if_priority` and + // `park_observer_triggers_if_paused` run below, so those baseline + // calls observe the final wait. With no carried recipient the + // finisher returns `produced` byte-for-byte, preserving baseline's + // `Priority { player: state.active_player }` fallback exactly — the + // active player, not the trigger controller. + state.waiting_for = + super::triggers::finish_trigger_construction_action(state, events, produced); } } @@ -155,12 +194,59 @@ pub(super) fn handle_optional_effect_choice_and_remember( handle_optional_effect_choice(state, matches!(choice, AutoMayChoice::Accept), events) } +/// CR 605.4a: the sidecar-aware wrapper around the unmodified baseline handler, +/// exactly as [`handle_optional_effect_choice`] above. The inner body keeps every +/// early return it already had — an intermediate `OpponentMayChoice` re-prompt is +/// a repeated pause of the same accepted occurrence, and readiness recognizes it +/// as one. pub(super) fn handle_opponent_may_choice( state: &mut GameState, waiting_for: WaitingFor, accept: bool, events: &mut Vec, ) -> Result { + let events_before = events.len(); + let produced = super::triggers::with_accepted_triggered_mana_action_scope(state, |state| { + handle_opponent_may_choice_inner(state, waiting_for, accept, events) + })?; + if let super::triggers::TriggeredManaReadiness::Resumed { + wait, + settled_direct_priority_root, + } = super::triggers::finish_accepted_triggered_mana_action(state, events, events_before)? + { + // CR 117.5 + CR 605.4a: this reducer arm returns its `ActionResult` + // directly, so the ordinary epilogue never runs. A resumed direct + // `Priority` root with no live owner therefore has to converge here or + // its own frame's settled batch would sit undrained until some later + // action — that is exactly the gap the settled wrapper closes. Every + // other owner keeps its queue and returns the frame's wait unchanged. + let wait = *wait; + let wait = if settled_direct_priority_root { + engine_priority::run_post_action_pipeline_from_settled_priority( + state, + events, + events_before, + &wait, + )? + } else { + wait + }; + state.waiting_for = wait.clone(); + return Ok(action_result(events, wait)); + } + Ok(action_result(events, produced)) +} + +/// The unmodified baseline body, returning its wait rather than an +/// `ActionResult`: `action_result` takes the event vector, and the wrapper above +/// must still read this action's own emitted range afterwards. Every early +/// return is preserved exactly. +fn handle_opponent_may_choice_inner( + state: &mut GameState, + waiting_for: WaitingFor, + accept: bool, + events: &mut Vec, +) -> Result { let events_before = events.len(); let WaitingFor::OpponentMayChoice { player: promptee, @@ -258,7 +344,7 @@ pub(super) fn handle_opponent_may_choice( max_targets: 1, pending_ability: ability, }; - return Ok(action_result(events, state.waiting_for.clone())); + return Ok(state.waiting_for.clone()); } if !remaining.is_empty() { @@ -274,7 +360,7 @@ pub(super) fn handle_opponent_may_choice( description, remaining: rest, }; - return Ok(action_result(events, state.waiting_for.clone())); + return Ok(state.waiting_for.clone()); } state @@ -309,7 +395,7 @@ pub(super) fn handle_opponent_may_choice( description, remaining: rest, }; - return Ok(action_result(events, state.waiting_for.clone())); + return Ok(state.waiting_for.clone()); } else { set_active_priority(state); if let Some(frame) = state @@ -322,7 +408,7 @@ pub(super) fn handle_opponent_may_choice( resume_pending_continuation_if_priority(state, events)?; super::triggers::collect_and_drain_observer_triggers_if_settled(state, events, events_before); - Ok(action_result(events, state.waiting_for.clone())) + Ok(state.waiting_for.clone()) } fn resolve_all_declined_opponent_may( @@ -1687,6 +1773,13 @@ pub(super) fn handle_unless_payment_activate_ability( } let ability_def = object.abilities[ability_index].clone(); + // CR 605.3b + CR 118.12: propagate the activation's OWN returned wait. A + // colour choice, a hybrid mana-sub-cost choice, or the completed-frame + // seam's whitelisted pause is raised by return value, not by writing + // `state.waiting_for`; reading the field back therefore silently dropped + // those prompts and left the unless payment reprompted with the interactive + // step never taken. Every pause that DOES write the field (a replacement + // choice) returns the same value it wrote, so this is a strict improvement. mana_abilities::activate_mana_ability( state, source_id, @@ -1703,8 +1796,7 @@ pub(super) fn handle_unless_payment_activate_ability( remaining, }, None, - )?; - Ok(state.waiting_for.clone()) + ) } pub(super) fn handle_ward_discard_choice( diff --git a/crates/engine/src/game/engine_priority.rs b/crates/engine/src/game/engine_priority.rs index b42569ebd3..3c2608c444 100644 --- a/crates/engine/src/game/engine_priority.rs +++ b/crates/engine/src/game/engine_priority.rs @@ -1,12 +1,14 @@ use crate::types::events::GameEvent; use crate::types::game_state::{GameState, WaitingFor}; use crate::types::identifiers::ObjectId; +use crate::types::player::PlayerId; use super::engine::{begin_pending_trigger_target_selection, check_exile_returns, EngineError}; use super::match_flow; use super::players; use super::sba; use super::triggers; +use super::triggers::DeferredTriggerDrainPolicy; pub(super) fn run_post_action_pipeline( state: &mut GameState, @@ -35,9 +37,95 @@ pub(crate) fn run_post_action_pipeline_from( default_wf: &WaitingFor, skip_trigger_scan: bool, skip_deferred_trigger_drain: bool, +) -> Result { + run_post_action_pipeline_from_with_policy( + state, + events, + event_start, + default_wf, + skip_trigger_scan, + if skip_deferred_trigger_drain { + DeferredTriggerDrainPolicy::Skip + } else { + DeferredTriggerDrainPolicy::ResolutionSafe + }, + None, + ) +} + +/// CR 117.3c + CR 117.5 + CR 605.4a: the settled-Priority convergence wrapper. +/// +/// The one caller family is a handler that returns its `ActionResult` directly, +/// bypassing the reducer's ordinary epilogue, after a sidecar-owned accepted +/// triggered-mana occurrence resumed a **direct `ManaAbilityResume::Priority` +/// root** with no live cast/resolution/payment owner left. Its `settled_priority` +/// is that root's own exact reconstructed wait, and it is exhaustively validated +/// here rather than trusted. +/// +/// It differs from the ordinary wrappers in exactly three ways: the drain policy +/// permits a passive announced spell to remain on the stack (the batch below it +/// is fully settled, so its observers belong above it); the carried recipient +/// governs the no-choice stack-growth exit and is persisted before any ordering +/// or construction step can pause; and its own return is a finisher call, which +/// is Round-20 seam 7 — this wrapper must never hand back a non-prompt +/// `Priority` with the recipient still installed. +pub(crate) fn run_post_action_pipeline_from_settled_priority( + state: &mut GameState, + events: &mut Vec, + event_start: usize, + settled_priority: &WaitingFor, +) -> Result { + let WaitingFor::Priority { player } = *settled_priority else { + debug_assert!( + false, + "settled-Priority convergence requires the root's exact Priority wait, \ + got {settled_priority:?}", + ); + return Ok(settled_priority.clone()); + }; + triggers::preserve_trigger_construction_priority_recipient(state, player); + let produced = run_post_action_pipeline_from_with_policy( + state, + events, + event_start, + settled_priority, + false, + DeferredTriggerDrainPolicy::SettledPriority, + Some(player), + )?; + Ok(triggers::finish_trigger_construction_action( + state, events, produced, + )) +} + +/// The shared post-action settlement core. Every ordinary wrapper reaches it +/// through the boolean form above, with `carried_priority_recipient == None`, +/// which preserves their current stack-growth fallback and every current +/// non-sidecar ordering behavior byte-for-byte. +/// +/// `carried_priority_recipient` is `Some(player)` only for a settled-Priority +/// convergence whose activator was not the active player (CR 117.3c + CR 117.5). +/// It changes exactly two things: the wait computed at the no-choice +/// stack-growth exit, and the fact that any ordering or construction prompt the +/// pipeline opens persists that player so the construction finisher can hand +/// priority back to them once the batch is fully announced. +fn run_post_action_pipeline_from_with_policy( + state: &mut GameState, + events: &mut Vec, + event_start: usize, + default_wf: &WaitingFor, + skip_trigger_scan: bool, + drain_policy: DeferredTriggerDrainPolicy, + carried_priority_recipient: Option, ) -> Result { stage_pending_activation_trigger_events(state, events, event_start); + // CR 117.3c + CR 117.5: the wait a completed no-choice pass hands back. With + // no carried recipient this is exactly baseline's active-player wait. + let settled_priority_wait = WaitingFor::Priority { + player: carried_priority_recipient.unwrap_or(state.active_player), + }; + // Capture stack depth before any trigger/SBA processing so we can detect // whether new triggered abilities were added during this pipeline pass. let stack_before = state.stack.len(); @@ -192,6 +280,7 @@ pub(crate) fn run_post_action_pipeline_from( // next SBA pass. if let Some(waiting_for) = begin_pending_trigger_target_selection(state)? { state.waiting_for = waiting_for.clone(); + persist_carried_recipient_across_prompt(state, carried_priority_recipient); state.consumed_before_priority_trigger_events.clear(); return Ok(waiting_for); } @@ -241,6 +330,7 @@ pub(crate) fn run_post_action_pipeline_from( ); if let Some(waiting_for) = outcome.prompt { state.waiting_for = waiting_for.clone(); + persist_carried_recipient_across_prompt(state, carried_priority_recipient); state.consumed_before_priority_trigger_events.clear(); return Ok(waiting_for); } @@ -262,9 +352,10 @@ pub(crate) fn run_post_action_pipeline_from( } } else if matches!(state.waiting_for, WaitingFor::Priority { .. }) && !state.deferred_triggers.is_empty() - && !skip_deferred_trigger_drain { - if let Some(wf) = triggers::drain_deferred_trigger_queue(state, events) { + if let Some(wf) = + triggers::drain_deferred_trigger_queue_with_policy(state, events, drain_policy) + { state.waiting_for = wf; } } @@ -273,6 +364,7 @@ pub(crate) fn run_post_action_pipeline_from( if matches!(state.waiting_for, WaitingFor::GameOver { .. }) { match_flow::handle_game_over_transition(state); } + persist_carried_recipient_across_prompt(state, carried_priority_recipient); state.consumed_before_priority_trigger_events.clear(); return Ok(state.waiting_for.clone()); } @@ -300,6 +392,7 @@ pub(crate) fn run_post_action_pipeline_from( // sets pending_trigger and is re-derived at begin_pending_trigger_target_selection) // is untouched. if matches!(state.waiting_for, WaitingFor::OrderTriggers { .. }) { + persist_carried_recipient_across_prompt(state, carried_priority_recipient); return Ok(state.waiting_for.clone()); } @@ -310,15 +403,14 @@ pub(crate) fn run_post_action_pipeline_from( if let Some(waiting_for) = begin_pending_trigger_target_selection(state)? { state.waiting_for = waiting_for.clone(); + persist_carried_recipient_across_prompt(state, carried_priority_recipient); return Ok(waiting_for); } if state.stack.len() > stack_before { let outgoing = flush_pending_priority_intercepts( state, - WaitingFor::Priority { - player: state.active_player, - }, + settled_priority_wait.clone(), default_wf.acting_player(), ); return Ok(outgoing); @@ -333,6 +425,34 @@ pub(crate) fn run_post_action_pipeline_from( )) } +/// CR 117.3c + CR 117.5: persist a carried priority recipient across any +/// ordering or trigger-construction prompt this pipeline pass just installed, so +/// the construction finisher can hand priority back to that player once the +/// batch has finished announcing. +/// +/// With `carried_priority_recipient == None` — every ordinary wrapper — this is +/// a no-op, and the existing active-player/controller fallbacks at each +/// construction seam are untouched. When ordering opens, the same player is +/// stored in both authorities: `PendingTriggerOrder::resume_after_ordering` +/// (which `handle_order_triggers` consumes and then drops) and the durable +/// recipient (which stays authoritative across every early return after that +/// order carrier is taken). +fn persist_carried_recipient_across_prompt( + state: &mut GameState, + carried_priority_recipient: Option, +) { + let Some(player) = carried_priority_recipient else { + return; + }; + if !triggers::is_trigger_construction_prompt(&state.waiting_for) { + return; + } + if matches!(state.waiting_for, WaitingFor::OrderTriggers { .. }) { + triggers::preserve_order_triggers_resume(state, WaitingFor::Priority { player }); + } + triggers::preserve_trigger_construction_priority_recipient(state, player); +} + /// Route events emitted while a target-bearing activation remains pending into /// its private trigger transaction before the ordinary post-action collector /// can observe them. diff --git a/crates/engine/src/game/engine_stack.rs b/crates/engine/src/game/engine_stack.rs index e5d574e0ee..aa65d742b2 100644 --- a/crates/engine/src/game/engine_stack.rs +++ b/crates/engine/src/game/engine_stack.rs @@ -194,8 +194,13 @@ pub(super) fn handle_trigger_target_selection_select_targets( .take() .ok_or_else(|| EngineError::InvalidAction("No pending trigger".to_string()))?; - Ok(finalize_trigger_target_selection( - state, trigger, ability, events, + let produced = finalize_trigger_target_selection(state, trigger, ability, events); + // Round-20 seam 3: wrapping at the action seam — not per return inside + // `finalize_trigger_target_selection` — covers all five of its returns + // uniformly and keeps the `engine_modes` delegation, which runs inside + // trigger dispatch, from consuming the recipient. + Ok(triggers::finish_trigger_construction_action( + state, events, produced, )) } @@ -351,8 +356,10 @@ pub(super) fn handle_trigger_target_selection_choose_target( .take() .ok_or_else(|| EngineError::InvalidAction("No pending trigger".to_string()))?; - Ok(finalize_trigger_target_selection( - state, trigger, ability, events, + let produced = finalize_trigger_target_selection(state, trigger, ability, events); + // Round-20 seam 3, step-by-step walk completion. + Ok(triggers::finish_trigger_construction_action( + state, events, produced, )) } } diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 2fa306cb0d..fb029dbc51 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -13,8 +13,9 @@ use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ CostResume, GameState, ManaAbilityCostCursor, ManaAbilityCostParent, ManaAbilityCostParentLifecycle, ManaAbilityCostResolutionMode, ManaAbilityResume, ManaChoice, - ManaChoiceContext, ManaChoicePrompt, PayCostKind, PayableResource, PendingCostMoveResume, - PendingManaAbility, ProductionOverride, WaitingFor, + ManaChoiceContext, ManaChoicePrompt, ManaColorChoiceResume, ManaTriggerFixedPointResume, + PayCostKind, PayableResource, PendingCostMoveResume, PendingManaAbility, ProductionOverride, + WaitingFor, }; use crate::types::identifiers::ObjectId; use crate::types::mana::{ManaColor, ManaCost, ManaPool, ManaType, PaymentContext}; @@ -322,6 +323,42 @@ pub fn is_triggered_mana_ability( ) } +/// CR 605.1b + CR 605.4a: the **resolver-facing** counterpart of +/// [`is_triggered_mana_ability`]. +/// +/// [`is_triggered_mana_ability`] is the *acceptance-time* gate: it answers +/// "does this classification-time graph qualify?" and is deliberately raw — +/// any target anywhere in the graph makes it false. That is the right question +/// once, when an occurrence is accepted. +/// +/// It is the wrong question during resolution. `resolve_ability_chain` may +/// materialize an engine resolution-context referent (a `chosen_players` +/// member surfacing through `ControllerRef::ChosenPlayer`, for instance) into +/// the overloaded `ResolvedAbility.targets` vector. That referent is not a +/// CR 115.1d announcement target — `build_target_slots` surfaces no slot for +/// it — so the already-accepted ability does not stop being a triggered mana +/// ability partway through its own resolution. CR 605.4a keeps the occurrence +/// stackless and owned by the immediate fixed point. +/// +/// So: while the accepted-occurrence marker is live, the classification +/// decision has already been made and is simply read back. Outside such an +/// occurrence there is no marker and this delegates to the raw classifier with +/// the ambient `current_trigger_event`, which is exactly baseline. Ordinary +/// callers — including the compatibility `resolve_triggered_mana_ability_inline` +/// wrapper, which deliberately installs no marker — are unaffected. +pub(crate) fn is_resolving_triggered_mana(state: &GameState, ability: &ResolvedAbility) -> bool { + if let Some(node) = state.active_accepted_triggered_mana_node { + debug_assert_eq!( + Some(node), + state.active_rules_execution_node, + "an accepted triggered-mana occurrence marker must name the ambient rules-execution \ + node; a mismatch means a scope was entered or restored without its partner" + ); + return true; + } + is_triggered_mana_ability(ability, state.current_trigger_event.as_ref()) +} + /// True iff every reachable link (via `sub_ability` and `else_ability` per /// CR 608.2c) has `Effect::Mana`. The "every link is mana" rule is the /// conservative reading of CR 605.1b(c) — inline resolution skips priority, @@ -571,6 +608,13 @@ pub(super) fn resolve_mana_ability_excluding( // its historical default-output semantics even when a cost move pauses. // The cursor serializes that resolution mode with the exact outer resume. let cost_event_start = events.len(); + // CR 603.2 + CR 603.3b: Prepare a fresh child-facing snapshot of the live + // synchronous parent carrying that parent frame's current unscanned suffix, + // including every earlier sibling that already completed synchronously. The + // live parent cursor is never mutated, so a synchronous child drops this + // snapshot without duplicating the root's eventual scan. + let prepared_parent = + parent.map(|parent| parent_snapshot_with_current_cost_events(parent, events)); let waiting_for = continue_mana_ability_cost_payment( state, pending, @@ -579,7 +623,7 @@ pub(super) fn resolve_mana_ability_excluding( excluded_sources, sub_cost_demand, ManaAbilityCostResolutionMode::AutoResolved, - parent, + prepared_parent.as_ref(), ), events, cost_event_start, @@ -1247,6 +1291,10 @@ pub fn handle_choose_mana_color( let node = pending .rules_execution_node .unwrap_or_else(|| state.begin_activated_mana_journal_node(pending.source_id)); + // CR 605.4a: the choice action's own live-collection start. The already-paid + // cost range was collected by the frame that returned this prompt; this + // action owns only what production/completion emits from here. + let choice_action_start = events.len(); state.with_rules_execution_node(node, |state| { produce_mana_from_ability( state, @@ -1267,6 +1315,26 @@ pub fn handle_choose_mana_color( ); }); + // CR 603.2 + CR 605.3c + CR 605.4a: the second typed half of the colour + // seam. The frame this choice completes is a completed mana frame with an + // empty durable ledger, so it runs the SAME collection helper before + // returning its resume owner — and before `batch_activate_mana_siblings` + // begins the next sibling, each of which performs the same helper on its own + // finish path. That is the CR 605.4a pre-pass between siblings, in place of + // one aggregate scan after the loop. + if let Some(pause) = collect_completed_mana_frame_events( + state, + Vec::new(), + events, + choice_action_start, + ManaTriggerFixedPointResume::Root { + player: pending.player, + resume: Box::new(pending.resume.clone()), + }, + ) { + return Ok(pause); + } + Ok(resume_waiting_for(pending.player, pending.resume.clone())) } @@ -2119,6 +2187,42 @@ fn mana_ability_definition( .ok_or_else(|| EngineError::InvalidAction("Mana ability no longer exists".to_string())) } +/// CR 603.2 + CR 603.3b: Build the child-facing snapshot of a live synchronous +/// parent frame. Only this clone's ledger gains the parent's current unscanned +/// suffix; the live parent cursor keeps its own `cost_event_start` and its +/// events remain in the reducer vector, so a synchronous child that drops the +/// snapshot cannot duplicate the root's eventual scan. Preparation runs at every +/// child entry, so a later pausing sibling inherits the parent prefix plus all +/// earlier synchronously completed siblings in chronological order. +fn parent_snapshot_with_current_cost_events( + parent: &ManaAbilityCostParent, + events: &[GameEvent], +) -> ManaAbilityCostParent { + debug_assert!( + matches!( + parent.lifecycle, + ManaAbilityCostParentLifecycle::Synchronous + ), + "only a live synchronous parent may be augmented; a suspended parent's prefix is already durable" + ); + debug_assert!( + parent.current_action_event_start <= events.len(), + "parent event marker must index the live reducer event vector" + ); + let mut prepared = parent.clone(); + if matches!( + parent.lifecycle, + ManaAbilityCostParentLifecycle::Synchronous + ) { + let start = parent.current_action_event_start.min(events.len()); + prepared + .cursor + .deferred_cost_events + .extend_from_slice(&events[start..]); + } + prepared +} + fn mana_ability_cost_cursor( cost: &Option, excluded_sources: &HashSet, @@ -2144,13 +2248,10 @@ fn mana_ability_cost_cursor( next_sacrificed: 0, selected_exile_remaining: None, selected_sacrifice_remaining: None, - // CR 603.2 + CR 603.3b: A nested child takes over every unscanned - // event already owned by its suspended parent. It appends only newly - // emitted events if it pauses, then hands the one batch back on - // completion; no ancestor batch is copied twice. - deferred_cost_events: parent.map_or_else(Vec::new, |parent| { - parent.cursor.deferred_cost_events.clone() - }), + // CR 603.2 + CR 603.3b: A nested child starts with an empty frame-local + // ledger. Its parent snapshot retains every ancestor-owned event + // opaquely until a suspended child moves its own events upward. + deferred_cost_events: Vec::new(), current_action_deferred_start: 0, parent: parent.cloned().map(Box::new), } @@ -2265,6 +2366,19 @@ fn ensure_mana_ability_selection_cursor_consumed( Ok(()) } +/// CR 603.2 + CR 603.3b: Move one suspended child's frame-local trigger-event +/// ledger into its parent without replacing the parent's earlier events. +fn append_suspended_child_cost_events( + parent: &mut ManaAbilityCostCursor, + child: &mut ManaAbilityCostCursor, + current: &[GameEvent], +) { + parent + .deferred_cost_events + .extend(std::mem::take(&mut child.deferred_cost_events)); + parent.deferred_cost_events.extend_from_slice(current); +} + fn advance_mana_ability_selection_cursor( cursor: &mut ManaAbilityCostCursor, cost: &AbilityCost, @@ -2657,15 +2771,19 @@ fn pay_mana_ability_cost_component( .skip(cursor.next_sacrificed); // CR 605.3b + CR 605.3c: A nested source that pauses while // funding this Mana component must retain this exact parent cursor. - // The child cursor inherits the parent's existing deferred batch - // when it is constructed. Do not also copy this action's events - // into the parent: if the child pauses, its own cursor appends them - // exactly once and becomes the temporary settlement authority. + // The child's frame-local deferred ledger starts empty; ancestor + // events stay opaque in this parent snapshot until upward handoff. let parent_cursor = cursor.clone(); let parent = ManaAbilityCostParent { pending: Box::new(pending.clone()), cursor: Box::new(parent_cursor), lifecycle: ManaAbilityCostParentLifecycle::Synchronous, + // CR 603.2 + CR 603.3b: Record where this parent frame's own + // unscanned events begin so each nested child entry can prepare + // a snapshot carrying that prefix plus any earlier synchronous + // sibling. The live cursor and its `cost_event_start` are not + // modified. + current_action_event_start: cost_event_start, }; let prior_waiting_for = state.waiting_for.clone(); let component_progress = match pay_mana_ability_cost_with_choices( @@ -2735,7 +2853,7 @@ fn pay_mana_ability_cost_component( fn finish_mana_ability_cost_payment( state: &mut GameState, mut pending: PendingManaAbility, - cursor: ManaAbilityCostCursor, + mut cursor: ManaAbilityCostCursor, events: &mut Vec, cost_event_start: usize, ) -> Result { @@ -2744,9 +2862,32 @@ fn finish_mana_ability_cost_payment( ManaAbilityCostResolutionMode::AutoResolved ); let has_deferred_cost_events = !cursor.deferred_cost_events.is_empty(); + let is_ultimate_root = cursor.parent.is_none(); + // CR 605.3b + CR 605.4a: The `AutoResolved` direct resolver + // (`resolve_mana_ability_excluding`) is not an action's completed root mana + // frame — it is the synchronous auto-tap/probe entry, whose events belong to + // the OUTER cost owner already on the Rust call stack and whose caller + // restores its own `waiting_for`. It keeps its historical default-output + // semantics: with an empty durable ledger it performs no collection at all, + // exactly as baseline. Only a frame that already owns replacement-paused + // events settles from this path. + let settles_completed_frame = + is_ultimate_root && (has_deferred_cost_events || !resolves_automatically); + if matches!( + cursor.parent.as_deref(), + Some(ManaAbilityCostParent { + lifecycle: ManaAbilityCostParentLifecycle::Synchronous, + .. + }) + ) { + debug_assert!( + cursor.deferred_cost_events.is_empty(), + "a synchronous mana child cannot own deferred ancestor events" + ); + } let parent = cursor .parent - .clone() + .take() .filter(|parent| matches!(parent.lifecycle, ManaAbilityCostParentLifecycle::Suspended)); let ability_def = mana_ability_definition(state, &pending)?; if !resolves_automatically && pending.color_override.is_none() { @@ -2771,32 +2912,42 @@ fn finish_mana_ability_cost_payment( pending.batch_siblings = batch_eligible_siblings(state, pending.player, pending.source_id, &ability_def); } + let choice_player = pending.player; + let context = ManaChoiceContext::ManaAbility(Box::new(pending)); let resume = WaitingFor::ChooseManaColor { - player: pending.player, - choice, - context: ManaChoiceContext::ManaAbility(Box::new(pending)), + player: choice_player, + choice: choice.clone(), + context: context.clone(), }; - if has_deferred_cost_events { - settle_mana_ability_cost_events( + if settles_completed_frame { + // CR 603.2 + CR 603.3b + CR 605.3b + CR 605.4a: A mana-color + // choice returns before the ordinary post-action pipeline + // runs, so the already-paid cost range needs its one normal + // trigger collection here — through the SAME typed + // completed-frame seam the durable-ledger root uses, whether + // or not that ledger is empty. The empty-ledger shape used to + // fall through to a bare `process_triggers`, which dispatched + // an ordinary cost observer separately from the frame's own + // synthetic reflexive. + debug_assert!(cursor.parent.is_none()); + if let Some(pause) = collect_completed_mana_frame_events( state, cursor.deferred_cost_events, events, cost_event_start, - ); + ManaTriggerFixedPointResume::ColorChoice(Box::new(ManaColorChoiceResume { + player: choice_player, + choice, + context, + })), + ) { + return Ok(pause); + } if let Some(order_wf) = super::triggers::preserve_order_triggers_resume(state, resume.clone()) { return Ok(order_wf); } - } else if events.len() > cost_event_start { - // CR 603.2 + CR 603.3b + CR 605.3b: A mana-color choice - // returns before the ordinary post-action pipeline runs. - // Its already-paid cost events therefore need their one - // normal trigger collection here. Replacement-paused events - // are deliberately excluded: their typed cursor owns them - // until `settle_mana_ability_cost_events` above. - let cost_events = events[cost_event_start..].to_vec(); - super::triggers::process_triggers(state, &cost_events); } return Ok(resume); } @@ -2828,13 +2979,15 @@ fn finish_mana_ability_cost_payment( super::triggers::resolve_tap_mana_triggers_inline(state, events, production_events_start); if let Some(parent) = parent { let mut parent_cursor = *parent.cursor; - // CR 603.2 + CR 603.3b: The child now owns the complete old batch; - // append its current action exactly once, then transfer that batch to - // the parent before it retries its still-unpaid Mana component. - parent_cursor.deferred_cost_events = cursor.deferred_cost_events; - parent_cursor - .deferred_cost_events - .extend_from_slice(&events[cost_event_start..]); + // CR 603.2 + CR 603.3b: Move the suspended child's frame-local batch + // upward without replacing the ancestor's earlier ledger. Append the + // child's current action exactly once before retrying the parent's + // still-unpaid Mana component. + append_suspended_child_cost_events( + &mut parent_cursor, + &mut cursor, + &events[cost_event_start..], + ); let parent_event_start = events.len(); return continue_mana_ability_cost_payment( state, @@ -2844,45 +2997,68 @@ fn finish_mana_ability_cost_payment( parent_event_start, ); } - let resume = resume_mana_ability_root(state, pending.player, pending.resume, events)?; - if super::casting::mana_ability_cost_payment_is_paused(state) { - defer_cost_events_into_active_mana_root( - state, - cursor.deferred_cost_events, - &events[cost_event_start..], - ); - return Ok(resume); - } - - if has_deferred_cost_events { - settle_mana_ability_cost_events( + // CR 603.2 + CR 603.3b + CR 605.3b + CR 605.4a: EVERY completed root mana + // frame settles through the one typed seam, whether or not its durable + // ledger holds replacement-paused events and whatever it resumes to — and it + // settles **before** `resume_mana_ability_root`, not after. + // + // Baseline had two bypasses here. The durable-ledger branch called the + // typed seam; a `ManaPayment`/`UnlessPayment` resume with an empty ledger + // called `process_triggers` directly (because the post-action pipeline is + // guarded by `waiting_for == Priority` and would otherwise drop every + // already-paid cost observer — Scavenger's Talent, Korvold, Mayhem Devil, + // ...; #5963); and a `Priority` resume with an empty ledger fell through to + // the pipeline's own generic scan. The first bypass could dispatch an + // ordinary cost observer separately from this frame's synthetic reflexive; + // the second could let the pipeline rediscover events the frame already + // owns. The typed seam closes both: it journals every live occurrence it + // claims into `consumed_before_priority_trigger_events`, so the pipeline's + // scan is narrowed by the journal rather than by excluding the `Priority` + // resume, and nothing double-fires (e.g. Kilo's becomes-tapped proliferate + // under a standalone Relic activation). + // + // Settlement-before-resume is what makes CR 605.4a hold at a durable-ledger + // root: this frame's accepted triggered mana must be spendable BY the thing + // it resumes into (the automatic payment finalizer, the unless-payment + // poll, the pay-to-end permission), exactly as it already is at the colour + // and `TapLandForMana` roots. It is also the only ordering at which + // `pending.resume` still exists, so the frame can name its own root in + // `ManaTriggerFixedPointResume::Root` for an accepted pause instead of the + // `Parent` variant, which is factually wrong for a parentless root. + if settles_completed_frame { + debug_assert!(cursor.parent.is_none()); + if let Some(pause) = settle_mana_ability_cost_events( state, - cursor.deferred_cost_events, + std::mem::take(&mut cursor.deferred_cost_events), events, cost_event_start, - ); - return Ok( - super::triggers::preserve_order_triggers_resume(state, resume.clone()) - .unwrap_or(resume), - ); + ManaTriggerFixedPointResume::Root { + player: pending.player, + resume: Box::new(pending.resume.clone()), + }, + ) { + return Ok(pause); + } } - // CR 603.2 + CR 603.3b + CR 605.3b: A direct (non-paused) mana-ability - // action normally reaches the ordinary post-action trigger pipeline, which - // remains its settlement authority. But that pipeline only runs for a - // `Priority` resume (it is guarded by `waiting_for == Priority`). A mana - // ability activated during mana payment or an unless-cost payment resumes - // to `ManaPayment`/`UnlessPayment`, which the pipeline skips — so its - // already-paid cost events (sacrifice, discard, exile) would never be - // scanned and every observer would be dropped (Scavenger's Talent, Korvold, - // Mayhem Devil, ...; #5963). Scan them here, the single settlement - // authority, exactly as the `ChooseManaColor` branch above already does for - // an interrupted mana-color choice. A `Priority` resume stays EXCLUDED so - // the pipeline remains the sole scan site and nothing double-fires (e.g. - // Kilo's becomes-tapped proliferate under a standalone Relic activation). - if !matches!(resume, WaitingFor::Priority { .. }) && events.len() > cost_event_start { - let cost_events = events[cost_event_start..].to_vec(); - super::triggers::process_triggers(state, &cost_events); + let resume = resume_mana_ability_root(state, pending.player, pending.resume, events)?; + if super::casting::mana_ability_cost_payment_is_paused(state) { + debug_assert!(is_ultimate_root); + // A settled frame has nothing left to hand upward: its ledger was taken + // and its live occurrences are already journaled, so re-deferring them + // into the next root would let that root's durable segment — which the + // journal does NOT filter — collect them a second time. + if !settles_completed_frame { + defer_cost_events_into_active_mana_root( + state, + cursor.deferred_cost_events, + &events[cost_event_start..], + ); + } + return Ok(resume); + } + + if settles_completed_frame { return Ok( super::triggers::preserve_order_triggers_resume(state, resume.clone()) .unwrap_or(resume), @@ -2897,33 +3073,72 @@ fn finish_mana_ability_cost_payment( /// pause. Deferred events were never scanned by their initiating action; current /// events are scanned here even when the nominal resume is Priority. fn settle_mana_ability_cost_events( + state: &mut GameState, + deferred: Vec, + events: &mut Vec, + current_start: usize, + outer_resume: ManaTriggerFixedPointResume, +) -> Option { + debug_assert!( + !matches!(outer_resume, ManaTriggerFixedPointResume::Parent), + "a parentless root must name its own resume, never the Parent variant" + ); + collect_completed_mana_frame_events(state, deferred, events, current_start, outer_resume) +} + +/// CR 603.2 + CR 603.3b + CR 605.4a: prepare the exact durable-plus-unconsumed-live +/// batch for one completed mana frame and drive it through the single +/// classifier/dispatcher fixed point. +/// +/// Used for **every** completed mana frame, whether or not its durable ledger is +/// empty, so the no-ledger direct/colour/`TapLandForMana` shapes stop falling +/// through to a separate aggregate scan. +/// +/// The historical inline output of the durable segment appears once in the +/// logical batch and once only as a public copy in the returned event vector; +/// the copies are excluded from the live logical segment while their live +/// occurrences are still journaled, so the durable tail and the copied public +/// tail are never concatenated into the trigger batch twice. +pub(crate) fn collect_completed_mana_frame_events( state: &mut GameState, mut deferred: Vec, events: &mut Vec, current_start: usize, -) { + outer_resume: ManaTriggerFixedPointResume, +) -> Option { let deferred_original_len = deferred.len(); super::triggers::resolve_tap_mana_triggers_inline(state, &mut deferred, 0); + let historical_copy_start = events.len(); events.extend(deferred[deferred_original_len..].iter().cloned()); + let historical_copy_end = events.len(); super::triggers::resolve_tap_mana_triggers_inline(state, events, current_start); - deferred.extend_from_slice(&events[current_start..]); - if !deferred.is_empty() { - super::triggers::process_triggers(state, &deferred); - } - // The typed cursor has already collected the events emitted by this action. - // Claim their exact occurrences through the normal post-action authority so - // a Priority resume cannot scan the same events a second time. - let occurrences = events - .iter() - .enumerate() - .skip(current_start) + + // One chronological logical batch: the durable segment exactly once, then + // every live event this frame owns that an earlier synchronous child or a + // completed resume prefix has not already claimed. + let consumed = state.consumed_before_priority_trigger_events.clone(); + let live_indices: Vec = (current_start..events.len()) + .filter(|index| !(historical_copy_start..historical_copy_end).contains(index)) + .filter(|index| { + let occurrence = super::triggers::trigger_event_occurrence(events, *index); + !consumed + .iter() + .any(|claimed| claimed.event == events[*index] && claimed.occurrence == occurrence) + }) + .collect(); + let mut batch = deferred; + batch.extend(live_indices.iter().map(|index| events[*index].clone())); + + let live_end = events.len(); + let pause = super::triggers::collect_mana_action_trigger_batch(state, &batch, outer_resume); + + // Only after the combined collection result is durably owned: claim every + // exact live occurrence this frame is responsible for, by full-action index. + let occurrences = (current_start..live_end) .map( - |(index, event)| crate::game::triggers::ConsumedTriggerEventOccurrence { - event: event.clone(), - occurrence: events[..index] - .iter() - .filter(|prior| *prior == event) - .count(), + |index| crate::game::triggers::ConsumedTriggerEventOccurrence { + event: events[index].clone(), + occurrence: super::triggers::trigger_event_occurrence(events, index), }, ) .collect(); @@ -2936,6 +3151,7 @@ fn settle_mana_ability_cost_events( .expect( "mana-ability cost-settlement consumed-before-priority trigger journal cause must be live", ); + pause } /// CR 603.2 + CR 603.3b: A parent payment can immediately pause again after @@ -2960,8 +3176,55 @@ fn defer_cost_events_into_active_mana_root( cursor .deferred_cost_events .extend_from_slice(¤t[..inherited_current_len]); + cursor.current_action_deferred_start = cursor.deferred_cost_events.len(); cursor.deferred_cost_events.extend(local_events); - cursor.current_action_deferred_start = 0; +} + +/// CR 605.4a: convert one completed fixed point's typed outer continuation back +/// into the suspended mana frame's own wait, exactly once. +/// +/// This is the resumed-occurrence counterpart of the tails +/// `finish_mana_ability_cost_payment` and `handle_choose_mana_color` run +/// synchronously, and it mirrors them exactly — including the paused-payment +/// early exit and the `preserve_order_triggers_resume` wrap. It never re-defers +/// a cost-event ledger: the frame that installed this continuation already took +/// its ledger and journaled its live occurrences before pausing. +pub(crate) fn resume_settled_mana_frame( + state: &mut GameState, + outer_resume: ManaTriggerFixedPointResume, + events: &mut Vec, +) -> Result, EngineError> { + match outer_resume { + // A nested child frame's suspended parent cursor is the authority; no + // wait is reconstructed here. + ManaTriggerFixedPointResume::Parent => Ok(None), + ManaTriggerFixedPointResume::Root { player, resume } => { + let resumed = resume_mana_ability_root(state, player, *resume, events)?; + if super::casting::mana_ability_cost_payment_is_paused(state) { + return Ok(Some(resumed)); + } + Ok(Some( + super::triggers::preserve_order_triggers_resume(state, resumed.clone()) + .unwrap_or(resumed), + )) + } + ManaTriggerFixedPointResume::ColorChoice(choice) => { + let ManaColorChoiceResume { + player, + choice, + context, + } = *choice; + let resume = WaitingFor::ChooseManaColor { + player, + choice, + context, + }; + Ok(Some( + super::triggers::preserve_order_triggers_resume(state, resume.clone()) + .unwrap_or(resume), + )) + } + } } pub(crate) fn resume_mana_ability_root( @@ -4520,6 +4783,107 @@ mod tests { use crate::game::test_fixtures::mana_fixture_roles; + /// **CR 605.4a — the acceptance decision is THREADED, not re-derived.** + /// + /// `is_triggered_mana_ability` answers CR 605.1b about a + /// *classification-time* graph, and it is deliberately raw: a target + /// anywhere makes it false. `is_resolving_triggered_mana` answers a + /// different question at a different time — "is the occurrence currently + /// executing an accepted triggered mana ability?" — because + /// `resolve_ability_chain` may materialize an engine resolution-context + /// referent into the overloaded `targets` vector partway through the very + /// resolution whose status is being asked about. CR 605.4a says that + /// occurrence stays stackless; re-asking the raw predicate would say + /// otherwise and hand the body to the ordinary prompt path. + /// + /// Rows: + /// + /// * **(a) no marker, qualifying graph** ⇒ true, delegated; + /// * **(b) no marker, targeted graph** ⇒ false, delegated. (a)+(b) are the + /// two-sided reach guard that the delegation is real rather than a + /// constant; + /// * **(c) no marker, wrong firing event** ⇒ false, so the ambient + /// `current_trigger_event` is genuinely consulted; + /// * **(d) marker live, targeted graph** ⇒ **true** — the delta. This is + /// exactly the clone shape (b) rejects, so the two rows differ only by + /// the marker; + /// * **(e) marker cleared again** ⇒ (b)'s answer returns, proving the + /// marker is scope-shaped rather than sticky. + /// + /// REVERT-PROBE: delete the marker short-circuit so the helper always + /// delegates ⇒ (d) flips to false while (a), (b), (c) and (e) still pass. + /// The inverse probe — returning `true` whenever the marker is `None` — is + /// caught by (b) and (c). + #[test] + fn the_accepted_occurrence_marker_is_the_resolution_time_classification_authority() { + use crate::types::ability::{ManaProduction, QuantityExpr}; + use crate::types::resolved_commands::{RulesExecutionNodeRef, SettlementNodeOrdinal}; + + let mana_effect = || Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }; + let untargeted = ResolvedAbility::new(mana_effect(), vec![], ObjectId(1), PlayerId(0)); + // The post-injection clone: identical body, but the resolver has + // written a context referent into the overloaded `targets` vector. + let injected = ResolvedAbility::new( + mana_effect(), + vec![crate::types::ability::TargetRef::Player(PlayerId(1))], + ObjectId(1), + PlayerId(0), + ); + + let mut state = GameState::new_two_player(42); + state.current_trigger_event = Some(GameEvent::ManaAdded { + player_id: PlayerId(0), + mana_type: ManaType::Colorless, + source_id: ObjectId(1), + tap_state: ManaTapState::default(), + }); + + // (a) + (b): both truth values are reachable through delegation. + assert!( + is_resolving_triggered_mana(&state, &untargeted), + "(a) with no accepted occurrence live this must BE the raw classifier" + ); + assert!( + !is_resolving_triggered_mana(&state, &injected), + "(b) CR 605.1b at classification time: a target in the graph rejects" + ); + + // (c) the ambient firing event is genuinely part of the delegation. + let restore_event = state.current_trigger_event.take(); + assert!( + !is_resolving_triggered_mana(&state, &untargeted), + "(c) CR 605.1b(b): no qualifying firing event, no triggered mana ability" + ); + state.current_trigger_event = restore_event; + + // (d) THE DELTA: inside an accepted occurrence the same rejected clone + // is still the accepted occurrence's own body. + let node = RulesExecutionNodeRef::TriggeredMana(SettlementNodeOrdinal(1)); + state.active_rules_execution_node = Some(node); + state.active_accepted_triggered_mana_node = Some(node); + assert!( + is_resolving_triggered_mana(&state, &injected), + "(d) CR 605.4a: a resolution-context referent injected AFTER acceptance \ + does not make an already-accepted occurrence begin using the stack" + ); + + // (e) the marker is scope-shaped: restoring it restores (b)'s answer. + state.active_accepted_triggered_mana_node = None; + state.active_rules_execution_node = None; + assert!( + !is_resolving_triggered_mana(&state, &injected), + "(e) outside the occurrence the raw classification-time answer returns" + ); + } + /// Matrix rows 15c + 20 — CR 605.1a classification is unchanged. This reader /// also bypasses `Effect::target_filter()`. /// @@ -4662,6 +5026,67 @@ mod tests { ); } + #[test] + fn targetless_mana_reflexive_produces_mana_now_and_waits_on_stack() { + let mut state = GameState::new_two_player(42); + let player = PlayerId(0); + state.active_player = player; + state.priority_player = player; + state.waiting_for = WaitingFor::Priority { player }; + let source = create_object( + &mut state, + CardId(9901), + player, + "Rubble Rouser fixture".to_string(), + Zone::Battlefield, + ); + let mut reflexive = AbilityDefinition::new( + AbilityKind::Database, + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 2 }, + target: None, + }, + ); + reflexive.condition = Some(AbilityCondition::WhenYouDo); + reflexive.player_scope = Some(PlayerFilter::Opponent); + let mana = make_mana_ability(ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }) + .sub_ability(reflexive); + Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities).push(mana); + let opponent_life = state.players[1].life; + + crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }, + ) + .expect("activate targetless reflexive mana ability"); + + assert_eq!(state.players[0].mana_pool.count_color(ManaType::Green), 1); + assert_eq!(state.players[1].life, opponent_life); + assert_eq!(state.stack.len(), 1); + assert!(matches!( + state.stack[0].kind, + crate::types::game_state::StackEntryKind::TriggeredAbility { .. } + )); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); + + let mut safety = 4; + while !state.stack.is_empty() && safety > 0 { + crate::game::engine::apply_as_current( + &mut state, + crate::types::actions::GameAction::PassPriority, + ) + .expect("pass priority to resolve reflexive"); + safety -= 1; + } + assert_eq!(state.players[1].life, opponent_life - 2); + } + #[test] fn scoped_mana_ability_tap_event_aggregates_recipient_dependent_production() { let mut state = GameState::new(crate::types::format::FormatConfig::standard(), 3, 42); @@ -13339,4 +13764,253 @@ mod tests { // selection: the gate returns None even though the sole detector matched it. assert!(discard_cost_choice(&state, PlayerId(0), source, &Some(random_leg)).is_none()); } + + fn ledger_event(source_id: u64) -> GameEvent { + GameEvent::EffectResolved { + kind: crate::types::ability::EffectKind::NoOp, + source_id: ObjectId(source_id), + subject: None, + } + } + + #[test] + fn nested_mana_cursor_starts_with_empty_frame_local_ledger() { + let mut parent_cursor = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + None, + ); + parent_cursor.deferred_cost_events.push(ledger_event(1)); + let parent = ManaAbilityCostParent { + pending: Box::new(pending_for(ObjectId(10))), + cursor: Box::new(parent_cursor), + lifecycle: ManaAbilityCostParentLifecycle::Synchronous, + current_action_event_start: 0, + }; + + let child = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + Some(&parent), + ); + + assert!(child.deferred_cost_events.is_empty()); + assert_eq!( + child + .parent + .as_deref() + .expect("child must retain its parent snapshot") + .cursor + .deferred_cost_events, + [ledger_event(1)] + ); + } + + #[test] + fn suspended_child_ledger_extends_parent_without_loss_or_overwrite() { + let mut parent = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + None, + ); + parent.deferred_cost_events = vec![ledger_event(1), ledger_event(2)]; + let mut child = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + None, + ); + child.deferred_cost_events = vec![ledger_event(3)]; + + append_suspended_child_cost_events(&mut parent, &mut child, &[ledger_event(4)]); + + assert_eq!( + parent.deferred_cost_events, + [ + ledger_event(1), + ledger_event(2), + ledger_event(3), + ledger_event(4) + ] + ); + assert!(child.deferred_cost_events.is_empty()); + } + + #[test] + fn repeated_pause_transfer_preserves_local_suffix_boundary() { + let mut active_cursor = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + None, + ); + active_cursor.deferred_cost_events = vec![ledger_event(5), ledger_event(8)]; + active_cursor.current_action_deferred_start = 1; + let mut state = GameState::new_two_player(42); + state.pending_cost_move_resume = Some(PendingCostMoveResume::ManaAbilityPayment { + pending: Box::new(pending_for(ObjectId(10))), + cursor: active_cursor, + }); + + defer_cost_events_into_active_mana_root( + &mut state, + vec![ledger_event(1), ledger_event(2)], + &[ledger_event(7), ledger_event(8)], + ); + + let Some(PendingCostMoveResume::ManaAbilityPayment { cursor, .. }) = + state.pending_cost_move_resume.as_ref() + else { + panic!("expected active mana root"); + }; + assert_eq!( + cursor.deferred_cost_events, + [ + ledger_event(5), + ledger_event(1), + ledger_event(2), + ledger_event(7), + ledger_event(8) + ] + ); + assert_eq!(cursor.current_action_deferred_start, 4); + } + + fn synchronous_parent_at(start: usize, ledger: Vec) -> ManaAbilityCostParent { + let mut cursor = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + None, + ); + cursor.deferred_cost_events = ledger; + ManaAbilityCostParent { + pending: Box::new(pending_for(ObjectId(10))), + cursor: Box::new(cursor), + lifecycle: ManaAbilityCostParentLifecycle::Synchronous, + current_action_event_start: start, + } + } + + /// CR 603.2 + CR 603.3b: The prepared child-facing snapshot owns the live + /// parent frame's current unscanned suffix, while the live parent cursor is + /// never mutated. A synchronous child that drops the snapshot therefore + /// leaves exactly one representation of those events — the reducer vector. + #[test] + fn prepared_parent_snapshot_carries_current_suffix_without_mutating_live_parent() { + let parent = synchronous_parent_at(0, Vec::new()); + let events = vec![ledger_event(1), ledger_event(2)]; + + let prepared = parent_snapshot_with_current_cost_events(&parent, &events); + + assert_eq!( + prepared.cursor.deferred_cost_events, + [ledger_event(1), ledger_event(2)], + "prepared snapshot must carry the parent's current unscanned suffix" + ); + assert!( + parent.cursor.deferred_cost_events.is_empty(), + "the live parent cursor must not be mutated by preparation" + ); + + let child = mana_ability_cost_cursor( + &None, + &HashSet::new(), + None, + ManaAbilityCostResolutionMode::AutoResolved, + Some(&prepared), + ); + assert!( + child.deferred_cost_events.is_empty(), + "a nested child's top-level ledger starts empty" + ); + assert_eq!( + child + .parent + .as_deref() + .expect("child retains its prepared parent") + .cursor + .deferred_cost_events, + [ledger_event(1), ledger_event(2)] + ); + } + + /// CR 603.2 + CR 603.3b: Preparation runs at every child entry from the + /// unchanged ephemeral parent, so a later child sees the parent's own prefix + /// plus every earlier synchronously completed sibling exactly once. Extending + /// a previously prepared snapshot instead would double-append the prefix. + #[test] + fn later_child_snapshot_includes_earlier_synchronous_sibling_events_once() { + let parent = synchronous_parent_at(0, Vec::new()); + let mut events = vec![ledger_event(1)]; + + let first = parent_snapshot_with_current_cost_events(&parent, &events); + assert_eq!(first.cursor.deferred_cost_events, [ledger_event(1)]); + + // The first child completed synchronously and emitted its own event. + events.push(ledger_event(2)); + let second = parent_snapshot_with_current_cost_events(&parent, &events); + + assert_eq!( + second.cursor.deferred_cost_events, + [ledger_event(1), ledger_event(2)], + "a later child prepares from the unchanged ephemeral parent, not from an earlier snapshot" + ); + } + + /// CR 603.2 + CR 603.3b: The parent frame's own `cost_event_start` is the + /// marker, so a parent that already scanned an earlier prefix contributes + /// only its unscanned suffix to the child snapshot. + #[test] + fn prepared_parent_snapshot_starts_at_the_parent_frame_marker() { + let parent = synchronous_parent_at(1, Vec::new()); + let events = vec![ledger_event(1), ledger_event(2), ledger_event(3)]; + + let prepared = parent_snapshot_with_current_cost_events(&parent, &events); + + assert_eq!( + prepared.cursor.deferred_cost_events, + [ledger_event(2), ledger_event(3)] + ); + } + + /// CR 603.2 + CR 603.3b: A pause makes the prepared prefix durable, so the + /// ephemeral marker is never consulted again and is deliberately not + /// serialized. Round-tripping a suspended parent keeps the prefix while the + /// marker resets to its default. + #[test] + fn suspended_parent_prefix_survives_serde_while_marker_is_skipped() { + let mut parent = synchronous_parent_at(0, Vec::new()); + parent = parent_snapshot_with_current_cost_events(&parent, &[ledger_event(1)]); + parent.lifecycle = ManaAbilityCostParentLifecycle::Suspended; + parent.current_action_event_start = 7; + + let json = serde_json::to_string(&parent).expect("serialize suspended parent"); + assert!( + !json.contains("current_action_event_start"), + "the ephemeral marker must not be serialized: {json}" + ); + let restored: ManaAbilityCostParent = + serde_json::from_str(&json).expect("deserialize suspended parent"); + + assert_eq!( + restored.cursor.deferred_cost_events, + [ledger_event(1)], + "the durable prefix must survive suspension" + ); + assert_eq!(restored.current_action_event_start, 0); + assert!(matches!( + restored.lifecycle, + ManaAbilityCostParentLifecycle::Suspended + )); + } } diff --git a/crates/engine/src/game/resolution_prompt.rs b/crates/engine/src/game/resolution_prompt.rs index f2322ffed8..27ab6c09e9 100644 --- a/crates/engine/src/game/resolution_prompt.rs +++ b/crates/engine/src/game/resolution_prompt.rs @@ -554,6 +554,7 @@ pub(crate) fn chain_offers_choice(a: &ResolvedAbility) -> bool { multi_target: _, // announce-time variable-count bounds (Resolution case caught by timing) target_constraints: _, // announce-time cross-target legality, no resolution prompt distribution: _, // CR 601.2d concrete pre-assigned portions (announce-time) + distribute: _, // CR 601.2d/603.3d unassigned division is an announce-time choice targets: _, // concrete announced target refs (already resolved) source_id: _, // object id source_incarnation: _, // self-transform epoch latch, no resolution-time choice @@ -715,6 +716,40 @@ mod tests { ProbeBudget::for_test(PROBE_BUDGET) } + /// CR 601.2d + CR 603.3d: an unassigned division UNIT is announcement metadata. + /// The division itself is answered while the object is announced (the trigger's + /// `DistributeAmong` prompt), never during resolution, so toggling only + /// `distribute` may not move the resolution-choice verdict. + /// + /// The base ability must be an ALLOW-LISTED choice-free effect with a fixed + /// quantity. `Effect::NoOp` is NOT one: `effect_offers_choice` fail-closes every + /// unclassified variant to `true`, so a `NoOp` base reports `MayPrompt` before + /// `distribute` is even read and the row would pass for the wrong reason in the + /// negative direction and fail outright in the positive one. + #[test] + fn unassigned_distribution_unit_is_not_a_resolution_choice() { + let base = ResolvedAbility::new( + Effect::DealDamage { + amount: fixed(3), + target: TargetFilter::Typed(crate::types::ability::TypedFilter::creature()), + damage_source: None, + excess: None, + }, + Vec::new(), + ObjectId(1), + PlayerId(0), + ); + assert!( + !chain_offers_choice(&base), + "reach guard: the undivided base must already be choice-free, otherwise the \ + divided clone below proves nothing" + ); + + let mut divided = base.clone(); + divided.distribute = Some(crate::types::game_state::DistributionUnit::Damage); + assert!(!chain_offers_choice(÷d)); + } + /// Snapshot every axis the witness reads. fn board_axes(state: &GameState) -> BoardAxes { BoardAxes { diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2b0712bd03..bd58e67dce 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -936,59 +936,104 @@ pub(crate) fn bind_resolution_scope( entry: &StackEntry, trigger_event_batch: Option>, ) -> bool { - // CR 603.4: Intervening-if condition rechecked at resolution time. - if let StackEntryKind::TriggeredAbility { - condition: Some(ref condition), - source_id: _, - ref trigger_event, - .. - } = &entry.kind - { - let trigger_source = entry - .ability() - .and_then(|ability| ability.trigger_source.as_ref()); - if !super::triggers::check_trigger_condition_with_source( - state, + let triggered = match &entry.kind { + StackEntryKind::TriggeredAbility { condition, - entry.controller, - trigger_source, - trigger_event.as_ref(), - ) { - return false; + trigger_event, + subject_match_count, + die_result, + .. + } => Some(TriggeredResolutionScope { + condition: condition.as_ref(), + controller: entry.controller, + trigger_source: entry + .ability() + .and_then(|ability| ability.trigger_source.as_ref()), + trigger_event: trigger_event.as_ref(), + subject_match_count: *subject_match_count, + die_result: *die_result, + }), + _ => None, + }; + bind_triggered_resolution_scope(state, triggered, trigger_event_batch) +} + +/// The facts a triggered ability contributes to its own resolution scope, +/// lifted out of `StackEntryKind::TriggeredAbility` so a resolution that owns +/// no stack entry can bind exactly the same scope. +/// +/// CR 605.4a triggered mana abilities are the motivating second consumer: they +/// resolve without ever creating a stack object, yet they still need the +/// CR 603.4 recheck, the CR 608.2k event context, the CR 603.2c subject count, +/// and the CR 706.2 die-roll re-stamp to be bound in exactly the order and with +/// exactly the semantics stack resolution uses. Reimplementing that binding +/// beside the immediate dispatcher would be a second authority for CR 603.4. +pub(crate) struct TriggeredResolutionScope<'a> { + pub condition: Option<&'a TriggerCondition>, + pub controller: PlayerId, + pub trigger_source: Option<&'a TriggerSourceContext>, + pub trigger_event: Option<&'a GameEvent>, + pub subject_match_count: Option, + pub die_result: Option, +} + +/// The decision-and-binding half of [`bind_resolution_scope`], with no stack +/// entry in sight. Returns `false` exactly when the CR 603.4 intervening-if +/// recheck fails, in which case **nothing** has been bound — the caller must +/// abandon the resolution without applying any effect. +/// +/// `triggered` is `None` for a non-triggered resolution (a spell, an activated +/// ability, a keyword action); such a scope has no condition, no subject count, +/// and no die result, and reaches only the batch branch below. +pub(crate) fn bind_triggered_resolution_scope( + state: &mut GameState, + triggered: Option>, + trigger_event_batch: Option>, +) -> bool { + // CR 603.4: Intervening-if condition rechecked at resolution time. + if let Some(scope) = &triggered { + if let Some(condition) = scope.condition { + if !super::triggers::check_trigger_condition_with_source( + state, + condition, + scope.controller, + scope.trigger_source, + scope.trigger_event, + ) { + return false; + } } } // CR 608.2k: Set trigger event context for event-context target resolution. // TriggeringSpellController, TriggeringSource, etc. read this during resolution. - if let StackEntryKind::TriggeredAbility { - trigger_event: Some(ref te), - .. - } = entry.kind - { - state.current_trigger_event = Some(te.clone()); - state.current_trigger_events = trigger_event_batch.unwrap_or_else(|| vec![te.clone()]); - } else if let Some(trigger_events) = trigger_event_batch { - state.current_trigger_event = trigger_events.first().cloned(); - state.current_trigger_events = trigger_events; + match ( + triggered.as_ref().and_then(|scope| scope.trigger_event), + trigger_event_batch, + ) { + (Some(te), batch) => { + state.current_trigger_event = Some(te.clone()); + state.current_trigger_events = batch.unwrap_or_else(|| vec![te.clone()]); + } + (None, Some(trigger_events)) => { + state.current_trigger_event = trigger_events.first().cloned(); + state.current_trigger_events = trigger_events; + } + (None, None) => {} } // CR 603.2c: Lift the filtered subject count of a batched trigger into // resolution scope so `QuantityRef::EventContextAmount` resolves "that // many" against the count, not against zero. Set in lockstep with // `current_trigger_event` and cleared at every reset site below. - if let StackEntryKind::TriggeredAbility { - subject_match_count, - die_result, - .. - } = entry.kind - { - state.current_trigger_match_count = subject_match_count; + if let Some(scope) = &triggered { + state.current_trigger_match_count = scope.subject_match_count; // CR 706.2 + CR 706.4 + CR 603.12: re-stamp the carried die-roll result // into resolution scope so a reflexive "When you do … the result" // sub-ability resolving on its own stack entry (a later apply(), after // the original roll's resolution scope cleared) reads the rolled value // via the `QuantityRef::EventContextAmount` cascade. - state.die_result_this_resolution = die_result; + state.die_result_this_resolution = scope.die_result; } true @@ -3103,6 +3148,7 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { forward_result, unless_pay, distribution, + distribute, player_scope, starting_with, chosen_x, @@ -3166,6 +3212,7 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { && !*forward_result && unless_pay.is_none() && distribution.is_none() + && distribute.is_none() && player_scope.is_none() && starting_with.is_none() && chosen_x.is_none() @@ -3318,6 +3365,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili forward_result, unless_pay, distribution, + distribute, player_scope, starting_with, chosen_x, @@ -3373,6 +3421,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili && !*forward_result && unless_pay.is_none() && distribution.is_none() + && distribute.is_none() && player_scope.is_none() && starting_with.is_none() && chosen_x.is_none() @@ -3513,6 +3562,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility forward_result, unless_pay, distribution, + distribute, player_scope, starting_with, chosen_x, @@ -3568,6 +3618,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility && !*forward_result && unless_pay.is_none() && distribution.is_none() + && distribute.is_none() && *player_scope == Some(PlayerFilter::Opponent) && starting_with.is_none() && chosen_x.is_none() @@ -4155,6 +4206,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( forward_result: a_forward_result, unless_pay: a_unless_pay, distribution: a_distribution, + distribute: a_distribute, player_scope: a_player_scope, starting_with: a_starting_with, chosen_x: a_chosen_x, @@ -4212,6 +4264,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( forward_result: b_forward_result, unless_pay: b_unless_pay, distribution: b_distribution, + distribute: b_distribute, player_scope: b_player_scope, starting_with: b_starting_with, chosen_x: b_chosen_x, @@ -4280,6 +4333,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( && a_forward_result == b_forward_result && a_unless_pay == b_unless_pay && a_distribution == b_distribution + && a_distribute == b_distribute && a_player_scope == b_player_scope && a_starting_with == b_starting_with && a_chosen_x == b_chosen_x @@ -4711,6 +4765,83 @@ mod tests { GameState::new_two_player(42) } + #[test] + fn unassigned_distribution_rejects_all_inert_batch_candidates() { + let self_counter = ResolvedAbility::new( + Effect::PutCounter { + counter_type: crate::types::counter::CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::SelfRef, + }, + Vec::new(), + ObjectId(1), + PlayerId(0), + ); + assert!(self_counter_ability_is_batch_candidate(&self_counter)); + let mut divided_counter = self_counter.clone(); + divided_counter.distribute = Some(crate::types::game_state::DistributionUnit::Counters( + "+1/+1".to_string(), + )); + assert!(!self_counter_ability_is_batch_candidate(÷d_counter)); + + let gain_life = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 2 }, + player: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(2), + PlayerId(0), + ); + assert!(fixed_controller_gain_life_ability_is_batch_candidate( + &gain_life + )); + let mut divided_gain = gain_life.clone(); + divided_gain.distribute = Some(crate::types::game_state::DistributionUnit::Life); + assert!(!fixed_controller_gain_life_ability_is_batch_candidate( + ÷d_gain + )); + + let mut lose_life = ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 2 }, + target: None, + }, + Vec::new(), + ObjectId(3), + PlayerId(0), + ); + lose_life.player_scope = Some(crate::types::ability::PlayerFilter::Opponent); + assert!(fixed_opponent_lose_life_ability_is_batch_candidate( + &lose_life + )); + let mut divided_loss = lose_life.clone(); + divided_loss.distribute = Some(crate::types::game_state::DistributionUnit::Life); + assert!(!fixed_opponent_lose_life_ability_is_batch_candidate( + ÷d_loss + )); + } + + #[test] + fn inert_trigger_identity_compares_unassigned_distribution_unit() { + let mut a = ResolvedAbility::new(Effect::NoOp, Vec::new(), ObjectId(10), PlayerId(0)); + a.distribute = Some(crate::types::game_state::DistributionUnit::Damage); + let mut same_shape_different_provenance = a.clone(); + same_shape_different_provenance.source_id = ObjectId(11); + same_shape_different_provenance.ability_index = Some(7); + + assert!(inert_trigger_abilities_eq_ignoring_provenance( + &a, + &same_shape_different_provenance + )); + + same_shape_different_provenance.distribute = None; + assert!(!inert_trigger_abilities_eq_ignoring_provenance( + &a, + &same_shape_different_provenance + )); + } + fn pending_spell_entry(id: ObjectId) -> StackEntry { StackEntry { id, @@ -13583,6 +13714,274 @@ mod tests { } } + /// **The shared CR 603.4 / CR 608.2k / CR 603.2c / CR 706.2 binder is + /// entry-shaped only at its adapter.** + /// + /// `bind_triggered_resolution_scope` is the authority a stackless CR 605.4a + /// triggered mana resolution will call — it owns no `StackEntry` and must + /// therefore reach every binding shape the entry-shaped wrapper reaches. + /// The delicate part is that baseline's three `if let` blocks are **not** + /// three independent branches: the event/batch block is an `if / else if` + /// chain, so a triggered ability carrying `trigger_event: None` and a batch + /// falls THROUGH to the non-triggered batch arm. A rewrite that keys the + /// batch arm on "not a triggered ability" silently loses that case. + /// + /// Rows, each keyed to one thing the extraction could have broken: + /// + /// * **(a)** triggered + `Some(event)` + no batch ⇒ the batch is + /// synthesized as a one-element vector from that event; + /// * **(b)** triggered + `Some(event)` + a batch ⇒ the batch wins and the + /// singleton event stays the authoritative one; + /// * **(c)** triggered + `None` event + a batch ⇒ the fall-through arm, so + /// the authoritative event is the batch head; + /// * **(d)** NOT triggered + a batch ⇒ same arm, but CR 603.2c/CR 706.2 + /// must NOT be re-stamped, because a spell carries neither; + /// * **(e)** a false CR 603.4 intervening-if returns `false` having bound + /// **nothing** — the caller must be able to abandon without unwinding; + /// * **(f)** the entry-shaped adapter and a hand-built scope produce + /// byte-identical bindings from the same facts. + /// + /// REACH-GUARD: every row asserts the pre-call scope is the sentinel value, + /// so "unchanged" can never be confused with "bound to the same thing". + /// + /// REVERT-PROBES: making the batch arm `else if triggered.is_none()` fails + /// (c); dropping the `(Some(te), batch)` arm's `unwrap_or_else` fails (a); + /// stamping the count/die outside the `triggered` guard fails (d); binding + /// before the condition check fails (e). + #[test] + fn the_shared_resolution_scope_binder_reaches_every_baseline_binding_shape() { + let event_a = GameEvent::StackResolved { + object_id: ObjectId(9001), + }; + let event_b = GameEvent::StackResolved { + object_id: ObjectId(9002), + }; + let sentinel = GameEvent::StackResolved { + object_id: ObjectId(9999), + }; + + // Every row starts from a distinguishable sentinel scope, so a row that + // asserts a bound value cannot pass because nothing ran. + let armed = || { + let mut state = setup(); + state.current_trigger_event = Some(sentinel.clone()); + state.current_trigger_events = vec![sentinel.clone()]; + state.current_trigger_match_count = Some(77); + state.die_result_this_resolution = Some(77); + state + }; + // ── (a) triggered + event, no batch ⇒ synthesized singleton batch ── + { + let mut state = armed(); + assert!(bind_triggered_resolution_scope( + &mut state, + Some(TriggeredResolutionScope { + condition: None, + controller: PlayerId(0), + trigger_source: None, + trigger_event: Some(&event_a), + subject_match_count: Some(4), + die_result: Some(6), + }), + None, + )); + assert_eq!(state.current_trigger_event.as_ref(), Some(&event_a)); + assert_eq!( + state.current_trigger_events, + vec![event_a.clone()], + "CR 608.2k: with no batch the authoritative event IS the batch" + ); + assert_eq!(state.current_trigger_match_count, Some(4)); + assert_eq!(state.die_result_this_resolution, Some(6)); + } + + // ── (b) triggered + event + batch ⇒ the batch wins ── + { + let mut state = armed(); + assert!(bind_triggered_resolution_scope( + &mut state, + Some(TriggeredResolutionScope { + condition: None, + controller: PlayerId(0), + trigger_source: None, + trigger_event: Some(&event_a), + subject_match_count: None, + die_result: None, + }), + Some(vec![event_a.clone(), event_b.clone()]), + )); + assert_eq!(state.current_trigger_event.as_ref(), Some(&event_a)); + assert_eq!( + state.current_trigger_events, + vec![event_a.clone(), event_b.clone()] + ); + assert_eq!( + state.current_trigger_match_count, None, + "CR 603.2c: a triggered scope stamps its own None over the sentinel" + ); + } + + // ── (c) triggered + NO event + batch ⇒ the fall-through arm ── + { + let mut state = armed(); + assert!(bind_triggered_resolution_scope( + &mut state, + Some(TriggeredResolutionScope { + condition: None, + controller: PlayerId(0), + trigger_source: None, + trigger_event: None, + subject_match_count: Some(2), + die_result: None, + }), + Some(vec![event_b.clone(), event_a.clone()]), + )); + assert_eq!( + state.current_trigger_event.as_ref(), + Some(&event_b), + "the batch HEAD becomes authoritative — this is the `else if` \ + fall-through a triggered ability with no singleton event reaches" + ); + assert_eq!( + state.current_trigger_events, + vec![event_b.clone(), event_a.clone()] + ); + assert_eq!(state.current_trigger_match_count, Some(2)); + } + + // ── (d) not triggered + batch ⇒ count/die are NOT re-stamped ── + { + let mut state = armed(); + assert!(bind_triggered_resolution_scope( + &mut state, + None, + Some(vec![event_a.clone()]), + )); + assert_eq!(state.current_trigger_event.as_ref(), Some(&event_a)); + assert_eq!( + ( + state.current_trigger_match_count, + state.die_result_this_resolution + ), + (Some(77), Some(77)), + "CR 603.2c + CR 706.2 belong to a TRIGGERED entry only; a spell must \ + leave the ambient values exactly as it found them" + ); + } + + // ── (e) a false CR 603.4 recheck binds nothing at all ── + { + let mut state = armed(); + assert!(!bind_triggered_resolution_scope( + &mut state, + Some(TriggeredResolutionScope { + // `setup()` is a 20-life board, so this is FALSE. + condition: Some(&TriggerCondition::LifeTotalGE { minimum: 99 }), + controller: PlayerId(0), + trigger_source: None, + trigger_event: Some(&event_a), + subject_match_count: Some(4), + die_result: Some(6), + }), + Some(vec![event_a.clone(), event_b.clone()]), + )); + assert_eq!( + ( + state.current_trigger_event.as_ref(), + state.current_trigger_events.as_slice(), + state.current_trigger_match_count, + state.die_result_this_resolution, + ), + ( + Some(&sentinel), + [sentinel.clone()].as_slice(), + Some(77), + Some(77) + ), + "CR 603.4: the recheck is the FIRST thing the binder does, so a \ + refused resolution leaves the caller's scope untouched" + ); + // The TRUE twin proves the row is not passing on a broken condition. + let mut state = armed(); + assert!(bind_triggered_resolution_scope( + &mut state, + Some(TriggeredResolutionScope { + condition: Some(&TriggerCondition::LifeTotalGE { minimum: 5 }), + controller: PlayerId(0), + trigger_source: None, + trigger_event: Some(&event_a), + subject_match_count: Some(4), + die_result: Some(6), + }), + None, + )); + assert_eq!(state.current_trigger_match_count, Some(4)); + } + + // ── (f) the entry-shaped adapter agrees with the hand-built scope ── + { + let entry = StackEntry { + id: ObjectId(9100), + source_id: ObjectId(9101), + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: ObjectId(9101), + ability: Box::new(ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + vec![], + ObjectId(9101), + PlayerId(0), + )), + condition: Some(TriggerCondition::LifeTotalGE { minimum: 5 }), + trigger_event: Some(event_a.clone()), + description: None, + source_name: String::new(), + subject_match_count: Some(3), + die_result: Some(20), + provenance: None, + }, + }; + let mut via_entry = armed(); + assert!(bind_resolution_scope( + &mut via_entry, + &entry, + Some(vec![event_a.clone(), event_b.clone()]), + )); + let mut via_scope = armed(); + assert!(bind_triggered_resolution_scope( + &mut via_scope, + Some(TriggeredResolutionScope { + condition: Some(&TriggerCondition::LifeTotalGE { minimum: 5 }), + controller: PlayerId(0), + trigger_source: None, + trigger_event: Some(&event_a), + subject_match_count: Some(3), + die_result: Some(20), + }), + Some(vec![event_a.clone(), event_b.clone()]), + )); + assert_eq!( + ( + via_entry.current_trigger_event, + via_entry.current_trigger_events, + via_entry.current_trigger_match_count, + via_entry.die_result_this_resolution, + ), + ( + via_scope.current_trigger_event, + via_scope.current_trigger_events, + via_scope.current_trigger_match_count, + via_scope.die_result_this_resolution, + ), + "the adapter is a projection of the entry onto the shared scope, \ + not a second binding policy" + ); + } + } + // ----------------------------------------------------------------------- // C2: resolution-default moves route through the zone pipeline so Moved // graveyard→exile redirects (Rest in Peace / Leyline of the Void class) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index b6f9e5f61f..a7ba7da9dd 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -18,11 +18,12 @@ use crate::types::ability::{EffectScope, TapStateChange}; use crate::types::card_type::CoreType; use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ - AutoMayChoice, DamageRecord, DelayedTrigger, DistributionUnit, GameState, - LatchedBatchedTrigger, LatchedSuppressTrigger, LogicalZoneChangeGroup, - LogicalZoneChangeTerminalOutcome, MayTriggerAutoChoiceKey, MayTriggerOrigin, StackEntry, - StackEntryKind, SyntheticTriggerProvenance, TargetSelectionConstraint, TargetSelectionSlot, - TriggerObservationTime, TriggerSourceContext, WaitingFor, + AutoMayChoice, CollectedTriggerContextBatch, DamageRecord, DelayedTrigger, DistributionUnit, + GameState, LatchedBatchedTrigger, LatchedSuppressTrigger, LogicalZoneChangeGroup, + LogicalZoneChangeTerminalOutcome, MayTriggerAutoChoiceKey, MayTriggerOrigin, + ProductionOverride, StackEntry, StackEntryKind, SyntheticTriggerProvenance, + TargetSelectionConstraint, TargetSelectionSlot, TriggerObservationTime, TriggerSourceContext, + WaitingFor, }; use crate::types::identifiers::{ DelayedInstallIdentity, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, @@ -6888,22 +6889,28 @@ pub(crate) fn park_observer_triggers_if_paused( events: &[GameEvent], slice_start: usize, ) { + if triggered_mana_sidecar_owns_local_collection(state) { + return; + } if matches!(state.waiting_for, WaitingFor::Priority { .. }) { return; } - let trigger_events: Vec = events[slice_start..] - .iter() - .filter(|ev| { - // CR 707.10: `copy_spell` collects `SpellCopied` observers at - // announcement and drains them after CopyRetarget finalization. - // Re-parking the same event duplicates Magecraft (issue #2866). - !matches!( - ev, - GameEvent::PhaseChanged { .. } | GameEvent::SpellCopied { .. } - ) - }) - .cloned() - .collect(); + let trigger_events: Vec = filter_consumed_trigger_events_from( + events, + slice_start, + &state.consumed_before_priority_trigger_events, + ) + .into_iter() + .filter(|ev| { + // CR 707.10: `copy_spell` collects `SpellCopied` observers at + // announcement and drains them after CopyRetarget finalization. + // Re-parking the same event duplicates Magecraft (issue #2866). + !matches!( + ev, + GameEvent::PhaseChanged { .. } | GameEvent::SpellCopied { .. } + ) + }) + .collect(); if !trigger_events.is_empty() { collect_triggers_into_deferred(state, &trigger_events); } @@ -6918,20 +6925,26 @@ pub(crate) fn collect_and_drain_observer_triggers_if_settled( events: &mut Vec, slice_start: usize, ) { + if triggered_mana_sidecar_owns_local_collection(state) { + return; + } if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { park_observer_triggers_if_paused(state, events, slice_start); return; } - let trigger_events: Vec = events[slice_start..] - .iter() - .filter(|ev| { - !matches!( - ev, - GameEvent::PhaseChanged { .. } | GameEvent::SpellCopied { .. } - ) - }) - .cloned() - .collect(); + let trigger_events: Vec = filter_consumed_trigger_events_from( + events, + slice_start, + &state.consumed_before_priority_trigger_events, + ) + .into_iter() + .filter(|ev| { + !matches!( + ev, + GameEvent::PhaseChanged { .. } | GameEvent::SpellCopied { .. } + ) + }) + .collect(); if !trigger_events.is_empty() { collect_triggers_into_deferred(state, &trigger_events); } @@ -6940,8 +6953,46 @@ pub(crate) fn collect_and_drain_observer_triggers_if_settled( } } -/// CR 106.6 + CR 603.3b: Queue a synthetic cost-payment trigger for the same -/// post-announcement stack-placement path as event-collected cost triggers. +/// CR 605.4a: Does the classifier-accepted triggered-mana occurrence own local +/// event collection right now? +/// +/// The two authorities are deliberately independent and either alone is +/// sufficient. A scoped sidecar action may temporarily move the durable carrier +/// out of `GameState` while it delegates to an existing handler, and there the +/// live accepted-node marker is the only authority; at a resting pause the +/// durable carrier is present while the lexical marker has already been +/// restored by `with_rules_execution_node`. +/// +/// Both observer helpers fail closed on this predicate *before* collecting or +/// draining: they are collection and partial-release authorities, not readiness +/// probes, and the fixed point — not a helper — owns every emitted event of an +/// accepted occurrence. +/// +/// It stores no boolean and recognizes no `WaitingFor`, card name, trigger +/// mode, or event value. +fn triggered_mana_sidecar_owns_local_collection(state: &GameState) -> bool { + let sidecar_node = state + .pending_triggered_mana_resume + .as_ref() + .map(|resume| resume.rules_execution_node); + let marker = state.active_accepted_triggered_mana_node; + if let (Some(sidecar_node), Some(marker)) = (sidecar_node, marker) { + debug_assert_eq!( + sidecar_node, marker, + "the live accepted-occurrence marker must name the sidecar's current node" + ); + debug_assert_eq!( + state.active_rules_execution_node, + Some(marker), + "a live accepted-occurrence marker must equal the ambient rules-execution node" + ); + } + sidecar_node.is_some() || marker.is_some() +} + +/// CR 603.12 + CR 603.3b: Queue a synthetic reflexive or cost-payment trigger +/// for the same post-announcement stack-placement path as event-collected +/// triggers. This collector never drains or mutates trigger-construction state. pub(crate) fn defer_pending_trigger(state: &mut GameState, trigger: PendingTrigger) { state .deferred_triggers @@ -7608,6 +7659,191 @@ fn dispatch_pending_trigger_context_with_origin( } } +/// Where a dispatched trigger is placed once the common core has finished +/// modal legality, target preparation, event binding, and the CR 603.4 recheck. +/// +/// Private control flow, never a rules/data/wire enum: the two backends own +/// exactly one decision between them — "push and journal a stack entry" versus +/// "resolve without creating one". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TriggerPlacement { + /// The baseline CR 603.3 placement. Every existing caller uses this and its + /// behaviour is unchanged in every branch. + OrdinaryStack, + /// CR 605.4a stackless placement, reachable **only** for a context that + /// [`classify_pending_triggered_mana`] accepted. It never pushes an entry, + /// never writes `pending_trigger_entry` or a stack side table, and never + /// emits `StackPushed`. + TriggeredManaImmediate, +} + +/// The typed accepted view produced by [`classify_pending_triggered_mana`]. +/// +/// Deliberately not a bare boolean: acceptance carries the exact source +/// incarnation the one `RulesExecutionNodeKind::TriggeredMana` node must be +/// begun from, which the classifier already had to prove exists. +#[derive(Debug, Clone)] +pub(crate) struct AcceptedTriggeredMana { + pub context: PendingTriggerContext, + /// Baseline's exact source derivation: the trigger-source incarnation + /// stamped at construction, else the still-live object incarnation. + pub source: ObjectIncarnationRef, +} + +/// CR 605.1b partition of one complete `PendingTriggerContext`. +#[derive(Debug, Clone)] +pub(crate) enum PendingTriggeredManaClass { + /// Immediate stackless placement is proven safe for this exact context. + Accepted(Box), + /// Everything else. The complete context is preserved unchanged and goes to + /// the ordinary deferred batch. + Ordinary(Box), +} + +/// True iff any reachable `sub_ability` / `else_ability` link satisfies `pred`. +fn any_reachable_link(ability: &ResolvedAbility, pred: &dyn Fn(&ResolvedAbility) -> bool) -> bool { + if pred(ability) { + return true; + } + if let Some(sub) = ability.sub_ability.as_deref() { + if any_reachable_link(sub, pred) { + return true; + } + } + if let Some(else_branch) = ability.else_ability.as_deref() { + if any_reachable_link(else_branch, pred) { + return true; + } + } + false +} + +/// True iff any reachable link carries an `unless_pay` modifier +/// (CR 118.12 / CR 608.2c). +/// +/// Rejecting this structurally — rather than by card name or by trusting the +/// current Oracle census — is what makes `UnlessPayment` and every nested cost +/// prompt unreachable while the immediate path owns accepted work. +fn chain_has_any_unless_payment(ability: &ResolvedAbility) -> bool { + any_reachable_link(ability, &|link| link.unless_pay.is_some()) +} + +/// True iff any reachable link can raise a repeat interaction from inside +/// `effects::resolve_ability_chain` — either repeat form (`RepeatDecision` and +/// its iteration frames). +/// +/// `optional` (`OptionalEffectChoice`) and `optional_for` (`OpponentMayChoice`) +/// were rejected here until their owning handler family — `engine_payment_choices` +/// — gained the readiness hook; they are accepted now, and that widening owns a +/// real behaviour delta of its own. It does **not** ride on the modal rejection's +/// "baseline never inlines this anyway" argument: baseline `is_triggered_mana_ability` +/// returns true for an all-mana `optional` body, so baseline resolves it inline +/// today and leaves `waiting_for` set with no continuation. The accepted path now +/// carries that pause instead. +/// +/// The repeat forms stay rejected only because `engine_resolution_choices` has +/// no readiness hook yet: deleting them here without one installs a carrier +/// nothing can resume, which is the exact state this predicate exists to +/// prevent. +fn chain_can_pause_on_player_interaction(ability: &ResolvedAbility) -> bool { + any_reachable_link(ability, &|link| { + link.repeat_for.is_some() || link.repeat_until.is_some() + }) +} + +/// CR 605.1b + CR 605.4a: the single complete-context authority that decides +/// whether one collected `PendingTriggerContext` may take stackless immediate +/// placement. +/// +/// `mana_abilities::is_triggered_mana_ability` remains the exact and +/// authoritative acceptance-time gate and is called here, unmodified, on the +/// unmodified carried graph and firing event. This function never reimplements +/// that predicate, never clears a target to force a match, and never accepts a +/// context the predicate rejects — it only *narrows* further, on the retained +/// complete-context safety axes below. +/// +/// Modal contexts are rejected in this revision. That is not a behaviour +/// regression: baseline's modal branch in `dispatch_pending_trigger_context` +/// pushes the entry to the stack *before* mode selection, so it never reaches +/// baseline's inline classifier check at all — no modal trigger resolves inline +/// today either. A stackless modal path is genuinely new machinery and is +/// tracked separately. +pub(crate) fn classify_pending_triggered_mana( + state: &mut GameState, + context: PendingTriggerContext, +) -> PendingTriggeredManaClass { + let ordinary = + |context: PendingTriggerContext| PendingTriggeredManaClass::Ordinary(Box::new(context)); + let trigger = &context.pending; + + // Modal: rejected to ordinary stack announcement (see the doc comment). + if trigger.modal.is_some() { + return ordinary(context); + } + // CR 601.2d + CR 603.3d: announcement-time division and declared constraints + // belong to the stack announcement authority. + if !trigger.target_constraints.is_empty() + || trigger.distribute.is_some() + || trigger.ability.distribution.is_some() + { + return ordinary(context); + } + // Round-11 finding 2: reject `unless_pay` on every reachable link, so a live + // sidecar can never coincide with `UnlessPayment` or any nested cost prompt. + if chain_has_any_unless_payment(&trigger.ability) { + return ordinary(context); + } + // Pause-narrowing for this revision (see the predicate's doc comment). + if chain_can_pause_on_player_interaction(&trigger.ability) { + return ordinary(context); + } + // The exact baseline acceptance gate, on the unmodified carried graph and + // the carried authoritative firing event. + if !super::mana_abilities::is_triggered_mana_ability( + &trigger.ability, + trigger.trigger_event.as_ref(), + ) { + return ordinary(context); + } + // Definition-owned slot proof, under the pushed event/count context. This is + // deliberately stronger than inspecting `target_constraints`, `multi_target`, + // or `ability.targets` separately: it catches `Effect::Mana { target: Some(..) }` + // and every downstream definition slot even when all three are empty. + let snapshot = push_trigger_event_context( + state, + trigger.trigger_event.as_ref(), + &context.trigger_events, + trigger.subject_match_count, + ); + let slots_are_empty = matches!( + super::ability_utils::build_target_slots(state, &trigger.ability), + Ok(slots) if slots.is_empty() + ); + restore_trigger_event_context(state, snapshot); + if !slots_are_empty { + return ordinary(context); + } + // CR 605.4a: exactly one `TriggeredMana` node per accepted occurrence, begun + // from baseline's exact source derivation. A malformed synthetic/legacy + // context for which neither authority exists is rejected rather than given + // an ambient node. + let Some(source) = trigger + .ability + .trigger_source + .as_ref() + .map(|trigger_source| trigger_source.identity.reference) + .or_else(|| { + state + .objects + .get(&trigger.ability.source_id) + .map(ObjectIncarnationRef::from_object) + }) + else { + return ordinary(context); + }; + PendingTriggeredManaClass::Accepted(Box::new(AcceptedTriggeredMana { context, source })) +} + /// CR 113.2c + CR 603.2 + CR 603.3b: Drive a single collected trigger through /// its disposition. Returns a [`TriggerDispatchDisposition`] classifying the /// terminal outcome: [`Paused`](TriggerDispatchDisposition::Paused) when the @@ -7631,6 +7867,21 @@ fn dispatch_pending_trigger_context( trigger_context: PendingTriggerContext, events_out: &mut Vec, ) -> TriggerDispatchDisposition { + dispatch_pending_trigger_context_core( + state, + trigger_context, + TriggerPlacement::OrdinaryStack, + events_out, + ) +} + +fn dispatch_pending_trigger_context_core( + state: &mut GameState, + trigger_context: PendingTriggerContext, + placement: TriggerPlacement, + events_out: &mut Vec, +) -> TriggerDispatchDisposition { + let immediate = matches!(placement, TriggerPlacement::TriggeredManaImmediate); let PendingTriggerContext { pending: trigger, trigger_events, @@ -7653,6 +7904,16 @@ fn dispatch_pending_trigger_context( // exists on the stack. if let Some(modal_ref) = trigger.modal.as_ref() { if !trigger.mode_abilities.is_empty() { + // The complete classifier rejects every modal context, so immediate + // placement can never reach the stack-pushing modal branch below. + if immediate { + debug_assert!( + false, + "classify_pending_triggered_mana must reject a modal context before \ + immediate placement; reaching the modal branch would push a stack entry" + ); + return TriggerDispatchDisposition::DroppedNoLegalMode; + } // CR 603.3c + CR 603.3d: a triggered modal's mode choice is announced as // the ability is put on the stack, by the same process as casting a spell // (CR 601.2c-d). The triggering event must be live for the ENTIRE choice, @@ -7771,7 +8032,35 @@ fn dispatch_pending_trigger_context( subject_match_count, ); - match prepare_trigger_targets(state, &trigger) { + let prepared = prepare_trigger_targets(state, &trigger); + + if immediate { + // CR 605.4a: the stackless backend. The classifier already proved an + // empty canonical slot set, so `NoTargets` is the only reachable + // preparation outcome; every other outcome is an invariant failure and + // must not fall through to a push or to the delayed re-push fallback. + let PreparedTriggerTargets::NoTargets { + trigger, + rng, + events, + } = prepared + else { + debug_assert!( + false, + "classify_pending_triggered_mana proved an empty canonical target-slot set, so \ + an accepted context cannot reach a target pause, drop, or fallback push" + ); + restore_trigger_event_context(state, context_snapshot); + return TriggerDispatchDisposition::DroppedTargetUnresolved; + }; + commit_prepared_trigger_targets(state, rng, events, events_out); + let disposition = + resolve_accepted_triggered_mana_body(state, &trigger, &trigger_events, events_out); + restore_trigger_event_context(state, context_snapshot); + return disposition; + } + + match prepared { PreparedTriggerTargets::NoTargets { trigger, rng, @@ -7905,6 +8194,487 @@ fn dispatch_pending_trigger_context( } } +/// The exhaustive whitelist of existing interactions an accepted triggered-mana +/// body may pause on (CR 605.4a). +/// +/// A triggered-body colour choice and every unless/nested-cost prompt are +/// deliberately absent: the classifier proved the graph choice-free at +/// acceptance and rejected every `unless_pay` link, so encountering one is an +/// invariant failure rather than a wait to serialize generically. +fn is_accepted_triggered_mana_pause(waiting_for: &WaitingFor) -> bool { + matches!( + waiting_for, + WaitingFor::OptionalEffectChoice { .. } + | WaitingFor::OpponentMayChoice { .. } + | WaitingFor::RepeatDecision { .. } + | WaitingFor::ReplacementChoice { .. } + ) +} + +/// CR 605.4a: rebind one accepted occurrence's rules-execution node together +/// with the equal accepted-occurrence marker and its occurrence-local +/// production override, for exactly one lexical extent. +/// +/// Both transient scopes are restored on every return, including a return +/// caused by a prompt — which is precisely why a pause must copy the node into +/// `TriggeredManaResume` rather than relying on either scope surviving it. +pub(crate) fn with_triggered_mana_resolution_scope( + state: &mut GameState, + node: crate::types::RulesExecutionNodeRef, + production_override: Option, + operation: impl FnOnce(&mut GameState) -> T, +) -> T { + state.with_rules_execution_node(node, |state| { + let previous_marker = state.active_accepted_triggered_mana_node.replace(node); + let previous_override = state.current_triggered_mana_override.take(); + state.current_triggered_mana_override = production_override; + let result = operation(state); + state.current_triggered_mana_override = previous_override; + state.active_accepted_triggered_mana_node = previous_marker; + result + }) +} + +/// The stackless placement backend: bind the CR 603.4 scope through the shared +/// binder, then run the body. No stack entry, stack event, stack side table, or +/// stack journal is created on any path. +fn resolve_accepted_triggered_mana_body( + state: &mut GameState, + trigger: &PendingTrigger, + trigger_events: &[GameEvent], + events_out: &mut Vec, +) -> TriggerDispatchDisposition { + let waiting_before = state.waiting_for.clone(); + // CR 603.4 + CR 608.2k + CR 603.2c + CR 706.2, through the same binder stack + // resolution uses — immediate dispatch must not re-implement the recheck. + let bound = super::stack::bind_triggered_resolution_scope( + state, + Some(super::stack::TriggeredResolutionScope { + condition: trigger.condition.as_ref(), + controller: trigger.controller, + trigger_source: trigger.ability.trigger_source.as_ref(), + trigger_event: trigger.trigger_event.as_ref(), + subject_match_count: trigger.subject_match_count, + die_result: trigger.die_result, + }), + Some(trigger_events.to_vec()), + ); + if !bound { + // CR 603.4: a false intervening-if is terminal with no effects at all. + return TriggerDispatchDisposition::ResolvedInline; + } + let _ = super::effects::resolve_ability_chain(state, &trigger.ability, events_out, 0); + if state.waiting_for != waiting_before && is_accepted_triggered_mana_pause(&state.waiting_for) { + return TriggerDispatchDisposition::Paused; + } + debug_assert!( + state.waiting_for == waiting_before, + "an accepted triggered-mana body may only pause on a whitelisted interaction; \ + encountering a new pause is a stop condition, not a wait to serialize generically" + ); + TriggerDispatchDisposition::ResolvedInline +} + +/// The trigger-side immediate wrapper: mint the one `TriggeredMana` node for +/// this accepted occurrence, then run the shared dispatcher core under that node +/// and its equal accepted-occurrence marker. +fn dispatch_accepted_triggered_mana( + state: &mut GameState, + accepted: AcceptedTriggeredMana, + production_override: Option, + events_out: &mut Vec, +) -> ( + TriggerDispatchDisposition, + crate::types::RulesExecutionNodeRef, +) { + let AcceptedTriggeredMana { context, source } = accepted; + // Baseline's exact cause derivation for the node. + let caused_by = match context.pending.trigger_event.as_ref() { + Some( + GameEvent::ManaAdded { source_id, .. } | GameEvent::TappedForMana { source_id, .. }, + ) => state + .resolved_rules_journal + .latest_mana_producer_for_source(*source_id), + _ => None, + }; + let node = state.begin_triggered_mana_journal_node( + source, + context.pending.ability.trigger_definition_ref.clone(), + caused_by, + ); + let disposition = + with_triggered_mana_resolution_scope(state, node, production_override, |state| { + dispatch_pending_trigger_context_core( + state, + context, + TriggerPlacement::TriggeredManaImmediate, + events_out, + ) + }); + (disposition, node) +} + +/// Partition one already-collected context list into the members that may take +/// stackless immediate placement and the members that stay on the ordinary +/// authority, both in collection order. +fn partition_collected_mana_frame_contexts( + state: &mut GameState, + contexts: Vec, +) -> (Vec, Vec) { + let mut accepted = Vec::new(); + let mut ordinary = Vec::new(); + for context in contexts { + match classify_pending_triggered_mana(state, context) { + PendingTriggeredManaClass::Accepted(view) => accepted.push(*view), + PendingTriggeredManaClass::Ordinary(context) => ordinary.push(*context), + } + } + (accepted, ordinary) +} + +/// CR 603.3b: queue every rejected context on `state.deferred_triggers`, in the +/// combined APNAP order the shared collector already established, through the +/// one journaled `ResolvedTriggerCollection::DeferPending` authority. +/// +/// A completed mana frame is **not** a release boundary: it is a cost-payment +/// micro-frame inside somebody else's action. Ordering and dispatching its +/// ordinary observers here would form one release group per micro-frame and put +/// CR 603.3b ordering in front of a player mid-payment. Deferring instead makes +/// the owner's own boundary — the reducer epilogue, a cast/resolution finalizer, +/// or the settled-Priority convergence wrapper — form exactly one release group +/// over every micro-frame the action produced. +/// +/// This is the plan's one-release-group behaviour and it is a deliberate +/// corpus-visible change from baseline, which dispatched a completed mana +/// frame's observers immediately from inside the payment. +fn defer_rejected_mana_frame_contexts(state: &mut GameState, ordinary: Vec) { + resolve_and_apply_trigger_collection( + state, + crate::types::resolved_commands::ResolvedTriggerCollection::DeferPending { + contexts: ordinary, + }, + ) + .expect("completed mana-frame deferred trigger collection cause must be live"); +} + +/// CR 603.3b + CR 605.4a: the one ordered driver for a completed mana frame. +/// +/// It performs exactly one normal observation pass and one delayed match pass +/// over `raw_batch`, durably owns the combined collection, partitions it through +/// the one complete classifier, queues every ordinary context, and only then +/// dispatches accepted work immediately. It deliberately never terminalizes an +/// unmatched reflexive: a frame-local child batch is not the complete creating +/// boundary of every live reflexive. It never calls `begin_trigger_ordering`, +/// `dispatch_collected_triggers`, or either deferred drain. +/// +/// Returns `Some(wait)` when an accepted body paused on a whitelisted +/// interaction, after storing the complete `pending_triggered_mana_resume`. +pub(crate) fn collect_mana_action_trigger_batch( + state: &mut GameState, + raw_batch: &[GameEvent], + outer_resume: crate::types::game_state::ManaTriggerFixedPointResume, +) -> Option { + // The dispatch buffer is frame-local, exactly as baseline `process_triggers` + // keeps its own: events emitted while announcing or inline-resolving this + // batch are represented by the contexts and journal entries they produced, + // and are deliberately not given a live occurrence identity in the reducer's + // public event vector. + let events_out = &mut Vec::new(); + let seed = collect_triggers_for_batch(state, raw_batch); + let collected = collect_pending_and_delayed_triggers_for_batch( + state, + seed, + raw_batch, + DelayedTriggerEventScope::Any, + ); + let (accepted, ordinary) = partition_collected_mana_frame_contexts(state, collected.contexts); + let pause = run_accepted_triggered_mana_fixed_point(state, accepted, outer_resume, events_out); + defer_rejected_mana_frame_contexts(state, ordinary); + pause +} + +/// CR 603.3b + CR 603.7 + CR 605.4a: materialize one **undispatched** combined +/// normal-plus-delayed collection for the range an accepted occurrence emitted, +/// for storage on the sidecar across a pause. +/// +/// This is the durable form the plan requires: the raw emitted range dies with +/// the frame that produced it, so a pause must persist *contexts*, not events. +/// Nothing here is claimed, ordered, queued, or given a node — an accepted child +/// discovered by this pass mints its node only when readiness makes it current, +/// and a rejected sibling enters `state.deferred_triggers` only then too. +/// +/// Returns `None` for an empty range or an empty collection so the sidecar never +/// accumulates vacuous batches across repeated pauses. +fn collect_undispatched_emission_batch( + state: &mut GameState, + emitted: &[GameEvent], +) -> Option { + if emitted.is_empty() { + return None; + } + let seed = collect_triggers_for_batch(state, emitted); + let batch = collect_pending_and_delayed_triggers_for_batch( + state, + seed, + emitted, + DelayedTriggerEventScope::Any, + ); + (!batch.contexts.is_empty()).then_some(batch) +} + +/// CR 605.4a fixed point: resolve each accepted occurrence under its own node, +/// then collect the events it emitted and prepend any accepted children ahead of +/// the prior simultaneous tail, until no accepted work remains. +fn run_accepted_triggered_mana_fixed_point( + state: &mut GameState, + mut accepted: Vec, + outer_resume: crate::types::game_state::ManaTriggerFixedPointResume, + events_out: &mut Vec, +) -> Option { + while !accepted.is_empty() { + let current = accepted.remove(0); + let context_for_resume = current.context.clone(); + let emitted_start = events_out.len(); + let (disposition, node) = + dispatch_accepted_triggered_mana(state, current, None, events_out); + if matches!(disposition, TriggerDispatchDisposition::Paused) { + let waiting_for = state.waiting_for.clone(); + // CR 605.4a: the occurrence's own scopes have already restored (they + // restore on a prompt return exactly as on a synchronous one), so + // the range it emitted before pausing may be combined-collected + // here — and it MUST be, because `events_out` is frame-local and + // dies with this call. The batch is stored undispatched: no child + // node is minted and no ordinary context is queued while the current + // occurrence is unresolved. + debug_assert!( + state.active_accepted_triggered_mana_node.is_none(), + "a paused occurrence's marker must restore before its emission batch is collected" + ); + let emitted: Vec = events_out[emitted_start..].to_vec(); + let collected_batches = collect_undispatched_emission_batch(state, &emitted) + .into_iter() + .collect(); + state.pending_triggered_mana_resume = + Some(Box::new(crate::types::game_state::TriggeredManaResume { + current: Box::new(context_for_resume), + current_override: None, + rules_execution_node: node, + accepted_tail: accepted.into_iter().map(|view| view.context).collect(), + collected_batches, + outer_resume, + stage: crate::types::game_state::TriggeredManaStage::ResolvingBody, + })); + return Some(waiting_for); + } + // CR 605.4a: the completed occurrence's own scopes have restored, so its + // emitted events may now be collected and any accepted child minted. + debug_assert!( + state.active_accepted_triggered_mana_node.is_none(), + "a completed occurrence's marker must restore before child discovery" + ); + if events_out.len() > emitted_start { + let emitted: Vec = events_out[emitted_start..].to_vec(); + let seed = collect_triggers_for_batch(state, &emitted); + let child = collect_pending_and_delayed_triggers_for_batch( + state, + seed, + &emitted, + DelayedTriggerEventScope::Any, + ); + let (mut children, child_ordinary) = + partition_collected_mana_frame_contexts(state, child.contexts); + defer_rejected_mana_frame_contexts(state, child_ordinary); + children.extend(accepted); + accepted = children; + } + } + None +} + +/// CR 605.4a: the response-side counterpart to +/// [`with_triggered_mana_resolution_scope`]. +/// +/// Every handler family that can re-enter a paused accepted occurrence's body +/// runs its unmodified baseline work inside this scope, so the occurrence's own +/// rules-execution node and the equal accepted-occurrence marker are ambient +/// again. That is what makes both payment-choice observer helpers fail closed on +/// [`triggered_mana_sidecar_owns_local_collection`] while the resumed body runs: +/// the fixed point, never a helper, owns every event an accepted occurrence +/// emits. +/// +/// The durable carrier deliberately stays in `GameState` for the whole scope — +/// it is the serde authority if the body pauses again inside it — so the +/// predicate's two authorities agree rather than one covering for the other. +/// +/// With no live carrier the operation runs bare and the hook is a no-op. +pub(crate) fn with_accepted_triggered_mana_action_scope( + state: &mut GameState, + operation: impl FnOnce(&mut GameState) -> T, +) -> T { + let Some(carrier) = state.pending_triggered_mana_resume.as_ref() else { + return operation(state); + }; + let node = carrier.rules_execution_node; + let production_override = carrier.current_override.clone(); + with_triggered_mana_resolution_scope(state, node, production_override, operation) +} + +/// CR 603.3b: claim every live occurrence in one resumed action's own emitted +/// range, by full-action index, through the same journal authority a completed +/// mana frame uses. +/// +/// The observer helpers were suppressed for this range by the sidecar guard, so +/// the combined collection above is its only collection; the journal is what +/// stops the ordinary post-action pipeline rediscovering the same occurrences. +fn claim_resumed_triggered_mana_events( + state: &mut GameState, + events: &[GameEvent], + event_start: usize, +) { + if event_start >= events.len() { + return; + } + let occurrences = (event_start..events.len()) + .map(|index| ConsumedTriggerEventOccurrence { + event: events[index].clone(), + occurrence: trigger_event_occurrence(events, index), + }) + .collect(); + resolve_and_apply_trigger_collection( + state, + crate::types::resolved_commands::ResolvedTriggerCollection::ConsumeBeforePriority { + occurrences, + }, + ) + .expect("resumed accepted triggered-mana consumed-before-priority journal cause must be live"); +} + +/// CR 605.4a: the one readiness authority for a resumed accepted triggered-mana +/// occurrence. Called by every sidecar-aware handler family **after** the action +/// scope has restored, and never from inside it. +/// +/// In order: combine-collect the exact unclaimed suffix this action emitted and +/// store it undispatched, claim that suffix's live identities, and then either +/// keep the carrier (the current occurrence re-paused on a whitelisted +/// interaction) or terminate it — partition every stored batch once, mint +/// accepted children ahead of the prior simultaneous tail, run that tail, and +/// resume the suspended mana frame exactly once. +/// +/// [`TriggeredManaReadiness::Pending`] means "no carrier, or the carrier is +/// still live": the handler returns its own unmodified result. +/// [`TriggeredManaReadiness::Resumed`] carries the resumed mana frame's +/// authoritative wait, which the handler must install and return. +pub(crate) fn finish_accepted_triggered_mana_action( + state: &mut GameState, + events: &mut Vec, + event_start: usize, +) -> Result { + if state.pending_triggered_mana_resume.is_none() { + return Ok(TriggeredManaReadiness::Pending); + } + debug_assert!( + state.active_accepted_triggered_mana_node.is_none(), + "readiness must run outside the resumed occurrence's own scope" + ); + let emitted: Vec = events[event_start.min(events.len())..].to_vec(); + let batch = collect_undispatched_emission_batch(state, &emitted); + claim_resumed_triggered_mana_events(state, events, event_start); + let carrier = state + .pending_triggered_mana_resume + .as_mut() + .expect("the carrier was proven live above and collection never clears it"); + carrier.collected_batches.extend(batch); + if is_accepted_triggered_mana_pause(&state.waiting_for) { + // A repeated pause of the same occurrence: the carrier keeps its node, + // its tail, and now one more undispatched batch. + return Ok(TriggeredManaReadiness::Pending); + } + let carrier = *state + .pending_triggered_mana_resume + .take() + .expect("the carrier was proven live above"); + let crate::types::game_state::TriggeredManaResume { + accepted_tail, + collected_batches, + outer_resume, + .. + } = carrier; + let stored: Vec = collected_batches + .into_iter() + .flat_map(|batch| batch.contexts) + .collect(); + let (mut accepted, ordinary) = partition_collected_mana_frame_contexts(state, stored); + defer_rejected_mana_frame_contexts(state, ordinary); + // CR 605.4a: children go ahead of the prior simultaneous tail. The tail is + // reclassified rather than trusted: acceptance is a property of live state, + // and a tail member whose source left the battlefield during the pause + // belongs to the ordinary authority now. + let (tail_accepted, tail_ordinary) = + partition_collected_mana_frame_contexts(state, accepted_tail); + defer_rejected_mana_frame_contexts(state, tail_ordinary); + accepted.extend(tail_accepted); + // The tail's dispatch buffer is frame-local, exactly as the synchronous + // frame's is: an accepted body's own emissions are represented by the + // contexts and journal entries they produce, not by a live identity in this + // action's public event vector. + let tail_events = &mut Vec::new(); + if run_accepted_triggered_mana_fixed_point(state, accepted, outer_resume.clone(), tail_events) + .is_some() + { + // A tail member paused and installed its own carrier naming the same + // outer continuation; that carrier is the authority now. + return Ok(TriggeredManaReadiness::Resumed { + wait: Box::new(state.waiting_for.clone()), + settled_direct_priority_root: false, + }); + } + // CR 117.3c + CR 117.5: recognize the ONE consumed shape that may run full + // settled-Priority convergence, exhaustively and before the frame resumes: + // a direct `ManaAbilityResume::Priority` root, i.e. an activation that began + // at `WaitingFor::Priority` with nothing else owning the action. Every cast, + // payment, colour, unless and special-action owner is excluded here, not by + // the convergence wrapper. + let direct_priority_root = matches!( + outer_resume, + crate::types::game_state::ManaTriggerFixedPointResume::Root { ref resume, .. } + if matches!(**resume, crate::types::game_state::ManaAbilityResume::Priority) + ); + let Some(wait) = super::mana_abilities::resume_settled_mana_frame(state, outer_resume, events)? + else { + return Ok(TriggeredManaReadiness::Pending); + }; + let settled_direct_priority_root = direct_priority_root + && matches!(wait, WaitingFor::Priority { .. }) + && state.pending_triggered_mana_resume.is_none() + && state.pending_cast.is_none() + && !super::casting::mana_ability_cost_payment_is_paused(state) + && resolution_completion_can_settle(state); + Ok(TriggeredManaReadiness::Resumed { + wait: Box::new(wait), + settled_direct_priority_root, + }) +} + +/// What one sidecar-aware handler's readiness hook concluded. +pub(crate) enum TriggeredManaReadiness { + /// No carrier, or the carrier is still live after this action. The handler + /// returns its own unmodified result. + Pending, + /// The fixed point terminated and the suspended mana frame resumed exactly + /// once to `wait`. + /// + /// `settled_direct_priority_root` is true only for the single consumed shape + /// that may run full settled-Priority convergence — see the gate above. A + /// handler whose reducer arm returns its `ActionResult` directly (and so + /// never reaches the ordinary epilogue) uses it to decide whether to call + /// `run_post_action_pipeline_from_settled_priority`; every other owner + /// returns `wait` unchanged. + Resumed { + wait: Box, + settled_direct_priority_root: bool, + }, +} + /// CR 608.2e + issue #1793: True end-of-resolution boundary for draining /// `deferred_triggers`. Mid-resolution `Priority` from player-scope iteration, /// `repeat_for`, or replacement continuations must not drain (or offer CR @@ -7917,6 +8687,37 @@ pub(crate) fn resolution_completion_can_settle(state: &GameState) -> bool { if is_pending_trigger_construction_active(state) { return false; } + // CR 601.2h + CR 602.2b: A parked cost-move continuation still owns the + // announcement or activation whose events created these observers. The + // complete typed enum is a release barrier until its exact resumer takes + // and completes that continuation. + if state.pending_cost_move_resume.is_some() { + return false; + } + // CR 118.3b + CR 119.4: Deferred life payment likewise retains its cast, + // mana-root, or pay-amount owner across replacement interaction. + if state.pending_deferred_life_cost_resume.is_some() { + return false; + } + // CR 605.4a: A classifier-accepted triggered mana ability paused + // mid-resolution owns its own emitted-event fixed point. Its carrier — and + // only that carrier — joins this owned-prompt guard. + // + // `pending_trigger_construction_priority_recipient` is deliberately absent. + // `can_drain_deferred_triggers` consults this predicate first, so a + // construction recipient here would reject the settled wrapper's own drain, + // the construction finisher's drain, and every deferred-sibling drain in the + // same batch — deadlocking the batch with its own recipient. What protects + // an in-flight construction batch instead is unchanged baseline ownership: + // `is_pending_trigger_construction_active` above, the empty deferred queue + // while `OrderTriggers` is open, and the reducer dispatching on the + // `(waiting_for, action)` pair. The only waiting-state-agnostic entries are + // concession/elimination and debug actions, which is why `elimination.rs` + // clears that recipient alongside the construction cursors it already + // clears. + if state.pending_triggered_mana_resume.is_some() { + return false; + } if !state.resolution_stack.is_empty() { return false; } @@ -7936,14 +8737,49 @@ pub(crate) fn resolution_completion_can_settle(state: &GameState) -> bool { true } -fn can_drain_deferred_triggers(state: &GameState, allow_spell_on_stack: bool) -> bool { +/// Which deferred-trigger drain, if any, a post-action seam owns. +/// +/// Every variant still goes through `resolution_completion_can_settle`, so the +/// complete carrier census is shared; the policy decides only whether this seam +/// drains at all and whether a `Spell` entry may remain on the stack while it +/// does. Replacing the two historic booleans — `engine_priority`'s +/// `skip_deferred_trigger_drain` and this module's `allow_spell_on_stack` — +/// with one enum keeps those two independent axes from being recombined +/// accidentally at a new seam. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum DeferredTriggerDrainPolicy { + /// This seam owns no drain. Its caller has already drained the batch, or it + /// is a mid-continuation owner that must not. + Skip, + /// CR 603.3b + issue #1793: drain only once no `Spell` entry remains on the + /// stack, so parked observers cannot offer ordering mid-resolution. + ResolutionSafe, + /// CR 601.2h + CR 602.2b + CR 603.3: a passive announced stack object may + /// still be on the stack; its cost/cast observers, and the settled batch a + /// direct mana root converges into, drain above it. + SettledPriority, +} + +impl DeferredTriggerDrainPolicy { + fn allows_spell_on_stack(self) -> bool { + match self { + DeferredTriggerDrainPolicy::Skip | DeferredTriggerDrainPolicy::ResolutionSafe => false, + DeferredTriggerDrainPolicy::SettledPriority => true, + } + } +} + +fn can_drain_deferred_triggers(state: &GameState, policy: DeferredTriggerDrainPolicy) -> bool { + if matches!(policy, DeferredTriggerDrainPolicy::Skip) { + return false; + } if state.deferred_triggers.is_empty() || !resolution_completion_can_settle(state) { return false; } // CR 603.3b + issue #1793: observer triggers parked during a spell's // resolution must wait until that spell leaves the stack — draining while // a `Spell` entry remains would offer ordering mid player_scope iteration. - if !allow_spell_on_stack + if !policy.allows_spell_on_stack() && state .stack .iter() @@ -7955,7 +8791,22 @@ fn can_drain_deferred_triggers(state: &GameState, allow_spell_on_stack: bool) -> } pub(crate) fn should_drain_deferred_triggers_now(state: &GameState) -> bool { - can_drain_deferred_triggers(state, false) + can_drain_deferred_triggers(state, DeferredTriggerDrainPolicy::ResolutionSafe) +} + +/// Policy-parameterized form of [`drain_deferred_trigger_queue`] for the shared +/// post-action pipeline core, whose drain slot is owned by its caller's policy +/// rather than by a fixed boundary. +pub(super) fn drain_deferred_trigger_queue_with_policy( + state: &mut GameState, + events_out: &mut Vec, + policy: DeferredTriggerDrainPolicy, +) -> Option { + if !can_drain_deferred_triggers(state, policy) { + return None; + } + + drain_deferred_trigger_queue_unchecked(state, events_out) } /// CR 113.2c + CR 603.2 + CR 603.3b: Drain the deferred-trigger queue after @@ -7991,6 +8842,43 @@ pub(crate) fn drain_deferred_triggers_after_stack_object_announcement( state: &mut GameState, events_out: &mut Vec, ) -> Option { + // CR 601.2c + CR 601.2d + CR 601.2h: the EXTERNAL pending-cast carrier has + // not completed announcement yet. Cost observers wait until the finalizer + // consumes that exact cast root. + // + // The companion INLINE `WaitingFor` carrier check is deliberately NOT here, + // and that placement is load-bearing rather than an omission. Most callers + // of this helper run INSIDE a reducer action, before `apply_action` assigns + // the handler's returned wait — so `state.waiting_for` is the wait the + // action STARTED from, not the one the announcement is installing. Reading + // it here blocks the exact drain this helper exists to perform: the + // additional-sacrifice cast that pauses on `TargetSelection` (whose + // `CostResume::Spell` carries the cast inline) still holds that stale wait + // when `casting_targets` finalizes the announcement and calls in, so the + // cost-sacrifice observers stay parked behind the announced spell forever. + // + // Every post-announcement boundary already excludes an inline carrier + // STRUCTURALLY, which is why dropping the read loses no protection: + // + // * the `casting_costs`/`casting_targets` wrapper returns early unless the + // wait it is ABOUT to install is `Priority`, and a `Priority` wait + // carries no cast by construction; + // * `engine_priority`'s post-action call sits behind + // `settle_pending_resolution_completion`, which itself requires + // `state.waiting_for` to be `Priority`; + // * the copy-announcement callers (`engine.rs` copy-target choice, + // `effects/copy_spell.rs`) drain for a COPY, which is put on the stack + // without being cast, so no announcement is in flight; + // * `drain_deferred_triggers_after_trigger_construction` runs after a + // TRIGGER finished construction; a cast paused around it lives in the + // external `pending_cast` carrier this function does read. + // + // The live discriminator for this placement is + // `casting_costs::tests::cost_paid_multi_sacrifice_kicker_paused_under_observes`: + // restoring the `state.waiting_for.has_pending_cast()` read fails it. + if state.pending_cast.is_some() { + return None; + } // CR 603.3b + CR 608.2g: terminal Ripple settlement owns this exact // post-announcement boundary. It must first collect the current final // cast's SpellCast event with earlier accepted casts, then drain one batch. @@ -7999,7 +8887,7 @@ pub(crate) fn drain_deferred_triggers_after_stack_object_announcement( if state.pending_resolution_completion.is_some() { return None; } - if !can_drain_deferred_triggers(state, true) { + if !can_drain_deferred_triggers(state, DeferredTriggerDrainPolicy::SettledPriority) { return None; } @@ -8030,6 +8918,144 @@ pub(crate) fn drain_deferred_triggers_after_trigger_construction( } } +/// CR 117.3c + CR 117.5 + CR 605.4a: record the player who must receive +/// priority once a settled trigger batch has finished announcing, when that +/// player is not the active player. +/// +/// The recipient is installed only from the settled-Priority convergence core's +/// own exhaustively validated `WaitingFor::Priority { player }`, before any +/// ordered or singleton construction step can pause. Reinstalling the same +/// player is idempotent; observing a *different* player while the field is live +/// is an internal invariant error. The one carved-out rewrite is +/// `elimination.rs`'s CR 800.4a re-point past a departed recipient, which does +/// not come through here. +pub(crate) fn preserve_trigger_construction_priority_recipient( + state: &mut GameState, + player: PlayerId, +) { + debug_assert!( + state + .pending_trigger_construction_priority_recipient + .is_none_or(|live| live == player), + "a second settled batch installed a different construction priority recipient", + ); + state.pending_trigger_construction_priority_recipient = Some(player); +} + +/// The closed census of trigger-construction prompts that may be returned to a +/// player while a construction priority recipient stays installed. +/// +/// Any other wait shape at a finisher seam means a seam was added that the +/// Round-20 closure map does not cover, which is an invariant error rather than +/// something to coerce. +pub(super) fn is_trigger_construction_prompt( + waiting_for: &crate::types::game_state::WaitingFor, +) -> bool { + matches!( + waiting_for, + WaitingFor::OrderTriggers { .. } + | WaitingFor::NamedChoice { .. } + | WaitingFor::OptionalEffectChoice { .. } + | WaitingFor::AbilityModeChoice { .. } + | WaitingFor::TriggerTargetSelection { .. } + | WaitingFor::DistributeAmong { .. } + ) +} + +/// Close out one reducer action that may have been a trigger-construction step. +/// +/// This is the single authority that decides whether a construction wait is +/// really terminal, and it is deliberately event-aware: a `Priority` produced by +/// a successful announcement, a dangling-cursor recovery, or an optional decline +/// is only terminal once the *whole* deferred tail behind it has announced. +/// Baseline's `abandon_ceased_pending_trigger` deliberately leaves deferred +/// siblings installed, and both dangling recoveries return `Priority` without +/// draining, so the drain has to live here rather than at each return. +/// +/// Contract: +/// +/// 1. With no recipient installed, return `produced` byte-for-byte and perform +/// no drain. This is the entire ordinary-caller contract — every existing +/// controller/active-player fallback at the seams stays exactly baseline. +/// 2. With a recipient and one of the six approved construction prompts, return +/// the real prompt and leave the recipient installed for the next step. +/// 3. With a recipient and a `Priority` of any player, run the bounded +/// construction drain, return an approved prompt it surfaces with the +/// recipient retained, and otherwise consume the recipient and return +/// `Priority { player }` — never leaving a durable recipient installed across +/// an action that returns ordinary priority. +/// 4. With a recipient and any other wait shape, this is an invariant error: do +/// not coerce, do not consume, do not invent a recipient. +/// +/// Applied exactly once per reducer action, at the outermost handler return of +/// each enumerated seam — never inside trigger dispatch, where a `Priority` +/// result is discarded by the dispatcher and the recipient would be lost +/// mid-batch. +pub(crate) fn finish_trigger_construction_action( + state: &mut GameState, + events: &mut Vec, + produced: crate::types::game_state::WaitingFor, +) -> crate::types::game_state::WaitingFor { + debug_assert!( + !state.trigger_construction_finisher_ran_this_action, + "the trigger-construction finisher ran twice in one reducer action", + ); + state.trigger_construction_finisher_ran_this_action = true; + + let Some(player) = state.pending_trigger_construction_priority_recipient else { + return produced; + }; + + if is_trigger_construction_prompt(&produced) { + return produced; + } + + if !matches!(produced, WaitingFor::Priority { .. }) { + debug_assert!( + false, + "trigger-construction finisher reached a wait outside the closed \ + construction/priority census: {produced:?}", + ); + return produced; + } + + // CR 113.2c + CR 603.2 + CR 603.3b: announce the rest of the batch before + // priority is really handed over. The existing selector picks the + // post-announcement boundary whenever no ability continuation or spell + // resolution is active, which is exactly the settled-batch case and the only + // drain that may place newly announced triggers above a passive spell. + let bound = state.deferred_triggers.len(); + let mut iterations = 0usize; + while !state.deferred_triggers.is_empty() + && state.pending_trigger.is_none() + && state.pending_trigger_entry.is_none() + && state.pending_trigger_order.is_none() + { + let queued_before = state.deferred_triggers.len(); + if let Some(waiting_for) = drain_deferred_triggers_after_trigger_construction(state, events) + { + debug_assert!( + is_trigger_construction_prompt(&waiting_for), + "the construction drain surfaced a wait outside the closed census: {waiting_for:?}", + ); + return waiting_for; + } + if state.deferred_triggers.len() >= queued_before { + // Undrainable queue: an ineligible drain state the settled-root gate + // proves absent upstream. Fall through and consume rather than leak. + break; + } + iterations += 1; + debug_assert!( + iterations <= bound, + "construction drain loop exceeded its queued-context bound", + ); + } + + state.pending_trigger_construction_priority_recipient = None; + WaitingFor::Priority { player } +} + fn drain_deferred_trigger_queue_unchecked( state: &mut GameState, events_out: &mut Vec, @@ -8534,8 +9560,13 @@ pub fn check_state_triggers(state: &mut GameState) { /// One-shot triggers are removed after firing; multi-fire (WheneverEvent) triggers /// persist until end-of-turn cleanup (CR 603.7c). pub fn check_delayed_triggers(state: &mut GameState, events: &[GameEvent]) -> Vec { + // CR 603.7 + CR 603.12: this is a closing `Any` boundary. Its contract is + // "match, then terminalize, then dispatch": the unmatched-reflexive pass must + // precede the empty-batch early return, so a complete boundary with zero + // firing contexts still expires a reflexive that this batch did not satisfy. let (pending, _) = collect_matching_delayed_triggers(state, events, DelayedTriggerEventScope::Any); + terminalize_unmatched_reflexives_for_closed_batch(state, events, DelayedTriggerEventScope::Any); if pending.is_empty() { return vec![]; } @@ -9536,6 +10567,19 @@ fn delayed_trigger_to_context( ) } +/// CR 603.7 + CR 603.7b: Match-only delayed-trigger collection. It fires +/// matching instances, removes fired one-shots, keeps duration-bearing +/// multi-fire sources installed, synthesizes Epic upkeep copies, records +/// delayed-due provenance and consumed raw identities, and returns the batch in +/// APNAP order. +/// +/// It deliberately performs no unmatched-reflexive lifetime closure. CR 603.12 +/// expires a reflexive against its *creating* resolution/batch, so a partial +/// frame-local batch — for example the micro-batch of a nested mana payment +/// inside an outer resolution — must be able to run this collector without +/// terminating an outer reflexive that its own boundary has not yet closed. +/// Closing boundaries call [`terminalize_unmatched_reflexives_for_closed_batch`] +/// immediately afterwards. fn collect_matching_delayed_triggers( state: &mut GameState, events: &[GameEvent], @@ -9552,20 +10596,6 @@ fn collect_matching_delayed_triggers( // One-shot triggers are removed; multi-fire triggers are cloned and left in place. let mut to_fire: Vec<(DelayedTrigger, usize, GameEvent, bool)> = Vec::new(); let mut to_remove: Vec<(usize, usize, GameEvent)> = Vec::new(); - - // CR 603.12: A reflexive delayed trigger ("when you [do X] this way, ...", - // including "when you win/lose the flip") is checked immediately after - // creation and triggers only on the event(s) that occurred earlier during the - // creating resolution. It gets exactly one shot on that creation batch: if it - // did not match on this first check, it must be discarded rather than left to - // fire on a later same-turn matching event (which a bare CR 603.7b - // `WhenNextEvent` would do). The phase-only path keeps the historical - // narrower coin-flip discard gate because it filters non-phase matches out of - // the candidate batch. - // - // Each entry pairs the index with the terminal disposition to record for it, - // so a discarded reflexive (CR 603.12) and a CR 603.4 intervening-`if` - // failure stay distinguishable in the lifecycle ledger. let mut to_discard: Vec<(usize, super::lifecycle::DelayedTerminalDisposition)> = Vec::new(); for (idx, delayed) in state.delayed_triggers.iter().enumerate() { @@ -9646,21 +10676,6 @@ fn collect_matching_delayed_triggers( to_fire.push((delayed.clone(), occ_index, occurrence, false)); } } - } else if match scope { - DelayedTriggerEventScope::Any => is_reflexive_lifetime(&delayed.condition), - DelayedTriggerEventScope::PhaseChangedOnly => { - reflexive_coin_flip_resolved_without_match( - &delayed.condition, - events, - state, - delayed.ability.trigger_source.as_ref(), - ) - } - } { - to_discard.push(( - idx, - super::lifecycle::DelayedTerminalDisposition::ReflexiveUnmatched, - )); } } @@ -9684,14 +10699,12 @@ fn collect_matching_delayed_triggers( } } - // Remove fired one-shot triggers AND every one-shot dropped without firing - // from `delayed_triggers` in a single descending-index pass so every index - // stays valid. Fired one-shots are collected into `to_fire`; the unfired set - // is dropped with its own recorded disposition — `ReflexiveUnmatched` for a - // CR 603.12 reflexive that never matched, `InterveningIfFalse` for a - // CR 603.4 gate that was false when the trigger event occurred. The two - // index sets are disjoint — a trigger either fired, or was dropped - // unfired, never both. + // Remove fired one-shot triggers from `delayed_triggers` in a descending-index + // pass so every index stays valid. Unmatched-reflexive lifetime closure is + // deliberately NOT performed here: it belongs to the batch's creating + // resolution boundary and is owned by + // `terminalize_unmatched_reflexives_for_closed_batch`, so a partial + // frame-local batch (a nested mana payment) cannot expire an outer reflexive. let mut fired_events: std::collections::HashMap = to_remove .iter() .map(|(idx, event_index, event)| (*idx, (*event_index, event.clone()))) @@ -9762,6 +10775,71 @@ fn collect_matching_delayed_triggers( (pending, consumed_events) } +/// CR 603.12: Close the lifetime of every reflexive delayed trigger that did not +/// match this *complete* event batch. +/// +/// A reflexive delayed trigger ("when you [do X] this way, ...", including "when +/// you win/lose the flip") is checked immediately after creation and triggers +/// only on the event(s) that occurred earlier during the creating resolution. It +/// gets exactly one shot on that creation batch: if it did not match, it is +/// discarded rather than left to fire on a later same-turn matching event (which +/// a bare CR 603.7b `WhenNextEvent` would do). The `PhaseChangedOnly` path keeps +/// the historical narrower coin-flip discard gate because it filters non-phase +/// matches out of the candidate batch. +/// +/// This runs only at a boundary that owns the complete batch. It constructs no +/// firing context, never touches Epic effects, and cannot remove a trigger that +/// [`collect_matching_delayed_triggers`] already fired and removed, because that +/// collector has already taken those instances out of `state.delayed_triggers`. +fn terminalize_unmatched_reflexives_for_closed_batch( + state: &mut GameState, + events: &[GameEvent], + scope: DelayedTriggerEventScope, +) { + if state.delayed_triggers.is_empty() { + return; + } + let mut to_discard: Vec = Vec::new(); + for (idx, delayed) in state.delayed_triggers.iter().enumerate() { + if delayed_trigger_event_with_index( + &delayed.condition, + events, + state, + delayed.source_id, + delayed.controller, + delayed.ability.trigger_source.as_ref(), + ) + .is_some() + { + // A matched instance is owned by the match collector: it either fired + // and was removed, was retained as duration-bearing, or was filtered + // out of this scope. None of those is an unmatched reflexive. + continue; + } + let expires = match scope { + DelayedTriggerEventScope::Any => is_reflexive_lifetime(&delayed.condition), + DelayedTriggerEventScope::PhaseChangedOnly => { + reflexive_coin_flip_resolved_without_match( + &delayed.condition, + events, + state, + delayed.ability.trigger_source.as_ref(), + ) + } + }; + if expires { + to_discard.push(idx); + } + } + for idx in to_discard.into_iter().rev() { + let trigger = state.delayed_triggers.remove(idx); + super::lifecycle::record_delayed_terminal( + trigger.provenance.firing(), + super::lifecycle::DelayedTerminalDisposition::ReflexiveUnmatched, + ); + } +} + pub(crate) fn collect_triggers_for_batch( state: &mut GameState, events: &[GameEvent], @@ -9769,6 +10847,42 @@ pub(crate) fn collect_triggers_for_batch( collect_pending_triggers(state, events) } +/// CR 603.3b + CR 603.7: Combine an already-observed normal seed with every +/// delayed trigger matching the same raw batch under an explicit scope, then +/// stable-sort the combined vector once by the existing APNAP rank/timestamp +/// key. +/// +/// `normal_pending` is a seed, never raw normal events: callers that own raw +/// normal events call [`collect_triggers_for_batch`] exactly once before entry, +/// so there is only ever one normal observation pass. `delayed_events` remains an +/// independent raw slice and `delayed_scope` is passed to the delayed matcher +/// unchanged. This authority is collection-only — it never terminalizes an +/// unmatched reflexive, so a partial frame-local batch can use it safely. +fn collect_pending_and_delayed_triggers_for_batch( + state: &mut GameState, + normal_pending: Vec, + delayed_events: &[GameEvent], + delayed_scope: DelayedTriggerEventScope, +) -> CollectedTriggerContextBatch { + let normal_was_non_empty = !normal_pending.is_empty(); + let (delayed_pending, delayed_consumed) = + collect_matching_delayed_triggers(state, delayed_events, delayed_scope); + let mut contexts = normal_pending; + contexts.extend(delayed_pending); + contexts.sort_by_key(|ctx| { + ( + trigger_apnap_rank(state, ctx.pending.controller), + ctx.pending.timestamp, + ) + }); + CollectedTriggerContextBatch { + contexts, + delayed_events: delayed_events.to_vec(), + delayed_consumed, + normal_was_non_empty, + } +} + fn current_trigger_prompt(state: &GameState, waiting_before: &WaitingFor) -> Option { let order_triggers_prompt = build_next_order_triggers_prompt_public(state); let active_trigger_prompt = (order_triggers_prompt.is_none() @@ -9828,11 +10942,21 @@ fn process_collected_triggers_with_delayed_events_scoped( ) -> TriggerBatchOutcome { let stack_before = state.stack.len(); let waiting_before = state.waiting_for.clone(); - let normal_was_non_empty = !normal_pending.is_empty(); - let (delayed_pending, consumed_events) = - collect_matching_delayed_triggers(state, delayed_events, delayed_scope); - let mut pending = normal_pending; - pending.extend(delayed_pending); + // CR 603.7 + CR 603.12: this is a closing boundary — match first, then expire + // every reflexive that this complete batch did not satisfy, before the + // empty-batch prompt recovery below can return. + let CollectedTriggerContextBatch { + contexts: pending, + delayed_events: _, + delayed_consumed: consumed_events, + normal_was_non_empty, + } = collect_pending_and_delayed_triggers_for_batch( + state, + normal_pending, + delayed_events, + delayed_scope, + ); + terminalize_unmatched_reflexives_for_closed_batch(state, delayed_events, delayed_scope); // CR 603.3b (issue #770 cluster): a truly-empty batch for *this* event // must still surface an orphaned `pending_trigger`/`deferred_triggers` @@ -9854,13 +10978,8 @@ fn process_collected_triggers_with_delayed_events_scoped( }; } - pending.sort_by_key(|ctx| { - ( - trigger_apnap_rank(state, ctx.pending.controller), - ctx.pending.timestamp, - ) - }); - + // The combined batch was stable-sorted exactly once by the shared collection + // authority above; do not re-sort it here. match begin_trigger_ordering(state, pending) { TriggerOrderingDisposition::PromptForChoice(wf) => { state.waiting_for = *wf; @@ -12942,8 +14061,9 @@ pub mod tests { use crate::types::counter::CounterType; use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ - DamageRecord, DelayedTrigger, DistributionUnit, GameState, LayersDirty, LoopDetectionMode, - NamedChoiceSourceBinding, SpellCastRecord, StackEntry, StackEntryKind, + DamageRecord, DeferredLifeCostResume, DelayedTrigger, DistributionUnit, GameState, + LayersDirty, LoopDetectionMode, NamedChoiceSourceBinding, PendingCast, + PendingCostMoveResume, SpellCastRecord, StackEntry, StackEntryKind, TransientContinuousEffect, WaitingFor, ZoneChangeRecord, }; use crate::types::identifiers::{ @@ -32460,6 +33580,503 @@ pub mod tests { assert_eq!(state.deferred_triggers.len(), 2); } + fn reflexive_delayed_trigger( + state: &mut GameState, + name: &str, + mode: crate::types::triggers::TriggerMode, + token: u64, + ) -> (ObjectId, DelayedTriggerOrigin) { + use crate::types::ability::{DelayedTriggerCondition, DelayedTriggerLifetime}; + let card_id = CardId(state.next_object_id); + let source_id = create_object( + state, + card_id, + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let origin = DelayedTriggerOrigin { + token: DelayedTriggerToken(token), + instance: DelayedTriggerInstanceId(token), + source_id, + }; + let mut ability = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + ability.description = Some(name.to_string()); + let is_phase = matches!(mode, crate::types::triggers::TriggerMode::Phase); + let mut definition = crate::types::ability::TriggerDefinition::new(mode); + if is_phase { + definition.phase = Some(crate::types::phase::Phase::Upkeep); + } + state.delayed_triggers.push(DelayedTrigger { + condition: DelayedTriggerCondition::WhenNextEvent { + trigger: Box::new(definition), + or_trigger: None, + lifetime: DelayedTriggerLifetime::Reflexive, + }, + ability: Box::new(ability), + controller: PlayerId(0), + source_id, + one_shot: true, + provenance: DelayedInstallIdentity::ReceiptEligible(origin), + }); + (source_id, origin) + } + + /// CR 603.12: `check_delayed_triggers` is a *closing* `Any` boundary. Its + /// contract is match, then terminalize, then dispatch — so a complete batch + /// with zero firing contexts must still expire an unmatched reflexive before + /// the empty early return. Moving terminalization below `pending.is_empty()` + /// leaves the instance installed and fails this row. + #[test] + fn direct_delayed_check_closes_unmatched_reflexive_before_empty_return() { + let mut state = setup(); + let (_, origin) = reflexive_delayed_trigger( + &mut state, + "Unmatched reflexive", + crate::types::triggers::TriggerMode::SpellCast, + 60_312, + ); + assert_eq!(state.delayed_triggers.len(), 1); + + // A complete `Any` batch that this reflexive's condition does not match. + let batch = vec![GameEvent::PhaseChanged { + phase: crate::types::phase::Phase::Upkeep, + }]; + + let guard = super::super::lifecycle::enter_action_frame(); + let fired = check_delayed_triggers(&mut state, &batch); + let facts = guard + .take_outer_facts() + .expect("outer action owns lifecycle facts"); + + assert!( + fired.is_empty(), + "no delayed context fires on a non-matching batch" + ); + assert!( + state.delayed_triggers.is_empty(), + "CR 603.12: the unmatched reflexive must be removed at its closing boundary" + ); + assert!( + facts.receipt_terminalized(origin), + "the closing boundary must record exactly one ReflexiveUnmatched terminal" + ); + assert!( + state.stack.is_empty(), + "an unmatched reflexive never reaches the stack" + ); + } + + /// **CR 605.1b + CR 605.4a — the complete-context partition at the mana + /// settlement seam, driven through the real `collect_mana_action_trigger_batch` + /// authority rather than by calling the classifier alone.** + /// + /// One completed mana frame emits one `ManaAdded` event that every candidate + /// below is coupled to. Exactly one of them may take stackless immediate + /// placement; every other one must be preserved unchanged and appended once + /// to the ordinary deferred batch. Rows: + /// + /// * **(a) targetless nonmodal all-mana** — ACCEPTED. Positive reach guard: + /// its mana is in the pool when the driver returns, with no stack entry, + /// no `pending_trigger*` cursor, and no deferred-queue member for it. + /// * **(b) `unless_pay` on the root** — rejected structurally, so a live + /// sidecar can never coincide with `UnlessPayment`. + /// * **(c) modal** — rejected. Baseline pushes a modal trigger to the stack + /// *before* mode selection, so it never reached the inline classifier + /// either; this cut keeps it on the ordinary announcement authority. + /// * **(d) `optional`** — rejected by this revision's pause-narrowing. This + /// one is an honest behaviour delta, not a no-op: baseline's + /// `is_triggered_mana_ability` returns **true** here. + /// * **(e) a target already present at classification time** — rejected by + /// the exact baseline predicate, with its referent preserved. + /// * **(f) `Effect::Mana { target: Some(..) }` with empty + /// `target_constraints` and empty runtime `targets`** — rejected only by + /// the canonical definition-owned slot proof. This is the row a predicate + /// that inspects `target_constraints`/`multi_target`/`ability.targets` + /// cannot fail on. + /// * **(g) an all-mana body with a non-CR-605.1b firing event** (the + /// Firebending shape) — rejected on the event axis. + /// + /// The deferral assertions are the reach guard that rejection means + /// "ordinary deferred dispatch", not "dropped": every rejected context is in + /// `deferred_triggers` exactly once and none of them reached the stack. + /// + /// REVERT-PROBES. Delete the modal guard ⇒ (c) is accepted and its stackless + /// dispatch trips the core's modal invariant. Delete the `unless_pay` walk ⇒ + /// (b) is accepted. Delete the slot proof ⇒ (f) is accepted. Replace the + /// baseline-predicate call with a hand-rolled all-mana check ⇒ (e) and (g) + /// are accepted. Any of those flips the exact-one-accepted count below. + #[test] + fn the_completed_mana_frame_classifier_accepts_only_the_stackless_safe_context() { + use crate::types::ability::{ManaProduction, UnlessPayModifier}; + use crate::types::game_state::ManaTriggerFixedPointResume; + use crate::types::mana::ManaType; + + let mut state = setup(); + let producer_card = CardId(state.next_object_id); + let producer = create_object( + &mut state, + producer_card, + PlayerId(0), + "Mana producer".to_string(), + Zone::Battlefield, + ); + let firing = GameEvent::ManaAdded { + player_id: PlayerId(0), + mana_type: ManaType::Green, + source_id: producer, + tap_state: ManaTapState::default(), + }; + + let mana_effect = |target: Option| Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target, + }; + + let mut contexts = Vec::new(); + let candidate = + |state: &mut GameState, label: &str, configure: &dyn Fn(&mut PendingTrigger)| { + let card_id = CardId(state.next_object_id); + let source_id = create_object( + state, + card_id, + PlayerId(0), + label.to_string(), + Zone::Battlefield, + ); + let mut ability = + ResolvedAbility::new(mana_effect(None), vec![], source_id, PlayerId(0)); + ability.description = Some(label.to_string()); + let mut pending = PendingTrigger::ordinary( + source_id, + PlayerId(0), + None, + Box::new(ability), + state.next_timestamp() as u32, + ); + pending.trigger_event = Some(firing.clone()); + configure(&mut pending); + PendingTriggerContext::single(pending) + }; + + contexts.push(candidate(&mut state, "(a) accepted", &|_| {})); + contexts.push(candidate(&mut state, "(b) unless_pay", &|pending| { + pending.ability.unless_pay = Some(UnlessPayModifier { + cost: AbilityCost::Mana { + cost: crate::types::mana::ManaCost::zero(), + }, + payer: TargetFilter::Controller, + }); + })); + contexts.push(candidate(&mut state, "(c) modal", &|pending| { + pending.modal = Some(ModalChoice::default()); + pending.mode_abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + )]; + })); + // The surviving pause narrowing is the repeat family only: `optional` + // and `optional_for` are accepted now that `engine_payment_choices` + // carries their readiness hook, and `engine_resolution_choices` does not + // yet carry `RepeatDecision`'s. + contexts.push(candidate(&mut state, "(d) repeat", &|pending| { + pending.ability.repeat_until = + Some(crate::types::ability::RepeatContinuation::ControllerChoice); + })); + contexts.push(candidate(&mut state, "(e) inherited target", &|pending| { + pending.ability.targets = vec![TargetRef::Player(PlayerId(1))]; + })); + let slot_effect = mana_effect(Some(crate::types::ability::ManaTargetRole::Recipient { + recipient: TargetFilter::Player, + })); + contexts.push(candidate(&mut state, "(f) definition slot", &|pending| { + pending.ability.effect = slot_effect.clone(); + })); + contexts.push(candidate( + &mut state, + "(g) wrong firing event", + &|pending| { + pending.trigger_event = Some(GameEvent::AttackersDeclared { + attacker_ids: Vec::new(), + defending_player: PlayerId(1), + attacks: Vec::new(), + }); + }, + )); + + // Positive reach guard: every candidate really is coupled to the frame + // and really would qualify structurally but for its one distinguishing + // axis — (a) proves the accepted shape exists at all. + let accepted_labels: Vec<_> = contexts + .iter() + .filter(|context| { + matches!( + classify_pending_triggered_mana(&mut state.clone(), (*context).clone()), + PendingTriggeredManaClass::Accepted(_) + ) + }) + .filter_map(|context| context.pending.ability.description.clone()) + .collect(); + let accepted_count = accepted_labels.len(); + assert_eq!( + accepted_count, 1, + "exactly one of the seven complete contexts may take stackless placement; \ + accepted = {accepted_labels:?}" + ); + + let pool_before = state.players[0].mana_pool.total(); + let mut events = Vec::new(); + let (accepted, ordinary) = + partition_collected_mana_frame_contexts(&mut state, contexts.clone()); + assert_eq!(accepted.len(), 1); + assert_eq!( + ordinary.len(), + 6, + "every rejected context is preserved for the ordinary authority exactly once" + ); + assert_eq!( + accepted[0].context.pending.ability.description.as_deref(), + Some("(a) accepted"), + "the accepted view must carry the complete context unchanged" + ); + let pause = run_accepted_triggered_mana_fixed_point( + &mut state, + accepted, + ManaTriggerFixedPointResume::Parent, + &mut events, + ); + + assert!(pause.is_none(), "a pause-free accepted body settles inline"); + assert!( + state.players[0].mana_pool.total() > pool_before, + "(a) CR 605.4a: the accepted body's mana is in the pool before the frame returns" + ); + assert!( + state.stack.is_empty(), + "CR 605.4a: immediate placement creates no stack object for any context" + ); + assert!( + state.pending_trigger.is_none() && state.pending_trigger_entry.is_none(), + "immediate placement never writes a construction cursor" + ); + assert!( + state.pending_triggered_mana_resume.is_none() + && state.active_accepted_triggered_mana_node.is_none(), + "a synchronous occurrence leaves no sidecar and restores its marker" + ); + assert!( + state.deferred_triggers.is_empty(), + "the driver itself neither queues nor drains: rejected contexts are returned to \ + the caller's ordinary authority" + ); + let deferred_labels: Vec<_> = ordinary + .iter() + .filter_map(|context| context.pending.ability.description.clone()) + .collect(); + for label in [ + "(b) unless_pay", + "(c) modal", + "(d) repeat", + "(e) inherited target", + "(f) definition slot", + "(g) wrong firing event", + ] { + assert!( + deferred_labels.iter().any(|found| found == label), + "{label} must be preserved for ordinary dispatch rather than dropped" + ); + } + let inherited = ordinary + .iter() + .find(|context| { + context.pending.ability.description.as_deref() == Some("(e) inherited target") + }) + .expect("(e) is preserved for ordinary dispatch"); + assert_eq!( + inherited.pending.ability.targets, + vec![TargetRef::Player(PlayerId(1))], + "a rejected context's pre-existing referent is preserved, never purged" + ); + } + + /// Positive control for the rows above: the extraction must not have broken + /// ordinary delayed matching. A matching one-shot still fires, is removed + /// exactly once by the match collector, and is never recorded as a terminal + /// by the separated unmatched-reflexive pass. + #[test] + fn closing_boundary_still_fires_and_removes_a_matching_one_shot_once() { + use crate::types::ability::DelayedTriggerCondition; + + let mut state = setup(); + let card_id = CardId(state.next_object_id); + let source_id = create_object( + &mut state, + card_id, + PlayerId(0), + "Matching delayed source".to_string(), + Zone::Battlefield, + ); + let victim = make_creature(&mut state, PlayerId(0), "Victim", 1, 1); + let origin = DelayedTriggerOrigin { + token: DelayedTriggerToken(60_313), + instance: DelayedTriggerInstanceId(60_313), + source_id, + }; + state.delayed_triggers.push(DelayedTrigger { + condition: DelayedTriggerCondition::WhenDies { + filter: TargetFilter::Any, + }, + ability: Box::new(ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + )), + controller: PlayerId(0), + source_id, + one_shot: true, + provenance: DelayedInstallIdentity::ReceiptEligible(origin), + }); + + let batch = vec![zone_changed_event( + victim, + Zone::Battlefield, + Zone::Graveyard, + vec![CoreType::Creature], + vec![], + )]; + + let guard = super::super::lifecycle::enter_action_frame(); + check_delayed_triggers(&mut state, &batch); + let facts = guard + .take_outer_facts() + .expect("outer action owns lifecycle facts"); + + assert!( + state.delayed_triggers.is_empty(), + "a matched one-shot is removed exactly once by the match collector" + ); + assert!( + !facts.receipt_terminalized(origin), + "a matched instance must never reach the unmatched-reflexive terminalizer" + ); + assert_eq!( + state.stack.len(), + 1, + "the matched delayed trigger reaches the stack through the ordinary dispatcher" + ); + } + + /// CR 603.12: a *partial* frame-local batch must be able to run the shared + /// match-only collector without ending an outer reflexive's lifetime. Only a + /// closing boundary terminalizes. + #[test] + fn match_only_collection_leaves_an_unmatched_reflexive_installed() { + let mut state = setup(); + let (_, origin) = reflexive_delayed_trigger( + &mut state, + "Outer reflexive", + crate::types::triggers::TriggerMode::SpellCast, + 60_314, + ); + + let child_batch = vec![GameEvent::PhaseChanged { + phase: crate::types::phase::Phase::Upkeep, + }]; + + let guard = super::super::lifecycle::enter_action_frame(); + let batch = collect_pending_and_delayed_triggers_for_batch( + &mut state, + Vec::new(), + &child_batch, + DelayedTriggerEventScope::Any, + ); + let facts = guard + .take_outer_facts() + .expect("outer action owns lifecycle facts"); + + assert!(batch.contexts.is_empty()); + assert!(!batch.normal_was_non_empty); + assert_eq!( + state.delayed_triggers.len(), + 1, + "a frame-local micro-batch must not expire an outer reflexive" + ); + assert!( + !facts.receipt_terminalized(origin), + "collection-only must record no terminal disposition" + ); + } + + /// The phase closing processor must keep its narrow `PhaseChangedOnly` scope + /// for both matching and unmatched-reflexive cleanup: an unrelated non-phase + /// reflexive stays installed even though the batch also carries its event. + #[test] + fn phase_scope_stays_narrow_for_matching_and_terminalization() { + let mut state = setup(); + let (_, unrelated) = reflexive_delayed_trigger( + &mut state, + "Unrelated non-phase reflexive", + crate::types::triggers::TriggerMode::SpellCast, + 60_315, + ); + let seed = vec![make_draw_pending_trigger(&mut state, "Seed", PlayerId(0))]; + + let batch = vec![GameEvent::PhaseChanged { + phase: crate::types::phase::Phase::Upkeep, + }]; + + let guard = super::super::lifecycle::enter_action_frame(); + let mut events_out = Vec::new(); + let outcome = process_collected_triggers_with_delayed_phase_events( + &mut state, + seed, + &batch, + &mut events_out, + ); + let facts = guard + .take_outer_facts() + .expect("outer action owns lifecycle facts"); + + assert!( + outcome.fired, + "the pre-collected normal seed still dispatches" + ); + assert_eq!( + state.delayed_triggers.len(), + 1, + "PhaseChangedOnly must not expire an unrelated non-phase reflexive" + ); + assert!( + !facts.receipt_terminalized(unrelated), + "widening the phase scope to Any would terminalize this row" + ); + } + #[test] fn resolution_completion_requires_an_empty_resolution_stack() { let mut state = setup(); @@ -32475,6 +34092,770 @@ pub mod tests { ); } + #[test] + fn pending_cost_move_and_deferred_life_owners_block_until_released() { + let mut state = setup(); + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher A", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher B", PlayerId(0)), + ]; + state.pending_cost_move_resume = Some(PendingCostMoveResume::Foretell { + player: PlayerId(0), + object_id: ObjectId(100), + cost: ManaCost::NoCost, + turn_foretold: 1, + }); + assert!(!resolution_completion_can_settle(&state)); + + state.pending_cost_move_resume = None; + state.pending_deferred_life_cost_resume = Some(DeferredLifeCostResume::PayAmount { + player: PlayerId(0), + total: 3, + resume_at_resolution_depth: 0, + }); + assert!(!resolution_completion_can_settle(&state)); + + state.pending_deferred_life_cost_resume = None; + assert!(resolution_completion_can_settle(&state)); + let mut events = Vec::new(); + assert!(matches!( + drain_deferred_trigger_queue(&mut state, &mut events), + Some(WaitingFor::OrderTriggers { .. }) + )); + } + + /// Build a `TriggeredManaResume` naming `node`, with an otherwise inert + /// accepted work item. The row-level assertions never read its body; what + /// matters is that the durable carrier exists and names a node. + fn triggered_mana_sidecar( + state: &mut GameState, + node: crate::types::resolved_commands::RulesExecutionNodeRef, + ) -> Box { + let source = create_object( + state, + CardId(state.next_object_id), + PlayerId(0), + "Accepted triggered mana source".to_string(), + Zone::Battlefield, + ); + let pending = PendingTrigger::ordinary( + source, + PlayerId(0), + None, + Box::new(ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + Vec::new(), + source, + PlayerId(0), + )), + state.turn_number, + ); + Box::new(crate::types::game_state::TriggeredManaResume { + current: Box::new(PendingTriggerContext::single(pending)), + current_override: None, + rules_execution_node: node, + accepted_tail: Vec::new(), + collected_batches: Vec::new(), + outer_resume: crate::types::game_state::ManaTriggerFixedPointResume::Parent, + stage: crate::types::game_state::TriggeredManaStage::ResolvingBody, + }) + } + + /// Round-20 closure map, "Why the carrier is not part of + /// `resolution_completion_can_settle`": the settlement predicate takes the + /// triggered-mana sidecar **and only that carrier**. + /// + /// The construction priority recipient is deliberately excluded. + /// `can_drain_deferred_triggers` consults this predicate first, so putting + /// the recipient in it would reject the settled wrapper's own drain, the + /// construction finisher's drain, and every deferred-sibling drain in the + /// same batch — the batch would deadlock on its own recipient. The third + /// assertion below is that revert discriminator: a regression that adds the + /// recipient to the predicate turns the `OrderTriggers` drain into `None`. + #[test] + fn settlement_predicate_takes_the_mana_sidecar_and_never_the_construction_recipient() { + let mut state = setup(); + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher A", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher B", PlayerId(0)), + ]; + let baseline = resolution_completion_can_settle(&state); + assert!( + baseline, + "positive reach guard: the batch settles at baseline" + ); + + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + assert_eq!( + resolution_completion_can_settle(&state), + baseline, + "a live construction recipient must not change the settlement result" + ); + + let mut events = Vec::new(); + assert!( + matches!( + drain_deferred_trigger_queue(&mut state, &mut events), + Some(WaitingFor::OrderTriggers { .. }) + ), + "a settled batch must still drain with the construction recipient installed" + ); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)), + "the predicate must not consume or rewrite the recipient" + ); + + // Rebuild the batch the drain just consumed, then prove the other side. + let mut state = setup(); + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher A", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher B", PlayerId(0)), + ]; + let node = crate::types::resolved_commands::RulesExecutionNodeRef::TriggeredMana( + crate::types::resolved_commands::SettlementNodeOrdinal(7), + ); + state.pending_triggered_mana_resume = Some(triggered_mana_sidecar(&mut state, node)); + assert!( + !resolution_completion_can_settle(&state), + "an accepted triggered-mana occurrence owns its own emitted-event fixed point" + ); + let mut events = Vec::new(); + assert!( + drain_deferred_trigger_queue(&mut state, &mut events).is_none(), + "no ordinary drain may run while the sidecar is live" + ); + + state.pending_triggered_mana_resume = None; + assert!( + resolution_completion_can_settle(&state), + "removing the sidecar restores the baseline result exactly" + ); + } + + /// One instance of each of the six approved trigger-construction prompts, + /// in the census order of `is_trigger_construction_prompt`. + fn approved_construction_prompts() -> Vec { + vec![ + WaitingFor::OrderTriggers { + player: PlayerId(0), + triggers: Vec::new(), + }, + WaitingFor::NamedChoice { + player: PlayerId(0), + choice_type: crate::types::ability::ChoiceType::Player { + distinctness: Default::default(), + }, + options: vec!["P2".to_string()], + source: None, + persist_player: None, + free_entry: None, + }, + WaitingFor::OptionalEffectChoice { + player: PlayerId(0), + source_id: ObjectId(1), + description: None, + may_trigger_key: None, + }, + WaitingFor::AbilityModeChoice { + player: PlayerId(0), + modal: crate::types::ability::ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }, + source_id: ObjectId(1), + mode_abilities: Vec::new(), + is_activated: false, + ability_index: None, + ability_cost: None, + unavailable_modes: Vec::new(), + }, + WaitingFor::TriggerTargetSelection { + player: PlayerId(0), + trigger_controller: Some(PlayerId(0)), + trigger_event: None, + trigger_events: Vec::new(), + target_slots: Vec::new(), + mode_labels: Vec::new(), + target_constraints: Vec::new(), + selection: Default::default(), + source_id: None, + description: None, + }, + WaitingFor::DistributeAmong { + player: PlayerId(0), + total: 2, + targets: Vec::new(), + unit: crate::types::game_state::DistributionUnit::Damage, + }, + ] + } + + /// Round-20 closure map, "Exact preserve/finish contract" clause 1: with no + /// recipient installed, the finisher returns `produced` byte-for-byte and + /// performs no drain. + /// + /// This is the entire ordinary-caller contract. Every existing + /// controller/active-player fallback at the seven seams depends on it, so + /// the row includes a terminal `Priority` with a *drainable* queue behind it + /// — exactly the shape clause 3 would drain — and requires the queue to be + /// left untouched. + #[test] + fn construction_finisher_is_a_no_op_without_a_construction_recipient() { + for produced in approved_construction_prompts() + .into_iter() + .chain([WaitingFor::Priority { + player: PlayerId(0), + }]) + { + let mut state = drain_policy_fixture(false); + state.trigger_construction_finisher_ran_this_action = false; + let mut events = Vec::new(); + let returned = + finish_trigger_construction_action(&mut state, &mut events, produced.clone()); + assert_eq!(returned, produced, "the ordinary contract is byte-for-byte"); + assert_eq!( + state.deferred_triggers.len(), + 2, + "an ordinary caller performs no drain: {produced:?}" + ); + assert!(events.is_empty()); + assert!(state + .pending_trigger_construction_priority_recipient + .is_none()); + } + } + + /// Clause 2: with a recipient installed and one of the six approved + /// construction prompts, the finisher returns the real prompt and leaves the + /// recipient installed for the next construction step. + #[test] + fn construction_finisher_retains_the_recipient_across_every_approved_prompt() { + for produced in approved_construction_prompts() { + let mut state = drain_policy_fixture(false); + state.trigger_construction_finisher_ran_this_action = false; + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + let mut events = Vec::new(); + let returned = + finish_trigger_construction_action(&mut state, &mut events, produced.clone()); + assert_eq!(returned, produced, "the real prompt is surfaced unchanged"); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)), + "the recipient is retained across {produced:?}" + ); + assert_eq!( + state.deferred_triggers.len(), + 2, + "an intermediate prompt does not drain the tail" + ); + } + } + + /// Clause 3: a `Priority` produced by a successful announcement, a dangling + /// recovery, or an optional decline is only terminal once the whole deferred + /// tail behind it has announced. + /// + /// Baseline's `abandon_ceased_pending_trigger` deliberately leaves deferred + /// siblings installed and both dangling recoveries return `Priority` without + /// draining, which is exactly why this drain lives in the finisher rather + /// than at each return. The row proves both halves: a queue that still owes + /// an ordering choice surfaces `OrderTriggers` with the recipient retained, + /// and only an exhausted tail consumes the recipient and returns + /// `Priority { player }` — the carried player, not the produced one. + #[test] + fn construction_finisher_drains_the_tail_before_consuming_the_recipient() { + let mut state = drain_policy_fixture(false); + state.trigger_construction_finisher_ran_this_action = false; + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + let mut events = Vec::new(); + let returned = finish_trigger_construction_action( + &mut state, + &mut events, + WaitingFor::Priority { + player: PlayerId(0), + }, + ); + assert!( + matches!(returned, WaitingFor::OrderTriggers { .. }), + "a queued same-controller batch is not terminal: {returned:?}" + ); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)), + "the drain's own prompt retains the recipient" + ); + + // Exhausted tail: the recipient is consumed and the returned wait names + // the carried player rather than the produced one. + let mut state = setup(); + state.trigger_construction_finisher_ran_this_action = false; + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + let mut events = Vec::new(); + let returned = finish_trigger_construction_action( + &mut state, + &mut events, + WaitingFor::Priority { + player: PlayerId(0), + }, + ); + assert_eq!( + returned, + WaitingFor::Priority { + player: PlayerId(1) + }, + "an exhausted tail hands priority to the carried recipient" + ); + assert!( + state + .pending_trigger_construction_priority_recipient + .is_none(), + "no durable recipient may survive an action returning ordinary priority" + ); + } + + /// Round-20 audit class (b), "Finisher branch: carrier live, queue + /// non-empty, drain ineligible." + /// + /// The settled-root gate proves this state absent upstream, so this is a + /// defensive unit proof and never action coverage. What it must show is that + /// the finisher consumes the recipient rather than leaking it: an + /// undrainable queue is not a reason to leave a durable recipient installed + /// across an action that returns ordinary priority. + #[test] + fn construction_finisher_consumes_rather_than_leaks_an_undrainable_queue() { + let mut state = drain_policy_fixture(false); + state.trigger_construction_finisher_ran_this_action = false; + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + // A live terminal-resolution marker is one of the three states the plan + // names for this branch: the post-announcement drain returns early on + // `pending_resolution_completion` before it ever reaches the policy. + state.pending_resolution_completion = + Some(crate::types::game_state::PendingResolutionCompletion { + player: PlayerId(0), + source_id: ObjectId(1), + final_cast: None, + }); + let mut probe = Vec::new(); + assert!( + drain_deferred_triggers_after_trigger_construction(&mut state, &mut probe).is_none(), + "positive reach guard: the construction drain really is ineligible" + ); + assert_eq!(state.deferred_triggers.len(), 2); + let mut events = Vec::new(); + let returned = finish_trigger_construction_action( + &mut state, + &mut events, + WaitingFor::Priority { + player: PlayerId(0), + }, + ); + assert_eq!( + returned, + WaitingFor::Priority { + player: PlayerId(1) + } + ); + assert!(state + .pending_trigger_construction_priority_recipient + .is_none()); + assert_eq!( + state.deferred_triggers.len(), + 2, + "the undrainable queue is left exactly as found" + ); + } + + /// Round-20 audit class (b), "Finisher branch: carrier live, non-`Priority`, + /// non-approved wait." The six approved prompts are the closed census of + /// what the seven seams can produce, so anything else means a seam was added + /// below dispatch. Do not coerce, do not consume, do not invent a recipient. + #[test] + #[should_panic(expected = "outside the closed")] + fn construction_finisher_rejects_a_wait_outside_the_closed_census() { + let mut state = setup(); + state.trigger_construction_finisher_ran_this_action = false; + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + let mut events = Vec::new(); + let _ = finish_trigger_construction_action( + &mut state, + &mut events, + WaitingFor::GameOver { + winner: Some(PlayerId(0)), + }, + ); + } + + /// The install rule: reinstalling the same player is idempotent, and + /// observing a different player while the field is live is an internal + /// invariant error rather than a silent overwrite. (`elimination.rs`'s + /// CR 800.4a re-point past a departed recipient deliberately does not come + /// through this authority.) + #[test] + fn preserving_the_same_construction_recipient_twice_is_idempotent() { + let mut state = setup(); + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + assert_eq!( + state.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)) + ); + } + + #[test] + #[should_panic(expected = "different construction priority recipient")] + fn preserving_a_different_construction_recipient_while_live_is_an_invariant_error() { + let mut state = setup(); + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(1)); + preserve_trigger_construction_priority_recipient(&mut state, PlayerId(0)); + } + + /// Build a two-observer batch that raises `OrderTriggers` when it drains, + /// optionally under a passive announced spell. + fn drain_policy_fixture(spell_on_stack: bool) -> GameState { + let mut state = setup(); + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher A", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher B", PlayerId(0)), + ]; + if spell_on_stack { + let card_id = CardId(state.next_object_id); + let spell = create_object( + &mut state, + card_id, + PlayerId(0), + "Passive announced spell".to_string(), + Zone::Stack, + ); + state.stack.push_back(StackEntry { + id: spell, + source_id: spell, + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(2), + ability: None, + casting_variant: Default::default(), + actual_mana_spent: 0, + }, + }); + } + state + } + + /// Plan v23 Step 5, "replace the existing two drain booleans inside the + /// shared core with private `DeferredTriggerDrainPolicy`". + /// + /// `engine_priority`'s `skip_deferred_trigger_drain` and this module's + /// `allow_spell_on_stack` were two independent axes carried as two + /// unrelated booleans. The enum has to keep them independent — collapsing + /// them (making `Skip` drain, or letting `ResolutionSafe` tolerate a + /// `Spell` entry) flips exactly one assertion below — while leaving the + /// shared carrier census in `resolution_completion_can_settle` in front of + /// every variant. + #[test] + fn deferred_trigger_drain_policy_separates_the_skip_and_spell_on_stack_axes() { + // Positive reach guard: with no spell on the stack, the resolution-safe + // policy really drains this batch. + let mut state = drain_policy_fixture(false); + let mut events = Vec::new(); + assert!( + matches!( + drain_deferred_trigger_queue_with_policy( + &mut state, + &mut events, + DeferredTriggerDrainPolicy::ResolutionSafe, + ), + Some(WaitingFor::OrderTriggers { .. }) + ), + "positive reach guard: ResolutionSafe drains an unobstructed batch" + ); + assert!(state.deferred_triggers.is_empty()); + + // `Skip` owns no drain at all, and leaves the queue exactly as it found it. + let mut state = drain_policy_fixture(false); + let mut events = Vec::new(); + assert!( + drain_deferred_trigger_queue_with_policy( + &mut state, + &mut events, + DeferredTriggerDrainPolicy::Skip, + ) + .is_none(), + "Skip must not drain even when every other gate is open" + ); + assert_eq!( + state.deferred_triggers.len(), + 2, + "Skip must leave the queue intact for its owning caller" + ); + assert!(events.is_empty()); + + // CR 603.3b + issue #1793: a `Spell` entry blocks the resolution-safe + // policy and only the resolution-safe policy. + let mut state = drain_policy_fixture(true); + let mut events = Vec::new(); + assert!( + drain_deferred_trigger_queue_with_policy( + &mut state, + &mut events, + DeferredTriggerDrainPolicy::ResolutionSafe, + ) + .is_none(), + "ResolutionSafe must refuse to order observers above a spell on the stack" + ); + assert_eq!(state.deferred_triggers.len(), 2); + + // CR 601.2h + CR 602.2b: the post-announcement policy drains the same + // batch above the same spell. + let mut state = drain_policy_fixture(true); + let mut events = Vec::new(); + assert!( + matches!( + drain_deferred_trigger_queue_with_policy( + &mut state, + &mut events, + DeferredTriggerDrainPolicy::SettledPriority, + ), + Some(WaitingFor::OrderTriggers { .. }) + ), + "SettledPriority drains above the passive announced spell" + ); + + // Every policy still runs behind the shared carrier census: a live + // triggered-mana sidecar rejects both draining variants. + for policy in [ + DeferredTriggerDrainPolicy::ResolutionSafe, + DeferredTriggerDrainPolicy::SettledPriority, + ] { + let mut state = drain_policy_fixture(false); + let node = crate::types::resolved_commands::RulesExecutionNodeRef::TriggeredMana( + crate::types::resolved_commands::SettlementNodeOrdinal(3), + ); + state.pending_triggered_mana_resume = Some(triggered_mana_sidecar(&mut state, node)); + let mut events = Vec::new(); + assert!( + drain_deferred_trigger_queue_with_policy(&mut state, &mut events, policy).is_none(), + "{policy:?} must still consult resolution_completion_can_settle" + ); + assert_eq!(state.deferred_triggers.len(), 2); + } + } + + /// Round-18 closure, "Baseline bypass and exact ownership guard": both + /// observer helpers are collection and partial-release authorities, so they + /// must fail closed on sidecar ownership *before* collecting or draining. + /// + /// Either authority alone suffices, and the row proves both independently: + /// a scoped sidecar action can move the durable carrier out of `GameState` + /// (leaving only the live accepted-node marker), and at a resting pause the + /// durable carrier is present while the lexical marker has been restored. + #[test] + fn both_observer_helpers_are_no_ops_under_either_triggered_mana_authority() { + let node = crate::types::resolved_commands::RulesExecutionNodeRef::TriggeredMana( + crate::types::resolved_commands::SettlementNodeOrdinal(11), + ); + + // Positive reach guard: with neither authority, each helper really + // collects this event. + for settled in [false, true] { + let (mut state, events) = observer_helper_fixture(settled); + let mut events = events; + if settled { + collect_and_drain_observer_triggers_if_settled(&mut state, &mut events, 0); + } else { + park_observer_triggers_if_paused(&mut state, &events, 0); + } + assert!( + !state.deferred_triggers.is_empty() || !state.stack.is_empty(), + "positive reach guard (settled={settled}): the ordinary helper collects" + ); + } + + // Durable carrier only (resting pause). + for settled in [false, true] { + let (mut state, mut events) = observer_helper_fixture(settled); + state.pending_triggered_mana_resume = Some(triggered_mana_sidecar(&mut state, node)); + if settled { + collect_and_drain_observer_triggers_if_settled(&mut state, &mut events, 0); + } else { + park_observer_triggers_if_paused(&mut state, &events, 0); + } + assert!( + state.deferred_triggers.is_empty() && state.stack.is_empty(), + "durable sidecar (settled={settled}): the helper must collect nothing" + ); + } + + // Live marker only (scoped sidecar action). + for settled in [false, true] { + let (mut state, mut events) = observer_helper_fixture(settled); + state.active_accepted_triggered_mana_node = Some(node); + state.active_rules_execution_node = Some(node); + if settled { + collect_and_drain_observer_triggers_if_settled(&mut state, &mut events, 0); + } else { + park_observer_triggers_if_paused(&mut state, &events, 0); + } + assert!( + state.deferred_triggers.is_empty() && state.stack.is_empty(), + "live accepted marker (settled={settled}): the helper must collect nothing" + ); + } + } + + /// Round-18 closure: the ordinary (no-sidecar) branch of both helpers now + /// derives its suffix through `filter_consumed_trigger_events_from`, so an + /// exact occurrence already claimed by `ConsumeBeforePriority` cannot be + /// recollected — while an equal-looking *unclaimed* occurrence still is. + /// + /// The journal is borrowed, never taken: unrelated callers with an empty + /// journal stay behaviour-identical, which the positive reach guard in the + /// row above asserts. + #[test] + fn ordinary_observer_helpers_filter_one_preclaimed_occurrence_out_of_two_equal_events() { + for settled in [false, true] { + let (mut state, mut events) = observer_helper_fixture(settled); + // Two equal-looking full-buffer occurrences of the same event. + let repeated = events[0].clone(); + events.push(repeated.clone()); + state.consumed_before_priority_trigger_events = vec![ConsumedTriggerEventOccurrence { + event: repeated.clone(), + occurrence: trigger_event_occurrence(&events, 0), + }]; + + if settled { + collect_and_drain_observer_triggers_if_settled(&mut state, &mut events, 0); + } else { + park_observer_triggers_if_paused(&mut state, &events, 0); + } + + let collected = state.deferred_triggers.len() + state.stack.len(); + assert_eq!( + collected, 1, + "settled={settled}: exactly the unclaimed second occurrence is collected" + ); + assert_eq!( + state.consumed_before_priority_trigger_events.len(), + 1, + "settled={settled}: the helper borrows the journal, it never takes it" + ); + } + } + + /// One battlefield observer whose `Taps` trigger fires on the single event + /// in the returned buffer. `settled` selects the wait each helper requires + /// to reach its collecting branch. + fn observer_helper_fixture(settled: bool) -> (GameState, Vec) { + let mut state = setup(); + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + let observer = make_creature(&mut state, PlayerId(0), "Tap observer", 2, 2); + let observer_trigger = TriggerDefinition::new(TriggerMode::Taps) + .valid_card(TargetFilter::Typed(TypedFilter::creature())) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )); + let object = state.objects.get_mut(&observer).expect("observer exists"); + object.trigger_definitions.push(observer_trigger.clone()); + std::sync::Arc::make_mut(&mut object.base_trigger_definitions).push(observer_trigger); + object.materialize_base_trigger_definitions(); + + let tapper = make_creature(&mut state, PlayerId(0), "Tapper", 2, 2); + let victim = make_creature(&mut state, PlayerId(1), "Tapped victim", 2, 2); + let events = vec![GameEvent::PermanentTapped { + object_id: victim, + caused_by: Some(tapper), + }]; + + state.waiting_for = if settled { + WaitingFor::Priority { + player: PlayerId(0), + } + } else { + WaitingFor::OptionalEffectChoice { + player: PlayerId(0), + source_id: observer, + description: Some("paused".to_string()), + may_trigger_key: None, + } + }; + (state, events) + } + + /// CR 601.2h + CR 602.2b: the post-announcement drain's cast guard reads the + /// EXTERNAL `pending_cast` carrier, and deliberately does NOT read + /// `state.waiting_for`. + /// + /// The second leg is the load-bearing one. `state.waiting_for` is the wait + /// the current reducer action STARTED from — `apply_action` assigns the + /// handler's returned wait only after the handler returns — so an inline + /// carrier read here is a read of the past. The concrete casualty is an + /// additional-sacrifice cast that paused on `TargetSelection`: its + /// `CostResume::Spell` still holds that stale wait while `casting_targets` + /// finalizes the announcement and calls in, so the cost-sacrifice observers + /// would stay parked behind the announced spell forever + /// (`casting_costs::tests::cost_paid_multi_sacrifice_kicker_paused_under_observes` + /// is that row, and it is what fails if the read is restored). Every real + /// boundary excludes an inline carrier structurally instead — see the + /// contract comment on the function. + #[test] + fn the_external_pending_cast_carrier_blocks_the_post_announcement_drain() { + let mut state = setup(); + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher A", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher B", PlayerId(0)), + ]; + let ability = ResolvedAbility::new(Effect::NoOp, Vec::new(), ObjectId(101), PlayerId(0)); + let pending = PendingCast::new(ObjectId(101), CardId(101), ability, ManaCost::NoCost); + state.pending_cast = Some(Box::new(pending.clone())); + let mut events = Vec::new(); + assert!( + drain_deferred_triggers_after_stack_object_announcement(&mut state, &mut events) + .is_none(), + "an unconsumed external cast root blocks the drain" + ); + + // A STALE inline carrier must not block: this is the exact state the + // mid-action callers are in when they finalize an announcement. + state.pending_cast = None; + state.waiting_for = WaitingFor::ModeChoice { + player: PlayerId(0), + modal: ModalChoice::default(), + pending_cast: Box::new(pending), + unavailable_modes: Vec::new(), + }; + assert!( + matches!( + drain_deferred_triggers_after_stack_object_announcement(&mut state, &mut events), + Some(WaitingFor::OrderTriggers { .. }) + ), + "the drain reads the external carrier, never the action's incoming wait" + ); + + // Positive reach guard for the first leg: the same queue drains once the + // external carrier is gone. + state.deferred_triggers = vec![ + make_draw_pending_trigger(&mut state, "Watcher C", PlayerId(0)), + make_draw_pending_trigger(&mut state, "Watcher D", PlayerId(0)), + ]; + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + assert!(matches!( + drain_deferred_triggers_after_stack_object_announcement(&mut state, &mut events), + Some(WaitingFor::OrderTriggers { .. }) + )); + } + /// Issue #1793: at a true resolution boundary, 2+ same-controller deferred /// triggers must surface CR 603.3b ordering before dispatch. #[test] @@ -35041,6 +37422,45 @@ pub mod tests { panic!("stack did not settle: waiting_for={:?}", state.waiting_for); } + /// CR 603.3b + CR 603.12: drive to the next REAL player decision, answering + /// any CR 603.3b ordering prompt along the way in the order the engine + /// offered. + /// + /// A materialized reflexive trigger is an ordinary member of the deferred + /// APNAP batch, so a same-controller batch that previously held only the + /// co-triggered observer now also holds the reflexive and surfaces an + /// ordering prompt that the inline-resolution flow never showed. + /// [`resolve_stack_until_paused`] treats that prompt as a terminal pause and + /// returns without resolving anything, which is correct for its other ~30 + /// callers and wrong for the reflexive rows — hence a separate driver rather + /// than a change to the shared one. + /// + /// Returns as soon as a non-ordering wait is reached (a genuine choice) or + /// the stack empties at `Priority`. + fn settle_answering_trigger_order(state: &mut GameState) { + for _ in 0..30 { + match &state.waiting_for { + WaitingFor::OrderTriggers { triggers, .. } => { + let order: Vec = (0..triggers.len()).collect(); + crate::game::engine::apply_as_current( + state, + GameAction::OrderTriggers { order }, + ) + .expect("submit the CR 603.3b ordering prompt"); + } + WaitingFor::Priority { .. } if !state.stack.is_empty() => { + crate::game::engine::apply_as_current(state, GameAction::PassPriority) + .expect("pass priority"); + } + _ => return, + } + } + panic!( + "did not settle while answering trigger order: waiting_for={:?}", + state.waiting_for + ); + } + /// CR 702.93a + issue #423 (4a, B1 baseline): an Undying creature with zero /// +1/+1 counters sacrificed inside the `EffectZoneChoice` resolution /// handler must still fire its dies-trigger and return to the battlefield @@ -35110,7 +37530,14 @@ pub mod tests { // The Undying trigger must have been collected and dispatched; resolve // whatever reached the stack. - resolve_stack_until_paused(&mut state); + // + // CR 603.12 + CR 603.3b: the reflexive `Destroy SelfRef` is now a + // CREATED reflexive trigger rather than an inline continuation, so it is + // an ordinary member of the same same-controller deferred batch as the + // Undying dies-trigger and the batch opens a CR 603.3b ordering prompt. + // Answer it and keep going; the row's claim (Undying fires and returns + // the creature) is unchanged. + settle_answering_trigger_order(&mut state); let obj = state.objects.get(&young_wolf).expect("object tracked"); assert_eq!( @@ -35454,59 +37881,45 @@ pub mod tests { ) .expect("select Young Wolf to sacrifice"); - // (ii) The reflexive `WhenYouDo` continuation was NOT dropped — it - // resolved into a second `EffectZoneChoice` (the opponent sacrifice). - assert!( - matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }), - "the reflexive opponent Sacrifice must raise an EffectZoneChoice, got {:?}", - state.waiting_for - ); - - // The Undying dies-trigger and the co-triggered observer trigger were - // batched into the deferred queue — they would be lost pre-fix. - assert_eq!( - state.deferred_triggers.len(), - 2, - "the Undying dies-trigger and the targeted observer trigger must \ - both be batched into deferred_triggers (issue #423)" - ); - - // Resolve the reflexive opponent sacrifice → the handler settles to - // `Priority` and drains the deferred queue. - crate::game::engine::apply_as_current( - &mut state, - GameAction::SelectCards { cards: vec![opp_a] }, - ) - .expect("opponent sacrifices a creature"); - - // #1793: the deferred flush now routes through `begin_trigger_ordering`, - // so the two same-controller deferred triggers (Undying dies-trigger + - // targeted observer) surface a CR 603.3b ordering prompt before - // dispatch. Order the no-input Undying trigger first so it reaches the - // stack, leaving the targeted observer to pause on its own target - // selection. + // (ii) CR 603.12 + CR 603.3b: the reflexive `WhenYouDo` opponent + // Sacrifice was NOT dropped — but it is no longer an inline continuation + // that pauses the parent resolution on a second `EffectZoneChoice`. It + // is a CREATED reflexive trigger, so the parent resolution RUNS TO + // COMPLETION and the reflexive joins the very same same-controller + // deferred batch as the two dies-triggers. That is what structurally + // removes this row's original blocker: a reflexive can no longer strand + // a co-triggered batch by pausing mid-resolution, because it never + // pauses mid-resolution. let WaitingFor::OrderTriggers { triggers: order_choices, .. } = &state.waiting_for else { panic!( - "the two same-controller deferred triggers must surface a CR 603.3b \ - ordering prompt after the deferred flush, got {:?}", + "the co-triggered batch (Undying dies-trigger + targeted observer + \ + the materialized reflexive) must surface one CR 603.3b ordering \ + prompt, got {:?}", state.waiting_for ); }; - // The reflexive opponent sacrifice itself kills a creature, so the - // dies-observer fires again for that death: the co-triggered group is - // the Undying dies-trigger plus one targeted observer per creature that - // died (Young Wolf + the sacrificed opponent). All are P0-controlled and - // ordered together (CR 603.3b). - assert!( - order_choices.len() >= 2, - "the co-triggered group (Undying dies-trigger + targeted dies-observers) \ - must be ordered together (issue #423), got {}", - order_choices.len() + let names: Vec<&str> = order_choices + .iter() + .map(|t| t.source_name.as_str()) + .collect(); + assert_eq!( + order_choices.len(), + 3, + "exactly three P0-controlled triggers are co-ordered, got {names:?}" ); + for expected in ["Young Wolf", "Grim Observer", "Grist Stand-In"] { + assert!( + names.contains(&expected), + "{expected} must be in the co-triggered group, got {names:?}" + ); + } + + // Order the no-input Undying trigger first so it reaches the stack, and + // leave the targeted observer and the reflexive behind it. let undying_idx = order_choices .iter() .position(|t| t.source_name == "Young Wolf") @@ -35522,7 +37935,8 @@ pub mod tests { ) .expect("submit deferred-trigger order"); - // (iii) The drained targeted observer reached its own target selection. + // (iii) The drained targeted observer reached its own target selection + // as it was put on the stack (CR 603.3d). assert!( matches!(state.waiting_for, WaitingFor::TriggerTargetSelection { .. }), "the targeted dies-observer must reach TriggerTargetSelection after the \ @@ -35538,15 +37952,20 @@ pub mod tests { "the Undying dies-trigger must have reached the stack via the deferred flush" ); - // Drive the flush to completion: each targeted dies-observer picks a - // legal Tap target (opp_b is always a legal creature target), and the - // stack resolves. The #423 invariant: nothing is dropped and Undying - // returns its creature to the battlefield. + // Drive the whole batch to completion. Each targeted dies-observer picks + // a legal Tap target (`opp_b` is always a legal creature target), each + // CR 603.3b ordering prompt is answered in engine order, and the + // reflexive opponent Sacrifice raises its `EffectZoneChoice` when IT + // resolves from the stack — answered with `opp_a`. The #423 invariant is + // unchanged: nothing is dropped, and Undying returns its creature. + let mut saw_reflexive_sacrifice_choice = false; let mut guard = 0; - while state.objects.get(&young_wolf).map(|o| o.zone) != Some(Zone::Battlefield) { + while state.objects.get(&young_wolf).map(|o| o.zone) != Some(Zone::Battlefield) + || !state.stack.is_empty() + { guard += 1; assert!( - guard < 16, + guard < 32, "issue #423 deferred flush failed to settle (state: {:?})", state.waiting_for ); @@ -35560,12 +37979,28 @@ pub mod tests { ) .expect("choose observer Tap target"); } - _ => { - resolve_stack_until_paused(&mut state); + WaitingFor::EffectZoneChoice { .. } => { + saw_reflexive_sacrifice_choice = true; + crate::game::engine::apply_as_current( + &mut state, + GameAction::SelectCards { cards: vec![opp_a] }, + ) + .expect("the reflexive opponent Sacrifice picks its victim"); } + _ => settle_answering_trigger_order(&mut state), } } + assert!( + saw_reflexive_sacrifice_choice, + "the materialized reflexive must resolve from the stack and raise its own \ + opponent-Sacrifice EffectZoneChoice" + ); + assert_eq!( + state.objects.get(&opp_a).map(|o| o.zone), + Some(Zone::Graveyard), + "the reflexive opponent Sacrifice must actually sacrifice its victim" + ); let wolf = state.objects.get(&young_wolf).expect("wolf tracked"); assert_eq!( wolf.zone, @@ -35736,7 +38171,10 @@ pub mod tests { }, ) .expect("select Plain Bear to sacrifice"); - resolve_stack_until_paused(&mut state); + // CR 603.12 + CR 603.3b: the reflexive `Destroy SelfRef` joins the same + // same-controller deferred batch as the Blood Artist-class observer, so + // the flush opens an ordering prompt before either can dispatch. + settle_answering_trigger_order(&mut state); assert_eq!( state.players[0].life, 21, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index bab7ff6a43..22edde1c34 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -251,6 +251,19 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // hidden card and target context. The projected WaitingFor is the only // viewer-facing interaction surface. filtered.pending_deferred_life_cost_resume = None; + // CR 605.4a: The triggered-mana continuation is trusted persistence + // authority. Its pending context can carry hidden object identities, + // last-known source snapshots, controller-only event batches, chosen + // players, legal-mode sets, the current rules-execution node, and a + // suspended parent/payment cursor. The projected WaitingFor is the complete + // public interaction surface, so this is cleared for every viewer — + // including the controller who owns the prompt — before the later + // per-controller pending-trigger redaction. + filtered.pending_triggered_mana_resume = None; + // CR 117.3c: The construction priority recipient is engine scheduling + // authority for who receives priority after the batch finishes announcing. + // No viewer projection carries it. + filtered.pending_trigger_construction_priority_recipient = None; // Resolution frames are server-authoritative continuations. They can carry // private object identities, trigger source contexts, and resolved ability // payloads; the separately projected `WaitingFor` prompt is the complete @@ -6157,6 +6170,154 @@ mod tests { ); } + /// CR 605.4a + CR 117.3c (plan Step 6): the triggered-mana continuation and + /// the trigger-construction priority recipient are trusted persistence + /// authority. They must survive an authoritative round trip exactly, and + /// must be absent from **every** viewer projection — including the + /// projection of the player who owns the live prompt. + /// + /// Each carrier gets a distinct sentinel so the two redaction lines are + /// independently revert-sensitive: deleting either clearing statement leaves + /// its own sentinel (the private description string, or the exact nonactive + /// `PlayerId`) reachable in a viewer snapshot while the other row still + /// passes. + #[test] + fn triggered_mana_sidecar_and_construction_recipient_are_erased_from_every_viewer() { + let (state, marker) = triggered_mana_projection_fixture(); + + // Trusted persistence retains both authorities exactly, and the live + // public prompt is unchanged. + let trusted = serde_json::to_value(&state).expect("authoritative state serializes"); + assert!( + trusted["pending_triggered_mana_resume"].is_object(), + "trusted persistence must retain the triggered-mana continuation" + ); + assert_eq!( + trusted["pending_trigger_construction_priority_recipient"], 1, + "trusted persistence must retain the exact carried recipient" + ); + let trusted_text = serde_json::to_string(&state).expect("authoritative state serializes"); + assert!( + trusted_text.contains(marker), + "test precondition: the private sidecar payload is really present" + ); + let restored: GameState = + serde_json::from_value(trusted).expect("the authoritative state round-trips"); + assert_eq!( + restored.pending_triggered_mana_resume, state.pending_triggered_mana_resume, + "the sidecar and its rules-execution node must survive serde exactly" + ); + assert_eq!( + restored.pending_trigger_construction_priority_recipient, + Some(PlayerId(1)), + "the carried recipient must survive serde exactly" + ); + + // The prompt owner is P0; P1 is the carried recipient; P2 is an + // unrelated opponent. None of them may receive either carrier. + for viewer in [PlayerId(0), PlayerId(1), PlayerId(2)] { + let filtered = filter_state_for_viewer(&state, viewer); + assert!( + filtered.pending_triggered_mana_resume.is_none(), + "viewer {viewer:?} must not receive the triggered-mana continuation" + ); + assert!( + filtered + .pending_trigger_construction_priority_recipient + .is_none(), + "viewer {viewer:?} must not receive the construction priority recipient" + ); + let wire = serde_json::to_string(&filtered).expect("the filtered snapshot serializes"); + assert!( + !wire.contains(marker), + "viewer {viewer:?} snapshot leaked the private sidecar payload" + ); + assert!( + !wire.contains("pending_triggered_mana_resume") + && !wire.contains("pendingTriggeredManaResume") + && !wire.contains("pending_trigger_construction_priority_recipient") + && !wire.contains("pendingTriggerConstructionPriorityRecipient"), + "viewer {viewer:?} snapshot leaked a carrier field name" + ); + assert!( + matches!( + filtered.waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ), + "the public prompt remains the complete viewer-facing surface" + ); + } + + assert!( + state.pending_triggered_mana_resume.is_some() + && state.pending_trigger_construction_priority_recipient == Some(PlayerId(1)), + "filtering must not alter the authoritative carriers" + ); + } + + /// A three-player authoritative state carrying both Step-6 authorities: + /// a live `TriggeredManaResume` whose pending context holds a private + /// description sentinel and a real `TriggeredMana` rules-execution node plus + /// an accepted tail, and a construction recipient naming nonactive P1 while + /// P0 owns the live prompt. Returns the private sentinel. + fn triggered_mana_projection_fixture() -> (GameState, &'static str) { + use crate::game::triggers::{PendingTrigger, PendingTriggerContext}; + use crate::types::ability::QuantityExpr; + use crate::types::game_state::{ + ManaTriggerFixedPointResume, TriggeredManaResume, TriggeredManaStage, + }; + use crate::types::resolved_commands::{RulesExecutionNodeRef, SettlementNodeOrdinal}; + + const MARKER: &str = "SIDECAR-PRIVATE-ORACLE-SENTINEL"; + + let mut state = GameState::new(FormatConfig::standard(), 3, 42); + state.next_object_id = 70_501; + let hidden = create_object( + &mut state, + CardId(70_501), + PlayerId(0), + "Hidden Sidecar Source".to_string(), + Zone::Battlefield, + ); + let work = |description: &str| { + let mut pending = PendingTrigger::ordinary( + hidden, + PlayerId(0), + None, + Box::new(ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + Vec::new(), + hidden, + PlayerId(0), + )), + 1, + ); + pending.description = Some(description.to_string()); + PendingTriggerContext::single(pending) + }; + + state.pending_triggered_mana_resume = Some(Box::new(TriggeredManaResume { + current: Box::new(work(MARKER)), + current_override: None, + rules_execution_node: RulesExecutionNodeRef::TriggeredMana(SettlementNodeOrdinal(3)), + accepted_tail: vec![work("accepted tail")], + collected_batches: Vec::new(), + outer_resume: ManaTriggerFixedPointResume::Parent, + stage: TriggeredManaStage::ResolvingBody, + })); + state.pending_trigger_construction_priority_recipient = Some(PlayerId(1)); + state.waiting_for = WaitingFor::OptionalEffectChoice { + player: PlayerId(0), + source_id: hidden, + description: Some("Accepted triggered mana may".to_string()), + may_trigger_key: None, + }; + (state, MARKER) + } + #[test] fn active_search_grants_only_exact_incarnation_and_filters_event_by_latched_audience() { let mut state = GameState::new_two_player(7); diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 5c9cebd197..01d75c5ff6 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -2,7 +2,7 @@ use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; use nom::bytes::complete::{tag, tag_no_case, take_till, take_until}; use nom::character::complete::multispace1; -use nom::combinator::{all_consuming, eof, map, opt, rest, value}; +use nom::combinator::{all_consuming, eof, map, map_opt, opt, rest, value}; use nom::sequence::{preceded, terminated}; use nom::Parser; @@ -3627,8 +3627,8 @@ fn recognize_counter_spell_zone_redirect(lower: &str) -> Option Option OracleResult<'_, ()> { + map_opt( + crate::parser::oracle_nom::condition::parse_affirmative_reflexive_connector, + |condition| condition.is_optional_effect_performed().then_some(()), + ) + .parse(input) +} + pub(super) fn parse_copy_retarget_clause(input: &str) -> OracleResult<'_, bool> { map( ( - opt(crate::parser::oracle_nom::condition::parse_affirmative_reflexive_connector), + opt(parse_inline_copy_retarget_connector), opt(alt((tag(", and "), tag("and ")))), opt(tag("you ")), tag("may choose "), @@ -9931,15 +9939,17 @@ mod tests { "may choose a new target for the creature" )); - // CR 707.10c + CR 603.12: the grant printed as the CONSEQUENT of an - // AFFIRMATIVE reflexive gate rather than as its own sentence (Spider-Verse's + // CR 707.10c + CR 608.2c: the grant printed as the consequent of an + // affirmative `If` continuation rather than as its own sentence (Spider-Verse's // "you may copy it. If you do, you may choose new targets for the copy."). // The gate is redundant on an already-optional copy — no copy, nothing to // retarget — so it folds into the same continuation. assert!(recognize_copy_retarget_clause( "if you do, you may choose new targets for the copy." )); - assert!(recognize_copy_retarget_clause( + // CR 603.12: literal `When` creates a reflexive trigger and cannot be + // folded into an inline copy-retarget permission. + assert!(!recognize_copy_retarget_clause( "when you do, you may choose new targets for the copies" )); assert_eq!( diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index d36bd36d1d..32cdb5f96b 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -9815,19 +9815,16 @@ pub fn parse_you_draw_this_way_condition(input: &str) -> OracleResult<'_, Abilit )) } -/// CR 603.12: the AFFIRMATIVE half of the reflexive-conditional connector set — -/// "the preceding optional effect WAS performed" ("if you do, ", "when you do, ", -/// "if they do, ", …). +/// CR 603.12 + CR 608.2c: the affirmative connector set. Literal "when you do" +/// creates a reflexive triggered ability; literal "if ... do" continues the +/// resolving effect when its preceding instruction was performed. /// /// Split out from [`parse_reflexive_conditional_connector`] because the two halves -/// are NOT interchangeable to a consumer that wants to fold the gate away. A gate -/// that is redundant in the affirmative — a permission attached to an effect that -/// only exists when the antecedent happened, e.g. CR 707.10c's "If you do, you may -/// choose new targets for the copy" riding an already-optional `CopySpell` — is the -/// exact OPPOSITE of redundant in the negative ("if they don't, …" gates a branch -/// that runs precisely when the antecedent did NOT happen). A consumer must -/// therefore be able to ask for the affirmative set ALONE; matching the whole set -/// and discarding the condition would silently invert a negated clause. +/// are NOT interchangeable to a consumer that wants to fold the gate away. Such a +/// consumer must inspect the typed result: folding an already-proven +/// `EffectOutcome::OptionalEffectPerformed` can be sound, while folding +/// `WhenYouDo` would erase a CR 603.12 trigger. The negative set remains separate +/// because discarding it would invert the branch. pub(crate) fn parse_affirmative_reflexive_connector( input: &str, ) -> OracleResult<'_, AbilityCondition> { @@ -9889,7 +9886,7 @@ fn parse_discard_this_way_affirmative_connector(input: &str) -> OracleResult<'_, .parse(input) } -/// CR 603.12: the NEGATED half — "the preceding optional effect was NOT performed". +/// CR 608.2c: the negated half — the preceding optional effect was not performed. /// /// Kept disjoint from the affirmative half by construction, not by luck: each tag /// here ends in `n't, `, so no affirmative tag (which requires `, ` immediately @@ -9949,15 +9946,14 @@ fn parse_discard_this_way_negated_connector(input: &str) -> OracleResult<'_, Abi .parse(input) } -/// CR 603.12 + CR 608.2c: Recognize a leading reflexive-conditional connector +/// CR 603.12 + CR 608.2c: Recognize a leading conditional connector /// and return the corresponding AbilityCondition with the connector consumed. /// Single authority for this set; consumed by both /// `oracle_effect::conditions::strip_if_you_do_conditional` and the /// `oracle_effect::sequence` chunk-splitter sticky-detection so they never drift. /// -/// Composed from the affirmative + negated halves so a consumer that needs only one -/// polarity (CR 707.10c copy-retarget) shares this exact tag set rather than -/// re-spelling it. +/// Composed from the affirmative + negated halves so consumers share this exact +/// grammar while retaining the typed `When`/`If` distinction. pub(crate) fn parse_reflexive_conditional_connector( input: &str, ) -> OracleResult<'_, AbilityCondition> { diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index b6d0d0603a..65726da8b0 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3170,8 +3170,9 @@ fn parse_clone_replacement( // BecomeCopy replacement still registers — dropping the entire replacement // for an unparsed suffix would lose the clone behaviour entirely. // - // The suffix may also carry a trailing "When you do, ..." reflexive trigger - // clause past the sentence boundary — parsed separately into a sub_ability. + // The suffix may also carry a trailing post-replacement rider past the + // sentence boundary — literal `When` remains a reflexive trigger, while an + // accepted-branch `If` remains an inline continuation. let (mana_value_limit, duration, additional_modifications, post_period) = parse_clone_suffix(suffix.trim(), card_name); @@ -3192,13 +3193,11 @@ fn parse_clone_replacement( ) .description(original_text.to_string()); - // CR 603.12: "When you do, ..." — reflexive trigger that fires when the - // clone replacement's choose-and-copy action was performed. Parsed as a - // sub_ability with condition `WhenYouDo`; the parent's targets (the copied - // source card) are forwarded so "that card" (`TargetFilter::TriggeringSource`) - // resolves to the chosen card for e.g. "exile that card". - if let Some(reflexive) = parse_when_you_do_reflexive(post_period) { - copy_effect = copy_effect.sub_ability(reflexive); + // CR 603.12 + CR 608.2c: Preserve literal `When` as a reflexive trigger and + // consume literal `If` only at this accepted replacement branch. The parent's + // copied-card referent is forwarded to either rider. + if let Some(rider) = parse_post_replacement_rider(post_period) { + copy_effect = copy_effect.sub_ability(rider); } // CR 614.1c: When the verb phrase includes "tapped" ("enter tapped as a copy @@ -3378,20 +3377,20 @@ fn attach_zone_to_filter(filter: TargetFilter, zone: Zone) -> TargetFilter { } } -/// Parse a trailing "When you do, ..." / "If you do, ..." reflexive trigger clause. +/// Parse a trailing "When you do, ..." / "If you do, ..." post-replacement rider. /// /// Delegates to the existing effect-chain parser. The "when you do" connector /// maps to `AbilityCondition::WhenYouDo`; the "if you do" connector maps to -/// `AbilityCondition::EffectOutcome { OptionalEffectPerformed }`. On the -/// clone-replacement path the parent "do" is the optional copy, applied via the -/// copy-target-choice completion (a non-cost `BecomeCopy` parent), so the -/// condition is normalized to `WhenYouDo` (CR 603.12) — see the normalization -/// note below. Returns None when the text doesn't start with a "when you do" / -/// "if you do" phrase or the chain parser produces an unimplemented effect (so +/// `AbilityCondition::EffectOutcome { OptionalEffectPerformed }`. +/// At this owning accepted-branch seam, literal `When` keeps its CR 603.12 +/// `WhenYouDo` creation gate, while literal `If` has its CR 608.2c performed gate +/// consumed because reaching the replacement's execute branch proves acceptance. +/// Any other condition fails closed. Returns None when the text doesn't start +/// with a connector or the chain parser produces an unimplemented effect (so /// the caller can fall back to the plain BecomeCopy replacement without a /// reflexive trigger). -fn parse_when_you_do_reflexive(post_period: &str) -> Option { - use crate::types::ability::{AbilityCondition, EffectOutcomeSignal}; +fn parse_post_replacement_rider(post_period: &str) -> Option { + use crate::types::ability::AbilityCondition; // Strip the sentence terminator / separator space preceding the reflexive // clause. These are structural punctuation, not parsing dispatch. @@ -3404,8 +3403,8 @@ fn parse_when_you_do_reflexive(post_period: &str) -> Option { // seam for future reflexive-clause variants ("when that happens", etc.) // without reshaping the guard. let lower = trimmed.to_lowercase(); - // CR 603.12: both reflexive connectors — "when you do" (Superior Spider-Man) - // and "if you do" (The Fourteenth Doctor). + // CR 603.12 + CR 608.2c: admit the two typed connector classes; + // classification remains owned by the shared effect-chain parser below. nom_on_lower(trimmed, &lower, |i| { value( (), @@ -3423,24 +3422,12 @@ fn parse_when_you_do_reflexive(post_period: &str) -> Option { if matches!(*def.effect, Effect::Unimplemented { .. }) { return None; } - // CR 603.12: The reflexive parent here is the optional enter-as-a-copy - // replacement, resolved via the copy-target-choice completion — a non-cost - // `BecomeCopy` parent, NOT an `Effect::OptionalEffect` resolution. The engine - // gates `BecomeCopy` / copy-replacement reflexives on `WhenYouDo`, which is - // unconditionally true when the sub-ability is reached (the copy having been - // performed is guaranteed by the CopyTargetChoice completion path; a declined - // copy never reaches the sub-ability). The generic "if you do" mapping to - // `EffectOutcome { OptionalEffectPerformed }` reads a resolution-context flag - // that this replacement path never sets, so it would silently never fire. - // Normalize it to the `WhenYouDo` contract (Superior Spider-Man's "when you - // do" already lands there). - if matches!( - def.condition, - Some(AbilityCondition::EffectOutcome { - signal: EffectOutcomeSignal::OptionalEffectPerformed, - }) - ) { - def.condition = Some(AbilityCondition::WhenYouDo); + match def.condition.take() { + Some(AbilityCondition::WhenYouDo) => { + def.condition = Some(AbilityCondition::WhenYouDo); + } + Some(condition) if condition.is_optional_effect_performed() => {} + Some(_) | None => return None, } Some(def) } @@ -7629,7 +7616,7 @@ fn strip_optional_draw_skip<'a>(lower_body: &str, original_body: &'a str) -> Opt Some(rest.trim_start()) } -/// CR 603.12 + issue #5655: Attach an optional `"if you do, …"` rider to an +/// CR 608.2c + issue #5655: Attach an optional `"if you do, …"` rider to an /// optional draw-skip replacement. Returns `None` when non-empty rider text is /// present but cannot be lowered to a typed effect — fail closed rather than /// report the card as supported with a silently discarded rider (Island @@ -7642,7 +7629,7 @@ fn attach_optional_draw_skip_rider( if trimmed.is_empty() { return Some(def); } - let rider = parse_when_you_do_reflexive(remainder)?; + let rider = parse_post_replacement_rider(remainder)?; Some(def.execute(rider)) } @@ -19630,7 +19617,7 @@ mod tests { /// graveyard zone + ZoneChangedThisTurn predicate + "if you do" connector. #[test] fn fourteenth_doctor_graveyard_copy_with_zone_change_predicate_and_haste() { - use crate::types::ability::{AbilityCondition, Effect, FilterProp, TypeFilter}; + use crate::types::ability::{Effect, FilterProp, TypeFilter}; let def = parse_replacement_line( "You may have The Fourteenth Doctor enter as a copy of a Doctor card in your graveyard that was put there from your library this turn. If you do, it gains haste until end of turn.", @@ -19693,19 +19680,18 @@ mod tests { other => panic!("expected Typed filter, got {other:?}"), } - // Reflexive "If you do, it gains haste until end of turn." attaches as a - // sub-ability. CR 603.12: normalized to `WhenYouDo` because the parent is - // a non-cost BecomeCopy replacement (the copy-completion path the engine - // gates on `WhenYouDo`), not an `Effect::OptionalEffect` resolution. + // CR 608.2c: literal "If you do" is an inline continuation. Reaching the + // accepted replacement execute branch proves the copy occurred, so the + // owning parser seam consumes the performed gate instead of fabricating + // a CR 603.12 `WhenYouDo` trigger. let sub = execute .sub_ability .as_ref() .expect("reflexive haste sub_ability"); assert_eq!( + sub.condition, None, + "accepted-branch If rider must be conditionless and inline, got {:?}", sub.condition, - Some(AbilityCondition::WhenYouDo), - "reflexive haste must gate on WhenYouDo (the BecomeCopy reflexive contract), got {:?}", - sub.condition ); assert!( !matches!(*sub.effect, Effect::Unimplemented { .. }), diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8f7b84c215..3e654884de 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -21013,14 +21013,11 @@ pub enum AbilityCondition { /// state sources. Feeds `RepeatContinuation::WhileCondition` ("repeat this /// process") and any cross-sentence flip-result gate. CoinFlipOutcome { result: CoinFlipResult }, - /// CR 603.12: "When you do" — reflexive trigger that fires based on whether the - /// parent's trigger event actually occurred. A mandatory non-cost parent (e.g. - /// a `BecomeCopy` reflexive or a copy/exile replacement sub-ability) always - /// occurred, while an optional non-cost parent must actually be performed. - /// For a cost-payment parent (`Effect::PayCost`), an unpayable or declined cost - /// is not an occurrence, so the reflexive sub-ability is skipped — - /// `evaluate_condition` gates on `cost_payment_failed_flag` for that case - /// (mirrors `IfYouDo`). + /// CR 603.12: "When you do" — a reflexive trigger based on whether the + /// parent event actually occurred. An optional non-cost parent must be + /// performed; an unpayable or declined `Effect::PayCost` is not an occurrence. + /// The runtime materializer consumes this root condition on the trigger clone + /// so the reflexive ability cannot recreate itself. WhenYouDo, /// CR 601.2a + CR 707.10: "if [this spell] was cast from [zone]" — sub_ability /// executes only if the spell was cast. `zone: None` = cast from any origin; @@ -25768,6 +25765,11 @@ pub struct ResolvedAbility { /// Each entry maps a target to its assigned portion. Read at resolution. #[serde(default, skip_serializing_if = "Option::is_none")] pub distribution: Option>, + /// CR 601.2d + CR 603.3d: Unassigned division metadata carried from the + /// definition until this stack object's targets and portions are announced. + /// Distinct from `distribution`, which stores the completed assignment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub distribute: Option, /// Player scope for "each player/opponent [effect]" patterns. /// When set, the effect iterates over matching players (each becomes the acting player). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -25903,7 +25905,7 @@ pub struct ResolvedAbility { pub sibling_condition: SiblingCondition, /// CR 700.2b + CR 603.3c: Modal choice for a reflexive modal trigger whose modes /// are gated behind an optional cost (Caesar). Carried from the def so - /// try_begin_reflexive_target_selection can hand it to the PendingTrigger and + /// `try_materialize_reflexive_trigger` can hand it to the PendingTrigger and /// route to AbilityModeChoice. None for non-modal abilities. #[serde(default, skip_serializing_if = "Option::is_none")] pub modal: Option, @@ -25961,6 +25963,7 @@ impl ResolvedAbility { forward_result: false, unless_pay: None, distribution: None, + distribute: None, player_scope: None, starting_with: None, chosen_x: None, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 3750e04bbd..f4e199d2d3 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6137,6 +6137,18 @@ pub struct ManaAbilityCostParent { pub cursor: Box, #[serde(default)] pub lifecycle: ManaAbilityCostParentLifecycle, + /// CR 603.2 + CR 603.3b: Index into the live reducer action's `events` + /// vector marking where this parent frame's own unscanned cost events + /// begin, while the lifecycle is `Synchronous`. Each nested child entry + /// prepares a fresh local snapshot whose ledger is extended with + /// `events[current_action_event_start..]`, so a later pausing child + /// inherits the parent prefix plus every earlier synchronously completed + /// sibling. It is never consulted once the parent becomes `Suspended`, + /// because the pause has already made that prefix durable, so it is not + /// serialized. Distinct from `ManaAbilityCostCursor::current_action_deferred_start`, + /// which indexes the local ledger rather than the event vector. + #[serde(skip)] + pub current_action_event_start: usize, } /// CR 601.2h + CR 602.2b + CR 605.3b + CR 616.1: The unpaid suffix of an @@ -6184,16 +6196,18 @@ pub struct ManaAbilityCostCursor { /// before the cost cursor advances to the next component. #[serde(default, skip_serializing_if = "Option::is_none")] pub selected_sacrifice_remaining: Option>, - /// CR 603.2 + CR 603.3b: Cost events produced before a replacement-choice - /// pause cannot reach the ordinary post-action pipeline. Keep them with - /// their typed payment root so observers are collected exactly once when - /// that root completes. + /// CR 603.2 + CR 603.3b: Frame-local cost events produced before a + /// replacement-choice pause cannot reach the ordinary post-action + /// pipeline. Each cursor owns only its own unscanned events; an ancestor's + /// ledger remains opaque in `parent` until a suspended child moves its + /// local ledger upward. Only the ultimate parentless cursor may settle the + /// accumulated batch. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deferred_cost_events: Vec, - /// Ephemeral split point for the active reducer action. It lets a newly - /// nested typed root prepend its parent's unscanned batch without copying - /// the local events this root already captured on pause. The split is - /// consumed before state is returned to a player, so it is not serialized. + /// Ephemeral split point into this cursor's frame-local ledger for the + /// active reducer action. It lets an immediately re-paused ultimate root + /// retain its already captured local suffix while inherited root events + /// move in front of that suffix without duplication. It is not serialized. #[serde(skip)] pub current_action_deferred_start: usize, /// A nested costed mana source owns this parent until it completes. This @@ -7496,6 +7510,132 @@ pub struct ReplacementCandidateSummary { pub description: String, } +/// CR 603.3b + CR 603.7: One completed normal-plus-delayed trigger collection +/// for a single raw event batch, produced before any live occurrence is claimed. +/// +/// Generic and phase processing consume this immediately; the triggered-mana +/// continuation may instead persist it undispatched across a pause, so a later +/// action partitions already-materialized contexts rather than rematching raw +/// events. It deliberately carries no live action ordinal: occurrence identities +/// are only meaningful against the reducer event vector of the action that +/// claimed them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CollectedTriggerContextBatch { + /// The combined normal and delayed contexts in APNAP rank/timestamp order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub contexts: Vec, + /// The raw logical delayed batch, stored exactly once for durable replay and + /// accounting. Immediate consumers drop it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delayed_events: Vec, + /// Raw identities consumed by the delayed matcher for this batch. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delayed_consumed: Vec, + /// Whether the caller's already-observed normal seed was non-empty. The + /// closing processors clear post-collection transients only in that case. + #[serde(default)] + pub normal_was_non_empty: bool, +} + +/// CR 605.3b + CR 608.2d: The activated mana ability's own colour prompt, +/// latched exactly as the mana frame had already built it before the +/// triggered-mana fixed point suspended that frame. +/// +/// This wait belongs to the outer activated mana ability, never to an accepted +/// triggered-mana body: the fixed point reconstructs it verbatim rather than +/// re-deriving an option set against a board the accepted triggers may have +/// changed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ManaColorChoiceResume { + pub player: PlayerId, + pub choice: ManaChoicePrompt, + pub context: ManaChoiceContext, +} + +/// CR 605.4a: What resumes once every accepted triggered-mana occurrence in one +/// completed mana frame has reached a terminal disposition. +/// +/// Exactly one of the three shapes a completed frame can own. `Parent` is a +/// nested child returning to its suspended cursor; `Root` is the completed root +/// frame's own resume root; `ColorChoice` is the already-built prompt above. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub(crate) enum ManaTriggerFixedPointResume { + /// A nested child frame: its suspended parent cursor is the authority and + /// no wait is reconstructed here. + Parent, + /// The completed root mana frame's own activation resume root, together + /// with the mana source's controller — `resume_mana_ability_root`'s own + /// first argument, which its catch-all arm needs to rebuild the waiting + /// state. It travels with the root because a resumed accepted occurrence + /// no longer has the payment cursor that supplied it. + Root { + player: PlayerId, + resume: Box, + }, + /// The root frame's already-built `ChooseManaColor` wait. + ColorChoice(Box), +} + +/// CR 605.4a: Which existing interaction the current accepted triggered-mana +/// occurrence is paused on. Private control state, never a wire discriminator: +/// the public interaction surface is the ordinary `WaitingFor` value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum TriggeredManaStage { + /// CR 700.2e: the controller is choosing which other player picks modes. + ChoosingModalChooser, + /// The chosen player is selecting modes. + ChoosingModes, + /// The selected body is executing and paused on one whitelisted + /// optional / optional-for / repeat / replacement interaction. + ResolvingBody, +} + +/// CR 605.4a + CR 603.12a: The authoritative continuation for a classifier- +/// accepted triggered mana ability that paused mid-resolution. +/// +/// It owns only "after this immediate trigger finishes under this exact +/// rules-execution node, partition these already-collected emission batches, +/// then run this accepted tail, then resume this exact mana frame". Every inner +/// operation (resolution frames, optional/repeat frames, pending replacement or +/// cost-move state, and `waiting_for` itself) keeps its existing owner. +/// +/// This carrier is trusted persistence authority: it can hold hidden object +/// identities, last-known source snapshots, controller-only event batches, and +/// the current rules-execution node. It is redacted from every client +/// projection by `visibility.rs` and `derived_views.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TriggeredManaResume { + /// The complete accepted work item currently executing. + pub current: Box, + /// The occurrence-local production override captured by the auto-tap + /// planner for this exact `TapsForMana` occurrence, if any. The transient + /// override map is cleared before an interactive trigger can resume, so it + /// must travel with the occurrence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_override: Option, + /// CR 605.4a: The one `RulesExecutionNodeKind::TriggeredMana` node for this + /// occurrence. Required, not optional: every classifier-accepted context + /// yields exactly one source incarnation, and every pause of this + /// occurrence rebinds this exact ref. + pub rules_execution_node: RulesExecutionNodeRef, + /// Accepted work that has not been made current yet, in collection order. + /// Tail members deliberately have no node until they become current. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accepted_tail: Vec, + /// Already-collected, still-undispatched emission batches produced by the + /// current occurrence across its pauses. Stored before the live occurrences + /// were claimed, so a later action partitions materialized contexts instead + /// of rematching raw events. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub collected_batches: Vec, + /// The suspended mana frame this fixed point resumes exactly once. + pub outer_resume: ManaTriggerFixedPointResume, + /// Which existing interaction owns the current pause. + pub stage: TriggeredManaStage, +} + /// CR 603.3b: One controller's group within an in-flight trigger ordering /// pass. `ordered = true` once the controller has submitted their permutation /// (or once the group is single-trigger and trivially in final order, or once @@ -16909,6 +17049,55 @@ declare_game_state! { #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_deferred_life_cost_resume: Option, + /// CR 605.4a + CR 603.12a: Typed continuation for a classifier-accepted + /// triggered mana ability that paused mid-resolution. It stays serialized + /// with the matching whitelisted prompt so a host checkpoint resumes the + /// same stackless occurrence under the same rules-execution node. + /// Redacted from every client projection (`visibility.rs`, + /// `derived_views.rs`); the projected `WaitingFor` is the complete public + /// interaction surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pending_triggered_mana_resume: Option>, + + /// CR 117.3c + CR 117.5 + CR 605.4a: The player who must receive priority + /// once a settled-Priority trigger batch finishes announcing, when that + /// player is not the active player. Installed only by the settled-Priority + /// convergence wrapper from its own exhaustively validated + /// `WaitingFor::Priority { player }`, retained across every + /// trigger-construction prompt, and consumed by the construction finisher + /// once the whole construction/deferred tail is exhausted. + /// + /// Deliberately **not** part of `resolution_completion_can_settle`: that + /// predicate gates `can_drain_deferred_triggers`, so a carrier inside it + /// would reject the very drains this batch depends on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pending_trigger_construction_priority_recipient: Option, + + /// CR 605.1b + CR 605.4a: The exact rules-execution node of the accepted + /// triggered-mana occurrence whose stackless body is currently executing or + /// resuming. Presence means the complete classifier accepted the original + /// graph, so a resolver-time context-ref injection into `targets` cannot + /// reopen classification (CR 605.4a). + /// + /// Transient by construction: it is a lexical scope marker equal to + /// `active_rules_execution_node` while live, and every sidecar-owned action + /// reinstalls it from `TriggeredManaResume::rules_execution_node`. Serde + /// persists the sidecar and its node, never this marker. + #[serde(skip)] + pub(crate) active_accepted_triggered_mana_node: Option, + + /// Debug-only witness that the trigger-construction finisher ran at most + /// once per reducer action. The finisher is applied at the outermost handler + /// return of each of its enumerated action seams; a second call in the same + /// action would mean a seam was added below dispatch, where a `Priority` + /// result is discarded and the recipient would be lost mid-batch. + /// + /// Transient by construction — `apply_action_boundary_core` clears it at + /// action entry beside the other per-action transients — so it is never + /// serialized and never part of state equality. + #[serde(skip)] + pub(crate) trigger_construction_finisher_ran_this_action: bool, + /// CR 601.2h + CR 616.1: Resume a sequential discard cost after a /// replacement choice. Cost moves use `pending_cost_move_resume` above. #[serde(skip)] @@ -20876,6 +21065,10 @@ impl GameState { current_triggered_mana_override: None, pending_cost_move_resume: None, pending_deferred_life_cost_resume: None, + pending_triggered_mana_resume: None, + pending_trigger_construction_priority_recipient: None, + active_accepted_triggered_mana_node: None, + trigger_construction_finisher_ran_this_action: false, pending_discard_for_cost: None, pending_cast: None, ring_level: HashMap::new(), @@ -21591,16 +21784,25 @@ impl GameState { ability.clear_trigger_identity_recursive(); } } + // `PendingTrigger::timestamp` is a live CR 603.3b ordering key. It is + // monotonic allocation history, not a difference in the recurring game + // position, so normalize it only in this CR 104.4b snapshot. Keeping + // the live values preserves APNAP/same-controller ordering in + // `game::triggers`. + let normalize_pending_trigger = |pending: &mut crate::game::triggers::PendingTrigger| { + pending.timestamp = 0; + pending.ability.clear_trigger_identity_recursive(); + }; if let Some(pt) = clone.pending_trigger.as_mut() { - pt.ability.clear_trigger_identity_recursive(); + normalize_pending_trigger(pt); } for ctx in clone.deferred_triggers.iter_mut() { - ctx.pending.ability.clear_trigger_identity_recursive(); + normalize_pending_trigger(&mut ctx.pending); } if let Some(order) = clone.pending_trigger_order.as_mut() { for group in order.groups.iter_mut() { for ctx in group.triggers.iter_mut() { - ctx.pending.ability.clear_trigger_identity_recursive(); + normalize_pending_trigger(&mut ctx.pending); } } } @@ -21608,6 +21810,27 @@ impl GameState { dt.ability.clear_trigger_identity_recursive(); dt.provenance = DelayedInstallIdentity::LegacyDelayed; } + // The triggered-mana sidecar is eq-compared like its sibling carriers + // above, and it holds both ability carriers and a globally advancing + // `SettlementNodeOrdinal`. The live loop-sample sites only fire at + // `WaitingFor::Priority`, where the sidecar is None today — this + // normalization keeps that a non-load-bearing coincidence rather than a + // hidden precondition of CR 104.4b detection. + if let Some(resume) = clone.pending_triggered_mana_resume.as_mut() { + normalize_pending_trigger(&mut resume.current.pending); + for ctx in resume.accepted_tail.iter_mut() { + normalize_pending_trigger(&mut ctx.pending); + } + for batch in resume.collected_batches.iter_mut() { + for ctx in batch.contexts.iter_mut() { + normalize_pending_trigger(&mut ctx.pending); + } + } + resume.rules_execution_node = + crate::types::resolved_commands::RulesExecutionNodeRef::TriggeredMana( + crate::types::resolved_commands::SettlementNodeOrdinal(0), + ); + } for epic in clone.epic_effects.iter_mut() { epic.spell.clear_trigger_identity_recursive(); } @@ -22682,6 +22905,10 @@ fn _gamestate_partition_is_total(s: &GameState) { current_triggered_mana_override: _, pending_cost_move_resume: _, pending_deferred_life_cost_resume: _, + pending_triggered_mana_resume: _, + pending_trigger_construction_priority_recipient: _, + active_accepted_triggered_mana_node: _, + trigger_construction_finisher_ran_this_action: _, pending_discard_for_cost: _, pending_cast: _, ring_level: _, @@ -22942,6 +23169,9 @@ impl PartialEq for GameState { && self.pending_library_search_delivery == other.pending_library_search_delivery && self.pending_search_found_batch == other.pending_search_found_batch && self.pending_cost_move_resume == other.pending_cost_move_resume + && self.pending_triggered_mana_resume == other.pending_triggered_mana_resume + && self.pending_trigger_construction_priority_recipient + == other.pending_trigger_construction_priority_recipient && self.may_trigger_auto_choices == other.may_trigger_auto_choices && self.decision_templates == other.decision_templates && self.priority_yields == other.priority_yields @@ -27550,6 +27780,36 @@ mod tests { ); } + /// CR 104.4b: deferred-trigger timestamps are CR 603.3b scheduling history, + /// not a changed recurring position. Their live values must remain distinct + /// for ordering, while loop snapshots compare the same pending trigger + /// regardless of the allocator position that produced it. + #[test] + fn normalize_for_loop_canonicalizes_deferred_trigger_timestamps() { + let mut first = GameState::new_two_player(7); + let source = ObjectId(90_001); + first + .deferred_triggers + .push(PendingTriggerContext::single(ordinary_pending_trigger( + source, 11, + ))); + let mut later = first.clone(); + later.deferred_triggers[0].pending.timestamp = 97; + + assert_ne!( + first, later, + "fixture must differ in the Eq-compared deferred scheduling timestamp" + ); + let first_normalized = first.normalize_for_loop(); + let later_normalized = later.normalize_for_loop(); + assert_eq!(first_normalized.deferred_triggers[0].pending.timestamp, 0); + assert_eq!(later_normalized.deferred_triggers[0].pending.timestamp, 0); + assert!( + loop_states_equal(&first_normalized, &later_normalized), + "the volatile deferred ordering timestamp must not hide a recurring state" + ); + } + #[test] fn loop_states_equal_distinguishes_stack_trigger_firing() { let mut ordinary = GameState::new_two_player(7); diff --git a/crates/engine/tests/integration/ancient_brass_dragon_roll_d20.rs b/crates/engine/tests/integration/ancient_brass_dragon_roll_d20.rs index 5959b35ed4..3f8a49b871 100644 --- a/crates/engine/tests/integration/ancient_brass_dragon_roll_d20.rs +++ b/crates/engine/tests/integration/ancient_brass_dragon_roll_d20.rs @@ -214,8 +214,15 @@ fn ancient_brass_dragon_zero_targets_is_clean_no_op() { .act(GameAction::SelectTargets { targets: vec![] }) .expect("selecting zero targets must be a clean no-op"); + // CR 603.12 + CR 603.3b: the reflexive is its own stack object, so let it + // RESOLVE and then STOP. Passing priority into an already-empty stack + // advances phases instead, and by turn 3 P1 is decked — elimination exiles + // every card that player owned, including the graveyard cards this row + // measures. The observation point for "clean no-op" is the first settled + // empty stack, not an unbounded pass loop. for _ in 0..30 { match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, WaitingFor::Priority { .. } => { if runner.act(GameAction::PassPriority).is_err() { break; @@ -225,6 +232,18 @@ fn ancient_brass_dragon_zero_targets_is_clean_no_op() { } } + // Positive reach guard: the loop's error/other-prompt breaks may exit + // before the observation point, and the negative assertions below would + // vacuously pass on an unresolved trigger. Prove the settled window first. + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + && runner.state().stack.is_empty(), + "the zero-target reflexive must resolve to a settled priority window with an \ + empty stack before the no-op is measured, got {:?} with {} stack entries", + runner.state().waiting_for, + runner.state().stack.len() + ); + // No graveyard card moved (still in a graveyard) and the battlefield count // did not grow from reanimation. for (id, _) in &grave { diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index c90c1dcb0c..7bfafd78cc 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -1,3 +1,4 @@ +use engine::ai_support::legal_actions_full; use engine::database::synthesis::synthesize_plot; use engine::game::effects::resolve_ability_chain; use engine::game::game_object::AttachTarget; @@ -7,11 +8,13 @@ use engine::parser::oracle_cost::parse_oracle_cost; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, CardPlayMode, CardSelectionMode, CastFromZoneDriver, CastingPermission, CategoryChooserScope, ChoiceType, Chooser, - ContinuousModification, DigRestOrder, DigSource, DiscardSelfScope, Effect, EffectKind, - FilterProp, ForEachCategoryAction, IterationCategory, ManaContribution, ManaProduction, - ModalChoice, QuantityExpr, QuantityRef, ReplacementDefinition, ReplacementMode, + ContinuousModification, DelayedTriggerCondition, DelayedTriggerLifetime, DigRestOrder, + DigSource, DiscardSelfScope, Effect, EffectKind, FilterProp, ForEachCategoryAction, + IterationCategory, ManaContribution, ManaProduction, ManaSpendRestriction, ModalChoice, + OpponentMayScope, QuantityExpr, QuantityRef, ReplacementDefinition, ReplacementMode, ResolvedAbility, SacrificeCost, SpellCastingOption, TargetFilter, TargetRef, - TargetSelectionMode, TriggerDefinition, TypeFilter, TypedFilter, + TargetSelectionMode, TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter, + UnlessPayModifier, WheneverEventExpiry, }; use engine::types::actions::GameAction; use engine::types::card::CardFace; @@ -20,11 +23,13 @@ use engine::types::counter::CounterType; use engine::types::events::{GameEvent, PlayerActionKind}; use engine::types::game_state::{ BatchCompletion, CastPaymentMode, CollectEvidenceResume, GameState, - ManaAbilityCostParentLifecycle, ManaAbilityCostResolutionMode, ManaAbilityResume, PayCostKind, - PendingCast, PendingCostMoveResume, PendingReplacement, StackEntryKind, WaitingFor, + ManaAbilityCostParentLifecycle, ManaAbilityCostResolutionMode, ManaAbilityResume, ManaChoice, + PayCostKind, PendingCast, PendingCostMoveResume, PendingReplacement, StackEntryKind, + WaitingFor, }; +use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; -use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType}; use engine::types::phase::Phase; use engine::types::proposed_event::{ProposedEvent, ReplacementId}; use engine::types::replacements::ReplacementEvent; @@ -3726,6 +3731,139 @@ fn effect_pay_cost_auto_tap_redirect_serializes_exact_cost_and_trailing_effect_o assert!(runner.state().pending_cost_move_resume.is_none()); } +/// The plan's §"Exact no-ledger split" ordering, measured at a DURABLE-LEDGER +/// root: *"For a completed root, invoke the wrapper immediately after mana +/// production/activation completion and **before** `resume_mana_ability_root`."* +/// +/// The witness is an `ManaAbilityResume::EffectPayCost` root — the one root +/// family whose resume is not inert: `pay_ability_cost_for_resolution` spends +/// the pool and then `resolve_effect_pay_cost_rider` runs the trailing effect, +/// all inside `resume_mana_ability_root`. Two distinguishable observers pin the +/// boundary: OT watches the frame's own tap, OL watches the RIDER's life gain. +/// +/// With settlement before the resume, the frame's batch is exactly its own tap +/// events, so OT is a one-member batch that needs no CR 603.3b ordering prompt, +/// and the rider's life-gain event is not part of the frame at all. Restoring +/// baseline's resume-then-settle order sweeps the rider's event into the same +/// completed-frame batch, producing a two-member group and an `OrderTriggers` +/// prompt — the exact durable-state difference this slice owns. +#[test] +fn durable_ledger_effect_pay_cost_root_settles_before_its_resume_runs_the_rider() { + let (mut scenario, source) = mana_self_exile_cost_redirect_witness(); + let observer_t = scenario + .add_creature(P0, "OT Source-Tap Observer", 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::Taps) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 2 }, + player: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::SpecificObject { id: source }) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id(); + let observer_l = scenario + .add_creature(P0, "OL Life-Gain Observer", 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::LifeGained) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::Any) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id(); + let mut runner = scenario.build(); + + let cost = ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }; + let mut ability = ResolvedAbility::new( + Effect::PayCost { + cost: AbilityCost::Mana { cost: cost.clone() }, + scale: None, + payer: TargetFilter::Controller, + }, + vec![], + source, + P0, + ); + ability.sub_ability = Some(Box::new(ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + vec![], + source, + P0, + ))); + + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("effect payment pauses only for the replacement choice"); + // Positive reach guard: the DURABLE ledger really is live, and the root + // really is the effect-payment family whose resume is not inert. + assert!(matches!( + runner.state().pending_cost_move_resume.as_ref(), + Some(PendingCostMoveResume::ManaAbilityPayment { pending, cursor }) + if matches!(&pending.resume, ManaAbilityResume::EffectPayCost { payer: P0, .. }) + && !cursor.deferred_cost_events.is_empty() + )); + assert!( + runner.state().deferred_triggers.is_empty(), + "no context is materialized while the replacement choice is live" + ); + let life_before = runner.state().players[P0.0 as usize].life; + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the redirected source cost resumes the exact outer effect cost"); + + // The frame settled BEFORE the rider ran, so its batch is exactly OT. + assert!( + !matches!(resumed.waiting_for, WaitingFor::OrderTriggers { .. }), + "a one-member completed-frame batch needs no CR 603.3b ordering prompt, got {:?}", + resumed.waiting_for + ); + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 1, + "the rider ran exactly once, after the frame settled" + ); + let stacked: Vec = runner + .state() + .stack + .iter() + .filter_map(|entry| match entry.kind { + StackEntryKind::TriggeredAbility { .. } => Some(entry.source_id), + _ => None, + }) + .collect(); + assert!( + stacked.contains(&observer_t), + "OT was collected by the completed mana frame: {stacked:?}" + ); + assert!( + stacked.contains(&observer_l), + "and OL was collected for the rider's own event: {stacked:?}" + ); + assert_eq!( + stacked.len(), + 2, + "each observer is placed exactly once: {stacked:?}" + ); +} + #[test] fn effect_pay_cost_rider_waits_for_scry_post_effect_before_typed_root_settles() { let mut scenario = GameScenario::new(); @@ -4496,23 +4634,54 @@ fn paused_mana_cost_events_create_observer_triggers_once_and_preserve_order_resu .act(GameAction::ChooseReplacement { index: 0 }) .expect("resume the typed cost event settlement"); - assert!(matches!( - resumed.waiting_for, - WaitingFor::OrderTriggers { ref triggers, .. } if triggers.len() == 2 - )); + // A completed mana frame is a cost-payment micro-frame inside somebody + // else's action, not a CR 603.3b release boundary. Its ordinary observers + // are collected exactly once and deferred; ordering and announcement belong + // to the owner's own boundary, so the resumed action returns straight to the + // outer payment with the queue intact and nothing on the stack. + assert!( + matches!( + resumed.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "a completed mana micro-frame returns to its owner's payment, not to CR 603.3b ordering: \ + {:?}", + resumed.waiting_for + ); assert!(runner.state().pending_cost_move_resume.is_none()); - let ordered = runner - .act(GameAction::OrderTriggers { order: vec![0, 1] }) - .expect("both observer triggers remain orderable after the cost settles"); - assert!(matches!( - ordered.waiting_for, - WaitingFor::ManaPayment { player: P0, .. } - )); + assert!( + runner.state().stack.is_empty(), + "no observer may be announced from inside the payment that produced its events" + ); + let queued_amounts = |state: &GameState| -> Vec { + state + .deferred_triggers + .iter() + .map(|context| match &context.pending.ability.effect { + Effect::GainLife { + amount: QuantityExpr::Fixed { value }, + .. + } => *value, + other => panic!("unexpected deferred observer effect {other:?}"), + }) + .collect() + }; assert_eq!( - runner.state().stack.len(), - 2, - "each actual observer trigger is collected exactly once, not once per pause and resume" + queued_amounts(runner.state()), + vec![1, 2], + "each actual observer trigger is collected exactly once, in the collector's APNAP order, \ + not once per pause and resume" ); + + // The queue is durable across the returned prompt: it is engine state, not + // action-local coordination. + let json = serde_json::to_string(runner.state()) + .expect("the deferred observer release group serializes at the outer payment prompt"); + let across: GameState = serde_json::from_str(&json) + .expect("the deferred observer release group deserializes at the outer payment prompt"); + assert_eq!(queued_amounts(&across), vec![1, 2]); + assert!(across.stack.is_empty()); + assert_eq!( initial_events .iter() @@ -4641,76 +4810,2898 @@ fn nested_costed_mana_source_serializes_parent_cursor_and_finishes_outer_payment }) )); - let json = - serde_json::to_string(runner.state()).expect("the suspended parent mana cursor serializes"); + let json = + serde_json::to_string(runner.state()).expect("the suspended parent mana cursor serializes"); + assert!( + json.contains("Suspended"), + "the serialized parent frame must retain its typed re-entry ownership" + ); + let restored: GameState = + serde_json::from_str(&json).expect("the suspended parent mana cursor deserializes"); + let mut runner = GameRunner::from_state(restored); + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("redirect inner self-exile and resume the exact parent cursor"); + + assert_eq!(runner.state().objects[&inner].zone, Zone::Graveyard); + assert!(runner.state().objects[&outer].tapped); + assert!(matches!( + resumed.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + )); + assert_eq!( + initial_events + .iter() + .chain(resumed.events.iter()) + .filter(|event| matches!(event, GameEvent::PermanentTapped { object_id, .. } if *object_id == outer)) + .count(), + 1, + "the outer tap prefix is retained by the parent cursor rather than replayed" + ); + assert_eq!( + initial_events + .iter() + .chain(resumed.events.iter()) + .filter(|event| matches!(event, GameEvent::PermanentTapped { object_id, .. } if *object_id == inner)) + .count(), + 1, + "the inner source's tap cost is paid once across the replacement pause" + ); + assert_eq!( + initial_events + .iter() + .chain(resumed.events.iter()) + .filter(|event| matches!( + event, + GameEvent::ZoneChanged { + object_id, + from: Some(Zone::Battlefield), + to: Zone::Graveyard, + .. + } if *object_id == inner + )) + .count(), + 1, + "the redirected inner self-exile cost is delivered once" + ); + for source_id in [inner, outer] { + assert_eq!( + initial_events + .iter() + .chain(resumed.events.iter()) + .filter(|event| matches!(event, GameEvent::ManaAdded { source_id: id, .. } if *id == source_id)) + .count(), + 1, + "each nested mana ability produces exactly once" + ); + } + + runner + .act(GameAction::PassPriority) + .expect("the outer spell payment consumes the outer mana once"); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// Which permanent the ordinary observer of the A/B/C/D topology watches. +/// +/// This is the ONLY axis the sibling row changes, which is what makes it the +/// discriminator for the ancestor-prefix finding: observer E fires on A's tap, +/// which lives in the *suspended parent's* prefix, not in the paused child's +/// local ledger. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HostileObserverAxis { + /// Observer D: source-filtered `Taps` on the paused child B. + TapsB, + /// Observer E: source-filtered `Taps` on the suspended parent A. + TapsA, +} + +struct HostileAbcdFixture { + scenario: GameScenario, + source_a: ObjectId, + source_b: ObjectId, + source_c: ObjectId, + observer: ObjectId, + observer_gain: i32, +} + +/// The plan's four-permanent hostile topology, shared byte-for-byte by the +/// direct-Priority row, its observer-axis sibling, and both masked-root +/// controls. Only `axis` differs between them. +fn hostile_abcd_fixture(axis: HostileObserverAxis) -> HostileAbcdFixture { + fn targetless_reflexive_gain(amount: i32) -> AbilityDefinition { + let mut reflexive = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: amount }, + player: TargetFilter::Controller, + }, + ); + reflexive.condition = Some(engine::types::ability::AbilityCondition::WhenYouDo); + reflexive + } + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // A — noncreature ARTIFACT so no summoning sickness and no creature-tier + // ambiguity in source selection. `{T}, {2}: Add {G}`, plus a targetless true + // `WhenYouDo` rider gaining 5. + let source_a = scenario + .add_creature(P0, "A Root Green Source", 1, 1) + .as_artifact() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Mana { + cost: ManaCost::generic(2), + }, + ], + }) + .sub_ability(targetless_reflexive_gain(5)), + ) + .id(); + + // B — noncreature LAND. Its land card tier deterministically precedes C's + // artifact tier, and its reflexive continuation classifies it + // `HasIrreversibleContinuation`; both penalties stay in tier zero. No + // auto-tap ordering code is touched — the fixture must EARN B-before-C by + // reaching the pause, and the assertions in each row are what prove it did. + let source_b = scenario + .add_creature(P0, "B Paused Reflexive Land", 1, 1) + .as_land() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![ManaSpendRestriction::ActivateOnly], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::Exile { + count: 1, + zone: None, + filter: Some(TargetFilter::SelfRef), + }, + ], + }) + .sub_ability(targetless_reflexive_gain(3)), + ) + .id(); + + // C — noncreature artifact, `{T}: Add {C}`, no continuation. + let source_c = scenario + .add_creature(P0, "C Synchronous Source", 1, 1) + .as_artifact() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![ManaSpendRestriction::ActivateOnly], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap), + ) + .id(); + + // Two COMPETING redirects for B's self-exile, so the exile raises a real + // `ReplacementChoice` rather than applying silently. + for name in ["B Redirect One", "B Redirect Two"] { + scenario + .add_creature(P0, name, 0, 0) + .as_enchantment() + .with_replacement_definition(redirect_moved_to(Zone::Exile, Zone::Graveyard)); + } + + let (observer_name, watched, observer_gain) = match axis { + HostileObserverAxis::TapsB => ("D B-Tap Observer", source_b, 2), + HostileObserverAxis::TapsA => ("E A-Tap Observer", source_a, 4), + }; + let observer = scenario + .add_creature(P0, observer_name, 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::Taps) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { + value: observer_gain, + }, + player: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::SpecificObject { id: watched }) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id(); + + HostileAbcdFixture { + scenario, + source_a, + source_b, + source_c, + observer, + observer_gain, + } +} + +/// CR 605.3a-c + CR 603.12 + CR 601.2h — the plan's **direct-priority A/B/C/D +/// hostile regression**, replacing the blocked `EndContinuousEffect` row. +/// +/// `EndContinuousEffect` cannot host this proof and was not weakened to match +/// the trace it actually produces: `pay_non_cast_mana_cost`'s automatic planner +/// pre-funds a costed source LEAF-FIRST, so B is parentless and C is already +/// committed before A starts, and no live suspended parent with a LATER +/// synchronous child is ever formed. `game/casting_costs.rs` and the +/// special-action protocol are out of scope, so the root moves instead. +/// +/// The reachable root is a real player action: `ActivateAbility` at +/// `WaitingFor::Priority`. A becomes a genuine parentless cursor with no +/// `PendingCast`, no resolution stack entry, and no unless-payment owner. Its +/// `{2}` Mana component asks automatic payment, which selects the LAND B before +/// the artifact C; B's `{T}, Exile this` hits two competing exile replacements +/// and serializes the whole nested cursor tree with A recursively `Suspended`. +/// +/// What this row discriminates that a ledger-shape unit test cannot: the +/// **prepared parent snapshot's wiring**. At the pause, A's suspended parent +/// ledger must hold exactly A's own tap and no B tap, and B's frame-local +/// ledger exactly B's tap and no A tap. Replacing +/// `parent_snapshot_with_current_cost_events(..)` at the +/// `resolve_mana_ability_excluding` call site with a plain `parent.cloned()` +/// leaves A's parent prefix EMPTY and fails assertion (7); restoring the +/// pre-fix clone-and-scan child ledger puts A's tap into B's ledger and fails +/// assertion (6). +/// +/// SCOPE: this row lands the plan's assertions 1-16 for the DIRECT-Priority +/// root. Its observer-E sibling +/// (`direct_priority_mana_root_orders_the_ancestor_prefix_observer_with_both_reflexives`) +/// and the masked cast-root control +/// (`masked_cast_root_mana_batch_stays_queued_until_the_spell_is_announced`) +/// carry the rest of this topology's plan requirements. The second masked +/// control — an `UnlessPayment` resolution owner over the same A/B/C/D fixture — +/// is `masked_unless_payment_root_mana_batch_stays_queued_until_the_owner_settles`. +#[test] +fn direct_priority_mana_root_suspends_its_parent_and_keeps_ledgers_disjoint() { + let HostileAbcdFixture { + scenario, + source_a, + source_b, + source_c, + observer, + observer_gain: _, + } = hostile_abcd_fixture(HostileObserverAxis::TapsB); + let observer_d = observer; + let mut runner = scenario.build(); + + // (1) Reach guards for the ROOT itself: a real empty-stack Priority with no + // cast, resolution, or payment owner. Without these the row could pass from + // some other production chronology. + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { player } if player == P0 + )); + assert!(runner.state().stack.is_empty()); + assert!(runner.state().pending_cast.is_none()); + assert!(runner.state().pending_cost_move_resume.is_none()); + let life_before = runner.state().players[P0.0 as usize].life; + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); + + // (2) The production root action. + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_a, + ability_index: 0, + }) + .expect("A's {2} Mana component pays through B and pauses on its exile replacement"); + + // (3) A real replacement pause, not a synthesized one. + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "expected B's exile ReplacementChoice, got {:?}", + paused.waiting_for + ); + + // (4)-(7) The nested cursor tree, its owner, and the two DISJOINT ledgers. + assert_nested_hostile_pause(runner.state(), source_a, source_b); + + // (5) Tapped-state reach guards for the intended chronology: A and B are + // paid, C has not been reached yet, and B is still on the battlefield + // pending its replacement choice. + assert!(runner.state().objects[&source_a].tapped, "A paid its tap"); + assert!(runner.state().objects[&source_b].tapped, "B paid its tap"); + assert!( + !runner.state().objects[&source_c].tapped, + "C must be untapped at the pause — a tapped C means the leaf-first \ + planner chronology, not the nested-parent one this row exists to prove" + ); + assert_eq!(runner.state().objects[&source_b].zone, Zone::Battlefield); + + // (11) Nothing has resolved yet. + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + // (6) The mandatory durable branch. `current_action_event_start` is + // `#[serde(skip)]`, so A's one-event parent prefix must survive on the + // serialized ledger itself, not on the marker. + let json = serde_json::to_string(runner.state()).expect("paused cursor tree serializes"); + let restored: GameState = serde_json::from_str(&json).expect("paused cursor tree restores"); + let mut runner = GameRunner::from_state(restored); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + assert_nested_hostile_pause(runner.state(), source_a, source_b); + assert!(runner.state().objects[&source_a].tapped); + assert!(runner.state().objects[&source_b].tapped); + assert!(!runner.state().objects[&source_c].tapped); + + // (7) Resume through the normal action. + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes B, then A's later synchronous C"); + + // (8) B moved exactly once, to the redirected zone. + assert_eq!(runner.state().objects[&source_b].zone, Zone::Graveyard); + assert_eq!( + resumed + .events + .iter() + .filter(|event| matches!( + event, + GameEvent::ZoneChanged { object_id, .. } if *object_id == source_b + )) + .count(), + 1, + "no duplicate exile or move delivery across the pause/resume boundary" + ); + + // (9) C then taps exactly once, and A produces its green mana. B's and C's + // colorless mana was spent on A's {2}; only A's green remains. + assert!( + runner.state().objects[&source_c].tapped, + "A's remaining generic mana must come from the LATER synchronous child C" + ); + let pool = &runner.state().players[P0.0 as usize].mana_pool; + assert_eq!( + pool.total(), + 1, + "only A's own production remains in the pool" + ); + assert_eq!(pool.count_color(ManaType::Green), 1); + + // (10) Every paid cost and every production happened exactly once across + // the pause/resume boundary. + let all_events: Vec<&GameEvent> = paused.events.iter().chain(resumed.events.iter()).collect(); + for (label, id) in [("A", source_a), ("B", source_b), ("C", source_c)] { + assert_eq!( + all_events + .iter() + .filter(|event| matches!( + event, + GameEvent::PermanentTapped { object_id, .. } if *object_id == id + )) + .count(), + 1, + "{label} paid its tap cost exactly once" + ); + assert_eq!( + all_events + .iter() + .filter(|event| matches!( + event, + GameEvent::ManaAdded { source_id, .. } if *source_id == id + )) + .count(), + 1, + "{label} produced mana exactly once" + ); + } + + // (11, after resume) Still nothing resolved: neither reflexive nor D. + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before, + "no reflexive rider and no observer may resolve before the batch is ordered" + ); + // (12) ONE empty-stack `OrderTriggers` group holding exactly three members: + // B's reflexive rider, D's B-tap observer, and A's reflexive rider — the + // last of which only exists after C completed and A produced its mana. + // + // (13) This exact three-member, empty-stack shape is the joint revert + // discriminator. Under the old inherited-ledger behaviour C scans A's cloned + // ancestor batch before A finishes, exposing an early incomplete or + // duplicated group; under a split collection seam D is already a stack entry + // and only the two reflexives remain to order. Neither revert can produce + // one empty-stack order prompt containing all three. + let WaitingFor::OrderTriggers { + player: order_player, + triggers: ref group, + } = resumed.waiting_for + else { + panic!( + "the completed root must expose one CR 603.3b ordering group, got {:?}", + resumed.waiting_for + ); + }; + assert_eq!(order_player, P0); + assert!( + runner.state().stack.is_empty(), + "no member of the batch may be dispatched separately: the stack must still \ + be empty when the single ordering group is offered" + ); + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!( + members.len(), + 3, + "expected exactly B's reflexive, D's observer, and A's reflexive; got {members:?}" + ); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} must appear in the single ordering group exactly once: {members:?}" + ); + } + + // (14) Order the group through the real action and identify the resulting + // stack entries by source. + let ordered = runner + .act(GameAction::OrderTriggers { + order: vec![0, 1, 2], + }) + .expect("the three-member group orders through the real CR 603.3b action"); + assert!( + matches!(ordered.waiting_for, WaitingFor::Priority { player } if player == P0), + "ordering the whole group returns priority, got {:?}", + ordered.waiting_for + ); + let announced: Vec = runner + .state() + .stack + .iter() + .filter_map(|entry| match entry.kind { + StackEntryKind::TriggeredAbility { source_id, .. } => Some(source_id), + _ => None, + }) + .collect(); + assert_eq!( + announced.len(), + 3, + "exactly three triggered entries, no duplicates and no preexisting D entry: {announced:?}" + ); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + announced.iter().filter(|entry| **entry == id).count(), + 1, + "{label} announced exactly once: {announced:?}" + ); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before, + "announcement alone resolves nothing" + ); + assert!(runner.state().deferred_triggers.is_empty()); + + // (15) Clone at the post-order point for two normal-action branches. + let post_order = runner.state().clone(); + + // (15a) Resolve all three: 5 + 3 + 2, each exactly once. + let mut resolve_all = GameRunner::from_state(post_order.clone()); + for _ in 0..3 { + resolve_all + .act(GameAction::PassPriority) + .expect("P0 passes priority to resolve the next triggered ability"); + resolve_all + .act(GameAction::PassPriority) + .expect("P1 passes priority to resolve the next triggered ability"); + } + assert!( + resolve_all.state().stack.is_empty(), + "all three triggered abilities resolve through the normal priority path" + ); + assert_eq!( + resolve_all.state().players[P0.0 as usize].life, + life_before + 10, + "the 5, 3 and 2 life effects each occur exactly once" + ); + + // (15b) Counter one identified reflexive through the normal stack path and + // resolve the other two: the countered effect must not occur while the other + // two occur exactly once. + let mut countered = GameRunner::from_state(post_order); + let top = countered + .state() + .stack + .last() + .expect("the ordered group left three entries on the stack"); + let countered_source = match top.kind { + StackEntryKind::TriggeredAbility { source_id, .. } => source_id, + ref other => panic!("unexpected top-of-stack entry {other:?}"), + }; + let countered_gain = if countered_source == source_a { + 5 + } else if countered_source == source_b { + 3 + } else { + 2 + }; + let top_id = top.id; + let counter_ability = ResolvedAbility::new( + Effect::Counter { + target: TargetFilter::StackAbility { + controller: None, + tag: None, + kind: None, + }, + source_rider: None, + countered_spell_zone: None, + }, + vec![TargetRef::Object(top_id)], + observer_d, + P0, + ); + engine::game::effects::counter::resolve( + countered.state_mut(), + &counter_ability, + &mut Vec::new(), + ) + .expect("the identified entry is countered through the production counter resolver"); + assert_eq!( + countered + .state() + .stack + .iter() + .filter(|entry| matches!( + entry.kind, + StackEntryKind::TriggeredAbility { source_id, .. } if source_id == countered_source + )) + .count(), + 0, + "the countered entry leaves the stack" + ); + for _ in 0..2 { + countered + .act(GameAction::PassPriority) + .expect("P0 passes priority to resolve a surviving triggered ability"); + countered + .act(GameAction::PassPriority) + .expect("P1 passes priority to resolve a surviving triggered ability"); + } + assert!(countered.state().stack.is_empty()); + assert_eq!( + countered.state().players[P0.0 as usize].life, + life_before + 10 - countered_gain, + "the countered reflexive's effect does not occur while the other two occur exactly once" + ); +} + +/// CR 603.3b + CR 605.3a — the plan's **observer-axis sibling** of the hostile +/// direct-Priority row, and the positive-and-revert discriminator for the +/// ancestor-prefix finding. +/// +/// It changes exactly one thing: the ordinary observer watches A's tap instead +/// of B's. A's tap lives in the SUSPENDED PARENT's retained prefix, not in the +/// paused child B's frame-local ledger, so the completed root can only collect +/// observer E if the parent-snapshot suffix augmentation actually preserved +/// A's first-action tap across the pause. Reverting that augmentation leaves no +/// collector holding A's tap: E never fires, the single ordering group has only +/// two members, and E's distinguishable +4 never occurs. A ledger-shape-only +/// unit test cannot close this — the shapes look identical until a real +/// observer has to match out of them. +#[test] +fn direct_priority_mana_root_orders_the_ancestor_prefix_observer_with_both_reflexives() { + let HostileAbcdFixture { + scenario, + source_a, + source_b, + source_c, + observer: observer_e, + observer_gain, + } = hostile_abcd_fixture(HostileObserverAxis::TapsA); + let mut runner = scenario.build(); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { player } if player == P0 + )); + assert!(runner.state().stack.is_empty()); + assert!(runner.state().pending_cast.is_none()); + let life_before = runner.state().players[P0.0 as usize].life; + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_a, + ability_index: 0, + }) + .expect("A's {2} Mana component pays through B and pauses on its exile replacement"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "expected B's exile ReplacementChoice, got {:?}", + paused.waiting_for + ); + // The identical chronology reach guards: same nested cursor tree, same two + // DISJOINT ledgers, same untapped C. + assert_nested_hostile_pause(runner.state(), source_a, source_b); + assert!(runner.state().objects[&source_a].tapped); + assert!(runner.state().objects[&source_b].tapped); + assert!(!runner.state().objects[&source_c].tapped); + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + let json = serde_json::to_string(runner.state()).expect("paused cursor tree serializes"); + let restored: GameState = serde_json::from_str(&json).expect("paused cursor tree restores"); + let mut runner = GameRunner::from_state(restored); + assert_nested_hostile_pause(runner.state(), source_a, source_b); + assert!(!runner.state().objects[&source_c].tapped); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes B, then A's later synchronous C"); + assert!(runner.state().objects[&source_c].tapped); + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + // ONE empty-stack ordering group holding exactly B's reflexive, E's A-tap + // observer, and A's reflexive. + let WaitingFor::OrderTriggers { + player: order_player, + triggers: ref group, + } = resumed.waiting_for + else { + panic!( + "the completed root must expose one CR 603.3b ordering group, got {:?}", + resumed.waiting_for + ); + }; + assert_eq!(order_player, P0); + assert!( + runner.state().stack.is_empty(), + "no member of the batch may be dispatched separately" + ); + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!( + members.len(), + 3, + "expected exactly B's reflexive, E's A-tap observer, and A's reflexive; got {members:?}" + ); + for (label, id) in [("A", source_a), ("B", source_b), ("E", observer_e)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} must appear in the single ordering group exactly once: {members:?}" + ); + } + + runner + .act(GameAction::OrderTriggers { + order: vec![0, 1, 2], + }) + .expect("the three-member group orders through the real CR 603.3b action"); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 3 + ); + for _ in 0..3 { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert!(runner.state().stack.is_empty()); + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 5 + 3 + observer_gain, + "E's distinct effect occurs exactly once together with the two reflexive effects" + ); +} + +/// CR 601.2h + CR 603.3b — the plan's **masked cast-root control** for the same +/// A/B/C/D topology. +/// +/// A completed mana frame is a cost-payment micro-frame inside somebody else's +/// action, so it may not release its own batch. With a real `PendingCast` and a +/// live `WaitingFor::ManaPayment` owner, the whole three-member batch must stay +/// in `state.deferred_triggers` — no ordering prompt, no triggered stack entry, +/// and specifically no observer-D entry — until the spell is actually announced. +/// Only the cast finalizer may expose the single ordering group, and it must +/// expose exactly those three above the already-announced spell. +/// +/// The `deferred_triggers`-size assertion alone does not close the unified-seam +/// finding; the explicit "no D on the stack" assertion is the one that fails +/// under a separate fresh dispatch of the ordinary half. +#[test] +fn masked_cast_root_mana_batch_stays_queued_until_the_spell_is_announced() { + let HostileAbcdFixture { + mut scenario, + source_a, + source_b, + source_c, + observer: observer_d, + observer_gain, + } = hostile_abcd_fixture(HostileObserverAxis::TapsB); + let spell = scenario + .add_spell_to_hand(P0, "Masked Cast Root Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + let cast = runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Manual, + }) + .expect("the masked owner announces a real pending cast in manual payment mode"); + assert!( + matches!(cast.waiting_for, WaitingFor::ManaPayment { player: P0, .. }), + "expected a live ManaPayment owner, got {:?}", + cast.waiting_for + ); + assert!(runner.state().pending_cast.is_some()); + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_a, + ability_index: 0, + }) + .expect("A is manually activated during the cast's mana payment"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "expected the same nested B pause under the masked owner, got {:?}", + paused.waiting_for + ); + assert_nested_hostile_pause_cursor_tree(runner.state(), source_a, source_b); + assert!( + !runner.state().objects[&source_c].tapped, + "C must be untapped at the pause under the masked owner too" + ); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes B, then A's later synchronous C"); + assert!(runner.state().objects[&source_c].tapped); + + // The masked contract: the owner is still live, and the WHOLE batch is + // queued rather than released. + assert!( + matches!( + resumed.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "the cast owner must retain the action, got {:?}", + resumed.waiting_for + ); + assert!( + !matches!(resumed.waiting_for, WaitingFor::OrderTriggers { .. }), + "a masked mana frame may not open CR 603.3b ordering" + ); + assert!( + !runner + .state() + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })), + "no member of the batch — and specifically not observer D — may reach the stack \ + while the cast owner is live" + ); + let queued: Vec = runner + .state() + .deferred_triggers + .iter() + .map(|context| context.pending.source_id) + .collect(); + assert_eq!( + queued.len(), + 3, + "one deferred queue holding exactly B's reflexive, D's observer, and A's reflexive: \ + {queued:?}" + ); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + queued.iter().filter(|member| **member == id).count(), + 1, + "{label} is queued exactly once: {queued:?}" + ); + } + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + // Complete the payment through the real action protocol. Only the cast + // finalizer may release the group. + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "A's green mana is available to pay the spell's single green pip" + ); + let finalized = runner.act(GameAction::PassPriority).expect( + "the manual payment finalizes from the available green mana and announces the spell", + ); + + assert!( + runner + .state() + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::Spell { .. })), + "the spell is announced exactly once before the batch is released" + ); + let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = finalized.waiting_for + else { + panic!( + "the cast finalizer must expose the single ordering group, got {:?}", + finalized.waiting_for + ); + }; + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!(members.len(), 3, "{members:?}"); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} appears in the released group exactly once: {members:?}" + ); + } + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 0, + "no trigger entry may be on the stack before the group is ordered" + ); + + runner + .act(GameAction::OrderTriggers { + order: vec![0, 1, 2], + }) + .expect("the released group orders above the announced spell"); + let stack_kinds: Vec = runner + .state() + .stack + .iter() + .map(|entry| matches!(entry.kind, StackEntryKind::Spell { .. })) + .collect(); + assert_eq!(stack_kinds.iter().filter(|is_spell| **is_spell).count(), 1); + assert!( + stack_kinds[0], + "CR 603.3 places the newly ordered triggers ABOVE the spell that was already announced" + ); + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + for _ in 0..3 { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 5 + 3 + observer_gain, + "each masked batch member resolves exactly once after the owner released it" + ); +} + +/// CR 118.12 + CR 603.3b + CR 608.2 — the plan's **masked resolution-root +/// control** for the same A/B/C/D topology, and the sibling that session 7's +/// report recorded as the one remaining owed member of this family. +/// +/// The masked cast-root control proves a live `PendingCast` masks the batch. +/// This row proves the *other* masking owner the plan names: a real resolution +/// that has stopped at `WaitingFor::UnlessPayment`. There is no `PendingCast` +/// here at all — the owner is a live `resolution_stack` frame — so the two +/// controls cannot share a bug: a completed mana frame may not release its own +/// batch under EITHER owner, and only the owner's own settlement may. +/// +/// The `deferred_triggers`-size assertion alone does not close the unified-seam +/// finding; the explicit "no D on the stack" assertion is the one that fails +/// under a separate fresh dispatch of the ordinary half. The unpaid punishment +/// is -100 life, so a resolution that leaked past its unless-payment would be +/// impossible to confuse with the batch's own +1/+5/+3/+2. +/// +/// FIXTURE NOTE, deliberately recorded rather than absorbed: the punisher +/// carries an "if you do" rider, so the paid branch runs an ability chain and +/// the owner reaches its own post-action settlement, which is what releases the +/// group. A rider-FREE unless-payment currently has no settlement convergence +/// at all — `finish_successful_unless_payment` runs the pipeline only when it +/// resolved a sub-ability, so a bare paid cost lands on `Priority` with the +/// three contexts still queued. That gap is exactly what the plan's settled- +/// Priority convergence wrapper (`run_post_action_pipeline_from_settled_priority` +/// plus the `engine_payment_choices` readiness hook) closes, and it is still +/// owed; this row deliberately does not encode the gap as a contract. +#[test] +fn masked_unless_payment_root_mana_batch_stays_queued_until_the_owner_settles() { + let HostileAbcdFixture { + mut scenario, + source_a, + source_b, + source_c, + observer: observer_d, + observer_gain, + } = hostile_abcd_fixture(HostileObserverAxis::TapsB); + + // "You lose 100 life unless you pay {1}. If you do, you gain 1 life." The + // punishment is unmissable if the owner ever leaked past its payment, and + // the "if you do" rider is what carries the resolution to its own + // completion — this row's release boundary. + let mut paid_rider = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ); + paid_rider.condition = Some(engine::types::ability::AbilityCondition::EffectOutcome { + signal: engine::types::ability::EffectOutcomeSignal::OptionalEffectPerformed, + }); + let mut punisher = AbilityDefinition::new( + AbilityKind::Spell, + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 100 }, + target: Some(TargetFilter::Controller), + }, + ) + .sub_ability(paid_rider); + punisher.unless_pay = Some(UnlessPayModifier { + cost: AbilityCost::Mana { + cost: ManaCost::generic(1), + }, + payer: TargetFilter::Controller, + }); + let spell = scenario + .add_spell_to_hand(P0, "Masked Resolution Root Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 0, + }) + .with_ability_definition(punisher) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("the free witness spell is announced"); + runner.act(GameAction::PassPriority).expect("P0 passes"); + let owner = runner + .act(GameAction::PassPriority) + .expect("P1 passes and the witness resolves into its unless-payment"); + assert!( + matches!( + owner.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + ), + "expected a live UnlessPayment resolution owner, got {:?}", + owner.waiting_for + ); + // Positive reach guard for the axis that distinguishes this control from the + // cast-root one: the masking owner here is a paused resolution, not a cast. + assert!( + runner.state().pending_cast.is_none(), + "no PendingCast may mask this batch — the owner is the resolution itself" + ); + let WaitingFor::UnlessPayment { + pending_effect: ref parked, + ref cost, + .. + } = owner.waiting_for + else { + unreachable!() + }; + assert_eq!( + parked.source_id, spell, + "the parked punisher is the witness spell's own resolution" + ); + assert_eq!( + cost, + &AbilityCost::Mana { + cost: ManaCost::generic(1) + } + ); + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_a, + ability_index: 0, + }) + .expect("A is manually activated during the unless payment (CR 118.12)"); + assert!( + matches!(paused.waiting_for, WaitingFor::ReplacementChoice { .. }), + "expected the same nested B pause under the resolution owner, got {:?}", + paused.waiting_for + ); + assert_nested_hostile_pause_cursor_tree(runner.state(), source_a, source_b); + assert!( + !runner.state().objects[&source_c].tapped, + "C must be untapped at the pause under the resolution owner too" + ); + + let resumed = runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("the replacement choice resumes B, then A's later synchronous C"); + assert!(runner.state().objects[&source_c].tapped); + + // The masked contract: the resolution owner is still live, and the WHOLE + // batch is queued rather than released. + assert!( + matches!( + resumed.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + ), + "the resolution owner must retain the action, got {:?}", + resumed.waiting_for + ); + assert!( + !matches!(resumed.waiting_for, WaitingFor::OrderTriggers { .. }), + "a masked mana frame may not open CR 603.3b ordering" + ); + assert!( + !runner + .state() + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })), + "no member of the batch — and specifically not observer D — may reach the stack \ + while the resolution owner is live" + ); + let queued: Vec = runner + .state() + .deferred_triggers + .iter() + .map(|context| context.pending.source_id) + .collect(); + assert_eq!( + queued.len(), + 3, + "one deferred queue holding exactly B's reflexive, D's observer, and A's reflexive: \ + {queued:?}" + ); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + queued.iter().filter(|member| **member == id).count(), + 1, + "{label} is queued exactly once: {queued:?}" + ); + } + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "A's green mana is available to pay the unless cost" + ); + + // Only the resolution owner's own settlement may release the group. + let settled = runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the unless cost is paid from A's green mana and the punisher is prevented"); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 0, + "the green pip really paid the unless cost" + ); + let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = settled.waiting_for + else { + panic!( + "the settled resolution owner must expose the single ordering group, got {:?}", + settled.waiting_for + ); + }; + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!(members.len(), 3, "{members:?}"); + for (label, id) in [("A", source_a), ("B", source_b), ("D", observer_d)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} appears in the released group exactly once: {members:?}" + ); + } + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 0, + "no trigger entry may be on the stack before the group is ordered" + ); + // The paid branch already ran to completion: the +1 "if you do" rider is the + // owner's own last instruction and it happened BEFORE the release, while the + // -100 punishment never did. + assert_eq!(runner.state().players[P0.0 as usize].life, life_before + 1); + + runner + .act(GameAction::OrderTriggers { + order: vec![0, 1, 2], + }) + .expect("the released group orders once the resolution owner has settled"); + for _ in 0..3 { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 1 + 5 + 3 + observer_gain, + "each masked batch member resolves exactly once, and the paid-for punisher never does" + ); +} + +/// The plan's assertions (4)-(7) at the hostile pause, factored so the +/// in-memory and the restored-from-JSON branches assert byte-identical +/// properties. The two ledger assertions are the discriminating pair: they fail +/// in OPPOSITE directions for the two reverts this row exists to catch. +fn assert_nested_hostile_pause_cursor_tree( + state: &GameState, + source_a: ObjectId, + source_b: ObjectId, +) { + let Some(PendingCostMoveResume::ManaAbilityPayment { pending, cursor }) = + state.pending_cost_move_resume.as_ref() + else { + panic!( + "expected a nested ManaAbilityPayment owner, got {:?}", + state.pending_cost_move_resume + ); + }; + assert_eq!(pending.source_id, source_b, "the paused child is B"); + + let parent = cursor + .parent + .as_ref() + .expect("B's cursor must carry the nested A parent"); + assert_eq!( + parent.lifecycle, + ManaAbilityCostParentLifecycle::Suspended, + "pausing B recursively suspends A" + ); + assert_eq!(parent.pending.source_id, source_a, "the parent is A"); + assert!( + parent.cursor.remaining.iter().any(|cost| matches!( + cost, + AbilityCost::Mana { + cost: ManaCost::Cost { generic: 2, .. } + } + )), + "A still owes its {{2}} Mana component, so a LATER synchronous child follows: {:?}", + parent.cursor.remaining + ); + + let tap_of = |events: &[GameEvent], id: ObjectId| { + events + .iter() + .filter(|event| { + matches!( + event, + GameEvent::PermanentTapped { object_id, .. } if *object_id == id + ) + }) + .count() + }; + // (6) B's FRAME-LOCAL ledger: exactly B's own tap, and no ancestor data. + // Restoring the pre-fix clone-and-scan child ledger puts A's tap here too. + assert_eq!( + tap_of(&cursor.deferred_cost_events, source_b), + 1, + "B's local ledger owns exactly its own tap: {:?}", + cursor.deferred_cost_events + ); + assert_eq!( + tap_of(&cursor.deferred_cost_events, source_a), + 0, + "a child may never scan its ancestor's events: {:?}", + cursor.deferred_cost_events + ); + // (7) A's PREPARED parent snapshot: exactly A's pre-child suffix. Reverting + // the snapshot augmentation at the `resolve_mana_ability_excluding` call + // site leaves this empty. + assert_eq!( + tap_of(&parent.cursor.deferred_cost_events, source_a), + 1, + "the prepared synchronous parent captured A's pre-child suffix: {:?}", + parent.cursor.deferred_cost_events + ); + assert_eq!( + tap_of(&parent.cursor.deferred_cost_events, source_b), + 0, + "and never the child's own events: {:?}", + parent.cursor.deferred_cost_events + ); +} + +/// The direct-Priority root additionally owns the plan's "no competing owner" +/// half of assertion (4). The masked-root controls deliberately do NOT assert +/// it: a live `PendingCast` or resolution owner is exactly what they exist to +/// mask the batch behind. +fn assert_nested_hostile_pause(state: &GameState, source_a: ObjectId, source_b: ObjectId) { + assert_nested_hostile_pause_cursor_tree(state, source_a, source_b); + assert!(state.pending_cast.is_none()); + assert!(state.stack.is_empty()); + assert!(state.resolution_stack.is_empty()); + assert!(!matches!( + state.waiting_for, + WaitingFor::OrderTriggers { .. } + )); +} + +// --------------------------------------------------------------------------- +// Round-9 finding 2: the required NO-PAUSE matrix. One synchronous fixture, all +// three real roots, both colour halves — six action rows through the single +// typed completed-mana-frame seam, plus the `TapLandForMana` regression that +// exercises the one changed match arm none of the six reach. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NoPauseColorAxis { + /// N produces `{G}` outright: the activation completes in one action. + Fixed, + /// N produces one mana of any colour: the activation returns + /// `WaitingFor::ChooseManaColor` first, and the choice action completes it. + AnyOneColor, +} + +struct NoPauseFixture { + scenario: GameScenario, + source_n: ObjectId, + observer_o: ObjectId, +} + +/// The plan's synchronous no-pause fixture: source N `{T}: Add {G}` with a +/// targetless true `WhenYouDo` rider gaining 5, and a source-filtered ordinary +/// `Taps` observer O of N gaining 2. No replacement, no deferred life cost, no +/// other pause is reachable, so N's durable cost ledger is provably empty and +/// every row exercises the EMPTY-LEDGER half of the completed-frame seam. +fn no_pause_mana_fixture(axis: NoPauseColorAxis) -> NoPauseFixture { + let mut reflexive = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 5 }, + player: TargetFilter::Controller, + }, + ); + reflexive.condition = Some(engine::types::ability::AbilityCondition::WhenYouDo); + + let produced = match axis { + NoPauseColorAxis::Fixed => ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + NoPauseColorAxis::AnyOneColor => ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: vec![ManaColor::Green, ManaColor::Blue], + contribution: ManaContribution::Base, + }, + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source_n = scenario + .add_creature(P0, "N Synchronous Green Source", 1, 1) + .as_artifact() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap) + .sub_ability(reflexive), + ) + .id(); + let observer_o = scenario + .add_creature(P0, "O N-Tap Observer", 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::Taps) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 2 }, + player: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::SpecificObject { id: source_n }) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id(); + + NoPauseFixture { + scenario, + source_n, + observer_o, + } +} + +/// The plan's shared per-boundary assertions for the no-pause matrix: neither +/// member is separately pushed, the deferred queue holds exactly N's reflexive +/// and O once each, and no life has been gained yet. +fn assert_exactly_two_contexts_queued_and_unstacked( + state: &GameState, + source_n: ObjectId, + observer_o: ObjectId, + life_before: i32, +) { + assert!( + !state + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })), + "no member of the pair may be separately pushed before its owner releases it" + ); + let queued: Vec = state + .deferred_triggers + .iter() + .map(|context| context.pending.source_id) + .collect(); + assert_eq!( + queued.len(), + 2, + "exactly N's reflexive and O, once each: {queued:?}" + ); + for (label, id) in [("N", source_n), ("O", observer_o)] { + assert_eq!( + queued.iter().filter(|member| **member == id).count(), + 1, + "{label} is queued exactly once: {queued:?}" + ); + } + assert_eq!( + state.players[P0.0 as usize].life, life_before, + "no trigger may resolve before the release group is ordered" + ); +} + +/// The plan's terminal assertions: exactly one group of exactly the two +/// contexts, nothing on the stack before ordering, then the two distinguishable +/// effects exactly once each for +7. +fn assert_single_group_of_two_then_resolve_for_seven( + runner: &mut GameRunner, + group_wait: &WaitingFor, + source_n: ObjectId, + observer_o: ObjectId, + life_before: i32, +) { + let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = group_wait + else { + panic!("expected the single release group, got {group_wait:?}"); + }; + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!(members.len(), 2, "{members:?}"); + for (label, id) in [("N", source_n), ("O", observer_o)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} appears in the released group exactly once: {members:?}" + ); + } + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 0, + "no trigger entry may be on the stack before the group is ordered" + ); + + runner + .act(GameAction::OrderTriggers { order: vec![0, 1] }) + .expect("the released group orders"); + for _ in 0..3 { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 5 + 2, + "the distinguishable 5- and 2-life effects each happen exactly once" + ); +} + +/// Root 1 of the plan's no-pause matrix: a direct `ActivateAbility` from +/// `WaitingFor::Priority` with an EMPTY durable ledger. +/// +/// Baseline had no seam here at all: a `Priority` resume with no deferred cost +/// events fell straight through `finish_mana_ability_cost_payment` to the +/// generic post-action scan, which dispatched observer O on its own while N's +/// synthetic reflexive was materialized by the mana frame. Routing the empty +/// ledger through `collect_completed_mana_frame_events` makes the two one batch. +/// Restoring the `has_deferred_cost_events` gate splits them and the +/// exactly-two group assertion fails. +#[test] +fn direct_priority_no_pause_root_releases_its_reflexive_and_observer_as_one_group() { + let NoPauseFixture { + scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + assert!(runner.state().stack.is_empty()); + assert!(runner.state().pending_cast.is_none()); + + let acted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + + // The durable ledger really was empty: nothing paused. + assert!( + runner.state().pending_cost_move_resume.is_none(), + "positive reach guard: the fixture is synchronous, so no cursor survives" + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "N's mana is available immediately (CR 605.3b)" + ); + assert_eq!(runner.state().players[P0.0 as usize].life, life_before); + + // The direct root exposes ONE empty-stack ordering group after the action. + assert!( + runner + .state() + .stack + .iter() + .all(|entry| !matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })), + "CR 603.3b ordering happens before any entry is placed" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &acted.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +/// Root 2 of the plan's no-pause matrix: manual activation during a real +/// spell's `WaitingFor::ManaPayment`. The owner retains the action and one +/// two-context queue until the spell is announced, and only the cast finalizer +/// exposes the group — above the announced spell. +#[test] +fn masked_cast_no_pause_root_queues_both_contexts_until_the_spell_is_announced() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let spell = scenario + .add_spell_to_hand(P0, "No-Pause Masked Cast Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + let cast = runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Manual, + }) + .expect("the masked owner announces a real pending cast in manual payment mode"); + assert!(matches!( + cast.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + )); + + let activated = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is manually activated during the cast's mana payment"); + assert!( + matches!( + activated.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "the cast owner must retain the action, got {:?}", + activated.waiting_for + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "N's mana is available immediately, before any trigger resolves" + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let finalized = runner + .act(GameAction::PassPriority) + .expect("the manual payment finalizes and announces the spell"); + assert!( + runner + .state() + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::Spell { .. })), + "the spell is announced exactly once before the batch is released" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &finalized.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +/// Root 3 of the plan's no-pause matrix: manual activation during a real +/// `UnlessPayment` resolution owner. Same fixture, same queue, and the group is +/// exposed only once that owner has settled. +#[test] +fn masked_resolution_no_pause_root_queues_both_contexts_until_the_owner_settles() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let spell = scenario + .add_spell_to_hand(P0, "No-Pause Masked Resolution Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 0, + }) + .with_ability_definition(unless_pay_one_punisher()) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("the free witness spell is announced"); + runner.act(GameAction::PassPriority).expect("P0 passes"); + let owner = runner + .act(GameAction::PassPriority) + .expect("P1 passes and the witness resolves into its unless-payment"); + assert!( + matches!( + owner.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + ), + "expected a live UnlessPayment resolution owner, got {:?}", + owner.waiting_for + ); + assert!(runner.state().pending_cast.is_none()); + + let activated = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is manually activated during the unless payment (CR 118.12)"); + assert!( + matches!( + activated.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + ), + "the resolution owner must retain the action, got {:?}", + activated.waiting_for + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1 + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let settled = runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the unless cost is paid from N's green mana"); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 0, + "the green pip really paid the unless cost" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &settled.waiting_for, + source_n, + // The paid branch's +1 rider ran before the release, so the two batch + // members are measured against the post-rider baseline. + observer_o, + life_before + 1, + ); +} + +/// "You lose 100 life unless you pay {1}. If you do, you gain 1 life." — the +/// resolution-owner witness shared by the hostile masked control and the +/// no-pause resolution root. The rider is what carries the resolution to its +/// own completion, which is this family's release boundary; see +/// `masked_unless_payment_root_mana_batch_stays_queued_until_the_owner_settles` +/// for the recorded gap in the rider-free shape. +fn unless_pay_one_punisher() -> AbilityDefinition { + let mut paid_rider = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ); + paid_rider.condition = Some(engine::types::ability::AbilityCondition::EffectOutcome { + signal: engine::types::ability::EffectOutcomeSignal::OptionalEffectPerformed, + }); + let mut punisher = AbilityDefinition::new( + AbilityKind::Spell, + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 100 }, + target: Some(TargetFilter::Controller), + }, + ) + .sub_ability(paid_rider); + punisher.unless_pay = Some(UnlessPayModifier { + cost: AbilityCost::Mana { + cost: ManaCost::generic(1), + }, + payer: TargetFilter::Controller, + }); + punisher +} + +/// The plan's colour-half reach guard: the activation action returns +/// `ChooseManaColor` after collecting O but WITHOUT ordering or stacking it, and +/// the reflexive does not exist yet because no mana has been produced. +fn assert_color_prompt_holds_only_the_observer( + state: &GameState, + source_n: ObjectId, + observer_o: ObjectId, + life_before: i32, +) { + assert!( + !matches!(state.waiting_for, WaitingFor::OrderTriggers { .. }), + "the colour prompt may not be preceded by a CR 603.3b ordering pass" + ); + assert!( + !state + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::TriggeredAbility { .. })), + "O may not be separately pushed while the colour choice is open" + ); + let queued: Vec = state + .deferred_triggers + .iter() + .map(|context| context.pending.source_id) + .collect(); + assert_eq!( + queued, + vec![observer_o], + "the already-paid cost range was collected into exactly O; N's reflexive \ + cannot exist yet because no mana has been produced" + ); + assert!( + !queued.contains(&source_n), + "no reflexive before production: {queued:?}" + ); + assert_eq!(state.players[P0.0 as usize].life, life_before); +} + +/// Colour half of root 1: the direct `ActivateAbility` root whose `AnyOneColor` +/// production returns `ChooseManaColor` first. Both halves of the seam run — +/// the pre-prompt collection in `finish_mana_ability_cost_payment` and the +/// post-choice collection in `handle_choose_mana_color` — and the release group +/// is identical to the fixed-colour row's. +#[test] +fn direct_priority_color_choice_root_releases_the_same_two_context_group() { + let NoPauseFixture { + scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::AnyOneColor); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let prompted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N's AnyOneColor production opens the colour choice"); + assert!( + matches!( + prompted.waiting_for, + WaitingFor::ChooseManaColor { player: P0, .. } + ), + "expected ChooseManaColor, got {:?}", + prompted.waiting_for + ); + assert_color_prompt_holds_only_the_observer(runner.state(), source_n, observer_o, life_before); + + let chosen = runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Green), + count: 1, + }) + .expect("the choice action produces the chosen mana and materializes the reflexive"); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1 + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &chosen.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +/// Colour half of root 2: the same two halves under a live `PendingCast`. The +/// cast owner retains the action across BOTH halves and only the finalizer +/// exposes the group. +#[test] +fn masked_cast_color_choice_root_releases_the_same_two_context_group() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::AnyOneColor); + let spell = scenario + .add_spell_to_hand(P0, "Colour-Half Masked Cast Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Manual, + }) + .expect("the masked owner announces a real pending cast"); + + let prompted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is manually activated during the cast's mana payment"); + assert!( + matches!( + prompted.waiting_for, + WaitingFor::ChooseManaColor { player: P0, .. } + ), + "expected ChooseManaColor, got {:?}", + prompted.waiting_for + ); + assert_color_prompt_holds_only_the_observer(runner.state(), source_n, observer_o, life_before); + + let chosen = runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Green), + count: 1, + }) + .expect("the choice action completes N under the still-live cast owner"); + assert!( + matches!( + chosen.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "the cast owner must retain the action across the colour choice, got {:?}", + chosen.waiting_for + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let finalized = runner + .act(GameAction::PassPriority) + .expect("the manual payment finalizes and announces the spell"); + assert!(runner + .state() + .stack + .iter() + .any(|entry| matches!(entry.kind, StackEntryKind::Spell { .. }))); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &finalized.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +/// Colour half of root 3: the same two halves under a live `UnlessPayment` +/// resolution owner. +#[test] +fn masked_resolution_color_choice_root_releases_the_same_two_context_group() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::AnyOneColor); + let spell = scenario + .add_spell_to_hand(P0, "Colour-Half Masked Resolution Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 0, + }) + .with_ability_definition(unless_pay_one_punisher()) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("the free witness spell is announced"); + runner.act(GameAction::PassPriority).expect("P0 passes"); + let owner = runner + .act(GameAction::PassPriority) + .expect("P1 passes and the witness resolves into its unless-payment"); + assert!(matches!( + owner.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + )); + + let prompted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is manually activated during the unless payment"); + assert!( + matches!( + prompted.waiting_for, + WaitingFor::ChooseManaColor { player: P0, .. } + ), + "expected ChooseManaColor, got {:?}", + prompted.waiting_for + ); + assert_color_prompt_holds_only_the_observer(runner.state(), source_n, observer_o, life_before); + + let chosen = runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Green), + count: 1, + }) + .expect("the choice action completes N under the still-live resolution owner"); + assert!( + matches!( + chosen.waiting_for, + WaitingFor::UnlessPayment { player: P0, .. } + ), + "the resolution owner must retain the action across the colour choice, got {:?}", + chosen.waiting_for + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let settled = runner + .act(GameAction::PayUnlessCost { pay: true }) + .expect("the unless cost is paid from N's green mana"); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &settled.waiting_for, + source_n, + observer_o, + life_before + 1, + ); +} + +/// The plan's required `ManaPayment` + `TapLandForMana` action regression. +/// +/// The six-row no-pause matrix's manual-cast `ActivateAbility` row does not +/// execute the ADJACENT changed match arm, so this row drives the real +/// engine-authored `GameAction::TapLandForMana` instead — never +/// `ActivateAbility`, never `handle_tap_land_for_mana`, never a production +/// helper directly. +/// +/// Explicit revert discriminator: restoring that one arm's baseline immediate +/// `process_triggers(state, &mana_events)` dispatches or stages O separately +/// while L's reflexive stays under the cast guard, so the immediate exact-two +/// deferred queue, the empty trigger stack, and the later exact-two single group +/// cannot all hold. This row must fail under that one-arm revert even when the +/// adjacent `ManaPayment` `ActivateAbility` routing remains correct. +#[test] +fn manual_cast_tap_land_for_mana_defers_its_reflexive_and_observer_as_one_group() { + let mut reflexive = AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 5 }, + player: TargetFilter::Controller, + }, + ); + reflexive.condition = Some(engine::types::ability::AbilityCondition::WhenYouDo); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let land_l = scenario + .add_creature(P0, "L Reflexive Green Land", 1, 1) + .as_land() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap) + .sub_ability(reflexive), + ) + .id(); + let observer_o = scenario + .add_creature(P0, "O L-Tap Observer", 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::Taps) + .execute(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 2 }, + player: TargetFilter::Controller, + }, + )) + .valid_card(TargetFilter::SpecificObject { id: land_l }) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id(); + let spell = scenario + .add_spell_to_hand(P0, "Tap-Land Payment Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + let cast = runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Manual, + }) + .expect("a real one-green cast reaches manual mana payment"); + assert!( + matches!(cast.waiting_for, WaitingFor::ManaPayment { player: P0, .. }), + "expected the real ManaPayment owner, got {:?}", + cast.waiting_for + ); + + // The selection must be ENGINE-AUTHORED, not hand-built. + let (_, _, grouped) = legal_actions_full(runner.state()); + let selection = grouped + .get(&land_l) + .into_iter() + .flatten() + .find_map(|action| match action { + GameAction::TapLandForMana { selection } => Some(selection.clone()), + _ => None, + }) + .expect("the engine authors a ManaSourceSelection for L at the payment prompt"); + assert_eq!(selection.source.object_id, land_l); + assert_eq!( + selection.ability_index, + Some(0), + "the selection names L's own {{T}}: Add {{G}} ability" + ); + + let tapped = runner + .act(GameAction::TapLandForMana { selection }) + .expect("the engine-authored land tap is submitted as its own action"); + + assert!(runner.state().objects[&land_l].tapped, "L is tapped"); + assert_eq!( + tapped + .events + .iter() + .filter(|event| matches!( + event, + GameEvent::TappedForMana { source_id, .. } if *source_id == land_l + )) + .count(), + 1, + "exactly one source-identifiable TappedForMana(L): {:?}", + tapped.events + ); + assert_eq!( + tapped + .events + .iter() + .filter(|event| matches!(event, GameEvent::ManaAdded { .. })) + .count(), + 1, + "exactly one base ManaAdded occurrence: {:?}", + tapped.events + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "L's green mana is present and usable immediately" + ); + assert!( + runner.state().pending_cast.is_some(), + "the pending cast is still live" + ); + assert!( + matches!( + tapped.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "the cast owner retains the action, got {:?}", + tapped.waiting_for + ); + assert!( + !matches!(tapped.waiting_for, WaitingFor::OrderTriggers { .. }), + "a masked land tap may not open CR 603.3b ordering" + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + land_l, + observer_o, + life_before, + ); + assert!( + runner.state().deferred_triggers.iter().all(|context| { + !engine::game::mana_abilities::is_triggered_mana_ability( + &context.pending.ability, + context.pending.trigger_event.as_ref(), + ) + }), + "neither queued context is an accepted triggered-mana context" + ); + + let finalized = runner + .act(GameAction::PassPriority) + .expect("the manual payment spends L's green pip and announces the spell"); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 0, + "the green pip is consumed by the real cast" + ); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::Spell { .. })) + .count(), + 1, + "the spell is announced exactly once" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &finalized.waiting_for, + land_l, + observer_o, + life_before, + ); +} + +// --------------------------------------------------------------------------- +// `ManaAdded` immediacy: the plan's "targetless nonmodal `ManaAdded`" classifier +// row, driven as real actions over the no-pause fixture. A third permanent M +// observes N's `ManaAdded` with a targetless nonmodal mana body, so the complete +// classifier accepts it and the immediate backend resolves it stacklessly inside +// the completed mana frame — its bonus mana is in the pool before the frame's +// owner is resumed, and it never becomes a queue or stack member. +// --------------------------------------------------------------------------- + +/// Attach permanent M to the no-pause fixture: a `TriggerMode::ManaAdded` +/// observer whose executed body is `body`. `OncePerTurn` is mandatory — M's own +/// mana body emits a further `ManaAdded`, and CR 603.2h is what stops the +/// fixed point from re-triggering M off its own production. +fn add_mana_added_observer(scenario: &mut GameScenario, label: &str, body: Effect) -> ObjectId { + scenario + .add_creature(P0, label, 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::ManaAdded) + .execute(AbilityDefinition::new(AbilityKind::Database, body)) + .constraint(TriggerConstraint::OncePerTurn) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id() +} + +/// M's accepted body: one colorless mana, targetless and nonmodal, so +/// `build_target_slots` is empty and `is_triggered_mana_ability` holds. +fn mana_added_bonus_mana_body() -> Effect { + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Fixed { value: 1 }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + } +} + +fn count_mana_added_from(events: &[GameEvent], source: ObjectId) -> usize { + events + .iter() + .filter( + |event| matches!(event, GameEvent::ManaAdded { source_id, .. } if *source_id == source), + ) + .count() +} + +/// How many pips in P0's pool were produced by `source`. +/// +/// Provenance rather than the raw event vector is the right witness for an +/// accepted occurrence: `collect_mana_action_trigger_batch` resolves the +/// accepted body into its own frame-local dispatch buffer, exactly as baseline +/// `process_triggers` does, so an inline body's own `ManaAdded` deliberately +/// never receives a live occurrence identity in the reducer's public vector. +/// The produced pip and its `source_id` are the durable record. +fn pips_produced_by(state: &GameState, source: ObjectId) -> usize { + state.players[P0.0 as usize] + .mana_pool + .mana + .iter() + .filter(|unit| unit.source_id == source) + .count() +} + +/// The plan's `ManaAdded` immediacy row at the direct-`Priority` root. +/// +/// M is accepted by the complete classifier, so `TriggerPlacement::TriggeredManaImmediate` +/// resolves it inside N's completed mana frame: the colorless pip exists in the +/// same action, M is never appended to `state.deferred_triggers`, no +/// `TriggeredAbility` entry is ever pushed for it, and the single release group +/// is still exactly N's reflexive plus O. +#[test] +fn direct_priority_mana_added_bonus_resolves_inline_without_queue_or_stack() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = add_mana_added_observer( + &mut scenario, + "M Mana-Added Bonus", + mana_added_bonus_mana_body(), + ); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let acted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "N's own base mana (CR 605.3b)" + ); + // The immediacy claim: M's accepted body already ran, stacklessly, inside + // the same completed mana frame. + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 1, + "M's accepted triggered mana is spendable in the SAME action (CR 605.4a)" + ); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 1, + "M produces exactly once — CR 603.2h stops it observing its own ManaAdded" + ); + assert_eq!( + pips_produced_by(runner.state(), source_n), + 1, + "and N's base production happens exactly once" + ); + assert_eq!( + count_mana_added_from(&acted.events, source_n), + 1, + "N's own base ManaAdded is a live action event exactly once" + ); + assert!( + !runner + .state() + .deferred_triggers + .iter() + .any(|context| context.pending.source_id == bonus_m), + "an accepted triggered-mana context is never appended to the ordinary queue" + ); + if let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = acted.waiting_for + { + assert!( + !group.iter().any(|summary| summary.source_id == bonus_m), + "and it is never a member of the released ordinary group" + ); + } + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &acted.waiting_for, + source_n, + observer_o, + life_before, + ); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 0, + "M never occupied the stack at any point" + ); +} + +/// The pure-axis positive reach guard for the row above: the ONLY difference is +/// M's executed body. A `GainLife` body makes `is_triggered_mana_ability` false, +/// so the same firing `ManaAdded` event, the same matcher and the same +/// `OncePerTurn` constraint now produce an ORDINARY deferred context — proving +/// the fixture really does couple M to N's production, and that immediacy is a +/// property of the accepted mana body rather than of the fixture's topology. +#[test] +fn direct_priority_mana_added_nonmana_body_is_deferred_as_an_ordinary_trigger() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = add_mana_added_observer( + &mut scenario, + "M Mana-Added Life", + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 3 }, + player: TargetFilter::Controller, + }, + ); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let acted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 0, + "a nonmana body produces no mana at all" + ); + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before, + "and nothing resolves before the release group is ordered" + ); + + let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = acted.waiting_for + else { + panic!("expected one release group, got {:?}", acted.waiting_for); + }; + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!( + members.len(), + 3, + "the rejected M joins N's reflexive and O in the SAME ordinary group: {members:?}" + ); + for (label, id) in [("N", source_n), ("O", observer_o), ("M", bonus_m)] { + assert_eq!( + members.iter().filter(|member| **member == id).count(), + 1, + "{label} appears exactly once: {members:?}" + ); + } + + runner + .act(GameAction::OrderTriggers { + order: vec![0, 1, 2], + }) + .expect("the released group orders"); + for _ in 0..4 { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + 5 + 2 + 3, + "all three distinguishable effects happen exactly once each" + ); +} + +/// The immediacy payoff at a masked root: M's bonus colorless pip must be +/// spendable by the very cast whose `WaitingFor::ManaPayment` masks the frame. +/// The witness spell costs `{1}{G}`, which is unpayable unless M resolved +/// stacklessly inside N's completed frame — reverting `settles_completed_frame` +/// to baseline's `is_ultimate_root && has_deferred_cost_events` leaves the +/// empty-ledger `ManaPayment` shape with no collection at all, so M never fires +/// and the generic pip cannot be paid. +#[test] +fn masked_cast_mana_added_bonus_is_spendable_before_the_spell_is_announced() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = add_mana_added_observer( + &mut scenario, + "M Mana-Added Bonus", + mana_added_bonus_mana_body(), + ); + let spell = scenario + .add_spell_to_hand(P0, "Mana-Added Immediacy Witness", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 1, + }) + .id(); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let card_id = runner.state().objects[&spell].card_id; + let cast = runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Manual, + }) + .expect("the masked owner announces a real pending cast in manual payment mode"); + assert!(matches!( + cast.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + )); + + let activated = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is manually activated during the cast's mana payment"); + assert!( + matches!( + activated.waiting_for, + WaitingFor::ManaPayment { player: P0, .. } + ), + "the cast owner must retain the action, got {:?}", + activated.waiting_for + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1 + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 1, + "M's accepted bonus is in the pool BEFORE the payment owner resumes" + ); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 1, + "the colorless pip really is M's, not a second pip of N's" + ); + assert!( + !runner + .state() + .deferred_triggers + .iter() + .any(|context| context.pending.source_id == bonus_m), + "an accepted triggered-mana context is never appended to the ordinary queue" + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let finalized = runner + .act(GameAction::PassPriority) + .expect("the manual payment spends BOTH pips and announces the spell"); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 0 + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 0, + "the generic pip is paid by M's bonus mana" + ); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(entry.kind, StackEntryKind::Spell { .. })) + .count(), + 1, + "the spell is announced exactly once" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &finalized.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +// --------------------------------------------------------------------------- +// Delayed-trigger families: the plan's `WhenNextEvent` one-shot control and the +// duration-bearing `WheneverEvent` persistence control. Both prove the mana +// frame's COMBINED collector materializes normal and delayed contexts together +// in one APNAP batch — `collect_pending_and_delayed_triggers_for_batch` — rather +// than letting the generic delayed pass discover them separately after the +// frame has already claimed its live occurrences. +// --------------------------------------------------------------------------- + +/// A free witness spell that installs one real delayed trigger through +/// `Effect::CreateDelayedTrigger`, and the second `{T}: Add {G}` source used to +/// prove one-shot removal versus duration-bearing persistence. +struct DelayedFrameFixture { + scenario: GameScenario, + source_n: ObjectId, + observer_o: ObjectId, + installer: ObjectId, + source_n2: ObjectId, +} + +/// The embedded matcher both delayed rows install. `valid_card` is mandatory: +/// without it `taps_for_mana_card_matches` requires the tapping permanent to BE +/// the delayed trigger's own source, which an installer in the graveyard never +/// is. `Any` (rather than a specific id) is what makes the one-shot row's second +/// tap a non-vacuous probe — N2 would match, and only removal stops it. +fn delayed_taps_for_mana_matcher() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::TapsForMana).valid_card(TargetFilter::Any) +} + +fn delayed_frame_fixture(condition: DelayedTriggerCondition) -> DelayedFrameFixture { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let installer = scenario + .add_spell_to_hand(P0, "Delayed Trigger Installer", true) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 0, + }) + .with_ability_definition(AbilityDefinition::new( + AbilityKind::Spell, + Effect::CreateDelayedTrigger { + condition, + effect: Box::new(AbilityDefinition::new( + AbilityKind::Database, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 4 }, + player: TargetFilter::Controller, + }, + )), + uses_tracked_set: false, + }, + )) + .id(); + // A second, otherwise identical mana source with NO rider and NO observer, + // so a later tap is a clean probe for whether the delayed source is still + // installed. + let source_n2 = scenario + .add_creature(P0, "N2 Plain Green Source", 1, 1) + .as_artifact() + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap), + ) + .id(); + + DelayedFrameFixture { + scenario, + source_n, + observer_o, + installer, + source_n2, + } +} + +/// Cast and resolve the free installer, then assert exactly one delayed trigger +/// is installed and return the caller's life total at that point. +fn resolve_delayed_installer(runner: &mut GameRunner, installer: ObjectId) -> i32 { + let card_id = runner.state().objects[&installer].card_id; + runner + .act(GameAction::CastSpell { + object_id: installer, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("the free installer is announced"); + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner + .act(GameAction::PassPriority) + .expect("P1 passes and the installer resolves"); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "exactly one real delayed trigger is installed by the resolved effect" + ); + runner.state().players[P0.0 as usize].life +} + +/// Order and resolve a released group of exactly `expected` members, then assert +/// the total life delta. +fn resolve_group_of( + runner: &mut GameRunner, + group_wait: &WaitingFor, + expected: &[ObjectId], + life_before: i32, + total_gain: i32, +) { + let WaitingFor::OrderTriggers { + triggers: ref group, + .. + } = group_wait + else { + panic!("expected one release group, got {group_wait:?}"); + }; + let members: Vec = group.iter().map(|summary| summary.source_id).collect(); + assert_eq!( + members.len(), + expected.len(), + "the frame releases exactly one combined APNAP batch: {members:?}" + ); + for id in expected { + assert_eq!( + members.iter().filter(|member| *member == id).count(), + 1, + "{id:?} is a member exactly once: {members:?}" + ); + } + runner + .act(GameAction::OrderTriggers { + order: (0..members.len()).collect(), + }) + .expect("the released group orders"); + for _ in 0..(members.len() + 1) { + runner.act(GameAction::PassPriority).expect("P0 passes"); + runner.act(GameAction::PassPriority).expect("P1 passes"); + } + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_before + total_gain, + "each distinguishable effect happens exactly once" + ); +} + +/// The plan's synchronous delayed one-shot `TappedForMana` row. +/// +/// A real `Effect::CreateDelayedTrigger` carrying `WhenNextEvent` with an +/// embedded `TriggerMode::TapsForMana` is installed by a resolved spell. N's +/// fixed-colour activation then emits the base tap plus one source-identifiable +/// `TappedForMana`, and the completed mana frame's COMBINED collector must +/// return the delayed context in the SAME APNAP batch as N's reflexive and the +/// ordinary observer O — one group of three, each effect once, for +11. +/// +/// The one-shot half is measured twice: the instance is removed from +/// `state.delayed_triggers` exactly once, and a second identical tap by N2 +/// afterwards produces no further firing at all. +#[test] +fn synchronous_delayed_one_shot_taps_for_mana_joins_the_frame_batch_once() { + let DelayedFrameFixture { + scenario, + source_n, + observer_o, + installer, + source_n2, + } = delayed_frame_fixture(DelayedTriggerCondition::WhenNextEvent { + trigger: Box::new(delayed_taps_for_mana_matcher()), + or_trigger: None, + lifetime: DelayedTriggerLifetime::default(), + }); + let mut runner = scenario.build(); + let life_before = resolve_delayed_installer(&mut runner, installer); + + // Positive reach guard on the INSTALLED shape, before anything fires. + let installed = &runner.state().delayed_triggers[0]; assert!( - json.contains("Suspended"), - "the serialized parent frame must retain its typed re-entry ownership" + matches!( + installed.condition, + DelayedTriggerCondition::WhenNextEvent { ref trigger, .. } + if trigger.mode == TriggerMode::TapsForMana + ), + "the installed condition is the real embedded TapsForMana one-shot: {:?}", + installed.condition + ); + assert!(installed.one_shot, "CR 603.7: it is a one-shot"); + assert_eq!( + installed.source_id, installer, + "install origin is preserved" ); - let restored: GameState = - serde_json::from_str(&json).expect("the suspended parent mana cursor deserializes"); - let mut runner = GameRunner::from_state(restored); - let resumed = runner - .act(GameAction::ChooseReplacement { index: 0 }) - .expect("redirect inner self-exile and resume the exact parent cursor"); - assert_eq!(runner.state().objects[&inner].zone, Zone::Graveyard); - assert!(runner.state().objects[&outer].tapped); - assert!(matches!( - resumed.waiting_for, - WaitingFor::ManaPayment { player: P0, .. } - )); + let acted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); assert_eq!( - initial_events + acted + .events .iter() - .chain(resumed.events.iter()) - .filter(|event| matches!(event, GameEvent::PermanentTapped { object_id, .. } if *object_id == outer)) + .filter(|event| matches!( + event, + GameEvent::TappedForMana { source_id, .. } if *source_id == source_n + )) .count(), 1, - "the outer tap prefix is retained by the parent cursor rather than replayed" + "exactly one source-identifiable TappedForMana reach event" + ); + assert!( + runner.state().delayed_triggers.is_empty(), + "the matched one-shot instance is removed exactly once: {:?}", + runner.state().delayed_triggers + ); + resolve_group_of( + &mut runner, + &acted.waiting_for, + &[source_n, observer_o, installer], + life_before, + 5 + 2 + 4, + ); + + // The generic delayed pass creates none: a second identical tap after the + // one-shot was consumed produces no further delayed firing. + let life_after_group = runner.state().players[P0.0 as usize].life; + let second = runner + .act(GameAction::ActivateAbility { + source_id: source_n2, + ability_index: 0, + }) + .expect("N2 taps for mana with no delayed source installed"); + assert!( + !matches!(second.waiting_for, WaitingFor::OrderTriggers { .. }), + "no trigger at all may be released by the second tap, got {:?}", + second.waiting_for ); assert_eq!( - initial_events - .iter() - .chain(resumed.events.iter()) - .filter(|event| matches!(event, GameEvent::PermanentTapped { object_id, .. } if *object_id == inner)) - .count(), + runner.state().players[P0.0 as usize].life, + life_after_group, + "the consumed one-shot cannot fire a second time" + ); +} + +/// The plan's duration-bearing `WheneverEvent` persistence row, deliberately +/// separate from the one-shot control above. +/// +/// The same combined collector must place the delayed context in the frame's one +/// APNAP batch, but the duration-bearing source stays INSTALLED afterwards, and +/// a later matching event proves persistence by firing exactly one more time. +#[test] +fn duration_bearing_delayed_whenever_event_stays_installed_and_fires_again() { + let DelayedFrameFixture { + scenario, + source_n, + observer_o, + installer, + source_n2, + } = delayed_frame_fixture(DelayedTriggerCondition::WheneverEvent { + trigger: Box::new(delayed_taps_for_mana_matcher()), + expiry: WheneverEventExpiry::default(), + }); + let mut runner = scenario.build(); + let life_before = resolve_delayed_installer(&mut runner, installer); + assert!( + !runner.state().delayed_triggers[0].one_shot, + "CR 603.7c: a duration-bearing WheneverEvent is not a one-shot" + ); + + let acted = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + assert_eq!( + runner.state().delayed_triggers.len(), 1, - "the inner source's tap cost is paid once across the replacement pause" + "the duration-bearing source remains installed while its context fires" + ); + resolve_group_of( + &mut runner, + &acted.waiting_for, + &[source_n, observer_o, installer], + life_before, + 5 + 2 + 4, + ); + + // Persistence: a later matching event fires it exactly one additional time. + // N2 carries no rider and no observer, so the delayed context is the batch's + // only member and CR 603.3b needs no ordering prompt for it. + let life_after_group = runner.state().players[P0.0 as usize].life; + let second = runner + .act(GameAction::ActivateAbility { + source_id: source_n2, + ability_index: 0, + }) + .expect("N2 taps for mana while the duration-bearing source is still installed"); + assert!( + !matches!(second.waiting_for, WaitingFor::OrderTriggers { .. }), + "a one-member batch needs no ordering prompt, got {:?}", + second.waiting_for ); assert_eq!( - initial_events + runner + .state() + .stack .iter() - .chain(resumed.events.iter()) - .filter(|event| matches!( - event, - GameEvent::ZoneChanged { - object_id, - from: Some(Zone::Battlefield), - to: Zone::Graveyard, - .. - } if *object_id == inner - )) + .filter( + |entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. }) + && entry.controller == P0 + ) .count(), 1, - "the redirected inner self-exile cost is delivered once" + "the persistent delayed source fired exactly one additional time" ); - for source_id in [inner, outer] { - assert_eq!( - initial_events - .iter() - .chain(resumed.events.iter()) - .filter(|event| matches!(event, GameEvent::ManaAdded { source_id: id, .. } if *id == source_id)) - .count(), - 1, - "each nested mana ability produces exactly once" - ); - } - + runner.act(GameAction::PassPriority).expect("P0 passes"); runner .act(GameAction::PassPriority) - .expect("the outer spell payment consumes the outer mana once"); - assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); + .expect("P1 passes and the second firing resolves"); + assert_eq!( + runner.state().players[P0.0 as usize].life, + life_after_group + 4, + "and its distinguishable effect happens exactly once more" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "and it is still installed after firing again" + ); } fn two_source_assist_replacement_witness( @@ -11202,3 +14193,296 @@ fn commander_hand_return_keeps_its_may_choice_inside_cr_616_ordering() { "the still-applicable Hand-to-Command redirect resolves after the decline" ); } + +// --------------------------------------------------------------------------- +// The accepted-pause families: `optional` (`OptionalEffectChoice`) and +// `optional_for` (`OpponentMayChoice`). +// +// These are the first shapes the complete classifier accepts that do NOT +// complete synchronously. The occurrence pauses mid-body inside the completed +// mana frame, its continuation carrier holds the frame's own resume root, and +// the answering action's readiness hook in `engine_payment_choices` partitions +// the stored emission batches, runs the accepted tail, and resumes that exact +// mana frame once. +// +// The widening owns a real behaviour delta: baseline `is_triggered_mana_ability` +// returns true for an all-mana `optional` body, so baseline resolved it inline +// and left `waiting_for` set with no continuation at all. +// --------------------------------------------------------------------------- + +/// M's accepted body, made optional: still one colorless mana, still targetless +/// and nonmodal, so `build_target_slots` stays empty and the baseline +/// acceptance gate still holds — the ONLY difference from +/// `mana_added_bonus_mana_body` is the "you may". +fn optional_mana_added_bonus_observer(scenario: &mut GameScenario, label: &str) -> ObjectId { + scenario + .add_creature(P0, label, 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::ManaAdded) + .execute( + AbilityDefinition::new(AbilityKind::Database, mana_added_bonus_mana_body()) + .optional(), + ) + .constraint(TriggerConstraint::OncePerTurn) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id() +} + +/// The pause itself, asserted before it is answered: N's activation returns +/// `OptionalEffectChoice`, not a wait belonging to the payment, and NOTHING of +/// the frame has been released — M owns no pip, no queue slot and no stack +/// entry, while the frame's two ordinary observers are already queued +/// undispatched behind it. +fn assert_optional_pause_is_open_and_nothing_released( + runner: &GameRunner, + bonus_m: ObjectId, + source_n: ObjectId, + observer_o: ObjectId, + waiting_for: &WaitingFor, + life_before: i32, +) { + assert!( + matches!(waiting_for, WaitingFor::OptionalEffectChoice { .. }), + "the accepted body's own pause is the action's wait, got {waiting_for:?}" + ); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 0, + "an accepted occurrence produces nothing while its own decision is open" + ); + assert_eq!( + pips_produced_by(runner.state(), source_n), + 1, + "N's base production already happened — the frame really is mid-settlement" + ); + assert!( + !runner + .state() + .deferred_triggers + .iter() + .any(|context| context.pending.source_id == bonus_m), + "a paused accepted occurrence is never queued on the ordinary authority" + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); +} + +/// Accept: the resumed body's mana reaches the pool inside the answering +/// action, and readiness then resumes the suspended mana frame exactly once — +/// the frame's own release group is still exactly N's reflexive plus O. +#[test] +fn accepted_optional_mana_body_pauses_the_frame_and_resumes_it_on_accept() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = optional_mana_added_bonus_observer(&mut scenario, "M Optional Mana-Added Bonus"); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + assert_optional_pause_is_open_and_nothing_released( + &runner, + bonus_m, + source_n, + observer_o, + &paused.waiting_for, + life_before, + ); + + let resumed = runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P0 accepts the accepted occurrence's own optional body"); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 1, + "the resumed accepted body produces exactly once (CR 605.4a)" + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 1, + "and the bonus is spendable from the answering action onward" + ); + assert!( + !runner + .state() + .deferred_triggers + .iter() + .any(|context| context.pending.source_id == bonus_m), + "resumption never demotes the occurrence to the ordinary queue" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &resumed.waiting_for, + source_n, + observer_o, + life_before, + ); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!(&entry.kind, StackEntryKind::TriggeredAbility { .. })) + .count(), + 0, + "M never occupied the stack across the pause" + ); +} + +/// Decline: the same resumption runs, the same single group is released, and +/// the ONLY difference is that M produced nothing. This is the pure-axis +/// control for the accept row — it proves the frame's resumption is owned by +/// readiness rather than by the body having produced mana. +#[test] +fn accepted_optional_mana_body_declined_still_resumes_the_frame_once() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = optional_mana_added_bonus_observer(&mut scenario, "M Optional Mana-Added Bonus"); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + assert_optional_pause_is_open_and_nothing_released( + &runner, + bonus_m, + source_n, + observer_o, + &paused.waiting_for, + life_before, + ); + + let resumed = runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("P0 declines the accepted occurrence's own optional body"); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 0, + "a declined body produces nothing" + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 0, + "and nothing colorless reaches the pool by any other route" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &resumed.waiting_for, + source_n, + observer_o, + life_before, + ); +} + +/// The same accepted body under `optional_for` (`OpponentMayChoice`): the "you +/// may" is routed to the opponent, so the pause belongs to a NON-ACTIVATOR while +/// the mana frame it suspends belongs to P0. +/// +/// This is the second accepted-pause family and the second readiness hook in +/// `engine_payment_choices`. Its reducer arm returns the `ActionResult` +/// directly, without the ordinary post-action pipeline — see the assertion on +/// the resumed wait, which records exactly where full settled-Priority +/// convergence is still owed. +fn opponent_may_mana_added_bonus_observer(scenario: &mut GameScenario, label: &str) -> ObjectId { + let mut body = + AbilityDefinition::new(AbilityKind::Database, mana_added_bonus_mana_body()).optional(); + body.optional_for = Some(OpponentMayScope::AnyOpponent); + scenario + .add_creature(P0, label, 0, 0) + .as_enchantment() + .with_trigger_definition( + TriggerDefinition::new(TriggerMode::ManaAdded) + .execute(body) + .constraint(TriggerConstraint::OncePerTurn) + .trigger_zones(vec![Zone::Battlefield]), + ) + .id() +} + +#[test] +fn accepted_opponent_may_mana_body_pauses_on_the_nonactivator_and_resumes_the_frame() { + let NoPauseFixture { + mut scenario, + source_n, + observer_o, + } = no_pause_mana_fixture(NoPauseColorAxis::Fixed); + let bonus_m = + opponent_may_mana_added_bonus_observer(&mut scenario, "M Opponent-May Mana-Added Bonus"); + let mut runner = scenario.build(); + let life_before = runner.state().players[P0.0 as usize].life; + + let paused = runner + .act(GameAction::ActivateAbility { + source_id: source_n, + ability_index: 0, + }) + .expect("N is activated directly from Priority"); + let WaitingFor::OpponentMayChoice { player, .. } = paused.waiting_for else { + panic!( + "the accepted body's opponent-may pause is the action's wait, got {:?}", + paused.waiting_for + ); + }; + assert_eq!( + player, P1, + "CR 608.2d: the decision belongs to the opponent, not to the frame's activator" + ); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 0, + "nothing is produced while the opponent's decision is open" + ); + assert_exactly_two_contexts_queued_and_unstacked( + runner.state(), + source_n, + observer_o, + life_before, + ); + + let resumed = runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P1 accepts"); + assert_eq!( + pips_produced_by(runner.state(), bonus_m), + 1, + "the resumed accepted body produces exactly once, into its own controller's pool" + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Colorless), + 1, + "CR 605.4a: the bonus belongs to M's controller, not to the accepting opponent" + ); + assert_single_group_of_two_then_resolve_for_seven( + &mut runner, + &resumed.waiting_for, + source_n, + observer_o, + life_before, + ); +} diff --git a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs index 9d238b4861..d534525982 100644 --- a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs +++ b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs @@ -174,6 +174,51 @@ fn assert_current_action_consumed_occurrences_were_journaled( ); } +/// A completed mana frame claims exactly the live occurrences it settled, and +/// its ordinary observers are *deferred* rather than dispatched from inside the +/// payment. The owner's own boundary later drains that release group into the +/// same public event vector, so the action's final event list is strictly longer +/// than the claim. Comparing the journal against the whole final list — which +/// the pre-deferral contract did — is therefore no longer meaningful; the exact +/// contract is that the claim is the settlement's own full-buffer prefix. +/// +/// Returns the claimed prefix length so the caller can assert the release +/// actually happened after it. +fn assert_mana_frame_claimed_exactly_its_own_event_prefix( + state: &GameState, + events: &[GameEvent], +) -> usize { + let expected = current_action_occurrences(events); + let claims: Vec> = state + .resolved_rules_journal + .entries() + .iter() + .filter_map(|entry| match entry.command.as_ref() { + Some(ResolvedRulesCommand::TriggerCollection(ResolvedTriggerCollectionCommand { + collection: ResolvedTriggerCollection::ConsumeBeforePriority { occurrences }, + .. + })) => Some(occurrences.clone()), + _ => None, + }) + .collect(); + assert_eq!( + claims.len(), + 1, + "one completed mana frame claims its live occurrences exactly once" + ); + let claimed = &claims[0]; + assert!( + !claimed.is_empty() && claimed.len() <= expected.len(), + "the claim must be a non-empty prefix of the action's events" + ); + assert_eq!( + claimed.as_slice(), + &expected[..claimed.len()], + "the settlement must journal its own event prefix by exact full-buffer occurrence identity" + ); + claimed.len() +} + #[test] fn trigger_collection_command_variants_round_trip_inside_the_journal_envelope() { for command in [ @@ -381,7 +426,8 @@ fn paused_mana_cost_settlement_journals_consumed_occurrences_without_recollectin event, GameEvent::ZoneChanged { object_id, .. } if *object_id == source ))); - assert_current_action_consumed_occurrences_were_journaled(runner.state(), &resumed.events); + let claimed = + assert_mana_frame_claimed_exactly_its_own_event_prefix(runner.state(), &resumed.events); assert_eq!( runner .state() @@ -395,6 +441,19 @@ fn paused_mana_cost_settlement_journals_consumed_occurrences_without_recollectin 1, "the settled mana-cost zone change must not be collected again at priority" ); + // One release group, formed at the owner's boundary rather than inside the + // payment: the observer was deferred at the claim and announced afterwards, + // so the action's public event vector grew past the claimed prefix. + assert!( + claimed < resumed.events.len(), + "the deferred release group must append its announcement after the settlement's claim \ + (claimed {claimed} of {} events)", + resumed.events.len() + ); + assert!( + runner.state().deferred_triggers.is_empty(), + "the owner's boundary must release the whole deferred group exactly once" + ); } #[test] diff --git a/crates/engine/tests/integration/reflexive_body_token_referent.rs b/crates/engine/tests/integration/reflexive_body_token_referent.rs index 099d3216c9..95b77f1b4e 100644 --- a/crates/engine/tests/integration/reflexive_body_token_referent.rs +++ b/crates/engine/tests/integration/reflexive_body_token_referent.rs @@ -265,6 +265,17 @@ fn decide(runner: &mut GameRunner, accept: bool) -> bool { .is_ok() } +/// CR 603.12 + CR 603.3b: a `When you do` body is a CREATED reflexive TRIGGERED +/// ABILITY. Accepting the gate creates it; it does nothing until it is put on +/// the stack at the next priority point and resolves there. Every row that +/// measures the BODY's board effect must therefore drive that window, exactly as +/// an `If you do` row does not (an inline CR 608.2c continuation has already run +/// when the deciding action returns). Answering the ordering prompt with +/// identity is handled inside `advance_until_stack_empty`. +fn settle_reflexive_body(runner: &mut GameRunner) { + runner.advance_until_stack_empty(); +} + /// Answer a sub-clause's `TriggerTargetSelection` prompt and let the resulting /// stack object resolve. Used where the targeted clause is NOT the chain's head /// (North Pole's `SetTapState`, Ratonhnhaké꞉ton's `ChangeZone`), so its target @@ -333,6 +344,7 @@ fn iroh_reflexive_body_counter_binds_created_token() { vec![TargetRef::Object(bear), TargetRef::Player(P1)], ); assert!(decide(&mut runner, true), "the reflexive gate was accepted"); + settle_reflexive_body(&mut runner); let ally = token_named(&runner, decoy, "Ally"); assert_eq!( @@ -1419,6 +1431,7 @@ fn drive_clone(text: &str, types: &[&str], funded: bool) -> CloneRow { let def = parse_trigger(text, "Synth", types, 0); resolve_def(&mut runner, &def, source, vec![]); decide(&mut runner, true); + settle_reflexive_body(&mut runner); let live = live_tokens(&runner, decoy); CloneRow { diff --git a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs index d5355c4176..08d8248fe6 100644 --- a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs +++ b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs @@ -193,6 +193,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility forward_result: false, unless_pay: None, distribution: None, + distribute: None, target_selection_mode: TargetSelectionMode::Chosen, chosen_players: Vec::new(), repeat_until: None,