Skip to main content

manabrew_engine/mana/
mod.rs

1use forge_foundation::mana::ManaAtom;
2use forge_foundation::ZoneType;
3
4use crate::ability::{ProducedMana, ProducedManaCombo};
5use crate::agent::PlayerAgent;
6use crate::card::Card;
7use crate::cost::CostPart;
8use crate::game::GameState;
9use crate::ids::{CardId, PlayerId};
10use crate::spellability::SpellAbility;
11
12pub mod auto_pay;
13pub mod computer_util_mana;
14pub mod mana_conversion_matrix;
15pub mod mana_cost_being_paid;
16pub mod mana_pool;
17pub mod mana_refund_service;
18pub use auto_pay::{
19    pay_mana_cost_auto, pay_mana_cost_auto_with_callback,
20    pay_mana_cost_auto_with_callback_and_reserved_sacrifices, pay_mana_cost_auto_with_chooser,
21    AutoPayResult,
22};
23
24pub fn apply_player_life_payment_keywords(
25    game: &GameState,
26    player: PlayerId,
27    cost: &forge_foundation::ManaCost,
28) -> forge_foundation::ManaCost {
29    let mut result = cost.clone();
30    if crate::player::has_keyword(game, player, "PayLifeInsteadOf:W") {
31        result = result.colored_to_phyrexian(ManaAtom::WHITE as u8);
32    }
33    if crate::player::has_keyword(game, player, "PayLifeInsteadOf:U") {
34        result = result.colored_to_phyrexian(ManaAtom::BLUE as u8);
35    }
36    if crate::player::has_keyword(game, player, "PayLifeInsteadOf:B") {
37        result = result.colored_to_phyrexian(ManaAtom::BLACK as u8);
38    }
39    if crate::player::has_keyword(game, player, "PayLifeInsteadOf:R") {
40        result = result.colored_to_phyrexian(ManaAtom::RED as u8);
41    }
42    if crate::player::has_keyword(game, player, "PayLifeInsteadOf:G") {
43        result = result.colored_to_phyrexian(ManaAtom::GREEN as u8);
44    }
45    result
46}
47
48pub(crate) fn mana_ability_meets_script_requirements(
49    game: &GameState,
50    card_id: CardId,
51    ab: &crate::ability::activated::ActivatedAbility,
52) -> bool {
53    let card = game.card(card_id);
54    let requirements = crate::card::valid_filter::CardTraitRequirementsIr::from_key_values(
55        ab.params
56            .inner()
57            .iter()
58            .map(|(key, value)| (key.as_str(), value.as_str())),
59        None,
60        None,
61    );
62    requirements.meets(game, card, card)
63}
64
65pub use computer_util_mana::{
66    auto_tap_lands, auto_tap_lands_allow_reserved_source_reuse,
67    auto_tap_lands_allow_reserved_source_reuse_trace,
68    auto_tap_lands_allow_reserved_source_reuse_trace_with_callbacks_and_reserved_sacrifices,
69    auto_tap_lands_allow_reserved_source_reuse_with_callbacks,
70    auto_tap_lands_allow_reserved_source_reuse_with_callbacks_and_reserved_sacrifices,
71    auto_tap_lands_allow_reserved_source_reuse_with_chooser, auto_tap_lands_generic,
72    auto_tap_lands_trace, auto_tap_lands_trace_with_callbacks, auto_tap_lands_with_callbacks,
73    auto_tap_lands_with_chooser, can_pay_mana_cost_with_reserved_sacrifices,
74    can_pay_spell_mana_cost_for_action_space, collect_mana_payment_sources, next_auto_tap_choice,
75    next_auto_tap_choice_with_reserved_sacrifices, AutoTapChoice, ManaPayCallback,
76    ManaPayCallbackFn, ManaPaymentSources, SacrificeChooser,
77};
78
79impl ProducedMana {
80    pub fn to_atoms(&self, chosen_colors: &[String]) -> Vec<u16> {
81        let mut atoms = Vec::new();
82        match self {
83            Self::Any => add_any_colors(&mut atoms),
84            Self::Chosen => return chosen_colors_to_atoms(chosen_colors),
85            Self::Combo(combo) => match combo {
86                ProducedManaCombo::Any => add_any_colors(&mut atoms),
87                ProducedManaCombo::Chosen => {
88                    for atom in chosen_colors_to_atoms(chosen_colors) {
89                        unique_push(&mut atoms, atom);
90                    }
91                }
92                ProducedManaCombo::ColorIdentity => {}
93                ProducedManaCombo::Colors(colors) => {
94                    for color in colors {
95                        if let Some(atom) = mana_atom_from_produced(color) {
96                            unique_push(&mut atoms, atom);
97                        }
98                    }
99                }
100                ProducedManaCombo::Raw(raw) => {
101                    for part in raw.split_whitespace() {
102                        if part.eq_ignore_ascii_case("Any") {
103                            add_any_colors(&mut atoms);
104                        } else if part.eq_ignore_ascii_case("Chosen") {
105                            for atom in chosen_colors_to_atoms(chosen_colors) {
106                                unique_push(&mut atoms, atom);
107                            }
108                        } else if let Some(atom) = mana_atom_from_produced(part) {
109                            unique_push(&mut atoms, atom);
110                        }
111                    }
112                }
113            },
114            Self::Special(_) => {}
115            Self::Fixed(tokens) => {
116                for part in tokens.iter().flat_map(|token| token.split_whitespace()) {
117                    if let Some(atom) = mana_atom_from_produced(part) {
118                        unique_push(&mut atoms, atom);
119                    }
120                }
121            }
122            Self::Raw(raw) => {
123                for part in raw.split_whitespace() {
124                    if let Some(atom) = mana_atom_from_produced(part) {
125                        unique_push(&mut atoms, atom);
126                    }
127                }
128            }
129        }
130        atoms
131    }
132
133    pub fn fixed_atoms(&self) -> Option<Vec<u16>> {
134        let tokens = self.fixed_tokens()?;
135        if tokens.len() <= 1 {
136            return None;
137        }
138
139        let mut atoms = Vec::with_capacity(tokens.len());
140        for part in tokens {
141            atoms.push(mana_atom_from_produced(part)?);
142        }
143        Some(atoms)
144    }
145
146    pub fn to_color_names(&self, chosen_colors: &[String]) -> Vec<String> {
147        let mut colors = Vec::new();
148        for atom in self.to_atoms(chosen_colors) {
149            if let Some(name) = mana_atom_to_color_name(atom) {
150                colors.push(name.to_string());
151            }
152        }
153        colors
154    }
155}
156
157/// An individual mana object in the pool, tracking source and properties.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Mana {
160    pub color: u16,
161    pub source_card: Option<CardId>,
162    pub is_snow: bool,
163    /// Mana that persists across all phase transitions (Omnath, Kruphix).
164    pub is_persistent: bool,
165    /// Mana that persists through combat phases but empties at end of combat.
166    pub is_combat_mana: bool,
167    /// Restriction on what this mana can be spent on (from RestrictValid$).
168    /// e.g. "Spell.Creature", "Spell.Artifact", "Activated", "nonSpell".
169    pub restriction: Option<String>,
170    /// If true, spells paid with this mana can't be countered (Cavern of Souls).
171    pub adds_no_counter: bool,
172    /// Keywords to add to spells cast with this mana (e.g. "Haste" from Generator Servant).
173    /// Format: "Keyword" with optional valid filter "Keyword|ValidFilter" (e.g. "Haste|Spell.Creature").
174    pub adds_keywords: Option<String>,
175    /// Valid filter for which spells get the keywords (e.g. "Spell.Creature").
176    pub adds_keywords_valid: Option<String>,
177    /// Counter spec to add to permanents cast with this mana (e.g. "P1P1" from Guildmages' Forum).
178    pub adds_counters: Option<String>,
179    /// Valid filter for which cards get the counters.
180    pub adds_counters_valid: Option<String>,
181    /// SVar name of a trigger to fire when this mana is spent to cast a spell.
182    /// The SVar lives on the source card (identified by `source_card`).
183    pub triggers_when_spent: Option<String>,
184}
185
186impl Mana {
187    pub fn simple(color: u16) -> Self {
188        Self {
189            color,
190            source_card: None,
191            is_snow: false,
192            is_persistent: false,
193            is_combat_mana: false,
194            restriction: None,
195            adds_no_counter: false,
196            adds_keywords: None,
197            adds_keywords_valid: None,
198            adds_counters: None,
199            adds_counters_valid: None,
200            triggers_when_spent: None,
201        }
202    }
203
204    /// Whether spells paid with this mana can't be countered.
205    /// Mirrors Java's `Mana.addsNoCounterMagic()`.
206    pub fn adds_no_counter_magic(&self) -> bool {
207        self.adds_no_counter
208    }
209
210    /// Whether this mana adds counters to permanents cast with it.
211    /// Mirrors Java's `Mana.addsCounters()`.
212    pub fn adds_counters(&self) -> bool {
213        self.adds_counters.is_some()
214    }
215
216    /// Whether this mana adds keywords to spells cast with it.
217    /// Mirrors Java's `Mana.addsKeywords()`.
218    pub fn adds_keywords(&self) -> bool {
219        self.adds_keywords.is_some()
220    }
221
222    /// Whether this mana adds keywords with a type restriction.
223    /// Mirrors Java's `Mana.addsKeywordsType()`.
224    pub fn adds_keywords_type(&self) -> bool {
225        self.adds_keywords_valid.is_some()
226    }
227
228    /// Whether this mana adds keywords with a duration.
229    /// Mirrors Java's `Mana.addsKeywordsUntil()`.
230    pub fn adds_keywords_until(&self) -> bool {
231        // Duration is implicit — keywords from mana last until end of turn
232        self.adds_keywords.is_some()
233    }
234
235    /// Whether this mana has a trigger-when-spent effect.
236    /// Mirrors Java's `Mana.triggersWhenSpent()`.
237    pub fn triggers_when_spent(&self) -> bool {
238        self.triggers_when_spent.is_some()
239    }
240}
241
242/// Context about what a mana payment is for, used to check restrictions.
243#[derive(Debug, Clone, Default)]
244pub struct ManaPaymentContext {
245    /// True if paying for a spell (not an ability).
246    pub is_spell: bool,
247    /// True if paying for an activated ability's cost. Distinct from
248    /// `is_spell` so triggered abilities and effect-driven costs (UnlessCost,
249    /// cumulative upkeep, …) are neither spell nor activated.
250    pub is_activated_ability: bool,
251    /// True when this is the *actual* payment phase for a spell already
252    /// announced on the stack — i.e. cast_spell.rs has already moved the
253    /// card to `Stack` and is now running auto-pay. Java parity: mirrors
254    /// `AbilityManaPart.meetsManaRestrictions` line 438 — if the SA we're
255    /// paying for is currently on the stack, restricted mana sources whose
256    /// `RestrictValid$ Spell` (or similar) clause would otherwise apply are
257    /// rejected. This matches Forge's behaviour where Leyline-Immersion-
258    /// style grants are effectively unusable for the cast they were
259    /// announced for, leaving only unrestricted producers in the pool.
260    /// Default is `false` so playability prediction stays optimistic.
261    pub sa_on_stack: bool,
262    /// Card type line of the spell being cast OR the source of the activated
263    /// ability being paid for (for type checks like `Activated.Elemental`).
264    pub type_line: Option<forge_foundation::CardTypeLine>,
265    /// Subtypes of the spell being cast.
266    pub card_name: Option<String>,
267    /// Color of the spell being cast (for `Spell.Colorless`-style qualifiers).
268    pub card_color: Option<forge_foundation::ColorSet>,
269    /// Chosen creature/card types keyed by mana source card ID (e.g. Cavern of Souls).
270    pub chosen_types_by_source: std::collections::HashMap<CardId, String>,
271}
272
273pub fn payment_context_for_sa(game: &GameState, sa: &SpellAbility) -> ManaPaymentContext {
274    let (type_line, card_name, card_color) = if let Some(source) = sa.source {
275        let card = game.card(source);
276        (
277            Some(card.type_line.clone()),
278            Some(card.card_name.clone()),
279            Some(card.color),
280        )
281    } else {
282        (None, None, None)
283    };
284
285    ManaPaymentContext {
286        is_spell: sa.is_spell,
287        is_activated_ability: sa.is_activated,
288        // `payment_context_for_sa` is used for activated-ability cost
289        // calculations and AI lookahead — neither is the real cast-time
290        // payment of a spell on stack. Leave the SA-on-stack guard off so
291        // restricted-spell statics are still considered for those callers.
292        sa_on_stack: false,
293        type_line,
294        card_name,
295        card_color,
296        chosen_types_by_source: game
297            .cards
298            .iter()
299            .filter_map(|c| c.chosen_type.clone().map(|chosen| (c.id, chosen)))
300            .collect(),
301    }
302}
303
304/// Check if a mana with the given restriction can be spent in the given context.
305pub fn mana_meets_restriction(restriction: &str, ctx: &ManaPaymentContext) -> bool {
306    // Multiple comma-separated restrictions: any match is OK (OR logic)
307    for part in restriction.split(',') {
308        let part = part.trim();
309        if part.is_empty() {
310            continue;
311        }
312        if check_single_restriction(part, ctx) {
313            return true;
314        }
315    }
316    false
317}
318
319fn check_single_restriction(restriction: &str, ctx: &ManaPaymentContext) -> bool {
320    match restriction {
321        "nonSpell" => !ctx.is_spell,
322        "Activated" => ctx.is_activated_ability,
323        "Spell" => ctx.is_spell,
324        _ if restriction.starts_with("Spell.") => {
325            if !ctx.is_spell {
326                return false;
327            }
328            let type_check = &restriction[6..]; // After "Spell."
329            if let Some(ref tl) = ctx.type_line {
330                match type_check {
331                    "Creature" => tl.is_creature(),
332                    "Artifact" => tl.is_artifact(),
333                    "Enchantment" => tl.is_enchantment(),
334                    "Instant" => tl.is_instant(),
335                    "Sorcery" => tl.is_sorcery(),
336                    "Planeswalker" => tl.is_planeswalker(),
337                    "Land" => tl.is_land(),
338                    other => {
339                        // Check subtype (e.g. "Spell.Dragon", "Spell.Lesson")
340                        // Handle compound checks with + (e.g. "Creature+Dragon")
341                        if let Some((base, sub)) = other.split_once('+') {
342                            let base_ok = match base {
343                                "Creature" => tl.is_creature(),
344                                "Artifact" => tl.is_artifact(),
345                                _ => tl.has_subtype(base),
346                            };
347                            let sub_ok = match sub {
348                                "Colorless" => ctx.card_color.is_some_and(|c| c.is_colorless()),
349                                "Multicolor" => ctx.card_color.is_some_and(|c| c.is_multicolor()),
350                                _ => tl.has_subtype(sub),
351                            };
352                            base_ok && sub_ok
353                        } else {
354                            tl.has_subtype(other)
355                        }
356                    }
357                }
358            } else {
359                false
360            }
361        }
362        _ if restriction.starts_with("Activated.") => {
363            // `Activated.X` requires paying for an activated ability whose
364            // SOURCE matches X (e.g. Flamebraider's mana is restricted to
365            // "abilities of Elemental sources"). Triggered abilities and
366            // effect-payment costs (UnlessCost) are neither.
367            if !ctx.is_activated_ability {
368                return false;
369            }
370            let type_check = &restriction[10..]; // After "Activated."
371            let Some(tl) = ctx.type_line.as_ref() else {
372                return false;
373            };
374            match type_check {
375                "Creature" => tl.is_creature(),
376                "Artifact" => tl.is_artifact(),
377                "Enchantment" => tl.is_enchantment(),
378                "Land" => tl.is_land(),
379                "Planeswalker" => tl.is_planeswalker(),
380                other => {
381                    if let Some((base, sub)) = other.split_once('+') {
382                        let base_ok = match base {
383                            "Creature" => tl.is_creature(),
384                            "Artifact" => tl.is_artifact(),
385                            _ => tl.has_subtype(base),
386                        };
387                        base_ok && tl.has_subtype(sub)
388                    } else {
389                        tl.has_subtype(other)
390                    }
391                }
392            }
393        }
394        _ if restriction.starts_with("CantPayGenericCosts") => true, // handled separately in payment
395        _ if restriction.starts_with("CantCast") => true, // zone restrictions handled elsewhere
396        _ => true,                                        // Unknown restriction — be permissive
397    }
398}
399
400// ManaPool moved to mana_pool.rs — single source of truth.
401pub use mana_pool::ManaPool;
402
403// ── Mana helpers ────────────────────────────────────────────────────
404
405/// Determine what mana atom a basic land produces based on its subtypes.
406pub fn basic_land_mana_atom(card: &Card) -> Option<u16> {
407    if card.type_line.has_subtype("Plains") {
408        Some(ManaAtom::WHITE)
409    } else if card.type_line.has_subtype("Island") {
410        Some(ManaAtom::BLUE)
411    } else if card.type_line.has_subtype("Swamp") {
412        Some(ManaAtom::BLACK)
413    } else if card.type_line.has_subtype("Mountain") {
414        Some(ManaAtom::RED)
415    } else if card.type_line.has_subtype("Forest") {
416        Some(ManaAtom::GREEN)
417    } else {
418        // Check card name as fallback
419        match card.card_name.as_str() {
420            "Plains" => Some(ManaAtom::WHITE),
421            "Island" => Some(ManaAtom::BLUE),
422            "Swamp" => Some(ManaAtom::BLACK),
423            "Mountain" => Some(ManaAtom::RED),
424            "Forest" => Some(ManaAtom::GREEN),
425            _ => None,
426        }
427    }
428}
429
430/// Convert a Produced$ value (e.g. "G", "R", "W") to a ManaAtom.
431pub fn mana_atom_from_produced(produced: &str) -> Option<u16> {
432    match produced.trim() {
433        "W" => Some(ManaAtom::WHITE),
434        "U" => Some(ManaAtom::BLUE),
435        "B" => Some(ManaAtom::BLACK),
436        "R" => Some(ManaAtom::RED),
437        "G" => Some(ManaAtom::GREEN),
438        "C" => Some(ManaAtom::COLORLESS),
439        _ => None,
440    }
441}
442
443pub(crate) fn mana_atom_to_color_name(atom: u16) -> Option<&'static str> {
444    match atom {
445        ManaAtom::WHITE => Some("White"),
446        ManaAtom::BLUE => Some("Blue"),
447        ManaAtom::BLACK => Some("Black"),
448        ManaAtom::RED => Some("Red"),
449        ManaAtom::GREEN => Some("Green"),
450        ManaAtom::COLORLESS => Some("Colorless"),
451        _ => None,
452    }
453}
454
455fn unique_push(atoms: &mut Vec<u16>, atom: u16) {
456    if !atoms.contains(&atom) {
457        atoms.push(atom);
458    }
459}
460
461fn add_any_colors(atoms: &mut Vec<u16>) {
462    unique_push(atoms, ManaAtom::WHITE);
463    unique_push(atoms, ManaAtom::BLUE);
464    unique_push(atoms, ManaAtom::BLACK);
465    unique_push(atoms, ManaAtom::RED);
466    unique_push(atoms, ManaAtom::GREEN);
467}
468
469fn chosen_colors_to_atoms(chosen_colors: &[String]) -> Vec<u16> {
470    let mut atoms = Vec::new();
471    for chosen in chosen_colors {
472        if let Some(atom) = color_name_to_mana_atom(chosen) {
473            unique_push(&mut atoms, atom);
474            continue;
475        }
476        if let Some(atom) = mana_atom_from_produced(chosen) {
477            unique_push(&mut atoms, atom);
478        }
479    }
480    atoms
481}
482
483/// Convert a single mana letter ("G", "U", etc.) to its color name ("Green", "Blue", etc.).
484pub fn mana_letter_to_color_name(letter: &str) -> Option<String> {
485    match letter.trim() {
486        "W" => Some("White".to_string()),
487        "U" => Some("Blue".to_string()),
488        "B" => Some("Black".to_string()),
489        "R" => Some("Red".to_string()),
490        "G" => Some("Green".to_string()),
491        "C" => Some("Colorless".to_string()),
492        _ => None,
493    }
494}
495
496/// Compute the atoms a ManaReflected ability can produce by inspecting other
497/// permanents on the battlefield.  Used by both `calculate_available_mana` and
498/// `group_sources_by_mana_color` (auto-pay).
499pub(crate) fn compute_reflected_atoms(
500    game: &GameState,
501    player: PlayerId,
502    card_id: CardId,
503    ab: &crate::ability::activated::ActivatedAbility,
504) -> Vec<u16> {
505    let sa = crate::ability::ability_factory::build_spell_ability(
506        game,
507        card_id,
508        &ab.ability_text,
509        player,
510    );
511    let colors = crate::card::card_util::get_reflectable_mana_colors(game, &sa);
512    // Java parity: harness AutoPay sorts reflectable colours into canonical
513    // WUBRG(C) order before deriving atoms (see AutoPay.producedAtoms). Mirror
514    // that order here so generic-shard `produced.get(0)` matches on both sides.
515    let mut reflected_atoms = Vec::new();
516    for (name, atom) in [
517        ("white", ManaAtom::WHITE),
518        ("blue", ManaAtom::BLUE),
519        ("black", ManaAtom::BLACK),
520        ("red", ManaAtom::RED),
521        ("green", ManaAtom::GREEN),
522        ("colorless", ManaAtom::COLORLESS),
523    ] {
524        if colors.contains(name) || colors.contains(&capitalize_color(name)) {
525            reflected_atoms.push(atom);
526        }
527    }
528    reflected_atoms
529}
530
531/// Convert a color name ("Green", "Blue", etc.) to its ManaAtom constant.
532/// Case-insensitive: accepts "white", "White", "WHITE", etc.
533pub fn color_name_to_mana_atom(name: &str) -> Option<u16> {
534    match name.to_ascii_lowercase().as_str() {
535        "white" => Some(ManaAtom::WHITE),
536        "blue" => Some(ManaAtom::BLUE),
537        "black" => Some(ManaAtom::BLACK),
538        "red" => Some(ManaAtom::RED),
539        "green" => Some(ManaAtom::GREEN),
540        "colorless" => Some(ManaAtom::COLORLESS),
541        _ => None,
542    }
543}
544
545/// Capitalize a lowercase color name: "white" → "White".
546pub fn capitalize_color(s: &str) -> String {
547    let mut chars = s.chars();
548    match chars.next() {
549        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
550        None => String::new(),
551    }
552}
553
554/// Returns all ManaAtom values that correspond to the card's basic land subtypes.
555/// Multi-subtype lands (e.g. Breeding Pool = Forest + Island) return all matching atoms.
556/// Unlike `basic_land_mana_atom`, this returns ALL subtypes not just the first match.
557pub(crate) fn all_basic_subtype_atoms(card: &Card) -> Vec<u16> {
558    let mut atoms = Vec::new();
559    let subtypes = [
560        ("Plains", ManaAtom::WHITE),
561        ("Island", ManaAtom::BLUE),
562        ("Swamp", ManaAtom::BLACK),
563        ("Mountain", ManaAtom::RED),
564        ("Forest", ManaAtom::GREEN),
565    ];
566    for (subtype, atom) in &subtypes {
567        if card.type_line.has_subtype(subtype) && !atoms.contains(atom) {
568            atoms.push(*atom);
569        }
570    }
571    atoms
572}
573
574pub(crate) fn tap_land_for_mana(
575    game: &mut GameState,
576    pool: &mut ManaPool,
577    player: PlayerId,
578    land_id: CardId,
579    atom: u16,
580    should_tap: bool,
581    tapped_lands: &mut Vec<CardId>,
582    ability_index: Option<usize>,
583) {
584    let _ = player;
585    let card = game.card(land_id);
586    let is_snow = card.type_line.is_snow();
587    // Pull `TriggersWhenSpent$` metadata from the specific mana ability
588    // being activated (Path of Ancestry's `TrigScry`, etc.) so the produced
589    // mana carries the trigger SVar through cost payment. Without this the
590    // fast-path `auto_pay` flow strips the metadata that the SP$ Mana
591    // resolution would normally set in `mana_effect`.
592    let triggers_when_spent = ability_index
593        .and_then(|idx| card.activated_abilities.get(idx))
594        .and_then(|ab| ab.triggers_when_spent.clone());
595    if should_tap && !card.tapped {
596        game.tap(land_id);
597    }
598    let mut mana = if is_snow {
599        let mut m = crate::mana::Mana::simple(atom);
600        m.is_snow = true;
601        m
602    } else {
603        crate::mana::Mana::simple(atom)
604    };
605    mana.source_card = Some(land_id);
606    mana.triggers_when_spent = triggers_when_spent;
607    if std::env::var("FORGE_PAYMENT_TRACE").is_ok() {
608        let card_name = game.card(land_id).card_name.clone();
609        let turn = game.turn.turn_number;
610        let phase = format!("{:?}", game.turn.phase);
611        eprintln!(
612            "[pay-trace-rust] T{} {} P{:?} tap_land_for_mana card={}#{:?} atom={} pool_total={} ability_idx={:?}",
613            turn,
614            phase,
615            player,
616            card_name,
617            land_id,
618            ManaPool::atom_to_letter(atom),
619            pool.total_mana(),
620            ability_index,
621        );
622    }
623    pool.add_mana(mana);
624    tapped_lands.push(land_id);
625}
626
627/// Returns all ManaAtom values a land can produce from its activated mana abilities.
628/// Handles:
629/// - Single color (`Produced$ G`) → that atom
630/// - Combo (`Produced$ Combo G U`) → all listed atoms
631/// - Combo ColorIdentity → nothing (non-Commander game; no commander identity)
632/// - Colorless (`Produced$ C`) → COLORLESS
633/// - Implicit basic-land-subtype abilities (e.g. Breeding Pool = Forest + Island → G + U)
634pub fn land_mana_atoms(card: &Card) -> Vec<u16> {
635    let mut atoms = Vec::new();
636    for ab in &card.activated_abilities {
637        if !ab.is_mana_ability {
638            continue;
639        }
640        // Java parity: don't treat mana abilities with mana activation costs as free
641        // producers during static source detection.
642        if ab
643            .cost
644            .parts
645            .iter()
646            .any(|p| matches!(p, CostPart::Mana { .. }))
647        {
648            continue;
649        }
650        if let Some(produced_ir) = ab.produced_ir.as_ref() {
651            if produced_ir.is_combo_color_identity() {
652                // In a non-Commander game there is no commander identity, so this land
653                // produces no mana — matches Java Forge's ManaEffect which skips
654                // the mana production entirely when the choice string is empty.
655                // (Java: ManaEffect.java line 141-143: "No mana could be produced here")
656            } else {
657                for atom in produced_ir.to_atoms(&card.chosen_colors) {
658                    if !atoms.contains(&atom) {
659                        atoms.push(atom);
660                    }
661                }
662            }
663        }
664    }
665    // If no explicit activated mana abilities produced any atoms, fall back to basic land
666    // subtype inference. This handles dual lands like Breeding Pool (Forest Island → G + U)
667    // and Hallowed Fountain (Plains Island → W + U) which don't have explicit AB$ Mana
668    // entries in their card scripts — the mana ability is implied by the basic land subtype.
669    if atoms.is_empty() {
670        atoms = all_basic_subtype_atoms(card);
671        // Final fallback: basic_land_mana_atom for cards with a single subtype by name
672        if atoms.is_empty() {
673            if let Some(a) = basic_land_mana_atom(card) {
674                atoms.push(a);
675            }
676        }
677    }
678    atoms
679}
680
681pub(crate) fn atom_short(atom: u16) -> &'static str {
682    match atom {
683        ManaAtom::WHITE => "W",
684        ManaAtom::BLUE => "U",
685        ManaAtom::BLACK => "B",
686        ManaAtom::RED => "R",
687        ManaAtom::GREEN => "G",
688        ManaAtom::COLORLESS => "C",
689        _ => "1",
690    }
691}
692
693// ── Mana production (extracted from game_action.rs) ─────────────────
694
695/// Parameters that describe metadata to attach to produced mana.
696pub struct ManaProductionParams {
697    pub source_card: CardId,
698    pub is_snow: bool,
699    pub restriction: Option<String>,
700    pub adds_no_counter: bool,
701    pub adds_keywords: Option<String>,
702    pub adds_keywords_valid: Option<String>,
703    pub adds_counters: Option<String>,
704    pub adds_counters_valid: Option<String>,
705    pub triggers_when_spent: Option<String>,
706}
707
708/// Determine what mana to produce from a `Produced$` string, handling
709/// color choice, combo mana, `Amount$` multiplier, and replacement effects.
710///
711/// Returns the final mana string (e.g. `"W W"`, `"R G"`, `"C C C"`),
712/// or `None` if no mana can be produced (e.g. Amount$ evaluates to 0).
713///
714/// The caller is responsible for cost payment, trigger firing, sub-ability
715/// resolution, and the `Special` / `ManaReflected` branches.
716pub fn determine_mana_production_ir(
717    game: &mut GameState,
718    agents: &mut [Box<dyn PlayerAgent>],
719    player: PlayerId,
720    card_id: CardId,
721    produced_ir: &ProducedMana,
722    produced_text: &str,
723    amount_param: Option<&str>,
724    express_choice: Option<u16>,
725) -> Option<String> {
726    let mut mana_string: Option<String> = None;
727    let amount = amount_param.map(|amount_str| {
728        if let Ok(n) = amount_str.parse::<i32>() {
729            n
730        } else if let Some(svar_expr) = game.card(card_id).svars.get(amount_str).cloned() {
731            crate::ability::effects::resolve_count_svar(&svar_expr, game, card_id, player)
732        } else {
733            1
734        }
735    });
736    let uses_combo_distribution = amount.is_some_and(|amount| amount > 1)
737        && matches!(produced_ir, ProducedMana::Combo(_))
738        && !produced_ir.is_combo_color_identity();
739
740    // Forge routes multi-amount combo mana directly through specifyManaCombo.
741    if uses_combo_distribution {
742        let available: Vec<String> = if produced_ir.is_any_like() {
743            vec!["W", "U", "B", "R", "G"]
744                .into_iter()
745                .map(String::from)
746                .collect()
747        } else {
748            let chosen_colors = game.card(card_id).chosen_colors.clone();
749            let names = produced_ir.to_color_names(&chosen_colors);
750            names
751                .iter()
752                .filter_map(|name| {
753                    color_name_to_mana_atom(name).map(|a| ManaPool::atom_to_letter(a).to_string())
754                })
755                .collect()
756        };
757        if !available.is_empty() {
758            let chosen = agents[player.index()].specify_mana_combo(
759                player,
760                &available,
761                amount.unwrap_or(1) as usize,
762                Some(card_id),
763                express_choice,
764            );
765            mana_string = Some(chosen.join(" "));
766        }
767    } else if produced_ir.is_combo_color_identity() {
768        let colors = game.player_commander_color_identity(player);
769
770        if !colors.is_empty() {
771            if let Some(chosen) = agents[player.index()].choose_color(player, &colors) {
772                if let Some(atom) = color_name_to_mana_atom(&chosen) {
773                    mana_string = Some(ManaPool::atom_to_letter(atom).to_string());
774                }
775            }
776        }
777    } else if produced_ir.fixed_atoms().is_some() {
778        mana_string = Some(produced_text.to_string());
779    } else {
780        let chosen_colors = game.card(card_id).chosen_colors.clone();
781        let colors = produced_ir.to_color_names(&chosen_colors);
782        if colors.len() > 1 {
783            let chosen = if let Some(forced) = express_choice
784                .and_then(mana_atom_to_color_name)
785                .and_then(|forced_name| {
786                    colors
787                        .iter()
788                        .find(|valid| valid.eq_ignore_ascii_case(forced_name))
789                        .cloned()
790                }) {
791                // Java calls chooseColor even when expressChoice is set,
792                // presenting the forced color as a single-option choice.
793                // Consume the RNG pick for parity.
794                let single = vec![forced.clone()];
795                let _ = agents[player.index()].choose_color(player, &single);
796                Some(forced)
797            } else {
798                agents[player.index()].choose_color(player, &colors)
799            };
800            if let Some(chosen) = chosen {
801                if let Some(atom) = color_name_to_mana_atom(&chosen) {
802                    mana_string = Some(ManaPool::atom_to_letter(atom).to_string());
803                }
804            }
805        } else if let Some(single) = colors.first() {
806            if let Some(atom) = color_name_to_mana_atom(single) {
807                mana_string = Some(ManaPool::atom_to_letter(atom).to_string());
808            }
809        } else {
810            // Raw produced string (single-token fixed output)
811            mana_string = Some(produced_text.to_string());
812        }
813    }
814
815    // Apply Amount$ multiplier (e.g. Rofellos produces mana equal to Forests)
816    if let Some(ref mut ms) = mana_string {
817        if let Some(amount) = amount {
818            if amount > 1 {
819                if !uses_combo_distribution {
820                    let base = ms.clone();
821                    for _ in 1..amount {
822                        ms.push(' ');
823                        ms.push_str(&base);
824                    }
825                }
826            } else if amount <= 0 {
827                mana_string = None;
828            }
829        }
830    }
831
832    // Apply ProduceMana replacement effects (mana doublers like Mirari's Wake)
833    if let Some(ref mut ms) = mana_string {
834        use crate::replacement::replacement_handler::{
835            apply_replacements_with_agents, ReplacementEvent,
836        };
837        let mut event = ReplacementEvent::ProduceMana {
838            source: card_id,
839            activator: player,
840            mana: ms.clone(),
841        };
842        let result = apply_replacements_with_agents(game, agents, &mut event);
843        if result == crate::replacement::ReplacementResult::Updated {
844            if let ReplacementEvent::ProduceMana { mana: new_mana, .. } = event {
845                *ms = new_mana;
846            }
847        }
848    }
849
850    mana_string
851}
852
853/// Add produced mana to the pool with full metadata (snow, restriction, keywords, counters, triggers).
854pub fn add_produced_mana_to_pool(
855    pool: &mut ManaPool,
856    mana_string: &str,
857    params: &ManaProductionParams,
858) {
859    pool.produce_mana_from_string(
860        mana_string,
861        Some(params.source_card),
862        params.is_snow,
863        params.restriction.clone(),
864        params.adds_no_counter,
865        params.adds_keywords.clone(),
866        params.adds_keywords_valid.clone(),
867        params.adds_counters.clone(),
868        params.adds_counters_valid.clone(),
869        params.triggers_when_spent.clone(),
870    );
871}
872
873/// Calculate available mana from the current pool plus untapped lands and non-land mana sources.
874///
875/// Colors are tracked OPTIMISTICALLY: each source adds 1 per color it could produce,
876/// so that color-matching checks (`can_pay` for colored shards) work correctly.
877/// However, `total_sources` is set to the actual number of mana sources, so the
878/// total mana check in `can_pay` prevents dual/multi-color lands from being
879/// double-counted (e.g. Breeding Pool counts as 1 mana, not 2).
880pub fn calculate_available_mana(pool: &ManaPool, game: &GameState, player: PlayerId) -> ManaPool {
881    calculate_available_mana_excluding_with_reserved(pool, game, player, None, &[])
882}
883
884pub fn calculate_available_mana_for_casting(
885    pool: &ManaPool,
886    game: &GameState,
887    player: PlayerId,
888) -> ManaPool {
889    calculate_available_mana_for_casting_excluding(pool, game, player, None)
890}
891
892pub fn calculate_available_mana_for_casting_excluding(
893    pool: &ManaPool,
894    game: &GameState,
895    player: PlayerId,
896    excluded_source: Option<CardId>,
897) -> ManaPool {
898    calculate_available_mana_excluding_with_reserved_impl(
899        pool,
900        game,
901        player,
902        excluded_source,
903        &[],
904        true,
905        None,
906    )
907}
908
909/// Calculate available mana while excluding a specific battlefield source.
910///
911/// This is used by activated-ability legality checks to mirror Java's
912/// `ComputerUtilMana` behavior: an ability cannot pay its own mana cost from
913/// mana abilities on the same host permanent.
914pub fn calculate_available_mana_excluding(
915    pool: &ManaPool,
916    game: &GameState,
917    player: PlayerId,
918    excluded_source: Option<CardId>,
919) -> ManaPool {
920    calculate_available_mana_excluding_with_reserved(pool, game, player, excluded_source, &[])
921}
922
923pub fn calculate_available_mana_excluding_with_reserved(
924    pool: &ManaPool,
925    game: &GameState,
926    player: PlayerId,
927    excluded_source: Option<CardId>,
928    reserved_sacrifices: &[CardId],
929) -> ManaPool {
930    calculate_available_mana_excluding_with_reserved_impl(
931        pool,
932        game,
933        player,
934        excluded_source,
935        reserved_sacrifices,
936        false,
937        None,
938    )
939}
940
941/// Like `calculate_available_mana_excluding_with_reserved` but filters mana
942/// abilities whose `RestrictValid$` cannot be satisfied by the given payment
943/// context. Java's ActionSpace filters per-spell via
944/// `manaPart.meetsManaRestrictions(saBeingPaid)`; this mirrors that for
945/// Rust's playability checks.
946pub fn calculate_available_mana_with_context(
947    pool: &ManaPool,
948    game: &GameState,
949    player: PlayerId,
950    excluded_source: Option<CardId>,
951    reserved_sacrifices: &[CardId],
952    payment_ctx: Option<&ManaPaymentContext>,
953) -> ManaPool {
954    // Include hand-based mana sources (e.g. Simian Spirit Guide's exile-
955    // from-hand ability) so playability checks account for them. Mirrors
956    // Java's ComputerUtilMana which iterates hand mana abilities.
957    calculate_available_mana_excluding_with_reserved_impl(
958        pool,
959        game,
960        player,
961        excluded_source,
962        reserved_sacrifices,
963        true,
964        payment_ctx,
965    )
966}
967
968pub(crate) fn replacement_adjusted_atoms_for_availability(
969    game: &GameState,
970    player: PlayerId,
971    source: CardId,
972    atom: u16,
973) -> Vec<u16> {
974    use crate::replacement::replacement_handler::ReplacementEvent;
975
976    let mut event = ReplacementEvent::ProduceMana {
977        source,
978        activator: player,
979        mana: ManaPool::atom_to_letter(atom).to_string(),
980    };
981    if apply_produce_mana_replacements_for_availability(game, &mut event) {
982        let ReplacementEvent::ProduceMana { mana, .. } = event else {
983            return vec![atom];
984        };
985        let produced_ir = ProducedMana::from_raw_boundary(&mana);
986        let adjusted = produced_ir
987            .fixed_atoms()
988            .unwrap_or_else(|| produced_ir.to_atoms(&[]));
989        if !adjusted.is_empty() {
990            return adjusted;
991        }
992    }
993
994    vec![atom]
995}
996
997pub(crate) fn has_replacement_adjusted_available_mana(game: &GameState, player: PlayerId) -> bool {
998    fn is_adjusted(game: &GameState, player: PlayerId, source: CardId, atom: u16) -> bool {
999        let adjusted = replacement_adjusted_atoms_for_availability(game, player, source, atom);
1000        adjusted.len() != 1 || adjusted.first().copied() != Some(atom)
1001    }
1002
1003    for &card_id in game.cards_in_zone(ZoneType::Battlefield, player) {
1004        let card = game.card(card_id);
1005        if card.phased_out {
1006            continue;
1007        }
1008
1009        let mut has_explicit_mana = false;
1010        for ab in &card.activated_abilities {
1011            if !ab.is_mana_ability
1012                || ab
1013                    .cost
1014                    .parts
1015                    .iter()
1016                    .any(|part| matches!(part, CostPart::Mana { .. }))
1017                || (card.tapped
1018                    && ab
1019                        .cost
1020                        .parts
1021                        .iter()
1022                        .any(|part| matches!(part, CostPart::Tap)))
1023                || !crate::cost::can_pay_ignoring_mana(&ab.cost, game, card_id, player)
1024                || !mana_ability_meets_script_requirements(game, card_id, ab)
1025            {
1026                continue;
1027            }
1028            has_explicit_mana = true;
1029
1030            let atoms = if ab.is_mana_reflected {
1031                compute_reflected_atoms(game, player, card_id, ab)
1032            } else if let Some(produced_ir) = ab.produced_ir.as_ref() {
1033                if produced_ir.is_combo_color_identity() {
1034                    chosen_colors_to_atoms(&game.player_commander_color_identity(player))
1035                } else if let Some(fixed_atoms) = produced_ir.fixed_atoms() {
1036                    fixed_atoms
1037                } else {
1038                    produced_ir.to_atoms(&card.chosen_colors)
1039                }
1040            } else {
1041                Vec::new()
1042            };
1043
1044            if atoms
1045                .into_iter()
1046                .any(|atom| is_adjusted(game, player, card_id, atom))
1047            {
1048                return true;
1049            }
1050        }
1051
1052        if !has_explicit_mana && card.is_land() && !card.tapped {
1053            let mut atoms = all_basic_subtype_atoms(card);
1054            if atoms.is_empty() {
1055                if let Some(atom) = basic_land_mana_atom(card) {
1056                    atoms.push(atom);
1057                }
1058            }
1059            if atoms
1060                .into_iter()
1061                .any(|atom| is_adjusted(game, player, card_id, atom))
1062            {
1063                return true;
1064            }
1065        }
1066    }
1067
1068    false
1069}
1070
1071pub(crate) fn java_replacement_filtered_atoms_for_availability(
1072    game: &GameState,
1073    player: PlayerId,
1074    source: CardId,
1075    ab: &crate::ability::activated::ActivatedAbility,
1076    intrinsic_atoms: &[u16],
1077) -> Vec<u16> {
1078    if intrinsic_atoms.is_empty() {
1079        return Vec::new();
1080    }
1081
1082    let orig_produced = ab
1083        .produced_ir
1084        .as_ref()
1085        .map(ProducedMana::as_script_text)
1086        .unwrap_or_else(|| {
1087            if ab.is_mana_reflected {
1088                "1".into()
1089            } else {
1090                "".into()
1091            }
1092        });
1093    if orig_produced.is_empty() {
1094        return intrinsic_atoms.to_vec();
1095    }
1096
1097    let mut event = crate::replacement::replacement_handler::ReplacementEvent::ProduceMana {
1098        source,
1099        activator: player,
1100        mana: orig_produced.to_string(),
1101    };
1102    if !apply_produce_mana_replacements_for_availability(game, &mut event) {
1103        return intrinsic_atoms.to_vec();
1104    }
1105
1106    let crate::replacement::replacement_handler::ReplacementEvent::ProduceMana { mana, .. } = event
1107    else {
1108        return intrinsic_atoms.to_vec();
1109    };
1110    // Java's `groupSourcesByManaColor` checks `"Any".equals(replaced)` against
1111    // the *unmodified* origin string — `ReplaceAmount` (Mana Reflection,
1112    // Nyxbloom Ancient) never touches `replaced` on the Java side, only the
1113    // mana-pool tally. Our `apply_produce_mana_replacements_for_availability`
1114    // already multiplies the string ("Any" → "Any Any Any"), so anchor the
1115    // comparison on the *original* produced text. Mirroring Java's exact
1116    // string equality keeps quirky cases (e.g. "Combo Any" — granted by
1117    // Leyline Immersion — still parses only the "C" letter from "Combo",
1118    // not as full any-color) in lockstep.
1119    if orig_produced.trim() == "Any" {
1120        return intrinsic_atoms.to_vec();
1121    }
1122
1123    let pairs = [
1124        ("W", ManaAtom::WHITE),
1125        ("U", ManaAtom::BLUE),
1126        ("B", ManaAtom::BLACK),
1127        ("R", ManaAtom::RED),
1128        ("G", ManaAtom::GREEN),
1129        ("C", ManaAtom::COLORLESS),
1130    ];
1131    let mut filtered = Vec::new();
1132    for (letter, atom) in pairs {
1133        if mana.contains(letter) && intrinsic_atoms.contains(&atom) && !filtered.contains(&atom) {
1134            filtered.push(atom);
1135        }
1136    }
1137    filtered
1138}
1139
1140pub(crate) fn reflected_atoms_for_availability(
1141    game: &GameState,
1142    player: PlayerId,
1143    source: CardId,
1144    ab: &crate::ability::activated::ActivatedAbility,
1145) -> Vec<u16> {
1146    let reflected = compute_reflected_atoms(game, player, source, ab);
1147    java_replacement_filtered_atoms_for_availability(game, player, source, ab, &reflected)
1148}
1149
1150fn apply_produce_mana_replacements_for_availability(
1151    game: &GameState,
1152    event: &mut crate::replacement::replacement_handler::ReplacementEvent,
1153) -> bool {
1154    use crate::replacement::{
1155        replace_produce_mana, ReplacementLayer, ReplacementResult, ReplacementType,
1156    };
1157
1158    let mut has_run: std::collections::HashSet<(CardId, usize)> = std::collections::HashSet::new();
1159    let mut updated = false;
1160
1161    loop {
1162        let mut changed_this_pass = false;
1163        for layer in [
1164            ReplacementLayer::CantHappen,
1165            ReplacementLayer::Control,
1166            ReplacementLayer::Copy,
1167            ReplacementLayer::Transform,
1168            ReplacementLayer::Other,
1169        ] {
1170            let mut chosen = None;
1171            'cards: for (i, card) in game.cards.iter().enumerate() {
1172                let card_id = CardId(i as u32);
1173                for (effect_idx, effect) in card.replacement_effects.iter().enumerate() {
1174                    if has_run.contains(&(card_id, effect_idx))
1175                        || effect.event != ReplacementType::ProduceMana
1176                        || effect.layer != layer
1177                        || !effect.active_in_zone(card.zone)
1178                        || !effect.requirements_check(game, card)
1179                        || !replace_produce_mana::can_replace(effect, event, game, card)
1180                    {
1181                        continue;
1182                    }
1183                    chosen = Some((card_id, effect_idx));
1184                    break 'cards;
1185                }
1186            }
1187
1188            let Some((card_id, effect_idx)) = chosen else {
1189                continue;
1190            };
1191            has_run.insert((card_id, effect_idx));
1192            let effect = &game.card(card_id).replacement_effects[effect_idx];
1193            if replace_produce_mana::execute(effect, event, game, card_id)
1194                == ReplacementResult::Updated
1195            {
1196                updated = true;
1197                changed_this_pass = true;
1198                break;
1199            }
1200        }
1201
1202        if !changed_this_pass {
1203            return updated;
1204        }
1205    }
1206}
1207
1208fn atoms_mask_to_letters(mask: u16) -> String {
1209    let mut letters: Vec<&str> = Vec::new();
1210    for atom in [
1211        ManaAtom::WHITE,
1212        ManaAtom::BLUE,
1213        ManaAtom::BLACK,
1214        ManaAtom::RED,
1215        ManaAtom::GREEN,
1216        ManaAtom::COLORLESS,
1217    ] {
1218        if mask & atom != 0 {
1219            letters.push(ManaPool::atom_to_letter(atom));
1220        }
1221    }
1222    letters.join(" ")
1223}
1224
1225fn add_taps_for_mana_trigger_mana_for_availability(
1226    available: &mut ManaPool,
1227    source_count: &mut i32,
1228    source_colors: &mut Vec<u16>,
1229    game: &GameState,
1230    player: PlayerId,
1231    tapped_card_id: CardId,
1232    produced_letters: &str,
1233) {
1234    let params = crate::event::RunParams {
1235        card: Some(tapped_card_id),
1236        player: Some(player),
1237        activator: Some(player),
1238        produced: Some(produced_letters.to_string()),
1239        ..Default::default()
1240    };
1241
1242    for host in &game.cards {
1243        if host.zone != ZoneType::Battlefield || host.phased_out {
1244            continue;
1245        }
1246        for trigger in &host.triggers {
1247            if trigger.kind != crate::trigger::TriggerType::TapsForMana
1248                || !trigger.get_active_zone().contains(&host.zone)
1249                || !trigger.requirements_check(game, host.id)
1250                || !trigger.check_activation_limit(game, host.id)
1251                || !trigger.mode.perform_test(trigger, &params, game)
1252                || !trigger.meets_requirements_on_triggered_objects(game, &params, host.id)
1253            {
1254                continue;
1255            }
1256            let Some(svar_text) = host.svars.get(&trigger.execute) else {
1257                continue;
1258            };
1259            let trigger_params = crate::parsing::Params::from_raw(svar_text);
1260            if !trigger_params
1261                .get("DB")
1262                .is_some_and(|api| api.eq_ignore_ascii_case("Mana"))
1263            {
1264                continue;
1265            }
1266            let Some(produced) = trigger_params.get(crate::parsing::keys::PRODUCED) else {
1267                continue;
1268            };
1269            let amount = trigger_params
1270                .get(crate::parsing::keys::AMOUNT)
1271                .and_then(|raw| raw.parse::<i32>().ok())
1272                .unwrap_or(1)
1273                .max(1);
1274            let produced_ir = ProducedMana::from_raw_boundary(produced);
1275            if let Some(fixed_atoms) = produced_ir.fixed_atoms() {
1276                for atom in fixed_atoms {
1277                    available.add(atom, 1);
1278                    *source_count += 1;
1279                    source_colors.push(atom);
1280                }
1281                continue;
1282            }
1283
1284            let atoms = produced_ir.to_atoms(&host.chosen_colors);
1285            if atoms.is_empty() {
1286                continue;
1287            }
1288            let src_mask = atoms.iter().fold(0, |mask, atom| mask | *atom);
1289            for atom in atoms {
1290                for _ in 0..amount {
1291                    available.add(atom, 1);
1292                }
1293            }
1294            for _ in 0..amount {
1295                *source_count += 1;
1296                source_colors.push(src_mask);
1297            }
1298        }
1299    }
1300}
1301
1302fn calculate_available_mana_excluding_with_reserved_impl(
1303    pool: &ManaPool,
1304    game: &GameState,
1305    player: PlayerId,
1306    excluded_source: Option<CardId>,
1307    reserved_sacrifices: &[CardId],
1308    include_hand_sources: bool,
1309    payment_ctx: Option<&ManaPaymentContext>,
1310) -> ManaPool {
1311    let mut available = pool.clone();
1312    let battlefield = game.cards_in_zone(ZoneType::Battlefield, player);
1313    let hand_cards = if include_hand_sources {
1314        game.cards_in_zone(ZoneType::Hand, player).to_vec()
1315    } else {
1316        Vec::new()
1317    };
1318
1319    // Track actual number of mana sources (each can produce exactly 1 mana)
1320    let mut source_count: i32 = 0;
1321
1322    // Per-source color bitmasks for source-level matching in can_pay.
1323    // Start with floating mana from the existing pool.
1324    let mut source_colors: Vec<u16> = Vec::new();
1325    for _ in 0..pool.white() {
1326        source_colors.push(ManaAtom::WHITE);
1327    }
1328    for _ in 0..pool.blue() {
1329        source_colors.push(ManaAtom::BLUE);
1330    }
1331    for _ in 0..pool.black() {
1332        source_colors.push(ManaAtom::BLACK);
1333    }
1334    for _ in 0..pool.red() {
1335        source_colors.push(ManaAtom::RED);
1336    }
1337    for _ in 0..pool.green() {
1338        source_colors.push(ManaAtom::GREEN);
1339    }
1340    // colorless can only pay generic
1341    source_colors.extend(std::iter::repeat_n(0, pool.colorless() as usize));
1342
1343    // Helper: add mana to availability pool, marking as snow if source is snow.
1344    macro_rules! avail_add {
1345        ($avail:expr, $is_snow:expr, $atom:expr) => {
1346            if $is_snow {
1347                $avail.add_snow($atom, 1);
1348            } else {
1349                $avail.add($atom, 1);
1350            }
1351        };
1352    }
1353
1354    for card_id in battlefield.iter().copied().chain(hand_cards.into_iter()) {
1355        if excluded_source == Some(card_id) {
1356            continue;
1357        }
1358        let card = game.card(card_id);
1359        let is_tapped = card.tapped;
1360        let card_is_snow = card.type_line.is_snow();
1361
1362        // Summoning-sick creatures cannot activate {T} abilities (including mana).
1363        // Must match Java's ComputerUtilMana.canPayManaCost() behavior so
1364        // castability probes agree with actual payment and neither engine wastes RNG
1365        // on uncastable spells.
1366        let summoning_sick = card.is_creature() && card.summoning_sick && !card.has_haste();
1367        if summoning_sick {
1368            let all_need_tap = card
1369                .activated_abilities
1370                .iter()
1371                .filter(|ab| ab.is_mana_ability)
1372                .all(|ab| ab.cost.parts.iter().any(|p| matches!(p, CostPart::Tap)));
1373            if all_need_tap {
1374                continue;
1375            }
1376        }
1377
1378        // Check for mana abilities on this permanent.
1379        // If the card is tapped or summoning-sick, only include mana abilities that
1380        // don't require tapping (e.g. Rasputin Dreamweaver's "Remove a dream counter:
1381        // Add {C}"). This matches Java's ComputerUtilMana which checks individual
1382        // ability playability rather than skipping tapped cards entirely.
1383        let mana_abilities: Vec<_> = card
1384            .activated_abilities
1385            .iter()
1386            .filter(|ab| {
1387                ab.is_mana_ability
1388                    && match card.zone {
1389                        ZoneType::Battlefield => ab.activation_zone != Some(ZoneType::Hand),
1390                        ZoneType::Hand => ab.activation_zone == Some(ZoneType::Hand),
1391                        _ => false,
1392                    }
1393                    && !ab.cost.parts.iter().any(|p| matches!(p, CostPart::Mana { .. }))
1394                    && (!is_tapped || !ab.cost.parts.iter().any(|p| matches!(p, CostPart::Tap)))
1395                    && (!summoning_sick
1396                        || !ab.cost.parts.iter().any(|p| matches!(p, CostPart::Tap)))
1397                    // Mirror Java ComputerUtilMana playability checks:
1398                    // only count mana abilities whose non-mana costs are currently payable
1399                    // (e.g. Gilded Goose needs a Food to produce mana).
1400                    && crate::cost::can_pay_ignoring_mana(&ab.cost, game, card_id, player)
1401                    && crate::game_loop::GameLoop::mana_ability_available_for_payment_with_reserved(
1402                        game,
1403                        player,
1404                        card_id,
1405                        ab,
1406                        reserved_sacrifices,
1407                    )
1408                    // If a payment context is provided, filter out mana
1409                    // abilities whose RestrictValid$ cannot be satisfied.
1410                    // Substitute "ChosenType" with the source card's chosen
1411                    // type (e.g. Unclaimed Territory).
1412                    && {
1413                        if let Some(ctx) = payment_ctx {
1414                            match ab.restrict_valid.as_deref() {
1415                                Some(raw) => {
1416                                    let resolved = if raw.contains("ChosenType") {
1417                                        let chosen = card
1418                                            .chosen_type
1419                                            .clone()
1420                                            .unwrap_or_default();
1421                                        raw.replace("ChosenType", &chosen)
1422                                    } else {
1423                                        raw.to_string()
1424                                    };
1425                                    mana_meets_restriction(&resolved, ctx)
1426                                }
1427                                None => true,
1428                            }
1429                        } else {
1430                            true
1431                        }
1432                    }
1433            })
1434            .collect();
1435
1436        if mana_abilities.is_empty() {
1437            // Fallback for lands without explicit parsed mana abilities.
1438            // This handles non-basic lands with basic land subtypes (e.g. Breeding Pool
1439            // typed "Land Forest Island" — produces G or U from subtype, not AB$ Mana).
1440            // Also handles basic lands from the Forge CLI or other sources.
1441            // Tapped lands can't produce mana (implicit {T} cost), so skip them.
1442            if card.zone == ZoneType::Battlefield && card.is_land() && !is_tapped {
1443                let subtype_atoms = all_basic_subtype_atoms(card);
1444                if !subtype_atoms.is_empty() {
1445                    let mut src_mask: u16 = 0;
1446                    let mut source_units = 0usize;
1447                    for atom in subtype_atoms {
1448                        let adjusted_atoms = replacement_adjusted_atoms_for_availability(
1449                            game, player, card_id, atom,
1450                        );
1451                        source_units = source_units.max(adjusted_atoms.len());
1452                        for adjusted_atom in adjusted_atoms {
1453                            avail_add!(available, card_is_snow, adjusted_atom);
1454                            src_mask |= adjusted_atom;
1455                        }
1456                    }
1457                    for _ in 0..source_units.max(1) {
1458                        source_count += 1;
1459                        source_colors.push(src_mask);
1460                    }
1461                    add_taps_for_mana_trigger_mana_for_availability(
1462                        &mut available,
1463                        &mut source_count,
1464                        &mut source_colors,
1465                        game,
1466                        player,
1467                        card_id,
1468                        &atoms_mask_to_letters(src_mask),
1469                    );
1470                } else if let Some(atom) = basic_land_mana_atom(card) {
1471                    let adjusted_atoms =
1472                        replacement_adjusted_atoms_for_availability(game, player, card_id, atom);
1473                    let mut src_mask: u16 = 0;
1474                    for adjusted_atom in &adjusted_atoms {
1475                        avail_add!(available, card_is_snow, *adjusted_atom);
1476                        src_mask |= *adjusted_atom;
1477                    }
1478                    for _ in 0..adjusted_atoms.len().max(1) {
1479                        source_count += 1;
1480                        source_colors.push(src_mask);
1481                    }
1482                    add_taps_for_mana_trigger_mana_for_availability(
1483                        &mut available,
1484                        &mut source_count,
1485                        &mut source_colors,
1486                        game,
1487                        player,
1488                        card_id,
1489                        &atoms_mask_to_letters(src_mask),
1490                    );
1491                }
1492            }
1493            continue;
1494        }
1495
1496        // Add 1 mana for each distinct color this source can produce (optimistic for colors).
1497        // The total_sources cap ensures the total mana count stays correct.
1498        let mut added_any = false;
1499        let mut counted_fixed_output = false;
1500        let mut added_atoms: Vec<u16> = Vec::new();
1501        let mut src_mask: u16 = 0;
1502        for ab in &mana_abilities {
1503            // ManaReflected: check what colors other permanents can produce.
1504            // For playability purposes, optimistically add all colors that
1505            // matching permanents could produce.
1506            if ab.is_mana_reflected {
1507                let reflected_atoms = reflected_atoms_for_availability(game, player, card_id, ab);
1508                // Resolve Amount parameter (e.g. Incubation Druid produces 3 when adapted).
1509                let amount = resolve_mana_ability_amount(game, card_id, player, ab);
1510                for &atom in &reflected_atoms {
1511                    if !added_atoms.contains(&atom) {
1512                        for _ in 0..amount {
1513                            avail_add!(available, card_is_snow, atom);
1514                        }
1515                        added_atoms.push(atom);
1516                        src_mask |= atom;
1517                        added_any = true;
1518                    }
1519                }
1520                // ManaReflected with Amount > 1 produces multiple mana per activation.
1521                // Account for this in source_count so can_pay_source_matching knows
1522                // this source can satisfy multiple shard requirements.
1523                if amount > 1 && !reflected_atoms.is_empty() {
1524                    // We'll add (amount - 1) extra source entries later when we push.
1525                    // Store in a local variable to use below.
1526                    // (We add 1 normally, plus (amount-1) extras.)
1527                    for _ in 0..(amount - 1) {
1528                        source_count += 1;
1529                        source_colors.push(src_mask);
1530                    }
1531                }
1532            } else if let Some(produced_ir) = ab.produced_ir.as_ref() {
1533                if produced_ir.is_combo_color_identity() {
1534                    // Commander Color Identity support: in non-commander games this remains empty.
1535                    let colors = game.player_commander_color_identity(player);
1536                    if !colors.is_empty() {
1537                        for atom in chosen_colors_to_atoms(&colors) {
1538                            if !added_atoms.contains(&atom) {
1539                                avail_add!(available, card_is_snow, atom);
1540                                added_atoms.push(atom);
1541                                src_mask |= atom;
1542                            }
1543                        }
1544                        added_any = true;
1545                    }
1546                } else if let Some(special) =
1547                    ab.produced_ir.as_ref().and_then(ProducedMana::special_kind)
1548                {
1549                    // Special mana (e.g. Bloom Tender's "EachColorAmong_Valid Permanent.YouCtrl"):
1550                    // one mana per distinct color among matching permanents. Both colors and
1551                    // amount-per-activation come from the same atom set.
1552                    let special_atoms =
1553                        crate::ability::effects::mana_effect::available_special_mana_atoms(
1554                            game, card_id, player, special,
1555                        );
1556                    if !special_atoms.is_empty() {
1557                        for &atom in &special_atoms {
1558                            if !added_atoms.contains(&atom) {
1559                                let adjusted_atoms = replacement_adjusted_atoms_for_availability(
1560                                    game, player, card_id, atom,
1561                                );
1562                                for adjusted_atom in adjusted_atoms {
1563                                    avail_add!(available, card_is_snow, adjusted_atom);
1564                                    src_mask |= adjusted_atom;
1565                                    source_count += 1;
1566                                    source_colors.push(adjusted_atom);
1567                                }
1568                                added_atoms.push(atom);
1569                            }
1570                        }
1571                        added_any = true;
1572                        // Special branch already pushed one source entry per
1573                        // produced atom (Bloom Tender: G, U, R). Mark fixed
1574                        // output so the outer `if added_any` at the end of
1575                        // the loop doesn't push an additional combined-mask
1576                        // source (which would double-count the activation
1577                        // and let a single Bloom Tender pay multiple same-
1578                        // color requirements).
1579                        counted_fixed_output = true;
1580                    }
1581                } else {
1582                    let amount = resolve_mana_ability_amount(game, card_id, player, ab);
1583                    let mut counted_variable_source_units = false;
1584                    if let Some(fixed_atoms) = produced_ir.fixed_atoms() {
1585                        for atom in fixed_atoms {
1586                            for _ in 0..amount {
1587                                let adjusted_atoms = replacement_adjusted_atoms_for_availability(
1588                                    game, player, card_id, atom,
1589                                );
1590                                for adjusted_atom in adjusted_atoms {
1591                                    avail_add!(available, card_is_snow, adjusted_atom);
1592                                    src_mask |= adjusted_atom;
1593                                    source_count += 1;
1594                                    source_colors.push(adjusted_atom);
1595                                }
1596                            }
1597                            added_any = true;
1598                            counted_fixed_output = true;
1599                        }
1600                    } else {
1601                        let mut source_units = 0usize;
1602                        let intrinsic = produced_ir.to_atoms(&card.chosen_colors);
1603                        let allowed = java_replacement_filtered_atoms_for_availability(
1604                            game, player, card_id, ab, &intrinsic,
1605                        );
1606                        for atom in allowed {
1607                            if !added_atoms.contains(&atom) {
1608                                for _ in 0..amount {
1609                                    let adjusted_atoms =
1610                                        replacement_adjusted_atoms_for_availability(
1611                                            game, player, card_id, atom,
1612                                        );
1613                                    source_units =
1614                                        source_units.max(adjusted_atoms.len() * amount as usize);
1615                                    for adjusted_atom in adjusted_atoms {
1616                                        avail_add!(available, card_is_snow, adjusted_atom);
1617                                        src_mask |= adjusted_atom;
1618                                    }
1619                                }
1620                                added_atoms.push(atom);
1621                                added_any = true;
1622                            }
1623                        }
1624                        if source_units > 1 && added_any {
1625                            // Multi-mana ability slot pushing. There are two
1626                            // distinct cases for `source_units > 1`:
1627                            //
1628                            // (A) Replacement multiplier (Mana Reflection
1629                            //     tripling Rootbound Crag's `Combo R G | Amount$
1630                            //     1`). The original ability picks ONE color at
1631                            //     activation; the replacement multiplies *that*
1632                            //     color, so the 3 mana are all the same colour.
1633                            //     The extra slots must be COLORLESS to prevent
1634                            //     the matcher from satisfying multiple distinct
1635                            //     coloured shards from one activation.
1636                            //
1637                            // (B) Intrinsic `Amount$ N` (Leyline Immersion's
1638                            //     `Combo Any | Amount$ 5`). Each of the N mana
1639                            //     can be a different colour, so every slot
1640                            //     keeps the full `src_mask` and can satisfy
1641                            //     any single-colour shard.
1642                            let intrinsic_amount = amount > 1;
1643                            let colored_bits = (src_mask
1644                                & (ManaAtom::WHITE
1645                                    | ManaAtom::BLUE
1646                                    | ManaAtom::BLACK
1647                                    | ManaAtom::RED
1648                                    | ManaAtom::GREEN))
1649                                .count_ones();
1650                            let extra_mask = if intrinsic_amount {
1651                                src_mask
1652                            } else if colored_bits > 1 {
1653                                ManaAtom::COLORLESS
1654                            } else {
1655                                src_mask
1656                            };
1657                            for _ in 0..(source_units as i32 - 1) {
1658                                source_count += 1;
1659                                source_colors.push(extra_mask);
1660                            }
1661                            counted_variable_source_units = true;
1662                        }
1663                    }
1664                    // Amount > 1 (e.g. Sol Ring: Amount$ 2) — one activation produces
1665                    // multiple mana, so push extra source entries so
1666                    // can_pay_source_matching's source-count budget matches the real
1667                    // mana count and this source can satisfy multiple generic shards.
1668                    if amount > 1
1669                        && added_any
1670                        && !counted_fixed_output
1671                        && !counted_variable_source_units
1672                    {
1673                        for _ in 0..(amount - 1) {
1674                            source_count += 1;
1675                            source_colors.push(src_mask);
1676                        }
1677                    }
1678                }
1679            }
1680        }
1681        if !added_any && card.is_land() {
1682            // Safety net: land has mana abilities but none produced a recognized atom.
1683            // For multi-subtype lands (e.g. Breeding Pool = Forest + Island → G + U),
1684            // add ALL matching atoms optimistically. The total_sources cap prevents
1685            // double-counting (1 land activation = 1 mana, regardless of color options).
1686            let subtype_atoms = all_basic_subtype_atoms(card);
1687            if !subtype_atoms.is_empty() {
1688                for atom in subtype_atoms {
1689                    if !added_atoms.contains(&atom) {
1690                        avail_add!(available, card_is_snow, atom);
1691                        added_atoms.push(atom);
1692                        src_mask |= atom;
1693                        added_any = true;
1694                    }
1695                }
1696            } else if let Some(atom) = basic_land_mana_atom(card) {
1697                // Name-based fallback for basic lands named "Forest" etc.
1698                avail_add!(available, card_is_snow, atom);
1699                src_mask |= atom;
1700                added_any = true;
1701            }
1702        }
1703        if added_any && !counted_fixed_output {
1704            // Each productive source contributes exactly 1 activation (tap = 1 mana)
1705            source_count += 1;
1706            source_colors.push(src_mask);
1707        }
1708        if added_any {
1709            let combined_mask = added_atoms.iter().fold(src_mask, |mask, atom| mask | atom);
1710            add_taps_for_mana_trigger_mana_for_availability(
1711                &mut available,
1712                &mut source_count,
1713                &mut source_colors,
1714                game,
1715                player,
1716                card_id,
1717                &atoms_mask_to_letters(combined_mask),
1718            );
1719        }
1720    }
1721
1722    // Count extra mana from aura enchantments with TapsForMana triggers
1723    // (e.g. Utopia Sprawl, Wild Growth). When an untapped land has an attached
1724    // aura that produces mana on tap, that extra mana should be counted in
1725    // the playability check.
1726    for &card_id in battlefield {
1727        let card = game.card(card_id);
1728        if card.tapped || card.attachments.is_empty() {
1729            continue;
1730        }
1731        for &aura_id in &card.attachments {
1732            if aura_id.index() >= game.cards.len() {
1733                continue;
1734            }
1735            let aura = game.card(aura_id);
1736            if aura.zone != ZoneType::Battlefield {
1737                continue;
1738            }
1739            for trigger in &aura.triggers {
1740                if trigger.kind == crate::trigger::TriggerType::TapsForMana {
1741                    // This aura produces extra mana when the host is tapped.
1742                    // Determine what color from the Execute$ SVar.
1743                    if let Some(svar_text) = aura.svars.get(&trigger.execute) {
1744                        let params = crate::parsing::Params::from_raw(svar_text);
1745                        if let Some(produced) = params.get(crate::parsing::keys::PRODUCED) {
1746                            let produced_ir = ProducedMana::from_raw_boundary(produced);
1747                            let atoms = if matches!(produced_ir, ProducedMana::Chosen) {
1748                                // Use aura's chosen color
1749                                aura.chosen_colors
1750                                    .first()
1751                                    .and_then(|c| color_name_to_mana_atom(c))
1752                                    .into_iter()
1753                                    .collect::<Vec<_>>()
1754                            } else {
1755                                produced_ir.to_atoms(&aura.chosen_colors)
1756                            };
1757                            for atom in atoms {
1758                                available.add(atom, 1);
1759                                source_count += 1;
1760                                source_colors.push(atom);
1761                            }
1762                        }
1763                    }
1764                }
1765            }
1766        }
1767    }
1768
1769    // Set total_sources so can_pay enforces the real total mana cap
1770    available.total_sources = Some(pool.total_mana() + source_count);
1771    available.source_colors = Some(source_colors);
1772
1773    available
1774}
1775
1776/// Resolve the Amount parameter of a mana ability for availability checks.
1777/// Returns how many mana the ability produces per activation (default 1).
1778/// Handles SVar references like `Amount$ IncubationAmount` where the SVar
1779/// resolves to a Count$Compare expression.
1780pub(crate) fn resolve_mana_ability_amount(
1781    game: &GameState,
1782    card_id: CardId,
1783    player: PlayerId,
1784    ab: &crate::ability::activated::ActivatedAbility,
1785) -> i32 {
1786    let amount_str = match ab.amount.as_deref() {
1787        Some(v) if !v.is_empty() => v,
1788        _ => return 1,
1789    };
1790    // Direct number
1791    if let Ok(n) = amount_str.trim().parse::<i32>() {
1792        return n.max(1);
1793    }
1794    // SVar reference: look up in card's svars and resolve
1795    let card = game.card(card_id);
1796    if let Some(svar_expr) = card.svars.get(amount_str.trim()) {
1797        if svar_expr.starts_with("Count$") {
1798            return crate::ability::effects::resolve_count_svar(svar_expr, game, card_id, player)
1799                .max(1);
1800        }
1801        if let Ok(n) = svar_expr.trim().parse::<i32>() {
1802            return n.max(1);
1803        }
1804    }
1805    1
1806}
1807
1808pub(crate) fn mana_ability_prompt_metadata(
1809    game: &GameState,
1810    card_id: CardId,
1811    player: PlayerId,
1812    ab: &crate::ability::activated::ActivatedAbility,
1813) -> (Option<String>, Option<i32>) {
1814    let produced_mana = ab.produced_ir.as_ref().map(|produced_ir| {
1815        let chosen_colors = &game.card(card_id).chosen_colors;
1816        let atoms = produced_ir.to_atoms(chosen_colors);
1817        if atoms.is_empty() {
1818            produced_ir.as_script_text().into_owned()
1819        } else if matches!(produced_ir, ProducedMana::Chosen) {
1820            atoms
1821                .into_iter()
1822                .map(|atom| ManaPool::atom_to_letter(atom).to_string())
1823                .collect::<Vec<_>>()
1824                .join(" ")
1825        } else if matches!(produced_ir, ProducedMana::Combo(ProducedManaCombo::Chosen)) {
1826            format!(
1827                "Combo {}",
1828                atoms
1829                    .into_iter()
1830                    .map(|atom| ManaPool::atom_to_letter(atom).to_string())
1831                    .collect::<Vec<_>>()
1832                    .join(" ")
1833            )
1834        } else {
1835            produced_ir.as_script_text().into_owned()
1836        }
1837    });
1838    let produced_mana_amount = Some(resolve_mana_ability_amount(game, card_id, player, ab));
1839    (produced_mana, produced_mana_amount)
1840}
1841
1842#[cfg(test)]
1843mod tests {
1844    use super::*;
1845    use crate::agent::{PassAgent, PlayerAgent};
1846    use crate::card::Card;
1847    use crate::game::GameState;
1848    use crate::ids::{CardId, PlayerId};
1849    use forge_foundation::ManaCost;
1850    use forge_foundation::{CardTypeLine, ColorSet, ZoneType};
1851
1852    #[test]
1853    fn basic_land_detection() {
1854        use crate::card::Card;
1855        use crate::ids::{CardId, PlayerId};
1856        use forge_foundation::ColorSet;
1857
1858        let card = Card::new(
1859            CardId(0),
1860            "Mountain".to_string(),
1861            PlayerId(0),
1862            forge_foundation::CardTypeLine::parse("Basic Land - Mountain"),
1863            ManaCost::no_cost(),
1864            ColorSet::COLORLESS,
1865            None,
1866            None,
1867            vec![],
1868            vec![],
1869        );
1870        assert_eq!(basic_land_mana_atom(&card), Some(ManaAtom::RED));
1871    }
1872
1873    #[test]
1874    fn mana_atom_from_produced_test() {
1875        assert_eq!(mana_atom_from_produced("W"), Some(ManaAtom::WHITE));
1876        assert_eq!(mana_atom_from_produced("U"), Some(ManaAtom::BLUE));
1877        assert_eq!(mana_atom_from_produced("B"), Some(ManaAtom::BLACK));
1878        assert_eq!(mana_atom_from_produced("R"), Some(ManaAtom::RED));
1879        assert_eq!(mana_atom_from_produced("G"), Some(ManaAtom::GREEN));
1880        assert_eq!(mana_atom_from_produced("C"), Some(ManaAtom::COLORLESS));
1881        assert_eq!(mana_atom_from_produced("X"), None);
1882    }
1883
1884    #[test]
1885    fn produced_to_atoms_any_and_combo_any() {
1886        let any = ProducedMana::Any.to_atoms(&[]);
1887        assert!(any.contains(&ManaAtom::WHITE));
1888        assert!(any.contains(&ManaAtom::BLUE));
1889        assert!(any.contains(&ManaAtom::BLACK));
1890        assert!(any.contains(&ManaAtom::RED));
1891        assert!(any.contains(&ManaAtom::GREEN));
1892        assert!(!any.contains(&ManaAtom::COLORLESS));
1893
1894        let combo_any = ProducedMana::Combo(ProducedManaCombo::Any).to_atoms(&[]);
1895        assert_eq!(any.len(), combo_any.len());
1896        for a in any {
1897            assert!(combo_any.contains(&a));
1898        }
1899    }
1900
1901    #[test]
1902    fn produced_to_atoms_chosen_and_combo_chosen() {
1903        let chosen = vec!["Red".to_string(), "Green".to_string()];
1904        let a = ProducedMana::Chosen.to_atoms(&chosen);
1905        assert!(a.contains(&ManaAtom::RED));
1906        assert!(a.contains(&ManaAtom::GREEN));
1907        assert_eq!(a.len(), 2);
1908
1909        let b = ProducedMana::Combo(ProducedManaCombo::Chosen).to_atoms(&chosen);
1910        assert!(b.contains(&ManaAtom::RED));
1911        assert!(b.contains(&ManaAtom::GREEN));
1912        assert_eq!(b.len(), 2);
1913    }
1914
1915    #[test]
1916    fn produced_to_atoms_multi_token_fixed_output() {
1917        let atoms = ProducedMana::Fixed(vec!["C".to_string(), "C".to_string()]).to_atoms(&[]);
1918        assert_eq!(atoms, vec![ManaAtom::COLORLESS]);
1919    }
1920
1921    #[test]
1922    fn pay_simple_cost() {
1923        let mut pool = ManaPool::new();
1924        pool.add(ManaAtom::RED, 1);
1925
1926        let cost = ManaCost::parse("R");
1927        assert!(pool.can_pay(&cost));
1928        assert!(pool.try_pay(&cost));
1929        assert_eq!(pool.red(), 0);
1930    }
1931
1932    #[test]
1933    fn pay_generic_and_colored() {
1934        let mut pool = ManaPool::new();
1935        pool.add(ManaAtom::GREEN, 2);
1936
1937        let cost = ManaCost::parse("1 G");
1938        assert!(pool.can_pay(&cost));
1939        assert!(pool.try_pay(&cost));
1940        assert_eq!(pool.green(), 0); // 1 for G, 1 for generic
1941    }
1942
1943    #[test]
1944    fn insufficient_mana() {
1945        let mut pool = ManaPool::new();
1946        pool.add(ManaAtom::RED, 1);
1947
1948        let cost = ManaCost::parse("1 R R");
1949        assert!(!pool.can_pay(&cost));
1950    }
1951
1952    #[test]
1953    fn empty_pool() {
1954        let mut pool = ManaPool::new();
1955        pool.add(ManaAtom::WHITE, 3);
1956        pool.reset_pool();
1957        assert_eq!(pool.total_mana(), 0);
1958    }
1959
1960    #[test]
1961    fn combo_color_identity_uses_registered_commander_outside_command_zone() {
1962        let mut game = GameState::new(&["P1", "P2"], 20);
1963        let p0 = PlayerId(0);
1964
1965        let commander = Card::new(
1966            CardId(0),
1967            "Commander".to_string(),
1968            p0,
1969            CardTypeLine::parse("Legendary Creature Wizard"),
1970            ManaCost::parse("2 U"),
1971            ColorSet::BLUE,
1972            Some(2),
1973            Some(2),
1974            vec![],
1975            vec![],
1976        );
1977        let commander_id = game.create_card(commander);
1978        game.player_register_commander(p0, commander_id);
1979        game.player_create_commander_effect(p0, None);
1980        game.move_card(commander_id, ZoneType::Battlefield, p0);
1981
1982        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(PassAgent), Box::new(PassAgent)];
1983        let produced = determine_mana_production_ir(
1984            &mut game,
1985            &mut agents,
1986            p0,
1987            commander_id,
1988            &ProducedMana::Combo(ProducedManaCombo::ColorIdentity),
1989            "Combo ColorIdentity",
1990            None,
1991            None,
1992        );
1993
1994        assert_eq!(produced.as_deref(), Some("U"));
1995    }
1996
1997    #[test]
1998    fn restricted_mana_can_use_source_chosen_type_for_creature_spells() {
1999        let mut pool = ManaPool::new();
2000        let source = CardId(7);
2001        let mut mana = Mana::simple(ManaAtom::BLACK);
2002        mana.source_card = Some(source);
2003        mana.restriction = Some("Spell.Creature+ChosenType".to_string());
2004        pool.add_mana(mana);
2005
2006        let mut chosen_types_by_source = std::collections::HashMap::new();
2007        chosen_types_by_source.insert(source, "Assassin".to_string());
2008
2009        let ctx = ManaPaymentContext {
2010            is_spell: true,
2011            is_activated_ability: false,
2012            sa_on_stack: false,
2013            type_line: Some(CardTypeLine::parse("Creature Zombie Assassin")),
2014            card_name: Some("Unstoppable Slasher".to_string()),
2015            card_color: None,
2016            chosen_types_by_source,
2017        };
2018
2019        assert!(pool.can_pay_for_spell(&ManaCost::parse("B"), &ctx));
2020        assert!(pool.try_pay_for_spell(&ManaCost::parse("B"), &ctx));
2021        assert_eq!(pool.total_mana(), 0);
2022    }
2023}