Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
164 changes: 164 additions & 0 deletions crates/engine/src/game/mana_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9947,6 +9947,170 @@ mod tests {
);
}

// Issue #6507 integration follow-up: `make_gemstone_mine` above omits the
// trailing "If there are no mining counters on this land, sacrifice it."
// sub-ability entirely, so it never exercised the reported bug or its fix
// through the real activation pipeline — only the parser-level AST shape
// is covered by `oracle::tests::gemstone_mine_conditional_sacrifice_binds_to_self_ref`.
// This fixture mirrors the actual end-to-end parsed shape (mana effect +
// conditional `Sacrifice { target: SelfRef }` sub-ability gated on zero
// mining counters) so activation itself proves the fix: mana is produced
// regardless, and the land is sacrificed only on the activation that
// removes its LAST counter.
fn make_gemstone_mine_with_sacrifice_sub_ability(
state: &mut GameState,
player: PlayerId,
initial_mining_counters: u32,
) -> ObjectId {
let land = create_object(
state,
CardId(8003),
player,
"Gemstone Mine".to_string(),
Zone::Battlefield,
);
let obj = state.objects.get_mut(&land).unwrap();
obj.card_types
.core_types
.push(crate::types::card_type::CoreType::Land);
let mining_key = crate::types::counter::parse_counter_type("MINING");
obj.counters.insert(mining_key, initial_mining_counters);

let sacrifice_if_depleted = AbilityDefinition::new(
AbilityKind::Spell,
Effect::Sacrifice {
target: TargetFilter::SelfRef,
count: QuantityExpr::Fixed { value: 1 },
min_count: 0,
},
)
.condition(AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::CountersOn {
scope: ObjectScope::Source,
counter_type: Some(CounterType::Generic("mining".to_string())),
},
},
comparator: Comparator::EQ,
rhs: QuantityExpr::Fixed { value: 0 },
});

let ability = AbilityDefinition::new(
AbilityKind::Activated,
Effect::Mana {
produced: ManaProduction::AnyOneColor {
count: QuantityExpr::Fixed { value: 1 },
color_options: vec![
ManaColor::White,
ManaColor::Blue,
ManaColor::Black,
ManaColor::Red,
ManaColor::Green,
],
contribution: ManaContribution::Base,
},
restrictions: Vec::new(),
grants: Vec::new(),
expiry: None,
target: None,
},
)
.cost(AbilityCost::Composite {
costs: vec![
AbilityCost::Tap,
AbilityCost::RemoveCounter {
count: 1,
counter_type: CounterMatch::OfType(CounterType::Generic("mining".to_string())),
target: None,
selection: crate::types::ability::CounterCostSelection::SingleObject,
},
],
})
.sub_ability(sacrifice_if_depleted);
Arc::make_mut(&mut obj.abilities).push(ability);
land
}

#[test]
fn gemstone_mine_survives_activation_with_counters_remaining() {
let mut state = GameState::new_two_player(42);
let player = PlayerId(0);
let land = make_gemstone_mine_with_sacrifice_sub_ability(&mut state, player, 2);

let def = state
.objects
.get(&land)
.unwrap()
.abilities
.first()
.cloned()
.unwrap();
let mut events = Vec::new();
resolve_mana_ability(
&mut state,
land,
player,
&def,
&mut events,
Some(ProductionOverride::SingleColor(ManaType::Green)),
)
.expect("Gemstone Mine activation must not fail with counters present");

assert_eq!(
state.players[player.0 as usize]
.mana_pool
.count_color(ManaType::Green),
1,
"mana must be produced regardless of the trailing conditional sacrifice"
);
assert!(
state.battlefield.contains(&land),
"Gemstone Mine must remain on the battlefield while a mining counter remains after activation"
);
}

#[test]
fn gemstone_mine_sacrifices_itself_on_last_counter_removed() {
let mut state = GameState::new_two_player(42);
let player = PlayerId(0);
let land = make_gemstone_mine_with_sacrifice_sub_ability(&mut state, player, 1);

let def = state
.objects
.get(&land)
.unwrap()
.abilities
.first()
.cloned()
.unwrap();
let mut events = Vec::new();
resolve_mana_ability(
&mut state,
land,
player,
&def,
&mut events,
Some(ProductionOverride::SingleColor(ManaType::Green)),
)
.expect("Gemstone Mine activation must not fail on its last counter");

assert_eq!(
state.players[player.0 as usize]
.mana_pool
.count_color(ManaType::Green),
1,
"mana must still be produced on the activation that empties the last counter"
);
assert!(
!state.battlefield.contains(&land),
"Gemstone Mine must be sacrificed once its last mining counter is removed (issue #6507)"
);
assert!(
state.players[player.0 as usize].graveyard.contains(&land),
"the sacrificed Gemstone Mine must land in its controller's graveyard"
);
}

