diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index e8731a8dce..ff9a939063 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -30955,6 +30955,367 @@ 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:?}" + ); +} + +/// Build a bare-subtype Forest (Land + "Forest" subtype, no explicit +/// `abilities` entry) under `controller`, so `land_mana_options`'s +/// bare-subtype fallback — not `scan_mana_abilities` — is the branch under +/// test. Shared by the three sibling-gate regressions below. +fn add_bare_subtype_forest(state: &mut GameState, controller: PlayerId, card_id: u64) -> ObjectId { + let forest = create_object( + state, + CardId(card_id), + controller, + "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); + forest +} + +#[test] +fn bare_subtype_land_detained_excluded_from_legal_mana_actions() { + // CR 701.35a + CR 305.6: a detained permanent's activated abilities can't + // be activated — including a bare-subtype land's intrinsic mana ability, + // which `intrinsic_land_mana_ability_blocked` must route through the same + // `mana_ability_ready_without_simulation_gated` readiness authority a + // printed mana ability uses, not just the two activation-prohibition + // statics (issue #6469 follow-up). + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF0128); + state + .objects + .get_mut(&forest) + .unwrap() + .detained_by + .insert(PlayerId(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)), + "a detained bare-subtype land must not offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + +#[test] +fn bare_subtype_land_phased_out_excluded_from_legal_mana_actions() { + // CR 702.26b + CR 305.6: a phased-out permanent is treated as though it + // doesn't exist and can't activate abilities — including a bare-subtype + // land's intrinsic mana ability. + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF0129); + state.objects.get_mut(&forest).unwrap().phase_status = + crate::game::game_object::PhaseStatus::PhasedOut { + cause: crate::game::game_object::PhaseOutCause::Directly, + }; + + 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)), + "a phased-out bare-subtype land must not offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + +#[test] +fn bare_subtype_land_cant_tap_excluded_from_legal_mana_actions() { + // CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b + CR 305.6: a permanent that + // can't become tapped can't pay a {T} activation cost — including a bare-subtype land's + // intrinsic {T}: Add mana ability. + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF012A); + state + .objects + .get_mut(&forest) + .unwrap() + .static_definitions + .push(StaticDefinition::new(StaticMode::CantTap).affected(TargetFilter::SelfRef)); + + 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)), + "a can't-tap bare-subtype land must not 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). diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 941815d24a..534f140215 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1323,6 +1323,73 @@ impl ManaActivationGates { } } +/// CR 305.6: builds the minimal synthetic `AbilityDefinition` (Tap cost, +/// `Effect::Mana`) standing in for a land's INTRINSIC "{T}: Add [mana +/// symbol]" ability — the ability every land with a basic land type has +/// whether or not any `AbilityDefinition` object represents it. Used so a +/// legality check's `kind`/`exemption`/cost axes (e.g. Damping Matrix's +/// "unless they're mana abilities" carve-out) evaluate identically to how +/// they would against a real, printed mana ability. +fn intrinsic_land_mana_ability_definition(color: ManaColor) -> AbilityDefinition { + 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) +} + +/// CR 305.6 + CR 602.5: Is a land's basic-land-type INTRINSIC mana ability +/// currently blocked — by ANY of the gates a printed mana ability would be +/// checked against? `mana_sources::land_mana_options`'s bare-subtype +/// fallback synthesizes a `ManaSourceOption` for a land with no +/// `AbilityDefinition` object at all (Urborg/Blood-Moon-class grants), but +/// CR 305.6's intrinsic ability is still an activated mana ability, so every +/// gate `mana_ability_ready_without_simulation_gated` applies to a real one — +/// phased-out (CR 702.26b), detained (CR 701.35a), zone (CR 113.6), tapped/ +/// can't-tap (CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b), summoning sickness +/// (CR 302.6), CantBeActivated/CantActivateDuring (CR 602.5), static +/// activation restrictions (CR 604/605.3b) — must apply to it too. Routes the +/// synthetic definition through that SAME single-authority readiness check +/// rather than re-implementing any subset of it: the function takes an +/// `AbilityDefinition` by reference and never indexes `obj.abilities`, so a +/// synthesized definition with no real storage slot is exactly as valid an +/// input as a printed one. `ability_index: 0` is inert here — the intrinsic +/// ability carries empty `activation_restrictions` (so `ability_index` is +/// never read by that check) and a bare `Tap` cost (whose payability check +/// doesn't consult it either). +pub(crate) fn intrinsic_land_mana_ability_blocked( + state: &GameState, + controller: PlayerId, + object_id: ObjectId, + color: ManaColor, + gates: Option<&ManaActivationGates>, +) -> bool { + let ability_def = intrinsic_land_mana_ability_definition(color); + let ready = match gates { + Some(gates) => mana_ability_ready_without_simulation_gated( + state, + controller, + object_id, + 0, + &ability_def, + gates, + ), + None => { + mana_ability_ready_without_simulation(state, controller, object_id, 0, &ability_def) + } + }; + !ready +} + fn mana_ability_ready_without_simulation( state: &GameState, player: PlayerId, @@ -1380,8 +1447,8 @@ fn mana_ability_ready_without_simulation_gated( if mana_sources::has_tap_component(&ability_def.cost) && obj.tapped { return false; } - // CR 701.26a + CR 508.1f: a "can't become tapped" source (e.g. a goaded mana - // dork) can't activate a tap-cost mana ability. A {Q} untap-cost ability is + // CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b: a "can't become tapped" + // source can't pay a tap-cost mana ability. A {Q} untap-cost ability is // unaffected — untapping is governed by `StaticMode::CantUntap`. if mana_sources::has_tap_component(&ability_def.cost) && crate::game::restrictions::object_cant_tap(state, source_id) diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index aeabe1a72a..9dd3eeed3a 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2348,26 +2348,62 @@ 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 — every readiness gate a printed one is checked against + // (phased-out, detained, tapped/can't-tap, summoning sickness, + // CantBeActivated/CantActivateDuring, static activation + // restrictions) must apply to it too, not just the two activation- + // prohibition statics. 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, gates, + ) + }); + 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(), + }); + } } }