Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions crates/engine/src/game/effects/choose_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,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
74 changes: 70 additions & 4 deletions crates/engine/src/parser/oracle_effect/sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3975,6 +3975,40 @@ pub(super) fn apply_clause_continuation(
destination,
reorder_all,
} => {
// CR 608.2c + CR 701.20b (Portent of Calamity): After a per-category
// exile from among revealed cards, "put the rest into <zone>" moves
// the revealed cards still in the library — `LastRevealed` ∩ origin
// Library — NOT Dig.rest_destination (a keep_count-0 reveal Dig
// returns before applying rest) and NOT the chain tracked set of
// cards just exiled (which would dump the player's picks into the
// graveyard). Prefer this over Dig patching when both antecedents
// exist in the clause list.
let for_each_bound = defs.iter().rposition(|def| {
matches!(
&*def.effect,
Effect::ForEachCategory {
action: ForEachCategoryAction::ExileFromPool { .. },
..
}
)
});
if for_each_bound.is_some() {
defs.push(AbilityDefinition::new(
kind,
Effect::ChangeZoneAll {
origin: Some(Zone::Library),
destination,
target: TargetFilter::LastRevealed,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enter_with_counters: vec![],
face_down_profile: None,
library_position: None,
random_order: false,
},
));
return;
}
// Absorbed into preceding Dig or RevealUntil — sets rest_destination
// for unchosen/non-matching cards. CR 608.2c: When the preceding def is
// a conditional "instead" alternative (new def with `else_ability =
Expand All @@ -3995,10 +4029,13 @@ pub(super) fn apply_clause_continuation(
None,
super::assembly::OnMiss::Ignore,
);
let Some(bound_index) = bound else {
return;
};
patch_rest_destination_recursively(&mut defs[bound_index], destination, reorder_all);
if let Some(bound_index) = bound {
patch_rest_destination_recursively(
&mut defs[bound_index],
destination,
reorder_all,
);
}
}
ContinuationAst::DigFromAmong {
quantity,
Expand Down Expand Up @@ -6615,6 +6652,35 @@ pub(super) fn parse_followup_continuation_ast(
reorder_all: false,
})
}
// CR 608.2c + CR 701.20b (Portent of Calamity / Sanar class): "Put the
// rest into your graveyard" after a per-category exile from among the
// revealed cards. The rest are the revealed cards still in the library
// (not the cards just exiled into the chain tracked set).
Effect::ForEachCategory {
action: ForEachCategoryAction::ExileFromPool { .. },
..
} if nom_primitives::scan_contains(&lower, "put the rest") =>
{
let destination = if nom_primitives::scan_contains(&lower, "into your graveyard")
|| nom_primitives::scan_contains(&lower, "into their graveyard")
{
Zone::Graveyard
} else if nom_primitives::scan_contains(&lower, "into your hand")
|| nom_primitives::scan_contains(&lower, "into their hand")
{
Zone::Hand
} else if nom_primitives::scan_contains(&lower, "on the bottom")
|| nom_primitives::scan_contains(&lower, "on top of")
{
Zone::Library
} else {
Zone::Graveyard
};
Some(ContinuationAst::PutRest {
destination,
reorder_all: false,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// CR 701.20a + CR 608.2c: A reveal-until rest-pile clause may be
// separated from the RevealUntil by a transparent intervening effect
// ("~ deals damage equal to that card's mana value. Put that card into
Expand Down
Loading
Loading