#[test]
fn cabal_coffers_pays_generic_taps_and_counts_swamps() {
let mut state = GameState::new_two_player(42);
Expand Down
57 changes: 56 additions & 1 deletion crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30668,10 +30668,46 @@ pub(crate) fn parse_effect_chain_ir(
// which is exactly why a head-only rewrite corrupts the chain.
let is_distributed_chunk = text_is_compound_subject_distribution(&text);
// Kicker clauses referencing "that creature"/"it" inherit the parent's target.
//
// CR 608.2c + CR 122.1 (issue #6507): gated on the immediately preceding
// clause actually exposing SOME target concept (`target_filter().is_some()`),
// not merely `!builder.is_empty()`. A prior clause can establish a legitimate
// antecedent through several different shapes (a chosen typed target — the
// kicker_instead_chain_produces_correct_condition DealDamage head; a
// self-reference by printed name — Managorger Phoenix's `PutCounter{SelfRef}`;
// a cost-paid object — Jhoira of the Ghitu's exiled card), so this deliberately
// does not enumerate those shapes — it only excludes the one shape that has NO
// target concept at all. A mana ability's `Effect::Mana` has an `Option<target>`
// that is `None` for every ordinary mana ability (Gemstone Mine's "Add one mana
// of any color" chooses nothing), so `target_filter()` returns `None` and there
// is no antecedent for a following "it" to inherit. Previously this rewrite
// fired unconditionally whenever the builder was non-empty, clobbering an
// unbound sacrifice target into `ParentTarget`, which resolves to nothing at
// runtime — see the sibling fixup immediately below for the other half of the
// fix (rebinding that unbound default to the ability's own source instead).
//
// `target_filter().is_none()` alone over-fires on two OTHER legitimate
// non-target antecedents, so both must be excluded here too:
// - `Effect::Populate` has no `target` field (`target_filter()` is `None`)
// but DOES publish a created-token referent — the exact "populate anaphor
// chain" class already documented at `parse_targeted_action_ast`'s bare-"it"
// sacrifice binding. `chain_prior_referent_is_created_token` is the single
// authority that already recognizes this (Token/CopyTokenOf are excluded
// for free since both DO have a `target_filter()`).
// - A `GenericEffect` whose referent lives only in a granted static's
// `affected` field (not its own top-level `target`) still has
// `target_filter() == None`, but `if_you_do_object_anchor` — computed just
// above for this exact clause's "if you do" gate — already finds it.
let prior_clause_has_no_target_concept = builder
.clauses()
.last()
.is_some_and(|prev| prev.parsed.effect.target_filter().is_none())
&& !chain_prior_referent_is_created_token(builder.clauses())
&& if_you_do_anchor.is_none();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if condition.is_some()
&& !is_distributed_chunk
&& !condition.as_ref().is_some_and(condition_refs_source_object)
&& !builder.is_empty()
&& !prior_clause_has_no_target_concept
&& has_anaphoric_reference(&text_lower)
&& !matches!(if_you_do_anchor, Some(TargetFilter::SelfRef))
&& !typed_trigger_subject
Expand All @@ -30683,6 +30719,25 @@ pub(crate) fn parse_effect_chain_ir(
{
replace_target_with_parent(&mut clause.effect);
}
// CR 608.2c + CR 122.1 (issue #6507): the sacrifice imperative parser
// (`parse_targeted_action_ast`) defaults a bare "it"/"them" pronoun with no
// trigger subject to `ParentTarget`, on the assumption that SOME earlier
Comment thread
matthewevans marked this conversation as resolved.
// clause in the chain chose a real target for it to inherit — the common case
// (`bare_it_without_trigger_subject_preserves_parent_target`). When the
// immediately preceding clause has no target concept at all (the Kicker-clause
// guard above), that assumption is false: there is nothing for `ParentTarget`
// to resolve to, so a conditional "sacrifice it" tail (Gemstone Mine's "{T},
// Remove a mining counter: Add one mana of any color. If there are no mining
// counters on this land, sacrifice it.") silently never sacrifices anything.
// Rebind it to the ability's own source, the only object "it" can plausibly
// name here.
if condition.is_some() && !is_distributed_chunk && prior_clause_has_no_target_concept {
if let Effect::Sacrifice { target, .. } = &mut clause.effect {
if matches!(target, TargetFilter::ParentTarget) {
*target = TargetFilter::SelfRef;
}
}
}
// CR 608.2c: Pronoun clause following a conditional targeted effect.
if condition.is_none()
&& !is_distributed_chunk
Expand Down
32 changes: 32 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18715,6 +18715,38 @@ fn kicker_instead_chain_produces_correct_condition() {
));
}

/// Issue #6507 review follow-up: `Effect::Populate` has no `target` field
/// (`target_filter()` is `None`, just like a mana ability's untargeted `Add`),
/// but it DOES publish a created-token referent via
/// `chain_prior_referent_is_created_token` — the same "populate anaphor
/// chain" class documented at the sacrifice imperative's bare-"it" binding.
/// A conditional "sacrifice it" tail after Populate must keep inheriting the
/// populated token (`ParentTarget`, rewritten to `LastCreated` by
/// `rewrite_parent_target_to_last_created`) — NOT get rebound to `SelfRef`,
/// which would sacrifice the ability's source instead of the populated copy.
#[test]
fn populate_conditional_sacrifice_keeps_created_token_not_self_ref() {
let ability = parse_effect_chain(
"Populate. If it was kicked, sacrifice it.",
AbilityKind::Spell,
);
assert!(matches!(&*ability.effect, Effect::Populate));
let sub = ability.sub_ability.as_ref().expect("expected sub_ability");
assert!(
sub.condition.is_some(),
"expected the 'if it was kicked' gate to survive as a condition"
);
let Effect::Sacrifice { target, .. } = &*sub.effect else {
panic!("expected Effect::Sacrifice, got {:?}", sub.effect);
};
assert_eq!(
*target,
TargetFilter::LastCreated,
"sacrifice target must bind to the populated token (LastCreated), \
not fall back to SelfRef (the ability's own source)"
);
}

#[test]
fn kicker_leading_instead_produces_correct_condition() {
// CR 608.2c: "if kicked, instead [effect]" — leading "instead" variant.
Expand Down
44 changes: 44 additions & 0 deletions crates/engine/src/parser/oracle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8214,6 +8214,50 @@ fn mox_pearl_mana_ability() {
assert_eq!(r.abilities[0].kind, AbilityKind::Activated);
}

/// Issue #6507: Gemstone Mine's mana ability is targetless ("Add one mana of
/// any color" chooses no target), so the trailing "If there are no mining
/// counters on this land, sacrifice it" conditional has no parent target to
/// inherit. The bare "it" pronoun must bind to the ability's own source
/// (`TargetFilter::SelfRef`), not `ParentTarget` — `ParentTarget` resolves to
/// nothing here and the land was never sacrificed. This is the
/// depletion-land class, not a one-off: any targetless activated ability
/// whose trailing conditional says "sacrifice it" shares the exposure.
#[test]
fn gemstone_mine_conditional_sacrifice_binds_to_self_ref() {
let r = parse(
"This land enters with three mining counters on it.\n\
{T}, Remove a mining counter from this land: Add one mana of any color. \
If there are no mining counters on this land, sacrifice it.",
"Gemstone Mine",
&[],
&["Land"],
&[],
);
assert_eq!(r.abilities.len(), 1);
let ability = &r.abilities[0];
assert!(
matches!(*ability.effect, Effect::Mana { .. }),
"expected Effect::Mana, got {:?}",
ability.effect
);
let sub_ability = ability
.sub_ability
.as_ref()
.expect("expected a conditional sub_ability for the trailing sacrifice sentence");
assert!(
sub_ability.condition.is_some(),
"expected the 'if there are no mining counters' gate to survive as a condition"
);
let Effect::Sacrifice { target, .. } = &*sub_ability.effect else {
panic!("expected Effect::Sacrifice, got {:?}", sub_ability.effect);
};
assert_eq!(
*target,
TargetFilter::SelfRef,
"sacrifice target must bind to the source land, not an unestablished ParentTarget"
);
}

#[test]
fn parses_return_forest_cost_untap_activated_ability() {
let r = parse(
Expand Down
Loading