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
7 changes: 4 additions & 3 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions crates/engine/src/types/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiscardFrameId> {
Expand Down Expand Up @@ -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(&[]);
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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::<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 resolve_recruit_to_discard_choice(runner: &mut GameRunner) -> Vec<ObjectId> {
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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading