Skip to main content

manabrew_engine/card/
card_util.rs

1//! Card utility helpers mirroring Java's `CardUtil`.
2
3use std::collections::{HashSet, VecDeque};
4
5use forge_foundation::{CardStateName, CardTypeLine, Color, ColorSet, ZoneType};
6
7use crate::ability::ability_factory;
8use crate::ability::ability_utils;
9use crate::ability::api_type::ApiType;
10use crate::card::card_collection::CardCollection;
11use crate::card::card_lists::CardLists;
12use crate::card::valid_filter;
13use crate::card::Card;
14use crate::game::GameState;
15use crate::ids::{CardId, PlayerId};
16use crate::parsing::cached_compiled_selector;
17use crate::spellability::target_restrictions;
18use crate::spellability::SpellAbility;
19
20const MODIFIABLE_KEYWORDS: &[&str] = &[
21    "Enchant",
22    "Protection",
23    "Cumulative upkeep",
24    "Equip",
25    "Buyback",
26    "Cycling",
27    "Echo",
28    "Kicker",
29    "Flashback",
30    "Madness",
31    "Morph",
32    "Affinity",
33    "Entwine",
34    "Splice",
35    "Ninjutsu",
36    "Transmute",
37    "Replicate",
38    "Recover",
39    "Squad",
40    "Suspend",
41    "Aura swap",
42    "Fortify",
43    "Transfigure",
44    "Champion",
45    "Evoke",
46    "Prowl",
47    "Freerunning",
48    "Reinforce",
49    "Unearth",
50    "Level up",
51    "Miracle",
52    "Overload",
53    "Cleave",
54    "Scavenge",
55    "Encore",
56    "Bestow",
57    "Outlast",
58    "Dash",
59    "Surge",
60    "Emerge",
61    "Hexproof:",
62    "Bands with other",
63    "Landwalk",
64    "Offering",
65    "etbCounter",
66    "Reflect",
67    "Ward",
68];
69
70const NON_STACKING_LIST: &[&str] = &[];
71
72/// Lightweight characteristic bundle used by face-down / empty-room helpers.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CardCharacteristic {
75    pub state: CardStateName,
76    pub name: String,
77    pub type_line: CardTypeLine,
78    pub base_power: Option<i32>,
79    pub base_toughness: Option<i32>,
80}
81
82pub fn is_keyword_modifiable(keyword: &str) -> bool {
83    MODIFIABLE_KEYWORDS
84        .iter()
85        .any(|prefix| keyword.starts_with(prefix))
86}
87
88pub fn is_stacking_keyword(keyword: &str) -> bool {
89    let kw = keyword.strip_prefix("HIDDEN").unwrap_or(keyword);
90    !kw.starts_with("Protection") && !NON_STACKING_LIST.contains(&kw)
91}
92
93pub fn get_this_turn_entered(
94    game: &GameState,
95    to: ZoneType,
96    from: ZoneType,
97    valid: &str,
98    src: CardId,
99    controller: PlayerId,
100) -> Vec<CardId> {
101    let mut res = Vec::new();
102    if to != ZoneType::Stack {
103        for &pid in &game.player_order {
104            res.extend(
105                game.zone(to, pid)
106                    .cards_added_this_turn
107                    .iter()
108                    .filter(|(origin, _)| *origin == from)
109                    .map(|(_, cid)| *cid),
110            );
111        }
112    } else {
113        res.extend(game.stack.get_spells_cast_this_turn().iter().copied());
114    }
115    filter_valid_cards(game, res, valid, src, controller)
116}
117
118pub fn get_last_turn_entered(
119    game: &GameState,
120    to: ZoneType,
121    from: ZoneType,
122    valid: &str,
123    src: CardId,
124    controller: PlayerId,
125) -> Vec<CardId> {
126    let mut res = Vec::new();
127    if to != ZoneType::Stack {
128        for &pid in &game.player_order {
129            res.extend(
130                game.zone(to, pid)
131                    .cards_added_last_turn
132                    .iter()
133                    .filter(|(origin, _)| *origin == from)
134                    .map(|(_, cid)| *cid),
135            );
136        }
137    } else {
138        res.extend(game.stack.get_spells_cast_last_turn().iter().copied());
139    }
140    filter_valid_cards(game, res, valid, src, controller)
141}
142
143pub fn get_this_turn_cast(
144    game: &GameState,
145    valid: &str,
146    src: CardId,
147    controller: PlayerId,
148) -> Vec<CardId> {
149    filter_valid_cards(
150        game,
151        game.stack.get_spells_cast_this_turn().to_vec(),
152        valid,
153        src,
154        controller,
155    )
156}
157
158pub fn get_last_turn_cast(
159    game: &GameState,
160    valid: &str,
161    src: CardId,
162    controller: PlayerId,
163) -> Vec<CardId> {
164    filter_valid_cards(
165        game,
166        game.stack.get_spells_cast_last_turn().to_vec(),
167        valid,
168        src,
169        controller,
170    )
171}
172
173pub fn get_this_turn_activated(
174    game: &GameState,
175    valid: &str,
176    src: CardId,
177    controller: PlayerId,
178) -> Vec<CardId> {
179    let activated: Vec<CardId> = game
180        .cards
181        .iter()
182        .filter(|card| card.activated_this_turn())
183        .map(|card| card.id)
184        .collect();
185    filter_valid_cards(game, activated, valid, src, controller)
186}
187
188pub fn get_cast_since_beginning_of_your_last_turn(
189    game: &GameState,
190    valid: &str,
191    src: CardId,
192    controller: PlayerId,
193) -> Vec<CardId> {
194    filter_valid_cards(
195        game,
196        game.player(controller).cards_cast_this_turn.clone(),
197        valid,
198        src,
199        controller,
200    )
201}
202
203pub fn get_radiance(game: &GameState, sa: &SpellAbility) -> CardCollection {
204    let Some(targeted) = find_sa_targeting_card(sa) else {
205        return CardCollection::new();
206    };
207    if !targeted.uses_targeting() || !targeted.ir.radiance {
208        return CardCollection::new();
209    }
210
211    let Some(source) = targeted.source else {
212        return CardCollection::new();
213    };
214    let Some(target) = targeted.target_chosen.target_card else {
215        return CardCollection::new();
216    };
217
218    let valid_tokens: Vec<&str> = targeted
219        .ir
220        .valid_tgts_text
221        .as_deref()
222        .map(|value| value.split(',').map(str::trim).collect())
223        .unwrap_or_default();
224    let valid_selectors: Vec<_> = valid_tokens
225        .iter()
226        .map(|token| cached_compiled_selector(token))
227        .collect();
228
229    let combined = game.card(target).color;
230    let mut out = CardCollection::new();
231    for color in combined.iter() {
232        for &pid in &game.player_order {
233            for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
234                if cid == target || out.iter().any(|existing| *existing == cid) {
235                    continue;
236                }
237                let card = game.card(cid);
238                if !card.color.has_color(color) {
239                    continue;
240                }
241                if valid_selectors.is_empty()
242                    || valid_selectors.iter().any(|selector| {
243                        valid_filter::matches_valid_card_selector_in_game(
244                            selector,
245                            card,
246                            game.card(source),
247                            game,
248                        )
249                    })
250                {
251                    out.push(cid);
252                }
253            }
254        }
255    }
256
257    out
258}
259
260pub fn get_colors_from_cards(game: &GameState, list: &[CardId]) -> ColorSet {
261    let mut mask = 0u8;
262    for &cid in list {
263        mask |= game.card(cid).color.mask();
264    }
265    ColorSet::from_mask(mask)
266}
267
268pub fn get_face_down_characteristic(card: &Card) -> CardCharacteristic {
269    get_face_down_characteristic_with_state(card, CardStateName::FaceDown)
270}
271
272pub fn get_face_down_characteristic_with_state(
273    _card: &Card,
274    state: CardStateName,
275) -> CardCharacteristic {
276    CardCharacteristic {
277        state,
278        name: String::new(),
279        type_line: CardTypeLine::parse("Creature"),
280        base_power: Some(2),
281        base_toughness: Some(2),
282    }
283}
284
285pub fn get_empty_room_characteristic(card: &Card) -> CardCharacteristic {
286    get_empty_room_characteristic_with_state(card, CardStateName::EmptyRoom)
287}
288
289pub fn get_empty_room_characteristic_with_state(
290    _card: &Card,
291    state: CardStateName,
292) -> CardCharacteristic {
293    CardCharacteristic {
294        state,
295        name: String::new(),
296        type_line: CardTypeLine::parse("Enchantment Room"),
297        base_power: None,
298        base_toughness: None,
299    }
300}
301
302pub fn get_reflectable_mana_colors(game: &GameState, sa: &SpellAbility) -> HashSet<String> {
303    get_reflectable_mana_colors_inner(game, sa, sa, HashSet::new(), Vec::new())
304}
305
306fn get_reflectable_mana_colors_inner(
307    game: &GameState,
308    root_sa: &SpellAbility,
309    ab_mana: &SpellAbility,
310    mut colors: HashSet<String>,
311    mut parents: Vec<CardId>,
312) -> HashSet<String> {
313    if ab_mana.api != Some(ApiType::ManaReflected) {
314        return colors;
315    }
316
317    let color_or_type = root_sa.ir.color_or_type.as_deref().unwrap_or("Color");
318    let reflect_property = root_sa.ir.reflect_property.as_deref().unwrap_or("Is");
319    let max_choices = if color_or_type.eq_ignore_ascii_case("Type") {
320        6
321    } else {
322        5
323    };
324
325    let cards = collect_reflectable_cards(game, ab_mana, root_sa, &parents);
326    if root_sa.ir.valid_filter_text.is_some() && cards.is_empty() {
327        return colors;
328    }
329
330    match reflect_property {
331        "Is" => {
332            for cid in cards {
333                for color in game.card(cid).color.iter() {
334                    colors.insert(color_long_name(color).to_string());
335                    if colors.len() == max_choices {
336                        break;
337                    }
338                }
339            }
340        }
341        "Produced" => {
342            if let Some(produced_colors) =
343                ab_mana.get_triggering_object(crate::ability::AbilityKey::Produced)
344            {
345                for color in Color::ALL {
346                    if produced_colors.contains(color.short_name()) {
347                        colors.insert(color_long_name(color).to_string());
348                    }
349                }
350                if max_choices == 6
351                    && (produced_colors.contains('1') || produced_colors.contains('C'))
352                {
353                    colors.insert("Colorless".to_string());
354                }
355            }
356        }
357        "Produce" => {
358            let mut reflect_abilities = VecDeque::new();
359            for cid in cards {
360                let card = game.card(cid);
361                for ab in &card.activated_abilities {
362                    let ab = ability_factory::build_spell_ability(
363                        game,
364                        card.id,
365                        &ab.ability_text,
366                        card.controller,
367                    );
368                    if ab.is_spell || ab.is_land_ability {
369                        continue;
370                    }
371                    if colors.len() == max_choices {
372                        break;
373                    }
374                    if !parents.contains(&card.id) {
375                        parents.push(card.id);
376                    }
377                    if ab.api == Some(ApiType::ManaReflected)
378                        && ab.ir.reflect_property.as_deref().unwrap_or("") != "Produced"
379                    {
380                        reflect_abilities.push_back(ab);
381                    } else {
382                        colors = can_produce(max_choices, Some(&ab), colors);
383                    }
384                }
385                for trig in &card.triggers {
386                    let Some(mut trig_sa) = trig.ensure_ability(game, card.id, card.controller)
387                    else {
388                        continue;
389                    };
390                    if trig_sa.is_spell || trig_sa.is_land_ability {
391                        continue;
392                    }
393                    if colors.len() == max_choices {
394                        break;
395                    }
396                    if !parents.contains(&card.id) {
397                        parents.push(card.id);
398                    }
399                    trig_sa.activating_player = card.controller;
400                    if trig_sa.api == Some(ApiType::ManaReflected)
401                        && trig_sa.ir.reflect_property.as_deref().unwrap_or("") != "Produced"
402                    {
403                        reflect_abilities.push_back(trig_sa);
404                    } else {
405                        colors = can_produce(max_choices, Some(&trig_sa), colors);
406                    }
407                }
408            }
409
410            while let Some(reflect_sa) = reflect_abilities.pop_front() {
411                if colors.len() == max_choices {
412                    break;
413                }
414                colors = get_reflectable_mana_colors_inner(
415                    game,
416                    root_sa,
417                    &reflect_sa,
418                    colors,
419                    parents.clone(),
420                );
421            }
422        }
423        _ => {}
424    }
425
426    colors
427}
428
429fn collect_reflectable_cards(
430    game: &GameState,
431    ab_mana: &SpellAbility,
432    root_sa: &SpellAbility,
433    parents: &[CardId],
434) -> Vec<CardId> {
435    let Some(host) = ab_mana.source else {
436        return Vec::new();
437    };
438    let Some(valid_card) = root_sa.ir.valid_filter_text.as_deref() else {
439        return Vec::new();
440    };
441
442    let mut cards = if let Some(defined) = valid_card.strip_prefix("Defined.") {
443        ability_utils::get_defined_cards(game, Some(host), defined, Some(ab_mana.activating_player))
444    } else {
445        let valid_selector = root_sa
446            .ir
447            .valid_filter_selector
448            .clone()
449            .unwrap_or_else(|| cached_compiled_selector(valid_card));
450        game.player_order
451            .iter()
452            .flat_map(|&pid| {
453                game.cards_in_zone(ZoneType::Battlefield, pid)
454                    .iter()
455                    .copied()
456            })
457            .filter(|&cid| {
458                let card = game.card(cid);
459                valid_filter::matches_valid_card_selector_in_game(
460                    &valid_selector,
461                    card,
462                    game.card(host),
463                    game,
464                )
465            })
466            .collect()
467    };
468
469    cards.retain(|cid| !parents.contains(cid));
470    cards
471}
472
473/// Java parity: accumulate colors/types a mana ability can produce.
474pub fn can_produce(
475    max_choices: usize,
476    sa: Option<&SpellAbility>,
477    mut colors: HashSet<String>,
478) -> HashSet<String> {
479    let Some(sa) = sa else {
480        return colors;
481    };
482
483    for (short, long) in [
484        ("W", "white"),
485        ("U", "blue"),
486        ("B", "black"),
487        ("R", "red"),
488        ("G", "green"),
489    ] {
490        if sa.can_produce(short) {
491            colors.insert(long.to_string());
492        }
493    }
494    if max_choices == 6 && sa.can_produce("C") {
495        colors.insert("colorless".to_string());
496    }
497
498    colors
499}
500
501pub fn card_can_produce_color_mana(
502    game: &GameState,
503    card_id: CardId,
504    colors: &HashSet<String>,
505) -> bool {
506    let card = game.card(card_id);
507    for ab in &card.activated_abilities {
508        if !ab.is_mana_ability {
509            continue;
510        }
511        let sa = crate::ability::ability_factory::build_spell_ability(
512            game,
513            card_id,
514            &ab.ability_text,
515            card.controller,
516        );
517        for color in colors {
518            if sa.api == Some(ApiType::ManaReflected) {
519                if get_reflectable_mana_colors(game, &sa).contains(color) {
520                    return true;
521                }
522            } else if sa.can_produce(color_short_name(color)) {
523                return true;
524            }
525        }
526    }
527    false
528}
529
530pub fn card_can_produce_same_mana_type_with(
531    game: &GameState,
532    card_id: CardId,
533    other_id: CardId,
534) -> bool {
535    let card = game.card(card_id);
536    if !card.activated_abilities.iter().any(|ab| ab.is_mana_ability) {
537        return false;
538    }
539
540    let other = game.card(other_id);
541    let mut colors = HashSet::new();
542    for ab in &other.activated_abilities {
543        if !ab.is_mana_ability {
544            continue;
545        }
546        let sa = crate::ability::ability_factory::build_spell_ability(
547            game,
548            other_id,
549            &ab.ability_text,
550            other.controller,
551        );
552        if sa.api == Some(ApiType::ManaReflected) {
553            colors.extend(get_reflectable_mana_colors(game, &sa));
554        } else {
555            colors = can_produce(6, Some(&sa), colors);
556        }
557    }
558
559    card_can_produce_color_mana(game, card_id, &colors)
560}
561
562pub fn get_valid_cards_to_target(game: &GameState, ability: &SpellAbility) -> Vec<CardId> {
563    let Some(tgt) = ability.target_restrictions.as_ref() else {
564        return Vec::new();
565    };
566    let player = ability
567        .targeting_player
568        .unwrap_or(ability.activating_player);
569
570    let zones = if tgt.tgt_zone.is_empty() {
571        vec![ZoneType::Battlefield]
572    } else {
573        tgt.tgt_zone.clone()
574    };
575
576    let mut candidates = Vec::new();
577    for zone in zones {
578        match zone {
579            ZoneType::Battlefield => {
580                candidates.extend(match &tgt.target_kind {
581                    target_restrictions::TargetKind::Creature(filter) => {
582                        target_restrictions::get_all_candidates_creature_filtered_for_restrictions(
583                            game,
584                            tgt,
585                            filter.as_deref(),
586                            player,
587                            ability.source,
588                        )
589                    }
590                    target_restrictions::TargetKind::Permanent(filter) => {
591                        target_restrictions::get_all_battlefield_permanents_filtered_for_restrictions(
592                            game,
593                            tgt,
594                            filter.as_deref(),
595                            player,
596                            ability.source,
597                        )
598                    }
599                    target_restrictions::TargetKind::Any => {
600                        target_restrictions::get_all_candidates_any_filtered_for_restrictions(
601                            game,
602                            tgt,
603                            player,
604                            ability.source,
605                        )
606                    }
607                    _ => Vec::new(),
608                });
609            }
610            _ => {
611                if let target_restrictions::TargetKind::CardInZone {
612                    filter,
613                    zone: target_zone,
614                } = &tgt.target_kind
615                {
616                    if *target_zone == zone {
617                        candidates.extend(target_restrictions::get_valid_cards_in_zone_for_sa(
618                            game,
619                            zone,
620                            player,
621                            filter.as_deref(),
622                            ability,
623                        ));
624                    }
625                }
626            }
627        }
628    }
629
630    for valid in &tgt.valid_tgts {
631        candidates = target_restrictions::apply_other_source_filter(
632            candidates,
633            Some(valid.as_str()),
634            ability.source,
635        );
636    }
637
638    candidates.retain(|&cid| {
639        !ability.target_chosen.contains(cid)
640            && target_restrictions::can_be_targeted_by_sa(
641                game,
642                cid,
643                ability
644                    .targeting_player
645                    .unwrap_or(ability.activating_player),
646                ability,
647            )
648    });
649
650    candidates.sort_unstable_by_key(|cid| cid.0);
651    candidates.dedup();
652    candidates
653}
654
655fn filter_valid_cards(
656    game: &GameState,
657    cards: Vec<CardId>,
658    valid: &str,
659    src: CardId,
660    controller: PlayerId,
661) -> Vec<CardId> {
662    if valid.is_empty() {
663        return cards;
664    }
665    let _ = controller;
666    CardLists::filter_as_list_with_source(game, &cards, valid, src)
667}
668
669fn color_long_name(color: Color) -> &'static str {
670    match color {
671        Color::White => "white",
672        Color::Blue => "blue",
673        Color::Black => "black",
674        Color::Red => "red",
675        Color::Green => "green",
676    }
677}
678
679fn color_short_name(color: &str) -> &'static str {
680    match color {
681        "white" | "White" => "W",
682        "blue" | "Blue" => "U",
683        "black" | "Black" => "B",
684        "red" | "Red" => "R",
685        "green" | "Green" => "G",
686        "colorless" | "Colorless" => "C",
687        _ => "",
688    }
689}
690
691fn find_sa_targeting_card(sa: &SpellAbility) -> Option<&SpellAbility> {
692    let mut current = Some(sa);
693    while let Some(node) = current {
694        if node.uses_targeting() && node.ir.radiance {
695            return Some(node);
696        }
697        current = node.get_sub_ability();
698    }
699    None
700}