diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 632ed0594..6cd4b3c0e 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -5817,12 +5817,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; @@ -10722,6 +10723,94 @@ 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() { + 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(TypedFilter::creature().controller(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 = 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 = 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![ + ManaSpentSourceSnapshot { + source_id: treasure_id, + lki: state.objects[&treasure_id].snapshot_for_mana_spent(), + }, + 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/tests/integration/issue_1156_coin_of_mastery.rs b/crates/engine/tests/integration/issue_1156_coin_of_mastery.rs new file mode 100644 index 000000000..6ab58af37 --- /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 06bbf8ae4..741b5c5d1 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;