From 5ef85cfcefb8c62d8f1ae827529fc22039772da2 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:48:47 +0200 Subject: [PATCH 1/3] fix(engine): preserve mana-spent snapshots across spell ETB (#1156) Extend CastLinkSnapshot so creature spells keep mana_spent_source_snapshots and related cast-payment fields when resolving from the stack to the battlefield, letting Coin of Mastery count artifact mana spent to cast. Co-authored-by: Cursor --- crates/engine/src/game/zone_pipeline.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index efc29add82..cdae8a8e16 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -488,6 +488,10 @@ struct CastLinkSnapshot { additional_cost_payment_count: u32, additional_cost_payments: Vec, convoked_creatures: Vec, + mana_spent_to_cast: bool, + mana_spent_to_cast_amount: u32, + colors_spent_to_cast: crate::types::mana::ColoredManaCount, + mana_spent_source_snapshots: Vec, } /// Result of a single zone-move attempt through the replacement pipeline. @@ -1561,6 +1565,10 @@ pub(crate) fn deliver_replaced_zone_change( additional_cost_payment_count: obj.additional_cost_payment_count, additional_cost_payments: obj.additional_cost_payments.clone(), convoked_creatures: obj.convoked_creatures.clone(), + mana_spent_to_cast: obj.mana_spent_to_cast, + mana_spent_to_cast_amount: obj.mana_spent_to_cast_amount, + colors_spent_to_cast: obj.colors_spent_to_cast.clone(), + mana_spent_source_snapshots: obj.mana_spent_source_snapshots.clone(), }) }) .flatten(); @@ -1630,6 +1638,10 @@ pub(crate) fn deliver_replaced_zone_change( obj.additional_cost_payment_count = link.additional_cost_payment_count; obj.additional_cost_payments = link.additional_cost_payments; obj.convoked_creatures = link.convoked_creatures; + obj.mana_spent_to_cast = link.mana_spent_to_cast; + obj.mana_spent_to_cast_amount = link.mana_spent_to_cast_amount; + obj.colors_spent_to_cast = link.colors_spent_to_cast; + obj.mana_spent_source_snapshots = link.mana_spent_source_snapshots; } } if to == Zone::Battlefield || from == Zone::Battlefield { From 90ebcba05b539cec62cbb756f3b0a9bc04490933 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:41:50 +0200 Subject: [PATCH 2/3] test(engine): add Coin of Mastery artifact-mana ETB regressions (#1156) Revert the no-op CastLinkSnapshot mana-spent expansion in zone_pipeline. Coin-style ETB counters resolve before the battlefield move via extract_etb_counters using the entering spell's payment-time source snapshots (casting.rs), not post-delivery snapshot restore. Co-authored-by: Cursor --- crates/engine/src/game/replacement.rs | 96 +++++++++ crates/engine/src/game/zone_pipeline.rs | 12 -- .../integration/issue_1156_coin_of_mastery.rs | 203 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 4 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 crates/engine/tests/integration/issue_1156_coin_of_mastery.rs diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 1b4bea64b5..bc4eb40371 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -10676,6 +10676,102 @@ mod tests { } } + /// CR 614.1c + CR 601.2h: Coin of Mastery — artifact-source mana spent to + /// cast the entering creature resolves via payment-time source snapshots on + /// the spell object, not the static replacement source. + #[test] + fn artifact_mana_spent_on_self_resolves_against_entering_object() { + use crate::types::ability::{ + AbilityKind, CastManaObjectScope, CastManaSpentMetric, Effect, QuantityExpr, + QuantityRef, TargetFilter, TypeFilter, TypedFilter, + }; + use crate::types::card_type::CoreType; + + let coin_id = ObjectId(10); + let creature_id = ObjectId(20); + let treasure_id = ObjectId(30); + + let etb_counter_ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + target: TargetFilter::SelfRef, + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::FromSource { + source_filter: TargetFilter::Typed(TypedFilter::new( + TypeFilter::Artifact, + )), + }, + }, + }, + }, + ); + + let creature_filter = TargetFilter::Typed( + crate::types::ability::TypedFilter::creature() + .controller(crate::types::ability::ControllerRef::You), + ); + + let repl = ReplacementDefinition::new(ReplacementEvent::ChangeZone) + .execute(etb_counter_ability) + .valid_card(creature_filter) + .destination_zone(Zone::Battlefield); + + let mut state = test_state_with_object(coin_id, Zone::Battlefield, vec![repl]); + + let mut treasure = crate::game::game_object::GameObject::new( + treasure_id, + CardId(98), + PlayerId(0), + "Treasure".to_string(), + Zone::Battlefield, + ); + treasure.card_types.core_types.push(CoreType::Artifact); + treasure.card_types.subtypes.push("Treasure".to_string()); + state.objects.insert(treasure_id, treasure); + + let mut spell = crate::game::game_object::GameObject::new( + creature_id, + CardId(99), + PlayerId(0), + "Creature".to_string(), + Zone::Stack, + ); + spell.card_types.core_types.push(CoreType::Creature); + spell.mana_spent_source_snapshots = vec![ + crate::types::game_state::ManaSpentSourceSnapshot { + source_id: treasure_id, + lki: state.objects[&treasure_id].snapshot_for_mana_spent(), + }, + crate::types::game_state::ManaSpentSourceSnapshot { + source_id: treasure_id, + lki: state.objects[&treasure_id].snapshot_for_mana_spent(), + }, + ]; + state.objects.insert(creature_id, spell); + + let mut events = Vec::new(); + let proposed = + ProposedEvent::zone_change(creature_id, Zone::Stack, Zone::Battlefield, None); + + let result = replace_event(&mut state, proposed, &mut events); + match result { + ReplacementResult::Execute(ProposedEvent::ZoneChange { + enter_with_counters, + .. + }) => { + assert_eq!( + enter_with_counters, + vec![(CounterType::Plus1Plus1, 2u32)], + "expected 2 P1P1 counters (2 artifact-source mana units spent)" + ); + } + other => panic!("expected Execute(ZoneChange), got {:?}", other), + } + } + /// Regression: when a self-scoped spent-mana quantity is used outside an ETB /// context (no entering object), it resolves against the static source. This /// keeps `CountersOnSelf`-style refs working for static abilities that inspect diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index ef6797979c..26705a0051 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -488,10 +488,6 @@ struct CastLinkSnapshot { additional_cost_payment_count: u32, additional_cost_payments: Vec, convoked_creatures: Vec, - mana_spent_to_cast: bool, - mana_spent_to_cast_amount: u32, - colors_spent_to_cast: crate::types::mana::ColoredManaCount, - mana_spent_source_snapshots: Vec, } /// Result of a single zone-move attempt through the replacement pipeline. @@ -1568,10 +1564,6 @@ pub(crate) fn deliver_replaced_zone_change( additional_cost_payment_count: obj.additional_cost_payment_count, additional_cost_payments: obj.additional_cost_payments.clone(), convoked_creatures: obj.convoked_creatures.clone(), - mana_spent_to_cast: obj.mana_spent_to_cast, - mana_spent_to_cast_amount: obj.mana_spent_to_cast_amount, - colors_spent_to_cast: obj.colors_spent_to_cast.clone(), - mana_spent_source_snapshots: obj.mana_spent_source_snapshots.clone(), }) }) .flatten(); @@ -1641,10 +1633,6 @@ pub(crate) fn deliver_replaced_zone_change( obj.additional_cost_payment_count = link.additional_cost_payment_count; obj.additional_cost_payments = link.additional_cost_payments; obj.convoked_creatures = link.convoked_creatures; - obj.mana_spent_to_cast = link.mana_spent_to_cast; - obj.mana_spent_to_cast_amount = link.mana_spent_to_cast_amount; - obj.colors_spent_to_cast = link.colors_spent_to_cast; - obj.mana_spent_source_snapshots = link.mana_spent_source_snapshots; } } if to == Zone::Battlefield || from == Zone::Battlefield { diff --git a/crates/engine/tests/integration/issue_1156_coin_of_mastery.rs b/crates/engine/tests/integration/issue_1156_coin_of_mastery.rs new file mode 100644 index 0000000000..6ab58af37d --- /dev/null +++ b/crates/engine/tests/integration/issue_1156_coin_of_mastery.rs @@ -0,0 +1,203 @@ +//! Regression: GitHub issue #1156 — Coin of Mastery ETB counters from artifact mana. +//! +//! Oracle: "Each creature you control enters with an additional +1/+1 counter on +//! it for each mana from an artifact source spent to cast it." +//! +//! Drives the real cast → stack → resolve → ETB replacement pipeline. The +//! discriminating signal is `CastManaSpentMetric::FromSource { Artifact }` +//! resolved against the entering spell's payment-time source snapshots. +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 122.6 / 122.6a: counters placed as a permanent enters. +//! - CR 614.1c: replacement effect modifying how a permanent enters. +//! - CR 601.2h: mana spent to cast is tracked during payment. + +use engine::game::scenario::{CastOutcome, GameScenario, P0}; +use engine::game::zones::create_object; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +/// Coin of Mastery's printed Oracle text — byte-identical to `data/card-data.json`. +const COIN_OF_MASTERY: &str = "Each creature you control enters with an additional \ ++1/+1 counter on it for each mana from an artifact source spent to cast it.\n\ +{T}: Create a Treasure token. (It's an artifact with \"{T}, Sacrifice this token: \ +Add one mana of any color.\")"; + +fn make_treasure( + state: &mut engine::types::game_state::GameState, + card_id: u64, + owner: PlayerId, +) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + owner, + "Treasure".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Treasure".to_string()); + obj.base_card_types = obj.card_types.clone(); + id +} + +fn add_pool_mana( + runner: &mut engine::game::scenario::GameRunner, + player: PlayerId, + units: &[(ManaType, ObjectId)], +) { + let pool = &mut runner + .state_mut() + .players + .iter_mut() + .find(|p| p.id == player) + .unwrap() + .mana_pool; + for (mana, source) in units { + pool.add(ManaUnit::new(*mana, *source, false, vec![])); + } +} + +fn cast_creature_with_tagged_pool( + setup_treasures: impl FnOnce(&mut engine::types::game_state::GameState) -> Vec, +) -> CastOutcome { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature_from_oracle(P0, "Coin of Mastery", 0, 0, COIN_OF_MASTERY) + .as_artifact(); + let creature = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::generic(2)) + .id(); + + let mut runner = scenario.build(); + let treasure_sources = setup_treasures(runner.state_mut()); + let pool_units: Vec<_> = treasure_sources + .iter() + .map(|&source| (ManaType::Colorless, source)) + .collect(); + add_pool_mana(&mut runner, P0, &pool_units); + + runner.cast(creature).resolve() +} + +/// Payment-time regression: artifact-source snapshots must be on the spell at +/// stack commit so ETB replacements can read them before battlefield entry. +#[test] +fn coin_of_mastery_records_artifact_snapshots_on_stack_commit() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let coin = scenario + .add_creature_from_oracle(P0, "Coin of Mastery", 0, 0, COIN_OF_MASTERY) + .as_artifact() + .id(); + let creature = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::generic(2)) + .id(); + + let mut runner = scenario.build(); + let treasure = make_treasure(runner.state_mut(), 9001, P0); + add_pool_mana( + &mut runner, + P0, + &[ + (ManaType::Colorless, treasure), + (ManaType::Colorless, treasure), + ], + ); + + assert!( + !runner.state().objects[&coin] + .replacement_definitions + .is_empty(), + "Coin of Mastery must register its ETB replacement" + ); + + let commit = runner.cast(creature).commit(); + let spell = commit.state().objects.get(&creature).unwrap(); + assert_eq!( + spell.zone, + Zone::Stack, + "spell must be on the stack after commit" + ); + assert_eq!( + spell.mana_spent_source_snapshots.len(), + 2, + "each pool mana unit must snapshot its artifact source for FromSource queries" + ); +} + +/// Two Treasure mana units from one source → two additional +1/+1 counters on ETB. +#[test] +fn coin_of_mastery_etb_counters_from_one_artifact_mana() { + let outcome = cast_creature_with_tagged_pool(|state| { + let treasure = make_treasure(state, 9001, P0); + vec![treasure, treasure] + }); + let creature = outcome + .state() + .objects + .values() + .find(|o| o.name == "Grizzly Bears" && o.zone == Zone::Battlefield) + .expect("creature must enter the battlefield") + .id; + + outcome.assert_counters(creature, CounterType::Plus1Plus1, 2); +} + +/// Two distinct Treasure sources → two additional +1/+1 counters. +#[test] +fn coin_of_mastery_etb_counters_from_two_artifact_sources() { + let outcome = cast_creature_with_tagged_pool(|state| { + vec![ + make_treasure(state, 9001, P0), + make_treasure(state, 9002, P0), + ] + }); + let creature = outcome + .state() + .objects + .values() + .find(|o| o.name == "Grizzly Bears" && o.zone == Zone::Battlefield) + .expect("creature must enter the battlefield") + .id; + + outcome.assert_counters(creature, CounterType::Plus1Plus1, 2); +} + +/// Non-artifact mana must not contribute to Coin of Mastery's counter count. +#[test] +fn coin_of_mastery_ignores_non_artifact_mana_sources() { + let outcome = cast_creature_with_tagged_pool(|state| { + let treasure = make_treasure(state, 9001, P0); + let forest = create_object( + state, + CardId(9002), + P0, + "Forest".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&forest).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.base_card_types = obj.card_types.clone(); + vec![treasure, forest] + }); + let creature = outcome + .state() + .objects + .values() + .find(|o| o.name == "Grizzly Bears" && o.zone == Zone::Battlefield) + .expect("creature must enter the battlefield") + .id; + + outcome.assert_counters(creature, CounterType::Plus1Plus1, 1); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 06bbf8ae4c..741b5c5d1c 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -116,6 +116,7 @@ mod issue_1021_recurring_nightmare; mod issue_1022_savai_triome_cycling; mod issue_1023_oversold_cemetery; mod issue_1025_rishkars_expertise; +mod issue_1156_coin_of_mastery; mod issue_1202_prepared_spell_cast_timing; mod issue_1206_shelob_spider_death_tokens; mod issue_1297_venture_initiative_triggers; From 9ff322b68cf886ee84cb9b43db2b81c3886e6406 Mon Sep 17 00:00:00 2001 From: Matt Evans Date: Thu, 18 Jun 2026 13:24:57 -0700 Subject: [PATCH 3/3] style(engine): keep Coin regression imports at module scope --- crates/engine/src/game/replacement.rs | 29 ++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index bc4eb40371..b2800c7727 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -5791,12 +5791,13 @@ mod tests { use crate::game::effects::token::apply_create_token_after_replacement; use crate::game::game_object::{AttachTarget, GameObject}; use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, ChosenAttribute, Effect, FilterProp, - OriginConstraint, QuantityExpr, QuantityModification, QuantityRef, ReplacementDefinition, - ReplacementMode, ReplacementPlayerScope, TargetFilter, TargetRef, TypeFilter, TypedFilter, + AbilityCost, AbilityDefinition, AbilityKind, CastManaObjectScope, CastManaSpentMetric, + ChosenAttribute, ControllerRef, Effect, FilterProp, OriginConstraint, QuantityExpr, + QuantityModification, QuantityRef, ReplacementDefinition, ReplacementMode, + ReplacementPlayerScope, TargetFilter, TargetRef, TypeFilter, TypedFilter, }; use crate::types::card_type::CoreType; - use crate::types::game_state::DamageRecord; + use crate::types::game_state::{DamageRecord, ManaSpentSourceSnapshot}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; use crate::types::player::PlayerId; @@ -10681,12 +10682,6 @@ mod tests { /// the spell object, not the static replacement source. #[test] fn artifact_mana_spent_on_self_resolves_against_entering_object() { - use crate::types::ability::{ - AbilityKind, CastManaObjectScope, CastManaSpentMetric, Effect, QuantityExpr, - QuantityRef, TargetFilter, TypeFilter, TypedFilter, - }; - use crate::types::card_type::CoreType; - let coin_id = ObjectId(10); let creature_id = ObjectId(20); let treasure_id = ObjectId(30); @@ -10709,10 +10704,8 @@ mod tests { }, ); - let creature_filter = TargetFilter::Typed( - crate::types::ability::TypedFilter::creature() - .controller(crate::types::ability::ControllerRef::You), - ); + let creature_filter = + TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)); let repl = ReplacementDefinition::new(ReplacementEvent::ChangeZone) .execute(etb_counter_ability) @@ -10721,7 +10714,7 @@ mod tests { let mut state = test_state_with_object(coin_id, Zone::Battlefield, vec![repl]); - let mut treasure = crate::game::game_object::GameObject::new( + let mut treasure = GameObject::new( treasure_id, CardId(98), PlayerId(0), @@ -10732,7 +10725,7 @@ mod tests { treasure.card_types.subtypes.push("Treasure".to_string()); state.objects.insert(treasure_id, treasure); - let mut spell = crate::game::game_object::GameObject::new( + let mut spell = GameObject::new( creature_id, CardId(99), PlayerId(0), @@ -10741,11 +10734,11 @@ mod tests { ); spell.card_types.core_types.push(CoreType::Creature); spell.mana_spent_source_snapshots = vec![ - crate::types::game_state::ManaSpentSourceSnapshot { + ManaSpentSourceSnapshot { source_id: treasure_id, lki: state.objects[&treasure_id].snapshot_for_mana_spent(), }, - crate::types::game_state::ManaSpentSourceSnapshot { + ManaSpentSourceSnapshot { source_id: treasure_id, lki: state.objects[&treasure_id].snapshot_for_mana_spent(), },