From 145698640c8ab5cea95f9307c012fa91e0e3f348 Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:35:15 +0200 Subject: [PATCH 1/2] Fix Diluvian Primordial --- crates/engine/src/game/ability_utils.rs | 84 +++++ .../engine/src/game/effects/cast_from_zone.rs | 46 +++ .../src/game/effects/free_cast_from_zones.rs | 299 ++++++++++++---- .../src/parser/oracle_effect/assembly.rs | 4 +- .../engine/src/parser/oracle_effect/lower.rs | 60 +++- crates/engine/src/parser/oracle_effect/mod.rs | 74 +++- .../engine/src/parser/oracle_effect/tests.rs | 144 +++++++- .../integration/diluvian_primordial_6754.rs | 332 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + docs/parser-misparse-backlog.md | 1 - 10 files changed, 966 insertions(+), 79 deletions(-) create mode 100644 crates/engine/tests/integration/diluvian_primordial_6754.rs diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index b0395dcde7..f97b2955a6 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -11103,6 +11103,90 @@ mod tests { ); } + /// CR 115.1a + CR 108.3: The sole nonbattlefield per-opponent fanout class + /// binds each graveyard card to its immediately preceding opponent target. + /// The positive paired cards prove the path is reachable; a wrong-owner card + /// and a battlefield lookalike prove neither owner nor zone is widened. + #[test] + fn per_opponent_graveyard_fanout_pairs_only_each_opponents_typed_card() { + use crate::types::ability::{CardPlayMode, CastFromZoneDriver}; + + let mut state = GameState::new(FormatConfig::standard(), 3, 42); + let add_instant = |state: &mut GameState, owner, zone, card_id| { + let id = create_object( + state, + CardId(card_id), + owner, + format!("Instant {card_id}"), + zone, + ); + state + .objects + .get_mut(&id) + .unwrap() + .card_types + .core_types + .push(CoreType::Instant); + id + }; + let p1_graveyard = add_instant(&mut state, PlayerId(1), Zone::Graveyard, 1); + let p2_graveyard = add_instant(&mut state, PlayerId(2), Zone::Graveyard, 2); + let _wrong_owner = add_instant(&mut state, PlayerId(0), Zone::Graveyard, 3); + let _battlefield_lookalike = add_instant(&mut state, PlayerId(1), Zone::Battlefield, 4); + + let filter = TargetFilter::Typed( + TypedFilter::new(TypeFilter::Instant) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ); + let mut ability = ResolvedAbility::new( + Effect::CastFromZone { + target: filter, + without_paying_mana_cost: true, + mode: CardPlayMode::Cast, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: None, + driver: CastFromZoneDriver::DuringResolution, + mana_spend_permission: None, + }, + vec![], + ObjectId(900), + PlayerId(0), + ); + ability.target_choice_timing = TargetChoiceTiming::Stack; + ability.multi_target = Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )); + + let slots = build_target_slots(&state, &ability).expect("paired graveyard slots"); + assert_eq!(slots.len(), 4, "one player/object pair per opponent"); + assert_eq!(slots[0].legal_targets, vec![TargetRef::Player(PlayerId(1))]); + assert_eq!( + slots[1].legal_targets, + vec![TargetRef::Object(p1_graveyard)], + "P1's object slot excludes the wrong owner and battlefield lookalike" + ); + assert_eq!(slots[2].legal_targets, vec![TargetRef::Player(PlayerId(2))]); + assert_eq!( + slots[3].legal_targets, + vec![TargetRef::Object(p2_graveyard)] + ); + } + #[test] fn per_opponent_gain_control_runtime_transfers_all_objects_and_preserves_tail() { let mut state = GameState::new(FormatConfig::standard(), 3, 42); diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 800a6ab934..f559e4cb07 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -601,6 +601,52 @@ pub fn resolve( return Ok(()); } + // CR 608.2g + CR 115.1a: A per-opponent fanout has already chosen its + // player/object pairs as the trigger went on the stack. After resolution + // revalidation only the surviving object ids remain, so hand them to the + // existing free-cast window as an exact pool: do not rescan graveyards and + // do not substitute another card from the same opponent. The window's + // re-offer pipeline casts selected spells one at a time without priority. + let is_per_opponent_fanout = matches!( + ability + .multi_target + .as_ref() + .and_then(|spec| spec.max.as_ref()), + Some(QuantityExpr::Ref { + qty: crate::types::ability::QuantityRef::PlayerCount { + filter: crate::types::ability::PlayerFilter::Opponent + } + }) + ); + let graveyard_destination = cast_from_zone_graveyard_destination(ability); + if driver.is_during_resolution() + && without_paying + && alt_ability_cost.is_none() + && is_per_opponent_fanout + && !target_ids.is_empty() + && matches!( + graveyard_destination, + None | Some(SpellStackToGraveyardReplacement::Exile) + ) + { + let mut window = ability.clone(); + window.effect = Effect::FreeCastFromZones { + count: target_ids.len().try_into().unwrap_or(u8::MAX), + max_total_mv: None, + filter: target_filter.clone(), + zones: vec![Zone::Graveyard], + // The CastFromZone rider is stored as a sequential ParentTarget + // sub-ability; FreeCastWindow carries its Exile destination as + // per-cast metadata instead of installing a source-global effect. + exile_instead_of_graveyard: matches!( + graveyard_destination, + Some(SpellStackToGraveyardReplacement::Exile) + ), + }; + window.targets = target_ids.drain(..).map(TargetRef::Object).collect(); + return super::free_cast_from_zones::resolve(state, &window, events); + } + if driver_free_cast || immediate_graveyard_free_cast { // CR 608.2g: both gates require `alt_ability_cost.is_none()`, so the // pre-targeted free-cast path never carries a borrowed keyword cost — diff --git a/crates/engine/src/game/effects/free_cast_from_zones.rs b/crates/engine/src/game/effects/free_cast_from_zones.rs index 4c1396473d..a76d26810a 100644 --- a/crates/engine/src/game/effects/free_cast_from_zones.rs +++ b/crates/engine/src/game/effects/free_cast_from_zones.rs @@ -124,12 +124,14 @@ pub fn resolve( /// resolve their exile links against it, so the real id must be threaded /// rather than a sentinel. /// -/// `member_pool`, when non-empty, is THIS resolution's concrete "exiled this -/// way" batch (the pool the preceding `ChooseFromZone` offered): candidates -/// must belong to it, applied BEFORE the filter's `Not(InTrackedSet)` -/// chosen-card exclusion so a stale linked card from a previous resolution is -/// never offered (CR 607.2a — "the other cards exiled this way" is scoped to -/// this resolution's exile batch). Empty means no batch restriction. +/// `member_pool`, when non-empty, is THIS resolution's concrete approved pool: +/// candidates are enumerated from those exact ids, rather than from a zone scan. +/// It originally served "exiled this way" batches, and also carries paired +/// opponent-graveyard targets into the same window. In the latter case the +/// player target has intentionally been consumed before this resolver runs, so +/// `TargetPlayer`/`Owned(TargetPlayer)` are not evaluated again; zone, type, +/// cast legality, and every unrelated filter property remain authoritative. +/// Empty retains Invoke Calamity's existing controller-zone enumeration. pub(crate) fn eligible_candidates( state: &GameState, controller: PlayerId, @@ -144,72 +146,122 @@ pub(crate) fn eligible_candidates( }; let ctx = FilterContext::from_source_with_controller(source, controller); + let member_pool_filter = member_pool_filter(filter); let mut candidates = Vec::new(); - for &zone in zones { - let zone_ids = match zone { - Zone::Graveyard => &player.graveyard, - Zone::Hand => &player.hand, - // CR 400.10a + CR 608.2g: Exile is a shared zone — the whole pile - // is scanned and the `filter` (e.g. `ExiledBySource` + - // `Not(InTrackedSet)`) narrows it to this resolution's linked set - // regardless of who owns the exiled cards (Plargg and Nassari - // exiles from EVERY player's library). - Zone::Exile => &state.exile, - // CR 601.2a: Other zones would need a parser/effect change, so an - // unexpected zone contributes no candidates rather than silently - // scanning the wrong pile. - _ => continue, - }; - for &id in zone_ids { - // CR 607.2a: Confine the offer to THIS resolution's exile batch - // before any other narrowing — a card linked by a previous - // resolution is not among "the cards exiled this way" now. - if !member_pool.is_empty() && !member_pool.contains(&id) { - continue; - } - // CR 305.1: a land card can never be cast — this window is a cast - // grant, so lands are excluded from the offer outright (mirrors - // `cast_from_zone`'s cast-mode land guard). - if state.objects.get(&id).is_some_and(|obj| { - obj.card_types - .core_types - .contains(&crate::types::card_type::CoreType::Land) - }) { - continue; - } - if !matches_target_filter_in_owner_zone(state, id, filter, &ctx) { - continue; - } - // CR 601.2c + CR 608.2g: A spell cast during resolution still - // needs every required target to be legal before it can be offered. - let Some(obj) = state.objects.get(&id) else { - continue; + let candidate_ids: Vec = if member_pool.is_empty() { + let mut ids = Vec::new(); + for &zone in zones { + let zone_ids = match zone { + Zone::Graveyard => &player.graveyard, + Zone::Hand => &player.hand, + // CR 400.10a + CR 608.2g: Exile is a shared zone — the whole pile + // is scanned and the `filter` (e.g. `ExiledBySource` + + // `Not(InTrackedSet)`) narrows it to this resolution's linked set + // regardless of who owns the exiled cards (Plargg and Nassari + // exiles from EVERY player's library). + Zone::Exile => &state.exile, + // CR 601.2a: Other zones would need a parser/effect change, so an + // unexpected zone contributes no candidates rather than silently + // scanning the wrong pile. + _ => continue, }; - if !crate::game::casting::spell_has_legal_targets(state, obj, controller) { + ids.extend(zone_ids.iter().copied()); + } + ids + } else { + member_pool.to_vec() + }; + + for id in candidate_ids { + if !state + .objects + .get(&id) + .is_some_and(|object| zones.contains(&object.zone)) + { + continue; + } + // CR 305.1: a land card can never be cast — this window is a cast + // grant, so lands are excluded from the offer outright (mirrors + // `cast_from_zone`'s cast-mode land guard). + if state.objects.get(&id).is_some_and(|obj| { + obj.card_types + .core_types + .contains(&crate::types::card_type::CoreType::Land) + }) { + continue; + } + let candidate_filter = if member_pool.is_empty() { + filter + } else { + &member_pool_filter + }; + if !matches_target_filter_in_owner_zone(state, id, candidate_filter, &ctx) { + continue; + } + // CR 601.2c + CR 608.2g: A spell cast during resolution still + // needs every required target to be legal before it can be offered. + let Some(obj) = state.objects.get(&id) else { + continue; + }; + if !crate::game::casting::spell_has_legal_targets(state, obj, controller) { + continue; + } + // CR 202.3 + CR 107.3b + CR 601.2b: Respect the running MV budget. + // Because this window casts without paying a mana cost, X can only + // be announced as 0, so the card's printed mana_value() is the same + // value used when the choice is submitted. + if let Some(budget) = max_total_mv { + let mv = state + .objects + .get(&id) + // CR 202.3d + CR 709.4b: candidate cards are in a non-stack + // zone, so a split card's MV budget is its combined halves. + .map(|obj| obj.effective_mana_value()) + .unwrap_or(0); + if mv > budget { continue; } - // CR 202.3 + CR 107.3b + CR 601.2b: Respect the running MV budget. - // Because this window casts without paying a mana cost, X can only - // be announced as 0, so the card's printed mana_value() is the same - // value used when the choice is submitted. - if let Some(budget) = max_total_mv { - let mv = state - .objects - .get(&id) - // CR 202.3d + CR 709.4b: candidate cards are in a non-stack - // zone, so a split card's MV budget is its combined halves. - .map(|obj| obj.effective_mana_value()) - .unwrap_or(0); - if mv > budget { - continue; - } - } - candidates.push(id); } + candidates.push(id); } candidates } +/// CR 608.2g + CR 115.1a: A fixed member pool already proves which paired +/// player selected each object. Strip only that consumed player binding before +/// re-offering the exact ids; every type, zone, and cast-legality check remains. +fn member_pool_filter(filter: &TargetFilter) -> TargetFilter { + use crate::types::ability::{ControllerRef, FilterProp}; + + match filter { + TargetFilter::Typed(tf) => { + let mut tf = tf.clone(); + if tf.controller == Some(ControllerRef::TargetPlayer) { + tf.controller = None; + } + tf.properties.retain(|prop| { + !matches!( + prop, + FilterProp::Owned { + controller: ControllerRef::TargetPlayer + } + ) + }); + TargetFilter::Typed(tf) + } + TargetFilter::And { filters } => TargetFilter::And { + filters: filters.iter().map(member_pool_filter).collect(), + }, + TargetFilter::Or { filters } => TargetFilter::Or { + filters: filters.iter().map(member_pool_filter).collect(), + }, + TargetFilter::Not { filter } => TargetFilter::Not { + filter: Box::new(member_pool_filter(filter)), + }, + other => other.clone(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -343,6 +395,127 @@ mod tests { assert_eq!(candidates, vec![mine]); } + /// CR 608.2g + CR 115.1a: A nonempty member pool is the enumeration + /// authority. It preserves exactly the paired opponent-graveyard targets, + /// skips their consumed TargetPlayer/Owned binding, and never substitutes + /// another matching card from either graveyard on the initial offer or a + /// re-offer after one pool member becomes illegal. + #[test] + fn member_pool_keeps_exact_opponent_graveyard_targets_without_substitutes() { + use crate::types::ability::{ControllerRef, FilterProp}; + use crate::types::FormatConfig; + + let mut state = GameState::new(FormatConfig::standard(), 3, 1); + let selected_p1 = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let selected_p2 = add_card( + &mut state, + PlayerId(2), + Zone::Graveyard, + CoreType::Sorcery, + 1, + ); + let _p1_extra = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let _p2_extra = add_card( + &mut state, + PlayerId(2), + Zone::Graveyard, + CoreType::Sorcery, + 1, + ); + let filter = TargetFilter::Typed( + TypedFilter::new(TypeFilter::AnyOf(vec![ + TypeFilter::Instant, + TypeFilter::Sorcery, + ])) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ); + + let pool = [selected_p1, selected_p2]; + let candidates = eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &filter, + &[Zone::Graveyard], + None, + &pool, + ); + assert_eq!( + candidates, + vec![selected_p1, selected_p2], + "the type filter still applies, but only the approved paired targets may be offered" + ); + + state.objects.get_mut(&selected_p1).unwrap().zone = Zone::Exile; + let reoffered = eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &filter, + &[Zone::Graveyard], + None, + &pool, + ); + assert!( + reoffered == vec![selected_p2], + "an illegal selected target must disappear rather than be replaced by a graveyard extra" + ); + } + + /// Invoke Calamity has no fixed pool, so its established own-zone scan must + /// remain the authority rather than attempting paired-target semantics. + #[test] + fn empty_member_pool_retains_invoke_calamity_controller_zone_scan() { + let mut state = GameState::new_two_player(1); + let mine = add_card( + &mut state, + PlayerId(0), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let _opponent = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + assert_eq!( + eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &instant_sorcery_filter(), + &[Zone::Graveyard], + None, + &[], + ), + vec![mine], + "empty pool preserves Invoke Calamity's controller-graveyard scan" + ); + } + /// CR 608.2g: An empty candidate set sets no pause and emits EffectResolved, /// so the continuation (Exile ~) runs immediately. #[test] diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 3f9a8d473c..703e5112d2 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -1795,8 +1795,8 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // destination-replacement on the cast spell. Fold the canonical rider // onto the prior cast so the runtime stamps the redirect, intercepting it // before the generic chain assembly mistakes a `PutAtLibraryPosition{ - // Bottom}` for the Sanwell free-cast bottom-cleanup. Exile is left to its - // existing clean path (the helper declines it). + // Bottom}` for the Sanwell free-cast bottom-cleanup. Every destination, + // including exile, becomes the same ParentTarget rider shape. if let Some(dest) = parse_spell_graveyard_replacement_rider( &clause_ir .source diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 292f29bd62..3d7ea12822 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -1028,11 +1028,10 @@ pub(super) fn attach_any_color_mana_rider_to_previous_play_from_exile( /// count 0, and duplicating it into a bogus `else_ability`). Building the rider /// directly here bypasses that generic mis-route. /// -/// The exile destination is intentionally NOT handled here: it already lowers to -/// the clean `ChangeZone{Exile, ParentTarget}` sub-ability via the general -/// anaphor rebind (Torrential Gearhulk), and its effect shape never trips the -/// bottom-cleanup detector. Returns `false` (no fold) for exile so that path is -/// left undisturbed. +/// Exile uses the same canonical `ParentTarget` rider shape as library/hand. +/// That matters for "a spell cast this way": its grammatical subject is not a +/// pronoun for the generic anaphor rebind, but the rider must still attach to +/// the preceding cast rather than become a source-global replacement. pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( defs: &mut [AbilityDefinition], dest: SpellStackToGraveyardReplacement, @@ -1058,8 +1057,21 @@ pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( face_down_profile: None, enters_modified_if: None, }, - // Exile keeps its existing clean `ChangeZone{Exile, ParentTarget}` path. - SpellStackToGraveyardReplacement::Exile => return false, + SpellStackToGraveyardReplacement::Exile => Effect::ChangeZone { + origin: Some(Zone::Stack), + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, }; let Some(prev) = defs.last_mut() else { return false; @@ -3958,10 +3970,42 @@ fn is_per_opponent_target_fanout_clause(clause: &ParsedEffectClause) -> bool { } clause.effect.target_filter().is_some_and(|filter| { target_filter_controller_ref(filter) == Some(ControllerRef::TargetPlayer) - && target_filter_is_single_object_target(filter) + && (target_filter_is_single_object_target(filter) + || target_filter_is_explicit_target_player_graveyard_card(filter)) }) } +/// CR 115.1a + CR 108.3: The per-opponent fanout normally targets battlefield +/// objects. This is the sole nonbattlefield exception: an explicit typed card +/// target in a paired opponent's graveyard. An `Or` is allowed only when every +/// branch independently carries that complete binding; it must not inherit the +/// controller, ownership, or zone restriction from a sibling. This keeps "that +/// player's graveyard" tied to the immediately preceding player target instead +/// of broadly enabling all nonbattlefield fanout filters. +pub(super) fn target_filter_is_explicit_target_player_graveyard_card( + filter: &TargetFilter, +) -> bool { + match filter { + TargetFilter::Typed(tf) => { + tf.controller == Some(ControllerRef::TargetPlayer) + && !tf.type_filters.is_empty() + && tf.properties.contains(&FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }) + && tf.properties.contains(&FilterProp::InZone { + zone: Zone::Graveyard, + }) + } + TargetFilter::Or { filters } => { + !filters.is_empty() + && filters + .iter() + .all(target_filter_is_explicit_target_player_graveyard_card) + } + _ => false, + } +} + pub(crate) fn target_filter_is_single_object_target(filter: &TargetFilter) -> bool { let zones = filter.extract_zones(); if !zones.is_empty() && zones.iter().any(|zone| *zone != Zone::Battlefield) { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 687f5f561d..217cca2c7d 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -18054,9 +18054,11 @@ fn parse_spell_graveyard_replacement_rider( use nom::bytes::complete::tag; use nom::combinator::{all_consuming, value}; use nom::Parser; - let trimmed = lower.trim().trim_end_matches('.').trim(); - let (_, (_, _, _, dest)) = all_consuming(( - tag::<_, _, OracleError<'_>>("if that spell would be put into "), + let (_, (_, _, _, dest, _, _)) = all_consuming(( + alt(( + tag::<_, _, OracleError<'_>>("if that spell would be put into "), + tag("if a spell cast this way would be put into "), + )), alt(( tag("a graveyard"), tag("the graveyard"), @@ -18094,12 +18096,72 @@ fn parse_spell_graveyard_replacement_rider( )), ), )), + opt(tag(".")), + multispace0::<_, OracleError<'_>>, )) - .parse(trimmed) + .parse(lower.trim()) .ok()?; Some(dest) } +/// CR 115.1a + CR 601.2a + CR 608.2g: Parse a per-opponent graveyard free +/// cast target. The enclosing `for each opponent` fanout binds the paired +/// player/object targets on the stack; this clause only provides the typed +/// target filter and the during-resolution casting method. +/// +/// The player binding is deliberately represented on both axes: `controller` +/// makes the paired-target enumerator bind the object slot to that opponent, +/// while `Owned` captures "that player's/their graveyard" for target legality +/// and post-selection revalidation (CR 108.3). +fn try_parse_per_opponent_graveyard_free_cast(lower: &str) -> Option { + type E<'a> = OracleError<'a>; + + let (rest, _) = opt(tag::<_, _, E>("you may ")).parse(lower).ok()?; + let (rest, _) = tag::<_, _, E>("cast up to ").parse(rest).ok()?; + let (rest, count) = nom_primitives::parse_number.parse(rest).ok()?; + if count != 1 { + return None; + } + let (rest, _) = tag::<_, _, E>(" target ").parse(rest).ok()?; + let (rest, mut filter) = parse_free_cast_candidate_filter(rest)?; + let (rest, _) = tag::<_, _, E>(" card").parse(rest).ok()?; + let (rest, _) = tag::<_, _, E>(" from ").parse(rest).ok()?; + let (rest, _) = alt((tag::<_, _, E>("that player's "), tag("their "))) + .parse(rest) + .ok()?; + let (rest, _) = tag::<_, _, E>("graveyard without paying its mana cost") + .parse(rest) + .ok()?; + let (_, _) = all_consuming((opt(tag(".")), multispace0::<_, E>)) + .parse(rest) + .ok()?; + + add_cast_target_props( + &mut filter, + &[ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ], + Some(ControllerRef::TargetPlayer), + ); + + Some(Effect::CastFromZone { + target: filter, + without_paying_mana_cost: true, + mode: CardPlayMode::Cast, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: None, + driver: CastFromZoneDriver::DuringResolution, + mana_spend_permission: None, + }) +} + /// CR 614.1a + CR 608.2n + CR 607.2b: Detect the resolving-spell exile rider — /// "exile it instead of putting it into a graveyard as it resolves". This is the /// trigger BODY of a `WhenAPlayerCasts` trigger (Rod of Absorption) or a @@ -21819,6 +21881,10 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { .map(|(rest, _)| rest) .unwrap_or(lower); + if let Some(effect) = try_parse_per_opponent_graveyard_free_cast(lower) { + return Some(effect); + } + // CR 401.5 + CR 701.25: Eye of Duskmantle's permission is scoped to // cards in your graveyard that you surveilled this turn. The current cast // permission targets can model graveyard, exile-linked, top-library, and diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 2cfbffb6bf..fe3cecd921 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -1,4 +1,6 @@ -use super::lower::rewrite_parent_target_to_last_created; +use super::lower::{ + rewrite_parent_target_to_last_created, target_filter_is_explicit_target_player_graveyard_card, +}; use super::*; use crate::parser::parse_oracle_text; use crate::types::ability::CardPlayMode::{Cast, Play}; @@ -32942,6 +32944,57 @@ fn per_opponent_fanout_excludes_non_battlefield_zone_targets() { ); } +/// The graveyard exception to per-opponent target fanout must admit an +/// instant-or-sorcery disjunction only when every alternative is independently +/// scoped to the paired target player's graveyard. A permissive `Or` would let +/// one broad branch inherit the other branch's safety-critical zone binding. +#[test] +fn per_opponent_graveyard_fanout_requires_every_or_branch_to_be_fully_scoped() { + let scoped = |kind| { + TargetFilter::Typed( + TypedFilter::new(kind) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ) + }; + let safely_scoped = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), scoped(TypeFilter::Sorcery)], + }; + assert!(target_filter_is_explicit_target_player_graveyard_card( + &safely_scoped + )); + + let unscoped_sorcery = TargetFilter::Typed( + TypedFilter::new(TypeFilter::Sorcery) + .controller(ControllerRef::TargetPlayer) + .properties(vec![FilterProp::InZone { + zone: Zone::Graveyard, + }]), + ); + let mixed_scope = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), unscoped_sorcery], + }; + assert!( + !target_filter_is_explicit_target_player_graveyard_card(&mixed_scope), + "every disjunct must carry the TargetPlayer ownership and graveyard restrictions" + ); + + let non_typed_branch = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), TargetFilter::ParentTarget], + }; + assert!( + !target_filter_is_explicit_target_player_graveyard_card(&non_typed_branch), + "non-typed alternatives must not qualify for the nonbattlefield fanout exception" + ); +} + #[test] fn effect_for_each_opponent_exile_up_to_one_preserves_optional_fanout_slots() { let def = parse_effect_chain( @@ -41421,6 +41474,10 @@ fn spell_graveyard_replacement_rider_recognises_determiner_and_destination_varia "if that spell would be put into a graveyard, exile it instead.", SpellStackToGraveyardReplacement::Exile, ), + ( + "if a spell cast this way would be put into a graveyard, exile it instead.", + SpellStackToGraveyardReplacement::Exile, + ), ( "if that spell would be put into a graveyard, put it on the bottom of its owner's library instead", SpellStackToGraveyardReplacement::Library { @@ -41451,6 +41508,91 @@ fn spell_graveyard_replacement_rider_recognises_determiner_and_destination_varia ); } +/// Diluvian Primordial's exact targeted per-opponent graveyard-cast grammar +/// must lower to the existing paired fanout and a free during-resolution cast, +/// with the cast-this-way destination rider attached to that cast. +#[test] +fn per_opponent_graveyard_free_cast_uses_paired_fanout_and_exile_rider() { + let parsed = parse_oracle_text( + "Flying\nWhen this creature enters, for each opponent, you may cast up to one target instant or sorcery card from that player's graveyard without paying its mana cost. If a spell cast this way would be put into a graveyard, exile it instead.", + "Diluvian Primordial", + &[], + &["Creature".to_string()], + &[], + ); + let def = parsed + .triggers + .first() + .and_then(|trigger| trigger.execute.as_deref()) + .expect("Diluvian Primordial ETB must lower to a typed trigger body"); + assert!( + !matches!(def.effect.as_ref(), Effect::Unimplemented { .. }), + "the exact Oracle text must not degrade to Unimplemented: {def:?}" + ); + + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )), + "one optional object target must be paired with each opponent" + ); + assert_eq!(def.target_choice_timing, TargetChoiceTiming::Stack); + let Effect::CastFromZone { + target, + without_paying_mana_cost, + driver, + .. + } = def.effect.as_ref() + else { + panic!("expected typed CastFromZone, got {:?}", def.effect); + }; + assert!(*without_paying_mana_cost); + assert_eq!(*driver, CastFromZoneDriver::DuringResolution); + let TargetFilter::Or { filters } = target else { + panic!("expected instant-or-sorcery graveyard target, got {target:?}"); + }; + assert_eq!(filters.len(), 2); + for filter in filters { + let TargetFilter::Typed(filter) = filter else { + panic!("expected typed instant-or-sorcery branch, got {filter:?}"); + }; + assert_eq!(filter.controller, Some(ControllerRef::TargetPlayer)); + assert!(filter.properties.contains(&FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + })); + assert!(filter.properties.contains(&FilterProp::InZone { + zone: Zone::Graveyard, + })); + } + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(TypedFilter { type_filters, .. }) + if type_filters.contains(&TypeFilter::Instant) + ))); + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(TypedFilter { type_filters, .. }) + if type_filters.contains(&TypeFilter::Sorcery) + ))); + assert!(matches!( + def.sub_ability + .as_deref() + .map(|rider| rider.effect.as_ref()), + Some(Effect::ChangeZone { + origin: Some(Zone::Stack), + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + .. + }) + )); +} + #[test] fn each_player_for_each_attacking_creature_they_control_binds_scoped_player() { let def = parse_effect_chain( diff --git a/crates/engine/tests/integration/diluvian_primordial_6754.rs b/crates/engine/tests/integration/diluvian_primordial_6754.rs new file mode 100644 index 0000000000..59e4c52f77 --- /dev/null +++ b/crates/engine/tests/integration/diluvian_primordial_6754.rs @@ -0,0 +1,332 @@ +//! Diluvian Primordial: paired opponent-graveyard targets become one fixed +//! during-resolution free-cast pool, with no graveyard substitution. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastOfferKind, CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use engine::types::PlayerId; + +const P2: PlayerId = PlayerId(2); +const DILUVIAN_ORACLE: &str = "Flying\nWhen this creature enters, for each opponent, you may cast up to one target instant or sorcery card from that player's graveyard without paying its mana cost. If a spell cast this way would be put into a graveyard, exile it instead."; + +fn advance_to_trigger_target_selection(runner: &mut GameRunner) { + for _ in 0..32 { + match runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { .. } => return, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("priority pass while reaching Diluvian trigger"); + } + ref other => panic!("unexpected state while reaching Diluvian trigger: {other:?}"), + } + } + panic!("Diluvian Primordial ETB never reached its target prompt"); +} + +fn advance_to_free_cast_window(runner: &mut GameRunner) { + for _ in 0..32 { + match runner.state().waiting_for { + WaitingFor::CastOffer { + kind: CastOfferKind::FreeCastWindow { .. }, + .. + } => return, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("priority pass while resolving Diluvian trigger"); + } + ref other => panic!("unexpected state while reaching Diluvian window: {other:?}"), + } + } + panic!("Diluvian Primordial never opened its free-cast window"); +} + +fn choose_paired_targets(runner: &mut GameRunner, p1_card: ObjectId, p2_card: ObjectId) { + for target in [ + TargetRef::Player(P1), + TargetRef::Object(p1_card), + TargetRef::Player(P2), + TargetRef::Object(p2_card), + ] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("paired Diluvian target must be legal"); + } +} + +/// CR 115.1a + CR 608.2g + CR 614.1a: The real cast/trigger pipeline chooses +/// one card per opponent, snapshots exactly those cards into FreeCastWindow, +/// casts one at zero mana, re-offers only the remaining approved card, then +/// exiles the successfully cast spell while declined and unselected cards stay +/// in their owners' graveyards. Each negative assertion has the selected-card +/// reach guard immediately above it. +#[test] +fn diluvian_primordial_uses_selected_pairs_as_its_fixed_free_cast_pool() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p1_selected = scenario + .add_spell_to_graveyard(P1, "P1 Selected", true) + .with_mana_cost(ManaCost::generic(2)) + .from_oracle_text("Draw a card.") + .id(); + let p2_selected = scenario + .add_spell_to_graveyard(P2, "P2 Selected", false) + .with_mana_cost(ManaCost::generic(3)) + .from_oracle_text("Draw a card.") + .id(); + let p1_extra = scenario + .add_spell_to_graveyard(P1, "P1 Unselected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Unselected", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + choose_paired_targets(&mut runner, p1_selected, p2_selected); + advance_to_free_cast_window(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + player: P0, + kind: + CastOfferKind::FreeCastWindow { + candidates, + remaining_casts, + member_pool, + exile_instead_of_graveyard, + .. + }, + } => { + assert_eq!(member_pool, vec![p1_selected, p2_selected]); + assert_eq!(candidates, vec![p1_selected, p2_selected]); + assert_eq!(remaining_casts, 2); + assert!(exile_instead_of_graveyard); + } + other => panic!("expected Diluvian FreeCastWindow, got {other:?}"), + } + + runner + .act(GameAction::FreeCastWindowChoice { + selection: Some(p1_selected), + }) + .expect("selected P1 spell must cast for free"); + assert_eq!(runner.state().objects[&p1_selected].zone, Zone::Stack); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p1_selected, p2_selected]); + assert_eq!(candidates, vec![p2_selected]); + } + other => panic!("expected fixed-pool re-offer, got {other:?}"), + } + runner + .act(GameAction::FreeCastWindowChoice { selection: None }) + .expect("declining the remaining approved card must finish the window"); + runner.advance_until_stack_empty(); + + assert_eq!(runner.state().objects[&p1_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&p1_extra].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); +} + +/// CR 608.2b + CR 608.2g: A paired target that leaves its graveyard before +/// resolution is removed from the fixed pool. The still-legal paired target +/// positively reaches the window; its same-owner extra proves the resolver did +/// not substitute after revalidation. +#[test] +fn diluvian_primordial_drops_removed_pair_without_graveyard_substitution() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p1_selected = scenario + .add_spell_to_graveyard(P1, "P1 Selected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_removed = scenario + .add_spell_to_graveyard(P2, "P2 Removed", false) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Extra", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + choose_paired_targets(&mut runner, p1_selected, p2_removed); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), p2_removed, Zone::Exile, &mut events); + advance_to_free_cast_window(&mut runner); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p1_selected]); + assert_eq!(candidates, vec![p1_selected]); + assert!(!candidates.contains(&p2_extra)); + } + other => panic!("expected surviving paired target window, got {other:?}"), + } +} + +/// CR 115.1a + CR 603.3d + CR 608.2g: An opponent with no legal instant or +/// sorcery target contributes no paired slots, but that omission must not +/// suppress another opponent's independently selected target or its normal +/// cast pipeline. +#[test] +fn diluvian_primordial_skips_targetless_opponent_and_casts_other_selected_spell() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p2_selected = scenario + .add_spell_to_graveyard(P2, "P2 Selected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Unselected", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { target_slots, .. } => { + assert_eq!( + target_slots.len(), + 2, + "the targetless P1 must contribute no unusable player/object pair" + ); + assert_eq!(target_slots[0].legal_targets, vec![TargetRef::Player(P2)]); + assert_eq!( + target_slots[1].legal_targets, + vec![TargetRef::Object(p2_selected), TargetRef::Object(p2_extra)] + ); + } + other => panic!("expected Diluvian target selection, got {other:?}"), + } + for target in [TargetRef::Player(P2), TargetRef::Object(p2_selected)] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("P2's paired target must remain selectable when P1 has none"); + } + advance_to_free_cast_window(&mut runner); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + remaining_casts, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p2_selected]); + assert_eq!(candidates, vec![p2_selected]); + assert_eq!(remaining_casts, 1); + } + other => panic!("expected P2-only Diluvian FreeCastWindow, got {other:?}"), + } + runner + .act(GameAction::FreeCastWindowChoice { + selection: Some(p2_selected), + }) + .expect("P2's selected spell must cast through the free-cast window"); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Stack); + runner.advance_until_stack_empty(); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1b7e4144e3..a9e34a8921 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -147,6 +147,7 @@ mod devour_co_entry_regression; mod devour_intellect_treasure_rider; mod dig_rest_pile_stranding_on_etb_pause; mod diligent_farmhand_counts_as_named; +mod diluvian_primordial_6754; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; mod doran_attack_block_pump; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index b951858543..88a9752cba 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -211,7 +211,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Devoted Sultai - Devout Harpist - Dewdrop Cure -- Diluvian Primordial - Dimension X Pizzasaur - Diplomatic Escort - Dire Fleet Warmonger From 9d390ef8805dc65459ce51b32d0a53652b6ce42b Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:08:56 +0200 Subject: [PATCH 2/2] Fix Diluvian Primordial review findings --- crates/engine/src/game/ability_scan.rs | 2 +- crates/engine/src/game/ability_utils.rs | 2 +- crates/engine/src/game/casting_costs.rs | 14 ++-- crates/engine/src/game/coverage.rs | 6 +- .../engine/src/game/effects/cast_from_zone.rs | 26 ++----- .../src/game/effects/exile_from_top_until.rs | 4 +- .../src/game/effects/free_cast_from_zones.rs | 26 +++---- .../src/game/engine_resolution_choices.rs | 8 ++- crates/engine/src/game/visibility.rs | 15 ++-- .../src/parser/oracle_effect/assembly.rs | 42 +++++++++-- .../engine/src/parser/oracle_effect/lower.rs | 48 ++++++++----- crates/engine/src/parser/oracle_effect/mod.rs | 70 +++++++++++++++++-- .../engine/src/parser/oracle_effect/tests.rs | 48 +++++++++++-- crates/engine/src/parser/oracle_tests.rs | 12 ++-- crates/engine/src/types/ability.rs | 34 +++++---- crates/engine/src/types/game_state.rs | 13 ++-- .../integration/diluvian_primordial_6754.rs | 18 ++++- .../integration/invoke_calamity_free_cast.rs | 9 ++- 18 files changed, 279 insertions(+), 118 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index deb416008d..38c901404c 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1249,7 +1249,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { count: _, max_total_mv: _, zones: _, - exile_instead_of_graveyard: _, + graveyard_replacement: _, } => { let mut acc = Axes::NONE; acc = acc.or(scan_target_filter(filter, target_ctx, mode)); diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index f97b2955a6..dd76b7409f 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4760,7 +4760,7 @@ fn attach_host_enchant_filter( Some((filter, attachment_id, controller)) } -fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool { +pub(crate) fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool { if ability.target_choice_timing != TargetChoiceTiming::Stack { return false; } diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 3014ee38a5..cfa0f81481 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -9317,17 +9317,13 @@ fn handle_resolution_cast_success( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, } => { - if exile_instead_of_graveyard { - // CR 614.1a: Invoke Calamity's free-cast rider redirects to exile. - apply_spell_graveyard_replacement_rider( - state, - cast_object, - SpellStackToGraveyardReplacement::Exile, - ); + if let Some(destination) = graveyard_replacement.clone() { + // CR 614.1a: Carry the exact printed replacement destination. + apply_spell_graveyard_replacement_rider(state, cast_object, destination); } let casts_left = remaining_casts.saturating_sub(1); // CR 202.3: shrink the shared budget by what was actually spent on @@ -9362,7 +9358,7 @@ fn handle_resolution_cast_success( remaining_mv_budget: budget_left, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, }, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 285a24f6a9..05b6de0f75 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3076,7 +3076,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => { d.push(("count".into(), count.to_string())); if let Some(mv) = max_total_mv { @@ -3091,8 +3091,8 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { .collect::>() .join("/"), )); - if *exile_instead_of_graveyard { - d.push(("exile instead of graveyard".into(), "yes".into())); + if let Some(destination) = graveyard_replacement { + d.push(("graveyard replacement".into(), format!("{destination:?}"))); } } Effect::RollDie { diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index f559e4cb07..9c2f7c4517 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -607,27 +607,13 @@ pub fn resolve( // existing free-cast window as an exact pool: do not rescan graveyards and // do not substitute another card from the same opponent. The window's // re-offer pipeline casts selected spells one at a time without priority. - let is_per_opponent_fanout = matches!( - ability - .multi_target - .as_ref() - .and_then(|spec| spec.max.as_ref()), - Some(QuantityExpr::Ref { - qty: crate::types::ability::QuantityRef::PlayerCount { - filter: crate::types::ability::PlayerFilter::Opponent - } - }) - ); + let is_per_opponent_fanout = crate::game::ability_utils::is_per_opponent_target_fanout(ability); let graveyard_destination = cast_from_zone_graveyard_destination(ability); if driver.is_during_resolution() && without_paying && alt_ability_cost.is_none() && is_per_opponent_fanout && !target_ids.is_empty() - && matches!( - graveyard_destination, - None | Some(SpellStackToGraveyardReplacement::Exile) - ) { let mut window = ability.clone(); window.effect = Effect::FreeCastFromZones { @@ -636,13 +622,13 @@ pub fn resolve( filter: target_filter.clone(), zones: vec![Zone::Graveyard], // The CastFromZone rider is stored as a sequential ParentTarget - // sub-ability; FreeCastWindow carries its Exile destination as + // sub-ability; FreeCastWindow carries its exact destination as // per-cast metadata instead of installing a source-global effect. - exile_instead_of_graveyard: matches!( - graveyard_destination, - Some(SpellStackToGraveyardReplacement::Exile) - ), + graveyard_replacement: graveyard_destination, }; + // The rider has been translated into the window's per-cast metadata; + // retaining it would run a second destination move after the window. + window.sub_ability = None; window.targets = target_ids.drain(..).map(TargetRef::Object).collect(); return super::free_cast_from_zones::resolve(state, &window, events); } diff --git a/crates/engine/src/game/effects/exile_from_top_until.rs b/crates/engine/src/game/effects/exile_from_top_until.rs index da6f86d1a8..f51b8635ad 100644 --- a/crates/engine/src/game/effects/exile_from_top_until.rs +++ b/crates/engine/src/game/effects/exile_from_top_until.rs @@ -1006,7 +1006,7 @@ mod tests { ], }, zones: vec![Zone::Exile], - exile_instead_of_graveyard: false, + graveyard_replacement: None, }, vec![], source, @@ -1212,7 +1212,7 @@ mod tests { ], }, zones: vec![Zone::Exile], - exile_instead_of_graveyard: false, + graveyard_replacement: None, }, vec![], source, diff --git a/crates/engine/src/game/effects/free_cast_from_zones.rs b/crates/engine/src/game/effects/free_cast_from_zones.rs index a76d26810a..687e3b43f1 100644 --- a/crates/engine/src/game/effects/free_cast_from_zones.rs +++ b/crates/engine/src/game/effects/free_cast_from_zones.rs @@ -22,27 +22,26 @@ use crate::types::zones::Zone; /// matching the Cascade/Discover/Ripple pattern. The "Exile ~" sub-ability is /// stashed as a `pending_continuation` and runs after the window finishes. /// -/// Invoke Calamity is the type specimen. The `exile_instead_of_graveyard` rider -/// (CR 614.1a — "if those spells would be put into your graveyard, exile them -/// instead") is carried on the offer so each cast spell is stamped with it. +/// Invoke Calamity is the type specimen. The optional CR 614.1a destination +/// rider is carried on the offer so each cast spell is stamped with it. pub fn resolve( state: &mut GameState, ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { - let (count, max_total_mv, filter, zones, exile_instead_of_graveyard) = match &ability.effect { + let (count, max_total_mv, filter, zones, graveyard_replacement) = match &ability.effect { Effect::FreeCastFromZones { count, max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => ( *count, *max_total_mv, filter.clone(), zones.clone(), - *exile_instead_of_graveyard, + graveyard_replacement.clone(), ), _ => return Err(EffectError::MissingParam("FreeCastFromZones".to_string())), }; @@ -105,7 +104,7 @@ pub fn resolve( remaining_mv_budget: max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source: ability.source_id, member_pool, }, @@ -266,7 +265,7 @@ fn member_pool_filter(filter: &TargetFilter) -> TargetFilter { mod tests { use super::*; use crate::game::zones::create_object; - use crate::types::ability::{TypeFilter, TypedFilter}; + use crate::types::ability::{SpellStackToGraveyardReplacement, TypeFilter, TypedFilter}; use crate::types::card_type::CoreType; use crate::types::identifiers::CardId; use crate::types::mana::ManaCost; @@ -534,7 +533,7 @@ mod tests { max_total_mv: Some(6), filter: instant_sorcery_filter(), zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some(SpellStackToGraveyardReplacement::Exile), }, vec![], source, @@ -580,7 +579,7 @@ mod tests { max_total_mv: Some(6), filter: instant_sorcery_filter(), zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some(SpellStackToGraveyardReplacement::Exile), }, vec![], source, @@ -596,7 +595,7 @@ mod tests { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, .. }, } => { @@ -604,7 +603,10 @@ mod tests { assert_eq!(candidates, &vec![instant]); assert_eq!(*remaining_casts, 2); assert_eq!(*remaining_mv_budget, Some(6)); - assert!(*exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&SpellStackToGraveyardReplacement::Exile) + ); } other => panic!("expected FreeCastWindow, got {other:?}"), } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index edf3f0a465..a7e7cbd3cd 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1992,7 +1992,7 @@ pub(super) fn handle_resolution_choice( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, }, @@ -2047,7 +2047,7 @@ pub(super) fn handle_resolution_choice( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement: graveyard_replacement.clone(), source, member_pool, }, @@ -2060,6 +2060,10 @@ pub(super) fn handle_resolution_choice( constraint: None, cast_transformed: false, cleanup, + // The window's success action installs this rider exactly + // once after the cast finalizes. Passing it through this + // request would make `initiate_cast_during_resolution` + // install a duplicate synthetic replacement. graveyard_replacement: None, cost: crate::types::ability::ResolutionCastCost::Free, }, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 62d1b46bb5..fd6bf56e1a 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1113,7 +1113,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState remaining_mv_budget, ref filter, ref zones, - exile_instead_of_graveyard, + ref graveyard_replacement, source, ref member_pool, }, @@ -1128,7 +1128,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState remaining_mv_budget, filter: filter.clone(), zones: zones.clone(), - exile_instead_of_graveyard, + graveyard_replacement: graveyard_replacement.clone(), source, // CR 400.2: the member pool can reference the same private // candidates (a hand/graveyard window would leak eligible @@ -4858,7 +4858,9 @@ mod tests { remaining_mv_budget: Some(6), filter: crate::types::ability::TargetFilter::Any, zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some( + crate::types::ability::SpellStackToGraveyardReplacement::Exile, + ), source: crate::types::game_state::zero_object_id(), member_pool: vec![hand_candidate], }, @@ -4894,7 +4896,7 @@ mod tests { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, member_pool, .. }, @@ -4914,7 +4916,10 @@ mod tests { assert_eq!(member_pool, vec![ObjectId(0)]); assert_eq!(remaining_casts, 2); assert_eq!(remaining_mv_budget, Some(6)); - assert!(exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&crate::types::ability::SpellStackToGraveyardReplacement::Exile) + ); } other => panic!("expected FreeCastWindow for opponent, got {other:?}"), } diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 703e5112d2..b24fcd8dc3 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -37,6 +37,7 @@ use super::lower::{ apply_where_x_to_latest_def, attach_any_color_mana_rider_to_previous_play_from_exile, attach_cast_cost_raise_to_previous_play_from_exile, attach_graveyard_redirect_rider_to_prior_cast_from_zone, + attach_graveyard_redirect_rider_to_prior_free_cast_from_zones, attach_land_enters_tapped_to_previous_play_from_exile, cast_cost_raise_rider, clone_would_transplant_gated_referent, consolidate_die_and_coin_defs, definition_targets_self_source, effect_publishes_revealed_subject, @@ -73,11 +74,13 @@ use super::{ def_is_keyword_counter_placement, def_is_perpetual_keyword_grant, demote_unbindable_batch_aggregate, draw_object_count_filter, fold_cast_copy_of_card_defs, has_explicit_player_target, inject_chosen_color_choice_grant, mark_uses_tracked_set, - parse_spell_graveyard_replacement_rider, publishes_aggregate_set_from_resolution, - publishes_tracked_set_from_resolution, rebind_tracked_aggregate_to_chain_set, - retarget_counter_additional_cost_to_target, rewrite_grant_parent_to_filter, - rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, rewrite_that_type_mana_instead, - stamp_delayed_returns, try_fold_token_repeat_into_count, wire_optional_cast_decline_fallback, + parse_spell_graveyard_replacement_rider, + parse_spells_cast_this_way_graveyard_replacement_rider, + publishes_aggregate_set_from_resolution, publishes_tracked_set_from_resolution, + rebind_tracked_aggregate_to_chain_set, retarget_counter_additional_cost_to_target, + rewrite_grant_parent_to_filter, rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, + rewrite_that_type_mana_instead, stamp_delayed_returns, try_fold_token_repeat_into_count, + wire_optional_cast_decline_fallback, }; /// CR 601.2c: True when the assembled head chose one or more players at @@ -1789,6 +1792,35 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { continue; } + // CR 614.1a + CR 608.2g: An exact "if a spell cast this way would be + // put into a graveyard" rider scopes each cast in the immediately prior + // free-cast window. Absorb it before the legacy singular-spell route; + // this avoids converting a multi-cast destination into a sequential + // one-shot `ParentTarget` rider. + if let Some(dest) = parse_spells_cast_this_way_graveyard_replacement_rider( + &clause_ir + .source + .fragment() + .unwrap_or_default() + .to_lowercase(), + ) { + if attach_graveyard_redirect_rider_to_prior_free_cast_from_zones( + &mut defs, + dest.clone(), + ) { + prev_boundary = clause_ir.boundary; + continue; + } + // Diluvian Primordial's paired target fanout remains a + // `CastFromZone` until its resolver opens the free-cast window. + // Preserve the canonical ParentTarget representation here; the + // direct resolver translates it to the same window metadata. + if attach_graveyard_redirect_rider_to_prior_cast_from_zone(&mut defs, dest) { + prev_boundary = clause_ir.boundary; + continue; + } + } + // CR 614.1a + CR 608.2n: a "if that spell would be put into a graveyard, // [put on library / return to hand] instead" rider that trails an // optional `CastFromZone` (Kylox's Voltstrider) is a CR 608.2n diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 3d7ea12822..9087a30bce 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -1028,10 +1028,11 @@ pub(super) fn attach_any_color_mana_rider_to_previous_play_from_exile( /// count 0, and duplicating it into a bogus `else_ability`). Building the rider /// directly here bypasses that generic mis-route. /// -/// Exile uses the same canonical `ParentTarget` rider shape as library/hand. -/// That matters for "a spell cast this way": its grammatical subject is not a -/// pronoun for the generic anaphor rebind, but the rider must still attach to -/// the preceding cast rather than become a source-global replacement. +/// The generic singular-spell route intentionally leaves exile to the existing +/// `ChangeZone{Exile, ParentTarget}` anaphor path. The exact plural "a spell +/// cast this way" form is handled separately by +/// `attach_graveyard_redirect_rider_to_prior_free_cast_from_zones`, which is +/// the only route that may attach stack/ParentTarget metadata for that wording. pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( defs: &mut [AbilityDefinition], dest: SpellStackToGraveyardReplacement, @@ -1057,21 +1058,7 @@ pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( face_down_profile: None, enters_modified_if: None, }, - SpellStackToGraveyardReplacement::Exile => Effect::ChangeZone { - origin: Some(Zone::Stack), - destination: Zone::Exile, - target: TargetFilter::ParentTarget, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: vec![], - conditional_enter_with_counters: vec![], - face_down_profile: None, - enters_modified_if: None, - }, + SpellStackToGraveyardReplacement::Exile => return false, }; let Some(prev) = defs.last_mut() else { return false; @@ -1085,6 +1072,29 @@ pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( true } +/// CR 614.1a + CR 608.2g: absorb an exact "a spell cast this way" destination +/// rider into the immediately preceding free-cast window. Unlike the legacy +/// "that spell" form, this rider applies independently to every spell the +/// window casts, so it belongs on `FreeCastFromZones` metadata rather than as a +/// sequential `ParentTarget` sub-ability. +pub(super) fn attach_graveyard_redirect_rider_to_prior_free_cast_from_zones( + defs: &mut [AbilityDefinition], + dest: SpellStackToGraveyardReplacement, +) -> bool { + let Some(prev) = defs.last_mut() else { + return false; + }; + let Effect::FreeCastFromZones { + graveyard_replacement, + .. + } = &mut *prev.effect + else { + return false; + }; + *graveyard_replacement = Some(dest); + true +} + /// CR 601.2f: Detect an "each/a spell cast this way costs {N} more to cast" /// rider sentence (Lightstall Inquisitor, Invasion of Gobakhan) and return the cost increase. This is /// a cost-raise scoped to spells cast via the immediately-preceding diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 217cca2c7d..22ad33c55a 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -18055,10 +18055,68 @@ fn parse_spell_graveyard_replacement_rider( use nom::combinator::{all_consuming, value}; use nom::Parser; let (_, (_, _, _, dest, _, _)) = all_consuming(( + tag::<_, _, OracleError<'_>>("if that spell would be put into "), alt(( - tag::<_, _, OracleError<'_>>("if that spell would be put into "), - tag("if a spell cast this way would be put into "), + tag("a graveyard"), + tag("the graveyard"), + tag("its owner's graveyard"), + tag("your graveyard"), + tag("an opponent's graveyard"), + )), + tag(", "), + alt(( + value( + SpellStackToGraveyardReplacement::Exile, + alt(( + tag("exile it instead"), + tag("exile that card instead"), + tag("exile that spell instead"), + )), + ), + value( + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Bottom, + }, + tag("put it on the bottom of its owner's library instead"), + ), + value( + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Top, + }, + tag("put it on top of its owner's library instead"), + ), + value( + SpellStackToGraveyardReplacement::Hand, + alt(( + tag("put it into its owner's hand instead"), + tag("return it to its owner's hand instead"), + )), + ), )), + opt(tag(".")), + multispace0::<_, OracleError<'_>>, + )) + .parse(lower.trim()) + .ok()?; + Some(dest) +} + +/// CR 614.1a + CR 608.2g: Parse the exact plural-free-cast rider, "if a spell +/// cast this way would be put into a graveyard, [destination] instead." This +/// route is deliberately typed and all-consuming: it must not swallow a +/// merely similar conditional before the assembly pass verifies that the prior +/// definition is a `FreeCastFromZones` window. +fn parse_spells_cast_this_way_graveyard_replacement_rider( + lower: &str, +) -> Option { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::character::complete::multispace0; + use nom::combinator::{all_consuming, opt, value}; + use nom::Parser; + + let (_, (_, _, _, dest, _, _)) = all_consuming(( + tag::<_, _, OracleError<'_>>("if a spell cast this way would be put into "), alt(( tag("a graveyard"), tag("the graveyard"), @@ -21480,7 +21538,7 @@ fn try_parse_counted_free_cast_from_exiled_this_way(rest: &str) -> Option Option { max_total_mv, filter, zones, - exile_instead_of_graveyard: false, + graveyard_replacement: None, }) } @@ -29055,11 +29113,11 @@ pub(crate) fn parse_effect_chain_ir( && matches!(&clause.parsed.effect, Effect::FreeCastFromZones { .. }) }) { if let Effect::FreeCastFromZones { - exile_instead_of_graveyard, + graveyard_replacement, .. } = &mut previous.parsed.effect { - *exile_instead_of_graveyard = true; + *graveyard_replacement = Some(SpellStackToGraveyardReplacement::Exile); continue; } } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index fe3cecd921..bc54c81e6e 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -40515,7 +40515,7 @@ fn plargg_and_nassari_full_trigger_chain_choose_then_cast_others() { ref filter, ref zones, max_total_mv, - exile_instead_of_graveyard, + ref graveyard_replacement, } = *cast.effect else { panic!("expected FreeCastFromZones tail, got {:?}", cast.effect); @@ -40526,7 +40526,7 @@ fn plargg_and_nassari_full_trigger_chain_choose_then_cast_others() { ); assert_eq!(*zones, vec![Zone::Exile]); assert_eq!(max_total_mv, None); - assert!(!exile_instead_of_graveyard); + assert!(graveyard_replacement.is_none()); let TargetFilter::And { filters: ref cf } = *filter else { panic!("expected And cast filter, got {filter:?}"); }; @@ -41474,10 +41474,6 @@ fn spell_graveyard_replacement_rider_recognises_determiner_and_destination_varia "if that spell would be put into a graveyard, exile it instead.", SpellStackToGraveyardReplacement::Exile, ), - ( - "if a spell cast this way would be put into a graveyard, exile it instead.", - SpellStackToGraveyardReplacement::Exile, - ), ( "if that spell would be put into a graveyard, put it on the bottom of its owner's library instead", SpellStackToGraveyardReplacement::Library { @@ -41508,6 +41504,44 @@ fn spell_graveyard_replacement_rider_recognises_determiner_and_destination_varia ); } +/// CR 614.1a + CR 608.2g: "a spell cast this way" is the multi-cast form, +/// not the legacy singular `that spell` rider. It must parse all destinations +/// through its own all-consuming route before assembly decides whether a prior +/// free-cast window can absorb it. +#[test] +fn spells_cast_this_way_graveyard_replacement_rider_recognises_destinations() { + use crate::types::ability::{LibraryPosition, SpellStackToGraveyardReplacement}; + for (clause, expected) in [ + ( + "if a spell cast this way would be put into a graveyard, exile it instead.", + SpellStackToGraveyardReplacement::Exile, + ), + ( + "if a spell cast this way would be put into a graveyard, put it on the bottom of its owner's library instead", + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Bottom, + }, + ), + ( + "if a spell cast this way would be put into a graveyard, return it to its owner's hand instead", + SpellStackToGraveyardReplacement::Hand, + ), + ] { + assert_eq!( + parse_spells_cast_this_way_graveyard_replacement_rider(clause), + Some(expected), + "should recognise multi-cast rider clause: {clause:?}" + ); + } + assert_eq!( + parse_spells_cast_this_way_graveyard_replacement_rider( + "if a spell cast this way would be put into a graveyard, exile it instead, then draw a card", + ), + None, + "the exact multi-cast rider must not partially consume a larger clause" + ); +} + /// Diluvian Primordial's exact targeted per-opponent graveyard-cast grammar /// must lower to the existing paired fanout and a free during-resolution cast, /// with the cast-this-way destination rider attached to that cast. @@ -41585,7 +41619,7 @@ fn per_opponent_graveyard_free_cast_uses_paired_fanout_and_exile_rider() { .as_deref() .map(|rider| rider.effect.as_ref()), Some(Effect::ChangeZone { - origin: Some(Zone::Stack), + origin: None, destination: Zone::Exile, target: TargetFilter::ParentTarget, .. diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index ee50378e7f..522c437842 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -3,6 +3,7 @@ use crate::parser::oracle_effect::parse_effect_chain; use crate::parser::oracle_ir::doc::{UnsupportedAbilityCategory, UnsupportedAbilityIr}; use crate::types::ability::{ AdditionalCostOrigin, AdditionalCostPaymentSource, CountScope, CounterAdjustment, DoorLockOp, + SpellStackToGraveyardReplacement, }; use crate::types::counter::{CounterMatch, CounterType}; @@ -2981,11 +2982,14 @@ fn free_cast_window_clause_chains_rider_and_self_exile() { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => { assert_eq!(*count, 2); assert_eq!(*max_total_mv, Some(6)); - assert!(*exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&SpellStackToGraveyardReplacement::Exile) + ); assert_eq!(zones, &vec![Zone::Graveyard, Zone::Hand]); assert_eq!( *filter, @@ -3039,7 +3043,7 @@ fn free_cast_window_parses_single_zone_non_invoke_variant() { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } = &*result.abilities[0].effect else { panic!( @@ -3055,7 +3059,7 @@ fn free_cast_window_parses_single_zone_non_invoke_variant() { TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)) ); assert_eq!(zones, &vec![Zone::Graveyard]); - assert!(!*exile_instead_of_graveyard); + assert!(graveyard_replacement.is_none()); } /// Issue #2385 MED — `Effect::FreeCastFromZones` is a *free* cast. A diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index aaf1b5b3f1..9fa77e7bf6 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3316,8 +3316,13 @@ pub enum ResolutionCastSuccessAction { remaining_mv_budget: Option, filter: TargetFilter, zones: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, /// CR 406.6: Source object of the granting ability, threaded so /// `ExiledBySource`-style filters (Plargg and Nassari) can rebuild the /// re-offer candidate set against the right exile links. Zero sentinel @@ -3673,7 +3678,7 @@ where /// → `None`), so an in-flight saved / reconnected permission serialized before /// the migration still reloads with its exile-on-resolution rider. Paired with /// `#[serde(alias = "exile_instead_of_graveyard_on_resolve")]` on the field. -fn deserialize_graveyard_replacement_compat<'de, D>( +pub(crate) fn deserialize_graveyard_replacement_compat<'de, D>( d: D, ) -> Result, D::Error> where @@ -12095,10 +12100,9 @@ pub enum Effect { /// cross-selection budget that shrinks as each spell is cast. "Up to N" /// makes every cast optional (the controller may stop early or cast none). /// - /// When `exile_instead_of_graveyard` is true, each spell cast this way - /// carries the rider "if those spells would be put into your graveyard, - /// exile them instead" (CR 614.1a) for the rest of the cast — applied as a - /// duration-scoped replacement on the cast spell. + /// When `graveyard_replacement` is `Some`, each spell cast this way carries + /// its CR 614.1a stack-to-graveyard destination rider for the rest of the + /// cast — applied as a duration-scoped replacement on the cast spell. /// /// Distinct from `CastFromZone`, which grants a casting *permission* on a /// targeted object (lingering or a single self-cast). This effect owns the @@ -12118,10 +12122,15 @@ pub enum Effect { /// CR 601.2a: Zones searched for candidates (the controller's own /// graveyard and/or hand). zones: Vec, - /// CR 614.1a: When true, spells cast this way are exiled instead of - /// being put into their owner's graveyard ("exile them instead"). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + /// CR 614.1a: Optional destination for spells cast this way instead of + /// their owner's graveyard (for example, "exile them instead"). + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, }, /// CR 614.1a + CR 608.2n + CR 607.2b: "Exile it instead of putting it into a /// graveyard as it resolves" — the self-replacement rider applied by a @@ -12136,7 +12145,8 @@ pub enum Effect { /// can later refer to the accumulating set. /// /// Distinct from `ChangeZone { destination: Exile }` (which moves a card that - /// is already in a zone) and from `FreeCastFromZones { exile_instead… }` + /// is already in a zone) and from `FreeCastFromZones { + /// graveyard_replacement: Some(..) }` /// (which stamps the same rider on a spell *cast during resolution*, with no /// linked-exile payoff). This effect is the trigger-driven, link-establishing /// form for the resolving spell itself. diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5b76725066..cf45c6342b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -7219,10 +7219,15 @@ pub enum CastOfferKind { /// CR 601.2a: Zones searched for candidates (controller's graveyard /// and/or hand). zones: Vec, - /// CR 614.1a: Whether spells cast this way are exiled instead of going - /// to their owner's graveyard. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + /// CR 614.1a: Optional destination for spells cast this way instead of + /// their owner's graveyard. + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::types::ability::deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, /// CR 406.6: Source object of the granting ability. `filter`s such as /// `ExiledBySource` (Plargg and Nassari's "the other cards exiled this /// way") resolve their exile links against this id, so the re-offer diff --git a/crates/engine/tests/integration/diluvian_primordial_6754.rs b/crates/engine/tests/integration/diluvian_primordial_6754.rs index 59e4c52f77..32c496ff24 100644 --- a/crates/engine/tests/integration/diluvian_primordial_6754.rs +++ b/crates/engine/tests/integration/diluvian_primordial_6754.rs @@ -2,7 +2,7 @@ //! during-resolution free-cast pool, with no graveyard substitution. use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; -use engine::types::ability::TargetRef; +use engine::types::ability::{SpellStackToGraveyardReplacement, TargetRef}; use engine::types::actions::GameAction; use engine::types::game_state::{CastOfferKind, CastPaymentMode, WaitingFor}; use engine::types::identifiers::ObjectId; @@ -81,6 +81,10 @@ fn diluvian_primordial_uses_selected_pairs_as_its_fixed_free_cast_pool() { vec![], )], ); + // The cast spell is a cantrip. Seed P0's library so resolving it proves + // the full cast path deterministically rather than relying on an empty + // library draw-loss implementation detail. + let p0_draw_filler = scenario.add_card_to_library_top(P0, "P0 Draw Filler"); let primordial = scenario .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) .with_mana_cost(ManaCost::generic(1)) @@ -126,14 +130,17 @@ fn diluvian_primordial_uses_selected_pairs_as_its_fixed_free_cast_pool() { candidates, remaining_casts, member_pool, - exile_instead_of_graveyard, + graveyard_replacement, .. }, } => { assert_eq!(member_pool, vec![p1_selected, p2_selected]); assert_eq!(candidates, vec![p1_selected, p2_selected]); assert_eq!(remaining_casts, 2); - assert!(exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement, + Some(SpellStackToGraveyardReplacement::Exile) + ); } other => panic!("expected Diluvian FreeCastWindow, got {other:?}"), } @@ -165,6 +172,7 @@ fn diluvian_primordial_uses_selected_pairs_as_its_fixed_free_cast_pool() { runner.advance_until_stack_empty(); assert_eq!(runner.state().objects[&p1_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p0_draw_filler].zone, Zone::Hand); assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Graveyard); assert_eq!(runner.state().objects[&p1_extra].zone, Zone::Graveyard); assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); @@ -255,6 +263,9 @@ fn diluvian_primordial_skips_targetless_opponent_and_casts_other_selected_spell( vec![], )], ); + // As above, make the selected cantrip's resolution observable without an + // empty-library draw-loss side effect. + let p0_draw_filler = scenario.add_card_to_library_top(P0, "P0 Draw Filler"); let primordial = scenario .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) .with_mana_cost(ManaCost::generic(1)) @@ -328,5 +339,6 @@ fn diluvian_primordial_skips_targetless_opponent_and_casts_other_selected_spell( assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Stack); runner.advance_until_stack_empty(); assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p0_draw_filler].zone, Zone::Hand); assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); } diff --git a/crates/engine/tests/integration/invoke_calamity_free_cast.rs b/crates/engine/tests/integration/invoke_calamity_free_cast.rs index e458127ee9..b90305bd62 100644 --- a/crates/engine/tests/integration/invoke_calamity_free_cast.rs +++ b/crates/engine/tests/integration/invoke_calamity_free_cast.rs @@ -20,7 +20,7 @@ //! CR 614.1a: spells cast this way are exiled instead of going to the graveyard. use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::ability::TargetRef; +use engine::types::ability::{SpellStackToGraveyardReplacement, TargetRef}; use engine::types::actions::GameAction; use engine::types::game_state::{CastOfferKind, CastPaymentMode, StackEntryKind, WaitingFor}; use engine::types::identifiers::ObjectId; @@ -115,14 +115,17 @@ fn invoke_calamity_opens_free_cast_window_and_exiles_cast_spells() { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, .. }, } => { assert_eq!(player, P0); assert_eq!(remaining_casts, 2, "up to two casts"); assert_eq!(remaining_mv_budget, Some(6)); - assert!(exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement, + Some(SpellStackToGraveyardReplacement::Exile) + ); assert!( candidates.contains(&gy_instant), "the graveyard instant must be a free-cast candidate"