diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 8043b36e17..88e578cc00 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -2407,9 +2407,10 @@ pub(crate) fn deliver_replaced_zone_change( let (recorded, source_id) = { let frame = state .resolution_stack - .active_discard_mut() - .filter(|frame| frame.id == frame_id) - .expect("discard provenance must name the active discard frame"); + .active_discard_parent_of_active_ability_continuation_mut(frame_id) + .expect( + "discard provenance must name the active continuation's discard parent", + ); 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 0cbe4effb6..6b8207f9ec 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -618,6 +618,32 @@ impl ResolutionStack { } } + /// Returns the exact active continuation's discard parent when that parent + /// immediately precedes it. This is deliberately positional rather than a + /// stack search: terminal zone delivery may record provenance only for the + /// discard operation that owns the active Recruit continuation. + pub fn active_discard_parent_of_active_ability_continuation_mut( + &mut self, + discard_id: DiscardFrameId, + ) -> Option<&mut DiscardFrame> { + let continuation_index = self.frames.len().checked_sub(1)?; + let discard_index = continuation_index.checked_sub(1)?; + match ( + self.frames.get(discard_index), + self.frames.get(continuation_index), + ) { + ( + Some(ResolutionFrame::Discard(discard)), + Some(ResolutionFrame::AbilityContinuation(_)), + ) if discard.id == discard_id => {} + _ => return None, + } + match self.frames.get_mut(discard_index) { + Some(ResolutionFrame::Discard(discard)) => Some(discard), + Some(_) | None => unreachable!("checked direct discard 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 { @@ -3865,6 +3891,53 @@ mod tests { }) } + #[test] + fn active_discard_parent_of_active_ability_continuation_is_direct_and_id_bound() { + let mut direct = ResolutionStack::default(); + let direct_id = direct.begin_discard(None); + direct.push_inner(continuation_frame(1)); + direct + .active_discard_parent_of_active_ability_continuation_mut(direct_id) + .expect("the direct discard parent is mutable") + .source_id = Some(ObjectId(1)); + assert_eq!( + match &direct.frames[0] { + ResolutionFrame::Discard(frame) => frame.source_id, + other => panic!("expected discard parent, got {other:?}"), + }, + Some(ObjectId(1)), + "the helper mutates the direct discard parent" + ); + + let mut mismatched = ResolutionStack::default(); + let wrong_id = mismatched.begin_discard(None); + let matching_id = mismatched.begin_discard(None); + mismatched.push_inner(continuation_frame(2)); + assert!( + mismatched + .active_discard_parent_of_active_ability_continuation_mut(wrong_id) + .is_none(), + "a sibling discard ID must not bind to the active continuation" + ); + assert!( + mismatched + .active_discard_parent_of_active_ability_continuation_mut(matching_id) + .is_some(), + "the immediate discard parent remains available by its exact ID" + ); + + let mut buried = ResolutionStack::default(); + let buried_id = buried.begin_discard(None); + buried.push_inner(continuation_frame(3)); + buried.push_inner(change_zone_frame(3)); + assert!( + buried + .active_discard_parent_of_active_ability_continuation_mut(buried_id) + .is_none(), + "a discard below an active child must not be recovered by a stack search" + ); + } + fn change_zone_frame(group_seed: u64) -> ResolutionFrame { let mut state = GameState::new_two_player(group_seed); let mut logical_zone_change_group = state.allocate_logical_zone_change_group(&[]); diff --git a/crates/engine/tests/integration/fixtures/issue_7087_recruit_discard_provenance.json.gz b/crates/engine/tests/integration/fixtures/issue_7087_recruit_discard_provenance.json.gz new file mode 100644 index 0000000000..22c8fdbb07 Binary files /dev/null and b/crates/engine/tests/integration/fixtures/issue_7087_recruit_discard_provenance.json.gz differ diff --git a/crates/engine/tests/integration/issue_7087_recruit_discard_provenance.rs b/crates/engine/tests/integration/issue_7087_recruit_discard_provenance.rs new file mode 100644 index 0000000000..0c05b3f78e --- /dev/null +++ b/crates/engine/tests/integration/issue_7087_recruit_discard_provenance.rs @@ -0,0 +1,176 @@ +//! GitHub issue #7087 — Recruit's contingent token must read the card chosen +//! for its interactive discard from the directly adjacent discard frame. + +use std::io::Read; + +use engine::game::engine::apply; +use engine::game::scenario::{GameRunner, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{GameState, PersistedGameState, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::resolution::ResolutionFrame; +use engine::types::zones::Zone; + +const BIFUR: ObjectId = ObjectId(80); +const PLAINS: ObjectId = ObjectId(45); +const MOUNTAIN_KINGS_RETURN: ObjectId = ObjectId(53); +const DRAWN_CARD: ObjectId = ObjectId(57); + +fn gunzip(gz: &[u8]) -> String { + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + json +} + +fn load_state() -> GameState { + let json = gunzip(include_bytes!( + "fixtures/issue_7087_recruit_discard_provenance.json.gz" + )); + 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 resolve_recruit_to_discard_choice(runner: &mut GameRunner) -> Vec { + apply(runner.state_mut(), P0, GameAction::PassPriority) + .expect("P0 may pass priority on the loaded trigger"); + + let WaitingFor::DiscardChoice { + player, + count, + cards, + .. + } = &runner.state().waiting_for + else { + panic!( + "Recruit must reach P1's interactive discard choice, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(*player, P1, "the Recruit controller chooses the discard"); + assert_eq!(*count, 1, "Recruit discards exactly one card"); + assert_eq!( + runner.state().resolution_stack.len(), + 2, + "Recruit's suspended frames are exactly [Discard, AbilityContinuation]" + ); + assert!( + matches!( + runner.state().resolution_stack.last(), + Some(ResolutionFrame::AbilityContinuation(_)) + ), + "the direct continuation is the stack top" + ); + assert!( + runner + .state() + .resolution_stack + .active_ability_continuation_discard_parent_id() + .is_some(), + "the active continuation has a direct discard parent" + ); + cards.clone() +} + +#[test] +fn recruit_from_the_reported_state_creates_a_token_after_discarding_bifur() { + let mut runner = GameRunner::from_state(load_state()); + let tokens_before = token_count(runner.state()); + + let offered = resolve_recruit_to_discard_choice(&mut runner); + assert!( + offered.contains(&BIFUR), + "Bifur is in P1's hand and must be offered for Recruit's discard" + ); + + runner + .act(GameAction::SelectCards { cards: vec![BIFUR] }) + .expect("discarding the offered nonland Bifur must resume Recruit"); + + assert_eq!( + runner.state().objects[&BIFUR].zone, + Zone::Graveyard, + "the selected nonland reaches P1's graveyard" + ); + assert_eq!( + token_count(runner.state()), + tokens_before + 1, + "Recruit creates its Human Soldier token after discarding a nonland" + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "the resolved Recruit chain returns to a clean priority window" + ); +} + +#[test] +fn recruit_from_the_reported_state_does_not_create_a_token_after_discarding_a_land() { + let mut runner = GameRunner::from_state(load_state()); + let mut setup_events = Vec::new(); + move_to_zone(runner.state_mut(), PLAINS, Zone::Hand, &mut setup_events); + assert_eq!( + runner.state().objects[&PLAINS].zone, + Zone::Hand, + "the real P1 Plains is moved into hand without changing the library top" + ); + + let tokens_before = token_count(runner.state()); + let offered = resolve_recruit_to_discard_choice(&mut runner); + assert_eq!( + runner.state().objects[&DRAWN_CARD].zone, + Zone::Hand, + "P1 draws the original library-top Patient Instructor" + ); + assert!( + offered.contains(&PLAINS), + "the moved P1 Plains is eligible for Recruit's discard choice" + ); + + runner + .act(GameAction::SelectCards { + cards: vec![PLAINS], + }) + .expect("discarding the offered land must resume Recruit"); + + assert_eq!( + runner.state().objects[&PLAINS].zone, + Zone::Graveyard, + "the selected land reaches P1's graveyard" + ); + assert_eq!( + token_count(runner.state()), + tokens_before, + "Recruit does not create a token after discarding a land" + ); + let untouched = &runner.state().objects[&MOUNTAIN_KINGS_RETURN]; + assert_eq!( + untouched.zone, + Zone::Hand, + "the known mistaken land premise must not discard object 53" + ); + assert!( + untouched + .card_types + .core_types + .contains(&CoreType::Enchantment), + "object 53 is The Mountain-king's Return, an enchantment rather than a land" + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "the resolved Recruit chain returns to a clean priority window" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 2f0792f0ec..7f261d4b48 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -664,6 +664,7 @@ mod issue_689_resonating_lute_hand_size; mod issue_691_sheoldred_saga_lore; mod issue_6943_faerie_slumber_party; mod issue_7063_library_reorder; +mod issue_7087_recruit_discard_provenance; mod issue_709_regression; mod issue_718_dina_sacrifice_draw; mod issue_735_amalia_power_threshold;