diff --git a/crates/engine/src/game/effects/draw.rs b/crates/engine/src/game/effects/draw.rs index bbcff0b84d..a79487e203 100644 --- a/crates/engine/src/game/effects/draw.rs +++ b/crates/engine/src/game/effects/draw.rs @@ -427,13 +427,11 @@ pub fn apply_draw_after_replacement( // `select_cards_to_draw` authority so a `DrawFromBottom` static is honored. let cards_to_draw = select_cards_to_draw(state, player_id, allowed_count as usize); - // CR 704.5b: If library has fewer cards than requested, mark the player. - // CR 121.4: Partial draws are legal — draw what's available. - if allowed_count > 0 && cards_to_draw.len() < allowed_count as usize { - if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) { - player.drew_from_empty_library = true; - } - } + // CR 704.5b: If library has fewer cards than requested, the ledger edit for + // this settled draw owns the player's empty-library fact. CR 121.4: partial + // draws are legal — draw what's available. + let mut attempted_empty_library = + allowed_count > 0 && cards_to_draw.len() < allowed_count as usize; let drawn_count = cards_to_draw.len() as u32; @@ -493,63 +491,60 @@ pub fn apply_draw_after_replacement( a swallowed NeedsChoice or otherwise failed to deliver, yet CardDrawn \ and the draw counters fire below" ); - // CR 121.1 + CR 504.1: Increment per-step + per-turn counters BEFORE - // emitting the event so the ordinal embedded in `CardDrawn` reflects - // this draw (1-indexed). Triggers/replacements that gate on "first - // draw of the draw step" read this ordinal. + let drawn_object = crate::types::identifiers::ObjectIncarnationRef::from_object( + state + .objects + .get(&obj_id) + .expect("settled draw object remains live for its ledger edit"), + ); + let established_first_draw = crate::game::ledger::resolve_and_apply_cards_drawn( + state, + player_id, + Some(drawn_object), + std::mem::take(&mut attempted_empty_library), + ) + .expect("settled draw bookkeeping must have a live player and journal cause"); + // CR 121.1 + CR 504.1: The exact ledger edit increments the counters + // before `CardDrawn` is emitted, so its ordinal is 1-indexed. + let player = state + .players + .iter() + .find(|player| player.id == player_id) + .expect("settled draw player remains live after its ledger edit"); let (nth_in_turn, nth_in_step) = - if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) { - // CR 121.1: This driver is the single authority for every - // settled draw, so it is the single place that marks the - // player as having drawn a card this turn — broadened from - // the pre-migration "took the draw-step draw" reading (the - // only production setter, deleted by the turns.rs/gift - // migration onto this driver) to "drew at least one card - // this turn". No production reader distinguishes the two; - // `turns.rs` clears it at turn start and - // `analysis/resource.rs` ignores it entirely. - player.has_drawn_this_turn = true; - player.cards_drawn_this_turn = player.cards_drawn_this_turn.saturating_add(1); - player.cards_drawn_this_step = player.cards_drawn_this_step.saturating_add(1); - (player.cards_drawn_this_turn, player.cards_drawn_this_step) - } else { - (1, 1) - }; + (player.cards_drawn_this_turn, player.cards_drawn_this_step); events.push(GameEvent::CardDrawn { player_id, object_id: obj_id, nth_in_turn, nth_in_step, }); - super::drawn_this_turn_choice::record_drawn_card(state, player_id, obj_id); - record_first_draw_and_enqueue_miracle(state, player_id, obj_id); + if established_first_draw { + enqueue_miracle_offer_for_first_draw(state, player_id, obj_id); + } + } + + if attempted_empty_library { + crate::game::ledger::resolve_and_apply_cards_drawn(state, player_id, None, true) + .expect("empty-library draw bookkeeping must have a live player and journal cause"); } drawn_count } -/// CR 702.94a + CR 603.11: Shared first-draw hook — record the drawn -/// `ObjectId` as `player`'s first-of-turn if absent, and if the drawn card has -/// `Keyword::Miracle(cost)`, enqueue a `MiracleOffer` for the priority-entry -/// flush to surface as `WaitingFor::MiracleReveal`. Subsequent draws do NOT -/// overwrite the first-draw entry and do NOT enqueue more offers (the static -/// ability only functions for the first-drawn card per CR 702.94a). -pub(crate) fn record_first_draw_and_enqueue_miracle( +/// CR 702.94a + CR 603.11: Continuation-only first-draw hook. The ledger edit +/// has already recorded `object_id` as this player's first card drawn this +/// turn; this function only enqueues the deferred miracle offer. +fn enqueue_miracle_offer_for_first_draw( state: &mut GameState, player: crate::types::player::PlayerId, object_id: crate::types::identifiers::ObjectId, ) { - // Only the FIRST draw of the turn per player establishes the miracle - // eligibility condition. `or_insert_with` returns a `&mut V` indicating - // whether the entry was freshly set; compare against `object_id` to know. - let is_first = !state.first_card_drawn_this_turn.contains_key(&player); - state - .first_card_drawn_this_turn - .entry(player) - .or_insert(object_id); - if !is_first { - return; - } + debug_assert_eq!( + state.first_card_drawn_this_turn.get(&player), + Some(&object_id), + "first-draw continuation must follow the matching ledger edit" + ); let Some(obj) = state.objects.get(&object_id) else { return; }; diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs index a6a9198c9c..fb67a9ee25 100644 --- a/crates/engine/src/game/ledger.rs +++ b/crates/engine/src/game/ledger.rs @@ -2,12 +2,14 @@ use crate::types::ability::TriggerDefinitionRef; use crate::types::game_state::{GameState, SpellCastRecord}; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::resolved_commands::{ - ResolvedLedgerEdit, ResolvedLedgerEditCommand, ResolvedLedgerEditReplayInvariantError, - ResolvedOncePerTurnPermission, ResolvedTriggerLedgerEdit, + ledger_edit_is_invalid, ResolvedLedgerEdit, ResolvedLedgerEditCommand, + ResolvedLedgerEditReplayInvariantError, ResolvedOncePerTurnPermission, + ResolvedTriggerLedgerEdit, }; +use crate::types::zones::Zone; /// Constructs, applies, and journals one exact semantic ledger edit. /// @@ -114,6 +116,83 @@ pub fn consume_once_per_turn_permission( ) } +/// CR 121.1 + CR 121.2 + CR 121.4: Capture and install one post-replacement +/// draw's exact bookkeeping. The zone-change hub has already installed +/// `drawn_object` before this ledger command is recorded; replay therefore +/// never selects a library card or re-runs replacement effects. +/// +/// `drawn_object` is absent only when an attempted draw found an empty library. +/// The returned boolean tells the continuation-only miracle hook whether this +/// command established the player's first draw of the turn. +pub fn resolve_and_apply_cards_drawn( + state: &mut GameState, + player: PlayerId, + drawn_object: Option, + attempted_empty_library: bool, +) -> Result { + let player_state = state + .players + .iter() + .find(|candidate| candidate.id == player) + .ok_or(ResolvedLedgerEditReplayInvariantError::UnknownPlayer( + player, + ))?; + let expected_drawn_cards_len = history_len( + state + .cards_drawn_this_turn + .get(&player) + .map_or(0, |drawn_cards| drawn_cards.len()), + )?; + let settled_card = drawn_object.is_some(); + let expected_first_card_drawn_this_turn = + state.first_card_drawn_this_turn.get(&player).copied(); + let resulting_first_card_drawn_this_turn = + expected_first_card_drawn_this_turn.or_else(|| drawn_object.map(|object| object.object_id)); + let established_first_draw = settled_card && expected_first_card_drawn_this_turn.is_none(); + let resulting_drawn_cards_len = if settled_card { + expected_drawn_cards_len + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)? + } else { + expected_drawn_cards_len + }; + + resolve_and_apply_ledger_edit( + state, + ResolvedLedgerEdit::CardsDrawn { + player, + drawn_object, + attempted_empty_library, + expected_has_drawn_this_turn: player_state.has_drawn_this_turn, + resulting_has_drawn_this_turn: if settled_card { + true + } else { + player_state.has_drawn_this_turn + }, + expected_cards_drawn_this_turn: player_state.cards_drawn_this_turn, + resulting_cards_drawn_this_turn: if settled_card { + player_state.cards_drawn_this_turn.saturating_add(1) + } else { + player_state.cards_drawn_this_turn + }, + expected_cards_drawn_this_step: player_state.cards_drawn_this_step, + resulting_cards_drawn_this_step: if settled_card { + player_state.cards_drawn_this_step.saturating_add(1) + } else { + player_state.cards_drawn_this_step + }, + expected_drew_from_empty_library: player_state.drew_from_empty_library, + resulting_drew_from_empty_library: player_state.drew_from_empty_library + || attempted_empty_library, + expected_drawn_cards_len, + resulting_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + }, + )?; + Ok(established_first_draw) +} + /// Applies one exact ledger edit without an event dispatcher, replacement /// pipeline, allocator, or dynamic permission lookup. pub fn apply_resolved_ledger_edit( @@ -216,6 +295,95 @@ pub fn apply_resolved_ledger_edit( .activated_abilities_this_game .insert(key, next_game_count); } + ResolvedLedgerEdit::CardsDrawn { + player, + drawn_object, + expected_has_drawn_this_turn, + resulting_has_drawn_this_turn, + expected_cards_drawn_this_turn, + resulting_cards_drawn_this_turn, + expected_cards_drawn_this_step, + resulting_cards_drawn_this_step, + expected_drew_from_empty_library, + resulting_drew_from_empty_library, + expected_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + .. + } => { + if ledger_edit_is_invalid(&command.edit) { + return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch); + } + let Some(player_index) = state + .players + .iter() + .position(|candidate| candidate.id == *player) + else { + return Err(ResolvedLedgerEditReplayInvariantError::UnknownPlayer( + *player, + )); + }; + let current_drawn_cards_len = history_len( + state + .cards_drawn_this_turn + .get(player) + .map_or(0, |drawn_cards| drawn_cards.len()), + )?; + let current_first_card_drawn_this_turn = + state.first_card_drawn_this_turn.get(player).copied(); + let player_state = &state.players[player_index]; + if player_state.has_drawn_this_turn != *expected_has_drawn_this_turn + || player_state.cards_drawn_this_turn != *expected_cards_drawn_this_turn + || player_state.cards_drawn_this_step != *expected_cards_drawn_this_step + || player_state.drew_from_empty_library != *expected_drew_from_empty_library + || current_drawn_cards_len != *expected_drawn_cards_len + || current_first_card_drawn_this_turn != *expected_first_card_drawn_this_turn + { + return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch); + } + if let Some(expected) = drawn_object { + let found = state + .objects + .get(&expected.object_id) + .map(ObjectIncarnationRef::from_object); + if found != Some(*expected) { + return Err( + ResolvedLedgerEditReplayInvariantError::DrawnObjectMismatch { + expected: *expected, + found, + }, + ); + } + if state.objects[&expected.object_id].zone == Zone::Library { + return Err( + ResolvedLedgerEditReplayInvariantError::DrawnObjectStillInLibrary( + *expected, + ), + ); + } + } + + let player_state = &mut state.players[player_index]; + player_state.has_drawn_this_turn = *resulting_has_drawn_this_turn; + player_state.cards_drawn_this_turn = *resulting_cards_drawn_this_turn; + player_state.cards_drawn_this_step = *resulting_cards_drawn_this_step; + player_state.drew_from_empty_library = *resulting_drew_from_empty_library; + if let Some(object) = drawn_object { + crate::game::effects::drawn_this_turn_choice::record_drawn_card( + state, + *player, + object.object_id, + ); + } + match resulting_first_card_drawn_this_turn { + Some(object) => { + state.first_card_drawn_this_turn.insert(*player, *object); + } + None => { + state.first_card_drawn_this_turn.remove(player); + } + } + } ResolvedLedgerEdit::TriggerFired { trigger, edit } => match edit { ResolvedTriggerLedgerEdit::OncePerTurn => { if !state.triggers_fired_this_turn.insert(trigger.clone()) { diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index 5ae9484783..cf73da677f 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -243,6 +243,26 @@ pub enum ResolvedLedgerEdit { source: super::identifiers::ObjectId, permission: ResolvedOncePerTurnPermission, }, + /// CR 121.1 + CR 121.2 + CR 121.4: Install one settled draw's bookkeeping + /// after its zone transition has already been resolved. `drawn_object` is + /// `None` only for an attempted draw from an empty library. + CardsDrawn { + player: PlayerId, + drawn_object: Option, + attempted_empty_library: bool, + expected_has_drawn_this_turn: bool, + resulting_has_drawn_this_turn: bool, + expected_cards_drawn_this_turn: u32, + resulting_cards_drawn_this_turn: u32, + expected_cards_drawn_this_step: u32, + resulting_cards_drawn_this_step: u32, + expected_drew_from_empty_library: bool, + resulting_drew_from_empty_library: bool, + expected_drawn_cards_len: u32, + resulting_drawn_cards_len: u32, + expected_first_card_drawn_this_turn: Option, + resulting_first_card_drawn_this_turn: Option, + }, } /// One exact per-event ledger mutation with its causal node. @@ -728,8 +748,17 @@ pub enum ResolvedLedgerEditReplayInvariantError { UnknownPlayer(PlayerId), SpellCastPreconditionMismatch, AbilityActivationPreconditionMismatch, + CardsDrawnPreconditionMismatch, + DrawnObjectMismatch { + expected: ObjectIncarnationRef, + found: Option, + }, + DrawnObjectStillInLibrary(ObjectIncarnationRef), TriggerAlreadyRecorded, - TriggerCountPreconditionMismatch { expected: u32, found: u32 }, + TriggerCountPreconditionMismatch { + expected: u32, + found: u32, + }, PermissionAlreadyConsumed(ResolvedOncePerTurnPermission), CounterOverflow, } @@ -748,6 +777,18 @@ impl std::fmt::Display for ResolvedLedgerEditReplayInvariantError { f, "resolved activated-ability command does not match its ledger prefix" ), + Self::CardsDrawnPreconditionMismatch => write!( + f, + "resolved draw-bookkeeping command does not match its ledger prefix" + ), + Self::DrawnObjectMismatch { expected, found } => write!( + f, + "resolved drawn-object occurrence mismatch: expected {expected:?}, found {found:?}" + ), + Self::DrawnObjectStillInLibrary(object) => write!( + f, + "resolved drawn-object occurrence remained in its library: {object:?}" + ), Self::TriggerAlreadyRecorded => { write!( f, @@ -1805,7 +1846,7 @@ fn zone_change_command_is_invalid(command: &ResolvedZoneChangeCommand) -> bool { || (command.to != Zone::Battlefield && record.entered_incarnation.is_some()) } -fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { +pub(crate) fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { match edit { ResolvedLedgerEdit::SpellCast { expected_game_count, @@ -1826,6 +1867,59 @@ fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { expected_game_count, .. } => *expected_turn_count == u32::MAX || *expected_game_count == u32::MAX, + ResolvedLedgerEdit::CardsDrawn { + drawn_object, + attempted_empty_library, + expected_has_drawn_this_turn, + resulting_has_drawn_this_turn, + expected_cards_drawn_this_turn, + resulting_cards_drawn_this_turn, + expected_cards_drawn_this_step, + resulting_cards_drawn_this_step, + expected_drew_from_empty_library, + resulting_drew_from_empty_library, + expected_drawn_cards_len, + resulting_drawn_cards_len, + expected_first_card_drawn_this_turn, + resulting_first_card_drawn_this_turn, + .. + } => { + let settled_card = drawn_object.is_some(); + let expected_first = if let Some(object) = drawn_object { + expected_first_card_drawn_this_turn.or(Some(object.object_id)) + } else { + *expected_first_card_drawn_this_turn + }; + (!settled_card && !attempted_empty_library) + || *resulting_has_drawn_this_turn + != if settled_card { + true + } else { + *expected_has_drawn_this_turn + } + || *resulting_cards_drawn_this_turn + != if settled_card { + expected_cards_drawn_this_turn.saturating_add(1) + } else { + *expected_cards_drawn_this_turn + } + || *resulting_cards_drawn_this_step + != if settled_card { + expected_cards_drawn_this_step.saturating_add(1) + } else { + *expected_cards_drawn_this_step + } + || *resulting_drew_from_empty_library + != (*expected_drew_from_empty_library || *attempted_empty_library) + || *expected_drawn_cards_len == u32::MAX + || *resulting_drawn_cards_len + != if settled_card { + expected_drawn_cards_len + 1 + } else { + *expected_drawn_cards_len + } + || *resulting_first_card_drawn_this_turn != expected_first + } ResolvedLedgerEdit::TriggerFired { edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old }, .. @@ -1840,6 +1934,12 @@ fn ledger_edit_has_legacy_object_identity(edit: &ResolvedLedgerEdit) -> bool { edit, ResolvedLedgerEdit::TriggerFired { trigger, .. } if trigger.source.incarnation == LEGACY_INCARNATION + ) || matches!( + edit, + ResolvedLedgerEdit::CardsDrawn { + drawn_object: Some(object), + .. + } if object.incarnation == LEGACY_INCARNATION ) } diff --git a/crates/engine/tests/integration/cr733_resolved_draw.rs b/crates/engine/tests/integration/cr733_resolved_draw.rs new file mode 100644 index 0000000000..bd91c8049d --- /dev/null +++ b/crates/engine/tests/integration/cr733_resolved_draw.rs @@ -0,0 +1,186 @@ +//! CR733 P2 coverage for replay-exact draw bookkeeping composed with zone changes. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::game_state::GameState; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::resolved_commands::{ + ResolvedLedgerEdit, ResolvedRulesCommand, ResolvedRulesJournal, +}; +use engine::types::zones::Zone; + +const DIVINATION_ORACLE: &str = "Draw two cards."; + +fn semantic_commands_after( + state: &GameState, + first_resolution_entry: usize, +) -> Vec { + state + .resolved_rules_journal + .entries() + .iter() + .skip(first_resolution_entry) + .filter_map(|entry| entry.command.clone()) + .collect() +} + +fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) { + match command { + ResolvedRulesCommand::ManaInsert(command) => { + state.apply_resolved_mana_insert(command).unwrap(); + } + ResolvedRulesCommand::ManaSpend(command) => { + state.apply_resolved_mana_spend(command).unwrap(); + } + ResolvedRulesCommand::PlayerEdit(command) => { + state.apply_resolved_player_edit(command).unwrap(); + } + ResolvedRulesCommand::ObjectStatus(command) => { + engine::game::object_state::apply_resolved_object_edit(state, command).unwrap(); + } + ResolvedRulesCommand::ObjectCounter(command) => { + engine::game::effects::counters::apply_resolved_counter_edit(state, command).unwrap(); + } + ResolvedRulesCommand::LedgerEdit(command) => { + engine::game::ledger::apply_resolved_ledger_edit(state, command).unwrap(); + } + ResolvedRulesCommand::LibraryShuffle(command) => { + engine::game::library::apply_resolved_library_shuffle(state, command, &mut Vec::new()) + .unwrap(); + } + ResolvedRulesCommand::ZoneChange(command) => { + engine::game::zones::apply_resolved_zone_change(state, command).unwrap(); + } + ResolvedRulesCommand::Information(command) => { + state.apply_resolved_information(command).unwrap(); + } + ResolvedRulesCommand::FrameTransition(command) => { + state + .apply_resolved_frame_transition(command.as_ref()) + .unwrap(); + } + ResolvedRulesCommand::TriggerCollection(command) => { + engine::game::triggers::apply_resolved_trigger_collection(state, command).unwrap(); + } + } +} + +/// CR 121.1 + CR 121.2: A real Divination-class draw spell records every +/// Library → Hand move through the zone-change hub and each settled draw's +/// bookkeeping through the ledger. Replaying those recorded commands from the +/// pre-resolution stack state must not inspect the library or select cards. +#[test] +fn divination_draw_replays_zone_changes_and_ledger_bookkeeping_exactly() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Divination", false, DIVINATION_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + scenario.with_library_top(P0, &["First", "Second", "Third"]); + let mut runner = scenario.build(); + + let committed = runner.cast(spell).commit(); + let pre_resolution_state = committed.state().clone(); + let first_resolution_entry = pre_resolution_state.resolved_rules_journal.entries().len(); + let outcome = committed.resolve(); + outcome.assert_hand_drawn(P0, 2); + let ordinary_state = runner.state().clone(); + let commands = semantic_commands_after(&ordinary_state, first_resolution_entry); + + let draw_zone_changes: Vec<_> = commands + .iter() + .filter_map(|command| match command { + ResolvedRulesCommand::ZoneChange(command) + if command.from == Zone::Library && command.to == Zone::Hand => + { + Some((command.object.object_id, command.resulting_incarnation)) + } + _ => None, + }) + .collect(); + let drawn_edits: Vec<_> = commands + .iter() + .filter_map(|command| match command { + ResolvedRulesCommand::LedgerEdit(command) => match &command.edit { + ResolvedLedgerEdit::CardsDrawn { + drawn_object: Some(object), + .. + } => Some((object.object_id, object.incarnation)), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!( + draw_zone_changes.len(), + 2, + "CR 121.2: the two-card instruction settles one Library → Hand command per card" + ); + assert_eq!( + drawn_edits.len(), + 2, + "each settled draw must record one CardsDrawn ledger edit after its zone command" + ); + assert_eq!( + drawn_edits, draw_zone_changes, + "the ledger records the exact post-zone-change object occurrence, not a replay-time selection" + ); + + let journal_wire = serde_json::to_value(&ordinary_state.resolved_rules_journal) + .expect("draw journal serializes"); + assert_eq!( + serde_json::from_value::(journal_wire) + .expect("draw journal validates and deserializes"), + ordinary_state.resolved_rules_journal + ); + + let mut replay = pre_resolution_state; + replay.resolved_rules_journal = ordinary_state.resolved_rules_journal.clone(); + for command in &commands { + apply_semantic_command(&mut replay, command); + } + + let replay_player = replay + .players + .iter() + .find(|player| player.id == P0) + .expect("replay keeps the drawing player"); + let ordinary_player = ordinary_state + .players + .iter() + .find(|player| player.id == P0) + .expect("ordinary resolution keeps the drawing player"); + assert_eq!( + replay_player.cards_drawn_this_turn, ordinary_player.cards_drawn_this_turn, + "replay installs the player turn counter" + ); + assert_eq!( + replay_player.cards_drawn_this_step, ordinary_player.cards_drawn_this_step, + "replay installs the player step counter" + ); + assert_eq!( + replay_player.has_drawn_this_turn, ordinary_player.has_drawn_this_turn, + "replay installs the player draw flag" + ); + assert_eq!( + replay_player.drew_from_empty_library, ordinary_player.drew_from_empty_library, + "replay installs the player empty-library fact" + ); + assert_eq!( + replay.cards_drawn_this_turn, ordinary_state.cards_drawn_this_turn, + "replay appends the GameState drawn-card ledger in order" + ); + assert_eq!( + replay.first_card_drawn_this_turn, ordinary_state.first_card_drawn_this_turn, + "replay installs the first-draw ledger fact" + ); + assert_eq!( + replay_player.hand, ordinary_player.hand, + "the zone-change commands install the exact hand contents" + ); + assert_eq!( + replay_player.library, ordinary_player.library, + "the zone-change commands preserve the exact remaining library order" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index bda623d32e..621d6f14a3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -93,6 +93,7 @@ mod court_of_cunning_multi_target_mill; mod cr733_resolved_commands_p0; mod cr733_resolved_commands_p1; mod cr733_resolved_commands_p2; +mod cr733_resolved_draw; mod cr733_resolved_frame_transition; mod cr733_resolved_trigger_collection; mod cr733_resolved_zone_change;