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
6 changes: 2 additions & 4 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions crates/engine/src/types/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiscardFrameId> {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 {
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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::<PersistedGameState>(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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading