Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
30 changes: 22 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,28 @@ 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.
// 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()
{
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 +6557,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
129 changes: 125 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,42 @@ 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. The exiled-card tail ("put the rest of
// the exiled cards …") is a distinct remainder set and must stay on
// the imperative `ExiledBySource` path.
let for_each_bound = defs.iter().rposition(|def| {
matches!(
&*def.effect,
Effect::ForEachCategory {
action: ForEachCategoryAction::ExileFromPool { .. },
..
}
)
});
if for_each_bound.is_some() && destination != Zone::Hand {
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 +4031,37 @@ 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 {
// CR 701.20a + CR 608.2c: Dynamic-count reveal-only Digs
// (`keep_count: 0`) return before `Dig.rest_destination` is
// applied at runtime. Emit an explicit `LastRevealed` sibling
// for the revealed-library remainder instead of patching an
// unused field (Sunbird's Invocation / Enshrined Memories class).
if !reorder_all && dig_needs_last_revealed_rest_sibling(&defs[bound_index].effect) {
let library_position =
(destination == Zone::Library).then_some(LibraryPosition::Bottom);
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,
random_order: false,
},
));
return;
}
patch_rest_destination_recursively(
&mut defs[bound_index],
destination,
reorder_all,
);
}
}
ContinuationAst::DigFromAmong {
quantity,
Expand Down Expand Up @@ -4925,6 +4988,38 @@ fn apply_search_destination_to_ability_chain(
}
}

/// CR 608.2c + CR 701.20b: True for "put the rest …" clauses that move the
/// revealed-library remainder after a per-category exile. False for the distinct
/// exiled-card tail ("put the rest of the exiled cards …"), which must bind to
/// `ExiledBySource` instead of `LastRevealed` / chain `TrackedSet`.
fn put_rest_targets_revealed_remainder(lower: &str) -> bool {
nom_primitives::scan_contains(lower, "put the rest")
&& !nom_primitives::scan_contains(lower, "of the exiled cards")
&& !nom_primitives::scan_contains(lower, "of those exiled cards")
}

/// CR 701.20a + CR 608.2c: True when a trailing PutRest must become an explicit
/// `LastRevealed` sibling rather than patching `Dig.rest_destination`. Matches
/// reveal-only Digs already at `keep_count: 0` and dynamic-count reveal Digs
/// that assembly demotes to `keep_count: 0` after continuations are applied.
fn dig_needs_last_revealed_rest_sibling(effect: &Effect) -> bool {
match effect {
Effect::Dig {
keep_count: Some(0),
reveal: true,
..
} => true,
Effect::Dig {
keep_count: None,
reveal: true,
filter: TargetFilter::Any,
count,
..
} => !matches!(count, QuantityExpr::Fixed { .. }),
_ => false,
}
}

/// Recursively patch `rest_destination` on Dig/RevealUntil effects reachable from
/// `def` via `else_ability`. CR 608.2c: When a preceding def is a conditional
/// "instead" wrapper (new_def with `else_ability = base_def`), a trailing
Expand Down Expand Up @@ -6615,6 +6710,32 @@ 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 put_rest_targets_revealed_remainder(&lower) =>
{
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 {
// "on the bottom", "on top of", and other library rest piles.
Zone::Library
};
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
20 changes: 19 additions & 1 deletion crates/engine/src/parser/oracle_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::types::ability::{
Comparator, ControllerRef, CountScope, DamageKindFilter, FilterProp, ObjectProperty,
ObjectScope, ParitySource, PlayerFilter, PtStat, PtValueScope, QuantityExpr, QuantityRef,
SeatDirection, SharedQuality, SharedQualityRelation, TargetFilter, TargetSelectionMode,
TypeFilter, TypedFilter,
ThisWayCause, TypeFilter, TypedFilter,
};
use crate::types::card_type::Supertype;
use crate::types::counter::{CounterMatch, CounterType};
Expand Down Expand Up @@ -1026,6 +1026,24 @@ pub fn parse_target_with_syntax<'a>(
return (filter, rest, syntax);
}

// CR 608.2c + CR 607.2a (Portent of Calamity): "the rest of the exiled cards"
// names the cards still linked to this resolution's exile step — not the bare
// "the rest" tracked-set anaphor, which can absorb unrelated chain members
// after an intervening revealed-library cleanup publishes to the chain set.
if let Ok((rest, _)) =
tag::<_, _, OracleError<'_>>("the rest of the exiled cards").parse(lower.as_str())
{
return (
TargetFilter::TrackedSetFiltered {
id: TrackedSetId(0),
filter: Box::new(TargetFilter::Any),
caused_by: Some(ThisWayCause::Exiled),
},
&text[lower.len() - rest.len()..],
syntax,
);
}

// CR 603.7: Anaphoric tracked-set pronouns
static TRACKED_SET_PHRASES: &[&str] = &[
"the chosen cards",
Expand Down
Loading
Loading