Skip to main content

forge_foundation/
mana.rs

1use serde::{Deserialize, Serialize};
2
3use crate::color::{Color, ColorSet};
4
5/// Bitmask constants for mana atoms, matching Java `ManaAtom`.
6/// Each bit represents a property of a mana symbol.
7pub struct ManaAtom;
8
9impl ManaAtom {
10    pub const WHITE: u16 = 1;
11    pub const BLUE: u16 = 2;
12    pub const BLACK: u16 = 4;
13    pub const RED: u16 = 8;
14    pub const GREEN: u16 = 16;
15    pub const COLORLESS: u16 = 32;
16    pub const GENERIC: u16 = 64;
17    pub const IS_X: u16 = 256;
18    pub const OR_2_GENERIC: u16 = 512;
19    pub const OR_2_LIFE: u16 = 1024;
20    pub const IS_SNOW: u16 = 2048;
21
22    pub const ALL_MANA_COLORS: u16 =
23        Self::WHITE | Self::BLUE | Self::BLACK | Self::RED | Self::GREEN;
24    pub const ALL_MANA_TYPES: u16 = Self::ALL_MANA_COLORS | Self::COLORLESS;
25    pub const COLORS_SUPERPOSITION: u16 = Self::ALL_MANA_COLORS;
26
27    /// Convert a color name (lowercase) to its atom bitmask.
28    pub fn from_name(name: &str) -> u16 {
29        match name {
30            "white" | "w" => Self::WHITE,
31            "blue" | "u" => Self::BLUE,
32            "black" | "b" => Self::BLACK,
33            "red" | "r" => Self::RED,
34            "green" | "g" => Self::GREEN,
35            "colorless" | "c" => Self::COLORLESS,
36            _ => 0,
37        }
38    }
39
40    pub fn from_char(c: char) -> u16 {
41        match c.to_ascii_uppercase() {
42            'W' => Self::WHITE,
43            'U' => Self::BLUE,
44            'B' => Self::BLACK,
45            'R' => Self::RED,
46            'G' => Self::GREEN,
47            'C' => Self::COLORLESS,
48            'P' => Self::OR_2_LIFE,
49            'S' => Self::IS_SNOW,
50            'X' => Self::IS_X,
51            '2' => Self::OR_2_GENERIC,
52            c if c.is_ascii_digit() => Self::GENERIC,
53            _ => 0,
54        }
55    }
56}
57
58/// Individual mana cost shard (one symbol in a mana cost).
59/// Mirrors Java `ManaCostShard`. Each variant stores its atom bitmask and display string.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
61pub enum ManaCostShard {
62    // Pure colors
63    White,
64    Blue,
65    Black,
66    Red,
67    Green,
68    Colorless,
69    // Hybrid
70    WhiteBlue,
71    WhiteBlack,
72    BlueBlack,
73    BlueRed,
74    BlackRed,
75    BlackGreen,
76    RedWhite,
77    RedGreen,
78    GreenWhite,
79    GreenBlue,
80    // Or 2 generic
81    White2,
82    Blue2,
83    Black2,
84    Red2,
85    Green2,
86    // Or Colorless hybrid
87    ColorlessWhite,
88    ColorlessBlue,
89    ColorlessBlack,
90    ColorlessRed,
91    ColorlessGreen,
92    // Snow
93    Snow,
94    // Generic (value 1)
95    Generic,
96    // Phyrexian
97    WhitePhyrexian,
98    BluePhyrexian,
99    BlackPhyrexian,
100    RedPhyrexian,
101    GreenPhyrexian,
102    // Hybrid Phyrexian
103    BlackGreenPhyrexian,
104    BlackRedPhyrexian,
105    GreenBluePhyrexian,
106    GreenWhitePhyrexian,
107    RedGreenPhyrexian,
108    RedWhitePhyrexian,
109    BlueBlackPhyrexian,
110    BlueRedPhyrexian,
111    WhiteBlackPhyrexian,
112    WhiteBluePhyrexian,
113    // X
114    X,
115    // Colored X (Emblazoned Golem)
116    ColoredX,
117}
118
119impl ManaCostShard {
120    pub fn shard(self) -> u16 {
121        match self {
122            Self::White => ManaAtom::WHITE,
123            Self::Blue => ManaAtom::BLUE,
124            Self::Black => ManaAtom::BLACK,
125            Self::Red => ManaAtom::RED,
126            Self::Green => ManaAtom::GREEN,
127            Self::Colorless => ManaAtom::COLORLESS,
128            Self::WhiteBlue => ManaAtom::WHITE | ManaAtom::BLUE,
129            Self::WhiteBlack => ManaAtom::WHITE | ManaAtom::BLACK,
130            Self::BlueBlack => ManaAtom::BLUE | ManaAtom::BLACK,
131            Self::BlueRed => ManaAtom::BLUE | ManaAtom::RED,
132            Self::BlackRed => ManaAtom::BLACK | ManaAtom::RED,
133            Self::BlackGreen => ManaAtom::BLACK | ManaAtom::GREEN,
134            Self::RedWhite => ManaAtom::RED | ManaAtom::WHITE,
135            Self::RedGreen => ManaAtom::RED | ManaAtom::GREEN,
136            Self::GreenWhite => ManaAtom::GREEN | ManaAtom::WHITE,
137            Self::GreenBlue => ManaAtom::GREEN | ManaAtom::BLUE,
138            Self::White2 => ManaAtom::WHITE | ManaAtom::OR_2_GENERIC,
139            Self::Blue2 => ManaAtom::BLUE | ManaAtom::OR_2_GENERIC,
140            Self::Black2 => ManaAtom::BLACK | ManaAtom::OR_2_GENERIC,
141            Self::Red2 => ManaAtom::RED | ManaAtom::OR_2_GENERIC,
142            Self::Green2 => ManaAtom::GREEN | ManaAtom::OR_2_GENERIC,
143            Self::ColorlessWhite => ManaAtom::WHITE | ManaAtom::COLORLESS,
144            Self::ColorlessBlue => ManaAtom::BLUE | ManaAtom::COLORLESS,
145            Self::ColorlessBlack => ManaAtom::BLACK | ManaAtom::COLORLESS,
146            Self::ColorlessRed => ManaAtom::RED | ManaAtom::COLORLESS,
147            Self::ColorlessGreen => ManaAtom::GREEN | ManaAtom::COLORLESS,
148            Self::Snow => ManaAtom::IS_SNOW,
149            Self::Generic => ManaAtom::GENERIC,
150            Self::WhitePhyrexian => ManaAtom::WHITE | ManaAtom::OR_2_LIFE,
151            Self::BluePhyrexian => ManaAtom::BLUE | ManaAtom::OR_2_LIFE,
152            Self::BlackPhyrexian => ManaAtom::BLACK | ManaAtom::OR_2_LIFE,
153            Self::RedPhyrexian => ManaAtom::RED | ManaAtom::OR_2_LIFE,
154            Self::GreenPhyrexian => ManaAtom::GREEN | ManaAtom::OR_2_LIFE,
155            Self::BlackGreenPhyrexian => ManaAtom::BLACK | ManaAtom::GREEN | ManaAtom::OR_2_LIFE,
156            Self::BlackRedPhyrexian => ManaAtom::BLACK | ManaAtom::RED | ManaAtom::OR_2_LIFE,
157            Self::GreenBluePhyrexian => ManaAtom::GREEN | ManaAtom::BLUE | ManaAtom::OR_2_LIFE,
158            Self::GreenWhitePhyrexian => ManaAtom::GREEN | ManaAtom::WHITE | ManaAtom::OR_2_LIFE,
159            Self::RedGreenPhyrexian => ManaAtom::RED | ManaAtom::GREEN | ManaAtom::OR_2_LIFE,
160            Self::RedWhitePhyrexian => ManaAtom::RED | ManaAtom::WHITE | ManaAtom::OR_2_LIFE,
161            Self::BlueBlackPhyrexian => ManaAtom::BLUE | ManaAtom::BLACK | ManaAtom::OR_2_LIFE,
162            Self::BlueRedPhyrexian => ManaAtom::BLUE | ManaAtom::RED | ManaAtom::OR_2_LIFE,
163            Self::WhiteBlackPhyrexian => ManaAtom::WHITE | ManaAtom::BLACK | ManaAtom::OR_2_LIFE,
164            Self::WhiteBluePhyrexian => ManaAtom::WHITE | ManaAtom::BLUE | ManaAtom::OR_2_LIFE,
165            Self::X => ManaAtom::IS_X,
166            Self::ColoredX => {
167                ManaAtom::WHITE
168                    | ManaAtom::BLUE
169                    | ManaAtom::BLACK
170                    | ManaAtom::RED
171                    | ManaAtom::GREEN
172                    | ManaAtom::IS_X
173            }
174        }
175    }
176
177    pub fn short_string(self) -> &'static str {
178        match self {
179            Self::White => "W",
180            Self::Blue => "U",
181            Self::Black => "B",
182            Self::Red => "R",
183            Self::Green => "G",
184            Self::Colorless => "C",
185            Self::WhiteBlue => "W/U",
186            Self::WhiteBlack => "W/B",
187            Self::BlueBlack => "U/B",
188            Self::BlueRed => "U/R",
189            Self::BlackRed => "B/R",
190            Self::BlackGreen => "B/G",
191            Self::RedWhite => "R/W",
192            Self::RedGreen => "R/G",
193            Self::GreenWhite => "G/W",
194            Self::GreenBlue => "G/U",
195            Self::White2 => "2/W",
196            Self::Blue2 => "2/U",
197            Self::Black2 => "2/B",
198            Self::Red2 => "2/R",
199            Self::Green2 => "2/G",
200            Self::ColorlessWhite => "C/W",
201            Self::ColorlessBlue => "C/U",
202            Self::ColorlessBlack => "C/B",
203            Self::ColorlessRed => "C/R",
204            Self::ColorlessGreen => "C/G",
205            Self::Snow => "S",
206            Self::Generic => "1",
207            Self::WhitePhyrexian => "W/P",
208            Self::BluePhyrexian => "U/P",
209            Self::BlackPhyrexian => "B/P",
210            Self::RedPhyrexian => "R/P",
211            Self::GreenPhyrexian => "G/P",
212            Self::BlackGreenPhyrexian => "B/G/P",
213            Self::BlackRedPhyrexian => "B/R/P",
214            Self::GreenBluePhyrexian => "G/U/P",
215            Self::GreenWhitePhyrexian => "G/W/P",
216            Self::RedGreenPhyrexian => "R/G/P",
217            Self::RedWhitePhyrexian => "R/W/P",
218            Self::BlueBlackPhyrexian => "U/B/P",
219            Self::BlueRedPhyrexian => "U/R/P",
220            Self::WhiteBlackPhyrexian => "W/B/P",
221            Self::WhiteBluePhyrexian => "W/U/P",
222            Self::X => "X",
223            Self::ColoredX => "1",
224        }
225    }
226
227    pub fn cmc(self) -> i32 {
228        let s = self.shard();
229        if (s & ManaAtom::IS_X) != 0 {
230            return 0;
231        }
232        if (s & ManaAtom::OR_2_GENERIC) != 0 {
233            return 2;
234        }
235        1
236    }
237
238    pub fn color_mask(self) -> u8 {
239        (self.shard() & ManaAtom::COLORS_SUPERPOSITION) as u8
240    }
241
242    pub fn color(self) -> ColorSet {
243        ColorSet::from_mask(self.color_mask())
244    }
245
246    pub fn is_phyrexian(self) -> bool {
247        (self.shard() & ManaAtom::OR_2_LIFE) != 0
248    }
249
250    /// Convert a Phyrexian shard to its non-Phyrexian color equivalent.
251    /// E.g. WhitePhyrexian → White, BlackGreenPhyrexian → BlackGreen.
252    pub fn to_non_phyrexian(self) -> ManaCostShard {
253        match self {
254            Self::WhitePhyrexian => Self::White,
255            Self::BluePhyrexian => Self::Blue,
256            Self::BlackPhyrexian => Self::Black,
257            Self::RedPhyrexian => Self::Red,
258            Self::GreenPhyrexian => Self::Green,
259            Self::BlackGreenPhyrexian => Self::BlackGreen,
260            Self::BlackRedPhyrexian => Self::BlackRed,
261            Self::GreenBluePhyrexian => Self::GreenBlue,
262            Self::GreenWhitePhyrexian => Self::GreenWhite,
263            Self::RedGreenPhyrexian => Self::RedGreen,
264            Self::RedWhitePhyrexian => Self::RedWhite,
265            Self::BlueBlackPhyrexian => Self::BlueBlack,
266            Self::BlueRedPhyrexian => Self::BlueRed,
267            Self::WhiteBlackPhyrexian => Self::WhiteBlack,
268            Self::WhiteBluePhyrexian => Self::WhiteBlue,
269            other => other,
270        }
271    }
272
273    pub fn is_snow(self) -> bool {
274        (self.shard() & ManaAtom::IS_SNOW) != 0
275    }
276
277    pub fn is_x(self) -> bool {
278        (self.shard() & ManaAtom::IS_X) != 0
279    }
280
281    pub fn is_generic(self) -> bool {
282        let s = self.shard();
283        (s & ManaAtom::GENERIC) != 0
284            || (s & ManaAtom::IS_X) != 0
285            || self.is_snow()
286            || self.is_or_2_generic()
287    }
288
289    pub fn is_or_2_generic(self) -> bool {
290        (self.shard() & ManaAtom::OR_2_GENERIC) != 0
291    }
292
293    pub fn is_colorless(self) -> bool {
294        (self.shard() & ManaAtom::COLORLESS) != 0
295    }
296
297    pub fn is_mono_color(self) -> bool {
298        (self.shard() & ManaAtom::COLORS_SUPERPOSITION).count_ones() == 1
299    }
300
301    pub fn is_multi_color(self) -> bool {
302        (self.shard() & ManaAtom::COLORS_SUPERPOSITION).count_ones() == 2
303    }
304
305    /// Parse a non-generic mana symbol string (e.g. "W", "U/R", "W/P", "2/W")
306    /// into a ManaCostShard. Matches Java `ManaCostShard.parseNonGeneric`.
307    pub fn parse_non_generic(s: &str) -> Option<ManaCostShard> {
308        let mut atoms: u16 = 0;
309        for c in s.chars() {
310            atoms |= ManaAtom::from_char(c);
311        }
312        // For cases when input is "2" or "12" or "20" — pure numeric
313        if atoms == ManaAtom::OR_2_GENERIC || atoms == (ManaAtom::OR_2_GENERIC | ManaAtom::GENERIC)
314        {
315            atoms = ManaAtom::GENERIC;
316        }
317        Self::from_atoms(atoms)
318    }
319
320    /// Look up a shard by its atom bitmask.
321    pub fn from_atoms(atoms: u16) -> Option<ManaCostShard> {
322        if atoms == 0 {
323            return Some(ManaCostShard::Generic);
324        }
325        // Check all variants
326        const ALL: &[ManaCostShard] = &[
327            ManaCostShard::White,
328            ManaCostShard::Blue,
329            ManaCostShard::Black,
330            ManaCostShard::Red,
331            ManaCostShard::Green,
332            ManaCostShard::Colorless,
333            ManaCostShard::WhiteBlue,
334            ManaCostShard::WhiteBlack,
335            ManaCostShard::BlueBlack,
336            ManaCostShard::BlueRed,
337            ManaCostShard::BlackRed,
338            ManaCostShard::BlackGreen,
339            ManaCostShard::RedWhite,
340            ManaCostShard::RedGreen,
341            ManaCostShard::GreenWhite,
342            ManaCostShard::GreenBlue,
343            ManaCostShard::White2,
344            ManaCostShard::Blue2,
345            ManaCostShard::Black2,
346            ManaCostShard::Red2,
347            ManaCostShard::Green2,
348            ManaCostShard::ColorlessWhite,
349            ManaCostShard::ColorlessBlue,
350            ManaCostShard::ColorlessBlack,
351            ManaCostShard::ColorlessRed,
352            ManaCostShard::ColorlessGreen,
353            ManaCostShard::Snow,
354            ManaCostShard::Generic,
355            ManaCostShard::WhitePhyrexian,
356            ManaCostShard::BluePhyrexian,
357            ManaCostShard::BlackPhyrexian,
358            ManaCostShard::RedPhyrexian,
359            ManaCostShard::GreenPhyrexian,
360            ManaCostShard::BlackGreenPhyrexian,
361            ManaCostShard::BlackRedPhyrexian,
362            ManaCostShard::GreenBluePhyrexian,
363            ManaCostShard::GreenWhitePhyrexian,
364            ManaCostShard::RedGreenPhyrexian,
365            ManaCostShard::RedWhitePhyrexian,
366            ManaCostShard::BlueBlackPhyrexian,
367            ManaCostShard::BlueRedPhyrexian,
368            ManaCostShard::WhiteBlackPhyrexian,
369            ManaCostShard::WhiteBluePhyrexian,
370            ManaCostShard::X,
371            ManaCostShard::ColoredX,
372        ];
373        ALL.iter().find(|&&shard| shard.shard() == atoms).copied()
374    }
375}
376
377impl std::fmt::Display for ManaCostShard {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        write!(f, "{{{}}}", self.short_string())
380    }
381}
382
383/// A complete mana cost (e.g. {2}{W}{U}).
384/// Mirrors Java `ManaCost`. Immutable once constructed.
385#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
386pub struct ManaCost {
387    shards: Vec<ManaCostShard>,
388    generic_cost: i32,
389    has_no_cost: bool,
390}
391
392impl ManaCost {
393    /// Represents a card with no mana cost (e.g. lands).
394    pub fn no_cost() -> Self {
395        ManaCost {
396            shards: Vec::new(),
397            generic_cost: 0,
398            has_no_cost: true,
399        }
400    }
401
402    /// Zero mana cost {0}.
403    pub fn zero() -> Self {
404        ManaCost {
405            shards: Vec::new(),
406            generic_cost: 0,
407            has_no_cost: false,
408        }
409    }
410
411    /// Pure generic cost (e.g. {3}).
412    pub fn generic(n: i32) -> Self {
413        if n < 0 {
414            return Self::no_cost();
415        }
416        ManaCost {
417            shards: Vec::new(),
418            generic_cost: n,
419            has_no_cost: false,
420        }
421    }
422
423    /// Parse a mana cost string in Forge format (space-separated tokens).
424    /// E.g. "2 W U", "3 G G", "X R", "0", "W/U W/U".
425    pub fn parse(s: &str) -> Self {
426        if s.is_empty() || s == "no cost" {
427            return Self::no_cost();
428        }
429
430        let mut shards = Vec::new();
431        let mut generic_cost: i32 = 0;
432        let mut has_x = false;
433
434        for token in s.split_whitespace() {
435            // Try parsing as integer (generic mana)
436            if let Ok(n) = token.parse::<i32>() {
437                generic_cost += n;
438            } else {
439                // Forge can encode colored pips as adjacent symbols (e.g. "BR"
440                // means "{B}{R}"), while hybrid/phyrexian/colorless-hybrid
441                // symbols are slash-separated (e.g. "B/R", "W/P", "2/W").
442                if token.contains('/') {
443                    if let Some(shard) = ManaCostShard::parse_non_generic(token) {
444                        if shard != ManaCostShard::Generic {
445                            if shard == ManaCostShard::X {
446                                has_x = true;
447                            }
448                            shards.push(shard);
449                        }
450                        // If it parsed to Generic, it was a numeric handled above
451                    }
452                } else {
453                    // First try parsing the whole token as a single shard.
454                    // In Forge card files, adjacent color chars like "BR" mean
455                    // hybrid {B/R}, not separate {B}{R} (which would be "B R").
456                    let whole = ManaCostShard::parse_non_generic(token);
457                    if let Some(shard) = whole {
458                        if shard.is_multi_color() || shard.is_phyrexian() {
459                            // Hybrid shard (e.g. "BR" → BlackRed) or phyrexian (e.g. "GP" → GreenPhyrexian)
460                            shards.push(shard);
461                        } else {
462                            // Mono-color or other — fall back to per-character
463                            // so "WW" correctly becomes two White shards.
464                            for c in token.chars() {
465                                let sym = c.to_ascii_uppercase().to_string();
466                                if let Some(shard) = ManaCostShard::parse_non_generic(&sym) {
467                                    if shard != ManaCostShard::Generic {
468                                        if shard == ManaCostShard::X {
469                                            has_x = true;
470                                        }
471                                        shards.push(shard);
472                                    }
473                                }
474                            }
475                        }
476                    } else {
477                        for c in token.chars() {
478                            let sym = c.to_ascii_uppercase().to_string();
479                            if let Some(shard) = ManaCostShard::parse_non_generic(&sym) {
480                                if shard != ManaCostShard::Generic {
481                                    if shard == ManaCostShard::X {
482                                        has_x = true;
483                                    }
484                                    shards.push(shard);
485                                }
486                            }
487                        }
488                    }
489                }
490            }
491        }
492
493        let has_no_cost = !has_x && generic_cost < 0;
494        if has_no_cost {
495            generic_cost = 0;
496        }
497
498        ManaCost {
499            shards,
500            generic_cost,
501            has_no_cost,
502        }
503    }
504
505    pub fn cmc(&self) -> i32 {
506        let shard_total: i32 = self.shards.iter().map(|s| s.cmc()).sum();
507        shard_total + self.generic_cost
508    }
509
510    pub fn color_profile(&self) -> u8 {
511        let mut result: u8 = 0;
512        for s in &self.shards {
513            result |= s.color_mask();
514        }
515        result
516    }
517
518    pub fn color_set(&self) -> ColorSet {
519        ColorSet::from_mask(self.color_profile())
520    }
521
522    pub fn generic_cost(&self) -> i32 {
523        self.generic_cost
524    }
525
526    pub fn shards(&self) -> &[ManaCostShard] {
527        &self.shards
528    }
529
530    pub fn is_no_cost(&self) -> bool {
531        self.has_no_cost
532    }
533
534    pub fn is_zero(&self) -> bool {
535        self.generic_cost == 0 && self.shards.is_empty() && !self.has_no_cost
536    }
537
538    pub fn is_pure_generic(&self) -> bool {
539        self.shards.is_empty() && !self.has_no_cost
540    }
541
542    pub fn count_x(&self) -> usize {
543        self.shards
544            .iter()
545            .filter(|s| **s == ManaCostShard::X)
546            .count()
547    }
548
549    pub fn has_phyrexian(&self) -> bool {
550        self.shards.iter().any(|s| s.is_phyrexian())
551    }
552
553    /// Build a ManaCost from explicit shards and generic cost.
554    pub fn from_parts(shards: Vec<ManaCostShard>, generic_cost: i32) -> ManaCost {
555        ManaCost {
556            shards,
557            generic_cost,
558            has_no_cost: false,
559        }
560    }
561
562    /// Return a copy of this cost with all X shards removed.
563    /// Used to compute the non-X portion for affordability checks.
564    pub fn without_x(&self) -> ManaCost {
565        ManaCost {
566            shards: self.shards.iter().filter(|s| !s.is_x()).copied().collect(),
567            generic_cost: self.generic_cost,
568            has_no_cost: self.has_no_cost,
569        }
570    }
571
572    /// Return a copy of this cost with all Phyrexian shards removed.
573    /// Used for playability checks — Phyrexian shards can always be paid with 2 life.
574    pub fn without_phyrexian(&self) -> ManaCost {
575        ManaCost {
576            shards: self
577                .shards
578                .iter()
579                .filter(|s| !s.is_phyrexian())
580                .copied()
581                .collect(),
582            generic_cost: self.generic_cost,
583            has_no_cost: self.has_no_cost,
584        }
585    }
586
587    /// Convert phyrexian shards to their colored equivalents (e.g. {B/P} → {B}).
588    /// Non-phyrexian shards are kept as-is. Generic cost is preserved.
589    pub fn phyrexian_to_colored(&self) -> ManaCost {
590        ManaCost {
591            shards: self
592                .shards
593                .iter()
594                .map(|s| {
595                    if s.is_phyrexian() {
596                        s.to_non_phyrexian()
597                    } else {
598                        *s
599                    }
600                })
601                .collect(),
602            generic_cost: self.generic_cost,
603            has_no_cost: self.has_no_cost,
604        }
605    }
606
607    /// Convert mono-colored shards of the given color into their phyrexian variants.
608    /// Used for effects like "you may pay 2 life rather than pay {B}".
609    pub fn colored_to_phyrexian(&self, color_mask: u8) -> ManaCost {
610        let shards = self
611            .shards
612            .iter()
613            .map(|s| match (*s, color_mask) {
614                (ManaCostShard::White, m) if m == ManaAtom::WHITE as u8 => {
615                    ManaCostShard::WhitePhyrexian
616                }
617                (ManaCostShard::Blue, m) if m == ManaAtom::BLUE as u8 => {
618                    ManaCostShard::BluePhyrexian
619                }
620                (ManaCostShard::Black, m) if m == ManaAtom::BLACK as u8 => {
621                    ManaCostShard::BlackPhyrexian
622                }
623                (ManaCostShard::Red, m) if m == ManaAtom::RED as u8 => ManaCostShard::RedPhyrexian,
624                (ManaCostShard::Green, m) if m == ManaAtom::GREEN as u8 => {
625                    ManaCostShard::GreenPhyrexian
626                }
627                (other, _) => other,
628            })
629            .collect();
630        ManaCost {
631            shards,
632            generic_cost: self.generic_cost,
633            has_no_cost: self.has_no_cost,
634        }
635    }
636
637    pub fn shard_count(&self, which: ManaCostShard) -> usize {
638        if which == ManaCostShard::Generic {
639            return self.generic_cost as usize;
640        }
641        self.shards.iter().filter(|s| **s == which).count()
642    }
643
644    /// Add another mana cost to this one, returning the combined cost.
645    pub fn add(&self, other: &ManaCost) -> ManaCost {
646        Self::combine(self, other)
647    }
648
649    /// Reduce the generic portion of this cost by `amount` (floor at 0).
650    /// Used for Emerge (cost reduced by sacrificed creature's mana value).
651    pub fn reduce_generic(&self, amount: i32) -> ManaCost {
652        ManaCost {
653            shards: self.shards.clone(),
654            generic_cost: (self.generic_cost - amount).max(0),
655            has_no_cost: self.has_no_cost,
656        }
657    }
658
659    /// Return a copy with the generic cost set to the given value.
660    pub fn with_generic(&self, generic: i32) -> ManaCost {
661        ManaCost {
662            shards: self.shards.clone(),
663            generic_cost: generic.max(0),
664            has_no_cost: self.has_no_cost,
665        }
666    }
667
668    /// Check if this cost has a mono-color shard matching the given ManaAtom.
669    pub fn has_color_shard(&self, atom: u16) -> bool {
670        self.shards.iter().any(|s| {
671            s.is_mono_color()
672                && !s.is_phyrexian()
673                && (s.shard() & ManaAtom::COLORS_SUPERPOSITION) == atom
674        })
675    }
676
677    /// Remove one mono-color shard matching the given ManaAtom. Returns new cost.
678    pub fn remove_color_shard(&self, atom: u16) -> ManaCost {
679        let mut shards = self.shards.clone();
680        if let Some(pos) = shards.iter().position(|s| {
681            s.is_mono_color()
682                && !s.is_phyrexian()
683                && (s.shard() & ManaAtom::COLORS_SUPERPOSITION) == atom
684        }) {
685            shards.remove(pos);
686        }
687        ManaCost {
688            shards,
689            generic_cost: self.generic_cost,
690            has_no_cost: self.has_no_cost,
691        }
692    }
693
694    /// Remove up to `count` colored shards matching `color` from this cost.
695    /// If `ignore_generic` is true, only removes colored shards (never converts to generic reduction).
696    /// If `ignore_generic` is false and fewer matching shards exist than `count`, the remainder
697    /// reduces the generic portion instead.
698    pub fn reduce_color(&self, color: Color, count: i32, ignore_generic: bool) -> ManaCost {
699        let mut shards = self.shards.clone();
700        let mut remaining = count;
701        let color_mask = color.mask() as u16;
702
703        // Remove matching mono-color shards
704        let mut i = 0;
705        while i < shards.len() && remaining > 0 {
706            let shard_colors = shards[i].shard() & ManaAtom::COLORS_SUPERPOSITION;
707            // Match mono-color shards of this exact color (not hybrid/phyrexian)
708            if shard_colors == color_mask && shards[i].is_mono_color() && !shards[i].is_phyrexian()
709            {
710                shards.remove(i);
711                remaining -= 1;
712            } else {
713                i += 1;
714            }
715        }
716
717        let generic_cost = if !ignore_generic && remaining > 0 {
718            (self.generic_cost - remaining).max(0)
719        } else {
720            self.generic_cost
721        };
722
723        ManaCost {
724            shards,
725            generic_cost,
726            has_no_cost: self.has_no_cost,
727        }
728    }
729
730    pub fn combine(a: &ManaCost, b: &ManaCost) -> ManaCost {
731        let mut shards = a.shards.clone();
732        shards.extend_from_slice(&b.shards);
733        ManaCost {
734            shards,
735            generic_cost: a.generic_cost + b.generic_cost,
736            has_no_cost: false,
737        }
738    }
739}
740
741impl std::fmt::Debug for ManaCost {
742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743        write!(f, "ManaCost({self})")
744    }
745}
746
747impl std::fmt::Display for ManaCost {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        if self.has_no_cost {
750            return write!(f, "no cost");
751        }
752        // X shards first
753        for s in &self.shards {
754            if *s == ManaCostShard::X {
755                write!(f, "{s}")?;
756            }
757        }
758        if self.generic_cost > 0 || (self.generic_cost == 0 && self.shards.is_empty()) {
759            write!(f, "{{{}}}", self.generic_cost)?;
760        }
761        for s in &self.shards {
762            if *s != ManaCostShard::X {
763                write!(f, "{s}")?;
764            }
765        }
766        if self.generic_cost < 0 {
767            write!(f, " {}", self.generic_cost)?;
768        }
769        Ok(())
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn parse_simple_costs() {
779        let cost = ManaCost::parse("2 W U");
780        assert_eq!(cost.cmc(), 4);
781        assert_eq!(cost.generic_cost(), 2);
782        assert_eq!(cost.shards().len(), 2);
783        assert!(cost.color_set().has_white());
784        assert!(cost.color_set().has_blue());
785    }
786
787    #[test]
788    fn parse_zero() {
789        let cost = ManaCost::parse("0");
790        assert!(cost.is_zero());
791        assert_eq!(cost.cmc(), 0);
792        assert_eq!(format!("{}", cost), "{0}");
793    }
794
795    #[test]
796    fn parse_x_cost() {
797        let cost = ManaCost::parse("X R");
798        assert_eq!(cost.count_x(), 1);
799        assert_eq!(cost.cmc(), 1); // X contributes 0
800    }
801
802    #[test]
803    fn parse_hybrid() {
804        let cost = ManaCost::parse("1 W/U");
805        assert_eq!(cost.cmc(), 2);
806        assert_eq!(cost.shards().len(), 1);
807        assert!(cost.color_set().has_white());
808        assert!(cost.color_set().has_blue());
809    }
810
811    #[test]
812    fn parse_adjacent_multicolor_as_hybrid() {
813        // In Forge card files, "BR" (single token) means hybrid {B/R},
814        // while separate pips would be written as "B R".
815        let cost = ManaCost::parse("2 BR");
816        assert_eq!(cost.generic_cost(), 2);
817        assert_eq!(cost.shards(), &[ManaCostShard::BlackRed]);
818        assert_eq!(cost.cmc(), 3); // 2 generic + 1 hybrid
819    }
820
821    #[test]
822    fn parse_separate_pips_not_hybrid() {
823        // Space-separated "B R" means two separate color pips
824        let cost = ManaCost::parse("2 B R");
825        assert_eq!(cost.generic_cost(), 2);
826        assert_eq!(cost.shards(), &[ManaCostShard::Black, ManaCostShard::Red]);
827        assert_eq!(cost.cmc(), 4); // 2 generic + B + R
828    }
829
830    #[test]
831    fn parse_repeated_same_color_not_hybrid() {
832        // "WW" = two White pips (not hybrid)
833        let cost = ManaCost::parse("WW");
834        assert_eq!(cost.shards(), &[ManaCostShard::White, ManaCostShard::White]);
835        assert_eq!(cost.cmc(), 2);
836    }
837
838    #[test]
839    fn parse_phyrexian() {
840        // Slash-separated format (e.g. "W/P")
841        let cost = ManaCost::parse("W/P");
842        assert!(cost.has_phyrexian());
843        assert_eq!(cost.cmc(), 1);
844        // Adjacent format from card files (e.g. "GP")
845        let cost2 = ManaCost::parse("GP");
846        assert!(cost2.has_phyrexian());
847        assert_eq!(cost2.cmc(), 1);
848        assert_eq!(cost2.shards().len(), 1);
849        // Multi-shard: "1 BP BP" (Dismember)
850        let cost3 = ManaCost::parse("1 BP BP");
851        assert!(cost3.has_phyrexian());
852        assert_eq!(cost3.shards().len(), 2);
853        assert_eq!(cost3.generic_cost(), 1);
854    }
855
856    #[test]
857    fn no_cost() {
858        let cost = ManaCost::no_cost();
859        assert!(cost.is_no_cost());
860        assert_eq!(format!("{}", cost), "no cost");
861    }
862
863    #[test]
864    fn shard_parse_non_generic() {
865        assert_eq!(
866            ManaCostShard::parse_non_generic("W"),
867            Some(ManaCostShard::White)
868        );
869        assert_eq!(
870            ManaCostShard::parse_non_generic("W/U"),
871            Some(ManaCostShard::WhiteBlue)
872        );
873        assert_eq!(
874            ManaCostShard::parse_non_generic("2/W"),
875            Some(ManaCostShard::White2)
876        );
877        assert_eq!(
878            ManaCostShard::parse_non_generic("W/P"),
879            Some(ManaCostShard::WhitePhyrexian)
880        );
881    }
882
883    #[test]
884    fn shard_cmc() {
885        assert_eq!(ManaCostShard::White.cmc(), 1);
886        assert_eq!(ManaCostShard::White2.cmc(), 2);
887        assert_eq!(ManaCostShard::X.cmc(), 0);
888    }
889
890    #[test]
891    fn display_cost() {
892        let cost = ManaCost::parse("3 R R");
893        assert_eq!(format!("{}", cost), "{3}{R}{R}");
894    }
895}