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
265 changes: 265 additions & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30955,6 +30955,271 @@ fn cant_be_activated_aura_blocks_enchanted_creature_not_others() {
);
}

#[test]
fn karn_blocks_liquimetal_coated_opponent_land() {
// Issue #6469: Karn's `TargetFilter::Typed(Artifact)` filter must apply to
// a permanent that becomes an artifact via a continuous type-changing
// effect (Liquimetal Coating's "becomes an artifact in addition to its
// other types until end of turn"), not just to permanents that are
// printed artifacts. Exercises the real GenericEffect -> transient
// continuous effect -> layer-4 AddType pipeline rather than hand-setting
// `card_types`, so a regression in that pipeline would be caught here too.
let mut state = setup_game_at_main_phase();

add_cant_be_activated_source(
&mut state,
PlayerId(0),
ProhibitionScope::AllPlayers,
TargetFilter::Typed(
TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent),
),
);

let coating = create_object(
&mut state,
CardId(0x1157),
PlayerId(0),
"Liquimetal Coating".to_string(),
Zone::Battlefield,
);
state
.objects
.get_mut(&coating)
.unwrap()
.card_types
.core_types
.push(CoreType::Artifact);

let land = create_object(
&mut state,
CardId(0x1a2d),
PlayerId(1),
"Utility Land".to_string(),
Zone::Battlefield,
);
{
let obj = state.objects.get_mut(&land).unwrap();
obj.card_types.core_types.push(CoreType::Land);
obj.base_card_types = obj.card_types.clone();
obj.entered_battlefield_turn = Some(0);
Arc::make_mut(&mut obj.abilities).push(
crate::types::ability::AbilityDefinition::new(
crate::types::ability::AbilityKind::Activated,
crate::types::ability::Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
},
)
.cost(AbilityCost::Tap),
);
}
let land_ability = state.objects[&land].abilities[0].clone();

assert!(
!is_blocked_by_cant_be_activated(&state, PlayerId(1), land, &land_ability),
"reach-guard: an uncoated land must not be blocked by Karn"
);

let ability = ResolvedAbility::new(
Effect::GenericEffect {
static_abilities: vec![StaticDefinition::new(StaticMode::Continuous)
.affected(TargetFilter::ParentTarget)
.modifications(vec![ContinuousModification::AddType {
core_type: CoreType::Artifact,
}])],
duration: Some(crate::types::ability::Duration::UntilEndOfTurn),
target: Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Permanent))),
end_cost: None,
},
vec![TargetRef::Object(land)],
coating,
PlayerId(0),
);

let mut events = Vec::new();
crate::game::effects::effect::resolve(&mut state, &ability, &mut events).unwrap();
crate::game::layers::evaluate_layers(&mut state);

assert!(
state.objects[&land]
.card_types
.core_types
.contains(&CoreType::Artifact),
"Liquimetal Coating must make the land an artifact; core_types = {:?}",
state.objects[&land].card_types.core_types
);
assert!(
is_blocked_by_cant_be_activated(&state, PlayerId(1), land, &land_ability),
"Karn must block a Coating-turned-artifact land controlled by an opponent"
);
}

#[test]
fn karn_blocks_liquimetal_coated_forest_from_legal_mana_actions() {
// Issue #6469: the ROOT CAUSE — `land_mana_options`'s basic-land-subtype
// fallback (`mana_sources.rs`) fired whenever `scan_mana_abilities` came
// back empty, without checking WHY it was empty. Karn correctly filters
// the coated Forest's real {T}: Add {G} ability out of `scan_mana_abilities`
// (confirmed by the reach-guard below), but the fallback then mistook that
// legitimate filtering for "no mana ability exists" and re-added an
// unconditional `ability_index: None` option for it, letting the opponent
// tap the Karn-blocked land for mana anyway. Exercises
// `activatable_mana_actions_for_player`, the real legal-action surface
// behind both the manual "tap for mana" UI and AI candidate generation.
let mut state = setup_game_at_main_phase();

add_cant_be_activated_source(
&mut state,
PlayerId(0),
ProhibitionScope::AllPlayers,
TargetFilter::Typed(
TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent),
),
);

// A real Forest: Land + "Forest" subtype + an explicit {T}: Add {G} ability
// (mirrors card-data.json's actual parsed Forest, not a bare subtype).
let forest = create_object(
&mut state,
CardId(0xF0125),
PlayerId(1),
"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.card_types.core_types.push(CoreType::Artifact); // Liquimetal Coating
obj.base_card_types = obj.card_types.clone();
obj.entered_battlefield_turn = Some(0);
Arc::make_mut(&mut obj.abilities).push(
crate::types::ability::AbilityDefinition::new(
crate::types::ability::AbilityKind::Activated,
crate::types::ability::Effect::Mana {
produced: crate::types::ability::ManaProduction::Fixed {
colors: vec![ManaColor::Green],
contribution: ManaContribution::Base,
},
restrictions: vec![],
grants: vec![],
expiry: None,
target: None,
},
)
.cost(AbilityCost::Tap),
);
}
let forest_ability = state.objects[&forest].abilities[0].clone();

assert!(
is_blocked_by_cant_be_activated(&state, PlayerId(1), forest, &forest_ability),
"reach-guard: Karn must block the coated Forest's own {{T}}: Add {{G}} ability"
);

let legal_actions =
crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1));
assert!(
!legal_actions
.iter()
.any(|action| action.source_object() == Some(forest)),
"Karn must remove the coated Forest from P1's legal mana actions, got {legal_actions:?}"
);
}

#[test]
fn karn_blocks_bare_subtype_artifact_land_from_legal_mana_actions() {
// Issue #6469 follow-up: the fix above only closed the gap for a land
// that carries an explicit `Effect::Mana` ability. A land with NO
// explicit ability at all — just a basic land subtype, the genuine
// Urborg/Blood-Moon-class case `land_mana_options`'s fallback exists for
// (CR 305.6: the "{T}: Add [mana symbol]" ability is intrinsic even with
// no text box) — hits `land_mana_options`'s bare-subtype fallback
// directly, bypassing `scan_mana_abilities` entirely. That intrinsic
// ability is still an activated mana ability (CR 305.6 + CR 605), so
// CR 602.5 activation prohibitions must block it exactly like a printed
// one. Companion to `karn_blocks_liquimetal_coated_forest_from_legal_mana_actions`,
// which covers the explicit-ability half of the same fallback.
let mut state = setup_game_at_main_phase();

add_cant_be_activated_source(
&mut state,
PlayerId(0),
ProhibitionScope::AllPlayers,
TargetFilter::Typed(
TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent),
),
);

// A bare-subtype Forest: Land + "Forest" subtype, no `abilities` entry at
// all — the intrinsic CR 305.6 ability, made an artifact by Liquimetal
// Coating and controlled by Karn's opponent.
let forest = create_object(
&mut state,
CardId(0xF0126),
PlayerId(1),
"Bare 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.card_types.core_types.push(CoreType::Artifact); // Liquimetal Coating
obj.base_card_types = obj.card_types.clone();
obj.entered_battlefield_turn = Some(0);
}
assert!(
state.objects[&forest].abilities.is_empty(),
"reach-guard: this land must have no explicit AbilityDefinition, so the \
bare-subtype fallback (not scan_mana_abilities) is the branch under test"
);

let legal_actions =
crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1));
assert!(
!legal_actions
.iter()
.any(|action| action.source_object() == Some(forest)),
"Karn must block the coated bare-subtype Forest's intrinsic mana ability too, \
got {legal_actions:?}"
);
}

#[test]
fn bare_subtype_land_still_offers_mana_without_a_prohibition() {
// Positive companion to the regression above: with no CantBeActivated
// static in play, the bare-subtype fallback this whole family guards
// must still work — an ordinary basic land (no explicit ability) is a
// legal mana source via its CR 305.6 intrinsic ability.
let mut state = setup_game_at_main_phase();

let forest = create_object(
&mut state,
CardId(0xF0127),
PlayerId(1),
"Bare 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();
obj.entered_battlefield_turn = Some(0);
}

let legal_actions =
crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1));
assert!(
legal_actions
.iter()
.any(|action| action.source_object() == Some(forest)),
"an unprohibited bare-subtype land must still offer its intrinsic mana ability, \
got {legal_actions:?}"
);
}

// === CR 605.1a: Pithing Needle mana-ability exemption gate ===

/// Build a Llanowar-Elves-style mana ability: `{T}: Add {G}` (no targets, produces mana).
Expand Down
38 changes: 38 additions & 0 deletions crates/engine/src/game/mana_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,44 @@ impl ManaActivationGates {
}
}

/// CR 305.6 + CR 602.5: A land with a basic land type has the INTRINSIC
/// ability "{T}: Add [mana symbol]" whether or not any `AbilityDefinition`
/// object represents it — `mana_sources::land_mana_options`'s bare-subtype
/// fallback synthesizes a `ManaSourceOption` for exactly this case. That
/// intrinsic ability is still an activated (mana) ability, so CR 602.5
/// activation prohibitions (CantBeActivated, CantActivateDuring — Karn/
/// Clarion/Damping Matrix/City of Solitude class) must apply to it exactly as
/// they would to a printed one. Builds a minimal synthetic `AbilityDefinition`
/// (Tap cost, `Effect::Mana`) purely so the prohibition's `kind`/`exemption`
/// axes (e.g. Damping Matrix's "unless they're mana abilities" carve-out)
/// evaluate identically to how they would against a real mana ability, then
/// delegates to the single-authority `is_blocked_by_cant_be_activated` /
/// `is_blocked_by_cant_activate_during` checks — never re-implements them.
pub(crate) fn intrinsic_land_mana_ability_blocked(
state: &GameState,
controller: PlayerId,
object_id: ObjectId,
color: ManaColor,
) -> bool {
let ability_def = AbilityDefinition::new(
crate::types::ability::AbilityKind::Activated,
Effect::Mana {
produced: ManaProduction::Fixed {
colors: vec![color],
contribution: crate::types::ability::ManaContribution::Base,
},
restrictions: vec![],
grants: vec![],
expiry: None,
target: None,
},
)
.cost(AbilityCost::Tap);

super::casting::is_blocked_by_cant_be_activated(state, controller, object_id, &ability_def)
|| super::casting::is_blocked_by_cant_activate_during(state, controller, &ability_def)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

fn mana_ability_ready_without_simulation(
state: &GameState,
player: PlayerId,
Expand Down
62 changes: 48 additions & 14 deletions crates/engine/src/game/mana_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2348,26 +2348,60 @@ fn land_mana_options(
gates,
);

// Legacy fallback for basic-land subtype-only objects (no explicit mana ability).
if options.is_empty() {
// CR 305.6 + CR 602.5: Legacy fallback for basic-land subtype-only objects that
// carry NO EXPLICIT mana ability at all (a nonbasic granted a basic land
// type by Urborg/Blood Moon-class effects with no accompanying
// `Effect::Mana` grant). This must NOT fire merely because
// `scan_mana_abilities` came back empty — it can be empty because a REAL
// `Effect::Mana` ability exists but was just filtered out by a legality
// gate (CantBeActivated, CantActivateDuring, an unsatisfied activation
// condition). Falling back to unconditional subtype-inferred production
// in that case would silently defeat the gate that filtered it (issue
// #6469: Karn, the Great Creator's "activated abilities of artifacts your
// opponents control can't be activated" stopped blocking a Liquimetal-
// Coating-turned-artifact land's own {T}: Add mana ability, because the
// ability's legitimate absence from `options` was mistaken for "no
// ability exists").
let has_explicit_mana_ability = obj
.abilities
.iter()
.any(|ability| matches!(*ability.effect, Effect::Mana { .. }));
if options.is_empty() && !has_explicit_mana_ability {
if let Some(mana_type) = obj
.card_types
.subtypes
.iter()
.find_map(|s| mana_payment::land_subtype_to_mana_type(s))
{
options.push(ManaSourceOption {
object_id,
ability_index: None,
mana_type,
source_could_produce_two_or_more_colors: source_could_produce_two_or_more_colors(
state, object_id, controller,
),
penalty: ManaSourcePenalty::None,
atomic_combination: None,
restrictions: Vec::new(),
taps_for_mana_overrides: Vec::new(),
});
// CR 305.6 + CR 602.5: the intrinsic "{T}: Add [mana symbol]"
// ability this fallback synthesizes is still an activated (mana)
// ability — a CantBeActivated/CantActivateDuring static (Karn,
// Clarion, Damping Matrix, City of Solitude) must block it exactly
// as it would a printed one. Mirrors the `require_current_payability`
// gating `is_active_tap_mana_ability` applies to a real ability: the
// auto-tap PLANNING pass (`require_current_payability == false`)
// does not consult per-source legality gates for ANY mana source,
// real or intrinsic, so this only fires on the interactive/
// legal-action path.
let blocked = require_current_payability
&& mana_type_to_color(mana_type).is_some_and(|color| {
mana_abilities::intrinsic_land_mana_ability_blocked(
state, controller, object_id, color,
)
});
if !blocked {
options.push(ManaSourceOption {
object_id,
ability_index: None,
mana_type,
source_could_produce_two_or_more_colors:
source_could_produce_two_or_more_colors(state, object_id, controller),
penalty: ManaSourcePenalty::None,
atomic_combination: None,
restrictions: Vec::new(),
taps_for_mana_overrides: Vec::new(),
});
}
}
}

Expand Down
Loading