diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 71a33bde4b..744828707f 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -2407,10 +2407,8 @@ pub(crate) fn deliver_replaced_zone_change( let (recorded, source_id) = { let frame = state .resolution_stack - .active_discard_parent_of_active_ability_continuation_mut(frame_id) - .expect( - "discard provenance must name the active continuation's discard parent", - ); + .active_discard_or_direct_continuation_parent_mut(frame_id) + .expect("discard provenance must name the active discard operation"); let recorded = frame.results.is_empty(); let source_id = frame.source_id; if recorded { diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index fe7a76f1e5..35282e4c92 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -656,6 +656,34 @@ impl ResolutionStack { } } + /// Returns the active discard operation, or its exact direct parent when + /// an ability continuation has already been parked above it. Terminal + /// discard delivery owns either of these two adjacent shapes; it must not + /// search through another active resolution frame for a matching ID. + pub fn active_discard_or_direct_continuation_parent_mut( + &mut self, + discard_id: DiscardFrameId, + ) -> Option<&mut DiscardFrame> { + let active_index = self.frames.len().checked_sub(1)?; + let discard_index = match self.frames.get(active_index) { + Some(ResolutionFrame::Discard(discard)) if discard.id == discard_id => active_index, + Some(ResolutionFrame::AbilityContinuation(_)) => { + let discard_index = active_index.checked_sub(1)?; + match self.frames.get(discard_index) { + Some(ResolutionFrame::Discard(discard)) if discard.id == discard_id => { + discard_index + } + Some(_) | None => return None, + } + } + Some(_) | None => return None, + }; + match self.frames.get_mut(discard_index) { + Some(ResolutionFrame::Discard(discard)) => Some(discard), + Some(_) | None => unreachable!("checked discard frame must retain its frame kind"), + } + } + /// Identifies the exact discard parent of the active continuation without /// exposing any non-adjacent frame. pub fn active_ability_continuation_discard_parent_id(&self) -> Option { @@ -3960,6 +3988,15 @@ mod tests { #[test] fn active_discard_parent_of_active_ability_continuation_is_direct_and_id_bound() { + let mut active = ResolutionStack::default(); + let active_id = active.begin_discard(None); + assert!( + active + .active_discard_or_direct_continuation_parent_mut(active_id) + .is_some(), + "an active discard owns terminal delivery before a continuation is parked" + ); + let mut direct = ResolutionStack::default(); let direct_id = direct.begin_discard(None); direct.push_inner(continuation_frame(1)); @@ -4003,6 +4040,12 @@ mod tests { .is_none(), "a discard below an active child must not be recovered by a stack search" ); + assert!( + buried + .active_discard_or_direct_continuation_parent_mut(buried_id) + .is_none(), + "terminal delivery may not recover a discard below another active frame" + ); } fn change_zone_frame(group_seed: u64) -> ResolutionFrame { diff --git a/crates/engine/tests/integration/fixtures/issue_7212_recruit_with_sibling_trigger.json.gz b/crates/engine/tests/integration/fixtures/issue_7212_recruit_with_sibling_trigger.json.gz new file mode 100644 index 0000000000..2b16da6bab Binary files /dev/null and b/crates/engine/tests/integration/fixtures/issue_7212_recruit_with_sibling_trigger.json.gz differ diff --git a/crates/engine/tests/integration/issue_7212_recruit_sibling_trigger.rs b/crates/engine/tests/integration/issue_7212_recruit_sibling_trigger.rs new file mode 100644 index 0000000000..84c2aac841 --- /dev/null +++ b/crates/engine/tests/integration/issue_7212_recruit_sibling_trigger.rs @@ -0,0 +1,82 @@ +//! GitHub issue #7212 — Recruit must retain its discard provenance while an +//! earlier ETB trigger from the same permanent remains on the stack. + +use std::io::Read; + +use engine::game::scenario::{GameRunner, P0}; +use engine::types::actions::GameAction; +use engine::types::game_state::{GameState, PersistedGameState, WaitingFor}; +use engine::types::zones::Zone; + +fn load_state() -> GameState { + let mut json = String::new(); + flate2::read::GzDecoder::new( + &include_bytes!("fixtures/issue_7212_recruit_with_sibling_trigger.json.gz")[..], + ) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("game-state envelope parses as JSON"); + serde_json::from_value::(envelope["gameState"].clone()) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +fn token_count(state: &GameState) -> usize { + state + .objects + .values() + .filter(|object| object.zone == Zone::Battlefield && object.is_token) + .count() +} + +fn graveyard_count(state: &GameState, player: engine::types::player::PlayerId) -> usize { + state + .objects + .values() + .filter(|object| object.zone == Zone::Graveyard && object.owner == player) + .count() +} + +fn pass_priority_until_combat(runner: &mut GameRunner) { + for _ in 0..8 { + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + return; + } + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "expected priority while resolving the two ETB triggers, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::PassPriority) + .expect("priority pass must advance the stacked ETB triggers"); + } + panic!( + "the stacked ETB triggers never settled; final state: {:?}", + runner.state().waiting_for + ); +} + +#[test] +fn recruit_discard_with_a_sibling_etb_trigger_records_its_own_result() { + let mut runner = GameRunner::from_state(load_state()); + let tokens_before = token_count(runner.state()); + let graveyard_before = graveyard_count(runner.state(), P0); + + pass_priority_until_combat(&mut runner); + + assert_eq!( + graveyard_count(runner.state(), P0), + graveyard_before + 1, + "Recruit's drawn card is automatically discarded from its controller's one-card hand" + ); + assert_eq!( + token_count(runner.state()), + tokens_before + 1, + "Recruit creates its contingent token after discarding a nonland" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1b91f279a9..9e49acac1b 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -692,6 +692,7 @@ mod issue_7063_library_reorder; mod issue_7087_recruit_discard_provenance; mod issue_709_regression; mod issue_718_dina_sacrifice_draw; +mod issue_7212_recruit_sibling_trigger; mod issue_7232_expend_auto_land_payment; mod issue_735_amalia_power_threshold; mod issue_735_cost_paid_object_non_regression;