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/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/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 53a2892fce..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: _, @@ -4686,6 +4687,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/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..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. /// @@ -648,6 +660,39 @@ 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, +) -> 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) + { + enter_attacking_at_target(state, object_id, defending_player, *target); + } + 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/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/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 5116c43f3c..68a4e58efb 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, + eff_attacking, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -822,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) @@ -908,6 +896,7 @@ pub fn resolve( ability.duration.as_ref(), effect_enter_transformed, eff_tapped, + eff_attacking, enters_under_player, &per_obj_enter_counters, face_down_profile.as_ref(), @@ -918,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) @@ -1518,6 +1494,7 @@ pub(crate) fn process_one_zone_move_with_terminal( ctx.duration.as_ref(), ctx.enter_transformed, eff_tapped, + eff_attacking, ctx.enters_under_player, &ctx.enter_with_counters, ctx.face_down_profile.as_ref(), @@ -1527,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 } @@ -1635,6 +1598,7 @@ pub fn resolve_all( dest_zone, target_filter, enter_tapped, + enters_attacking, enter_with_counters, effect_library_position, random_order, @@ -1645,6 +1609,7 @@ pub fn resolve_all( target, enters_under: _, enter_tapped, + enters_attacking, enter_with_counters, face_down_profile: _, library_position, @@ -1678,6 +1643,7 @@ pub fn resolve_all( *destination, target.clone(), *enter_tapped, + *enters_attacking, resolved_counters, library_position.clone(), *random_order, @@ -2004,6 +1970,7 @@ pub fn resolve_all( ability.duration.as_ref(), false, enter_tapped, + enters_attacking, enters_under_player, &enter_with_counters, face_down_profile.as_ref(), @@ -2031,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 @@ -2053,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(|| { @@ -2062,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, @@ -2072,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. @@ -2128,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(), @@ -3084,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, @@ -3475,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, @@ -4398,6 +4371,7 @@ mod tests { None, false, crate::types::zones::EtbTapState::Unspecified, + false, None, &[], None, @@ -4482,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, @@ -4534,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, @@ -4625,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, @@ -4700,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, @@ -4765,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, @@ -4840,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, @@ -4874,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, @@ -4949,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, @@ -5054,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, @@ -5482,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, @@ -5887,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), @@ -5957,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), @@ -7038,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, @@ -7176,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, @@ -7229,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, @@ -7338,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, @@ -7438,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, @@ -7573,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, @@ -7781,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, @@ -7850,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, @@ -7933,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, @@ -8008,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), @@ -8091,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, @@ -8163,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, @@ -8203,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, @@ -8828,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_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/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 be48042372..7310b30eb2 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,25 @@ 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) + .expect("a settled battlefield entrant must exist"); + // 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, + ) + .is_some() + { + return false; + } + } + true + } super::change_zone::ZoneDeliveryResult::NeedsChoice(_) => false, } } 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/dig.rs b/crates/engine/src/game/effects/dig.rs index 26a0708627..eabb6cf936 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, }, ); } @@ -509,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, @@ -516,6 +529,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 +541,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 +596,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -676,6 +692,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 +747,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -784,6 +802,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -855,6 +874,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 +957,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 +1019,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 +1092,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 +1157,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 +1219,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 +1290,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 +1364,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 +1432,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 +1511,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1560,6 +1589,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1866,6 +1896,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1923,6 +1954,7 @@ mod tests { rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, vec![], @@ -1991,6 +2023,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/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/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/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 ccb76c172c..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, @@ -27749,6 +27764,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ) @@ -28384,6 +28400,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, }, ) @@ -28619,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, @@ -28633,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/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/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.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. 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/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index a567985348..08d81b18bc 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 { .. } @@ -1556,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(), @@ -1565,6 +1569,35 @@ 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 }, + ) => { + // 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(), + )); + } + state.waiting_for = WaitingFor::Priority { player }; + if let Some(defending_player) = + crate::game::combat::entry_attack_target_defender(state, player, target) + { + crate::game::combat::enter_attacking_at_target( + state, + object_id, + defending_player, + target, + ); + } + ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) + } ( WaitingFor::ScryChoice { player, cards }, GameAction::SelectCards { cards: top_cards }, @@ -2002,6 +2035,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 +2067,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" @@ -3337,6 +3358,7 @@ pub(super) fn handle_resolution_choice( rest_destination, rest_order, enter_tapped, + enters_attacking, source_id: dig_source_id, .. }, @@ -3543,6 +3565,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 +8054,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 +8063,7 @@ pub(crate) fn run_batch_completion( selected, destination, enter_tapped, + enters_attacking, events, ); crate::game::zone_pipeline::BatchMoveResult::Done @@ -9912,6 +9937,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 +10020,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 +10060,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/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/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/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/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 6021b20a6d..33869320d2 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 @@ -34332,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, @@ -34673,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, @@ -34733,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/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..755dd6daf9 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,19 @@ 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) + .expect("a settled battlefield entrant must exist"); + if let Some(player) = crate::game::combat::choose_entry_attack_target_or_enter( + state, object_id, controller, + ) { + return ZoneDeliveryResult::NeedsChoice(player); + } + } + return result; } ZoneDeliveryResult::Done } @@ -3859,6 +3887,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 +3905,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 +3927,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 +3945,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 +3967,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 +4029,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..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, @@ -3385,6 +3386,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 { @@ -3469,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, @@ -4718,6 +4721,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, @@ -7067,6 +7071,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, @@ -7139,6 +7144,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, @@ -7226,6 +7232,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), @@ -7843,6 +7850,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, @@ -8154,6 +8162,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, @@ -12293,6 +12302,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 @@ -12307,6 +12317,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. @@ -13500,6 +13511,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, @@ -16620,6 +16632,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/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..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, @@ -34336,7 +34339,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..4afd96c86c 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}; @@ -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, @@ -4220,6 +4222,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 +4317,7 @@ pub(super) fn apply_clause_continuation( rest_order: DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::PriorLook, }, ); @@ -4336,6 +4340,7 @@ pub(super) fn apply_clause_continuation( rest_order: continuation_rest_order, reveal: false, enter_tapped, + enters_attacking, source: DigSource::PriorLook, }, ); @@ -4379,6 +4384,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 +4435,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, .. @@ -4473,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, @@ -4509,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![], @@ -4802,6 +4810,22 @@ 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, + .. + } => { + *enters_attacking = true; + *enter_tapped = true; + } Effect::Meld { entry, .. } => { *entry = crate::types::ability::PermanentEntryMode::TappedAndAttacking { destination: crate::types::ability::EntryAttackDestination::AnyDefender, @@ -5492,7 +5516,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 +5633,7 @@ pub(super) fn parse_dig_from_among( enters_under, face_down_profile, enter_tapped, + enters_attacking, reveal_verb, }); } @@ -5713,6 +5738,7 @@ pub(super) fn parse_dig_from_among( enters_under, face_down_profile, enter_tapped, + enters_attacking, reveal_verb, }); } @@ -5776,6 +5802,7 @@ pub(super) fn parse_dig_from_among( enters_under: None, face_down_profile: None, enter_tapped, + enters_attacking, reveal_verb: false, }); } @@ -5784,7 +5811,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 +5831,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 +5844,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 +5857,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 +5887,15 @@ fn parse_dig_destination_tail(input: &str) -> Option<(Option, bool)> { )) .parse(input) { - let (_, tapped) = opt(tag::<_, _, OracleError<'_>>(" tapped")) + // 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::<_, _, OracleError<'_>>(|character| character == '.') .parse(rest) .ok()?; - return Some((Some(Zone::Battlefield), tapped.is_some())); + let (tapped, attacking) = super::parse_battlefield_entry_qualifiers(qualifier_tail); + return Some((Some(Zone::Battlefield), tapped, attacking)); } if alt(( @@ -5875,7 +5907,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 +5923,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 @@ -6830,6 +6862,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, }) @@ -6852,6 +6885,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, }) @@ -9849,6 +9883,7 @@ mod tests { rest_order: crate::types::ability::DigRestOrder::Preserve, reveal: false, enter_tapped: false, + enters_attacking: false, source: DigSource::Library, } } @@ -10039,6 +10074,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10064,6 +10100,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10094,6 +10131,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10118,6 +10156,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10142,6 +10181,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -10168,6 +10208,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }), "{text}" @@ -10240,13 +10281,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) ); } @@ -10426,6 +10483,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10699,6 +10757,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10748,6 +10807,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -10815,6 +10875,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }, AbilityKind::Spell, @@ -11778,6 +11839,7 @@ mod tests { enters_under: None, face_down_profile: None, enter_tapped: false, + enters_attacking: false, reveal_verb: false, }) ); @@ -13078,6 +13140,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_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 f9c726f325..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, @@ -22657,6 +22663,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/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/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/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__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_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, 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", diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 5ee10bc995..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 @@ -11368,6 +11372,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..2549924e59 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. @@ -9834,13 +9842,20 @@ 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.4: 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 @@ -10268,6 +10283,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, @@ -12091,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", @@ -12244,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, .. } @@ -27422,6 +27443,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), 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/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/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/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/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index 8918d24976..df8e086f15 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, @@ -2474,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 new file mode 100644 index 0000000000..d0b678d28c --- /dev/null +++ b/crates/engine/tests/integration/issue_4232_winota_enters_attacking.rs @@ -0,0 +1,111 @@ +//! 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; +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."; + +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: 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 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"); + 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"); + 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"); + 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, p2); +} 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/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/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/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, 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 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", 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/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/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/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 33c02493cb..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 }), @@ -11838,6 +11839,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 +11965,7 @@ mod tests { rest_order: engine::types::ability::DigRestOrder::Preserve, source_id: None, enter_tapped: false, + enters_attacking: false, } }); push("SurveilChoice", &|state| WaitingFor::SurveilChoice { 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.