Skip to content
2 changes: 2 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2032,6 +2032,7 @@ fn legacy_duration(x: &Duration) -> bool {
| Duration::UntilEndOfCombat
| Duration::UntilHostLeavesPlay
| Duration::UntilSourceExilesAnotherCard
| Duration::UntilOpponentBecomesMonarch
| Duration::Permanent
| Duration::UntilNextTurnOf { .. }
| Duration::UntilEndOfNextTurnOf { .. }
Expand Down Expand Up @@ -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, .. }
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {} ({})",
Expand Down
62 changes: 40 additions & 22 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameEvent>) {
let mut to_return: Vec<crate::types::game_state::ExileLink> = 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());
}
}
}
}
_ => {}
}
}

Expand Down Expand Up @@ -14823,11 +14838,14 @@ pub(super) fn check_exile_returns(state: &mut GameState, events: &mut Vec<GameEv
if !still_in_exile {
continue;
}
let crate::types::game_state::ExileLinkKind::UntilSourceLeaves { return_zone } = &link.kind
else {
continue;
let return_zone = match &link.kind {
crate::types::game_state::ExileLinkKind::UntilSourceLeaves { return_zone }
| crate::types::game_state::ExileLinkKind::UntilOpponentBecomesMonarch {
return_zone,
..
} => *return_zone,
_ => continue,
};
let return_zone = *return_zone;
let gi = match groups.iter().position(|(zone, _)| *zone == return_zone) {
Some(i) => i,
None => {
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_ if matches!(exile_tracking, ZoneDeliveryExileTracking::TrackBySource) => {
Some(ExileLinkKind::TrackedBySource)
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
56 changes: 56 additions & 0 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25130,7 +25130,63 @@ 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<ParsedEffectClause> {
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);
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;
}

clause.duration = Some(Duration::UntilOpponentBecomesMonarch);
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);
}
Expand Down
42 changes: 42 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19045,6 +19045,48 @@ fn delayed_trigger_in_effect_chain() {
));
}

/// 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(
"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(_),
..
}
));
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]
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.\"");
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -29513,6 +29516,7 @@ mod tests {
},
Duration::UntilHostLeavesPlay,
Duration::UntilSourceExilesAnotherCard,
Duration::UntilOpponentBecomesMonarch,
Duration::Permanent,
];
let json = serde_json::to_string(&durations).unwrap();
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// 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
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ mod oversimplify_per_player_fractal;
mod ozolith_leaves_battlefield_counters;
mod pain_magnification_single_source_damage;
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;
Expand Down
Loading
Loading