From f569c3753289f763d95fc7ea5644bdf8ff7b6a5b Mon Sep 17 00:00:00 2001 From: Nishad Date: Tue, 4 Aug 2026 18:33:42 -0700 Subject: [PATCH 1/3] fix(engine): keep prepared-copy spells stack-resident through targeting --- crates/engine/src/game/sba.rs | 115 ++++++++- crates/engine/src/game/zone_pipeline.rs | 63 ++++- crates/engine/src/game/zones.rs | 118 ++++++++- ...issue_1312_prepared_spell_cast_triggers.rs | 224 ++++++++++++++---- 4 files changed, 451 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 343b418da3..f1af214ee9 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -2124,8 +2124,8 @@ fn check_token_cease_to_exist(state: &mut GameState, any_performed: &mut bool) { .objects .iter() .filter(|(_, obj)| { - zones::token_is_outside_battlefield_and_stack(obj) - || zones::copy_of_card_outside_battlefield_and_stack(obj) + zones::token_is_outside_battlefield_and_stack(state, obj) + || zones::copy_of_card_outside_battlefield_and_stack(state, obj) }) .map(|(id, obj)| (*id, obj.zone, obj.owner)) .collect(); @@ -2302,10 +2302,12 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, TargetFilter, + AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ResolvedAbility, + TargetFilter, }; use crate::types::actions::GameAction; use crate::types::format::FormatConfig; + use crate::types::game_state::{CastingVariant, StackEntry, StackEntryKind}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::replacements::ReplacementEvent; @@ -4909,6 +4911,113 @@ mod tests { ); } + #[test] + fn announced_off_zone_noncard_survival_requires_same_id_spell_entry() { + fn add_off_zone_noncard( + state: &mut GameState, + card_id: u64, + name: &str, + is_token: bool, + is_copy: bool, + ) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Exile, + ); + let object = state.objects.get_mut(&id).unwrap(); + object.is_token = is_token; + object.is_copy = is_copy; + id + } + + fn push_spell_placeholder(state: &mut GameState, id: ObjectId, card_id: u64) { + state.stack.push_back(StackEntry { + id, + source_id: id, + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(card_id), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + } + + fn push_virtual_activated_entry(state: &mut GameState, id: ObjectId) { + state.stack.push_back(StackEntry { + id, + source_id: id, + controller: PlayerId(0), + kind: StackEntryKind::ActivatedAbility { + source_id: id, + ability: Box::new(ResolvedAbility::new(Effect::NoOp, vec![], id, PlayerId(0))), + }, + }); + } + + let mut state = setup(); + let announced_token = add_off_zone_noncard(&mut state, 1, "Announced Token", true, false); + let activated_token = add_off_zone_noncard(&mut state, 2, "Activated Token", true, false); + let unmatched_token = add_off_zone_noncard(&mut state, 3, "Unmatched Token", true, false); + let announced_copy = add_off_zone_noncard(&mut state, 4, "Announced Copy", false, true); + let activated_copy = add_off_zone_noncard(&mut state, 5, "Activated Copy", false, true); + let unmatched_copy = add_off_zone_noncard(&mut state, 6, "Unmatched Copy", false, true); + + push_spell_placeholder(&mut state, announced_token, 1); + push_virtual_activated_entry(&mut state, activated_token); + push_spell_placeholder(&mut state, announced_copy, 4); + push_virtual_activated_entry(&mut state, activated_copy); + + for id in [ + announced_token, + activated_token, + unmatched_token, + announced_copy, + activated_copy, + unmatched_copy, + ] { + assert!(state.objects.contains_key(&id)); + assert_eq!(state.objects[&id].zone, Zone::Exile); + } + assert!(state.stack.iter().any(|entry| { + entry.id == activated_token + && entry.source_id == activated_token + && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. }) + })); + assert!(state.stack.iter().any(|entry| { + entry.id == activated_copy + && entry.source_id == activated_copy + && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. }) + })); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + // CR 601.2a: The exact same-id spell placeholders make these announced + // spell objects stack-resident despite the retained Exile field. + assert!(state.objects.contains_key(&announced_token)); + assert!(state.objects.contains_key(&announced_copy)); + + // CR 704.5d + CR 704.5e: Unmatched off-zone tokens/copies cease, and + // CR 109.1 / CR 602.2a means a same-id activated ability cannot protect + // its source. + for id in [ + activated_token, + unmatched_token, + activated_copy, + unmatched_copy, + ] { + assert!( + !state.objects.contains_key(&id), + "off-zone noncard object {id:?} must cease without its own spell entry" + ); + } + } + // --- CR 704.5e + CR 707.10a: Copy-of-a-card cease-to-exist tests --- /// A copy of a card (is_copy = true, is_token = false) resolving to the diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 2a5611424c..97772b6701 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -738,8 +738,11 @@ pub(crate) fn move_object_with_terminal( .get(&req.object_id) .expect("object exists (zone read above)"); // CR 111.8: A token that has left the battlefield can't change zones; it - // remains in place and ceases to exist at the next SBA (CR 111.7). - if zones::token_is_outside_battlefield_and_stack(obj) { + // remains in place and ceases to exist at the next SBA (CR 111.7). A + // same-id CR 601.2a `StackEntryKind::Spell` placeholder makes an + // announced spell effectively stack-resident and eligible for its + // retained-origin representation's delivery to `Zone::Stack`. + if zones::token_is_outside_battlefield_and_stack(state, obj) { return ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained); } // CR 603.2g + CR 603.6a: A Battlefield -> Battlefield move does not put a @@ -3081,6 +3084,62 @@ fn execute_zone_move_with_applied_terminal( } } +#[cfg(test)] +mod announced_spell_residency_tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::{Effect, ResolvedAbility}; + use crate::types::game_state::{StackEntry, StackEntryKind}; + use crate::types::identifiers::CardId; + + #[test] + fn casting_to_stack_rejects_same_id_activated_ability_entry() { + let mut state = GameState::new_two_player(42); + let object_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Activated Source".to_string(), + Zone::Exile, + ); + state.objects.get_mut(&object_id).unwrap().is_token = true; + state.stack.push_back(StackEntry { + id: object_id, + source_id: object_id, + controller: PlayerId(0), + kind: StackEntryKind::ActivatedAbility { + source_id: object_id, + ability: Box::new(ResolvedAbility::new( + Effect::NoOp, + vec![], + object_id, + PlayerId(0), + )), + }, + }); + assert_eq!(state.objects[&object_id].zone, Zone::Exile); + assert!(state.stack.iter().any(|entry| { + entry.id == object_id && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. }) + })); + + // CR 109.1 / CR 602.2a: A same-id activated ability is a distinct + // noncard stack object, so it cannot satisfy the spell-residency gate. + let mut events = Vec::new(); + let result = move_object_with_terminal( + &mut state, + ZoneMoveRequest::casting_to_stack(object_id, object_id), + &mut events, + ); + + assert!(matches!( + result, + ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained) + )); + assert_eq!(state.objects[&object_id].zone, Zone::Exile); + assert!(events.is_empty()); + } +} + #[cfg(test)] mod w3_library_placement_tests { use super::*; diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index a7be581206..cdefe1b5cc 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -1,7 +1,7 @@ use crate::types::card_type::CoreType; use crate::types::events::GameEvent; use crate::types::game_state::{ - GameState, ResolutionSourceRelatch, StackEntry, ZoneChangeCombatStatus, + GameState, ResolutionSourceRelatch, StackEntry, StackEntryKind, ZoneChangeCombatStatus, }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; @@ -17,11 +17,27 @@ use crate::types::zones::Zone; use super::game_object::GameObject; use super::printed_cards::{apply_back_face_to_object, snapshot_object_face}; -/// CR 111.7 / CR 111.8: A token outside the battlefield ceases to exist at -/// the next SBA, and can't change zones before then. Stack tokens are excluded -/// so spell copies can finish resolving before the next SBA check. -pub(super) fn token_is_outside_battlefield_and_stack(obj: &GameObject) -> bool { - obj.is_token && obj.zone != Zone::Battlefield && obj.zone != Zone::Stack +/// CR 109.1 + CR 601.2a + CR 405.1: A spell is an object on the stack from +/// announcement, even while this engine retains its origin-zone field until +/// finalization. CR 602.2a / CR 603.3: Activated and triggered abilities are +/// distinct noncard stack objects, so a same-id non-spell entry cannot make its +/// source object stack-resident. +fn object_has_stack_residency(state: &GameState, obj: &GameObject) -> bool { + obj.zone == Zone::Stack + || state.stack.iter().any(|entry| match &entry.kind { + StackEntryKind::Spell { .. } => entry.id == obj.id, + StackEntryKind::ActivatedAbility { .. } + | StackEntryKind::TriggeredAbility { .. } + | StackEntryKind::KeywordAction { .. } => false, + }) +} + +/// CR 704.5d / CR 111.7 / CR 111.8: A token outside the battlefield ceases to +/// exist at the next SBA and can't change zones before then. Effectively +/// stack-resident tokens are excluded so announced spell copies can finish +/// casting and resolving before the next applicable SBA check. +pub(super) fn token_is_outside_battlefield_and_stack(state: &GameState, obj: &GameObject) -> bool { + obj.is_token && obj.zone != Zone::Battlefield && !object_has_stack_residency(state, obj) } /// CR 704.5e + CR 707.10a: A copy of a card in any zone other than the stack or @@ -30,8 +46,11 @@ pub(super) fn token_is_outside_battlefield_and_stack(obj: &GameObject) -> bool { /// (CR 707.10f makes a permanent copy a token there) and may change zones freely /// while alive, so this predicate is used ONLY by the cease-to-exist SBA — never /// by the CR 111.8 "can't change zones" movement guards, which apply to tokens only. -pub(super) fn copy_of_card_outside_battlefield_and_stack(obj: &GameObject) -> bool { - obj.is_copy && obj.zone != Zone::Battlefield && obj.zone != Zone::Stack +pub(super) fn copy_of_card_outside_battlefield_and_stack( + state: &GameState, + obj: &GameObject, +) -> bool { + obj.is_copy && obj.zone != Zone::Battlefield && !object_has_stack_residency(state, obj) } /// CR 122.2 + CR 113.6b: Determine whether `object_id`'s counters survive a move @@ -941,7 +960,7 @@ pub fn move_to_zone( if state .objects .get(&object_id) - .is_some_and(token_is_outside_battlefield_and_stack) + .is_some_and(|obj| token_is_outside_battlefield_and_stack(state, obj)) { return; } @@ -1638,7 +1657,7 @@ pub fn move_to_library_at_index( if state .objects .get(&object_id) - .is_some_and(token_is_outside_battlefield_and_stack) + .is_some_and(|obj| token_is_outside_battlefield_and_stack(state, obj)) { return; } @@ -2286,10 +2305,10 @@ mod tests { use super::*; use crate::types::ability::{ - ContinuousModification, ControllerRef, FilterProp, StaticDefinition, TargetFilter, - TypeFilter, TypedFilter, + ContinuousModification, ControllerRef, Effect, FilterProp, ResolvedAbility, + StaticDefinition, TargetFilter, TypeFilter, TypedFilter, }; - use crate::types::game_state::GameState; + use crate::types::game_state::{CastingVariant, GameState}; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -2674,6 +2693,79 @@ mod tests { ); } + #[test] + fn announced_token_move_requires_same_id_spell_entry() { + let mut state = setup(); + let announced_spell = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Announced Spell Copy".to_string(), + Zone::Exile, + ); + state.objects.get_mut(&announced_spell).unwrap().is_token = true; + state.stack.push_back(StackEntry { + id: announced_spell, + source_id: announced_spell, + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(1), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + + let same_id_ability = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Activated Source".to_string(), + Zone::Exile, + ); + state.objects.get_mut(&same_id_ability).unwrap().is_token = true; + state.stack.push_back(StackEntry { + id: same_id_ability, + source_id: same_id_ability, + controller: PlayerId(0), + kind: StackEntryKind::ActivatedAbility { + source_id: same_id_ability, + ability: Box::new(ResolvedAbility::new( + Effect::NoOp, + vec![], + same_id_ability, + PlayerId(0), + )), + }, + }); + + assert_eq!(state.objects[&announced_spell].zone, Zone::Exile); + assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile); + + let mut spell_events = Vec::new(); + move_to_zone(&mut state, announced_spell, Zone::Stack, &mut spell_events); + assert_eq!(state.objects[&announced_spell].zone, Zone::Stack); + assert!(spell_events.iter().any(|event| { + matches!( + event, + GameEvent::ZoneChanged { object_id, to: Zone::Stack, .. } + if *object_id == announced_spell + ) + })); + + // CR 109.1 / CR 602.2a: The same-id activated ability is its own + // noncard stack object and cannot authorize movement of its source. + let mut ability_events = Vec::new(); + move_to_zone( + &mut state, + same_id_ability, + Zone::Stack, + &mut ability_events, + ); + assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile); + assert!(ability_events.is_empty()); + } + #[test] fn create_object_increments_id() { let mut state = setup(); diff --git a/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs b/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs index 9ffa2ed329..4446ae21d5 100644 --- a/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs +++ b/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs @@ -3,12 +3,13 @@ //! //! https://github.com/phase-rs/phase/issues/1312 -use engine::game::scenario::{GameScenario, P0}; +use engine::database::CardDatabase; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::types::ability::TargetRef; use engine::types::actions::GameAction; use engine::types::counter::CounterType; -use engine::types::game_state::WaitingFor; +use engine::types::game_state::{StackEntryKind, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; @@ -16,45 +17,40 @@ use engine::types::zones::Zone; use crate::support::shared_card_db as load_db; -fn drive_cast_to_stack(runner: &mut engine::game::scenario::GameRunner, spell_target: ObjectId) { - loop { - match &runner.state().waiting_for { - WaitingFor::TargetSelection { .. } => { - runner - .act(GameAction::ChooseTarget { - target: Some(TargetRef::Object(spell_target)), - }) - .expect("spell target selection should succeed"); - } - WaitingFor::TriggerTargetSelection { .. } => { - runner - .choose_first_legal_target() - .expect("trigger target selection should succeed"); - } - WaitingFor::ManaPayment { .. } => { - runner.act(GameAction::PassPriority).expect("pay mana"); - } - WaitingFor::Priority { .. } => break, - other => panic!("unexpected waiting state during cast: {other:?}"), - } - } +struct PreparedSwordsFixture { + runner: GameRunner, + emeritus: ObjectId, + exile_target: ObjectId, + scornmage: Option, + counterspell: Option, } -#[test] -fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { - let Some(db) = load_db() else { - return; - }; - +fn build_prepared_swords_fixture( + db: &CardDatabase, + with_scornmage: bool, + with_counterspell: bool, +) -> PreparedSwordsFixture { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); - let scornmage = scenario.add_real_card(P0, "Lecturing Scornmage", Zone::Battlefield, db); + let scornmage = with_scornmage + .then(|| scenario.add_real_card(P0, "Lecturing Scornmage", Zone::Battlefield, db)); let emeritus = scenario.add_real_card(P0, "Emeritus of Truce", Zone::Battlefield, db); let exile_target = scenario.add_creature(P0, "Exile Target", 2, 2).id(); + let counterspell = + with_counterspell.then(|| scenario.add_real_card(P1, "Counterspell", Zone::Hand, db)); scenario.with_mana_pool( P0, vec![ManaUnit::new(ManaType::White, ObjectId(0), false, vec![])], ); + if with_counterspell { + scenario.with_mana_pool( + P1, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ], + ); + } let mut runner = scenario.build(); runner.state_mut().debug_mode = true; @@ -64,10 +60,20 @@ fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { .state() .objects .get(&emeritus) - .and_then(|o| o.back_face.clone()) + .and_then(|object| object.back_face.clone()) .expect("Emeritus of Truce must hydrate Swords to Plowshares prepare face"); assert_eq!(back.name, "Swords to Plowshares"); + PreparedSwordsFixture { + runner, + emeritus, + exile_target, + scornmage, + counterspell, + } +} + +fn begin_prepared_cast_across_sba(runner: &mut GameRunner, emeritus: ObjectId) -> ObjectId { runner .act(GameAction::Debug( engine::types::actions::DebugAction::SetPrepared { @@ -81,7 +87,100 @@ fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { .act(GameAction::CastPreparedCopy { source: emeritus }) .expect("CastPreparedCopy should start the prepared spell cast"); + let copy_id = match &runner.state().waiting_for { + WaitingFor::TargetSelection { pending_cast, .. } => pending_cast.object_id, + other => panic!("prepared Swords cast must pause for a target, got {other:?}"), + }; + let placeholder = runner + .state() + .stack + .iter() + .find(|entry| entry.id == copy_id) + .expect("CR 601.2a announcement must create the exact prepared-copy stack entry"); + assert!(matches!( + &placeholder.kind, + StackEntryKind::Spell { ability: None, .. } + )); + assert!(runner.state().objects.contains_key(©_id)); + + let mut sba_events = Vec::new(); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut sba_events); + + // CR 601.2a: The announced spell remains on the stack throughout target + // selection, even while the engine retains its origin-zone representation. + assert!(runner.state().objects.contains_key(©_id)); + assert!(runner.state().stack.iter().any(|entry| entry.id == copy_id)); + assert!(matches!( + &runner.state().waiting_for, + WaitingFor::TargetSelection { pending_cast, .. } + if pending_cast.object_id == copy_id + )); + + copy_id +} + +fn drive_cast_to_stack(runner: &mut engine::game::scenario::GameRunner, spell_target: ObjectId) { + loop { + match &runner.state().waiting_for { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(spell_target)), + }) + .expect("spell target selection should succeed"); + } + WaitingFor::TriggerTargetSelection { .. } => { + runner + .choose_first_legal_target() + .expect("trigger target selection should succeed"); + } + WaitingFor::ManaPayment { .. } => { + runner.act(GameAction::PassPriority).expect("pay mana"); + } + WaitingFor::Priority { .. } => break, + other => panic!("unexpected waiting state during cast: {other:?}"), + } + } +} + +fn assert_prepared_copy_finalized_on_stack( + runner: &GameRunner, + copy_id: ObjectId, + spell_target: ObjectId, +) { + let entry = runner + .state() + .stack + .iter() + .find(|entry| entry.id == copy_id) + .expect("prepared spell must retain its exact stack entry after targeting"); + let ability = entry + .ability() + .expect("prepared spell stack entry must carry its finalized ability"); + assert!( + engine::game::ability_utils::flatten_targets_in_chain(ability) + .contains(&TargetRef::Object(spell_target)) + ); + assert_eq!(runner.state().objects[©_id].zone, Zone::Stack); +} + +#[test] +fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { + let Some(db) = load_db() else { + return; + }; + + let PreparedSwordsFixture { + mut runner, + emeritus, + exile_target, + scornmage, + counterspell: _, + } = build_prepared_swords_fixture(db, true, false); + let scornmage = scornmage.expect("Scornmage fixture requested"); + let copy_id = begin_prepared_cast_across_sba(&mut runner, emeritus); drive_cast_to_stack(&mut runner, exile_target); + assert_prepared_copy_finalized_on_stack(&runner, copy_id, exile_target); let scornmage_triggers = runner .state() @@ -93,27 +192,14 @@ fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { scornmage_triggers > 0, "Lecturing Scornmage must have SpellCast trigger after rehydrate" ); - let swords_stack_entry = runner - .state() - .stack - .iter() - .find(|entry| { - matches!( - entry.kind, - engine::types::game_state::StackEntryKind::Spell { .. } - ) - }) - .expect("prepared Swords copy must be on the stack after casting"); - let stack_ability = swords_stack_entry - .ability() - .expect("prepared spell stack entry must carry finalized ability"); - assert!( - !engine::game::ability_utils::flatten_targets_in_chain(stack_ability).is_empty(), - "prepared targeting spell must have targets on stack entry before trigger scan" - ); - runner.advance_until_stack_empty(); + assert_eq!(runner.state().objects[&exile_target].zone, Zone::Exile); + assert!(runner.state().stack.is_empty()); + // CR 608.2n + CR 704.5d + CR 704.5e: Swords resolves normally, then its + // previously proven-live synthetic copy ceases through the ordinary cleanup route. + assert!(!runner.state().objects.contains_key(©_id)); + let counters = runner .state() .objects @@ -126,3 +212,39 @@ fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { "Lecturing Scornmage must get a +1/+1 counter when a prepared targeting spell is cast" ); } + +#[test] +fn issue_1312_countered_prepared_copy_ceases_without_resolving() { + let Some(db) = load_db() else { + return; + }; + + let PreparedSwordsFixture { + mut runner, + emeritus, + exile_target, + scornmage: _, + counterspell, + } = build_prepared_swords_fixture(db, false, true); + let counterspell = counterspell.expect("Counterspell fixture requested"); + let copy_id = begin_prepared_cast_across_sba(&mut runner, emeritus); + drive_cast_to_stack(&mut runner, exile_target); + assert_prepared_copy_finalized_on_stack(&runner, copy_id, exile_target); + + runner + .act(GameAction::PassPriority) + .expect("P0 should pass priority to the Counterspell controller"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { player: P1 } + )); + + let outcome = runner.cast(counterspell).target_object(copy_id).resolve(); + + // CR 701.6a: Counterspell removes the prepared spell without resolving it. + outcome.assert_zone(&[exile_target], Zone::Battlefield); + assert!(outcome.state().stack.is_empty()); + // CR 704.5d + CR 704.5e: Once its own spell entry is gone, the synthetic + // copy's live stack-residency exemption expires and the next SBA makes it cease. + assert!(!outcome.state().objects.contains_key(©_id)); +} From 798fefe7646139626b52715f0f06527ad242f654 Mon Sep 17 00:00:00 2001 From: Nishad Date: Wed, 5 Aug 2026 12:03:40 -0700 Subject: [PATCH 2/3] fix(engine): scope prepared-copy residency to the pending cast lifecycle --- crates/engine/data/known-tokens.toml | 108 ----------------- crates/engine/data/mtgjson-vintage | 2 +- crates/engine/src/game/sba.rs | 72 ++---------- crates/engine/src/game/zone_pipeline.rs | 7 +- crates/engine/src/game/zones.rs | 110 ++++-------------- ...issue_1312_prepared_spell_cast_triggers.rs | 22 +--- 6 files changed, 46 insertions(+), 275 deletions(-) diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index fcf9b2fb66..7c62ba6f60 100644 --- a/crates/engine/data/known-tokens.toml +++ b/crates/engine/data/known-tokens.toml @@ -67684,11 +67684,6 @@ supertypes = [] colors = ["Green"] keywords = ["Trample"] -[[token.source_card_refs]] -card_name = "Advent of the Wurm" -scryfall_oracle_id = "eb62aa4b-c11b-4195-ae85-cff8f78ce17b" -scryfall_id = "f40284e6-01a1-4372-a92c-940e5732607e" - [[token.source_card_refs]] card_name = "Armada Wurm" scryfall_oracle_id = "33598888-0367-4085-9415-f4e21da08354" @@ -85532,11 +85527,6 @@ colors = [ ] keywords = ["Flying"] -[[token.source_card_refs]] -card_name = "Teysa, Envoy of Ghosts" -scryfall_oracle_id = "98b283dc-96a6-45a3-8a3f-11458497f358" -scryfall_id = "cbd8183c-6967-4332-b822-02b82c14ef2d" - [[token.source_card_refs]] card_name = "Beckon Apparition" scryfall_oracle_id = "10762505-ebaa-47de-9f3e-08d48f2ac240" @@ -119528,11 +119518,6 @@ card_name = "Horncaller's Chant" scryfall_oracle_id = "8c069834-ed89-4298-989b-e67036d56196" scryfall_id = "7b8d33ed-9ca2-41d1-ba35-fdeb5b88ad44" -[[token.source_card_refs]] -card_name = "Trostani's Summoner" -scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" -scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" - [token.token_image_ref] scryfall_id = "1331008a-ae86-4640-b823-a73be766ac16" scryfall_oracle_id = "a685171a-616f-42e1-9161-4a64fb51a977" @@ -139113,11 +139098,6 @@ supertypes = [] colors = ["White"] keywords = ["Vigilance"] -[[token.source_card_refs]] -card_name = "Sunspire Gatekeepers" -scryfall_oracle_id = "df2cc4ba-70b8-4875-8f84-9c268ab8d5f6" -scryfall_id = "0a3bc6b9-475b-4257-a3bc-1a0b70d45f79" - [[token.source_card_refs]] card_name = "Selesnya Charm" scryfall_oracle_id = "a1a49639-bcc4-4522-8862-c9fb13d40880" @@ -139138,11 +139118,6 @@ card_name = "Security Blockade" scryfall_oracle_id = "b4b397a9-85cb-4bdf-8c2e-a151a02a4208" scryfall_id = "e6250b31-8592-48b2-a877-3637c9ee7d49" -[[token.source_card_refs]] -card_name = "Trostani's Summoner" -scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" -scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" - [token.token_image_ref] scryfall_id = "67d3d039-248a-4eb8-be5c-12959b458fea" scryfall_oracle_id = "340aaeb6-cd18-4908-9729-8c53ab02c6f8" @@ -144208,11 +144183,6 @@ card_name = "Assemble the Legion" scryfall_oracle_id = "6f81bef3-6ca0-4cf4-aa99-7b2813eeef04" scryfall_id = "5515422b-5bdd-413b-a414-ad3510dd62cc" -[[token.source_card_refs]] -card_name = "Blaze Commando" -scryfall_oracle_id = "92e2f982-f8b9-4c3b-ab50-14c025370e5c" -scryfall_id = "5e179f0d-2965-44e4-8483-67b330a8608c" - [[token.source_card_refs]] card_name = "Sunhome Guildmage" scryfall_oracle_id = "f211d21f-7d78-4435-a592-a6896023af3b" @@ -169872,46 +169842,6 @@ scryfall_id = "4bd69d39-c2ce-44b4-b2d0-5e384d69db02" scryfall_oracle_id = "340aaeb6-cd18-4908-9729-8c53ab02c6f8" preset_id = "c2376c1a-2d69-52dc-84a0-aec8a4e9a82b" -[[token]] -id = "c24dadf0-35cd-5122-91a2-c6744ff64ca5" -category = "Creature" -fidelity = "PartialMissingAbilities" -source_card_names = [ - "Vernal Sovereign", - "Voice of Resurgence", -] -set_code = "DGM" -set_name = "Dragon's Maze" -collector_number = "1" -released_at = "2013-05-03" -type_line = "Token Creature — Elemental" -rules_text = "This creature's power and toughness are each equal to the number of creatures you control." - -[token.pt_provenance.SourceDefinedOrDynamic] -power = "*" -toughness = "*" - -[token.body] -display_name = "Elemental" -core_types = ["Creature"] -subtypes = ["Elemental"] -supertypes = [] -colors = [ - "Green", - "White", -] -keywords = [] - -[[token.source_card_refs]] -card_name = "Voice of Resurgence" -scryfall_oracle_id = "5cbbb3f3-63a4-4983-81ea-8c405b10e63f" -scryfall_id = "07246783-d475-4f61-99ac-e2b574072349" - -[token.token_image_ref] -scryfall_id = "5bfb1440-d4c1-42cf-a777-ee1644dbbac7" -scryfall_oracle_id = "9f790267-5be9-41aa-be69-f87a8ce5a29e" -preset_id = "c24dadf0-35cd-5122-91a2-c6744ff64ca5" - [[token]] id = "c260fa03-ae9a-5bda-8c06-aab126a23edd" category = "Creature" @@ -209858,9 +209788,7 @@ source_card_names = [ "Aether Channeler", "Akim, the Soaring Wind", "Battle Screech", - "Beck", "Beck // Call", - "Call", "Emeria Angel", "Eyes in the Skies", "Falcon and Redwing", @@ -209896,18 +209824,6 @@ supertypes = [] colors = ["White"] keywords = ["Flying"] -[[token.source_card_refs]] -card_name = "Beck // Call" -face_name = "Call" -scryfall_oracle_id = "5b8472c8-a7e7-46f2-aecf-e954082a5ef5" -scryfall_id = "a01d6540-9eaf-4e08-a62d-682551ee78e9" - -[[token.source_card_refs]] -card_name = "Beck // Call" -face_name = "Beck" -scryfall_oracle_id = "5b8472c8-a7e7-46f2-aecf-e954082a5ef5" -scryfall_id = "a01d6540-9eaf-4e08-a62d-682551ee78e9" - [[token.source_card_refs]] card_name = "Eyes in the Skies" scryfall_oracle_id = "9697cd05-474e-46f3-8bcc-d4b6fb2059a1" @@ -209918,11 +209834,6 @@ card_name = "Seller of Songbirds" scryfall_oracle_id = "87e4c6ff-f83f-412b-9d23-aac7a57ef6db" scryfall_id = "2a41edbe-4c5a-4535-a082-235dc3ffe60a" -[[token.source_card_refs]] -card_name = "Scion of Vitu-Ghazi" -scryfall_oracle_id = "a34c4d5c-6ecb-4da2-8734-a2f3d35cfe8b" -scryfall_id = "3cd20865-0a9a-4a72-92f9-77c8d6384b46" - [token.token_image_ref] scryfall_id = "05b4dbe1-12ac-404f-a1fe-96e0b620533e" scryfall_oracle_id = "b1a2b096-a440-4ef9-ab2a-059c79999297" @@ -220865,7 +220776,6 @@ id = "fa42ee21-15bf-591e-93c7-0f78f10e338f" category = "Creature" fidelity = "Full" source_card_names = [ - "Alive", "Alive // Well", "Call of the Conclave", "Centaur Glade", @@ -220874,7 +220784,6 @@ source_card_names = [ "Rampage of the Clans", "Trostani's Summoner", "Vitu-Ghazi Guildmage", - "Well", ] set_code = "RTR" set_name = "Return to Ravnica" @@ -220897,23 +220806,11 @@ card_name = "Call of the Conclave" scryfall_oracle_id = "e1635acd-ed1e-4038-a11a-6518df285253" scryfall_id = "c6df8f4d-a07a-4664-878d-efec8b2affb9" -[[token.source_card_refs]] -card_name = "Alive // Well" -face_name = "Alive" -scryfall_oracle_id = "57ad3c3a-6ac7-4a35-bc07-00f6ea0988c5" -scryfall_id = "db84415e-048a-4cfc-9121-5ae17a412198" - [[token.source_card_refs]] card_name = "Centaur's Herald" scryfall_oracle_id = "b47fd32d-1a56-414f-9d91-ddd46d4b5ac7" scryfall_id = "08598b2b-6fd2-4a1d-8d74-7ca6d93ad382" -[[token.source_card_refs]] -card_name = "Alive // Well" -face_name = "Well" -scryfall_oracle_id = "57ad3c3a-6ac7-4a35-bc07-00f6ea0988c5" -scryfall_id = "db84415e-048a-4cfc-9121-5ae17a412198" - [[token.source_card_refs]] card_name = "Coursers' Accord" scryfall_oracle_id = "23674648-d1a0-437d-8a0b-4173c75b4507" @@ -220924,11 +220821,6 @@ card_name = "Vitu-Ghazi Guildmage" scryfall_oracle_id = "da6f79ec-5f7e-4099-89c5-6d2bfc14d957" scryfall_id = "e54f8e61-550f-4493-b8ba-65f81b2457d3" -[[token.source_card_refs]] -card_name = "Trostani's Summoner" -scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" -scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" - [token.token_image_ref] scryfall_id = "880d5dc1-ceec-4c5f-93c2-c88b7dbfcac2" scryfall_oracle_id = "c41ea1cd-d87a-4e4c-b814-4c24c5bef511" diff --git a/crates/engine/data/mtgjson-vintage b/crates/engine/data/mtgjson-vintage index 37ee3543f0..4fe55d2621 100644 --- a/crates/engine/data/mtgjson-vintage +++ b/crates/engine/data/mtgjson-vintage @@ -1 +1 @@ -2026-08-03 +2026-08-04 diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index f1af214ee9..f1f6984d04 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -2302,8 +2302,7 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ResolvedAbility, - TargetFilter, + AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, TargetFilter, }; use crate::types::actions::GameAction; use crate::types::format::FormatConfig; @@ -4912,7 +4911,7 @@ mod tests { } #[test] - fn announced_off_zone_noncard_survival_requires_same_id_spell_entry() { + fn bare_same_id_spell_entry_does_not_prevent_off_zone_noncard_cleanup() { fn add_off_zone_noncard( state: &mut GameState, card_id: u64, @@ -4947,73 +4946,26 @@ mod tests { }); } - fn push_virtual_activated_entry(state: &mut GameState, id: ObjectId) { - state.stack.push_back(StackEntry { - id, - source_id: id, - controller: PlayerId(0), - kind: StackEntryKind::ActivatedAbility { - source_id: id, - ability: Box::new(ResolvedAbility::new(Effect::NoOp, vec![], id, PlayerId(0))), - }, - }); - } - let mut state = setup(); - let announced_token = add_off_zone_noncard(&mut state, 1, "Announced Token", true, false); - let activated_token = add_off_zone_noncard(&mut state, 2, "Activated Token", true, false); - let unmatched_token = add_off_zone_noncard(&mut state, 3, "Unmatched Token", true, false); - let announced_copy = add_off_zone_noncard(&mut state, 4, "Announced Copy", false, true); - let activated_copy = add_off_zone_noncard(&mut state, 5, "Activated Copy", false, true); - let unmatched_copy = add_off_zone_noncard(&mut state, 6, "Unmatched Copy", false, true); - - push_spell_placeholder(&mut state, announced_token, 1); - push_virtual_activated_entry(&mut state, activated_token); - push_spell_placeholder(&mut state, announced_copy, 4); - push_virtual_activated_entry(&mut state, activated_copy); - - for id in [ - announced_token, - activated_token, - unmatched_token, - announced_copy, - activated_copy, - unmatched_copy, - ] { + let token = add_off_zone_noncard(&mut state, 1, "Orphan Token", true, false); + let copy = add_off_zone_noncard(&mut state, 2, "Orphan Copy", false, true); + push_spell_placeholder(&mut state, token, 1); + push_spell_placeholder(&mut state, copy, 2); + + for id in [token, copy] { assert!(state.objects.contains_key(&id)); assert_eq!(state.objects[&id].zone, Zone::Exile); } - assert!(state.stack.iter().any(|entry| { - entry.id == activated_token - && entry.source_id == activated_token - && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. }) - })); - assert!(state.stack.iter().any(|entry| { - entry.id == activated_copy - && entry.source_id == activated_copy - && matches!(entry.kind, StackEntryKind::ActivatedAbility { .. }) - })); let mut events = Vec::new(); check_state_based_actions(&mut state, &mut events); - // CR 601.2a: The exact same-id spell placeholders make these announced - // spell objects stack-resident despite the retained Exile field. - assert!(state.objects.contains_key(&announced_token)); - assert!(state.objects.contains_key(&announced_copy)); - - // CR 704.5d + CR 704.5e: Unmatched off-zone tokens/copies cease, and - // CR 109.1 / CR 602.2a means a same-id activated ability cannot protect - // its source. - for id in [ - activated_token, - unmatched_token, - activated_copy, - unmatched_copy, - ] { + // CR 704.5d + CR 704.5e: Bare same-id spell entries do not establish a + // live casting lifecycle, so both synthetic off-zone objects cease. + for id in [token, copy] { assert!( !state.objects.contains_key(&id), - "off-zone noncard object {id:?} must cease without its own spell entry" + "off-zone noncard object {id:?} must cease without its own PendingCast" ); } } diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 97772b6701..aa11e3530a 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -738,10 +738,9 @@ pub(crate) fn move_object_with_terminal( .get(&req.object_id) .expect("object exists (zone read above)"); // CR 111.8: A token that has left the battlefield can't change zones; it - // remains in place and ceases to exist at the next SBA (CR 111.7). A - // same-id CR 601.2a `StackEntryKind::Spell` placeholder makes an - // announced spell effectively stack-resident and eligible for its - // retained-origin representation's delivery to `Zone::Stack`. + // remains in place and ceases to exist at the next SBA (CR 111.7). An + // exact CR 601.2a pending spell plus its announcement placeholder makes + // the retained-origin representation stack-resident until this delivery. if zones::token_is_outside_battlefield_and_stack(state, obj) { return ZoneMoveTerminalResult::Completed(ZoneMoveCompletion::Remained); } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index cdefe1b5cc..02de212751 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -19,17 +19,28 @@ use super::printed_cards::{apply_back_face_to_object, snapshot_object_face}; /// CR 109.1 + CR 601.2a + CR 405.1: A spell is an object on the stack from /// announcement, even while this engine retains its origin-zone field until -/// finalization. CR 602.2a / CR 603.3: Activated and triggered abilities are -/// distinct noncard stack objects, so a same-id non-spell entry cannot make its -/// source object stack-resident. +/// finalization. The retained-origin representation is stack-resident only while +/// the exact spell's `PendingCast` lifecycle and announcement placeholder both +/// remain live; a bare same-id stack entry is insufficient. fn object_has_stack_residency(state: &GameState, obj: &GameObject) -> bool { - obj.zone == Zone::Stack - || state.stack.iter().any(|entry| match &entry.kind { - StackEntryKind::Spell { .. } => entry.id == obj.id, - StackEntryKind::ActivatedAbility { .. } - | StackEntryKind::TriggeredAbility { .. } - | StackEntryKind::KeywordAction { .. } => false, - }) + if obj.zone == Zone::Stack { + return true; + } + + let is_pending_spell = |pending: &crate::types::game_state::PendingCast| { + pending.object_id == obj.id && pending.activation_ability_index.is_none() + }; + let has_pending_spell = state.pending_cast.as_deref().is_some_and(is_pending_spell) + || state + .waiting_for + .pending_cast_ref() + .is_some_and(is_pending_spell); + + has_pending_spell + && state + .stack + .iter() + .any(|entry| entry.id == obj.id && matches!(entry.kind, StackEntryKind::Spell { .. })) } /// CR 704.5d / CR 111.7 / CR 111.8: A token outside the battlefield ceases to @@ -2305,10 +2316,10 @@ mod tests { use super::*; use crate::types::ability::{ - ContinuousModification, ControllerRef, Effect, FilterProp, ResolvedAbility, - StaticDefinition, TargetFilter, TypeFilter, TypedFilter, + ContinuousModification, ControllerRef, FilterProp, StaticDefinition, TargetFilter, + TypeFilter, TypedFilter, }; - use crate::types::game_state::{CastingVariant, GameState}; + use crate::types::game_state::GameState; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -2693,79 +2704,6 @@ mod tests { ); } - #[test] - fn announced_token_move_requires_same_id_spell_entry() { - let mut state = setup(); - let announced_spell = create_object( - &mut state, - CardId(1), - PlayerId(0), - "Announced Spell Copy".to_string(), - Zone::Exile, - ); - state.objects.get_mut(&announced_spell).unwrap().is_token = true; - state.stack.push_back(StackEntry { - id: announced_spell, - source_id: announced_spell, - controller: PlayerId(0), - kind: StackEntryKind::Spell { - card_id: CardId(1), - ability: None, - casting_variant: CastingVariant::Normal, - actual_mana_spent: 0, - }, - }); - - let same_id_ability = create_object( - &mut state, - CardId(2), - PlayerId(0), - "Activated Source".to_string(), - Zone::Exile, - ); - state.objects.get_mut(&same_id_ability).unwrap().is_token = true; - state.stack.push_back(StackEntry { - id: same_id_ability, - source_id: same_id_ability, - controller: PlayerId(0), - kind: StackEntryKind::ActivatedAbility { - source_id: same_id_ability, - ability: Box::new(ResolvedAbility::new( - Effect::NoOp, - vec![], - same_id_ability, - PlayerId(0), - )), - }, - }); - - assert_eq!(state.objects[&announced_spell].zone, Zone::Exile); - assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile); - - let mut spell_events = Vec::new(); - move_to_zone(&mut state, announced_spell, Zone::Stack, &mut spell_events); - assert_eq!(state.objects[&announced_spell].zone, Zone::Stack); - assert!(spell_events.iter().any(|event| { - matches!( - event, - GameEvent::ZoneChanged { object_id, to: Zone::Stack, .. } - if *object_id == announced_spell - ) - })); - - // CR 109.1 / CR 602.2a: The same-id activated ability is its own - // noncard stack object and cannot authorize movement of its source. - let mut ability_events = Vec::new(); - move_to_zone( - &mut state, - same_id_ability, - Zone::Stack, - &mut ability_events, - ); - assert_eq!(state.objects[&same_id_ability].zone, Zone::Exile); - assert!(ability_events.is_empty()); - } - #[test] fn create_object_increments_id() { let mut state = setup(); diff --git a/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs b/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs index 4446ae21d5..366cbf47d7 100644 --- a/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs +++ b/crates/engine/tests/integration/issue_1312_prepared_spell_cast_triggers.rs @@ -73,7 +73,7 @@ fn build_prepared_swords_fixture( } } -fn begin_prepared_cast_across_sba(runner: &mut GameRunner, emeritus: ObjectId) -> ObjectId { +fn begin_prepared_cast(runner: &mut GameRunner, emeritus: ObjectId) -> ObjectId { runner .act(GameAction::Debug( engine::types::actions::DebugAction::SetPrepared { @@ -103,19 +103,6 @@ fn begin_prepared_cast_across_sba(runner: &mut GameRunner, emeritus: ObjectId) - )); assert!(runner.state().objects.contains_key(©_id)); - let mut sba_events = Vec::new(); - engine::game::sba::check_state_based_actions(runner.state_mut(), &mut sba_events); - - // CR 601.2a: The announced spell remains on the stack throughout target - // selection, even while the engine retains its origin-zone representation. - assert!(runner.state().objects.contains_key(©_id)); - assert!(runner.state().stack.iter().any(|entry| entry.id == copy_id)); - assert!(matches!( - &runner.state().waiting_for, - WaitingFor::TargetSelection { pending_cast, .. } - if pending_cast.object_id == copy_id - )); - copy_id } @@ -178,8 +165,11 @@ fn issue_1312_prepared_swords_to_plowshares_triggers_lecturing_scornmage() { counterspell: _, } = build_prepared_swords_fixture(db, true, false); let scornmage = scornmage.expect("Scornmage fixture requested"); - let copy_id = begin_prepared_cast_across_sba(&mut runner, emeritus); + let copy_id = begin_prepared_cast(&mut runner, emeritus); drive_cast_to_stack(&mut runner, exile_target); + // CR 601.2a + CR 704.3: Choosing the target completes the cast through + // `apply`, which reaches the ordinary priority-boundary SBA pipeline. The + // prepared copy must already have finalized into its real Stack zone there. assert_prepared_copy_finalized_on_stack(&runner, copy_id, exile_target); let scornmage_triggers = runner @@ -227,7 +217,7 @@ fn issue_1312_countered_prepared_copy_ceases_without_resolving() { counterspell, } = build_prepared_swords_fixture(db, false, true); let counterspell = counterspell.expect("Counterspell fixture requested"); - let copy_id = begin_prepared_cast_across_sba(&mut runner, emeritus); + let copy_id = begin_prepared_cast(&mut runner, emeritus); drive_cast_to_stack(&mut runner, exile_target); assert_prepared_copy_finalized_on_stack(&runner, copy_id, exile_target); From 4756407f0f3d66ed706ab310a082716468501d0b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 5 Aug 2026 12:09:34 -0700 Subject: [PATCH 3/3] fix(PR-7017): retain current generated token catalog Co-authored-by: nishu-builder --- crates/engine/data/known-tokens.toml | 108 +++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index cde3fdecc6..de74543516 100644 --- a/crates/engine/data/known-tokens.toml +++ b/crates/engine/data/known-tokens.toml @@ -67808,6 +67808,11 @@ supertypes = [] colors = ["Green"] keywords = ["Trample"] +[[token.source_card_refs]] +card_name = "Advent of the Wurm" +scryfall_oracle_id = "eb62aa4b-c11b-4195-ae85-cff8f78ce17b" +scryfall_id = "f40284e6-01a1-4372-a92c-940e5732607e" + [[token.source_card_refs]] card_name = "Armada Wurm" scryfall_oracle_id = "33598888-0367-4085-9415-f4e21da08354" @@ -85683,6 +85688,11 @@ colors = [ ] keywords = ["Flying"] +[[token.source_card_refs]] +card_name = "Teysa, Envoy of Ghosts" +scryfall_oracle_id = "98b283dc-96a6-45a3-8a3f-11458497f358" +scryfall_id = "cbd8183c-6967-4332-b822-02b82c14ef2d" + [[token.source_card_refs]] card_name = "Beckon Apparition" scryfall_oracle_id = "10762505-ebaa-47de-9f3e-08d48f2ac240" @@ -119740,6 +119750,11 @@ card_name = "Horncaller's Chant" scryfall_oracle_id = "8c069834-ed89-4298-989b-e67036d56196" scryfall_id = "7b8d33ed-9ca2-41d1-ba35-fdeb5b88ad44" +[[token.source_card_refs]] +card_name = "Trostani's Summoner" +scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" +scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" + [token.token_image_ref] scryfall_id = "1331008a-ae86-4640-b823-a73be766ac16" scryfall_oracle_id = "a685171a-616f-42e1-9161-4a64fb51a977" @@ -139367,6 +139382,11 @@ supertypes = [] colors = ["White"] keywords = ["Vigilance"] +[[token.source_card_refs]] +card_name = "Sunspire Gatekeepers" +scryfall_oracle_id = "df2cc4ba-70b8-4875-8f84-9c268ab8d5f6" +scryfall_id = "0a3bc6b9-475b-4257-a3bc-1a0b70d45f79" + [[token.source_card_refs]] card_name = "Selesnya Charm" scryfall_oracle_id = "a1a49639-bcc4-4522-8862-c9fb13d40880" @@ -139387,6 +139407,11 @@ card_name = "Security Blockade" scryfall_oracle_id = "b4b397a9-85cb-4bdf-8c2e-a151a02a4208" scryfall_id = "e6250b31-8592-48b2-a877-3637c9ee7d49" +[[token.source_card_refs]] +card_name = "Trostani's Summoner" +scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" +scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" + [token.token_image_ref] scryfall_id = "67d3d039-248a-4eb8-be5c-12959b458fea" scryfall_oracle_id = "340aaeb6-cd18-4908-9729-8c53ab02c6f8" @@ -144458,6 +144483,11 @@ card_name = "Assemble the Legion" scryfall_oracle_id = "6f81bef3-6ca0-4cf4-aa99-7b2813eeef04" scryfall_id = "5515422b-5bdd-413b-a414-ad3510dd62cc" +[[token.source_card_refs]] +card_name = "Blaze Commando" +scryfall_oracle_id = "92e2f982-f8b9-4c3b-ab50-14c025370e5c" +scryfall_id = "5e179f0d-2965-44e4-8483-67b330a8608c" + [[token.source_card_refs]] card_name = "Sunhome Guildmage" scryfall_oracle_id = "f211d21f-7d78-4435-a592-a6896023af3b" @@ -170155,6 +170185,46 @@ scryfall_id = "4bd69d39-c2ce-44b4-b2d0-5e384d69db02" scryfall_oracle_id = "340aaeb6-cd18-4908-9729-8c53ab02c6f8" preset_id = "c2376c1a-2d69-52dc-84a0-aec8a4e9a82b" +[[token]] +id = "c24dadf0-35cd-5122-91a2-c6744ff64ca5" +category = "Creature" +fidelity = "PartialMissingAbilities" +source_card_names = [ + "Vernal Sovereign", + "Voice of Resurgence", +] +set_code = "DGM" +set_name = "Dragon's Maze" +collector_number = "1" +released_at = "2013-05-03" +type_line = "Token Creature — Elemental" +rules_text = "This creature's power and toughness are each equal to the number of creatures you control." + +[token.pt_provenance.SourceDefinedOrDynamic] +power = "*" +toughness = "*" + +[token.body] +display_name = "Elemental" +core_types = ["Creature"] +subtypes = ["Elemental"] +supertypes = [] +colors = [ + "Green", + "White", +] +keywords = [] + +[[token.source_card_refs]] +card_name = "Voice of Resurgence" +scryfall_oracle_id = "5cbbb3f3-63a4-4983-81ea-8c405b10e63f" +scryfall_id = "07246783-d475-4f61-99ac-e2b574072349" + +[token.token_image_ref] +scryfall_id = "5bfb1440-d4c1-42cf-a777-ee1644dbbac7" +scryfall_oracle_id = "9f790267-5be9-41aa-be69-f87a8ce5a29e" +preset_id = "c24dadf0-35cd-5122-91a2-c6744ff64ca5" + [[token]] id = "c260fa03-ae9a-5bda-8c06-aab126a23edd" category = "Creature" @@ -210206,7 +210276,9 @@ source_card_names = [ "Aether Channeler", "Akim, the Soaring Wind", "Battle Screech", + "Beck", "Beck // Call", + "Call", "Emeria Angel", "Eyes in the Skies", "Falcon and Redwing", @@ -210242,6 +210314,18 @@ supertypes = [] colors = ["White"] keywords = ["Flying"] +[[token.source_card_refs]] +card_name = "Beck // Call" +face_name = "Call" +scryfall_oracle_id = "5b8472c8-a7e7-46f2-aecf-e954082a5ef5" +scryfall_id = "a01d6540-9eaf-4e08-a62d-682551ee78e9" + +[[token.source_card_refs]] +card_name = "Beck // Call" +face_name = "Beck" +scryfall_oracle_id = "5b8472c8-a7e7-46f2-aecf-e954082a5ef5" +scryfall_id = "a01d6540-9eaf-4e08-a62d-682551ee78e9" + [[token.source_card_refs]] card_name = "Eyes in the Skies" scryfall_oracle_id = "9697cd05-474e-46f3-8bcc-d4b6fb2059a1" @@ -210252,6 +210336,11 @@ card_name = "Seller of Songbirds" scryfall_oracle_id = "87e4c6ff-f83f-412b-9d23-aac7a57ef6db" scryfall_id = "2a41edbe-4c5a-4535-a082-235dc3ffe60a" +[[token.source_card_refs]] +card_name = "Scion of Vitu-Ghazi" +scryfall_oracle_id = "a34c4d5c-6ecb-4da2-8734-a2f3d35cfe8b" +scryfall_id = "3cd20865-0a9a-4a72-92f9-77c8d6384b46" + [token.token_image_ref] scryfall_id = "05b4dbe1-12ac-404f-a1fe-96e0b620533e" scryfall_oracle_id = "b1a2b096-a440-4ef9-ab2a-059c79999297" @@ -221212,6 +221301,7 @@ id = "fa42ee21-15bf-591e-93c7-0f78f10e338f" category = "Creature" fidelity = "Full" source_card_names = [ + "Alive", "Alive // Well", "Call of the Conclave", "Centaur Glade", @@ -221220,6 +221310,7 @@ source_card_names = [ "Rampage of the Clans", "Trostani's Summoner", "Vitu-Ghazi Guildmage", + "Well", ] set_code = "RTR" set_name = "Return to Ravnica" @@ -221242,11 +221333,23 @@ card_name = "Call of the Conclave" scryfall_oracle_id = "e1635acd-ed1e-4038-a11a-6518df285253" scryfall_id = "c6df8f4d-a07a-4664-878d-efec8b2affb9" +[[token.source_card_refs]] +card_name = "Alive // Well" +face_name = "Alive" +scryfall_oracle_id = "57ad3c3a-6ac7-4a35-bc07-00f6ea0988c5" +scryfall_id = "db84415e-048a-4cfc-9121-5ae17a412198" + [[token.source_card_refs]] card_name = "Centaur's Herald" scryfall_oracle_id = "b47fd32d-1a56-414f-9d91-ddd46d4b5ac7" scryfall_id = "08598b2b-6fd2-4a1d-8d74-7ca6d93ad382" +[[token.source_card_refs]] +card_name = "Alive // Well" +face_name = "Well" +scryfall_oracle_id = "57ad3c3a-6ac7-4a35-bc07-00f6ea0988c5" +scryfall_id = "db84415e-048a-4cfc-9121-5ae17a412198" + [[token.source_card_refs]] card_name = "Coursers' Accord" scryfall_oracle_id = "23674648-d1a0-437d-8a0b-4173c75b4507" @@ -221257,6 +221360,11 @@ card_name = "Vitu-Ghazi Guildmage" scryfall_oracle_id = "da6f79ec-5f7e-4099-89c5-6d2bfc14d957" scryfall_id = "e54f8e61-550f-4493-b8ba-65f81b2457d3" +[[token.source_card_refs]] +card_name = "Trostani's Summoner" +scryfall_oracle_id = "874e0b54-bc24-4887-abe1-1ecfd3a3abae" +scryfall_id = "1921fa4e-2256-4ef1-b2fe-874f9fbbcdf3" + [token.token_image_ref] scryfall_id = "880d5dc1-ceec-4c5f-93c2-c88b7dbfcac2" scryfall_oracle_id = "c41ea1cd-d87a-4e4c-b814-4c24c5bef511"