From a50d51ba496eb366c6a5a53c6908d50eadf329d0 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 13:58:10 -0500 Subject: [PATCH 1/3] Fix zero-count mana color prompts --- crates/engine/src/game/effects/mana.rs | 2 +- crates/engine/src/game/mana_abilities.rs | 40 ++++++++++++++++--- .../coalition_relic_integration.rs | 8 ++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index ad9f8309c0..ed7bc934ea 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -198,7 +198,7 @@ pub fn resolve( &ability.effect, state, ability.source_id, - Some(ability), + Some(count_ability), ) }) .flatten(); diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index f7f593aee6..d926a781e7 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -943,7 +943,13 @@ pub(crate) fn mana_choice_prompt( }; match produced { ManaProduction::AnyOneColor { color_options, .. } if color_options.len() > 1 => { - Some(ManaChoicePrompt::SingleColor { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + produces_mana.then(|| ManaChoicePrompt::SingleColor { options: color_options.iter().map(mana_color_to_type).collect(), }) } @@ -978,7 +984,13 @@ pub(crate) fn mana_choice_prompt( .into_iter() .map(|color| mana_color_to_type(&color)) .collect::>(); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None @@ -1038,7 +1050,13 @@ pub(crate) fn mana_choice_prompt( owner, source_id, ); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None @@ -1051,7 +1069,13 @@ pub(crate) fn mana_choice_prompt( ManaProduction::AnyInCommandersColorIdentity { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let identity = super::commander::commander_color_identity(state, owner); - if identity.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if identity.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options: identity.iter().map(mana_color_to_type).collect(), }) @@ -1067,7 +1091,13 @@ pub(crate) fn mana_choice_prompt( ManaProduction::OpponentLandColors { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let options = super::mana_sources::opponent_land_color_options(state, owner); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None diff --git a/crates/engine/tests/integration/coalition_relic_integration.rs b/crates/engine/tests/integration/coalition_relic_integration.rs index f1bc91b9ec..c1949e8cd1 100644 --- a/crates/engine/tests/integration/coalition_relic_integration.rs +++ b/crates/engine/tests/integration/coalition_relic_integration.rs @@ -235,4 +235,12 @@ fn coalition_relic_with_zero_charge_counters_produces_no_mana() { 0, "zero counters → zero mana" ); + assert!( + matches!( + state.waiting_for, + engine::types::game_state::WaitingFor::Priority { .. } + ), + "zero counters must not prompt for a mana color; got {:?}", + state.waiting_for + ); } From d099d43c14ab192b9fa889bbac03e08d1aac34ed Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 22:48:49 -0500 Subject: [PATCH 2/3] Fix mana prompt target context --- crates/engine/src/game/effects/mana.rs | 1 + crates/engine/src/game/mana_abilities.rs | 47 ++++-- .../coalition_relic_integration.rs | 93 +++++++++++- .../mana_target_recipient_and_count_source.rs | 134 +++++++++++++++++- 4 files changed, 255 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index ed7bc934ea..41ed1dffd7 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -198,6 +198,7 @@ pub fn resolve( &ability.effect, state, ability.source_id, + Some(ability), Some(count_ability), ) }) diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index d926a781e7..1781a90bcd 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -932,18 +932,24 @@ fn complete_mana_ability_activation( /// `Some(ManaChoicePrompt::AnyCombination)` when each produced mana unit has /// an independent color choice. Returns `None` when production is fully /// determined (Fixed, Colorless, single-option AnyOneColor). +/// `color_ability` retains the original target context for dynamic color +/// discovery; `count_ability` may be scoped to the count-source target for +/// quantity resolution. pub(crate) fn mana_choice_prompt( effect: &Effect, state: &GameState, source_id: ObjectId, - ability: Option<&ResolvedAbility>, + color_ability: Option<&ResolvedAbility>, + count_ability: Option<&ResolvedAbility>, ) -> Option { let Effect::Mana { produced, .. } = effect else { return None; }; match produced { ManaProduction::AnyOneColor { color_options, .. } if color_options.len() > 1 => { - let produces_mana = ability + // CR 106.5: An ability that would produce mana of an undefined type + // produces no mana, so it needs no color choice. + let produces_mana = count_ability .map(|ability| { !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .is_empty() @@ -954,7 +960,7 @@ pub(crate) fn mana_choice_prompt( }) } ManaProduction::AnyCombination { color_options, .. } if color_options.len() > 1 => { - let ability = ability?; + let ability = count_ability?; let count = super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .len(); @@ -979,12 +985,17 @@ pub(crate) fn mana_choice_prompt( // CR 106.1: Player chooses one of the colors among matching permanents they // control. let options = super::effects::mana::distinct_colors_among_permanents( - state, ability, source_id, filter, + state, + color_ability, + source_id, + filter, ) .into_iter() .map(|color| mana_color_to_type(&color)) .collect::>(); - let produces_mana = ability + // CR 106.5: An ability that would produce mana of an undefined type + // produces no mana, so it needs no color choice. + let produces_mana = count_ability .map(|ability| { !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .is_empty() @@ -1050,7 +1061,9 @@ pub(crate) fn mana_choice_prompt( owner, source_id, ); - let produces_mana = ability + // CR 106.5: An ability that would produce mana of an undefined type + // produces no mana, so it needs no color choice. + let produces_mana = count_ability .map(|ability| { !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .is_empty() @@ -1069,7 +1082,9 @@ pub(crate) fn mana_choice_prompt( ManaProduction::AnyInCommandersColorIdentity { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let identity = super::commander::commander_color_identity(state, owner); - let produces_mana = ability + // CR 106.5: An ability that would produce mana of an undefined type + // produces no mana, so it needs no color choice. + let produces_mana = count_ability .map(|ability| { !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .is_empty() @@ -1091,7 +1106,9 @@ pub(crate) fn mana_choice_prompt( ManaProduction::OpponentLandColors { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let options = super::mana_sources::opponent_land_color_options(state, owner); - let produces_mana = ability + // CR 106.5: An ability that would produce mana of an undefined type + // produces no mana, so it needs no color choice. + let produces_mana = count_ability .map(|ability| { !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .is_empty() @@ -1109,14 +1126,15 @@ pub(crate) fn mana_choice_prompt( // AnyCombination prompt only when the object has more than one color; 0 or // 1 color needs no prompt (CR 106.5 empty → no mana; single auto-picks). ManaProduction::AnyCombinationOfObjectColors { scope, .. } => { - let options = super::effects::mana::object_colors_for_scope(state, ability, *scope) - .iter() - .map(mana_color_to_type) - .collect::>(); + let options = + super::effects::mana::object_colors_for_scope(state, color_ability, *scope) + .iter() + .map(mana_color_to_type) + .collect::>(); if options.len() <= 1 { return None; } - let ability = ability?; + let ability = count_ability?; let count = super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) .len(); @@ -2704,6 +2722,7 @@ fn finish_mana_ability_cost_payment( state, pending.source_id, Some(&resolved_for_prompt), + Some(&resolved_for_prompt), ) { if matches!(choice, ManaChoicePrompt::SingleColor { .. }) && cost_resolves_without_choice(&ability_def.cost) @@ -10750,7 +10769,7 @@ mod tests { Arc::make_mut(&mut state.objects.get_mut(&nykthos).unwrap().abilities) .push(ability.clone()); - let prompt = mana_choice_prompt(&ability.effect, &state, nykthos, None) + let prompt = mana_choice_prompt(&ability.effect, &state, nykthos, None, None) .expect("chosen-color mana should prompt for a color"); assert!(matches!(prompt, ManaChoicePrompt::SingleColor { .. })); diff --git a/crates/engine/tests/integration/coalition_relic_integration.rs b/crates/engine/tests/integration/coalition_relic_integration.rs index c1949e8cd1..ea673a4e5e 100644 --- a/crates/engine/tests/integration/coalition_relic_integration.rs +++ b/crates/engine/tests/integration/coalition_relic_integration.rs @@ -31,16 +31,20 @@ //! the dynamic-count any-color mana production. use engine::game::effects; +use engine::game::scenario::{GameScenario, P0}; use engine::game::zones::create_object; use engine::types::ability::{ AbilityCondition, AbilityKind, DamageChannel, Effect, ManaContribution, ManaProduction, QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, TargetRef, }; +use engine::types::actions::GameAction; use engine::types::card_type::CoreType; use engine::types::counter::CounterType; use engine::types::game_state::GameState; +use engine::types::game_state::{ManaChoice, ManaChoicePrompt, WaitingFor}; use engine::types::identifiers::{CardId, ObjectId}; use engine::types::mana::ManaColor; +use engine::types::phase::Phase; use engine::types::player::PlayerId; use engine::types::zones::Zone; @@ -222,14 +226,16 @@ fn coalition_relic_with_zero_charge_counters_produces_no_mana() { effects::resolve_ability_chain(&mut state, &ability, &mut events, 0) .expect("chain must resolve cleanly even with zero counters"); - // last_effect_amount remains None — the events-scan only stamps when amount > 0. + // The events-scan only stamps when amount > 0; CR 106.5 then makes the + // undefined zero-production result produce no mana. assert!( state.last_effect_amount.is_none() || state.last_effect_amount == Some(0), "no counters removed → no last_effect_amount stamp; got {:?}", state.last_effect_amount ); - // No mana in the pool, no choice in flight. + // CR 106.5: an ability that would produce mana of an undefined type + // produces no mana and therefore needs no color choice. assert_eq!( state.players[controller.0 as usize].mana_pool.total(), 0, @@ -244,3 +250,86 @@ fn coalition_relic_with_zero_charge_counters_produces_no_mana() { state.waiting_for ); } + +/// CR 603.2 + CR 106.5: Drive the printed Coalition Relic ability through the +/// action pipeline. Three counters must reach the color prompt, while the +/// zero-counter resolver regression above must settle without one. +#[test] +fn coalition_relic_full_action_path_reaches_positive_mana_prompt() { + const ORACLE: &str = "At the beginning of your precombat main phase, you may remove all charge counters from ~. If you do, add one mana of any color for each charge counter removed this way."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::Upkeep); + scenario.with_library_top(P0, &["P0 Card"; 40]); + scenario.with_library_top(engine::types::player::PlayerId(1), &["P1 Card"; 40]); + let relic = scenario + .add_creature(P0, "Coalition Relic", 0, 0) + .as_artifact() + .from_oracle_text(ORACLE) + .id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&relic) + .unwrap() + .counters + .insert(CounterType::Generic("charge".to_string()), 3); + + runner.advance_to_phase(Phase::PreCombatMain); + for _ in 0..4 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + break; + } + runner + .act(GameAction::PassPriority) + .expect("the Coalition Relic trigger must resolve through priority"); + } + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ), + "the printed Coalition Relic trigger must reach its optional prompt, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accepting Coalition Relic must resolve its optional effect"); + + match &runner.state().waiting_for { + WaitingFor::ChooseManaColor { + choice: ManaChoicePrompt::SingleColor { options }, + .. + } => assert_eq!(options.len(), 5), + other => panic!("three removed counters must reach a mana prompt, got {other:?}"), + } + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaColor::Red.into()), + count: 1, + }) + .expect("choosing a mana color must complete Coalition Relic"); + + assert_eq!( + runner.state().players[P0.0 as usize].mana_pool.total(), + 3, + "three removed counters must produce three mana" + ); + assert_eq!( + runner + .state() + .objects + .get(&relic) + .unwrap() + .counters + .get(&CounterType::Generic("charge".to_string())) + .copied() + .unwrap_or(0), + 0, + "the accepted trigger must remove all three charge counters" + ); +} diff --git a/crates/engine/tests/integration/mana_target_recipient_and_count_source.rs b/crates/engine/tests/integration/mana_target_recipient_and_count_source.rs index f5c370f5ba..672837b2db 100644 --- a/crates/engine/tests/integration/mana_target_recipient_and_count_source.rs +++ b/crates/engine/tests/integration/mana_target_recipient_and_count_source.rs @@ -28,13 +28,13 @@ use engine::game::scenario::{GameScenario, P0, P1}; use engine::types::ability::EffectKind; use engine::types::ability::{ - Effect, ManaProduction, ManaTargetRole, QuantityExpr, QuantityRef, TargetFilter, TargetRef, - ZoneRef, + ControllerRef, Effect, ManaContribution, ManaProduction, ManaTargetRole, QuantityExpr, + QuantityRef, TargetFilter, TargetRef, TypedFilter, ZoneRef, }; use engine::types::actions::GameAction; use engine::types::events::GameEvent; -use engine::types::game_state::{CastPaymentMode, WaitingFor}; -use engine::types::mana::{ManaCost, ManaType}; +use engine::types::game_state::{CastPaymentMode, ManaChoice, ManaChoicePrompt, WaitingFor}; +use engine::types::mana::{ManaColor, ManaCost, ManaType}; use engine::types::phase::Phase; use engine::types::player::PlayerId; @@ -195,6 +195,132 @@ fn mana_recipient_and_count_source_resolve_from_their_own_slots() { ); } +/// CR 106.1 + CR 106.4: Color discovery uses the RECIPIENT target while the +/// production count uses the COUNT SOURCE target. The recipient controls red +/// and blue permanents, the count source controls green, and its five-card hand +/// determines the amount. Passing only the count-scoped ability to the prompt +/// would offer only green (and skip the prompt entirely). +#[test] +fn mana_color_prompt_keeps_recipient_context_separate_from_count_context() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_cards_in_hand( + P2, + &hand_names("Count Card", COUNT_SOURCE_HAND) + .iter() + .map(String::as_str) + .collect::>(), + ); + let recipient_red = scenario.add_creature(P1, "Recipient Red", 1, 1).id(); + let recipient_blue = scenario.add_creature(P1, "Recipient Blue", 1, 1).id(); + let count_source_green = scenario.add_creature(P2, "Count Source Green", 1, 1).id(); + + let spell = scenario + .add_spell_to_hand(P0, "Role Split Color Ritual", false) + .with_mana_cost(ManaCost::zero()) + .with_ability(Effect::Mana { + produced: ManaProduction::AnyOneColorAmongPermanents { + count: QuantityExpr::Ref { + qty: QuantityRef::TargetZoneCardCount { + zone: ZoneRef::Hand, + }, + }, + filter: TargetFilter::Typed( + TypedFilter::permanent().controller(ControllerRef::TargetPlayer), + ), + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: Some(ManaTargetRole::Both { + recipient: TargetFilter::Player, + count_source: TargetFilter::Player, + }), + }) + .id(); + + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&recipient_red) + .unwrap() + .color = vec![ManaColor::Red]; + runner + .state_mut() + .objects + .get_mut(&recipient_blue) + .unwrap() + .color = vec![ManaColor::Blue]; + runner + .state_mut() + .objects + .get_mut(&count_source_green) + .unwrap() + .color = vec![ManaColor::Green]; + + let spell_card = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id: spell_card, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting the role-split color ritual must succeed"); + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Player(P1), TargetRef::Player(P2)], + }) + .expect("recipient and count-source targets must be accepted"); + + for _ in 0..16 { + if matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + ) { + break; + } + runner + .act(GameAction::PassPriority) + .expect("resolving the ritual must advance to its mana prompt"); + } + + match &runner.state().waiting_for { + WaitingFor::ChooseManaColor { + choice: ManaChoicePrompt::SingleColor { options }, + .. + } => { + assert_eq!(options, &vec![ManaType::Blue, ManaType::Red]); + assert!(!options.contains(&ManaType::Green)); + } + other => panic!("expected recipient-scoped color prompt, got {other:?}"), + } + + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 1, + }) + .expect("choosing the recipient's red mana must succeed"); + + assert_eq!(total_pool(&runner, P1), COUNT_SOURCE_HAND as i32); + assert_eq!( + runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .count_color(ManaType::Red), + COUNT_SOURCE_HAND + ); + assert_eq!(total_pool(&runner, P2), 0); + assert_eq!(total_pool(&runner, P0), 0); +} + /// Paired negative / over-application guard: the SAME production, but with the /// recipient role dropped (Jeska's Will shape — count source only). The mana /// must stay with the CONTROLLER, and only ONE slot may be surfaced. A From 41090b225ad1d72d2c398083bbae02bdc1fdf28d Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 08:16:48 -0500 Subject: [PATCH 3/3] Fix zero-count chosen mana prompts --- crates/engine/src/game/mana_abilities.rs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 1781a90bcd..e98832d9f1 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1019,6 +1019,23 @@ pub(crate) fn mana_choice_prompt( ManaProduction::ChosenColor { fixed_alternative, .. } => { + if fixed_alternative.is_some() { + // CR 106.5: A fixed alternative makes production resolvable + // before the color choice. If it resolves to no mana, no color + // choice is needed. Pure chosen-color production must retain + // its prompt because that choice can determine its count. + let produces_mana = count_ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability( + produced, state, ability, + ) + .is_empty() + }) + .unwrap_or(true); + if !produces_mana { + return None; + } + } let chosen = super::effects::mana::chosen_color_for_mana(state, source_id); match (fixed_alternative, chosen) { // CR 106.1: "Add {fixed} or one mana of the chosen color" — once @@ -10787,6 +10804,55 @@ mod tests { assert_eq!(state.players[0].mana_pool.count_color(ManaType::Green), 2); } + #[test] + fn fixed_or_chosen_color_prompt_requires_positive_production() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(8110), + PlayerId(0), + "Fixed or Chosen Source".to_string(), + Zone::Battlefield, + ); + + for (count, expected_prompt) in [(0, false), (1, true)] { + let ability = ResolvedAbility::new( + Effect::Mana { + produced: ManaProduction::ChosenColor { + count: QuantityExpr::Fixed { value: count }, + contribution: ManaContribution::Base, + fixed_alternative: Some(ManaColor::Green), + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + vec![], + source, + PlayerId(0), + ); + + assert_eq!( + mana_choice_prompt( + &ability.effect, + &state, + source, + Some(&ability), + Some(&ability), + ) + .is_some(), + expected_prompt, + "CR 106.5: fixed-or-chosen mana count {count} must {} a color prompt", + if expected_prompt { + "reach" + } else { + "not reach" + }, + ); + } + } + /// Issue #460 + CR 106.12a: Vorinclex's `TapsForMana` trigger must fire /// **once per mana-ability resolution**, not once per mana unit. Activating /// Nykthos for 9 green (devotion = 9) plus a single Vorinclex fire = 10