From 817ecc8d2d3ce8dc30eb91b4e0863f371d1917d8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 01:36:32 -0700 Subject: [PATCH 01/16] fix(engine): make dig entries attack --- crates/engine/src/database/hideaway.rs | 1 + crates/engine/src/game/effects/change_zone.rs | 5 + crates/engine/src/game/effects/choose_card.rs | 1 + crates/engine/src/game/effects/counters.rs | 21 +++- crates/engine/src/game/effects/dig.rs | 32 ++++++ crates/engine/src/game/effects/end_phase.rs | 1 + .../src/game/effects/exile_from_top_until.rs | 1 + crates/engine/src/game/effects/explore.rs | 1 + crates/engine/src/game/effects/mod.rs | 2 + crates/engine/src/game/effects/prepare.rs | 1 + .../engine/src/game/effects/reveal_until.rs | 1 + crates/engine/src/game/elimination.rs | 2 + .../src/game/engine_resolution_choices.rs | 7 ++ crates/engine/src/game/mana_abilities.rs | 1 + crates/engine/src/game/triggers.rs | 1 + crates/engine/src/game/visibility.rs | 2 + crates/engine/src/game/zone_pipeline.rs | 46 ++++++++- .../src/parser/oracle_effect/conditions.rs | 2 + .../src/parser/oracle_effect/imperative.rs | 1 + .../engine/src/parser/oracle_effect/lower.rs | 1 + crates/engine/src/parser/oracle_effect/mod.rs | 2 +- .../src/parser/oracle_effect/sequence.rs | 60 +++++++++--- crates/engine/src/parser/oracle_ir/ast.rs | 4 + crates/engine/src/types/ability.rs | 3 + crates/engine/src/types/game_state.rs | 12 +++ .../tests/integration/cost_zone_pipeline.rs | 7 ++ .../integration/dig_impossible_keep_count.rs | 1 + .../dig_rest_pile_stranding_on_etb_pause.rs | 1 + .../issue_4232_winota_enters_attacking.rs | 97 +++++++++++++++++++ .../issue_5996_planetarium_look_cast.rs | 1 + crates/engine/tests/integration/main.rs | 1 + .../integration/metamorphic_alteration.rs | 1 + 32 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs diff --git a/crates/engine/src/database/hideaway.rs b/crates/engine/src/database/hideaway.rs index dcc298d110..bca6e21199 100644 --- a/crates/engine/src/database/hideaway.rs +++ b/crates/engine/src/database/hideaway.rs @@ -89,6 +89,7 @@ fn hideaway_trigger(n: u32) -> TriggerDefinition { // CR 701.20e: the cards are looked at privately, not revealed. reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 5116c43f3c..cd13fb7374 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -812,6 +812,7 @@ pub fn resolve( ability.duration.as_ref(), effect_enter_transformed, eff_tapped, + false, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -908,6 +909,7 @@ pub fn resolve( ability.duration.as_ref(), effect_enter_transformed, eff_tapped, + false, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -1518,6 +1520,7 @@ pub(crate) fn process_one_zone_move_with_terminal( ctx.duration.as_ref(), ctx.enter_transformed, eff_tapped, + false, ctx.enters_under_player, &ctx.enter_with_counters, ctx.face_down_profile.as_ref(), @@ -2004,6 +2007,7 @@ pub fn resolve_all( ability.duration.as_ref(), false, enter_tapped, + false, enters_under_player, &enter_with_counters, face_down_profile.as_ref(), @@ -4398,6 +4402,7 @@ mod tests { None, false, crate::types::zones::EtbTapState::Unspecified, + false, None, &[], None, diff --git a/crates/engine/src/game/effects/choose_card.rs b/crates/engine/src/game/effects/choose_card.rs index bcc32752c4..f15d61c69e 100644 --- a/crates/engine/src/game/effects/choose_card.rs +++ b/crates/engine/src/game/effects/choose_card.rs @@ -89,6 +89,7 @@ pub fn resolve( rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: false, + enters_attacking: false, }; events.push(GameEvent::EffectResolved { diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index be48042372..a92beb4197 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -24,6 +24,7 @@ use crate::types::resolved_commands::{ ResolvedObjectCounterCommand, ResolvedObjectCounterEdit, ResolvedObjectCounterReplayInvariantError, }; +use crate::types::zones::Zone; /// CR 306.5c + CR 310.4c: After mutating the counter map, re-derive the /// `obj.loyalty` / `obj.defense` field so the counter count and the cached @@ -792,6 +793,7 @@ fn apply_pending_counter_post_action( source_id, duration, exile_tracking, + enters_attacking, drain, } => { // CR 614.12a: the delivery tail may surface a Devour as-enters @@ -818,7 +820,24 @@ fn apply_pending_counter_post_action( None, events, ) { - super::change_zone::ZoneDeliveryResult::Done => true, + super::change_zone::ZoneDeliveryResult::Done => { + if enters_attacking && to == Zone::Battlefield { + let controller = state + .objects + .get(&object_id) + .map(|object| object.controller) + .unwrap_or(PlayerId(0)); + // CR 508.4: an entrant joins combat only after its + // replacement-modified entry has fully settled. + crate::game::combat::enter_attacking( + state, + object_id, + cause.or(source_id).unwrap_or(object_id), + controller, + ); + } + true + } super::change_zone::ZoneDeliveryResult::NeedsChoice(_) => false, } } diff --git a/crates/engine/src/game/effects/dig.rs b/crates/engine/src/game/effects/dig.rs index 26a0708627..247912eaa0 100644 --- a/crates/engine/src/game/effects/dig.rs +++ b/crates/engine/src/game/effects/dig.rs @@ -26,6 +26,7 @@ pub fn resolve( rest_order, is_reveal, enter_tapped, + enters_attacking, dig_source, ) = match &ability.effect { Effect::Dig { @@ -40,6 +41,7 @@ pub fn resolve( rest_order, reveal, enter_tapped, + enters_attacking, source, } => { let resolved_count = @@ -68,6 +70,7 @@ pub fn resolve( *rest_order, *reveal, *enter_tapped, + *enters_attacking, *source, ) } @@ -82,6 +85,7 @@ pub fn resolve( DigRestOrder::Preserve, false, false, + false, DigSource::Library, ), }; @@ -113,6 +117,7 @@ pub fn resolve( rest_dest, rest_order, enter_tapped, + enters_attacking, ); } @@ -248,6 +253,7 @@ pub fn resolve( rest_dest, rest_order, enter_tapped, + enters_attacking, events, ); return Ok(()); @@ -272,6 +278,7 @@ pub fn resolve( rest_order, source_id: Some(ability.source_id), enter_tapped, + enters_attacking, }; events.push(GameEvent::EffectResolved { @@ -309,6 +316,7 @@ fn resolve_from_prior_look( rest_dest: Option, rest_order: DigRestOrder, enter_tapped: bool, + enters_attacking: bool, ) -> Result<(), EffectError> { let cards = state.private_look_ids.clone(); if cards.is_empty() { @@ -430,6 +438,7 @@ fn resolve_from_prior_look( rest_order, source_id: Some(ability.source_id), enter_tapped, + enters_attacking, }; events.push(GameEvent::EffectResolved { @@ -463,6 +472,7 @@ fn resolve_mass_put_all( rest_destination: Option, rest_order: DigRestOrder, enter_tapped: bool, + enters_attacking: bool, events: &mut Vec, ) { let rest: Vec<_> = cards @@ -488,6 +498,7 @@ fn resolve_mass_put_all( selectable.to_vec(), dest, EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking, events, ), crate::game::zone_pipeline::BatchMoveResult::NeedsChoice => { @@ -499,6 +510,7 @@ fn resolve_mass_put_all( selected: selectable.to_vec(), destination: dest, enter_tapped: EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking, }, ); } @@ -516,6 +528,7 @@ pub(crate) fn move_mass_put_all_selected( selected: Vec, destination: Zone, enter_tapped: EtbTapState, + enters_attacking: bool, events: &mut Vec, ) { let requests = selected @@ -527,6 +540,7 @@ pub(crate) fn move_mass_put_all_selected( source_id, ); request.mods.enter_tapped = enter_tapped; + request.mods.enters_attacking = enters_attacking; request }) .collect(); @@ -581,6 +595,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -676,6 +691,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![crate::types::ability::TargetRef::Player(PlayerId(1))], @@ -730,6 +746,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -784,6 +801,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -855,6 +873,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let action = GameAction::SelectCards { cards: kept.clone(), @@ -937,6 +956,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut events = Vec::new(); @@ -998,6 +1018,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; state.park_ability_continuation(PendingContinuation::new( Box::new(ResolvedAbility::new( @@ -1070,6 +1091,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut events = Vec::new(); @@ -1134,6 +1156,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut events = Vec::new(); @@ -1195,6 +1218,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut events = Vec::new(); @@ -1265,6 +1289,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut gain_life = ResolvedAbility::new( Effect::GainLife { @@ -1338,6 +1363,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut gain_life = ResolvedAbility::new( Effect::GainLife { @@ -1405,6 +1431,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let mut gain_life = ResolvedAbility::new( Effect::GainLife { @@ -1483,6 +1510,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1560,6 +1588,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1866,6 +1895,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1923,6 +1953,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1991,6 +2022,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: Some(ObjectId(100)), enter_tapped: false, + enters_attacking: false, }; let action = GameAction::SelectCards { cards: kept.clone(), diff --git a/crates/engine/src/game/effects/end_phase.rs b/crates/engine/src/game/effects/end_phase.rs index f244185d93..5087f40a1e 100644 --- a/crates/engine/src/game/effects/end_phase.rs +++ b/crates/engine/src/game/effects/end_phase.rs @@ -74,6 +74,7 @@ pub(super) fn exile_nonresolving_stack_objects( None, false, crate::types::zones::EtbTapState::Unspecified, + false, None, &[], None, diff --git a/crates/engine/src/game/effects/exile_from_top_until.rs b/crates/engine/src/game/effects/exile_from_top_until.rs index f51b8635ad..490ca4466c 100644 --- a/crates/engine/src/game/effects/exile_from_top_until.rs +++ b/crates/engine/src/game/effects/exile_from_top_until.rs @@ -103,6 +103,7 @@ pub fn resolve( ability.duration.as_ref(), false, crate::types::zones::EtbTapState::Unspecified, + false, None, &[], None, diff --git a/crates/engine/src/game/effects/explore.rs b/crates/engine/src/game/effects/explore.rs index bfd05f2024..e5e04b20c1 100644 --- a/crates/engine/src/game/effects/explore.rs +++ b/crates/engine/src/game/effects/explore.rs @@ -355,6 +355,7 @@ pub(crate) fn resolve_explore_effect( rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: false, + enters_attacking: false, }; events.push(GameEvent::EffectResolved { diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index ccb76c172c..e0fe2c6291 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -27749,6 +27749,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ) @@ -28384,6 +28385,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ) diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index cb17a3aa3a..23aa654334 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -648,6 +648,7 @@ mod tests { None, false, crate::types::zones::EtbTapState::Unspecified, + false, None, &[], None, diff --git a/crates/engine/src/game/effects/reveal_until.rs b/crates/engine/src/game/effects/reveal_until.rs index 2ac0790d19..839236551a 100644 --- a/crates/engine/src/game/effects/reveal_until.rs +++ b/crates/engine/src/game/effects/reveal_until.rs @@ -524,6 +524,7 @@ fn resolve_choose_any_number( rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: Some(ability.source_id), enter_tapped: enter_tapped.is_tapped(), + enters_attacking: false, }; events.push(GameEvent::EffectResolved { diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 3df9dfa88d..ef8f3e75ca 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1478,6 +1478,7 @@ mod tests { destination: Zone::Graveyard, cause: crate::types::game_state::PendingBatchZoneChangeCause::StateBasedAction, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_transformed: false, controller_override: None, enter_with_counters: Vec::new(), @@ -1494,6 +1495,7 @@ mod tests { destination: Zone::Graveyard, cause: crate::types::game_state::PendingBatchZoneChangeCause::StateBasedAction, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_transformed: false, controller_override: None, enter_with_counters: Vec::new(), diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index a567985348..240bc2d3dc 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -3337,6 +3337,7 @@ pub(super) fn handle_resolution_choice( rest_destination, rest_order, enter_tapped, + enters_attacking, source_id: dig_source_id, .. }, @@ -3543,6 +3544,7 @@ pub(super) fn handle_resolution_choice( ); req.mods.enter_tapped = crate::types::zones::EtbTapState::from_legacy_bool(enter_tapped); + req.mods.enters_attacking = enters_attacking; match crate::game::zone_pipeline::move_object(state, req, events) { crate::game::zone_pipeline::ZoneMoveResult::Done => {} // CR 303.4f / CR 616.1: the kept card's battlefield @@ -8031,6 +8033,7 @@ pub(crate) fn run_batch_completion( selected, destination, enter_tapped, + enters_attacking, } => { crate::game::effects::dig::move_mass_put_all_selected( state, @@ -8039,6 +8042,7 @@ pub(crate) fn run_batch_completion( selected, destination, enter_tapped, + enters_attacking, events, ); crate::game::zone_pipeline::BatchMoveResult::Done @@ -9912,6 +9916,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }, GameAction::SelectCards { cards: vec![white] }, &mut events, @@ -9994,6 +9999,7 @@ mod tests { rest_order: DigRestOrder::Random, source_id: None, enter_tapped: false, + enters_attacking: false, }, GameAction::SelectCards { cards: vec![keep] }, &mut events, @@ -10033,6 +10039,7 @@ mod tests { rest_order: DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }, GameAction::SelectCards { cards: vec![keep] }, &mut events, diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 15bdc8fceb..2fa306cb0d 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -5779,6 +5779,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source, } } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 6021b20a6d..ca5f8a769b 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -24862,6 +24862,7 @@ pub mod tests { None, true, // enter_transformed crate::types::zones::EtbTapState::Unspecified, + false, // enters_attacking None, // controller_override &[], // effect_enter_with_counters None, // face_down_profile diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index b2220a4d64..0f293a0bf6 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -859,6 +859,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState rest_order, source_id, enter_tapped, + enters_attacking, } = state.waiting_for { if !can_view_private_for_player(player) { @@ -874,6 +875,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState rest_order, source_id, enter_tapped, + enters_attacking, }; } } diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index b11d736e43..4e68c8ad40 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -146,6 +146,9 @@ pub struct EntryMods { /// pipeline carrier `ProposedEvent::ZoneChange.enter_tapped` and preserving /// the Unspecified-vs-Untapped distinction at the request boundary. pub enter_tapped: EtbTapState, + /// CR 508.4: A creature put onto the battlefield attacking joins combat + /// without being declared as an attacker. + pub enters_attacking: bool, /// CR 712.14a. Genuinely two-valued (enters showing back face or not) — no /// Unspecified third state to preserve, unlike `enter_tapped`. pub enter_transformed: bool, @@ -228,6 +231,7 @@ impl ZoneMoveRequest { destination: self.to, cause, enter_tapped: self.mods.enter_tapped, + enters_attacking: self.mods.enters_attacking, enter_transformed: self.mods.enter_transformed, controller_override: self.mods.controller_override, enter_with_counters: self.mods.enter_with_counters, @@ -271,6 +275,7 @@ impl ZoneMoveRequest { cause, mods: EntryMods { enter_tapped: pending.enter_tapped, + enters_attacking: pending.enters_attacking, enter_transformed: pending.enter_transformed, controller_override: pending.controller_override, enter_with_counters: pending.enter_with_counters, @@ -978,6 +983,7 @@ pub(crate) fn move_object_with_terminal( if let ProposedEvent::ZoneChange { enter_transformed, enter_tapped, + enters_attacking, controller_override, enter_with_counters, face_down_profile, @@ -989,6 +995,7 @@ pub(crate) fn move_object_with_terminal( if !req.mods.enter_tapped.is_unspecified() { *enter_tapped = req.mods.enter_tapped; } + *enters_attacking = req.mods.enters_attacking; *controller_override = req.mods.controller_override; enter_with_counters.extend(req.mods.enter_with_counters.iter().cloned()); *face_down_profile = req.mods.face_down_profile.clone().map(Box::new); @@ -1029,6 +1036,7 @@ pub(crate) fn move_object_with_terminal( exile_links.duration.as_ref(), req.mods.enter_transformed, req.mods.enter_tapped, + req.mods.enters_attacking, req.mods.controller_override, &req.mods.enter_with_counters, req.mods.face_down_profile.as_ref(), @@ -1405,6 +1413,7 @@ fn anticipated_zone_change_delivery( ProposedEvent::zone_change(request.object_id, object.zone, request.to, request.source()); if let ProposedEvent::ZoneChange { enter_tapped, + enters_attacking, enter_transformed, controller_override, enter_with_counters, @@ -1415,6 +1424,7 @@ fn anticipated_zone_change_delivery( } = &mut expected_event { *enter_tapped = request.mods.enter_tapped; + *enters_attacking = request.mods.enters_attacking; *enter_transformed = request.mods.enter_transformed; *controller_override = request.mods.controller_override; *enter_with_counters = request.mods.enter_with_counters.clone(); @@ -1664,6 +1674,7 @@ fn append_zone_delivery_tail_after_counter_pause( duration: Option<&Duration>, exile_tracking: ZoneDeliveryExileTracking, drain: PostReplacementDrainOwner, + enters_attacking: bool, clear_pending_etb_counters: Option, ) -> ZoneDeliveryResult { let mut actions = Vec::new(); @@ -1679,6 +1690,7 @@ fn append_zone_delivery_tail_after_counter_pause( duration: duration.cloned(), exile_tracking, drain, + enters_attacking, }); crate::game::effects::counters::append_pending_counter_post_actions(state, actions); replacement_pause_delivery_result(state) @@ -3270,6 +3282,7 @@ pub(crate) fn deliver_replaced_zone_change( attach_to, enter_transformed: should_transform, enter_tapped: should_tap, + enters_attacking, enter_with_counters, controller_override: ctrl_override, face_down_profile, @@ -3775,6 +3788,7 @@ pub(crate) fn deliver_replaced_zone_change( duration, exile_tracking, drain, + enters_attacking, pending_etb_cleanup, ); } @@ -3806,11 +3820,12 @@ pub(crate) fn deliver_replaced_zone_change( duration, exile_tracking, drain, + enters_attacking, None, ); } } - return apply_zone_delivery_tail( + let result = apply_zone_delivery_tail( state, object_id, from, @@ -3823,6 +3838,20 @@ pub(crate) fn deliver_replaced_zone_change( library_placement.as_ref(), events, ); + if matches!(result, ZoneDeliveryResult::Done) && enters_attacking && entered_battlefield { + let controller = state + .objects + .get(&object_id) + .map(|object| object.controller) + .unwrap_or(PlayerId(0)); + crate::game::combat::enter_attacking( + state, + object_id, + cause.or(source_id).unwrap_or(object_id), + controller, + ); + } + return result; } ZoneDeliveryResult::Done } @@ -3859,6 +3888,7 @@ pub(crate) fn execute_zone_move( duration: Option<&Duration>, enter_transformed: bool, enter_tapped: EtbTapState, + enters_attacking: bool, controller_override: Option, effect_enter_with_counters: &[(CounterType, u32)], face_down_profile: Option<&crate::types::ability::FaceDownProfile>, @@ -3876,6 +3906,7 @@ pub(crate) fn execute_zone_move( duration, enter_transformed, enter_tapped, + enters_attacking, controller_override, effect_enter_with_counters, face_down_profile, @@ -3897,6 +3928,7 @@ pub(crate) fn execute_zone_move_with_terminal( duration: Option<&Duration>, enter_transformed: bool, enter_tapped: EtbTapState, + enters_attacking: bool, controller_override: Option, effect_enter_with_counters: &[(CounterType, u32)], face_down_profile: Option<&crate::types::ability::FaceDownProfile>, @@ -3914,6 +3946,7 @@ pub(crate) fn execute_zone_move_with_terminal( duration, enter_transformed, enter_tapped, + enters_attacking, controller_override, effect_enter_with_counters, face_down_profile, @@ -3935,6 +3968,7 @@ fn execute_zone_move_with_applied_terminal( duration: Option<&Duration>, enter_transformed: bool, enter_tapped: EtbTapState, + enters_attacking: bool, controller_override: Option, effect_enter_with_counters: &[(CounterType, u32)], face_down_profile: Option<&crate::types::ability::FaceDownProfile>, @@ -3996,6 +4030,16 @@ fn execute_zone_move_with_applied_terminal( } } + if enters_attacking { + if let ProposedEvent::ZoneChange { + enters_attacking: ref mut entering_attacking, + .. + } = proposed + { + *entering_attacking = true; + } + } + // CR 110.2a: Set controller_override on the proposed event so replacement effects // see the correct controller through the pipeline. if let Some(ctrl) = controller_override { diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index dc89004731..3d16bc3f1c 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4315,6 +4315,7 @@ pub(crate) fn try_parse_dig_instead_alternative( rest_destination: alt_rest, rest_order: alt_rest_order, enter_tapped: alt_enter_tapped, + enters_attacking: alt_enters_attacking, .. } = alt_continuation else { @@ -4353,6 +4354,7 @@ pub(crate) fn try_parse_dig_instead_alternative( rest_order: alt_rest.map_or(*prev_rest_order, |_| alt_rest_order), reveal: *prev_reveal, enter_tapped: alt_enter_tapped, + enters_attacking: alt_enters_attacking, source: DigSource::Library, }; diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 6c90e024cf..6fd9cbad59 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -3385,6 +3385,7 @@ pub(super) fn lower_search_and_creation_ast(ast: SearchCreationImperativeAst) -> rest_order: crate::types::ability::DigRestOrder::Preserve, reveal, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, SearchCreationImperativeAst::ExileTopLookedAt { diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 43b539c1fa..81712eba85 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -12430,6 +12430,7 @@ mod where_x_tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ), diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index c0929f83c1..6e64f1b15c 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -34336,7 +34336,7 @@ fn parse_battlefield_transformed_qualifier(tail_lower: &str) -> bool { /// `tail_lower` is the lowercase slice starting *immediately after* the /// destination needle (e.g. `" tapped and attacking that opponent."`); the /// leading space is part of the needle's word-boundary contract. -fn parse_battlefield_entry_qualifiers(tail_lower: &str) -> (bool, bool) { +pub(super) fn parse_battlefield_entry_qualifiers(tail_lower: &str) -> (bool, bool) { // Word-boundary anchor for the qualifier clause: the qualifier must end at // a real boundary so " tapped" doesn't accidentally consume " tappedly". // EOF, whitespace, or sentence punctuation all qualify. diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 4555544e6f..93a12eccaa 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -4220,6 +4220,7 @@ pub(super) fn apply_clause_continuation( enters_under, face_down_profile, enter_tapped, + enters_attacking, reveal_verb, } => { // CR 608.2c: the "from among those cards" continuation patches the @@ -4314,6 +4315,7 @@ pub(super) fn apply_clause_continuation( rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::PriorLook, }, ); @@ -4336,6 +4338,7 @@ pub(super) fn apply_clause_continuation( rest_order: continuation_rest_order, reveal: false, enter_tapped, + enters_attacking, source: DigSource::PriorLook, }, ); @@ -4379,6 +4382,7 @@ pub(super) fn apply_clause_continuation( rest_order, reveal, enter_tapped: dig_enter_tapped, + enters_attacking: dig_enters_attacking, .. } = &mut *previous.effect { @@ -4429,6 +4433,7 @@ pub(super) fn apply_clause_continuation( } *rest_order = continuation_rest_order; *dig_enter_tapped = enter_tapped; + *dig_enters_attacking = enters_attacking; } else if let Effect::Mill { destination: mill_destination, .. @@ -4802,6 +4807,14 @@ pub(super) fn apply_clause_continuation( // applying them unconditionally. *enters_modified_if = moved_filter; } + Effect::Dig { + enters_attacking, + enter_tapped, + .. + } => { + *enters_attacking = true; + *enter_tapped = true; + } Effect::Meld { entry, .. } => { *entry = crate::types::ability::PermanentEntryMode::TappedAndAttacking { destination: crate::types::ability::EntryAttackDestination::AnyDefender, @@ -5492,7 +5505,7 @@ pub(super) fn parse_dig_from_among( // Experiment): "reveal up to N cards from among them, then put the // rest on the bottom" — the kept cards are NOT auto-routed; subsequent // sub_abilities route them by type via `TargetFilter::TrackedSetFiltered`. - let (destination, enter_tapped) = parse_dig_kept_destination(lower); + let (destination, enter_tapped, enters_attacking) = parse_dig_kept_destination(lower); // CR 701.17c + CR 608.2c: "return a card milled this way to your hand" // is the same tracked-set continuation as "from among the milled cards", @@ -5609,6 +5622,7 @@ pub(super) fn parse_dig_from_among( enters_under, face_down_profile, enter_tapped, + enters_attacking, reveal_verb, }); } @@ -5713,6 +5727,7 @@ pub(super) fn parse_dig_from_among( enters_under, face_down_profile, enter_tapped, + enters_attacking, reveal_verb, }); } @@ -5776,6 +5791,7 @@ pub(super) fn parse_dig_from_among( enters_under: None, face_down_profile: None, enter_tapped, + enters_attacking, reveal_verb: false, }); } @@ -5784,7 +5800,7 @@ pub(super) fn parse_dig_from_among( None } -fn parse_dig_kept_destination(lower: &str) -> (Option, bool) { +fn parse_dig_kept_destination(lower: &str) -> (Option, bool, bool) { if let Some(parsed) = parse_dig_from_among_destination(lower) { return parsed; } @@ -5804,10 +5820,10 @@ fn parse_dig_kept_destination(lower: &str) -> (Option, bool) { } else { None }; - (destination, false) + (destination, false, false) } -fn parse_milled_this_way_destination(lower: &str) -> Option<(Option, bool)> { +fn parse_milled_this_way_destination(lower: &str) -> Option<(Option, bool, bool)> { let (tail, _) = preceded( take_until::<_, _, OracleError<'_>>("milled this way"), tag::<_, _, OracleError<'_>>("milled this way"), @@ -5817,7 +5833,7 @@ fn parse_milled_this_way_destination(lower: &str) -> Option<(Option, bool) parse_dig_destination_tail(tail) } -fn parse_dig_from_among_destination(lower: &str) -> Option<(Option, bool)> { +fn parse_dig_from_among_destination(lower: &str) -> Option<(Option, bool, bool)> { let (tail, _) = preceded( take_until::<_, _, OracleError<'_>>("from among"), ( @@ -5830,7 +5846,7 @@ fn parse_dig_from_among_destination(lower: &str) -> Option<(Option, bool)> parse_dig_destination_tail(tail) } -fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool)> { +fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool, bool)> { // Strip a leading clause separator: "from among them, then put that card on // top ..." (Fertile Thicket) leaves a ", " before the "then put" verb. let input = input.trim_start(); @@ -5860,10 +5876,8 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool)> { )) .parse(input) { - let (_, tapped) = opt(tag::<_, _, OracleError<'_>>(" tapped")) - .parse(rest) - .ok()?; - return Some((Some(Zone::Battlefield), tapped.is_some())); + let (tapped, attacking) = super::parse_battlefield_entry_qualifiers(rest); + return Some((Some(Zone::Battlefield), tapped, attacking)); } if alt(( @@ -5875,7 +5889,7 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool)> { .parse(input) .is_ok() { - return Some((Some(Zone::Hand), false)); + return Some((Some(Zone::Hand), false, false)); } // CR 401.4: cards put at a specific library position (top) are arranged by @@ -5891,7 +5905,7 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool)> { .parse(input) .is_ok() { - return Some((Some(Zone::Library), false)); + return Some((Some(Zone::Library), false, false)); } None @@ -9849,6 +9863,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, } } @@ -10240,13 +10255,29 @@ mod tests { parse_dig_kept_destination( "put a land card from among them onto the battlefield tapped. put the rest on the bottom of your library.", ), - (Some(Zone::Battlefield), true) + (Some(Zone::Battlefield), true, false) ); assert_eq!( parse_dig_kept_destination( "put a land card from among them onto the battlefield. put the rest onto the battlefield tapped.", ), - (Some(Zone::Battlefield), false) + (Some(Zone::Battlefield), false, false) + ); + } + + #[test] + fn from_among_battlefield_qualifiers_preserve_attacking() { + assert_eq!( + parse_dig_kept_destination( + "put a human creature card from among them onto the battlefield tapped and attacking. put the rest on the bottom of your library in a random order.", + ), + (Some(Zone::Battlefield), true, true) + ); + assert_eq!( + parse_dig_kept_destination( + "put a creature card from among them onto the battlefield attacking.", + ), + (Some(Zone::Battlefield), false, true) ); } @@ -13078,6 +13109,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, } } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index e58aaa593f..f33f8a81c6 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -442,6 +442,10 @@ pub(crate) enum ContinuationAst { /// from-among put-step. #[serde(default)] enter_tapped: bool, + /// CR 508.4: Kept cards enter attacking when the from-among clause + /// says "onto the battlefield ... attacking". + #[serde(default)] + enters_attacking: bool, /// CR 701.20a vs 701.20e: True when the from-among clause's stripped verb /// was "reveal" (a public action) rather than "put"/"choose" (a private /// look). Promotes the patched Dig to `reveal: true` even when the kept diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 5ee10bc995..d2f5e60f97 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11368,6 +11368,9 @@ pub enum Effect { /// tapped when true (Planar Genesis — "onto the battlefield tapped"). #[serde(default)] enter_tapped: bool, + /// CR 508.4: Kept cards routed to the battlefield enter attacking. + #[serde(default)] + enters_attacking: bool, /// Determines where the resolver reads the card set from. See [`DigSource`]. #[serde(default, skip_serializing_if = "DigSource::is_library")] source: DigSource, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f70fa9b39c..b8ea5f91d4 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4809,6 +4809,8 @@ pub struct PendingBatchZoneMoveRequest { pub cause: PendingBatchZoneChangeCause, #[serde(default, skip_serializing_if = "EtbTapState::is_unspecified")] pub enter_tapped: EtbTapState, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enters_attacking: bool, #[serde(default)] pub enter_transformed: bool, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -5085,6 +5087,8 @@ pub enum BatchCompletion { selected: Vec, destination: Zone, enter_tapped: EtbTapState, + #[serde(default)] + enters_attacking: bool, }, /// CR 608.2c + CR 616.1: Every selected card of a deterministic mass Dig /// has settled. Publish only cards that actually reached `destination`, then @@ -5502,6 +5506,10 @@ pub enum PendingCounterPostAction { source_id: Option, duration: Option, exile_tracking: ZoneDeliveryExileTracking, + /// CR 508.4: The completed battlefield entry joins combat after any + /// as-enters replacement choice has settled. + #[serde(default)] + enters_attacking: bool, /// Who drains `post_replacement_continuation` when this deferred tail /// finally runs (CR 614.12a). `#[serde(default)]` = `DeliveryTail`, /// matching every record minted before the field existed. @@ -10268,6 +10276,10 @@ pub enum WaitingFor { /// dig are tapped. #[serde(default)] enter_tapped: bool, + /// CR 508.4: Kept cards entering the battlefield via this dig enter + /// attacking rather than being declared as attackers. + #[serde(default)] + enters_attacking: bool, }, SurveilChoice { player: PlayerId, diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index 2da2c23299..c90c1dcb0c 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -103,6 +103,7 @@ fn dig_rest_pile_library_redirect_pauses_before_tracked_set_publish() { rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -233,6 +234,7 @@ fn dig_mass_put_all_nonbattlefield_redirect_publishes_only_delivered_set() { rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -392,6 +394,7 @@ fn uninterrupted_dig_rest_and_mass_put_all_complete_synchronously() { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -449,6 +452,7 @@ fn uninterrupted_dig_rest_and_mass_put_all_complete_synchronously() { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -534,6 +538,7 @@ fn dig_deferred_reveal_rest_pile_repauses_and_completes_once() { rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -8694,6 +8699,7 @@ fn dig_kept_nonbattlefield_redirect_pauses_before_tail() { rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -8835,6 +8841,7 @@ fn r2_effect_zone_moves_stay_synchronous_without_redirects() { rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], diff --git a/crates/engine/tests/integration/dig_impossible_keep_count.rs b/crates/engine/tests/integration/dig_impossible_keep_count.rs index fbac257cc0..06df31dcf0 100644 --- a/crates/engine/tests/integration/dig_impossible_keep_count.rs +++ b/crates/engine/tests/integration/dig_impossible_keep_count.rs @@ -50,6 +50,7 @@ fn filtered_dig_runner() -> (engine::game::scenario::GameRunner, Vec) rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }; (runner, looked_at) } diff --git a/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs b/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs index e01235b5e7..7883dd6163 100644 --- a/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs +++ b/crates/engine/tests/integration/dig_rest_pile_stranding_on_etb_pause.rs @@ -67,6 +67,7 @@ fn dig_rest_pile_not_stranded_when_kept_aura_pauses_on_attachment_choice() { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }; runner diff --git a/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs new file mode 100644 index 0000000000..4b5e9f098b --- /dev/null +++ b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs @@ -0,0 +1,97 @@ +//! Regression for GitHub issue #4232 — Winota's selected Human must enter +//! tapped and attacking from the attack-trigger Dig. + +use engine::game::combat::AttackTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; + +const WINOTA_ORACLE: &str = "Whenever a non-Human creature you control attacks, look at the top six cards of your library. You may put a Human creature card from among them onto the battlefield tapped and attacking. It gains indestructible until end of turn. Put the rest of the cards on the bottom of your library in a random order."; + +fn advance_to_dig_choice(runner: &mut GameRunner) { + for _ in 0..40 { + match runner.state().waiting_for.clone() { + WaitingFor::DigChoice { .. } => return, + WaitingFor::OrderTriggers { triggers, .. } => runner + .act(GameAction::OrderTriggers { + order: (0..triggers.len()).collect(), + }) + .expect("order Winota's trigger"), + WaitingFor::Priority { .. } => runner + .act(GameAction::PassPriority) + .expect("pass priority toward Winota's DigChoice"), + other => panic!("unexpected state before Winota's DigChoice: {other:?}"), + } + } + panic!("Winota's attack trigger never reached DigChoice"); +} + +/// CR 508.4 + CR 506.3: a creature that enters attacking joins the current +/// combat tapped, attacking the same defending player as the triggering attack. +#[test] +fn winota_puts_selected_human_onto_battlefield_tapped_and_attacking() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let human = scenario.add_card_to_library_top(P0, "Winota Human"); + for name in ["Filler 1", "Filler 2", "Filler 3", "Filler 4", "Filler 5"] { + scenario.add_card_to_library_top(P0, name); + } + let _winota = scenario + .add_creature_from_oracle(P0, "Winota, Joiner of Forces", 4, 4, WINOTA_ORACLE) + .with_subtypes(vec!["Human", "Warrior"]) + .id(); + let attacker = scenario + .add_creature(P0, "Non-Human Attacker", 2, 2) + .with_subtypes(vec!["Goblin"]) + .id(); + + let mut runner = scenario.build(); + { + let human_card = runner.state_mut().objects.get_mut(&human).unwrap(); + human_card.card_types.core_types = vec![CoreType::Creature]; + human_card.card_types.subtypes = vec!["Human".to_string()]; + human_card.base_card_types = human_card.card_types.clone(); + } + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("declare the non-Human attacker"); + advance_to_dig_choice(&mut runner); + + let WaitingFor::DigChoice { + cards, + selectable_cards, + .. + } = runner.state().waiting_for.clone() + else { + unreachable!("advance_to_dig_choice returns only at DigChoice"); + }; + assert!(cards.contains(&human), "Winota must look at the Human card"); + assert!( + selectable_cards.contains(&human), + "the Human creature card must be selectable from Winota's Dig" + ); + + runner + .act(GameAction::SelectCards { cards: vec![human] }) + .expect("put the selected Human onto the battlefield"); + runner.advance_until_stack_empty(); + + let human_object = runner.state().objects.get(&human).expect("Human object"); + assert!(human_object.tapped, "the selected Human must enter tapped"); + let combat = runner + .state() + .combat + .as_ref() + .expect("combat remains active"); + let human_attack = combat + .attackers + .iter() + .find(|attacker_info| attacker_info.object_id == human) + .expect("the selected Human must enter attacking"); + assert_eq!(human_attack.defending_player, P1); +} diff --git a/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs b/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs index dac024bb93..7bd58aa890 100644 --- a/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs +++ b/crates/engine/tests/integration/issue_5996_planetarium_look_cast.rs @@ -508,6 +508,7 @@ fn missing_look_referent_does_not_play_inherited_unrelated_object() { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![TargetRef::Object(unrelated)], diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index ebc71117c4..65da56d435 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -537,6 +537,7 @@ mod issue_4050_adamaro_extremum_hand_size; mod issue_4124_second_little_pig; mod issue_4220_agatha_soul_cauldron; mod issue_4226_elenda_azor_attack_pay_x; +mod issue_4232_winota_enters_attacking; mod issue_4235_cloak_and_dagger_entwined; mod issue_4239_nissa_steward_x_loyalty; mod issue_4240_damaged_player_anaphor_runtime; diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index efc27a53be..1f3b869d54 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -619,6 +619,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }; runner From 5f21cabfdddaafb5f83c78bc2df8250d6cacc022 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 02:42:23 -0700 Subject: [PATCH 02/16] fix(engine): carry dig attack entry through replacements --- crates/engine/src/game/ability_rw.rs | 1 + crates/engine/src/game/ability_scan.rs | 1 + crates/engine/src/game/effects/discard.rs | 1 + crates/engine/src/game/engine_debug.rs | 1 + crates/engine/src/game/engine_replacement.rs | 5 +++++ crates/engine/src/game/replacement.rs | 6 ++++++ crates/engine/src/parser/oracle_effect/sequence.rs | 2 ++ crates/engine/src/types/proposed_event.rs | 6 ++++++ crates/engine/tests/integration/integration_bending.rs | 2 ++ 9 files changed, 25 insertions(+) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 53a2892fce..11383ce1a9 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -4686,6 +4686,7 @@ fn rw_effect( rest_order: _, reveal: _, enter_tapped: _, + enters_attacking: _, source: _, } => { let mut p = ext_write(StateKind::SetMembership); diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 5137dd032b..3a32bab3d8 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -712,6 +712,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { rest_order: _, reveal: _, enter_tapped: _, + enters_attacking: _, source: _, keep_count_expr, } => { diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 89dfabcb77..92bc64cb9c 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -67,6 +67,7 @@ pub(crate) fn complete_discard_to_graveyard( cause: source_id, attach_to: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index a44e187059..511f867bb2 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -783,6 +783,7 @@ pub fn route_debug_create_to_battlefield( cause: None, attach_to: None, enter_tapped: Default::default(), + enters_attacking: false, enter_with_counters: vec![], controller_override: None, enter_transformed: false, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 2c342c0dae..7319472ce9 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -4197,6 +4197,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -6115,6 +6116,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -6320,6 +6322,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -6441,6 +6444,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -6897,6 +6901,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: crate::types::proposed_event::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 3b14c8b5e5..7e515871b6 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1671,6 +1671,7 @@ fn discard_applier( cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -11153,6 +11154,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -13372,6 +13374,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -14473,6 +14476,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -17624,6 +17628,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, @@ -17670,6 +17675,7 @@ mod tests { cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 93a12eccaa..8b2b3340c5 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -6844,6 +6844,7 @@ pub(super) fn parse_followup_continuation_ast( enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, // "put one of those cards onto the battlefield" — a put, not a reveal. reveal_verb: false, }) @@ -6866,6 +6867,7 @@ pub(super) fn parse_followup_continuation_ast( enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, // "put one ... back on top" — a put, not a reveal. reveal_verb: false, }) diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 83ed4aa728..26015a9882 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -391,6 +391,11 @@ pub enum ProposedEvent { /// `Unspecified` preserves any non-replacement tapped seed from the originating effect. #[serde(default)] enter_tapped: EtbTapState, + /// CR 508.4: Whether this permanent enters the battlefield attacking. + /// Carried through the replacement pipeline because an ETB-counter or + /// replacement-ordering pause resumes from the approved ZoneChange. + #[serde(default)] + enters_attacking: bool, /// Counters to place on this permanent as it enters the battlefield. /// Each entry is (counter_type, count). Set by ETB-counter replacements. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -759,6 +764,7 @@ impl ProposedEvent { cause, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, diff --git a/crates/engine/tests/integration/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index 8918d24976..f0cb4abffd 100644 --- a/crates/engine/tests/integration/integration_bending.rs +++ b/crates/engine/tests/integration/integration_bending.rs @@ -1940,6 +1940,7 @@ fn earthbend_return_skips_shock_land_pay_life_prompt() { cause: None, attach_to: None, enter_tapped: EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: Some(P0), enter_transformed: false, @@ -2017,6 +2018,7 @@ fn plain_shock_land_etb_still_prompts_for_life_payment() { cause: None, attach_to: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: Vec::new(), controller_override: None, enter_transformed: false, From 07eb37c95931acff0ac9b0ccf57fbaafc3729aea Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 02:55:28 -0700 Subject: [PATCH 03/16] fix(engine): allow complete mass dig entry context --- crates/engine/src/game/effects/dig.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/game/effects/dig.rs b/crates/engine/src/game/effects/dig.rs index 247912eaa0..eabb6cf936 100644 --- a/crates/engine/src/game/effects/dig.rs +++ b/crates/engine/src/game/effects/dig.rs @@ -521,6 +521,7 @@ fn resolve_mass_put_all( /// mass Dig. The typed batch completion is the single authority for publication /// and the parent result, so a replacement pause cannot expose a pre-redirect /// selected set to a chained instruction. +#[allow(clippy::too_many_arguments)] pub(crate) fn move_mass_put_all_selected( state: &mut GameState, player: crate::types::player::PlayerId, From e6d1c2f72ee22af99aa719d364c513eb2e9bd199 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 03:10:56 -0700 Subject: [PATCH 04/16] fix(engine): complete dig attack carrier defaults --- .../integration/issue_4232_winota_enters_attacking.rs | 2 +- crates/mtgish-import/src/convert/action.rs | 11 +++++++++++ crates/phase-ai/src/determinize.rs | 1 + crates/phase-ai/src/features/control.rs | 2 ++ crates/phase-ai/src/features/spellslinger_prowess.rs | 2 ++ crates/phase-ai/src/features/tests/graveyard_types.rs | 2 ++ crates/phase-ai/src/search.rs | 2 ++ 7 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs index 4b5e9f098b..04a7095a52 100644 --- a/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs +++ b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs @@ -23,7 +23,7 @@ fn advance_to_dig_choice(runner: &mut GameRunner) { .act(GameAction::PassPriority) .expect("pass priority toward Winota's DigChoice"), other => panic!("unexpected state before Winota's DigChoice: {other:?}"), - } + }; } panic!("Winota's attack trigger never reached DigChoice"); } diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index 269d9b2df3..45568d5949 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -3242,6 +3242,7 @@ pub fn convert(a: &Action) -> ConvResult { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, @@ -4906,6 +4907,7 @@ fn convert_look_at_top( rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }), @@ -4934,6 +4936,7 @@ fn convert_look_at_top( }, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -4954,6 +4957,7 @@ fn convert_look_at_top( rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -4984,6 +4988,7 @@ fn convert_look_at_top( }, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5003,6 +5008,7 @@ fn convert_look_at_top( rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5065,6 +5071,7 @@ fn convert_reveal_top_dig( rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5081,6 +5088,7 @@ fn convert_reveal_top_dig( rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5097,6 +5105,7 @@ fn convert_reveal_top_dig( rest_order: DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5121,6 +5130,7 @@ fn convert_reveal_top_dig( }, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } @@ -5145,6 +5155,7 @@ fn convert_reveal_top_dig( }, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }) } diff --git a/crates/phase-ai/src/determinize.rs b/crates/phase-ai/src/determinize.rs index dfc376c126..ea6af7554f 100644 --- a/crates/phase-ai/src/determinize.rs +++ b/crates/phase-ai/src/determinize.rs @@ -419,6 +419,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![TargetRef::Player(PlayerId(1))], diff --git a/crates/phase-ai/src/features/control.rs b/crates/phase-ai/src/features/control.rs index c7fd817c87..ef96c951d6 100644 --- a/crates/phase-ai/src/features/control.rs +++ b/crates/phase-ai/src/features/control.rs @@ -529,6 +529,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, )); @@ -558,6 +559,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, )); diff --git a/crates/phase-ai/src/features/spellslinger_prowess.rs b/crates/phase-ai/src/features/spellslinger_prowess.rs index bec579d7cb..7a65abb06b 100644 --- a/crates/phase-ai/src/features/spellslinger_prowess.rs +++ b/crates/phase-ai/src/features/spellslinger_prowess.rs @@ -655,6 +655,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, })); let f = detect(&[entry(c, 4)]); @@ -679,6 +680,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, })); let f = detect(&[entry(c, 4)]); diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs index 7f9411ff20..e6ba756e71 100644 --- a/crates/phase-ai/src/features/tests/graveyard_types.rs +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -141,6 +141,7 @@ fn dig_to_graveyard_enabler(name: &str, in_trigger: bool) -> CardFace { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: true, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }; let mut face = creature(name); @@ -651,6 +652,7 @@ fn dig_without_graveyard_rest_is_not_an_enabler() { rest_order: engine::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, )]; diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 33c02493cb..d15c1bc025 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -11838,6 +11838,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }; let config = create_config(AiDifficulty::VeryHard, Platform::Native); @@ -11963,6 +11964,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, } }); push("SurveilChoice", &|state| WaitingFor::SurveilChoice { From ce78d22a6527477caefcfc994e4f560affec41c2 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 03:16:24 -0700 Subject: [PATCH 05/16] fix(server): preserve dig attack choice state --- crates/server-core/src/session.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index bb76e482a8..58a1132623 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -5269,6 +5269,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, }; // Non-canonical permutation [c, a, b] — not an enumerated candidate. From 949d37c643f2b9e24739d1a0bade7c856c6ee77f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 03:23:23 -0700 Subject: [PATCH 06/16] fix(parser): complete dig attack continuation defaults --- crates/engine/src/parser/oracle_effect/sequence.rs | 11 +++++++++++ crates/engine/src/parser/oracle_effect/tests.rs | 1 + crates/engine/src/types/game_state.rs | 1 + 3 files changed, 13 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 8b2b3340c5..2b0cccbb20 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -10056,6 +10056,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10081,6 +10082,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10111,6 +10113,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10135,6 +10138,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10159,6 +10163,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10185,6 +10190,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }), "{text}" @@ -10459,6 +10465,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10732,6 +10739,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10781,6 +10789,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10848,6 +10857,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -11811,6 +11821,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index f9c726f325..ca7c447852 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -22657,6 +22657,7 @@ fn exiled_cause_publishers_all_stamp_exiled_at_runtime() { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: crate::types::ability::DigSource::default(), }, Effect::ExileHaunting { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index b8ea5f91d4..a9afa631a1 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -27434,6 +27434,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, })); variants.push(Box::new(WaitingFor::SurveilChoice { player: PlayerId(0), From bd91394a8f3a7c61e2480524c580815721db6f81 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 03:49:51 -0700 Subject: [PATCH 07/16] fix(parser): scope dig entry qualifiers --- crates/engine/src/parser/oracle_effect/sequence.rs | 9 +++++++-- ...er__oracle_ir__snapshot_tests__dark_confidant_ir.snap | 3 ++- ...le_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 2b0cccbb20..2a985a27ee 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -1,6 +1,6 @@ use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; -use nom::bytes::complete::{tag, tag_no_case, take_until}; +use nom::bytes::complete::{tag, tag_no_case, take_till, take_until}; use nom::character::complete::multispace1; use nom::combinator::{all_consuming, eof, map, opt, rest, value}; use nom::sequence::{preceded, terminated}; @@ -5876,7 +5876,12 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool, bool)> )) .parse(input) { - let (tapped, attacking) = super::parse_battlefield_entry_qualifiers(rest); + // Qualifiers belong to the kept-card clause. The continuation may + // have a later sentence that describes the rest pile (for example, + // "put the rest ... onto the battlefield tapped"), which must not + // affect the kept-card entry mode. + let (_, qualifier_tail) = take_till(|character| character == '.').parse(rest).ok()?; + let (tapped, attacking) = super::parse_battlefield_entry_qualifiers(qualifier_tail); return Some((Some(Zone::Battlefield), tapped, attacking)); } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap index a34a512c85..434071c8c1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap @@ -91,7 +91,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": true, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "duration": null, "sub_ability": null, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap index ffd51dbcfd..a63d7cfdb1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap @@ -67,7 +67,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "duration": null, "sub_ability": null, From 7a9c8ebf53aaaec532d65df8f311f0756d14b57f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 03:56:49 -0700 Subject: [PATCH 08/16] fix(parser): type dig qualifier combinator --- crates/engine/src/parser/oracle_effect/sequence.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 2a985a27ee..2b0d178b90 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5880,7 +5880,9 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool, bool)> // have a later sentence that describes the rest pile (for example, // "put the rest ... onto the battlefield tapped"), which must not // affect the kept-card entry mode. - let (_, qualifier_tail) = take_till(|character| character == '.').parse(rest).ok()?; + let (_, qualifier_tail) = take_till::<_, _, OracleError<'_>>(|character| character == '.') + .parse(rest) + .ok()?; let (tapped, attacking) = super::parse_battlefield_entry_qualifiers(qualifier_tail); return Some((Some(Zone::Battlefield), tapped, attacking)); } From 7b17c75a9e7ca1158c2e65104c4670e310b3b220 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 04:08:44 -0700 Subject: [PATCH 09/16] test(parser): update dig attack snapshots --- ...cle_ir__snapshot_tests__follow_the_lumarets_ir.snap | 10 +++++++--- ...r__snapshot_tests__follow_the_lumarets_lowered.snap | 6 ++++-- ...snapshot_tests__jace_the_mind_sculptor_lowered.snap | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap index 751ded6b74..b1dc6d4e7c 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap @@ -68,7 +68,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "duration": null, "sub_ability": null, @@ -143,6 +144,7 @@ expression: "&ir" "rest_destination": null, "rest_order": "preserve", "enter_tapped": false, + "enters_attacking": false, "reveal_verb": true } } @@ -238,7 +240,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "cost": null, "sub_ability": null, @@ -305,7 +308,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "duration": null, "sub_ability": null, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap index 68b56e0e5d..1ce9a35103 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_lowered.snap @@ -42,7 +42,8 @@ expression: "&lowered" "rest_destination": "Library", "rest_order": "random", "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "cost": null, "sub_ability": null, @@ -84,7 +85,8 @@ expression: "&lowered" "rest_destination": "Library", "rest_order": "random", "reveal": true, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "cost": null, "sub_ability": null, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_lowered.snap index 7f8df11136..8cf1ec4679 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_lowered.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_lowered.snap @@ -23,7 +23,8 @@ expression: "&lowered" }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "cost": { "type": "Loyalty", From cc91b002239dc129c2e44c3bd242f178e09f4b0a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 04:25:53 -0700 Subject: [PATCH 10/16] test(parser): complete dig attack snapshots --- ...__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap | 3 ++- ...ration__oracle_parser__snapshot_jace_the_mind_sculptor.snap | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap index 895ade8d80..93d9880777 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap @@ -91,7 +91,8 @@ expression: "&ir" }, "rest_destination": null, "reveal": true, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "duration": null, "sub_ability": null, diff --git a/crates/engine/tests/integration/snapshots/integration__oracle_parser__snapshot_jace_the_mind_sculptor.snap b/crates/engine/tests/integration/snapshots/integration__oracle_parser__snapshot_jace_the_mind_sculptor.snap index 9b74104e5e..a04e27bd6f 100644 --- a/crates/engine/tests/integration/snapshots/integration__oracle_parser__snapshot_jace_the_mind_sculptor.snap +++ b/crates/engine/tests/integration/snapshots/integration__oracle_parser__snapshot_jace_the_mind_sculptor.snap @@ -23,7 +23,8 @@ expression: result }, "rest_destination": null, "reveal": false, - "enter_tapped": false + "enter_tapped": false, + "enters_attacking": false }, "cost": { "type": "Loyalty", From 34cdf7afe2fe08f623e751ca11428e7917cd1266 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 05:06:48 -0700 Subject: [PATCH 11/16] fix(combat): choose entry attack target --- .../waiting-for-handler-parity.test.ts | 1 + client/src/adapter/types.ts | 1 + client/src/game/waitingForRegistry.ts | 1 + client/src/pages/GamePage.tsx | 5 +- crates/engine/src/ai_support/candidates.rs | 11 ++- crates/engine/src/game/ability_rw.rs | 1 + crates/engine/src/game/ability_utils.rs | 3 + crates/engine/src/game/combat.rs | 34 +++++++ crates/engine/src/game/effects/change_zone.rs | 95 +++++++++---------- .../src/game/effects/choose_from_zone.rs | 3 + crates/engine/src/game/effects/counters.rs | 15 +-- .../src/game/effects/delayed_trigger.rs | 7 ++ crates/engine/src/game/effects/incubate.rs | 1 + crates/engine/src/game/effects/mod.rs | 37 ++++++-- .../src/game/engine_resolution_choices.rs | 42 +++++--- crates/engine/src/game/interaction.rs | 3 + crates/engine/src/game/scenario.rs | 1 + crates/engine/src/game/triggers.rs | 3 + crates/engine/src/game/zone_pipeline.rs | 13 ++- .../src/parser/oracle_effect/imperative.rs | 10 ++ crates/engine/src/parser/oracle_effect/mod.rs | 3 + .../src/parser/oracle_effect/sequence.rs | 13 ++- .../parser/oracle_effect/snapshot_tests.rs | 1 + .../engine/src/parser/oracle_effect/tests.rs | 6 ++ crates/engine/src/types/ability.rs | 4 + crates/engine/src/types/game_state.rs | 9 ++ .../integration/devour_co_entry_regression.rs | 2 + .../tests/integration/integration_bending.rs | 1 + .../issue_4232_winota_enters_attacking.rs | 22 ++++- .../issue_6498_portent_of_calamity.rs | 1 + .../mechtitan_core_return_exiled.rs | 1 + 31 files changed, 254 insertions(+), 96 deletions(-) diff --git a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts index ba0c7d176e..f783bdaf92 100644 --- a/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts +++ b/client/src/adapter/__tests__/waiting-for-handler-parity.test.ts @@ -30,6 +30,7 @@ describe("WaitingFor handler parity", () => { it("registers both interactive meld waiting states", () => { expect(HANDLED_WAITING_FOR_TYPES.has("MeldPairChoice")).toBe(true); expect(HANDLED_WAITING_FOR_TYPES.has("MeldAttackTargetChoice")).toBe(true); + expect(HANDLED_WAITING_FOR_TYPES.has("EntryAttackTargetChoice")).toBe(true); }); it("every engine WaitingFor variant has a frontend UI handler", () => { diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index bec16e8da5..e70368da07 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1664,6 +1664,7 @@ export type WaitingFor = | { type: "Priority"; data: { player: PlayerId } } | { type: "MeldPairChoice"; data: { player: PlayerId; choices: MeldSelection[] } } | { type: "MeldAttackTargetChoice"; data: { player: PlayerId; context: MeldSelection; valid_targets: AttackTarget[] } } + | { type: "EntryAttackTargetChoice"; data: { player: PlayerId; object_id: ObjectId; valid_targets: AttackTarget[] } } | { type: "ActivationCostOneOfChoice"; data: { player: PlayerId; costs: SerializedAbilityCost[]; pending_cast: PendingCast } } | { type: "MulliganDecision"; diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index e8dfb6b406..7a1bd329ff 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -36,6 +36,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = // CR 701.42 / CR 508.4: meld pair and attacking-entry destination dialogs. "MeldPairChoice", "MeldAttackTargetChoice", + "EntryAttackTargetChoice", // Cast / activation chain — ManaPayment + PhyrexianPayment share ManaPaymentUI. ...MANA_PAYMENT_WAITING_FOR_TYPES, "ManaSourceSelection", diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 7a19ffb6e0..2310d6c8fa 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -3669,7 +3669,10 @@ function MeldChoiceModal() { ); } - if (waitingFor?.type === "MeldAttackTargetChoice") { + if ( + waitingFor?.type === "MeldAttackTargetChoice" || + waitingFor?.type === "EntryAttackTargetChoice" + ) { const targets = waitingFor.data.valid_targets; return ( Vec { player, valid_targets, .. + } + | WaitingFor::EntryAttackTargetChoice { + player, + valid_targets, + .. } => valid_targets .iter() .map(|target| { @@ -840,9 +845,9 @@ pub fn candidate_actions_broad_with_probe( probe: Option<&casting::PriorityCastProbe>, ) -> Vec { let actions = match &state.waiting_for { - WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } => { - candidate_actions_exact(state) - } + WaitingFor::MeldPairChoice { .. } + | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } => candidate_actions_exact(state), WaitingFor::Priority { player } => priority_actions_with_probe(state, *player, probe), WaitingFor::ChooseAnnouncingOpponent { player, candidates, .. diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 11383ce1a9..4bc7653a5d 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -4598,6 +4598,7 @@ fn rw_effect( enter_with_counters, enters_under: _, enter_tapped: _, + enters_attacking: _, face_down_profile: _, library_position: _, random_order: _, diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index a160856922..8c686b9d40 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -8711,6 +8711,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -13340,6 +13341,7 @@ mod tests { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -13378,6 +13380,7 @@ mod tests { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 0e43a938f7..c4dbabdaca 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -648,6 +648,40 @@ pub fn place_attacking_alongside( push_attacker_and_journal(state, object_id, defending_player, attack_target); } +/// CR 508.4a: seat an entering creature against its sole legal defender, or +/// park the controller's required destination choice when several are legal. +/// Returns the chooser only when resolution must pause. +pub fn choose_entry_attack_target_or_enter( + state: &mut GameState, + object_id: ObjectId, + controller: PlayerId, + events: &mut Vec, +) -> Option { + let valid_targets = valid_entry_attack_targets( + state, + controller, + &crate::types::ability::EntryAttackDestination::AnyDefender, + ); + match valid_targets.as_slice() { + [] => None, + [target] => { + if let Some(defending_player) = entry_attack_target_defender(state, controller, *target) + { + place_attacking_alongside(state, object_id, defending_player, *target, events); + } + None + } + _ => { + state.waiting_for = crate::types::game_state::WaitingFor::EntryAttackTargetChoice { + player: controller, + object_id, + valid_targets, + }; + Some(controller) + } + } +} + /// CR 509.1g + CR 506.3e + CR 509.1h: Put a permanent onto the battlefield as a /// blocking creature for `attacker_id`. Used by effects that create or place a /// creature already "blocking that creature" (Mirror Match's copy tokens). diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index cd13fb7374..68a4e58efb 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -812,7 +812,7 @@ pub fn resolve( ability.duration.as_ref(), effect_enter_transformed, eff_tapped, - false, + eff_attacking, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -823,19 +823,6 @@ pub fn resolve( ) { ZoneMoveResult::Done => { state.last_effect_count = Some(1); - if eff_attacking && dest_zone == Zone::Battlefield { - let controller = state - .objects - .get(&chosen) - .map(|obj| obj.controller) - .unwrap_or(ability.controller); - crate::game::combat::enter_attacking( - state, - chosen, - ability.source_id, - controller, - ); - } } ZoneMoveResult::NeedsChoice(player) => { // CR 614.12a: single-pick branch (Random single / single-eligible) @@ -909,7 +896,7 @@ pub fn resolve( ability.duration.as_ref(), effect_enter_transformed, eff_tapped, - false, + eff_attacking, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -920,19 +907,6 @@ pub fn resolve( ) { ZoneMoveResult::Done => { state.last_effect_count = Some(1); - if eff_attacking && dest_zone == Zone::Battlefield { - let controller = state - .objects - .get(&chosen) - .map(|obj| obj.controller) - .unwrap_or(ability.controller); - crate::game::combat::enter_attacking( - state, - chosen, - ability.source_id, - controller, - ); - } } ZoneMoveResult::NeedsChoice(player) => { // CR 614.12a: single-pick branch (Random single / single-eligible) @@ -1520,7 +1494,7 @@ pub(crate) fn process_one_zone_move_with_terminal( ctx.duration.as_ref(), ctx.enter_transformed, eff_tapped, - false, + eff_attacking, ctx.enters_under_player, &ctx.enter_with_counters, ctx.face_down_profile.as_ref(), @@ -1530,20 +1504,6 @@ pub(crate) fn process_one_zone_move_with_terminal( events, ); - if matches!( - result, - crate::game::zone_pipeline::ZoneMoveTerminalResult::Completed(_) - ) { - // CR 508.4: Place on battlefield attacking (not declared as attacker). - if eff_attacking && ctx.destination == Zone::Battlefield { - let controller = state - .objects - .get(&obj_id) - .map(|obj| obj.controller) - .unwrap_or(ctx.controller); - crate::game::combat::enter_attacking(state, obj_id, ctx.source_id, controller); - } - } result } @@ -1638,6 +1598,7 @@ pub fn resolve_all( dest_zone, target_filter, enter_tapped, + enters_attacking, enter_with_counters, effect_library_position, random_order, @@ -1648,6 +1609,7 @@ pub fn resolve_all( target, enters_under: _, enter_tapped, + enters_attacking, enter_with_counters, face_down_profile: _, library_position, @@ -1681,6 +1643,7 @@ pub fn resolve_all( *destination, target.clone(), *enter_tapped, + *enters_attacking, resolved_counters, library_position.clone(), *random_order, @@ -2007,7 +1970,7 @@ pub fn resolve_all( ability.duration.as_ref(), false, enter_tapped, - false, + enters_attacking, enters_under_player, &enter_with_counters, face_down_profile.as_ref(), @@ -2035,6 +1998,10 @@ pub fn resolve_all( } } crate::game::zone_pipeline::ZoneMoveTerminalResult::NeedsChoice(player) => { + let entry_target_choice = matches!( + state.waiting_for, + crate::types::game_state::WaitingFor::EntryAttackTargetChoice { .. } + ); // CR 614.12a + CR 614.13: a Devour as-enters sacrifice surfaced its // own `EffectZoneChoice` (or a counter-pause replacement choice). // Stash the unprocessed co-entering members so @@ -2057,7 +2024,7 @@ pub fn resolve_all( state.push_change_zone_iteration_after_child( crate::types::game_state::PendingChangeZoneIteration { logical_zone_change_group, - paused_current: Some( + paused_current: (!entry_target_choice).then(|| { state .pending_zone_change_delivery_from_replacement() .or_else(|| { @@ -2066,8 +2033,8 @@ pub fn resolve_all( boundary }) }) - .expect("zone-change pause must retain its exact boundary"), - ), + .expect("replacement pause must retain its exact boundary") + }), remaining: matching[i + 1..].to_vec(), source_id: ability.source_id, controller: ability.controller, @@ -2076,14 +2043,14 @@ pub fn resolve_all( enter_transformed: false, enter_tapped, enters_under_player, - enters_attacking: false, + enters_attacking, // CR 122.1h: resumed members of a paused mass return still // receive their counters (Shilgengar's finality counter). enter_with_counters: enter_with_counters.clone(), conditional_enter_with_counters: vec![], duration: ability.duration.clone(), track_exiled_by_source, - moved_count: Some(moved_count), + moved_count: Some(moved_count + i32::from(entry_target_choice)), face_down_profile: face_down_profile.clone(), library_placement: effect_library_position.clone(), // CR 614.12: mass zone moves carry no moved-object type gate. @@ -2132,7 +2099,7 @@ pub fn resolve_all( enter_transformed: false, enter_tapped, enters_under_player, - enters_attacking: false, + enters_attacking, // CR 122.1h: resumed members of a paused mass return still // receive their counters (Shilgengar's finality counter). enter_with_counters: enter_with_counters.clone(), @@ -3088,6 +3055,7 @@ mod tests { target: TargetFilter::None, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -3479,6 +3447,7 @@ mod tests { ])), enters_under: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4487,6 +4456,7 @@ mod tests { target: TargetFilter::None, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4539,6 +4509,7 @@ mod tests { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4630,6 +4601,7 @@ mod tests { target: TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4705,6 +4677,7 @@ mod tests { }), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4770,6 +4743,7 @@ mod tests { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4845,6 +4819,7 @@ mod tests { ), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4879,6 +4854,7 @@ mod tests { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4954,6 +4930,7 @@ mod tests { }), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -5059,6 +5036,7 @@ mod tests { target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -5487,6 +5465,7 @@ mod tests { }), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -5892,6 +5871,7 @@ mod tests { target: TargetFilter::LastRevealed, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: Some(LibraryPosition::Bottom), @@ -5962,6 +5942,7 @@ mod tests { target: TargetFilter::LastRevealed, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: Some(LibraryPosition::Bottom), @@ -7043,6 +7024,7 @@ mod tests { target: TargetFilter::Controller, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7181,6 +7163,7 @@ mod tests { target: TargetFilter::Typed(TypedFilter::creature()), enters_under: Some(ControllerRef::You), enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7234,6 +7217,7 @@ mod tests { target: TargetFilter::Typed(TypedFilter::creature()), enters_under: Some(ControllerRef::Opponent), enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7343,6 +7327,7 @@ mod tests { ), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7443,6 +7428,7 @@ mod tests { ), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7578,6 +7564,7 @@ mod tests { ])), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7786,6 +7773,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7855,6 +7843,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7938,6 +7927,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -8013,6 +8003,7 @@ mod tests { }, enters_under: Some(ControllerRef::You), enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: Some(FaceDownProfile { power: Some(2), @@ -8096,6 +8087,7 @@ mod tests { }, enters_under: None, enter_tapped: EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -8168,6 +8160,7 @@ mod tests { }, enters_under: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -8208,6 +8201,7 @@ mod tests { }, enters_under: Some(ControllerRef::You), enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: Some(FaceDownProfile::vanilla_2_2()), library_position: None, @@ -8833,6 +8827,7 @@ mod tests { target: TargetFilter::Any, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/effects/choose_from_zone.rs b/crates/engine/src/game/effects/choose_from_zone.rs index 3d1b73b962..5000d5b954 100644 --- a/crates/engine/src/game/effects/choose_from_zone.rs +++ b/crates/engine/src/game/effects/choose_from_zone.rs @@ -2759,6 +2759,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -3014,6 +3015,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -3541,6 +3543,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index a92beb4197..8e49f361ff 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -826,15 +826,16 @@ fn apply_pending_counter_post_action( .objects .get(&object_id) .map(|object| object.controller) - .unwrap_or(PlayerId(0)); + .expect("a settled battlefield entrant must exist"); // CR 508.4: an entrant joins combat only after its // replacement-modified entry has fully settled. - crate::game::combat::enter_attacking( - state, - object_id, - cause.or(source_id).unwrap_or(object_id), - controller, - ); + if crate::game::combat::choose_entry_attack_target_or_enter( + state, object_id, controller, events, + ) + .is_some() + { + return false; + } } true } diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 6bcad0e331..0dfbe1c191 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -1047,6 +1047,7 @@ fn bind_tracked_set_to_effect(effect: &mut Effect, real_id: TrackedSetId) { target: bound_target, enters_under: enters_under.clone(), enter_tapped: *enter_tapped, + enters_attacking: false, enter_with_counters: enter_with_counters.clone(), face_down_profile: face_down_profile.clone(), library_position: None, @@ -2138,6 +2139,7 @@ mod tests { target: TargetFilter::Any, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -2194,6 +2196,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -2273,6 +2276,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -2351,6 +2355,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -2425,6 +2430,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -3837,6 +3843,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/effects/incubate.rs b/crates/engine/src/game/effects/incubate.rs index afc7e33441..5aa5d75a9b 100644 --- a/crates/engine/src/game/effects/incubate.rs +++ b/crates/engine/src/game/effects/incubate.rs @@ -216,6 +216,7 @@ mod tests { target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index e0fe2c6291..e76a16c5a8 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -1346,15 +1346,21 @@ fn drain_pending_change_zone_iteration(state: &mut GameState, events: &mut Vec { - let paused_current = state - .pending_zone_change_delivery_from_replacement() - .or_else(|| { - anticipated_pause.map(|mut boundary| { - boundary.append_delivery_events(&events[delivery_start..]); - boundary + let entry_target_choice = matches!( + state.waiting_for, + WaitingFor::EntryAttackTargetChoice { .. } + ); + let paused_current = (!entry_target_choice).then(|| { + state + .pending_zone_change_delivery_from_replacement() + .or_else(|| { + anticipated_pause.map(|mut boundary| { + boundary.append_delivery_events(&events[delivery_start..]); + boundary + }) }) - }) - .expect("zone-change pause must retain its exact boundary"); + .expect("replacement pause must retain its exact boundary") + }); let trigger_events: Vec = events[events_before_drain..] .iter() .filter(|event| !matches!(event, GameEvent::PhaseChanged { .. })) @@ -1369,7 +1375,7 @@ fn drain_pending_change_zone_iteration(state: &mut GameState, events: &mut Vec bool { waiting_for, WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::ScryChoice { .. } | WaitingFor::ArrangePlanarDeckTopChoice { .. } | WaitingFor::RedistributeLifeTotals { .. } @@ -18054,6 +18062,7 @@ mod tests { target: TargetFilter::Any, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -19090,6 +19099,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -19189,6 +19199,7 @@ mod tests { }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -19262,6 +19273,7 @@ mod tests { target: TargetFilter::Typed(crate::types::ability::TypedFilter::creature()), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -21283,6 +21295,7 @@ mod tests { target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -21349,6 +21362,7 @@ mod tests { target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -21429,6 +21443,7 @@ mod tests { target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -28621,6 +28636,7 @@ mod tests { target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -28635,6 +28651,7 @@ mod tests { target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 240bc2d3dc..c97b771acf 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -675,6 +675,7 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { waiting_for, WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::ScryChoice { .. } | WaitingFor::ArrangePlanarDeckTopChoice { .. } | WaitingFor::RedistributeLifeTotals { .. } @@ -1565,6 +1566,33 @@ pub(super) fn handle_resolution_choice( crate::game::meld::finish_meld_attack_choice(state, context, target, events); ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) } + ( + WaitingFor::EntryAttackTargetChoice { + player, + object_id, + valid_targets, + }, + GameAction::ChooseEntryAttackTarget { target }, + ) => { + if !valid_targets.contains(&target) { + return Err(EngineError::InvalidAction( + "entry attack target is not one of the offered destinations".to_string(), + )); + } + state.waiting_for = WaitingFor::Priority { player }; + if let Some(defending_player) = + crate::game::combat::entry_attack_target_defender(state, player, target) + { + crate::game::combat::place_attacking_alongside( + state, + object_id, + defending_player, + target, + events, + ); + } + ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) + } ( WaitingFor::ScryChoice { player, cards }, GameAction::SelectCards { cards: top_cards }, @@ -2002,6 +2030,7 @@ pub(super) fn handle_resolution_choice( source_id, ); req.mods.enter_tapped = enter_tapped; + req.mods.enters_attacking = enters_attacking; match crate::game::zone_pipeline::move_object(state, req, events) { crate::game::zone_pipeline::ZoneMoveResult::Done => {} // CR 303.4f / CR 616.1: the accepted card's battlefield @@ -2033,19 +2062,6 @@ pub(super) fn handle_resolution_choice( )); } } - // CR 508.4: "...tapped and attacking" — place the accepted card - // in combat. `source_id` (the ability source / trigger attacker) - // supplies the defending player, matching the synchronous path. - if enters_attacking { - let controller = state - .objects - .get(&hit_card) - .map(|obj| obj.controller) - .unwrap_or(player); - crate::game::combat::enter_attacking( - state, hit_card, source_id, controller, - ); - } } else { // CR 614.6: a kept card accepted to a non-battlefield zone // (graveyard — Mind Funeral-style "put it into your graveyard" diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 9d9a8d9db2..2209606c5b 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -255,6 +255,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::MulliganDecision { .. } | WaitingFor::AssistChoosePlayer { .. } | WaitingFor::ExertChoice { .. } @@ -481,6 +482,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::ChooseXValue { .. } | WaitingFor::UntapChoice { .. } | WaitingFor::ExertChoice { .. } @@ -3422,6 +3424,7 @@ fn selection_projection( WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::ManaPayment { .. } | WaitingFor::ManaSourceSelection { .. } | WaitingFor::AssistChoosePlayer { .. } diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 1ee37cc937..c2b40851ad 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1743,6 +1743,7 @@ impl GameRunner { WaitingFor::Priority { .. } => "Priority", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", + WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index ca5f8a769b..33869320d2 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -34333,6 +34333,7 @@ pub mod tests { target: TargetFilter::Typed(TypedFilter::default().with_type(TypeFilter::Creature)), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -34674,6 +34675,7 @@ pub mod tests { target: TargetFilter::Typed(TypedFilter::default().with_type(TypeFilter::Creature)), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -34734,6 +34736,7 @@ pub mod tests { target: TargetFilter::Typed(TypedFilter::default().with_type(TypeFilter::Creature)), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 4e68c8ad40..e3a9cd96e9 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -3843,13 +3843,12 @@ pub(crate) fn deliver_replaced_zone_change( .objects .get(&object_id) .map(|object| object.controller) - .unwrap_or(PlayerId(0)); - crate::game::combat::enter_attacking( - state, - object_id, - cause.or(source_id).unwrap_or(object_id), - controller, - ); + .expect("a settled battlefield entrant must exist"); + if let Some(player) = crate::game::combat::choose_entry_attack_target_or_enter( + state, object_id, controller, events, + ) { + return ZoneDeliveryResult::NeedsChoice(player); + } } return result; } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 6fd9cbad59..46fde3e3c5 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -4719,6 +4719,7 @@ pub(super) fn parse_for_each_player_exile_controlled( }, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7068,6 +7069,7 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect { target, enters_under: enters_under.as_controller_ref(), enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position, @@ -7140,6 +7142,7 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect { // for them — identical to the prior hardcoded default. enters_under, enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -7227,6 +7230,7 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect { target: TargetFilter::Controller, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: Some(position), @@ -7844,6 +7848,7 @@ pub(super) fn lower_shuffle_ast(ast: ShuffleImperativeAst) -> ParsedEffectClause target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -8155,6 +8160,7 @@ fn change_zone_all_to_library_effect(origin: Zone) -> Effect { target: TargetFilter::Controller, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -12294,6 +12300,7 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff target, enters_under, enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, // CR 401.4: `library_position` is the PRIMARY move's own @@ -12308,6 +12315,7 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff target: rest_target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, // CR 401.4: the "rest" pile's bottom/top position and randomness. @@ -13501,6 +13509,7 @@ pub(super) fn lower_zone_counter_ast(ast: ZoneCounterImperativeAst) -> Effect { target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -16621,6 +16630,7 @@ mod tests { target: TargetFilter::Or { filters }, enters_under: None, enter_tapped, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 6e64f1b15c..094c63c33a 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -5044,6 +5044,7 @@ fn try_parse_airbend_clause(tp: TextPair<'_>) -> Option { target: mass_target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -29802,6 +29803,7 @@ fn parse_return_target_and_same_name_from_your_graveyard_ir( ])), enters_under: None, enter_tapped, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -34143,6 +34145,7 @@ fn try_parse_put_zone_change_parts( enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool( enter_tapped, ), + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position, diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 2b0d178b90..4afd96c86c 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -4150,6 +4150,7 @@ pub(super) fn apply_clause_continuation( target: TargetFilter::LastRevealed, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -4195,6 +4196,7 @@ pub(super) fn apply_clause_continuation( target: TargetFilter::LastRevealed, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position, @@ -4478,6 +4480,7 @@ pub(super) fn apply_clause_continuation( enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool( enter_tapped, ), + enters_attacking, enter_with_counters: vec![], face_down_profile, library_position: None, @@ -4514,7 +4517,7 @@ pub(super) fn apply_clause_continuation( enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool( enter_tapped, ), - enters_attacking: false, + enters_attacking, up_to: is_up_to, enter_with_counters: vec![], conditional_enter_with_counters: vec![], @@ -4807,6 +4810,14 @@ pub(super) fn apply_clause_continuation( // applying them unconditionally. *enters_modified_if = moved_filter; } + Effect::ChangeZoneAll { + enters_attacking, + enter_tapped, + .. + } => { + *enters_attacking = true; + *enter_tapped = crate::types::zones::EtbTapState::Tapped; + } Effect::Dig { enters_attacking, enter_tapped, diff --git a/crates/engine/src/parser/oracle_effect/snapshot_tests.rs b/crates/engine/src/parser/oracle_effect/snapshot_tests.rs index bcfd0610df..566da19b1d 100644 --- a/crates/engine/src/parser/oracle_effect/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_effect/snapshot_tests.rs @@ -484,6 +484,7 @@ fn return_target_and_same_name_from_your_graveyard_carries_zone_and_mass_tail() target, enters_under, enter_tapped, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index ca7c447852..d99a00e2f0 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -8229,6 +8229,7 @@ fn effect_exile_target_player_graveyard_is_change_zone_all() { target: TargetFilter::Player, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, @@ -8428,6 +8429,7 @@ fn effect_put_exiled_with_this_artifact_into_graveyard() { target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, @@ -12125,6 +12127,7 @@ fn all_player_hand_shuffle_normalizer_requires_an_immediate_defaulted_pair() { target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -12953,6 +12956,7 @@ fn compound_shuffle_hand_and_graveyard_into_library() { target: TargetFilter::Controller, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, @@ -12972,6 +12976,7 @@ fn compound_shuffle_hand_and_graveyard_into_library() { target: TargetFilter::Controller, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: _, face_down_profile: None, library_position: None, @@ -22549,6 +22554,7 @@ fn exiled_cause_publishers_all_stamp_exiled_at_runtime() { target: TargetFilter::Any, enters_under: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index d2f5e60f97..caf3fd59cb 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11294,6 +11294,10 @@ pub enum Effect { skip_serializing_if = "EtbTapState::is_unspecified" )] enter_tapped: EtbTapState, + /// CR 508.4: Creatures enter combat during the mass move without being + /// declared as attackers. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + enters_attacking: bool, /// CR 122.1 + CR 122.1h: Counters placed on each object as it enters the /// battlefield during the mass move. Each entry is `(counter_type, /// count)`. Mirrors `Effect::ChangeZone.enter_with_counters` for the mass diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index a9afa631a1..504ced0a91 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9849,6 +9849,13 @@ pub enum WaitingFor { context: MeldSelection, valid_targets: Vec, }, + /// CR 508.4a: choose a defending player, planeswalker, or battle for a + /// creature that entered the battlefield attacking during resolution. + EntryAttackTargetChoice { + player: PlayerId, + object_id: ObjectId, + valid_targets: Vec, + }, /// CR 103.5 + 103.5b: London mulligan — each un-kept player decides /// simultaneously. The `pending` list holds every player who has not yet /// finished the flow, each with their current mulligan count and a @@ -12103,6 +12110,7 @@ impl WaitingFor { WaitingFor::Priority { .. } => "Priority", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", + WaitingFor::EntryAttackTargetChoice { .. } => "EntryAttackTargetChoice", WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", @@ -12256,6 +12264,7 @@ impl WaitingFor { WaitingFor::Priority { player } | WaitingFor::MeldPairChoice { player, .. } | WaitingFor::MeldAttackTargetChoice { player, .. } + | WaitingFor::EntryAttackTargetChoice { player, .. } | WaitingFor::ManaPayment { player, .. } | WaitingFor::ManaSourceSelection { player, .. } | WaitingFor::ChooseXValue { player, .. } diff --git a/crates/engine/tests/integration/devour_co_entry_regression.rs b/crates/engine/tests/integration/devour_co_entry_regression.rs index aa3724eaa3..a06f90b06d 100644 --- a/crates/engine/tests/integration/devour_co_entry_regression.rs +++ b/crates/engine/tests/integration/devour_co_entry_regression.rs @@ -132,6 +132,7 @@ fn devour_cannot_eat_simultaneous_co_arrival() { target: TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)), enters_under: None, enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, @@ -213,6 +214,7 @@ fn two_devourers_cannot_eat_each_other() { target: TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)), enters_under: None, enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/tests/integration/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index f0cb4abffd..df8e086f15 100644 --- a/crates/engine/tests/integration/integration_bending.rs +++ b/crates/engine/tests/integration/integration_bending.rs @@ -2476,6 +2476,7 @@ fn earthbended_land_returns_tapped_after_exile() { target: TargetFilter::SpecificObject { id: land_id }, enters_under: None, enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs index 04a7095a52..d0b678d28c 100644 --- a/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs +++ b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs @@ -7,6 +7,7 @@ use engine::types::actions::GameAction; use engine::types::card_type::CoreType; use engine::types::game_state::WaitingFor; use engine::types::phase::Phase; +use engine::types::player::PlayerId; const WINOTA_ORACLE: &str = "Whenever a non-Human creature you control attacks, look at the top six cards of your library. You may put a Human creature card from among them onto the battlefield tapped and attacking. It gains indestructible until end of turn. Put the rest of the cards on the bottom of your library in a random order."; @@ -28,11 +29,12 @@ fn advance_to_dig_choice(runner: &mut GameRunner) { panic!("Winota's attack trigger never reached DigChoice"); } -/// CR 508.4 + CR 506.3: a creature that enters attacking joins the current -/// combat tapped, attacking the same defending player as the triggering attack. +/// CR 508.4 + CR 506.3: the controller chooses a legal defender for a creature +/// that enters attacking, independent of the trigger's original attack target. #[test] fn winota_puts_selected_human_onto_battlefield_tapped_and_attacking() { - let mut scenario = GameScenario::new(); + let p2 = PlayerId(2); + let mut scenario = GameScenario::new_n_player(3, 42); scenario.at_phase(Phase::PreCombatMain); let human = scenario.add_card_to_library_top(P0, "Winota Human"); @@ -79,6 +81,18 @@ fn winota_puts_selected_human_onto_battlefield_tapped_and_attacking() { runner .act(GameAction::SelectCards { cards: vec![human] }) .expect("put the selected Human onto the battlefield"); + let WaitingFor::EntryAttackTargetChoice { valid_targets, .. } = + runner.state().waiting_for.clone() + else { + panic!("Winota's Human must choose among multiple defenders"); + }; + assert!(valid_targets.contains(&AttackTarget::Player(P1))); + assert!(valid_targets.contains(&AttackTarget::Player(p2))); + runner + .act(GameAction::ChooseEntryAttackTarget { + target: AttackTarget::Player(p2), + }) + .expect("choose a different legal defender for Winota's Human"); runner.advance_until_stack_empty(); let human_object = runner.state().objects.get(&human).expect("Human object"); @@ -93,5 +107,5 @@ fn winota_puts_selected_human_onto_battlefield_tapped_and_attacking() { .iter() .find(|attacker_info| attacker_info.object_id == human) .expect("the selected Human must enter attacking"); - assert_eq!(human_attack.defending_player, P1); + assert_eq!(human_attack.defending_player, p2); } diff --git a/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs b/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs index beb308c11e..ecfaf6468c 100644 --- a/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs +++ b/crates/engine/tests/integration/issue_6498_portent_of_calamity.rs @@ -331,6 +331,7 @@ fn opponent_library_bottom_order_prompts_owner_and_applies_submitted_order() { target: TargetFilter::LastRevealed, enters_under: None, enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: Some(LibraryPosition::Bottom), diff --git a/crates/engine/tests/integration/mechtitan_core_return_exiled.rs b/crates/engine/tests/integration/mechtitan_core_return_exiled.rs index 429c1dfbc7..b74e3cc05c 100644 --- a/crates/engine/tests/integration/mechtitan_core_return_exiled.rs +++ b/crates/engine/tests/integration/mechtitan_core_return_exiled.rs @@ -58,6 +58,7 @@ fn install_mechtitan_return_trigger(state: &mut GameState, core: ObjectId, token target: TargetFilter::ExiledBySource, enters_under: None, enter_tapped: EtbTapState::Tapped, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, From a2824d3f5da91cf77b09ae279fd4348212914c57 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 05:11:35 -0700 Subject: [PATCH 12/16] fix(combat): complete mass entry attack propagation --- crates/engine/src/game/coverage.rs | 4 ++++ crates/engine/src/game/effects/overload.rs | 1 + crates/engine/src/parser/oracle_effect/imperative.rs | 2 ++ 3 files changed, 7 insertions(+) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index d4a2cb0a9b..fce3ba8abd 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2802,6 +2802,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { target, enters_under, enter_tapped, + enters_attacking, enter_with_counters, face_down_profile, library_position, @@ -2821,6 +2822,9 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { if !matches!(enter_tapped, EtbTapState::Unspecified) { d.push(("enter_tapped".into(), format!("{enter_tapped:?}"))); } + if *enters_attacking { + d.push(("enters_attacking".into(), "true".into())); + } if !enter_with_counters.is_empty() { d.push(( "enter_with_counters".into(), diff --git a/crates/engine/src/game/effects/overload.rs b/crates/engine/src/game/effects/overload.rs index c78ee75d9c..66f2e782c2 100644 --- a/crates/engine/src/game/effects/overload.rs +++ b/crates/engine/src/game/effects/overload.rs @@ -167,6 +167,7 @@ fn transform_effect_in_place(effect: &mut Effect) { target, enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 46fde3e3c5..e1c4710543 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -2462,6 +2462,7 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { target, enters_under: enters_under.as_controller_ref(), enter_tapped: crate::types::zones::EtbTapState::from_legacy_bool(enter_tapped), + enters_attacking: false, // CR 122.1 + CR 122.1h: each returned object enters with these // counters (e.g. a finality counter on Shilgengar's mass return). enter_with_counters, @@ -3470,6 +3471,7 @@ pub(super) fn lower_search_and_creation_ast(ast: SearchCreationImperativeAst) -> ])), enters_under: None, enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, enter_with_counters: vec![], face_down_profile: None, library_position: None, From ac31e01d23b761f7c22d1a026831916c0f36814f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 05:16:25 -0700 Subject: [PATCH 13/16] fix(ai): handle entry attack target choices --- crates/phase-ai/src/decision_kind.rs | 1 + crates/phase-ai/src/projection.rs | 22 +++++++++++++--------- crates/phase-ai/src/search.rs | 3 ++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 82ad9727e2..85f941cbdc 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -76,6 +76,7 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { WaitingFor::ReplacementChoice { .. } | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } | WaitingFor::OrderTriggers { .. } | WaitingFor::CopyTargetChoice { .. } | WaitingFor::ExploreChoice { .. } diff --git a/crates/phase-ai/src/projection.rs b/crates/phase-ai/src/projection.rs index 69a1bf713b..a4d25687b0 100644 --- a/crates/phase-ai/src/projection.rs +++ b/crates/phase-ai/src/projection.rs @@ -434,15 +434,19 @@ fn resolve_choice( pick_empty_blockers(&actions) } - // CR 701.42b / CR 508.4: deterministic projection for the two Meld - // resolution choices. Tactical public play uses the policy/search path; - // projection only needs a stable legal branch. - WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } => actions - .first() - .cloned() - .ok_or_else(|| BailReason::NoLegalAction { - waiting_for: format!("{:?}", state.waiting_for), - })?, + // CR 701.42b / CR 508.4: deterministic projection for Meld and + // battlefield-entry attack-target choices. Tactical public play uses the + // policy/search path; projection only needs a stable legal branch. + WaitingFor::MeldPairChoice { .. } + | WaitingFor::MeldAttackTargetChoice { .. } + | WaitingFor::EntryAttackTargetChoice { .. } => { + actions + .first() + .cloned() + .ok_or_else(|| BailReason::NoLegalAction { + waiting_for: format!("{:?}", state.waiting_for), + })? + } // CR 118.3 + CR 605.3b: ReturnToHand, Behold, and TapCreatures cost // payments project as "first legal payment" (matching the pre-collapse diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index d15c1bc025..15da7f6e85 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1333,7 +1333,8 @@ pub fn fallback_action( source_id: choice.source_id, partner_id: choice.partner_id, }), - WaitingFor::MeldAttackTargetChoice { valid_targets, .. } => valid_targets + WaitingFor::MeldAttackTargetChoice { valid_targets, .. } + | WaitingFor::EntryAttackTargetChoice { valid_targets, .. } => valid_targets .first() .copied() .map(|target| GameAction::ChooseEntryAttackTarget { target }), From f70e2de9221edf19625b252b269ff415f96d5357 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 05:29:30 -0700 Subject: [PATCH 14/16] test(engine): re-pin optional prompt census --- crates/engine/src/game/engine.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 2f3ec0abe6..01f19f68d3 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16108,9 +16108,9 @@ mod stage2_injector_tests { // Current-main port: #7221's typed player-action completion seam and the // contemporaneous upstream changes moved these three producers. Re-derived // in the merged source, still in their named production functions. - "game/effects/mod.rs:6632".to_string(), - "game/effects/mod.rs:6709".to_string(), - "game/effects/mod.rs:9914".to_string(), + "game/effects/mod.rs:6640".to_string(), + "game/effects/mod.rs:6717".to_string(), + "game/effects/mod.rs:9922".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. From e0d2cc85f52d8e0d176147ba1d94e48aa47f25b1 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 05:46:11 -0700 Subject: [PATCH 15/16] fix(combat): preserve untapped entry attacks --- crates/engine/src/game/combat.rs | 15 +++++++++++++-- crates/engine/src/game/effects/counters.rs | 2 +- .../engine/src/game/engine_resolution_choices.rs | 3 +-- crates/engine/src/game/zone_pipeline.rs | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index c4dbabdaca..737e51ae5f 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -486,6 +486,18 @@ pub fn enter_attacking( push_attacker_and_journal(state, object_id, defending_player, attack_target); } +/// CR 508.4: Seat a creature that entered the battlefield attacking against an +/// explicitly chosen legal defender. Unlike Ninjutsu and Sneak, this does not +/// tap the creature: entering attacking alone is not a declaration. +pub fn enter_attacking_at_target( + state: &mut GameState, + object_id: ObjectId, + defending_player: PlayerId, + attack_target: AttackTarget, +) { + push_attacker_and_journal(state, object_id, defending_player, attack_target); +} + /// CR 508.4 + CR 733: seat `object_id` as an attacking creature against an /// already-decided defender and journal the settled pair. /// @@ -655,7 +667,6 @@ pub fn choose_entry_attack_target_or_enter( state: &mut GameState, object_id: ObjectId, controller: PlayerId, - events: &mut Vec, ) -> Option { let valid_targets = valid_entry_attack_targets( state, @@ -667,7 +678,7 @@ pub fn choose_entry_attack_target_or_enter( [target] => { if let Some(defending_player) = entry_attack_target_defender(state, controller, *target) { - place_attacking_alongside(state, object_id, defending_player, *target, events); + enter_attacking_at_target(state, object_id, defending_player, *target); } None } diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 8e49f361ff..7310b30eb2 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -830,7 +830,7 @@ fn apply_pending_counter_post_action( // CR 508.4: an entrant joins combat only after its // replacement-modified entry has fully settled. if crate::game::combat::choose_entry_attack_target_or_enter( - state, object_id, controller, events, + state, object_id, controller, ) .is_some() { diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index c97b771acf..11133a4684 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1583,12 +1583,11 @@ pub(super) fn handle_resolution_choice( if let Some(defending_player) = crate::game::combat::entry_attack_target_defender(state, player, target) { - crate::game::combat::place_attacking_alongside( + crate::game::combat::enter_attacking_at_target( state, object_id, defending_player, target, - events, ); } ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index e3a9cd96e9..755dd6daf9 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -3845,7 +3845,7 @@ pub(crate) fn deliver_replaced_zone_change( .map(|object| object.controller) .expect("a settled battlefield entrant must exist"); if let Some(player) = crate::game::combat::choose_entry_attack_target_or_enter( - state, object_id, controller, events, + state, object_id, controller, ) { return ZoneDeliveryResult::NeedsChoice(player); } From bc3546aa866fb36995f65d9c3037aa0608ee8296 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 06:06:59 -0700 Subject: [PATCH 16/16] docs(combat): correct entry attack rules --- crates/engine/src/game/engine_resolution_choices.rs | 6 ++++++ crates/engine/src/types/game_state.rs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 11133a4684..08d81b18bc 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1557,6 +1557,9 @@ pub(super) fn handle_resolution_choice( }, GameAction::ChooseEntryAttackTarget { target }, ) => { + // CR 508.4: the entering creature's controller chooses one of the + // engine-issued defending players, planeswalkers, or battles. + // `entry_attack_target_defender` applies CR 508.4a if it went stale. if !valid_targets.contains(&target) { return Err(EngineError::InvalidAction( "entry attack target is not one of the offered destinations".to_string(), @@ -1574,6 +1577,9 @@ pub(super) fn handle_resolution_choice( }, GameAction::ChooseEntryAttackTarget { target }, ) => { + // CR 508.4: the entering creature's controller chooses one of the + // engine-issued defending players, planeswalkers, or battles. + // `entry_attack_target_defender` applies CR 508.4a if it went stale. if !valid_targets.contains(&target) { return Err(EngineError::InvalidAction( "entry attack target is not one of the offered destinations".to_string(), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 504ced0a91..2549924e59 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9842,14 +9842,14 @@ pub enum WaitingFor { player: PlayerId, choices: Vec, }, - /// CR 508.4a: choose what the meld result enters attacking. The engine + /// CR 508.4: choose what the meld result enters attacking. The engine /// supplies the complete legal topology; clients only return one member. MeldAttackTargetChoice { player: PlayerId, context: MeldSelection, valid_targets: Vec, }, - /// CR 508.4a: choose a defending player, planeswalker, or battle for a + /// CR 508.4: choose a defending player, planeswalker, or battle for a /// creature that entered the battlefield attacking during resolution. EntryAttackTargetChoice { player: PlayerId,