forge-foundation 0.2.0

Core Magic types for the manabrew engine: colors, mana, card types, zones, phases
Documentation
use serde::{Deserialize, Serialize};

use super::booster_slots::BoosterSlots;
use super::foil_type::FoilType;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SealedTemplate {
    pub name: Option<String>,
    pub slots: Vec<(String, u32)>,

    pub foil_chance: f64,
    pub foil_type: FoilType,
    pub foil_always_in_common_slot: bool,
    pub additional_sheet_for_foils: Option<String>,
    pub chance_replace_common_with: f64,
    pub slot_replace_common_with: Option<String>,
    pub booster_must_contain: Option<String>,
    pub booster_replace_slot_from_print_sheet: Option<String>,
    pub sheet_replace_card_from_sheet: Option<String>,
    pub sheet_replace_card_from_sheet2: Option<String>,
}

impl SealedTemplate {
    pub fn new(name: Option<String>, slots: Vec<(String, u32)>) -> Self {
        Self {
            name,
            slots,
            foil_chance: 0.0,
            foil_type: FoilType::NotSupported,
            foil_always_in_common_slot: false,
            additional_sheet_for_foils: None,
            chance_replace_common_with: 0.0,
            slot_replace_common_with: None,
            booster_must_contain: None,
            booster_replace_slot_from_print_sheet: None,
            sheet_replace_card_from_sheet: None,
            sheet_replace_card_from_sheet2: None,
        }
    }

    pub fn generic_draft_booster() -> Self {
        Self {
            name: None,
            slots: vec![
                (BoosterSlots::COMMON.to_string(), 10),
                (BoosterSlots::UNCOMMON.to_string(), 3),
                (BoosterSlots::RARE_MYTHIC.to_string(), 1),
                (BoosterSlots::BASIC_LAND.to_string(), 1),
            ],
            foil_chance: 1.0 / 3.0,
            foil_type: FoilType::Modern,
            foil_always_in_common_slot: false,
            additional_sheet_for_foils: None,
            chance_replace_common_with: 0.0,
            slot_replace_common_with: None,
            booster_must_contain: None,
            booster_replace_slot_from_print_sheet: None,
            sheet_replace_card_from_sheet: None,
            sheet_replace_card_from_sheet2: None,
        }
    }

    pub fn generic_no_slot_booster() -> Self {
        Self::new(None, vec![(BoosterSlots::ANY.to_string(), 15)])
    }

    pub fn number_of_cards_expected(&self) -> u32 {
        self.slots.iter().map(|(_, n)| n).sum()
    }

    pub fn has_slot(&self, s: &str) -> bool {
        self.slots
            .iter()
            .any(|(slot_name, _)| slot_name.split([' ', ':', '!']).next() == Some(s))
    }

    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    pub fn parse_line(line: &str) -> Option<Self> {
        let (head, data) = line.split_once(':')?;
        Some(Self::new(Some(head.trim().to_string()), parse_slots(data)))
    }
}

pub fn parse_slots(data: &str) -> Vec<(String, u32)> {
    let mut out = Vec::new();
    for chunk in split_with_parens(data, ',') {
        let trimmed = chunk.trim();
        if trimmed.is_empty() {
            continue;
        }
        let mut parts = split_with_parens(trimmed, ' ');
        let count_str = match parts.next() {
            Some(s) => s,
            None => continue,
        };
        let count: u32 = match count_str.trim().parse() {
            Ok(n) => n,
            Err(_) => continue,
        };
        let slot_name: String = parts.collect::<Vec<_>>().join(" ").replace(';', ",");
        if !slot_name.is_empty() {
            out.push((slot_name, count));
        }
    }
    out
}

fn split_with_parens(s: &str, sep: char) -> std::vec::IntoIter<&str> {
    let mut depth = 0i32;
    let mut start = 0usize;
    let mut out = Vec::new();
    for (i, c) in s.char_indices() {
        match c {
            '(' => depth += 1,
            ')' => depth = depth.saturating_sub(1),
            ch if ch == sep && depth == 0 => {
                out.push(&s[start..i]);
                start = i + ch.len_utf8();
            }
            _ => {}
        }
    }
    out.push(&s[start..]);
    out.into_iter()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generic_draft_has_15_cards() {
        let t = SealedTemplate::generic_draft_booster();
        assert_eq!(t.number_of_cards_expected(), 15);
    }

    #[test]
    fn generic_no_slot_has_15_cards() {
        let t = SealedTemplate::generic_no_slot_booster();
        assert_eq!(t.number_of_cards_expected(), 15);
        assert!(t.has_slot(BoosterSlots::ANY));
    }

    #[test]
    fn parses_template_line() {
        let t = SealedTemplate::parse_line("M21:10 Common, 3 Uncommon, 1 RareMythic, 1 BasicLand")
            .unwrap();
        assert_eq!(t.name.as_deref(), Some("M21"));
        assert_eq!(t.slots.len(), 4);
        assert_eq!(t.slots[0], ("Common".to_string(), 10));
        assert_eq!(t.number_of_cards_expected(), 15);
    }

    #[test]
    fn semicolons_in_slot_become_commas() {
        let t = SealedTemplate::parse_line("X:1 Foo;Bar").unwrap();
        assert_eq!(t.slots[0].0, "Foo,Bar");
    }
}