Skip to main content

manabrew_engine/ability/effects/
mana_effect.rs

1use forge_foundation::mana::ManaAtom;
2
3use super::{mana_atom_from_produced, EffectContext};
4use crate::spellability::SpellAbility;
5
6/// Build/configure the spell ability after construction.
7/// Mirrors Java's `ManaEffect.buildSpellAbility(SpellAbility)`.
8///
9/// For mana effects, marks the ability as a mana ability if it doesn't
10/// use targeting.
11pub fn build_spell_ability(sa: &mut crate::spellability::SpellAbility) {
12    // Mana abilities that don't target are mana abilities (can be activated
13    // any time you need mana, don't use the stack).
14    if !sa.uses_targeting() {
15        sa.is_mana_ability = true;
16    }
17}
18
19/// Handle special mana production types.
20/// Mirrors Java's `ManaEffect.handleSpecialMana(SpellAbility, String)`.
21///
22/// This is a public wrapper around `resolve_special_mana` for structural parity.
23pub fn handle_special_mana(
24    ctx: &mut EffectContext,
25    sa: &crate::spellability::SpellAbility,
26    source_id: crate::ids::CardId,
27    player: crate::ids::PlayerId,
28    special: &str,
29) -> Vec<String> {
30    resolve_special_mana(ctx, sa, source_id, player, special)
31}
32
33/// Resolve DB$ Mana — produce mana as a sub-ability effect.
34/// Mirrors Java's ManaEffect.java for the stack-based resolution path.
35/// Struct form of this effect so it can participate in the
36/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
37/// `ManaEffect` class extending `SpellAbilityEffect`.
38#[manabrew_engine_macros::spell_effect(ManaEffect)]
39fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
40    let player = sa.activating_player;
41    let source_id = match sa.source {
42        Some(id) => id,
43        None => return,
44    };
45
46    let produced_ir = match sa.produced_ir() {
47        Some(ir) => ir,
48        None => return,
49    };
50    let produced = produced_ir.as_script_text();
51
52    // `Optional$` — activator confirms before producing (Java ManaEffect
53    // optional-prompt branch).
54    if sa.ir.optional {
55        let _card_name = ctx.game.card(source_id).card_name.clone();
56        if !ctx.agents[player.index()].confirm_action(
57            player,
58            Some("ProduceMana"),
59            "Produce mana?",
60            &[],
61            Some(source_id),
62            sa.api,
63        ) {
64            return;
65        }
66    }
67
68    // `Chooser$` — delegate the color / combo choice to a specific player
69    // (e.g. Mirari's Wake: owner chooses mana type but opponent votes).
70    let chooser = sa
71        .chooser()
72        .and_then(|d| super::resolve_defined_player(d, player, ctx.game))
73        .unwrap_or(player);
74
75    // Read metadata params from the ability. Substitute "ChosenType" with
76    // the source card's chosen type so the restriction stored on the Mana
77    // can be checked without needing the source card later.
78    let restriction = sa.ir.restrict_valid.clone().map(|r| {
79        if r.contains("ChosenType") {
80            let chosen = ctx
81                .game
82                .card(source_id)
83                .chosen_type
84                .clone()
85                .unwrap_or_default();
86            r.replace("ChosenType", &chosen)
87        } else {
88            r
89        }
90    });
91    let adds_no_counter = sa.ir.adds_no_counter;
92    let adds_keywords = sa.ir.adds_keywords.clone();
93    let adds_keywords_valid = sa.ir.adds_keywords_valid.clone();
94    let adds_counters = sa.ir.adds_counters.clone();
95    let adds_counters_valid = sa.ir.adds_counters_valid.clone();
96    let triggers_when_spent = sa.ir.triggers_when_spent.clone();
97    let Some(produced_ir) = sa.produced_ir().cloned() else {
98        return;
99    };
100
101    // Handle Special mana production types (mirrors Java handleSpecialMana)
102    if let Some(special) = produced_ir.special_kind() {
103        let mana_tokens = resolve_special_mana(ctx, sa, source_id, player, special);
104        if mana_tokens.is_empty() {
105            return;
106        }
107        let source_is_snow = ctx.game.card(source_id).type_line.is_snow();
108        for tok in &mana_tokens {
109            if let Some(atom) = mana_atom_from_produced(tok) {
110                let mut m = crate::mana::Mana::simple(atom);
111                m.source_card = Some(source_id);
112                m.is_snow = source_is_snow;
113                m.restriction = restriction.clone();
114                m.adds_no_counter = adds_no_counter;
115                m.adds_keywords = adds_keywords.clone();
116                m.adds_keywords_valid = adds_keywords_valid.clone();
117                m.adds_counters = adds_counters.clone();
118                m.adds_counters_valid = adds_counters_valid.clone();
119                m.triggers_when_spent = triggers_when_spent.clone();
120                ctx.mana_pools[player.index()].add_mana(m);
121            }
122        }
123        return;
124    }
125
126    // Determine mana string to produce
127    let mana_string: Option<String> = if produced_ir.is_any_like() {
128        // "Any" — all 5 colors available
129        let available = [
130            "W".to_string(),
131            "U".to_string(),
132            "B".to_string(),
133            "R".to_string(),
134            "G".to_string(),
135        ];
136        Some(available.first().cloned().unwrap_or("W".to_string()))
137    } else if produced_ir.is_choice_like() {
138        // Combo or comma-separated choices — normalize to color letters
139        let options: Vec<&str> = if let Some(rest) = produced.strip_prefix("Combo ") {
140            rest.split_whitespace().collect()
141        } else {
142            produced.split(',').map(|s| s.trim()).collect()
143        };
144        let available: Vec<String> = options
145            .iter()
146            .filter_map(|name| {
147                let lower = name.to_lowercase();
148                match lower.as_str() {
149                    "white" | "w" => Some("W".to_string()),
150                    "blue" | "u" => Some("U".to_string()),
151                    "black" | "b" => Some("B".to_string()),
152                    "red" | "r" => Some("R".to_string()),
153                    "green" | "g" => Some("G".to_string()),
154                    "colorless" | "c" => Some("C".to_string()),
155                    _ => mana_atom_from_produced(name).map(|_| name.to_string()),
156                }
157            })
158            .collect();
159        available
160            .first()
161            .cloned()
162            .or_else(|| Some(produced.to_string()))
163    } else if produced == "Chosen" {
164        // Use card's chosen color
165        let chosen = ctx
166            .game
167            .card(source_id)
168            .chosen_colors
169            .first()
170            .cloned()
171            .unwrap_or_else(|| "White".to_string());
172        let lower = chosen.to_lowercase();
173        match lower.as_str() {
174            "white" => Some("W".to_string()),
175            "blue" => Some("U".to_string()),
176            "black" => Some("B".to_string()),
177            "red" => Some("R".to_string()),
178            "green" => Some("G".to_string()),
179            _ => Some("C".to_string()),
180        }
181    } else {
182        // Raw produced string (e.g. "W", "B B", "C")
183        Some(produced.to_string())
184    };
185
186    // Apply Amount$ multiplier, with specifyManaCombo for multi-amount any/combo mana
187    let mut final_mana = match mana_string {
188        Some(ms) => ms,
189        None => return,
190    };
191    let amount = super::resolve_numeric_svar(ctx.game, sa, "Amount", 1);
192    if amount <= 0 {
193        return;
194    }
195    let is_combo = produced_ir.is_choice_like();
196    if amount > 1 && is_combo {
197        // Multi-amount combo: let agent choose color distribution.
198        // Java's `ManaEffect` calls `chooseColor` once per output unit, then
199        // emits a `specifyManaCombo` summary. Mirror that here so the parity
200        // trace and the per-mana RNG draws line up.
201        let available: Vec<String> = if produced_ir.is_any_like() {
202            vec!["W", "U", "B", "R", "G"]
203                .into_iter()
204                .map(String::from)
205                .collect()
206        } else {
207            let options: Vec<&str> = if let Some(rest) = produced.strip_prefix("Combo ") {
208                rest.split_whitespace().collect()
209            } else {
210                produced.split(',').map(|s| s.trim()).collect()
211            };
212            options
213                .iter()
214                .filter_map(|name| {
215                    let lower = name.to_lowercase();
216                    match lower.as_str() {
217                        "white" | "w" => Some("W".to_string()),
218                        "blue" | "u" => Some("U".to_string()),
219                        "black" | "b" => Some("B".to_string()),
220                        "red" | "r" => Some("R".to_string()),
221                        "green" | "g" => Some("G".to_string()),
222                        "colorless" | "c" => Some("C".to_string()),
223                        _ => None,
224                    }
225                })
226                .collect()
227        };
228        let mut per_unit: Vec<String> = Vec::with_capacity(amount as usize);
229        for _ in 0..amount {
230            let pick = ctx.agents[chooser.index()]
231                .choose_color(chooser, &available)
232                .or_else(|| available.first().cloned())
233                .unwrap_or_else(|| "C".to_string());
234            per_unit.push(pick);
235        }
236        // Emit a summary `specify_mana_combo` callback so the parity trace
237        // matches Java's harness logging pattern. The agent's pick here is
238        // ignored; the per-unit `choose_color` results above are authoritative.
239        let _card_name = ctx.game.card(source_id).card_name.clone();
240        let _summary = ctx.agents[chooser.index()].specify_mana_combo(
241            chooser,
242            &per_unit,
243            amount as usize,
244            Some(source_id),
245            None,
246        );
247        final_mana = per_unit.join(" ");
248    } else if amount > 1 {
249        let base = final_mana.clone();
250        for _ in 1..amount {
251            final_mana.push(' ');
252            final_mana.push_str(&base);
253        }
254    }
255
256    // Apply ProduceMana replacement effects (mana doublers)
257    {
258        use crate::replacement::replacement_handler::{apply_replacements, ReplacementEvent};
259        let mut event = ReplacementEvent::ProduceMana {
260            source: source_id,
261            activator: player,
262            mana: final_mana.clone(),
263        };
264        let result = apply_replacements(ctx.game, &mut event);
265        if result == crate::replacement::ReplacementResult::Updated {
266            if let ReplacementEvent::ProduceMana { mana: new_mana, .. } = event {
267                final_mana = new_mana;
268            }
269        }
270    }
271
272    // Check if source is snow
273    let source_is_snow = ctx.game.card(source_id).type_line.is_snow();
274
275    // Add the mana to the pool with full metadata
276    for tok in final_mana.split_whitespace() {
277        if let Some(atom) = mana_atom_from_produced(tok) {
278            let mut m = crate::mana::Mana::simple(atom);
279            m.source_card = Some(source_id);
280            m.is_snow = source_is_snow;
281            m.restriction = restriction.clone();
282            m.adds_no_counter = adds_no_counter;
283            m.adds_keywords = adds_keywords.clone();
284            m.adds_keywords_valid = adds_keywords_valid.clone();
285            m.adds_counters = adds_counters.clone();
286            m.adds_counters_valid = adds_counters_valid.clone();
287            m.triggers_when_spent = triggers_when_spent.clone();
288            ctx.mana_pools[player.index()].add_mana(m);
289        }
290    }
291}
292
293/// Resolve special mana production types (mirrors Java ManaEffect.handleSpecialMana).
294/// Returns a list of mana letter tokens to produce (e.g. ["W", "U", "B"]).
295pub fn resolve_special_mana(
296    ctx: &mut EffectContext,
297    sa: &SpellAbility,
298    source_id: crate::ids::CardId,
299    player: crate::ids::PlayerId,
300    special: &str,
301) -> Vec<String> {
302    use forge_foundation::ZoneType;
303
304    if special.starts_with("EachColorAmong_Valid") {
305        // EachColorAmong_Valid <ValidFilter> — one mana per unique color among matching permanents
306        let filter = special
307            .strip_prefix("EachColorAmong_Valid ")
308            .unwrap_or("Permanent.YouCtrl");
309        return each_color_among_valid(ctx.game, source_id, player, filter);
310    }
311
312    if special == "EachColorAmong_ExiledWith" {
313        // One mana per unique color among cards exiled by this card
314        let mut colors = Vec::new();
315        for card in ctx.game.cards.iter() {
316            if card.zone != ZoneType::Exile {
317                continue;
318            }
319            // Check if exiled by source (via remembered_cards or exiled_with)
320            if !card.remembered_cards.contains(&source_id) {
321                continue;
322            }
323            for color in card.color.iter() {
324                let letter = color_to_letter(color.long_name());
325                if !letter.is_empty() && !colors.contains(&letter) {
326                    colors.push(letter);
327                }
328            }
329        }
330        return colors;
331    }
332
333    if special.starts_with("EachColoredManaSymbol") {
334        // EachColoredManaSymbol_Milled — one mana per colored symbol in milled card's cost
335        // The milled card should be the last card that entered the graveyard
336        if special.contains("Milled") {
337            let gy = ctx.game.cards_in_zone(ZoneType::Graveyard, player);
338            if let Some(&last_card_id) = gy.last() {
339                let milled = ctx.game.card(last_card_id);
340                let mut tokens = Vec::new();
341                for &shard in milled.mana_cost.shards() {
342                    let atoms = shard.shard();
343                    if (atoms & ManaAtom::WHITE) != 0 {
344                        tokens.push("W".to_string());
345                    }
346                    if (atoms & ManaAtom::BLUE) != 0 {
347                        tokens.push("U".to_string());
348                    }
349                    if (atoms & ManaAtom::BLACK) != 0 {
350                        tokens.push("B".to_string());
351                    }
352                    if (atoms & ManaAtom::RED) != 0 {
353                        tokens.push("R".to_string());
354                    }
355                    if (atoms & ManaAtom::GREEN) != 0 {
356                        tokens.push("G".to_string());
357                    }
358                }
359                return tokens;
360            }
361        }
362        return vec![];
363    }
364
365    if special == "DoubleManaInPool" {
366        // Double the amount of each type of mana in pool
367        let pool = &ctx.mana_pools[player.index()];
368        let mut tokens = Vec::new();
369        for &(atom, letter) in &[
370            (ManaAtom::WHITE, "W"),
371            (ManaAtom::BLUE, "U"),
372            (ManaAtom::BLACK, "B"),
373            (ManaAtom::RED, "R"),
374            (ManaAtom::GREEN, "G"),
375            (ManaAtom::COLORLESS, "C"),
376        ] {
377            let count = pool.count_color(atom);
378            for _ in 0..count {
379                tokens.push(letter.to_string());
380            }
381        }
382        return tokens;
383    }
384
385    if special == "EnchantedManaCost" {
386        // Produce mana matching the enchanted permanent's mana cost colors
387        let source = ctx.game.card(source_id);
388        if let Some(attached_to) = source.attached_to {
389            let enchanted = ctx.game.card(attached_to);
390            let mut tokens = Vec::new();
391            for &shard in enchanted.mana_cost.shards() {
392                let atoms = shard.shard();
393                if (atoms & ManaAtom::WHITE) != 0 {
394                    tokens.push("W".to_string());
395                }
396                if (atoms & ManaAtom::BLUE) != 0 {
397                    tokens.push("U".to_string());
398                }
399                if (atoms & ManaAtom::BLACK) != 0 {
400                    tokens.push("B".to_string());
401                }
402                if (atoms & ManaAtom::RED) != 0 {
403                    tokens.push("R".to_string());
404                }
405                if (atoms & ManaAtom::GREEN) != 0 {
406                    tokens.push("G".to_string());
407                }
408            }
409            // Also add generic as colorless
410            let generic = enchanted.mana_cost.generic_cost();
411            for _ in 0..generic {
412                tokens.push("C".to_string());
413            }
414            return tokens;
415        }
416        return vec![];
417    }
418
419    if special.starts_with("LastNotedType") {
420        // Produce mana of the last noted type (Jeweled Amulet, Ice Cauldron)
421        let source = ctx.game.card(source_id);
422        if let Some(noted) = source.chosen_colors.first() {
423            let letter = color_to_letter(&noted.to_lowercase());
424            if !letter.is_empty() {
425                // Check for Amount$ or default to 1
426                let amount = super::resolve_numeric_svar(ctx.game, sa, "Amount", 1);
427                let mut tokens = Vec::new();
428                for _ in 0..amount {
429                    tokens.push(letter.clone());
430                }
431                return tokens;
432            }
433        }
434        return vec![];
435    }
436
437    vec![]
438}
439
440/// Collect one mana per unique color among permanents matching the valid filter.
441pub fn each_color_among_valid(
442    game: &crate::game::GameState,
443    source_id: crate::ids::CardId,
444    controller: crate::ids::PlayerId,
445    filter: &str,
446) -> Vec<String> {
447    use forge_foundation::ZoneType;
448
449    let mut colors = Vec::new();
450
451    for card in game.cards.iter() {
452        if card.zone != ZoneType::Battlefield {
453            continue;
454        }
455        if !matches_simple_valid(card, filter, source_id, controller) {
456            continue;
457        }
458        for color in card.color.iter() {
459            let letter = color_to_letter(color.long_name());
460            if !letter.is_empty() && !colors.contains(&letter) {
461                colors.push(letter);
462            }
463        }
464    }
465
466    colors
467}
468
469/// Compute the mana atoms a `Produced$ Special <kind>` ability can produce
470/// for availability/playability checks (read-only over `GameState`).
471///
472/// Mirrors Java's `AbilityManaPart.isSpecialMana()` dispatch combined with
473/// `ManaEffect.handleSpecialMana` color enumeration: callers use the returned
474/// atoms both as the unique colors that can be paid and as the per-activation
475/// mana count (e.g. Bloom Tender produces N mana for N distinct colors).
476pub fn available_special_mana_atoms(
477    game: &crate::game::GameState,
478    source_id: crate::ids::CardId,
479    controller: crate::ids::PlayerId,
480    special: &str,
481) -> Vec<u16> {
482    let mut atoms = Vec::new();
483
484    if special.starts_with("EachColorAmong_Valid") {
485        let filter = special
486            .strip_prefix("EachColorAmong_Valid ")
487            .unwrap_or("Permanent.YouCtrl");
488        for letter in each_color_among_valid(game, source_id, controller, filter) {
489            if let Some(atom) = crate::mana::mana_atom_from_produced(&letter) {
490                atoms.push(atom);
491            }
492        }
493    }
494
495    atoms
496}
497
498/// Simple valid filter matching for special mana (e.g. "Permanent.YouCtrl+MonoColor").
499fn matches_simple_valid(
500    card: &crate::card::Card,
501    filter: &str,
502    source_id: crate::ids::CardId,
503    controller: crate::ids::PlayerId,
504) -> bool {
505    let parts: Vec<&str> = filter.split('.').collect();
506    let base = parts[0];
507
508    let type_ok = match base {
509        "Permanent" | "Card" => true,
510        "Creature" => card.is_creature(),
511        "Land" => card.type_line.is_land(),
512        "Artifact" => card.type_line.is_artifact(),
513        "Enchantment" => card.type_line.is_enchantment(),
514        _ => card.type_line.has_subtype(base),
515    };
516    if !type_ok {
517        return false;
518    }
519
520    for &qualifier in parts.iter().skip(1) {
521        for q in qualifier.split('+') {
522            match q {
523                "YouCtrl" => {
524                    if card.controller != controller {
525                        return false;
526                    }
527                }
528                "OppCtrl" => {
529                    if card.controller == controller {
530                        return false;
531                    }
532                }
533                "MonoColor" => {
534                    // Card must be exactly one color
535                    if card.color.count_colors() != 1 {
536                        return false;
537                    }
538                }
539                "Self" => {
540                    if card.id != source_id {
541                        return false;
542                    }
543                }
544                "Legendary" => {
545                    if !card.type_line.is_legendary() {
546                        return false;
547                    }
548                }
549                _ => {}
550            }
551        }
552    }
553
554    true
555}
556
557fn color_to_letter(color_name: &str) -> String {
558    match color_name {
559        "white" => "W".to_string(),
560        "blue" => "U".to_string(),
561        "black" => "B".to_string(),
562        "red" => "R".to_string(),
563        "green" => "G".to_string(),
564        _ => String::new(),
565    }
566}