diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 9ce94ea539..1d8e257cdf 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1912,11 +1912,24 @@ fn park_cost_payment_triggers_if_paused( return; } - let cost_events: Vec = events[cost_event_start..cost_event_end] - .iter() + // CR 603.2c + CR 603.3b: `finish_pending_cost_or_cast`'s announcement drain + // can already have collected this span, claiming its occurrences in + // `consumed_before_priority_trigger_events`. Route the span through the + // already-collected authority so those exact occurrences are not parked a + // second time, rather than re-collecting the span wholesale. + let cost_events: Vec = + crate::game::triggers::filter_already_collected_trigger_events_from( + state, + &events[..cost_event_end], + cost_event_start, + &state.consumed_before_priority_trigger_events, + ) + .into_iter() .filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. })) - .cloned() .collect(); + if cost_events.is_empty() { + return; + } if let Some(mut collection) = state.take_pending_activation_trigger_collection() { // CR 602.2b + CR 603.3b: A target-first activation owns cost-trigger // collection until its stack entry exists, even when a later payment @@ -2610,10 +2623,18 @@ fn park_deferred_cost_triggers_if_paused( let Some((start, end)) = cost_event_range else { return; }; - let cost_events: Vec = events[start..end] - .iter() + // CR 603.2c + CR 603.3b: same authority as `park_cost_payment_triggers_if_paused` + // — a deferred sacrifice span whose occurrences an earlier collector already + // claimed must not be parked again. + let cost_events: Vec = + crate::game::triggers::filter_already_collected_trigger_events_from( + state, + &events[..end], + start, + &state.consumed_before_priority_trigger_events, + ) + .into_iter() .filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. })) - .cloned() .collect(); crate::game::triggers::collect_triggers_into_deferred(state, &cost_events); } @@ -2693,40 +2714,74 @@ fn pause_sacrifice_for_cost( fn settle_sacrifice_for_cost_events( state: &mut GameState, pending: &mut PendingCast, - mut deferred_cost_events: Vec, + deferred_cost_events: Vec, events: &[GameEvent], current_start: usize, current_end: usize, ) { if let Some(collection) = pending.activation_trigger_collection.as_mut() { + // Earlier action fragments carry no ordinal in THIS buffer, so the + // consumed journal — whose ordinals are absolute within the current + // action — must not be applied to them. The queued-context witness is + // occurrence-exact independently of any buffer; `turn_zone_change_index` + // separates distinct occurrences within a turn. + let unclaimed_cost_events = + crate::game::triggers::filter_already_collected_trigger_events_from( + state, + &deferred_cost_events, + 0, + &[], + ); // CR 602.2b + CR 603.2: an announced target-bearing activation owns // replacement-paused cost events until its stack commit. Earlier action // fragments are not present in this action's event buffer, while the // current fragment is collected once by the eventual stack boundary (or // the next pending-action staging pass). - if !deferred_cost_events.is_empty() { - collection.collect(state, &deferred_cost_events); + if !unclaimed_cost_events.is_empty() { + collection.collect(state, &unclaimed_cost_events); } return; } - deferred_cost_events.extend_from_slice(&events[current_start..current_end]); + // Two occurrence bases, filtered separately and never rebased into each + // other: the carried fragments against the buffer-independent queued-context + // witness, the current fragment against this action's buffer with its + // absolute `current_start` offset — the basis `filter_consumed_trigger_events_from` + // requires, and the same one the journal below records. + let carried_cost_events = crate::game::triggers::filter_already_collected_trigger_events_from( + state, + &deferred_cost_events, + 0, + &[], + ); + let current_cost_events = crate::game::triggers::filter_already_collected_trigger_events_from( + state, + &events[..current_end], + current_start, + &state.consumed_before_priority_trigger_events, + ); + let deferred_cost_events: Vec = carried_cost_events + .into_iter() + .chain(current_cost_events) + .collect(); if !deferred_cost_events.is_empty() { crate::game::triggers::collect_triggers_into_deferred(state, &deferred_cost_events); } + // The journal claims the whole current fragment, not just what survived the + // filter: an occurrence the filter dropped is one an earlier collector + // already took, so the Priority pipeline must not reach it either. let occurrences = events[current_start..current_end] .iter() .enumerate() - .map(|(offset, event)| { - let index = current_start + offset; - crate::game::triggers::ConsumedTriggerEventOccurrence { + .map( + |(offset, event)| crate::game::triggers::ConsumedTriggerEventOccurrence { event: event.clone(), - occurrence: events[..index] - .iter() - .filter(|prior| *prior == event) - .count(), - } - }) + occurrence: crate::game::triggers::trigger_event_occurrence( + events, + current_start + offset, + ), + }, + ) .collect(); crate::game::triggers::resolve_and_apply_trigger_collection( state, diff --git a/crates/engine/tests/fixtures/integration_cards.json.gz b/crates/engine/tests/fixtures/integration_cards.json.gz index 15fa288428..d26eeac8cf 100644 Binary files a/crates/engine/tests/fixtures/integration_cards.json.gz and b/crates/engine/tests/fixtures/integration_cards.json.gz differ diff --git a/crates/engine/tests/integration/issue_worldspine_wurm_duplicate_triggers.rs b/crates/engine/tests/integration/issue_worldspine_wurm_duplicate_triggers.rs new file mode 100644 index 0000000000..f11c0f541e --- /dev/null +++ b/crates/engine/tests/integration/issue_worldspine_wurm_duplicate_triggers.rs @@ -0,0 +1,157 @@ +//! Regression coverage for duplicate Worldspine Wurm triggers during a +//! Recurring Nightmare activation. + +use engine::database::card_db::CardDatabase; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{PayCostKind, StackEntryKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use crate::support::shared_card_db; + +fn card_db() -> &'static CardDatabase { + shared_card_db().expect("integration card fixture must load") +} + +/// Sacrifice Worldspine Wurm to a Recurring Nightmare activation, optionally +/// under extra battlefield observers, and drive the activation to priority. +fn activate_recurring_nightmare(observers: &[&str]) -> (GameRunner, ObjectId, Vec) { + let db = card_db(); + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(9_998), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]), + ManaUnit::new(ManaType::Black, ObjectId(10_000), false, vec![]), + ], + ); + + let wurm = scenario.add_real_card(P0, "Worldspine Wurm", Zone::Battlefield, db); + let nightmare = scenario.add_real_card(P0, "Recurring Nightmare", Zone::Battlefield, db); + let graveyard_creature = scenario.add_real_card(P0, "Grizzly Bears", Zone::Graveyard, db); + let _other_graveyard_creature = + scenario.add_real_card(P0, "Elvish Mystic", Zone::Graveyard, db); + let observer_ids: Vec = observers + .iter() + .map(|name| scenario.add_real_card(P0, name, Zone::Battlefield, db)) + .collect(); + let mut runner = scenario.build(); + let ability_index = runner.state().objects[&nightmare] + .abilities + .iter() + .position(|ability| matches!(ability.kind, engine::types::ability::AbilityKind::Activated)) + .expect("Recurring Nightmare must have an activated ability"); + + runner + .act(GameAction::ActivateAbility { + source_id: nightmare, + ability_index, + }) + .expect("begin Recurring Nightmare activation"); + + let mut saw_sacrifice = false; + let mut saw_target = false; + for _ in 0..32 { + match runner.state().waiting_for.clone() { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(graveyard_creature)], + }) + .expect("select Recurring Nightmare target"); + saw_target = true; + } + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + .. + } => { + runner + .act(GameAction::SelectCards { cards: vec![wurm] }) + .expect("sacrifice Worldspine Wurm"); + saw_sacrifice = true; + } + WaitingFor::ManaPayment { .. } => { + runner + .act(GameAction::PassPriority) + .expect("pay Recurring Nightmare's mana cost"); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::Priority { .. } => break, + other => panic!("unexpected waiting state during activation: {other:?}"), + } + } + + assert!(saw_sacrifice, "activation must sacrifice Worldspine Wurm"); + assert!(saw_target, "activation must choose a graveyard creature"); + (runner, wurm, observer_ids) +} + +fn trigger_descriptions(runner: &GameRunner, source_id: ObjectId) -> Vec { + runner + .state() + .stack + .iter() + .filter(|entry| entry.source_id == source_id) + .filter_map(|entry| match &entry.kind { + StackEntryKind::TriggeredAbility { description, .. } => description.clone(), + _ => None, + }) + .collect() +} + +#[test] +fn worldspine_wurm_sacrifice_creates_each_trigger_once() { + let (runner, wurm, _) = activate_recurring_nightmare(&[]); + + // Without the cost-event ownership filter, the sacrifice event is parked a + // second time while this ordering prompt is being returned, producing four + // Wurm trigger entries instead of the two below. + assert_eq!( + trigger_descriptions(&runner, wurm), + vec![ + "When ~ dies, create three 5/5 green Wurm creature tokens with trample.".to_string(), + "When ~ is put into a graveyard from anywhere, shuffle it into its owner's library." + .to_string(), + ], + "a single Battlefield-to-Graveyard move must create one of each Wurm trigger", + ); +} + +/// CR 603.2c: the same parked cost span carries three distinct occurrences — the +/// Wurm's death, its sacrifice, and Recurring Nightmare's own return to hand — +/// and `finish_pending_cost_or_cast`'s announcement drain has already claimed +/// all three in `consumed_before_priority_trigger_events` by the time the span +/// is parked. This pins that the parking helper suppresses exactly the claimed +/// occurrences and nothing else, so every observer of the span still reaches the +/// stack once. Without the filter the Wurm triggers double. +#[test] +fn paused_cost_resume_keeps_every_occurrence_in_the_span_exactly_once() { + let (runner, wurm, observers) = + activate_recurring_nightmare(&["Korvold, Fae-Cursed King", "Justice, Vance Astrovik"]); + let (korvold, justice) = (observers[0], observers[1]); + + assert_eq!( + trigger_descriptions(&runner, wurm).len(), + 2, + "the owned sacrifice occurrence must still produce exactly one of each Wurm trigger", + ); + assert_eq!( + trigger_descriptions(&runner, korvold).len(), + 1, + "the sacrifice occurrence in the same span must trigger its other observer once", + ); + assert_eq!( + trigger_descriptions(&runner, justice).len(), + 1, + "the return-to-hand occurrence in the same span must still trigger once", + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e1744a5dc5..09653c6e06 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -771,6 +771,7 @@ mod issue_bound_by_moonsilver_sacrifice_attach; mod issue_circle_of_protection_source_choice; mod issue_desperate_gambit_choose_damage_source; mod issue_haze_frog_other_creature_prevention; +mod issue_worldspine_wurm_duplicate_triggers; mod ivory_gargoyle_temporal_and_skip_tail; mod jace_wielder_empty_library_win; mod jagged_lightning_each_of_two_targets;