Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 93 additions & 4 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
203 changes: 203 additions & 0 deletions crates/engine/tests/integration/issue_1156_coin_of_mastery.rs
Original file line number Diff line number Diff line change
@@ -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<ObjectId>,
) -> 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);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading