From 1bf194c0ec62b1f8084a746387bfcb15fb1c2820 Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 31 Jul 2026 11:29:02 -0500 Subject: [PATCH 1/5] fix(engine): stop basic-land mana fallback from bypassing CantBeActivated land_mana_options() fell back to unconditional subtype-inferred mana production whenever scan_mana_abilities() returned no options, without checking whether that emptiness meant "no mana ability exists" or "a real mana ability exists but was just filtered out by a legality gate." Karn, the Great Creator correctly filtered a Liquimetal-Coating-turned- artifact land's own {T}: Add ability out of scan_mana_abilities, but the fallback then silently re-added it via bare subtype inference, letting the opponent tap the blocked land for mana anyway (#6469). Gate the fallback on the object genuinely carrying no Effect::Mana ability at all, so Urborg/Blood-Moon-class subtype-only production still works while a real, currently-prohibited ability stays prohibited. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/game/casting_tests.rs | 172 ++++++++++++++++++++++++ crates/engine/src/game/mana_sources.rs | 21 ++- 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index e8731a8dce..47154797ad 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -30955,6 +30955,178 @@ 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:?}" + ); +} + // === 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_sources.rs b/crates/engine/src/game/mana_sources.rs index aeabe1a72a..00c60739d3 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2348,8 +2348,25 @@ fn land_mana_options( gates, ); - // Legacy fallback for basic-land subtype-only objects (no explicit mana ability). - if options.is_empty() { + // 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 From 7e230700912066ff98a470d4c949ca617fcf39f8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Fri, 31 Jul 2026 10:05:39 -0700 Subject: [PATCH 2/5] docs(PR-6841): cite land-type mana fallback rule --- crates/engine/src/game/mana_sources.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index 00c60739d3..b3d3bb1950 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2348,7 +2348,7 @@ fn land_mana_options( gates, ); - // CR 602.5: Legacy fallback for basic-land subtype-only objects that + // 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 From 9c4aab9652aa4da38e11a5372b7fd0ed3d2f69b3 Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 31 Jul 2026 13:13:45 -0500 Subject: [PATCH 3/5] fix(engine): block the bare-subtype-land mana fallback too The first fix only closed the gap for a land carrying 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 the fallback exists for - hits land_mana_options()'s bare-subtype branch directly, bypassing scan_mana_abilities() entirely and any CantBeActivated check with it. Per CR 305.6 that intrinsic "{T}: Add [mana symbol]" ability is still an activated mana ability, so CR 602.5 prohibitions must block it exactly like a printed one. Add mana_abilities::intrinsic_land_mana_ability_blocked, which builds a minimal synthetic AbilityDefinition for the intrinsic ability and delegates to the single-authority is_blocked_by_cant_be_activated / is_blocked_by_cant_activate_during checks - never re-implements them. Wire it into the fallback, gated on require_current_payability to match how is_active_tap_mana_ability treats real abilities (the auto-tap planning pass doesn't consult per-source legality gates for any mana source, real or intrinsic). Adds a regression test with a bare-subtype artifact land under Karn's prohibition (the case the prior fix's Forest test couldn't reach, since Forest carries an explicit ability) and a positive companion test confirming the ordinary bare-subtype fallback still works with no prohibition in play. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/game/casting_tests.rs | 93 ++++++++++++++++++++++++ crates/engine/src/game/mana_abilities.rs | 38 ++++++++++ crates/engine/src/game/mana_sources.rs | 41 ++++++++--- 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 47154797ad..65cb860e8c 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -31127,6 +31127,99 @@ fn karn_blocks_liquimetal_coated_forest_from_legal_mana_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). diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 941815d24a..c00605ec34 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -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) +} + fn mana_ability_ready_without_simulation( state: &GameState, player: PlayerId, diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index b3d3bb1950..c61acbe47d 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2373,18 +2373,35 @@ fn land_mana_options( .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(), + }); + } } } From a9b986c5b7e4a8c1146ef02a7e14a5aa600e35b3 Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 31 Jul 2026 14:13:03 -0500 Subject: [PATCH 4/5] fix(engine): route the intrinsic land mana ability through the shared readiness authority intrinsic_land_mana_ability_blocked reimplemented only two of the checks a printed mana ability goes through (CantBeActivated, CantActivateDuring), missing phased-out (CR 702.26b), detained (CR 701.35a), and can't-tap (CR 701.26a + CR 508.1f) sources. A bare-subtype land in any of those states was still offered as a legal mana source, since the equivalent printed mana ability is excluded by mana_ability_ready_without_simulation_gated but the synthesized intrinsic ability was checked against a narrower, hand-picked subset. That readiness authority 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 - no refactor of the authority needed. Route the synthetic {T}: Add ability through it directly instead of re-implementing any subset of its checks, preserving the require_current_payability split that already keeps the auto-tap planning path (which doesn't consult per-source legality gates for any mana source, real or intrinsic) distinct from the interactive legal-action path this whole family targets. Adds three regression tests (detained, phased-out, can't-tap bare-subtype lands, none of which need a CantBeActivated static to demonstrate the gap) alongside the existing CantBeActivated and positive-fallback coverage. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/game/casting_tests.rs | 96 ++++++++++++++++++++++++ crates/engine/src/game/mana_abilities.rs | 75 ++++++++++++------ crates/engine/src/game/mana_sources.rs | 10 ++- 3 files changed, 154 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 65cb860e8c..be40dfddd3 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -31220,6 +31220,102 @@ fn bare_subtype_land_still_offers_mana_without_a_prohibition() { ); } +/// 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 701.26a + CR 508.1f + 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 c00605ec34..0473790424 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1323,26 +1323,15 @@ 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( +/// 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 { @@ -1355,10 +1344,50 @@ pub(crate) fn intrinsic_land_mana_ability_blocked( target: None, }, ) - .cost(AbilityCost::Tap); + .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) +/// 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 106.12/602.5a, CR 701.26a+508.1f), 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( diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index c61acbe47d..9dd3eeed3a 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2375,9 +2375,11 @@ fn land_mana_options( { // 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` + // 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, @@ -2386,7 +2388,7 @@ fn land_mana_options( 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, + state, controller, object_id, color, gates, ) }); if !blocked { From 670985d289199fddf2ed484cf8391ee59c964df9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Fri, 31 Jul 2026 12:44:26 -0700 Subject: [PATCH 5/5] fix(PR-6841): correct activation-cost CR annotations --- crates/engine/src/game/casting_tests.rs | 4 ++-- crates/engine/src/game/mana_abilities.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index be40dfddd3..ff9a939063 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -31293,8 +31293,8 @@ fn bare_subtype_land_phased_out_excluded_from_legal_mana_actions() { #[test] fn bare_subtype_land_cant_tap_excluded_from_legal_mana_actions() { - // CR 701.26a + CR 508.1f + CR 305.6: a permanent that can't become tapped - // can't pay a {T} activation cost — including a bare-subtype land's + // 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); diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 0473790424..534f140215 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1355,7 +1355,7 @@ fn intrinsic_land_mana_ability_definition(color: ManaColor) -> AbilityDefinition /// 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 106.12/602.5a, CR 701.26a+508.1f), summoning sickness +/// 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 @@ -1447,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)