From 06b18bdd2b865f3a5f026e9b4c770ad8c0501432 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 21:51:22 -0500 Subject: [PATCH 1/8] Fix Palace Jailer monarch-bounded exile --- crates/engine/src/parser/oracle_effect/mod.rs | 91 +++++++ .../engine/src/parser/oracle_effect/tests.rs | 75 ++++++ crates/engine/tests/integration/main.rs | 1 + .../engine/tests/integration/palace_jailer.rs | 246 ++++++++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 crates/engine/tests/integration/palace_jailer.rs diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 6d3e812348..8250991175 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -25080,7 +25080,98 @@ fn parse_imperative_effect(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl parse_imperative_effect_inner(tp, ctx) } +/// CR 603.7 + CR 610.3 + CR 725.1: An event-bounded exile creates the exile +/// immediately and a separate one-shot return effect when an opponent becomes +/// the monarch. The delayed payload keeps the chosen object as `ParentTarget` +/// and requires it to still be in exile when the return resolves. +fn try_parse_exile_until_opponent_becomes_monarch_clause( + tp: TextPair<'_>, + ctx: &mut ParseContext, +) -> Option { + let (_, (body_lower, suffix)) = nom_primitives::split_once_on(tp.lower, " until ").ok()?; + let body = tp.slice(0, body_lower.len()).trim_end(); + let ast = parse_imperative_family_ast(body.original, body.lower, ctx)?; + let mut clause = lower_imperative_family_ast(ast); + if !matches!( + clause.effect, + Effect::ChangeZone { + destination: Zone::Exile, + .. + } + ) { + return None; + } + + let supported_suffix = all_consuming(tag::<_, _, OracleError<'_>>( + "an opponent becomes the monarch", + )) + .parse(suffix) + .is_ok(); + let monarch_suffix = all_consuming(terminated( + take_until::<_, _, OracleError<'_>>(" becomes the monarch"), + tag(" becomes the monarch"), + )) + .parse(suffix) + .is_ok(); + if !supported_suffix { + if monarch_suffix { + return Some(parsed_clause(Effect::unimplemented( + "unsupported_monarch_bounded_exile", + tp.original, + ))); + } + return None; + } + + let return_effect = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Exile), + destination: Zone::Battlefield, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + ); + let delayed_return = AbilityDefinition::new( + AbilityKind::Spell, + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::WhenNextEvent { + trigger: Box::new( + TriggerDefinition::new(TriggerMode::BecomeMonarch).valid_target( + TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::Opponent), + ), + ), + ), + or_trigger: None, + lifetime: DelayedTriggerLifetime::Persistent, + }, + effect: Box::new(return_effect), + uses_tracked_set: false, + }, + ); + if let Some(existing) = clause.sub_ability.as_mut() { + append_to_deepest_sub_ability(existing, Some(Box::new(delayed_return))); + } else { + clause.sub_ability = Some(Box::new(delayed_return)); + } + Some(clause) +} + fn parse_imperative_effect_inner(tp: TextPair, ctx: &mut ParseContext) -> ParsedEffectClause { + if let Some(clause) = try_parse_exile_until_opponent_becomes_monarch_clause(tp, ctx) { + return clause; + } + if let Some(ast) = parse_imperative_family_ast(tp.original, tp.lower, ctx) { return lower_imperative_family_ast(ast); } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 8b9101cab3..aea595428f 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -19018,6 +19018,81 @@ fn delayed_trigger_in_effect_chain() { )); } +/// CR 603.7 + CR 610.3 + CR 725.1: Palace Jailer must exile immediately and +/// retain a persistent delayed return keyed to an opponent becoming monarch. +#[test] +fn palace_jailer_monarch_bounded_exile_preserves_return_provenance() { + let clause = parse_effect_clause( + "exile target creature an opponent controls until an opponent becomes the monarch", + &mut ParseContext::default(), + ); + + assert!(matches!( + clause.effect, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::Typed(_), + .. + } + )); + + let delayed = clause + .sub_ability + .as_deref() + .expect("monarch-bounded exile must install a delayed return"); + let Effect::CreateDelayedTrigger { + condition: + DelayedTriggerCondition::WhenNextEvent { + trigger, + or_trigger: None, + lifetime: DelayedTriggerLifetime::Persistent, + }, + effect: return_effect, + uses_tracked_set: false, + } = delayed.effect.as_ref() + else { + panic!( + "expected a persistent monarch delayed trigger, got {:?}", + delayed.effect + ); + }; + assert_eq!(trigger.mode, TriggerMode::BecomeMonarch); + assert!(matches!( + trigger.valid_target, + Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::Opponent), + .. + })) + )); + + let Effect::ChangeZone { + origin: Some(Zone::Exile), + destination: Zone::Battlefield, + target: TargetFilter::ParentTarget, + .. + } = return_effect.effect.as_ref() + else { + panic!( + "expected an exile-guarded ParentTarget return, got {:?}", + return_effect.effect + ); + }; +} + +#[test] +fn palace_jailer_monarch_bounded_exile_rejects_other_player_scope() { + let clause = parse_effect_clause( + "exile target creature an opponent controls until a player becomes the monarch", + &mut ParseContext::default(), + ); + assert!( + matches!(clause.effect, Effect::Unimplemented { .. }), + "unsupported player-scope variant must remain a strict parser gap: {:?}", + clause.effect + ); +} + #[test] fn effect_emblem_ninjas_get_plus_one() { let e = parse_effect("You get an emblem with \"Ninjas you control get +1/+1.\""); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 026fc41ac2..2911d827f2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -897,6 +897,7 @@ mod overload_no_legal_target; mod oversimplify_per_player_fractal; mod ozolith_leaves_battlefield_counters; mod painters_servant_multi_zone_additive_color; +mod palace_jailer; mod palisade_giant_redirect; mod panther_habit_equipped_prevention_scope; mod pass_priority_structural_legality; diff --git a/crates/engine/tests/integration/palace_jailer.rs b/crates/engine/tests/integration/palace_jailer.rs new file mode 100644 index 0000000000..e2b1172251 --- /dev/null +++ b/crates/engine/tests/integration/palace_jailer.rs @@ -0,0 +1,246 @@ +//! Palace Jailer — the ETB exile must last until an opponent becomes the monarch. +//! +//! The tests use the real card Oracle text and the cast/apply pipeline. The +//! delayed return is source-independent, persistent across cleanup, scoped to +//! any opponent, and guarded so a target that left exile is not moved again. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P2: PlayerId = PlayerId(2); + +const PALACE_JAILER: &str = "When this creature enters, you become the monarch.\n\ +When this creature enters, exile target creature an opponent controls until an opponent becomes the monarch."; + +const OPPONENT_BECOMES_MONARCH: &str = "Target opponent becomes the monarch."; +const CONTROLLER_BECOMES_MONARCH: &str = "You become the monarch."; +const DESTROY_CREATURE: &str = "Destroy target creature."; + +struct Board { + runner: GameRunner, + jailer: ObjectId, + target: ObjectId, + opponent_crown: ObjectId, + controller_crown: ObjectId, + p1_crown: ObjectId, + destroy: ObjectId, +} + +fn board(player_count: u8) -> Board { + let mut scenario = GameScenario::new_n_player(player_count, 42); + scenario.at_phase(Phase::PreCombatMain); + for seat in 0..player_count { + scenario.with_library_top( + PlayerId(seat), + &["Filler 1", "Filler 2", "Filler 3", "Filler 4"], + ); + } + + let jailer = scenario + .add_creature_to_hand_from_oracle(P0, "Palace Jailer", 2, 2, PALACE_JAILER) + .id(); + let target = scenario.add_creature(P1, "Exiled Creature", 2, 2).id(); + let opponent_crown = scenario + .add_spell_to_hand_from_oracle(P0, "Crown Opponent", true, OPPONENT_BECOMES_MONARCH) + .id(); + let controller_crown = scenario + .add_spell_to_hand_from_oracle(P0, "Crown Controller", true, CONTROLLER_BECOMES_MONARCH) + .id(); + let p1_crown = scenario + .add_spell_to_hand_from_oracle(P1, "Crown Yourself", true, CONTROLLER_BECOMES_MONARCH) + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy Jailer", true, DESTROY_CREATURE) + .id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(jailer).target_object(target).resolve(); + assert_eq!( + outcome.zone_of(target), + Zone::Exile, + "reach-guard: Palace Jailer must actually exile the target" + ); + assert_eq!( + outcome.state().monarch, + Some(P0), + "reach-guard: the first ETB trigger must make Palace Jailer's controller monarch" + ); + assert_eq!( + outcome.state().delayed_triggers.len(), + 1, + "reach-guard: the monarch-bounded return must be installed" + ); + + Board { + runner, + jailer, + target, + opponent_crown, + controller_crown, + p1_crown, + destroy, + } +} + +fn pass_to(runner: &mut GameRunner, player: PlayerId) { + for _ in 0..4 { + match runner.state().waiting_for { + WaitingFor::Priority { player: current } if current == player => return, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("passing priority should succeed"); + } + ref waiting => panic!("expected a priority window, got {waiting:?}"), + } + } + panic!("priority did not reach {player:?}"); +} + +fn crown_opponent(runner: &mut GameRunner, spell: ObjectId, opponent: PlayerId) { + pass_to(runner, P0); + let outcome = runner.cast(spell).target_player(opponent).resolve(); + assert_eq!( + outcome.state().monarch, + Some(opponent), + "reach-guard: the opponent crown spell must create a monarch-change event" + ); +} + +/// CR 603.7 + CR 610.3 + CR 725.1: An opponent becoming the monarch returns +/// the exiled creature through the delayed-trigger path. +#[test] +fn opponent_becoming_monarch_returns_exiled_creature() { + let Board { + mut runner, + target, + opponent_crown, + .. + } = board(2); + + crown_opponent(&mut runner, opponent_crown, P1); + + assert_eq!( + runner.state().objects[&target].zone, + Zone::Battlefield, + "the target must return when an opponent becomes monarch" + ); + assert_eq!(runner.state().objects[&target].controller, P1); +} + +/// CR 725.1: The delayed condition is scoped to an opponent, so the controller +/// becoming monarch does not satisfy it. +#[test] +fn controller_becoming_monarch_does_not_return_exiled_creature() { + let Board { + mut runner, + target, + controller_crown, + .. + } = board(2); + + pass_to(&mut runner, P0); + runner.cast(controller_crown).resolve(); + + assert_eq!( + runner.state().objects[&target].zone, + Zone::Exile, + "the target must remain exiled when only the controller becomes monarch" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "the unmatched persistent trigger must remain installed" + ); +} + +/// CR 603.7d + CR 400.7: Once created, the delayed trigger survives its source +/// moving to the graveyard and still returns the target on the matching event. +#[test] +fn palace_jailer_in_graveyard_does_not_change_the_delayed_return() { + let Board { + mut runner, + jailer, + target, + opponent_crown, + destroy, + .. + } = board(2); + + pass_to(&mut runner, P1); + runner.cast(destroy).target_object(jailer).resolve(); + assert_eq!(runner.state().objects[&jailer].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&target].zone, Zone::Exile); + + crown_opponent(&mut runner, opponent_crown, P1); + assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); +} + +/// CR 603.7b + CR 514.2: No "this turn" duration is printed, so the delayed +/// trigger remains through cleanup and fires on a later turn. +#[test] +fn monarch_bounded_return_survives_a_turn_boundary() { + let Board { + mut runner, + target, + p1_crown, + .. + } = board(2); + + runner.advance_to_phase(Phase::End); + runner.auto_advance_to_main_phase(); + assert!( + runner.state().turn_number > 2, + "reach-guard: the scenario must cross a cleanup into a later turn" + ); + assert_eq!(runner.state().objects[&target].zone, Zone::Exile); + assert_eq!(runner.state().delayed_triggers.len(), 1); + + pass_to(&mut runner, P1); + runner.cast(p1_crown).resolve(); + assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); +} + +/// CR 102.2 + CR 725.1: "an opponent" means any opponent, not only the +/// opponent whose creature was selected. P2 becomes monarch while P1's +/// creature is the exiled object. +#[test] +fn any_opponent_becoming_monarch_returns_the_selected_creature() { + let Board { + mut runner, + target, + opponent_crown, + .. + } = board(3); + + crown_opponent(&mut runner, opponent_crown, P2); + assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); +} + +/// CR 603.7c + CR 610.3: If the object leaves exile before the event, the +/// delayed return's expected-origin guard makes it a no-op. +#[test] +fn target_leaving_exile_before_monarch_change_is_not_moved_again() { + let Board { + mut runner, + target, + opponent_crown, + .. + } = board(2); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), target, Zone::Battlefield, &mut events); + assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); + + crown_opponent(&mut runner, opponent_crown, P1); + assert_eq!( + runner.state().objects[&target].zone, + Zone::Battlefield, + "the delayed Exile -> Battlefield move must not re-exile or otherwise move a new object" + ); +} From 7324e8653f941ac5c20124ea35157730b03f1ff7 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 16 Aug 2026 07:52:07 -0500 Subject: [PATCH 2/8] Address Palace Jailer review feedback --- crates/engine/src/parser/oracle_effect/mod.rs | 7 ++++++- crates/engine/tests/integration/palace_jailer.rs | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 8250991175..52132545d2 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -25088,7 +25088,12 @@ fn try_parse_exile_until_opponent_becomes_monarch_clause( tp: TextPair<'_>, ctx: &mut ParseContext, ) -> Option { - let (_, (body_lower, suffix)) = nom_primitives::split_once_on(tp.lower, " until ").ok()?; + let (_, (body_lower, suffix)) = ( + take_until::<_, _, OracleError<'_>>(" until "), + preceded(tag(" until "), rest), + ) + .parse(tp.lower) + .ok()?; let body = tp.slice(0, body_lower.len()).trim_end(); let ast = parse_imperative_family_ast(body.original, body.lower, ctx)?; let mut clause = lower_imperative_family_ast(ast); diff --git a/crates/engine/tests/integration/palace_jailer.rs b/crates/engine/tests/integration/palace_jailer.rs index e2b1172251..2224fac1f4 100644 --- a/crates/engine/tests/integration/palace_jailer.rs +++ b/crates/engine/tests/integration/palace_jailer.rs @@ -20,6 +20,7 @@ When this creature enters, exile target creature an opponent controls until an o const OPPONENT_BECOMES_MONARCH: &str = "Target opponent becomes the monarch."; const CONTROLLER_BECOMES_MONARCH: &str = "You become the monarch."; const DESTROY_CREATURE: &str = "Destroy target creature."; +const RETURN_FROM_EXILE: &str = "Return target card from exile to the battlefield."; struct Board { runner: GameRunner, @@ -29,6 +30,7 @@ struct Board { controller_crown: ObjectId, p1_crown: ObjectId, destroy: ObjectId, + return_from_exile: ObjectId, } fn board(player_count: u8) -> Board { @@ -57,6 +59,9 @@ fn board(player_count: u8) -> Board { let destroy = scenario .add_spell_to_hand_from_oracle(P1, "Destroy Jailer", true, DESTROY_CREATURE) .id(); + let return_from_exile = scenario + .add_spell_to_hand_from_oracle(P1, "Return From Exile", true, RETURN_FROM_EXILE) + .id(); let mut runner = scenario.build(); let outcome = runner.cast(jailer).target_object(target).resolve(); @@ -84,6 +89,7 @@ fn board(player_count: u8) -> Board { controller_crown, p1_crown, destroy, + return_from_exile, } } @@ -230,11 +236,15 @@ fn target_leaving_exile_before_monarch_change_is_not_moved_again() { mut runner, target, opponent_crown, + return_from_exile, .. } = board(2); - let mut events = Vec::new(); - engine::game::zones::move_to_zone(runner.state_mut(), target, Zone::Battlefield, &mut events); + pass_to(&mut runner, P1); + runner + .cast(return_from_exile) + .target_object(target) + .resolve(); assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); crown_opponent(&mut runner, opponent_crown, P1); From 453306e8d80e8f8c6473ec082c91d1d187220b17 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 16 Aug 2026 09:51:32 -0500 Subject: [PATCH 3/8] Model Palace Jailer as an immediate event-bounded return --- crates/engine/src/game/ability_rw.rs | 2 + crates/engine/src/game/ability_scan.rs | 1 + crates/engine/src/game/coverage.rs | 3 + crates/engine/src/game/engine.rs | 62 ++++++++++++------- crates/engine/src/game/zone_pipeline.rs | 8 +++ crates/engine/src/game/zones.rs | 1 + crates/engine/src/parser/oracle_effect/mod.rs | 42 +------------ .../engine/src/parser/oracle_effect/tests.rs | 55 ++++------------ crates/engine/src/types/ability.rs | 4 ++ crates/engine/src/types/game_state.rs | 6 ++ .../engine/tests/integration/palace_jailer.rs | 57 +++++++++++++---- 11 files changed, 121 insertions(+), 120 deletions(-) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index bf91365caa..e0e9efb41f 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2032,6 +2032,7 @@ fn legacy_duration(x: &Duration) -> bool { | Duration::UntilEndOfCombat | Duration::UntilHostLeavesPlay | Duration::UntilSourceExilesAnotherCard + | Duration::UntilOpponentBecomesMonarch | Duration::Permanent | Duration::UntilNextTurnOf { .. } | Duration::UntilEndOfNextTurnOf { .. } @@ -4178,6 +4179,7 @@ fn rw_duration(x: &Duration) -> RwProfile { | Duration::UntilEndOfCombat | Duration::UntilHostLeavesPlay | Duration::UntilSourceExilesAnotherCard + | Duration::UntilOpponentBecomesMonarch | Duration::Permanent => RwProfile::empty(), Duration::UntilNextTurnOf { player, .. } | Duration::UntilEndOfNextTurnOf { player, .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 53f5063b8d..e876a20869 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -3576,6 +3576,7 @@ fn scan_duration(x: &Duration, mode: ScanMode) -> Axes { } Duration::UntilHostLeavesPlay => Axes::NONE, Duration::UntilSourceExilesAnotherCard => Axes::NONE, + Duration::UntilOpponentBecomesMonarch => Axes::NONE, Duration::UntilNextStepOf { player, .. } => { let mut acc = Axes::NONE; acc = acc.or(scan_player_scope(player)); diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 4c736aebae..d0c74ce920 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1201,6 +1201,9 @@ fn fmt_duration(d: &Duration) -> String { } Duration::UntilHostLeavesPlay => "while on battlefield".to_string(), Duration::UntilSourceExilesAnotherCard => "until source exiles another card".to_string(), + Duration::UntilOpponentBecomesMonarch => { + "until an opponent becomes the monarch".to_string() + } Duration::UntilNextStepOf { step, player } => { format!( "until next {} ({})", diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index d3291c12d8..18dd69303b 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -14767,30 +14767,45 @@ pub fn start_game_skip_mulligan(state: &mut GameState) -> ActionResult { } } -/// CR 607.2a + CR 406.6: Check if any exile-return sources have left the battlefield. -/// If so, move the exiled cards back — linked abilities track which cards were exiled by the source. +/// CR 607.2a + CR 406.6 + CR 610.3: Check for event-bounded exile returns. +/// Move linked exiled cards back through the replacement-aware zone pipeline. pub(super) fn check_exile_returns(state: &mut GameState, events: &mut Vec) { let mut to_return: Vec = Vec::new(); for event in events.iter() { - if let GameEvent::ZoneChanged { - object_id, - from: Some(Zone::Battlefield), - .. - } = event - { - // Find exile links where this object was the source and the exile - // effect specified an automatic return when that source leaves. - for link in &state.exile_links { - if link.source_id == *object_id - && matches!( - &link.kind, - crate::types::game_state::ExileLinkKind::UntilSourceLeaves { .. } - ) - { - to_return.push(link.clone()); + match event { + GameEvent::ZoneChanged { + object_id, + from: Some(Zone::Battlefield), + .. + } => { + // Find exile links where this object was the source and the exile + // effect specified an automatic return when that source leaves. + for link in &state.exile_links { + if link.source_id == *object_id + && matches!( + &link.kind, + crate::types::game_state::ExileLinkKind::UntilSourceLeaves { .. } + ) + { + to_return.push(link.clone()); + } + } + } + GameEvent::MonarchChanged { player_id } => { + for link in &state.exile_links { + if let crate::types::game_state::ExileLinkKind::UntilOpponentBecomesMonarch { + controller, + .. + } = &link.kind + { + if super::players::is_opponent(state, *controller, *player_id) { + to_return.push(link.clone()); + } + } } } + _ => {} } } @@ -14823,11 +14838,14 @@ pub(super) fn check_exile_returns(state: &mut GameState, events: &mut Vec *return_zone, + _ => continue, }; - let return_zone = *return_zone; let gi = match groups.iter().position(|(zone, _)| *zone == return_zone) { Some(i) => i, None => { diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index e5fdc56f36..f6b2bdcc18 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -1743,6 +1743,14 @@ pub(crate) fn apply_zone_delivery_tail( Some(Duration::UntilHostLeavesPlay) => { Some(ExileLinkKind::UntilSourceLeaves { return_zone: from }) } + Some(Duration::UntilOpponentBecomesMonarch) => { + state.objects.get(&source_id).map(|source| { + ExileLinkKind::UntilOpponentBecomesMonarch { + return_zone: from, + controller: source.controller, + } + }) + } _ if matches!(exile_tracking, ZoneDeliveryExileTracking::TrackBySource) => { Some(ExileLinkKind::TrackedBySource) } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index d612aac9f3..ad65f3ab13 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -576,6 +576,7 @@ pub(crate) fn apply_zone_exit_cleanup( || matches!( link.kind, crate::types::game_state::ExileLinkKind::UntilSourceLeaves { .. } + | crate::types::game_state::ExileLinkKind::UntilOpponentBecomesMonarch { .. } | crate::types::game_state::ExileLinkKind::Haunt | crate::types::game_state::ExileLinkKind::CraftMaterial ) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index bbe6e459fb..7167609cf3 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -25178,47 +25178,7 @@ fn try_parse_exile_until_opponent_becomes_monarch_clause( return None; } - let return_effect = AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChangeZone { - origin: Some(Zone::Exile), - destination: Zone::Battlefield, - target: TargetFilter::ParentTarget, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: crate::types::zones::EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: vec![], - conditional_enter_with_counters: vec![], - face_down_profile: None, - enters_modified_if: None, - }, - ); - let delayed_return = AbilityDefinition::new( - AbilityKind::Spell, - Effect::CreateDelayedTrigger { - condition: DelayedTriggerCondition::WhenNextEvent { - trigger: Box::new( - TriggerDefinition::new(TriggerMode::BecomeMonarch).valid_target( - TargetFilter::Typed( - TypedFilter::default().controller(ControllerRef::Opponent), - ), - ), - ), - or_trigger: None, - lifetime: DelayedTriggerLifetime::Persistent, - }, - effect: Box::new(return_effect), - uses_tracked_set: false, - }, - ); - if let Some(existing) = clause.sub_ability.as_mut() { - append_to_deepest_sub_ability(existing, Some(Box::new(delayed_return))); - } else { - clause.sub_ability = Some(Box::new(delayed_return)); - } + clause.duration = Some(Duration::UntilOpponentBecomesMonarch); Some(clause) } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 740ab4d72b..e57ace0dce 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -19045,8 +19045,8 @@ fn delayed_trigger_in_effect_chain() { )); } -/// CR 603.7 + CR 610.3 + CR 725.1: Palace Jailer must exile immediately and -/// retain a persistent delayed return keyed to an opponent becoming monarch. +/// CR 610.3 + CR 725.1: Palace Jailer must exile immediately and retain the +/// event-bounded return metadata needed by the immediate exile-link pipeline. #[test] fn palace_jailer_monarch_bounded_exile_preserves_return_provenance() { let clause = parse_effect_clause( @@ -19063,48 +19063,15 @@ fn palace_jailer_monarch_bounded_exile_preserves_return_provenance() { .. } )); - - let delayed = clause - .sub_ability - .as_deref() - .expect("monarch-bounded exile must install a delayed return"); - let Effect::CreateDelayedTrigger { - condition: - DelayedTriggerCondition::WhenNextEvent { - trigger, - or_trigger: None, - lifetime: DelayedTriggerLifetime::Persistent, - }, - effect: return_effect, - uses_tracked_set: false, - } = delayed.effect.as_ref() - else { - panic!( - "expected a persistent monarch delayed trigger, got {:?}", - delayed.effect - ); - }; - assert_eq!(trigger.mode, TriggerMode::BecomeMonarch); - assert!(matches!( - trigger.valid_target, - Some(TargetFilter::Typed(TypedFilter { - controller: Some(ControllerRef::Opponent), - .. - })) - )); - - let Effect::ChangeZone { - origin: Some(Zone::Exile), - destination: Zone::Battlefield, - target: TargetFilter::ParentTarget, - .. - } = return_effect.effect.as_ref() - else { - panic!( - "expected an exile-guarded ParentTarget return, got {:?}", - return_effect.effect - ); - }; + assert_eq!( + clause.duration, + Some(Duration::UntilOpponentBecomesMonarch), + "the event-bounded return must be represented on the immediate exile" + ); + assert!( + clause.sub_ability.is_none(), + "CR 610.3 return must not be a triggered-ability sub-chain" + ); } #[test] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index d7cb3c6ec4..0b3606f99f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3003,6 +3003,9 @@ pub enum Duration { /// source exiles another card. Used by "you may play that card until you /// exile another card with [this object]" source-linked exile grants. UntilSourceExilesAnotherCard, + /// CR 610.3: The exiled object returns to its previous zone immediately + /// after an opponent of the source's controller becomes the monarch. + UntilOpponentBecomesMonarch, Permanent, } @@ -29513,6 +29516,7 @@ mod tests { }, Duration::UntilHostLeavesPlay, Duration::UntilSourceExilesAnotherCard, + Duration::UntilOpponentBecomesMonarch, Duration::Permanent, ]; let json = serde_json::to_string(&durations).unwrap(); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f1e2efbb72..f97d5525f7 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -2058,6 +2058,12 @@ pub struct CounterAddedRecord { pub enum ExileLinkKind { /// CR 610.3a: Return the exiled object when the source leaves the battlefield. UntilSourceLeaves { return_zone: Zone }, + /// CR 610.3: Return the exiled object immediately after an opponent of + /// `controller` becomes the monarch. + UntilOpponentBecomesMonarch { + return_zone: Zone, + controller: PlayerId, + }, /// Track cards "exiled with" a source without creating an automatic return. TrackedBySource, /// CR 702.xxx: Paradigm (Strixhaven) — this exile entry marks the card as a diff --git a/crates/engine/tests/integration/palace_jailer.rs b/crates/engine/tests/integration/palace_jailer.rs index 2224fac1f4..a113da892b 100644 --- a/crates/engine/tests/integration/palace_jailer.rs +++ b/crates/engine/tests/integration/palace_jailer.rs @@ -6,7 +6,7 @@ use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::types::actions::GameAction; -use engine::types::game_state::WaitingFor; +use engine::types::game_state::{ExileLinkKind, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::phase::Phase; use engine::types::player::PlayerId; @@ -77,8 +77,22 @@ fn board(player_count: u8) -> Board { ); assert_eq!( outcome.state().delayed_triggers.len(), - 1, - "reach-guard: the monarch-bounded return must be installed" + 0, + "reach-guard: the event-bounded return must not use a delayed trigger" + ); + assert!( + outcome.state().exile_links.iter().any(|link| { + link.exiled_id == target + && link.source_id == jailer + && matches!( + link.kind, + ExileLinkKind::UntilOpponentBecomesMonarch { + return_zone: Zone::Battlefield, + controller: P0, + } + ) + }), + "reach-guard: the immediate exile must install a monarch-bounded link" ); Board { @@ -108,14 +122,31 @@ fn pass_to(runner: &mut GameRunner, player: PlayerId) { panic!("priority did not reach {player:?}"); } -fn crown_opponent(runner: &mut GameRunner, spell: ObjectId, opponent: PlayerId) { +fn crown_opponent(runner: &mut GameRunner, spell: ObjectId, opponent: PlayerId, target: ObjectId) { pass_to(runner, P0); - let outcome = runner.cast(spell).target_player(opponent).resolve(); + let mut committed = runner.cast(spell).target_player(opponent).commit(); + for _ in 0..4 { + if committed.state().monarch == Some(opponent) { + break; + } + committed + .act(GameAction::PassPriority) + .expect("each player must be able to pass priority"); + } assert_eq!( - outcome.state().monarch, + committed.state().monarch, Some(opponent), "reach-guard: the opponent crown spell must create a monarch-change event" ); + assert_eq!( + committed.state().objects[&target].zone, + Zone::Battlefield, + "CR 610.3: the event-bounded return must complete in the same action pipeline" + ); + assert!( + committed.state().stack.is_empty(), + "CR 610.3: the return must not wait behind a triggered-ability stack boundary" + ); } /// CR 603.7 + CR 610.3 + CR 725.1: An opponent becoming the monarch returns @@ -129,7 +160,7 @@ fn opponent_becoming_monarch_returns_exiled_creature() { .. } = board(2); - crown_opponent(&mut runner, opponent_crown, P1); + crown_opponent(&mut runner, opponent_crown, P1, target); assert_eq!( runner.state().objects[&target].zone, @@ -159,9 +190,9 @@ fn controller_becoming_monarch_does_not_return_exiled_creature() { "the target must remain exiled when only the controller becomes monarch" ); assert_eq!( - runner.state().delayed_triggers.len(), + runner.state().exile_links.len(), 1, - "the unmatched persistent trigger must remain installed" + "the unmatched monarch-bounded exile link must remain installed" ); } @@ -183,7 +214,7 @@ fn palace_jailer_in_graveyard_does_not_change_the_delayed_return() { assert_eq!(runner.state().objects[&jailer].zone, Zone::Graveyard); assert_eq!(runner.state().objects[&target].zone, Zone::Exile); - crown_opponent(&mut runner, opponent_crown, P1); + crown_opponent(&mut runner, opponent_crown, P1, target); assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); } @@ -205,7 +236,7 @@ fn monarch_bounded_return_survives_a_turn_boundary() { "reach-guard: the scenario must cross a cleanup into a later turn" ); assert_eq!(runner.state().objects[&target].zone, Zone::Exile); - assert_eq!(runner.state().delayed_triggers.len(), 1); + assert_eq!(runner.state().exile_links.len(), 1); pass_to(&mut runner, P1); runner.cast(p1_crown).resolve(); @@ -224,7 +255,7 @@ fn any_opponent_becoming_monarch_returns_the_selected_creature() { .. } = board(3); - crown_opponent(&mut runner, opponent_crown, P2); + crown_opponent(&mut runner, opponent_crown, P2, target); assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); } @@ -247,7 +278,7 @@ fn target_leaving_exile_before_monarch_change_is_not_moved_again() { .resolve(); assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); - crown_opponent(&mut runner, opponent_crown, P1); + crown_opponent(&mut runner, opponent_crown, P1, target); assert_eq!( runner.state().objects[&target].zone, Zone::Battlefield, From 63834589f33a0929561a2af695b8f5344e914723 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 16 Aug 2026 12:07:54 -0500 Subject: [PATCH 4/8] Fix Palace Jailer controller snapshot contract --- client/src/adapter/types.ts | 1 + client/src/network/__tests__/protocol.test.ts | 26 ++++ crates/engine/src/game/effects/change_zone.rs | 12 +- crates/engine/src/game/effects/counters.rs | 2 + crates/engine/src/game/effects/discard.rs | 3 + crates/engine/src/game/elimination.rs | 14 +++ crates/engine/src/game/engine_debug.rs | 1 + crates/engine/src/game/engine_replacement.rs | 24 +++- crates/engine/src/game/mana_abilities.rs | 2 + crates/engine/src/game/replacement.rs | 16 +++ crates/engine/src/game/sba.rs | 4 + crates/engine/src/game/triggers.rs | 1 + crates/engine/src/game/zone_pipeline.rs | 117 +++++++++++++++++- crates/engine/src/types/game_state.rs | 10 ++ crates/engine/src/types/resolution.rs | 2 + .../tests/integration/cost_zone_pipeline.rs | 4 + .../engine/tests/integration/palace_jailer.rs | 88 ++++++++++++- 17 files changed, 312 insertions(+), 15 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 8495461144..604071b666 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -3190,6 +3190,7 @@ export type ExileLinkKind = | "HideawayLookable" | "CraftMaterial" | { UntilSourceLeaves: { return_zone: Zone } } + | { UntilOpponentBecomesMonarch: { return_zone: Zone; controller: PlayerId } } | { ParadigmSource: { player: PlayerId } }; export interface GameState { diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 58104761bf..6dbfad5222 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -183,6 +183,32 @@ describe("encodeWireMessage / decodeWireMessage", () => { expect(out).toEqual(msg); }); + it("round-trips monarch-bounded exile links", async () => { + const msg: P2PMessage = { + type: "state_update", + state: buildGameState({ + exile_links: [ + { + exiled_id: 12, + source_id: 34, + kind: { + UntilOpponentBecomesMonarch: { + return_zone: "Battlefield", + controller: 0, + }, + }, + }, + ], + }), + events: [], + legalActions: [], + manaPaymentShortcutActions: [], + viewerInteraction: viewerInteractionWithProducedMana, + }; + const bytes = await encodeWireMessage(msg); + await expect(decodeWireMessage(bytes)).resolves.toEqual(msg); + }); + // (b) Tiny messages take FORMAT_RAW. it("ping uses FORMAT_RAW (0x00) — too small for gzip to win", async () => { const bytes = await encodeWireMessage({ type: "ping", timestamp: 1 }); diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 0939e69ec8..f64fb34c58 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -803,7 +803,7 @@ pub fn resolve( ); // CR 110.2a: `enters_under_player` was resolved once at resolver // entry — pass it straight through (no per-branch re-resolution). - match execute_zone_move( + match crate::game::zone_pipeline::execute_zone_move_with_controller( state, chosen, scan_zone, @@ -819,6 +819,7 @@ pub fn resolve( track_exiled_by_source, None, None, + Some(ability.controller), events, ) { ZoneMoveResult::Done => { @@ -887,7 +888,7 @@ pub fn resolve( ); // CR 110.2a: pre-resolved controller override (single-eligible // branch). No per-branch re-resolution. - match execute_zone_move( + match crate::game::zone_pipeline::execute_zone_move_with_controller( state, chosen, scan_zone, @@ -903,6 +904,7 @@ pub fn resolve( track_exiled_by_source, None, None, + Some(ability.controller), events, ) { ZoneMoveResult::Done => { @@ -1485,7 +1487,7 @@ pub(crate) fn process_one_zone_move_with_terminal( ctx.enters_attacking, ctx.enters_modified_if.as_ref(), ); - let result = crate::game::zone_pipeline::execute_zone_move_with_terminal( + let result = crate::game::zone_pipeline::execute_zone_move_with_terminal_and_controller( state, obj_id, from_zone, @@ -1501,6 +1503,7 @@ pub(crate) fn process_one_zone_move_with_terminal( ctx.track_exiled_by_source, ctx.library_placement.clone(), ctx.enter_attached_to, + Some(ctx.controller), events, ); @@ -1961,7 +1964,7 @@ pub fn resolve_all( anticipated_zone_change_delivery(state, obj_id, dest_zone, ability.source_id); let delivery_start = events.len(); let stack_depth_before_zone_move = state.resolution_stack.len(); - match crate::game::zone_pipeline::execute_zone_move_with_terminal( + match crate::game::zone_pipeline::execute_zone_move_with_terminal_and_controller( state, obj_id, per_object_origin, @@ -1977,6 +1980,7 @@ pub fn resolve_all( track_exiled_by_source, effect_library_position.clone(), None, + Some(ability.controller), events, ) { crate::game::zone_pipeline::ZoneMoveTerminalResult::Completed(completion) => { diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 4103db1b11..10f33259ca 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -865,6 +865,7 @@ fn apply_pending_counter_post_action( cause, source_id, duration, + exile_controller, exile_tracking, enters_attacking, drain, @@ -882,6 +883,7 @@ fn apply_pending_counter_post_action( cause, source_id, duration.as_ref(), + exile_controller, exile_tracking, drain, // CR 701.24a: the counter-pause continuation never carries a diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 5b51987b15..57ae94f86c 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -83,6 +83,7 @@ pub(crate) fn complete_discard_to_graveyard( event, source_id, None, + None, false, crate::types::game_state::PostReplacementDrainOwner::DeliveryTail, // Discard delivers to the graveyard — no library placement. @@ -378,6 +379,7 @@ pub fn resolve( zone_event, None, None, + None, false, crate::types::game_state::PostReplacementDrainOwner::DeliveryTail, None, @@ -780,6 +782,7 @@ fn route_discard( zone_event, None, None, + None, false, crate::types::game_state::PostReplacementDrainOwner::DeliveryTail, None, diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 42e8a9e1a0..d626038bd1 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1497,6 +1497,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -1609,6 +1611,7 @@ mod tests { attach_to: None, library_placement: None, exile_duration: None, + exile_controller: None, exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, replacement_applied: HashSet::new(), face_down_in_exile: false, @@ -1626,6 +1629,7 @@ mod tests { attach_to: None, library_placement: None, exile_duration: None, + exile_controller: None, exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, replacement_applied: HashSet::new(), face_down_in_exile: false, @@ -2599,6 +2603,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2734,6 +2740,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2782,6 +2790,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2821,6 +2831,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2890,6 +2902,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index 511f867bb2..ec8885ca3f 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -803,6 +803,7 @@ pub fn route_debug_create_to_battlefield( event, None, None, + None, false, crate::types::game_state::PostReplacementDrainOwner::DeliveryTail, None, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index c4fdc4a685..e00d3d0ef2 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -170,6 +170,14 @@ pub(super) fn handle_replacement_choice( .pending_replacement .as_ref() .and_then(|pending| pending.library_placement.clone()); + let parked_exile_controller = state + .pending_replacement + .as_ref() + .and_then(|pending| pending.exile_controller); + let parked_exile_duration = state + .pending_replacement + .as_ref() + .and_then(|pending| pending.exile_duration.clone()); // CR 120.4a + CR 702.15b: capture the excess-redirect rider and the deferred // lifelink bonus BEFORE `continue_replacement` consumes the pending record, so // the Damage resume arm can restore them onto the ctx it rebuilds from the @@ -269,7 +277,11 @@ pub(super) fn handle_replacement_choice( approved, crate::game::zone_pipeline::DeliveryCtx { source_id: cause, - exile_links: crate::game::zone_pipeline::ExileLinkSpec::default(), + exile_links: crate::game::zone_pipeline::ExileLinkSpec { + duration: parked_exile_duration, + controller: parked_exile_controller, + tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, + }, drain: crate::types::game_state::PostReplacementDrainOwner::CallerEpilogue, // CR 701.24a: thread the parked W3 library placement so @@ -1165,6 +1177,12 @@ pub(super) fn handle_replacement_choice( if pending.library_placement.is_none() { pending.library_placement = parked_library_placement.clone(); } + if pending.exile_controller.is_none() { + pending.exile_controller = parked_exile_controller; + } + if pending.exile_duration.is_none() { + pending.exile_duration = parked_exile_duration.clone(); + } // CR 120.4a: a SECOND material replacement ordering choice on the // same damage event re-parked a fresh record with // `excess_recipient: None`. Reapply the rider captured before @@ -3053,6 +3071,8 @@ mod tests { depth: 0, is_optional: true, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -3725,6 +3745,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index fb029dbc51..3a6137edd7 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -4989,6 +4989,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index b8d41f1855..b512d149c3 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -9292,6 +9292,8 @@ fn park_entry_controller_choice( depth, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -9355,6 +9357,8 @@ fn pipeline_loop( // CR 701.24a: set by the W3 library-placement arm after parking // (the pipeline doesn't know the caller's placement here). library_placement: None, + exile_controller: None, + exile_duration: None, // CR 120.4a: set by `apply_damage_to_target` right after this // park returns NeedsChoice (the ctx rider isn't known here). excess_recipient: None, @@ -9408,6 +9412,8 @@ fn pipeline_loop( is_optional: false, // CR 701.24a: set by the W3 library-placement arm after parking. library_placement: None, + exile_controller: None, + exile_duration: None, // CR 120.4a: set by `apply_damage_to_target` right after this park // returns NeedsChoice (the ctx rider isn't known here). excess_recipient: None, @@ -9739,6 +9745,8 @@ fn continue_replacement_impl( depth: reparked_depth, is_optional: true, library_placement: reparked_library_placement, + exile_controller: None, + exile_duration: None, // CR 120.4a: this MayCost re-park path is a zone-change / // permanent-entry accept, never a damage hit, so no excess // rider applies here. @@ -12318,6 +12326,8 @@ mod tests { depth: 0, is_optional: true, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -12377,6 +12387,8 @@ mod tests { depth: 0, is_optional: true, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -12457,6 +12469,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -15829,6 +15843,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 740dea1a33..d9bab26780 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -4013,6 +4013,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -4181,6 +4183,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 63d506e5ac..f0eafa9a67 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -43289,6 +43289,7 @@ pub mod tests { event, None, None, + None, false, crate::types::game_state::PostReplacementDrainOwner::DeliveryTail, None, diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index f6b2bdcc18..9f0267bfc2 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -171,6 +171,10 @@ pub struct ExileLinkSpec { /// `Some(Duration::UntilHostLeavesPlay)` installs a return-on-source-leave /// link; other durations / `None` fall back to `tracking`. pub duration: Option, + /// Resolved controller for a monarch-bounded link. `Some` is captured when + /// the originating ability resolves; `None` means that duration cannot + /// create a monarch link. + pub controller: Option, /// `TrackBySource` records an "exiled with" link; `None` records nothing /// unless `duration` requires it. pub tracking: ZoneDeliveryExileTracking, @@ -239,6 +243,7 @@ impl ZoneMoveRequest { attach_to: self.mods.attach_to, library_placement: self.placement, exile_duration: self.exile_links.duration, + exile_controller: self.exile_links.controller, exile_tracking: self.exile_links.tracking, replacement_applied: self.replacement_applied, face_down_in_exile: self.face_down_in_exile, @@ -285,6 +290,7 @@ impl ZoneMoveRequest { placement: pending.library_placement, exile_links: ExileLinkSpec { duration: pending.exile_duration, + controller: pending.exile_controller, tracking: pending.exile_tracking, }, replacement_applied: pending.replacement_applied, @@ -832,6 +838,7 @@ pub(crate) fn move_object_with_terminal( event, source_id, req.exile_links.duration.as_ref(), + req.exile_links.controller, matches!( req.exile_links.tracking, ZoneDeliveryExileTracking::TrackBySource @@ -906,6 +913,7 @@ pub(crate) fn move_object_with_terminal( event, source_id, exile_links.duration.as_ref(), + exile_links.controller, track_exiled_by_source, PostReplacementDrainOwner::DeliveryTail, None, @@ -1043,6 +1051,7 @@ pub(crate) fn move_object_with_terminal( track_exiled_by_source, None, None, + exile_links.controller, req.replacement_applied, events, ) @@ -1607,6 +1616,7 @@ pub(crate) fn deliver( approved.event, ctx.source_id, ctx.exile_links.duration.as_ref(), + ctx.exile_links.controller, track_exiled_by_source, ctx.drain, // CR 701.24a: most `deliver` callers (bucket-A destroy / sacrifice / SBA / @@ -1672,6 +1682,7 @@ fn append_zone_delivery_tail_after_counter_pause( cause: Option, source_id: Option, duration: Option<&Duration>, + exile_controller: Option, exile_tracking: ZoneDeliveryExileTracking, drain: PostReplacementDrainOwner, enters_attacking: bool, @@ -1688,6 +1699,7 @@ fn append_zone_delivery_tail_after_counter_pause( cause, source_id, duration: duration.cloned(), + exile_controller, exile_tracking, drain, enters_attacking, @@ -1705,6 +1717,7 @@ pub(crate) fn apply_zone_delivery_tail( cause: Option, source_id: Option, duration: Option<&Duration>, + exile_controller: Option, exile_tracking: ZoneDeliveryExileTracking, drain: PostReplacementDrainOwner, // CR 701.24a: when a specific library position was requested, the object was @@ -1744,11 +1757,9 @@ pub(crate) fn apply_zone_delivery_tail( Some(ExileLinkKind::UntilSourceLeaves { return_zone: from }) } Some(Duration::UntilOpponentBecomesMonarch) => { - state.objects.get(&source_id).map(|source| { - ExileLinkKind::UntilOpponentBecomesMonarch { - return_zone: from, - controller: source.controller, - } + exile_controller.map(|controller| ExileLinkKind::UntilOpponentBecomesMonarch { + return_zone: from, + controller, }) } _ if matches!(exile_tracking, ZoneDeliveryExileTracking::TrackBySource) => { @@ -3277,6 +3288,7 @@ pub(crate) fn deliver_replaced_zone_change( event: ProposedEvent, source_id: Option, duration: Option<&Duration>, + exile_controller: Option, track_exiled_by_source: bool, drain: PostReplacementDrainOwner, library_placement: Option, @@ -3345,6 +3357,7 @@ pub(crate) fn deliver_replaced_zone_change( cause, source_id, duration, + exile_controller, exile_tracking, drain, library_placement.as_ref(), @@ -3807,6 +3820,7 @@ pub(crate) fn deliver_replaced_zone_change( cause, source_id, duration, + exile_controller, exile_tracking, drain, enters_attacking, @@ -3839,6 +3853,7 @@ pub(crate) fn deliver_replaced_zone_change( cause, source_id, duration, + exile_controller, exile_tracking, drain, enters_attacking, @@ -3854,6 +3869,7 @@ pub(crate) fn deliver_replaced_zone_change( cause, source_id, duration, + exile_controller, exile_tracking, drain, library_placement.as_ref(), @@ -3939,6 +3955,48 @@ pub(crate) fn execute_zone_move( .into_zone_move_result() } +#[allow(clippy::too_many_arguments)] +pub(crate) fn execute_zone_move_with_controller( + state: &mut GameState, + obj_id: ObjectId, + from_zone: Zone, + dest_zone: Zone, + source_id: ObjectId, + 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>, + track_exiled_by_source: bool, + library_placement: Option, + enter_attached_to: Option, + exile_controller: Option, + events: &mut Vec, +) -> ZoneMoveResult { + execute_zone_move_with_terminal_and_controller( + state, + obj_id, + from_zone, + dest_zone, + source_id, + duration, + enter_transformed, + enter_tapped, + enters_attacking, + controller_override, + effect_enter_with_counters, + face_down_profile, + track_exiled_by_source, + library_placement, + enter_attached_to, + exile_controller, + events, + ) + .into_zone_move_result() +} + #[allow(clippy::too_many_arguments)] pub(crate) fn execute_zone_move_with_terminal( state: &mut GameState, @@ -3957,6 +4015,47 @@ pub(crate) fn execute_zone_move_with_terminal( library_placement: Option, enter_attached_to: Option, events: &mut Vec, +) -> ZoneMoveTerminalResult { + execute_zone_move_with_terminal_and_controller( + state, + obj_id, + from_zone, + dest_zone, + source_id, + duration, + enter_transformed, + enter_tapped, + enters_attacking, + controller_override, + effect_enter_with_counters, + face_down_profile, + track_exiled_by_source, + library_placement, + enter_attached_to, + None, + events, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn execute_zone_move_with_terminal_and_controller( + state: &mut GameState, + obj_id: ObjectId, + from_zone: Zone, + dest_zone: Zone, + source_id: ObjectId, + 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>, + track_exiled_by_source: bool, + library_placement: Option, + enter_attached_to: Option, + exile_controller: Option, + events: &mut Vec, ) -> ZoneMoveTerminalResult { execute_zone_move_with_applied_terminal( state, @@ -3974,6 +4073,7 @@ pub(crate) fn execute_zone_move_with_terminal( track_exiled_by_source, library_placement, enter_attached_to, + exile_controller, HashSet::new(), events, ) @@ -3996,6 +4096,7 @@ fn execute_zone_move_with_applied_terminal( track_exiled_by_source: bool, library_placement: Option, enter_attached_to: Option, + exile_controller: Option, replacement_applied: HashSet, events: &mut Vec, ) -> ZoneMoveTerminalResult { @@ -4301,6 +4402,7 @@ fn execute_zone_move_with_applied_terminal( event, Some(source_id), duration, + exile_controller, track_exiled_by_source, PostReplacementDrainOwner::DeliveryTail, library_placement, @@ -4340,6 +4442,7 @@ fn execute_zone_move_with_applied_terminal( event, Some(source_id), duration, + exile_controller, track_exiled_by_source, PostReplacementDrainOwner::DeliveryTail, library_placement, @@ -4376,6 +4479,10 @@ fn execute_zone_move_with_applied_terminal( // delivery-tail NeedsChoice path above is NOT parked here — its // wait state is already set by the counter-pause / devour machinery // (`replacement_pause_delivery_result` reads it). + if let Some(pending) = state.pending_replacement.as_mut() { + pending.exile_controller = exile_controller; + pending.exile_duration = duration.cloned(); + } state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f97d5525f7..fac2b74629 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4833,6 +4833,8 @@ pub struct PendingBatchZoneMoveRequest { pub library_placement: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub exile_duration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exile_controller: Option, #[serde(default)] pub exile_tracking: ZoneDeliveryExileTracking, #[serde( @@ -5527,6 +5529,8 @@ pub enum PendingCounterPostAction { cause: Option, source_id: Option, duration: Option, + #[serde(default)] + exile_controller: Option, exile_tracking: ZoneDeliveryExileTracking, /// CR 508.4: The completed battlefield entry joins combat after any /// as-enters replacement choice has settled. @@ -18138,6 +18142,12 @@ pub struct PendingReplacement { /// away. `None` for every other parked event (the common case). #[serde(default)] pub library_placement: Option, + /// CR 603.3a + CR 109.5: preserve the controller that resolved a + /// monarch-bounded exile while its zone change waits on CR 616.1. + #[serde(default)] + pub exile_controller: Option, + #[serde(default)] + pub exile_duration: Option, /// CR 120.4a: carries the excess-redirect rider ("Excess damage is dealt to /// that creature's controller instead") across a damage replacement *choice* /// pause. The resume in `handle_replacement_choice` rebuilds the diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index 211bef18d2..c6f32f7ccf 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -5910,6 +5910,8 @@ mod tests { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index 7bfafd78cc..fc34e8967d 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -778,6 +778,8 @@ fn stage_prevented_cost_move(state: &mut GameState, source: engine::types::ident depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -4530,6 +4532,8 @@ fn effect_pay_cost_composite_mana_life_prevention_serializes_and_rides_once() { depth: 0, is_optional: false, library_placement: None, + exile_controller: None, + exile_duration: None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/tests/integration/palace_jailer.rs b/crates/engine/tests/integration/palace_jailer.rs index a113da892b..344dd36e1e 100644 --- a/crates/engine/tests/integration/palace_jailer.rs +++ b/crates/engine/tests/integration/palace_jailer.rs @@ -1,7 +1,7 @@ //! Palace Jailer — the ETB exile must last until an opponent becomes the monarch. //! //! The tests use the real card Oracle text and the cast/apply pipeline. The -//! delayed return is source-independent, persistent across cleanup, scoped to +//! event-bounded return is source-independent, persistent across cleanup, scoped to //! any opponent, and guarded so a target that left exile is not moved again. use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; @@ -149,8 +149,86 @@ fn crown_opponent(runner: &mut GameRunner, spell: ObjectId, opponent: PlayerId, ); } +/// CR 109.5 + CR 603.3a + CR 610.3: The Palace Jailer trigger is controlled by +/// P0 when it triggers, even if P1 gains control of the source before that +/// trigger resolves. The monarch-bounded link must use P0 for its opponent +/// test, not the source object's later controller. +#[test] +fn control_change_before_etb_resolution_preserves_trigger_controller() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + for player in [P0, P1] { + scenario.with_library_top(player, &["Filler 1", "Filler 2", "Filler 3", "Filler 4"]); + } + let jailer = scenario + .add_creature_to_hand_from_oracle(P0, "Palace Jailer", 2, 2, PALACE_JAILER) + .id(); + let target = scenario.add_creature(P1, "Exiled Creature", 2, 2).id(); + let gain_control = scenario + .add_spell_to_hand_from_oracle( + P1, + "Act of Treason", + true, + "Gain control of target creature until end of turn.", + ) + .id(); + let crown = scenario + .add_spell_to_hand_from_oracle(P1, "Crown Yourself", true, CONTROLLER_BECOMES_MONARCH) + .id(); + + let mut runner = scenario.build(); + let mut committed = runner.cast(jailer).target_object(target).commit(); + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority for Palace Jailer"); + committed + .act(GameAction::PassPriority) + .expect("P1 can pass priority for Palace Jailer"); + assert_eq!(committed.state().objects[&jailer].zone, Zone::Battlefield); + committed + .act(GameAction::OrderTriggers { order: vec![0, 1] }) + .expect("the Palace Jailer ETB triggers must be ordered before priority"); + + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority to the responding player"); + committed.cast(gain_control).target_object(jailer).resolve(); + assert_eq!(committed.state().objects[&jailer].controller, P1); + + for _ in 0..8 { + if committed.state().objects[&target].zone == Zone::Exile { + break; + } + committed + .act(GameAction::PassPriority) + .expect("priority must advance the Palace Jailer ETB triggers"); + } + assert_eq!(committed.state().objects[&target].zone, Zone::Exile); + assert!(committed.state().exile_links.iter().any(|link| { + link.exiled_id == target + && matches!( + link.kind, + ExileLinkKind::UntilOpponentBecomesMonarch { controller: P0, .. } + ) + })); + + for _ in 0..8 { + match committed.state().waiting_for { + WaitingFor::Priority { player: current } if current == P1 => break, + WaitingFor::Priority { .. } => { + committed + .act(GameAction::PassPriority) + .expect("priority should advance to P1"); + } + ref waiting => panic!("expected a priority window, got {waiting:?}"), + } + } + committed.cast(crown).resolve(); + assert_eq!(committed.state().objects[&target].zone, Zone::Battlefield); +} + /// CR 603.7 + CR 610.3 + CR 725.1: An opponent becoming the monarch returns -/// the exiled creature through the delayed-trigger path. +/// the exiled creature through the event-bounded link path. #[test] fn opponent_becoming_monarch_returns_exiled_creature() { let Board { @@ -196,8 +274,8 @@ fn controller_becoming_monarch_does_not_return_exiled_creature() { ); } -/// CR 603.7d + CR 400.7: Once created, the delayed trigger survives its source -/// moving to the graveyard and still returns the target on the matching event. +/// CR 603.7d + CR 400.7: Once created, the event-bounded link survives its +/// source moving to the graveyard and still returns the target on the matching event. #[test] fn palace_jailer_in_graveyard_does_not_change_the_delayed_return() { let Board { @@ -260,7 +338,7 @@ fn any_opponent_becoming_monarch_returns_the_selected_creature() { } /// CR 603.7c + CR 610.3: If the object leaves exile before the event, the -/// delayed return's expected-origin guard makes it a no-op. +/// event-bounded return's expected-origin guard makes it a no-op. #[test] fn target_leaving_exile_before_monarch_change_is_not_moved_again() { let Board { From 1bdf1efd3c835d7c80d18694103c265a4764da0b Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 16 Aug 2026 16:00:45 -0500 Subject: [PATCH 5/8] Handle Palace Jailer pre-resolution monarch events --- crates/engine/src/game/effects/change_zone.rs | 33 +++ crates/engine/src/game/elimination.rs | 6 + crates/engine/src/game/engine.rs | 221 +++++++++++++++++- crates/engine/src/game/engine_replacement.rs | 15 +- crates/engine/src/game/mana_abilities.rs | 1 + crates/engine/src/game/replacement.rs | 8 + crates/engine/src/game/sba.rs | 2 + crates/engine/src/game/triggers.rs | 82 +++++-- crates/engine/src/game/zone_pipeline.rs | 17 ++ crates/engine/src/types/ability.rs | 58 +++++ crates/engine/src/types/game_state.rs | 4 + crates/engine/src/types/resolution.rs | 1 + .../tests/integration/cost_zone_pipeline.rs | 54 ++++- .../engine/tests/integration/palace_jailer.rs | 88 ++++++- 14 files changed, 562 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index f64fb34c58..f5af62ef0f 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -488,6 +488,39 @@ pub fn resolve( .map(|cause| EffectResolutionResult { cause, count }) }; + // CR 610.3b: If the specified event occurred after this triggered ability + // triggered but before its initial one-shot zone change, the object does + // not move. + if let Some(duration_event) = ability + .duration + .as_ref() + .and_then(Duration::zone_change_event) + { + let occurred_before_this_resolution = + ability.context.duration_events.contains(&duration_event); + let occurred_earlier_this_resolution = events.iter().any(|event| { + crate::game::engine::duration_event_matches( + state, + ability.source_id, + ability + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + ability.controller, + duration_event, + event, + ) + }); + if occurred_before_this_resolution || occurred_earlier_this_resolution { + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + subject: None, + }); + return Ok(completed_result(0)); + } + } + let mut origin = origin; let parsed_target = match &ability.effect { diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index d626038bd1..03573df9da 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1499,6 +1499,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2605,6 +2606,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2742,6 +2744,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2792,6 +2795,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2833,6 +2837,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -2904,6 +2909,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 18dd69303b..d4b4e019a9 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2,7 +2,7 @@ use rand::Rng; use std::collections::{HashSet, VecDeque}; use thiserror::Error; -use crate::types::ability::{EffectKind, KeywordAction, TargetRef}; +use crate::types::ability::{DurationEvent, EffectKind, KeywordAction, TargetRef}; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; use crate::types::actions::{ @@ -14769,8 +14769,227 @@ pub fn start_game_skip_mulligan(state: &mut GameState) -> ActionResult { /// CR 607.2a + CR 406.6 + CR 610.3: Check for event-bounded exile returns. /// Move linked exiled cards back through the replacement-aware zone pipeline. +pub(crate) fn duration_event_matches( + state: &GameState, + source_id: ObjectId, + source_incarnation: Option, + controller: PlayerId, + duration_event: DurationEvent, + event: &GameEvent, +) -> bool { + match (duration_event, event) { + ( + DurationEvent::SourceLeftBattlefield, + GameEvent::ZoneChanged { + object_id, + from: Some(Zone::Battlefield), + record, + .. + }, + ) => { + *object_id == source_id + && source_incarnation.is_none_or(|expected| { + record + .trigger_source_context + .as_ref() + .is_none_or(|observed| observed.identity.reference == expected) + }) + } + (DurationEvent::OpponentBecameMonarch, GameEvent::MonarchChanged { player_id }) => { + super::players::is_opponent(state, controller, *player_id) + } + _ => false, + } +} + pub(super) fn check_exile_returns(state: &mut GameState, events: &mut Vec) { let mut to_return: Vec = Vec::new(); + let mut stack_latches = Vec::new(); + let mut resolving_latches = Vec::new(); + let mut deferred_latches = Vec::new(); + let mut ordered_latches = Vec::new(); + + for (event_index, event) in events.iter().enumerate() { + for entry in &state.stack { + let StackEntryKind::TriggeredAbility { + ability, + trigger_event, + .. + } = &entry.kind + else { + continue; + }; + let event_follows_trigger = trigger_event + .as_ref() + .and_then(|trigger| events.iter().position(|candidate| candidate == trigger)) + .is_none_or(|trigger_index| event_index > trigger_index); + if !event_follows_trigger { + continue; + } + for duration_event in [ + DurationEvent::SourceLeftBattlefield, + DurationEvent::OpponentBecameMonarch, + ] { + if ability.contains_duration_event(duration_event) + && duration_event_matches( + state, + entry.source_id, + ability + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + entry.controller, + duration_event, + event, + ) + { + stack_latches.push((entry.id, duration_event)); + } + } + } + + if let Some(entry) = state.resolving_stack_entry.as_ref() { + if matches!(entry.kind, StackEntryKind::TriggeredAbility { .. }) { + if let Some(ability) = entry.ability() { + for duration_event in [ + DurationEvent::SourceLeftBattlefield, + DurationEvent::OpponentBecameMonarch, + ] { + if ability.contains_duration_event(duration_event) + && duration_event_matches( + state, + entry.source_id, + ability + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + entry.controller, + duration_event, + event, + ) + { + resolving_latches.push(duration_event); + } + } + } + } + } + + for (index, context) in state.deferred_triggers.iter().enumerate() { + let event_follows_trigger = context + .pending + .trigger_event + .as_ref() + .and_then(|trigger| events.iter().position(|candidate| candidate == trigger)) + .is_none_or(|trigger_index| event_index > trigger_index); + if !event_follows_trigger { + continue; + } + for duration_event in [ + DurationEvent::SourceLeftBattlefield, + DurationEvent::OpponentBecameMonarch, + ] { + if context + .pending + .ability + .contains_duration_event(duration_event) + && duration_event_matches( + state, + context.pending.source_id, + context + .pending + .ability + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + context.pending.controller, + duration_event, + event, + ) + { + deferred_latches.push((index, duration_event)); + } + } + } + + if let Some(order) = state.pending_trigger_order.as_ref() { + for (group_index, group) in order.groups.iter().enumerate() { + for (trigger_index, context) in group.triggers.iter().enumerate() { + let event_follows_trigger = context + .pending + .trigger_event + .as_ref() + .and_then(|trigger| { + events.iter().position(|candidate| candidate == trigger) + }) + .is_none_or(|origin_index| event_index > origin_index); + if !event_follows_trigger { + continue; + } + for duration_event in [ + DurationEvent::SourceLeftBattlefield, + DurationEvent::OpponentBecameMonarch, + ] { + if context + .pending + .ability + .contains_duration_event(duration_event) + && duration_event_matches( + state, + context.pending.source_id, + context + .pending + .ability + .trigger_source + .as_ref() + .map(|source| source.identity.reference), + context.pending.controller, + duration_event, + event, + ) + { + ordered_latches.push((group_index, trigger_index, duration_event)); + } + } + } + } + } + } + + for (entry_id, duration_event) in stack_latches { + if let Some(ability) = state + .stack + .iter_mut() + .find(|entry| entry.id == entry_id) + .and_then(StackEntry::ability_mut) + { + ability.record_duration_event_recursive(duration_event); + } + } + for duration_event in resolving_latches { + if let Some(ability) = state + .resolving_stack_entry + .as_mut() + .and_then(StackEntry::ability_mut) + { + ability.record_duration_event_recursive(duration_event); + } + } + for (index, duration_event) in deferred_latches { + if let Some(context) = state.deferred_triggers.get_mut(index) { + context.record_duration_event(duration_event); + } + } + for (group_index, trigger_index, duration_event) in ordered_latches { + if let Some(context) = state + .pending_trigger_order + .as_mut() + .and_then(|order| order.groups.get_mut(group_index)) + .and_then(|group| group.triggers.get_mut(trigger_index)) + { + context.record_duration_event(duration_event); + } + } for event in events.iter() { match event { diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index e00d3d0ef2..b225579d40 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -178,6 +178,11 @@ pub(super) fn handle_replacement_choice( .pending_replacement .as_ref() .and_then(|pending| pending.exile_duration.clone()); + let parked_exile_tracking = state + .pending_replacement + .as_ref() + .map(|pending| pending.exile_tracking) + .unwrap_or_default(); // CR 120.4a + CR 702.15b: capture the excess-redirect rider and the deferred // lifelink bonus BEFORE `continue_replacement` consumes the pending record, so // the Damage resume arm can restore them onto the ctx it rebuilds from the @@ -280,7 +285,7 @@ pub(super) fn handle_replacement_choice( exile_links: crate::game::zone_pipeline::ExileLinkSpec { duration: parked_exile_duration, controller: parked_exile_controller, - tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, + tracking: parked_exile_tracking, }, drain: crate::types::game_state::PostReplacementDrainOwner::CallerEpilogue, @@ -1183,6 +1188,12 @@ pub(super) fn handle_replacement_choice( if pending.exile_duration.is_none() { pending.exile_duration = parked_exile_duration.clone(); } + if matches!( + pending.exile_tracking, + crate::types::game_state::ZoneDeliveryExileTracking::None + ) { + pending.exile_tracking = parked_exile_tracking; + } // CR 120.4a: a SECOND material replacement ordering choice on the // same damage event re-parked a fresh record with // `excess_recipient: None`. Reapply the rider captured before @@ -3073,6 +3084,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -3747,6 +3759,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 3a6137edd7..d66e068f4e 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -4991,6 +4991,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index b512d149c3..7716ac5868 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -9294,6 +9294,7 @@ fn park_entry_controller_choice( library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -9359,6 +9360,7 @@ fn pipeline_loop( library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, // CR 120.4a: set by `apply_damage_to_target` right after this // park returns NeedsChoice (the ctx rider isn't known here). excess_recipient: None, @@ -9414,6 +9416,7 @@ fn pipeline_loop( library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, // CR 120.4a: set by `apply_damage_to_target` right after this park // returns NeedsChoice (the ctx rider isn't known here). excess_recipient: None, @@ -9747,6 +9750,7 @@ fn continue_replacement_impl( library_placement: reparked_library_placement, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, // CR 120.4a: this MayCost re-park path is a zone-change / // permanent-entry accept, never a damage hit, so no excess // rider applies here. @@ -12328,6 +12332,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -12389,6 +12394,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -12471,6 +12477,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -15845,6 +15852,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index d9bab26780..b3bfdc3732 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -4015,6 +4015,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -4185,6 +4186,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: crate::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index f0eafa9a67..4028cf200c 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -7,11 +7,12 @@ use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCostOrigin, BounceSelection, CardTypeSetSource, CastManaSpentMetric, ChosenAttribute, CommanderOwnership, ControllerRef, CopyRetargetPermission, DamageAmountScope, DamageAmountThreshold, - DamageKindFilter, DelayedTriggerCondition, Effect, FilterProp, ModalChoice, ObjectScope, - OriginConstraint, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, RenownSubject, - ResolvedAbility, SacrificeCost, StaticCondition, TargetFilter, TargetRef, TributeOutcome, - TriggerCondition, TriggerConstraint, TriggerDefinition, TriggerDefinitionOccurrenceRef, - TriggerDefinitionRef, TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter, + DamageKindFilter, DelayedTriggerCondition, DurationEvent, Effect, FilterProp, ModalChoice, + ObjectScope, OriginConstraint, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, + RenownSubject, ResolvedAbility, SacrificeCost, StaticCondition, TargetFilter, TargetRef, + TributeOutcome, TriggerCondition, TriggerConstraint, TriggerDefinition, + TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, TriggerEntry, TriggerGrantProducerKey, + TypeFilter, TypedFilter, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -323,6 +324,10 @@ pub struct PendingTriggerContext { pub pending: PendingTrigger, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trigger_events: Vec, + /// CR 610.3b: specified duration events observed after this ability + /// triggered but before it reached the stack. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) duration_events: Vec, #[serde( default, skip_serializing_if = "pending_trigger_dispatch_origin_is_normal" @@ -348,6 +353,7 @@ impl PendingTriggerContext { Self { pending, trigger_events, + duration_events: Vec::new(), dispatch_origin: PendingTriggerDispatchOrigin::Normal, firing: TriggerFiring::Ordinary, } @@ -357,6 +363,7 @@ impl PendingTriggerContext { Self { pending, trigger_events, + duration_events: Vec::new(), dispatch_origin: PendingTriggerDispatchOrigin::Normal, firing: TriggerFiring::Ordinary, } @@ -367,6 +374,7 @@ impl PendingTriggerContext { Self { pending, trigger_events, + duration_events: Vec::new(), dispatch_origin: PendingTriggerDispatchOrigin::Delayed, firing: identity.firing(), } @@ -376,6 +384,14 @@ impl PendingTriggerContext { self.firing } + pub(crate) fn record_duration_event(&mut self, event: DurationEvent) { + if self.pending.ability.contains_duration_event(event) + && !self.duration_events.contains(&event) + { + self.duration_events.push(event); + } + } + #[cfg(test)] pub(crate) fn delayed_for_test(pending: PendingTrigger, origin: DelayedTriggerOrigin) -> Self { Self::delayed(pending, DelayedInstallIdentity::ReceiptEligible(origin)) @@ -7359,6 +7375,29 @@ fn push_pending_trigger_to_stack_with_firing( events: &mut Vec, firing: TriggerFiring, ) -> ObjectId { + push_pending_trigger_to_stack_with_firing_and_duration_events( + state, + trigger, + trigger_events, + Vec::new(), + events, + firing, + ) +} + +fn push_pending_trigger_to_stack_with_firing_and_duration_events( + state: &mut GameState, + mut trigger: PendingTrigger, + trigger_events: Vec, + duration_events: Vec, + events: &mut Vec, + firing: TriggerFiring, +) -> ObjectId { + for duration_event in duration_events { + trigger + .ability + .record_duration_event_recursive(duration_event); + } let PendingTrigger { source_id, controller, @@ -7837,12 +7876,14 @@ fn dispatch_pending_trigger_context_with_origin( let firing = trigger_context.firing(); let context_pending = trigger_context.pending.clone(); let context_events = trigger_context.trigger_events.clone(); + let context_duration_events = trigger_context.duration_events.clone(); match dispatch_pending_trigger_context(state, trigger_context, events_out) { TriggerDispatchDisposition::DroppedTargetUnresolved if firing.is_delayed() => { - push_pending_trigger_to_stack_with_firing( + push_pending_trigger_to_stack_with_firing_and_duration_events( state, context_pending, context_events, + context_duration_events, events_out, firing, ); @@ -8078,6 +8119,7 @@ fn dispatch_pending_trigger_context_core( let PendingTriggerContext { pending: trigger, trigger_events, + duration_events, firing, .. } = trigger_context; @@ -8162,10 +8204,11 @@ fn dispatch_pending_trigger_context_core( let controller = trigger.controller; let source_id = trigger.source_id; let pending_for_state = trigger.clone(); - let entry_id = push_pending_trigger_to_stack_with_firing( + let entry_id = push_pending_trigger_to_stack_with_firing_and_duration_events( state, trigger, trigger_events.clone(), + duration_events.clone(), events_out, firing, ); @@ -8277,10 +8320,11 @@ fn dispatch_pending_trigger_context_core( restore_trigger_event_context(state, context_snapshot); return TriggerDispatchDisposition::ResolvedInline; } - push_pending_trigger_to_stack_with_firing( + push_pending_trigger_to_stack_with_firing_and_duration_events( state, trigger, trigger_events, + duration_events, events_out, firing, ); @@ -8320,13 +8364,15 @@ fn dispatch_pending_trigger_context_core( // when the division choice completes. let player = prepared_trigger.controller; let pending_for_state = prepared_trigger.clone(); - let entry_id = push_pending_trigger_to_stack_with_firing( - state, - prepared_trigger, - trigger_events.clone(), - events_out, - firing, - ); + let entry_id = + push_pending_trigger_to_stack_with_firing_and_duration_events( + state, + prepared_trigger, + trigger_events.clone(), + duration_events.clone(), + events_out, + firing, + ); state.pending_trigger_event_batch = trigger_events; state.pending_trigger = Some(Box::new(pending_for_state)); state.pending_trigger_firing = Some(firing); @@ -8344,10 +8390,11 @@ fn dispatch_pending_trigger_context_core( } } } - push_pending_trigger_to_stack_with_firing( + push_pending_trigger_to_stack_with_firing_and_duration_events( state, prepared_trigger, trigger_events, + duration_events, events_out, firing, ); @@ -8361,10 +8408,11 @@ fn dispatch_pending_trigger_context_core( // mutates the on-stack entry's `ability.targets` when each target // is chosen. let pending_for_state = trigger.clone(); - let entry_id = push_pending_trigger_to_stack_with_firing( + let entry_id = push_pending_trigger_to_stack_with_firing_and_duration_events( state, trigger, trigger_events.clone(), + duration_events.clone(), events_out, firing, ); diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 9f0267bfc2..d08130e83d 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -710,6 +710,18 @@ pub(crate) fn move_object( move_object_with_terminal(state, req, events).into_zone_move_result() } +#[cfg(feature = "test-support")] +pub fn move_object_for_test( + state: &mut GameState, + req: ZoneMoveRequest, + events: &mut Vec, +) -> bool { + matches!( + move_object(state, req, events), + ZoneMoveResult::NeedsChoice(_) + ) +} + pub(crate) fn move_object_with_terminal( state: &mut GameState, req: ZoneMoveRequest, @@ -4482,6 +4494,11 @@ fn execute_zone_move_with_applied_terminal( if let Some(pending) = state.pending_replacement.as_mut() { pending.exile_controller = exile_controller; pending.exile_duration = duration.cloned(); + pending.exile_tracking = if track_exiled_by_source { + ZoneDeliveryExileTracking::TrackBySource + } else { + ZoneDeliveryExileTracking::None + }; } state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 0b3606f99f..76154e6b8d 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3009,6 +3009,31 @@ pub enum Duration { Permanent, } +/// A specified event that can end a CR 610.3 zone-change duration before the +/// initial one-shot effect occurs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DurationEvent { + SourceLeftBattlefield, + OpponentBecameMonarch, +} + +impl Duration { + pub const fn zone_change_event(&self) -> Option { + match self { + Self::UntilHostLeavesPlay => Some(DurationEvent::SourceLeftBattlefield), + Self::UntilOpponentBecomesMonarch => Some(DurationEvent::OpponentBecameMonarch), + Self::UntilEndOfTurn + | Self::UntilEndOfCombat + | Self::UntilNextTurnOf { .. } + | Self::UntilEndOfNextTurnOf { .. } + | Self::UntilNextStepOf { .. } + | Self::ForAsLongAs { .. } + | Self::UntilSourceExilesAnotherCard + | Self::Permanent => None, + } + } +} + /// The attacker named by a force-block instruction. /// /// This intentionally has only exact single-object referents. A filter would @@ -21864,6 +21889,10 @@ pub struct EffectResolutionResult { /// Conditions in the sub_ability chain are evaluated against this context. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct SpellContext { + /// CR 610.3b: specified duration events observed after a triggered ability + /// triggered but before this initial zone-change effect occurred. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub duration_events: Vec, /// CR 118.1 + CR 119.4b: A completed resolution-time `PayCost` that paid /// life reports this amount to the immediate following "that much" clause. /// `Some(0)` is distinct from no life-payment channel at all, and the value @@ -26071,6 +26100,35 @@ pub struct ResolvedAbility { } impl ResolvedAbility { + /// Whether this ability chain contains a zone change bounded by `event`. + pub(crate) fn contains_duration_event(&self, event: DurationEvent) -> bool { + (matches!(self.effect, Effect::ChangeZone { .. }) + && self.duration.as_ref().and_then(Duration::zone_change_event) == Some(event)) + || self + .sub_ability + .as_ref() + .is_some_and(|sub| sub.contains_duration_event(event)) + || self + .else_ability + .as_ref() + .is_some_and(|branch| branch.contains_duration_event(event)) + } + + pub(crate) fn record_duration_event_recursive(&mut self, event: DurationEvent) { + if matches!(self.effect, Effect::ChangeZone { .. }) + && self.duration.as_ref().and_then(Duration::zone_change_event) == Some(event) + && !self.context.duration_events.contains(&event) + { + self.context.duration_events.push(event); + } + if let Some(sub) = self.sub_ability.as_mut() { + sub.record_duration_event_recursive(event); + } + if let Some(branch) = self.else_ability.as_mut() { + branch.record_duration_event_recursive(event); + } + } + /// Build from a typed Effect. Simply stores the fields. pub fn new( effect: Effect, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fac2b74629..13995e03db 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -18148,6 +18148,10 @@ pub struct PendingReplacement { pub exile_controller: Option, #[serde(default)] pub exile_duration: Option, + /// Preserve source-linked exile bookkeeping while a zone change waits on + /// a CR 616.1 replacement choice. + #[serde(default)] + pub exile_tracking: ZoneDeliveryExileTracking, /// CR 120.4a: carries the excess-redirect rider ("Excess damage is dealt to /// that creature's controller instead") across a damage replacement *choice* /// pause. The resume in `handle_replacement_choice` rebuilds the diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index c6f32f7ccf..7d19cd1351 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -5912,6 +5912,7 @@ mod tests { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index fc34e8967d..079d14fb72 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -4,6 +4,7 @@ use engine::game::effects::resolve_ability_chain; use engine::game::game_object::AttachTarget; use engine::game::mana_abilities::activate_mana_ability; use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::zone_pipeline::{move_object_for_test, ZoneMoveRequest}; use engine::parser::oracle_cost::parse_oracle_cost; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, CardPlayMode, CardSelectionMode, @@ -22,10 +23,10 @@ use engine::types::card_type::CoreType; use engine::types::counter::CounterType; use engine::types::events::{GameEvent, PlayerActionKind}; use engine::types::game_state::{ - BatchCompletion, CastPaymentMode, CollectEvidenceResume, GameState, + BatchCompletion, CastPaymentMode, CollectEvidenceResume, ExileLinkKind, GameState, ManaAbilityCostParentLifecycle, ManaAbilityCostResolutionMode, ManaAbilityResume, ManaChoice, PayCostKind, PendingCast, PendingCostMoveResume, PendingReplacement, StackEntryKind, - WaitingFor, + WaitingFor, ZoneDeliveryExileTracking, }; use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; @@ -60,6 +61,53 @@ fn redirect_moved_to(destination: Zone, redirected_to: Zone) -> ReplacementDefin )) } +/// CR 616.1: source-linked exile tracking is part of the parked zone-move +/// request and must survive an optional replacement choice. +#[test] +fn exile_tracking_parked_resume_preserves_source_link() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario.add_creature(P0, "Exile Source", 1, 1).id(); + scenario + .add_creature(P0, "Optional Exile Redirect", 0, 0) + .as_enchantment() + .with_replacement_definition( + redirect_moved_to(Zone::Exile, Zone::Graveyard) + .mode(ReplacementMode::Optional { decline: None }), + ); + let exiled = scenario + .add_creature_to_graveyard(P0, "Tracked Card", 1, 1) + .id(); + let mut runner = scenario.build(); + + let mut events = Vec::new(); + let paused = move_object_for_test( + runner.state_mut(), + ZoneMoveRequest::effect(exiled, Zone::Exile, source).track_exiled_by_source(), + &mut events, + ); + assert!(paused); + assert_eq!( + runner + .state() + .pending_replacement + .as_ref() + .map(|pending| pending.exile_tracking), + Some(ZoneDeliveryExileTracking::TrackBySource) + ); + + runner + .act(GameAction::ChooseReplacement { index: 1 }) + .expect("decline optional redirect"); + + assert_eq!(runner.state().objects[&exiled].zone, Zone::Exile); + assert!(runner.state().exile_links.iter().any(|link| { + link.exiled_id == exiled + && link.source_id == source + && matches!(link.kind, ExileLinkKind::TrackedBySource) + })); +} + /// W-R1 (red first): a Dig rest pile sent to the library bottom is an /// effect-owned batch. Competing Library-destination `Moved` replacements must /// pause before the kept tracked set is published, then re-pause safely while @@ -780,6 +828,7 @@ fn stage_prevented_cost_move(state: &mut GameState, source: engine::types::ident library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: engine::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, @@ -4534,6 +4583,7 @@ fn effect_pay_cost_composite_mana_life_prevention_serializes_and_rides_once() { library_placement: None, exile_controller: None, exile_duration: None, + exile_tracking: engine::types::game_state::ZoneDeliveryExileTracking::None, excess_recipient: None, lifelink_bonus: 0, may_cost_paid: false, diff --git a/crates/engine/tests/integration/palace_jailer.rs b/crates/engine/tests/integration/palace_jailer.rs index 344dd36e1e..50f750bf80 100644 --- a/crates/engine/tests/integration/palace_jailer.rs +++ b/crates/engine/tests/integration/palace_jailer.rs @@ -227,7 +227,81 @@ fn control_change_before_etb_resolution_preserves_trigger_controller() { assert_eq!(committed.state().objects[&target].zone, Zone::Battlefield); } -/// CR 603.7 + CR 610.3 + CR 725.1: An opponent becoming the monarch returns +/// CR 610.3b: If an opponent becomes the monarch after the triggered ability +/// triggered but before its initial exile effect occurs, the target does not +/// move and no return link is created. +#[test] +fn opponent_becomes_monarch_before_exile_trigger_resolves_prevents_exile() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + for player in [P0, P1] { + scenario.with_library_top(player, &["Filler 1", "Filler 2", "Filler 3", "Filler 4"]); + } + let jailer = scenario + .add_creature_to_hand_from_oracle(P0, "Palace Jailer", 2, 2, PALACE_JAILER) + .id(); + let target = scenario.add_creature(P1, "Exile Target", 2, 2).id(); + let crown = scenario + .add_spell_to_hand_from_oracle(P1, "Crown Yourself", true, CONTROLLER_BECOMES_MONARCH) + .id(); + + let mut runner = scenario.build(); + let mut committed = runner.cast(jailer).target_object(target).commit(); + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority for Palace Jailer"); + committed + .act(GameAction::PassPriority) + .expect("P1 can pass priority for Palace Jailer"); + committed + .act(GameAction::OrderTriggers { order: vec![0, 1] }) + .expect("the Palace Jailer ETB triggers must be ordered"); + + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority to the responding player"); + committed.cast(crown).commit(); + for _ in 0..4 { + if committed.state().monarch == Some(P1) { + break; + } + committed + .act(GameAction::PassPriority) + .expect("each player must be able to pass priority"); + } + assert_eq!( + committed.state().monarch, + Some(P1), + "reach-guard: an opponent became monarch while the exile trigger was on the stack" + ); + assert!(matches!( + committed.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert_eq!(committed.state().objects[&target].zone, Zone::Battlefield); + + for _ in 0..12 { + if committed.state().stack.is_empty() { + break; + } + committed + .act(GameAction::PassPriority) + .expect("priority must advance the Palace Jailer ETB triggers"); + } + + assert!(committed.state().stack.is_empty()); + assert_eq!(committed.state().objects[&target].zone, Zone::Battlefield); + assert!( + committed + .state() + .exile_links + .iter() + .all(|link| link.exiled_id != target), + "the suppressed initial one-shot effect must not install an exile link" + ); +} + +/// CR 610.3 + CR 725.1: An opponent becoming the monarch returns /// the exiled creature through the event-bounded link path. #[test] fn opponent_becoming_monarch_returns_exiled_creature() { @@ -248,7 +322,7 @@ fn opponent_becoming_monarch_returns_exiled_creature() { assert_eq!(runner.state().objects[&target].controller, P1); } -/// CR 725.1: The delayed condition is scoped to an opponent, so the controller +/// CR 725.1: The specified event is scoped to an opponent, so the controller /// becoming monarch does not satisfy it. #[test] fn controller_becoming_monarch_does_not_return_exiled_creature() { @@ -274,7 +348,7 @@ fn controller_becoming_monarch_does_not_return_exiled_creature() { ); } -/// CR 603.7d + CR 400.7: Once created, the event-bounded link survives its +/// CR 610.3: Once created, the event-bounded link survives its /// source moving to the graveyard and still returns the target on the matching event. #[test] fn palace_jailer_in_graveyard_does_not_change_the_delayed_return() { @@ -296,8 +370,8 @@ fn palace_jailer_in_graveyard_does_not_change_the_delayed_return() { assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); } -/// CR 603.7b + CR 514.2: No "this turn" duration is printed, so the delayed -/// trigger remains through cleanup and fires on a later turn. +/// CR 610.3: Cleanup is not the specified event, so the return remains pending +/// until an opponent becomes the monarch on a later turn. #[test] fn monarch_bounded_return_survives_a_turn_boundary() { let Board { @@ -337,7 +411,7 @@ fn any_opponent_becoming_monarch_returns_the_selected_creature() { assert_eq!(runner.state().objects[&target].zone, Zone::Battlefield); } -/// CR 603.7c + CR 610.3: If the object leaves exile before the event, the +/// CR 400.7 + CR 610.3: If the object leaves exile before the event, the /// event-bounded return's expected-origin guard makes it a no-op. #[test] fn target_leaving_exile_before_monarch_change_is_not_moved_again() { @@ -360,6 +434,6 @@ fn target_leaving_exile_before_monarch_change_is_not_moved_again() { assert_eq!( runner.state().objects[&target].zone, Zone::Battlefield, - "the delayed Exile -> Battlefield move must not re-exile or otherwise move a new object" + "the event-bounded Exile -> Battlefield move must not move a new object" ); } From f9af2fbcba19176d3c6f3ba1db0904c5c0357d72 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 16:10:03 -0700 Subject: [PATCH 6/8] docs(PR-7473): correct monarch-exile annotation Co-authored-by: traemyn --- crates/engine/src/parser/oracle_effect/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7167609cf3..ea784467ef 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -25130,10 +25130,10 @@ fn parse_imperative_effect(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl parse_imperative_effect_inner(tp, ctx) } -/// CR 603.7 + CR 610.3 + CR 725.1: An event-bounded exile creates the exile -/// immediately and a separate one-shot return effect when an opponent becomes -/// the monarch. The delayed payload keeps the chosen object as `ParentTarget` -/// and requires it to still be in exile when the return resolves. +/// CR 610.3 + CR 610.3b: This duration marks a zone-change effect that returns +/// its object immediately after an opponent becomes the monarch. The +/// `Duration::UntilOpponentBecomesMonarch` link also prevents the initial move +/// when that event occurred after the ability triggered but before it resolves. fn try_parse_exile_until_opponent_becomes_monarch_clause( tp: TextPair<'_>, ctx: &mut ParseContext, From 8bc7254fa991b842a38a48bdeafd12af3cc2a627 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 21:14:13 -0700 Subject: [PATCH 7/8] test(PR-7473): cover source-left duration timing Add a GameRunner regression for the generic CR 610.3b branch: an O-Ring-class source can leave while its linked-exile ETB waits on the stack, so the initial exile is suppressed and no link is installed. Co-authored-by: traemyn --- crates/engine/tests/integration/main.rs | 1 + .../until_source_leaves_cr610_3b.rs | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 crates/engine/tests/integration/until_source_leaves_cr610_3b.rs diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1e4666e955..e3faa263d9 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1057,6 +1057,7 @@ mod unmaterialized_lki_serialization; mod unravel_counter_mana_value; mod unstoppable_slasher_half_life; mod until_next_step_deadline_durations; +mod until_source_leaves_cr610_3b; mod urborg_scavengers_source_exiled_keyword_grant; mod ureni_attack_trigger; mod urge_to_feed_regression; diff --git a/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs b/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs new file mode 100644 index 0000000000..b8852fe35a --- /dev/null +++ b/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs @@ -0,0 +1,105 @@ +//! CR 610.3b regression for the generic linked-exile duration path. +//! +//! White Auracite is an O-Ring-class source: its ETB exile carries +//! `Duration::UntilHostLeavesPlay`. If the source leaves after that trigger is +//! on the stack but before it resolves, the initial exile does not happen and +//! no source-linked exile record is installed. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::Duration; +use engine::types::actions::GameAction; +use engine::types::game_state::StackEntryKind; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const WHITE_AURACITE: &str = "When this artifact enters, exile target nonland permanent an opponent controls until this artifact leaves the battlefield.\n{T}: Add {W}."; +const DESTROY_TARGET_ARTIFACT: &str = "Destroy target artifact."; + +/// CR 610.3b: If the specified event occurs after the linked-exile trigger is +/// put on the stack but before its initial zone change resolves, the target +/// stays on the battlefield and no exile link is created. +#[test] +fn source_leaving_before_linked_exile_trigger_resolves_prevents_initial_exile() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = { + let mut source = scenario.add_creature_to_hand(P0, "White Auracite", 0, 0); + source.as_artifact().from_oracle_text(WHITE_AURACITE); + source.id() + }; + let target = scenario + .add_creature(P1, "Opponent Nonland Permanent", 2, 2) + .as_enchantment() + .id(); + let destroy = scenario + .add_spell_to_hand_from_oracle(P1, "Destroy White Auracite", true, DESTROY_TARGET_ARTIFACT) + .id(); + + let mut runner = scenario.build(); + let mut committed = runner.cast(source).target_object(target).commit(); + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority for White Auracite"); + committed + .act(GameAction::PassPriority) + .expect("P1 can pass priority for White Auracite"); + assert_eq!(committed.state().objects[&source].zone, Zone::Battlefield); + committed + .act(GameAction::OrderTriggers { order: vec![0] }) + .expect("White Auracite's ETB trigger must be ordered"); + + let StackEntryKind::TriggeredAbility { ability, .. } = &committed + .state() + .stack + .back() + .expect("reach-guard: White Auracite's ETB trigger is on the stack") + .kind + else { + panic!("reach-guard: White Auracite's stack entry must be a triggered ability"); + }; + assert_eq!( + ability.duration, + Some(Duration::UntilHostLeavesPlay), + "reach-guard: parser synthesis must route this through the generic duration event" + ); + + committed + .act(GameAction::PassPriority) + .expect("P0 can pass priority to P1's response"); + committed.cast(destroy).target_object(source).resolve(); + + assert_eq!( + committed.state().objects[&source].zone, + Zone::Graveyard, + "reach-guard: the duration-ending source must leave while its ETB trigger waits" + ); + assert_eq!( + committed.state().objects[&target].zone, + Zone::Battlefield, + "the response must not move the ETB target" + ); + assert!( + committed.state().exile_links.is_empty(), + "no exile link can exist before the suppressed ETB trigger resolves" + ); + + for _ in 0..8 { + if committed.state().stack.is_empty() { + break; + } + committed + .act(GameAction::PassPriority) + .expect("priority must advance the pending ETB trigger"); + } + + assert!(committed.state().stack.is_empty()); + assert_eq!( + committed.state().objects[&target].zone, + Zone::Battlefield, + "CR 610.3b suppresses the initial exile after the source-left event" + ); + assert!( + committed.state().exile_links.is_empty(), + "the suppressed initial one-shot effect must not install an exile link" + ); +} From 4e4dcb8f3c77eabbb00a081bc2ad2e80f7ab427d Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 16 Aug 2026 22:06:50 -0700 Subject: [PATCH 8/8] test(PR-7473): drive source-left regression through priority Replace the nonexistent trigger-ordering prompt with a reach guard for the actual priority window after a single ETB trigger is stacked. Co-authored-by: traemyn --- .../integration/until_source_leaves_cr610_3b.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs b/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs index b8852fe35a..bf2be9854f 100644 --- a/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs +++ b/crates/engine/tests/integration/until_source_leaves_cr610_3b.rs @@ -8,7 +8,7 @@ use engine::game::scenario::{GameScenario, P0, P1}; use engine::types::ability::Duration; use engine::types::actions::GameAction; -use engine::types::game_state::StackEntryKind; +use engine::types::game_state::{StackEntryKind, WaitingFor}; use engine::types::phase::Phase; use engine::types::zones::Zone; @@ -44,9 +44,13 @@ fn source_leaving_before_linked_exile_trigger_resolves_prevents_initial_exile() .act(GameAction::PassPriority) .expect("P1 can pass priority for White Auracite"); assert_eq!(committed.state().objects[&source].zone, Zone::Battlefield); - committed - .act(GameAction::OrderTriggers { order: vec![0] }) - .expect("White Auracite's ETB trigger must be ordered"); + assert!( + matches!( + committed.state().waiting_for, + WaitingFor::Priority { player } if player == P0 + ), + "reach-guard: the single ETB trigger must be on the stack and return priority to P0" + ); let StackEntryKind::TriggeredAbility { ability, .. } = &committed .state()