Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 74 additions & 19 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1912,11 +1912,24 @@ fn park_cost_payment_triggers_if_paused(
return;
}

let cost_events: Vec<GameEvent> = 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<GameEvent> =
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
Expand Down Expand Up @@ -2610,10 +2623,18 @@ fn park_deferred_cost_triggers_if_paused(
let Some((start, end)) = cost_event_range else {
return;
};
let cost_events: Vec<GameEvent> = 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<GameEvent> =
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);
}
Expand Down Expand Up @@ -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<GameEvent>,
deferred_cost_events: Vec<GameEvent>,
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<GameEvent> = 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,
Expand Down
Binary file modified crates/engine/tests/fixtures/integration_cards.json.gz
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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<ObjectId>) {
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<ObjectId> = 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<String> {
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",
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading