Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1666,6 +1666,45 @@ pub fn resolve_all(
state.push_devour_change_zone_snapshot(state.battlefield.iter().copied().collect());
}

// CR 401.4: When multiple objects are placed at the same library position
// simultaneously and `random_order` is false, the owner arranges their
// relative order ("in any order" restates this default). Route through the
// shared `EffectZoneChoice` + `PutAtLibraryPosition` production path instead
// of silently picking an engine-default batch order.
if dest_zone == Zone::Library
&& effect_library_position.is_some()
&& !random_order
&& matching.len() > 1
{
let choice_count = matching.len();
state.waiting_for = WaitingFor::EffectZoneChoice {
player: filter_controller,
cards: matching,
count: choice_count,
min_count: choice_count,
up_to: false,
source_id: ability.source_id,
effect_kind: EffectKind::PutAtLibraryPosition,
zone: Zone::Library,
destination: None,
enter_tapped: EtbTapState::Unspecified,
enter_transformed: false,
enters_under_player: None,
enters_attacking: false,
owner_library: false,
track_exiled_by_source: false,
face_down_profile: None,
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
count_param: 0,
library_position: effect_library_position.clone(),
is_cost_payment: false,
enters_modified_if: None,
duration: ability.duration.clone(),
};
return Ok(());
}

