Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
44 changes: 44 additions & 0 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30668,6 +30668,14 @@ 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: untouched — its `!builder.is_empty()` gate is intentionally broad
// (many antecedent shapes reach this without a `target_filter()`, e.g. a typed
// trigger subject rebind chain), and narrowing it here regressed dozens of
// unrelated cards (Feather, the Redeemed's exile-return rider; Managorger
// Phoenix; dropped/added parent-target bindings across PutCounter/Pump/grant/
// ChangeZone/DealDamage/Destroy/GainControl) that a full parse-diff against
// main surfaced. See the Sacrifice-specific fixup immediately below instead,
// which is scoped to exactly the reported bug class.
if condition.is_some()
&& !is_distributed_chunk
&& !condition.as_ref().is_some_and(condition_refs_source_object)
Expand All @@ -30683,6 +30691,42 @@ 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
// clause in the chain chose a real target for it to inherit — the common case
// (`bare_it_without_trigger_subject_preserves_parent_target`). That assumption
// is false when the immediately preceding clause exposes no target concept at
// all (`target_filter().is_none()`), publishes no created-token referent
// (`chain_prior_referent_is_created_token` — Populate has no `target` field but
// DOES publish one; Token/CopyTokenOf are excluded for free since both DO have
// a `target_filter()`), and there is no "if you do" object anchor
// (`if_you_do_object_anchor`, computed above — a GenericEffect referent
// surfaced only through its granted static's `affected` field, not its own
// `target`). With none of those three antecedents present, 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.
//
// Deliberately scoped to `Effect::Sacrifice` only (not folded into the
// Kicker-clause rewrite's gate above): that gate is shared by every
// `replace_target_with_parent`-eligible effect kind, and narrowing it there
// regressed unrelated cards — see the comment above.
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();
if condition.is_some() && !is_distributed_chunk && prior_clause_has_no_target_concept {
if let Effect::Sacrifice { target, .. } = &mut clause.effect {
Comment thread
matthewevans marked this conversation as resolved.
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