Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/mana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ pub fn resolve(
state,
ability.source_id,
Some(ability),
Some(count_ability),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
})
.flatten();
Expand Down
143 changes: 129 additions & 14 deletions crates/engine/src/game/mana_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,23 +955,35 @@ 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<ManaChoicePrompt> {
let Effect::Mana { produced, .. } = effect else {
return None;
};
match produced {
ManaProduction::AnyOneColor { color_options, .. } if color_options.len() > 1 => {
Some(ManaChoicePrompt::SingleColor {
// 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()
})
.unwrap_or(true);
produces_mana.then(|| ManaChoicePrompt::SingleColor {
options: color_options.iter().map(mana_color_to_type).collect(),
})
}
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();
Expand All @@ -996,12 +1008,23 @@ 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::<Vec<_>>();
if options.len() > 1 {
// 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()
})
.unwrap_or(true);
if options.len() > 1 && produces_mana {
Some(ManaChoicePrompt::SingleColor { options })
} else {
None
Expand All @@ -1019,6 +1042,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
Expand Down Expand Up @@ -1061,7 +1101,15 @@ pub(crate) fn mana_choice_prompt(
owner,
source_id,
);
if options.len() > 1 {
// 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()
})
.unwrap_or(true);
if options.len() > 1 && produces_mana {
Some(ManaChoicePrompt::SingleColor { options })
} else {
None
Expand All @@ -1074,7 +1122,15 @@ 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 {
// 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()
})
.unwrap_or(true);
if identity.len() > 1 && produces_mana {
Some(ManaChoicePrompt::SingleColor {
options: identity.iter().map(mana_color_to_type).collect(),
})
Expand All @@ -1090,7 +1146,15 @@ 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 {
// 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()
})
.unwrap_or(true);
if options.len() > 1 && produces_mana {
Some(ManaChoicePrompt::SingleColor { options })
} else {
None
Expand All @@ -1102,14 +1166,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::<Vec<_>>();
let options =
super::effects::mana::object_colors_for_scope(state, color_ability, *scope)
.iter()
.map(mana_color_to_type)
.collect::<Vec<_>>();
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();
Expand Down Expand Up @@ -2698,6 +2763,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)
Expand Down Expand Up @@ -10822,7 +10888,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 { .. }));

Expand All @@ -10840,6 +10906,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
Expand Down
101 changes: 99 additions & 2 deletions crates/engine/tests/integration/coalition_relic_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -222,17 +226,110 @@ 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,
"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
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// 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"
);
}
Loading
Loading