// CR 401.4: When placing objects on the bottom of a library "in a random
// order", randomize the processing order so the final bottom-to-top sequence
// is non-deterministic without shuffling the rest of the library. Top
Expand Down
14 changes: 13 additions & 1 deletion crates/engine/src/game/effects/choose_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,13 @@ pub(crate) fn complete_per_category_exile(
events: &mut Vec<GameEvent>,
) {
if !chosen.is_empty() {
super::publish_tracked_set(state, chosen);
super::publish_tracked_set_with_causes(
state,
chosen
.iter()
.map(|&id| (id, Some(crate::types::ability::ThisWayCause::Exiled)))
.collect(),
);
}
let _ = prompt_next_category_member(state, &ability, &pool, remaining_member_filters, events);
}
Expand Down Expand Up @@ -556,6 +562,12 @@ fn resolve_category_pool(state: &GameState, ability: &ResolvedAbility) -> Vec<Ob
crate::game::targeting::latest_tracked_set_id(state)
.and_then(|id| state.tracked_object_sets.get(&id).cloned())
})
// CR 701.20b + CR 608.2c: Reveal-only Dig / RevealTop may leave the
// revealed pile only in `last_revealed_ids` when no TrackedSet consumer
// forced publication (Portent of Calamity: Dig → ForEachCategory →
// LastRevealed rest-move). Prefer the live reveal window over an empty
// ability-target fallback.
.or_else(|| (!state.last_revealed_ids.is_empty()).then(|| state.last_revealed_ids.clone()))
.unwrap_or_else(|| {
ability
.targets
Expand Down
45 changes: 31 additions & 14 deletions crates/engine/src/game/effects/dig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,21 +141,38 @@ pub fn resolve(
.collect::<Vec<_>>();
let raw_keep_count = raw_keep_num.min(cards.len());

// CR 701.20e: Pure-peek pattern (keep_count = 0): "look at the top card" with no
// player selection — the sub_ability condition decides whether to take it. Set
// last_revealed_ids so RevealedHasCardType can evaluate, then return without
// creating a DigChoice interaction.
if raw_keep_count == 0 && !is_reveal {
// CR 701.20e / CR 701.20a: Pure-peek pattern (keep_count = 0): "look at" /
// "reveal the top N" with no player selection on this step — a following
// ForEachCategory / LastRevealed move decides disposition (Portent of
// Calamity). Set last_revealed_ids (and emit CardsRevealed for public
// reveals) then return without creating a DigChoice interaction.
if raw_keep_count == 0 {
state.last_revealed_ids = cards.clone();
// CR 701.20e: "look at" privately reveals the cards to the looking
// player. The looker is the ability controller (e.g. Delver of Secrets'
// "look at the top card of your library"). Record the looker-scoped peek
// window so `filter_state_for_viewer` keeps these cards visible to the
// looker — and only the looker — through any subsequent "you may reveal
// that card" optional decision, instead of leaving the looking player to
// choose blind.
state.private_look_ids = cards.clone();
state.private_look_player = Some(ability.controller);
if is_reveal {
// CR 701.20a: public reveal — show to all players.
for &card_id in &cards {
state.revealed_cards.insert(card_id);
}
let card_names: Vec<String> = cards
.iter()
.filter_map(|id| state.objects.get(id).map(|o| o.name.clone()))
.collect();
events.push(GameEvent::CardsRevealed {
player: ability.controller,
card_ids: cards.clone(),
card_names,
});
} else {
// CR 701.20e: "look at" privately reveals the cards to the looking
// player. The looker is the ability controller (e.g. Delver of Secrets'
// "look at the top card of your library"). Record the looker-scoped peek
// window so `filter_state_for_viewer` keeps these cards visible to the
// looker — and only the looker — through any subsequent "you may reveal
// that card" optional decision, instead of leaving the looking player to
// choose blind.
state.private_look_ids = cards.clone();
state.private_look_player = Some(ability.controller);
}
events.push(GameEvent::EffectResolved {
kind: EffectKind::from(&ability.effect),
source_id: ability.source_id,
Expand Down
13 changes: 11 additions & 2 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4742,8 +4742,17 @@ fn affected_objects_from_events(
// CR 701.20b + CR 608.2c: Reveal instructions do not move cards, so they
// emit `CardsRevealed` rather than `ZoneChanged`. Publish the revealed
// card ids for downstream "from among the revealed cards"
// `ChooseFromZone` continuations (Atraxa, Grand Unifier class).
Effect::RevealTop { .. } | Effect::RevealHand { .. } | Effect::Clash => events
// `ChooseFromZone` / `ForEachCategory` continuations (Atraxa, Portent of
// Calamity). Reveal-only Digs with `keep_count: 0` take the same path —
// they never emit ZoneChanged either.
Effect::RevealTop { .. }
| Effect::RevealHand { .. }
| Effect::Clash
| Effect::Dig {
reveal: true,
keep_count: Some(0),
..
} => events
.iter()
.filter_map(|event| match event {
GameEvent::CardsRevealed { card_ids, .. } => Some(card_ids.as_slice()),
Expand Down
34 changes: 26 additions & 8 deletions crates/engine/src/parser/oracle_effect/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2302,6 +2302,11 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition {
// continuation patched it. An unpatched Dig { reveal: true, keep_count: None, filter: Any }
// is a simple "reveal the top N" with no player selection — it must resolve synchronously
// (via RevealTop) so that sub_ability chains like RevealedHasCardType evaluate inline.
//
// CR 107.3 + CR 701.20a: Dynamic counts (Portent of Calamity's "top X cards") cannot
// round-trip through `RevealTop { count: u32 }` without collapsing to 1. Keep those
// Digs as reveal-only peeks (`keep_count: 0`) so X resolves at runtime; a later
// ForEachCategory / LastRevealed rest-move consumes the revealed pool.
for def in &mut defs {
if let Effect::Dig {
count,
Expand All @@ -2317,14 +2322,27 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition {
if destination == &Some(Zone::Library) && rest_destination == &Some(Zone::Library) {
continue;
}
let count_val = match count {
QuantityExpr::Fixed { value } => *value as u32,
_ => 1,
};
*def.effect = Effect::RevealTop {
player: player.clone(),
count: count_val,
};
match count {
QuantityExpr::Fixed { value } => {
*def.effect = Effect::RevealTop {
player: player.clone(),
count: *value as u32,
};
}
_ => {
if let Effect::Dig {
keep_count,
destination,
rest_destination,
..
} = &mut *def.effect
{
*keep_count = Some(0);
*destination = None;
*rest_destination = None;
}
}
}
}
}

Expand Down
33 changes: 25 additions & 8 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2359,10 +2359,13 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect {
enter_tapped,
enter_with_counters,
} => {
let origin = if matches!(target, TargetFilter::ExiledBySource) {
Some(Zone::Exile)
} else {
origin
let origin = match &target {
TargetFilter::ExiledBySource => Some(Zone::Exile),
TargetFilter::TrackedSetFiltered {
caused_by: Some(crate::types::ability::ThisWayCause::Exiled),
..
} => Some(Zone::Exile),
_ => origin,
};
Effect::ChangeZoneAll {
origin,
Expand Down Expand Up @@ -6524,17 +6527,31 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect {
choice_count: _,
enter_with_counters,
} => {
// CR 610.3: Mass filters (ExiledBySource, TrackedSet) act on all matching
// objects without individual targeting — use ChangeZoneAll.
// CR 610.3: Mass filters (ExiledBySource, TrackedSet,
// TrackedSetFiltered) act on all matching objects without individual
// targeting — use ChangeZoneAll. Bounded "up to N" picks from the
// tracked set ("put up to one land discarded this way") remain
// `ChangeZone` so the player selects a subset at resolution.
// ExiledBySource always originates from Exile regardless of inferred zone.
// CR 122.1: ChangeZoneAll has no counter-stamping channel — those
// patterns are single-target only in current Oracle text, so the
// mass-filter branch deliberately drops `enter_with_counters`.
if matches!(
target,
TargetFilter::ExiledBySource | TargetFilter::TrackedSet { .. }
TargetFilter::ExiledBySource
| TargetFilter::TrackedSet { .. }
| TargetFilter::TrackedSetFiltered { .. }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) && enter_with_counters.is_empty()
&& !up_to
{
let origin = match target {
TargetFilter::TrackedSetFiltered {
caused_by: Some(crate::types::ability::ThisWayCause::Exiled),
..
} => origin.or(Some(Zone::Exile)),
TargetFilter::TrackedSetFiltered { .. } => origin,
_ => origin.or(Some(Zone::Exile)),
};
Effect::ChangeZoneAll {
// CR 608.2c + CR 400.7: A tracked-set / impulse mass move
// defaults to scanning Exile (cascade, impulse-draw, and the
Expand All @@ -6543,7 +6560,7 @@ pub(super) fn lower_put_ast(ast: PutImperativeAst) -> Effect {
// (Breach the Multiverse's graveyard choose stamps
// `origin: Some(Graveyard)` in `parse_put_ast`), honor it so
// the chosen cards are read out of the right zone.
origin: origin.or(Some(Zone::Exile)),
origin,
destination,
target,
// CR 110.2a: Preserve the parsed entering-controller override
Expand Down
Loading
Loading