Skip to content
1 change: 1 addition & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions client/src/network/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
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
45 changes: 41 additions & 4 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -803,7 +836,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,
Expand All @@ -819,6 +852,7 @@ pub fn resolve(
track_exiled_by_source,
None,
None,
Some(ability.controller),
events,
) {
ZoneMoveResult::Done => {
Expand Down Expand Up @@ -887,7 +921,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,
Expand All @@ -903,6 +937,7 @@ pub fn resolve(
track_exiled_by_source,
None,
None,
Some(ability.controller),
events,
) {
ZoneMoveResult::Done => {
Expand Down Expand Up @@ -1485,7 +1520,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,
Expand All @@ -1501,6 +1536,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,
);

Expand Down Expand Up @@ -1961,7 +1997,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,
Expand All @@ -1977,6 +2013,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) => {
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ fn apply_pending_counter_post_action(
cause,
source_id,
duration,
exile_controller,
exile_tracking,
enters_attacking,
drain,
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/effects/discard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -378,6 +379,7 @@ pub fn resolve(
zone_event,
None,
None,
None,
false,
crate::types::game_state::PostReplacementDrainOwner::DeliveryTail,
None,
Expand Down Expand Up @@ -780,6 +782,7 @@ fn route_discard(
zone_event,
None,
None,
None,
false,
crate::types::game_state::PostReplacementDrainOwner::DeliveryTail,
None,
Expand Down
20 changes: 20 additions & 0 deletions crates/engine/src/game/elimination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down Expand Up @@ -1609,6 +1612,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,
Expand All @@ -1626,6 +1630,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,
Expand Down Expand Up @@ -2599,6 +2604,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down Expand Up @@ -2734,6 +2742,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down Expand Up @@ -2782,6 +2793,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down Expand Up @@ -2821,6 +2835,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down Expand Up @@ -2890,6 +2907,9 @@ mod tests {
depth: 0,
is_optional: false,
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,
Expand Down
Loading
Loading