//! Partner-style abilities: the ways a Commander deck can have two commanders. //! //! Read from oracle text rather than Scryfall's keyword list, which files every //! "Partner—Survivors"/"Partner—Friends forever" variant under plain "Partner" //! and so can't tell which cards may pair. The frontend mirrors [`pairs_with`] //! in `DeckView.tsx` for its legality check; keep the two in step. use serde::{Deserialize, Serialize}; /// What lets a card be one of two commanders. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "name", rename_all = "snake_case")] pub enum Partner { /// Plain "Partner": pairs with any other card with plain partner. Partner, /// "Partner—Survivors" and the like: pairs with the same variant only. Variant(String), /// "Partner with Toothy, Imaginary Friend": pairs with that card only. With(String), /// "Choose a Background": pairs with a Background. ChoosesBackground, /// A legendary Background enchantment. Background, /// "Doctor's companion": pairs with a Doctor. DoctorsCompanion, /// A legendary creature that's a Time Lord Doctor and nothing else. Doctor, } /// The card's partner-style ability, if it has one. Abilities only count at /// the start of a line, so a card that merely mentions partner (Akroma, /// Vision of Ixidor's "…and partner.") doesn't get it. pub fn parse(oracle_text: &str, type_line: &str) -> Option { for line in oracle_text.lines() { // Drop reminder text: "Partner (You can have two commanders …)". let line = line.split(" (").next().unwrap_or("").trim(); if line == "Partner" { return Some(Partner::Partner); } if let Some(name) = line.strip_prefix("Partner with ") { return Some(Partner::With(name.trim().to_string())); } if let Some(variant) = line.strip_prefix("Partner—") { return Some(Partner::Variant(variant.trim().to_string())); } if line == "Choose a Background" { return Some(Partner::ChoosesBackground); } if line == "Doctor's companion" { return Some(Partner::DoctorsCompanion); } } // The rest come from the front face's type line. let front = type_line.split(" // ").next().unwrap_or(""); if !front.contains("Legendary") { return None; } let (types, subtypes) = front.split_once(" — ").unwrap_or((front, "")); if types.contains("Enchantment") && subtypes.split_whitespace().any(|s| s == "Background") { return Some(Partner::Background); } if types.contains("Creature") && subtypes.trim() == "Time Lord Doctor" { return Some(Partner::Doctor); } None } /// Whether two cards, given by name and partner ability, can be commanders /// together. Symmetric. pub fn pairs_with(a: (&str, &Partner), b: (&str, &Partner)) -> bool { use Partner as P; match (a.1, b.1) { (P::Partner, P::Partner) => a.0 != b.0, (P::Variant(x), P::Variant(y)) => x == y && a.0 != b.0, (P::With(x), P::With(y)) => x == b.0 && y == a.0, (P::ChoosesBackground, P::Background) | (P::Background, P::ChoosesBackground) => true, (P::DoctorsCompanion, P::Doctor) | (P::Doctor, P::DoctorsCompanion) => true, _ => false, } } #[cfg(test)] mod tests { use super::*; const ELLIE: &str = "Distract the Horde — Whenever a player attacks one of your opponents, that attacking player creates a tapped 1/1 black Fungus Zombie creature token named Cordyceps Infected that's attacking that opponent.\nPartner—Survivors (You can have two commanders if both have this ability.)"; const JOEL: &str = "Menace\nWhenever a creature token dies, put a +1/+1 counter on Joel and draw a card. This ability triggers only once each turn.\nPartner—Survivors (You can have two commanders if both have this ability.)"; const SOPHINA: &str = "Menace\nWhenever Sophina, Spearsage Deserter attacks, investigate once for each nontoken attacking creature.\nPartner—Friends forever (You can have two commanders if both have this ability.)"; const KRAUM: &str = "Flying, haste\nWhenever an opponent casts their second spell each turn, draw a card.\nPartner (You can have two commanders if both have partner.)"; const TEVESH: &str = "+2: Create two 0/1 black Thrull creature tokens.\nTevesh Szat, Doom of Fools can be your commander.\nPartner"; const PIR: &str = "Partner with Toothy, Imaginary Friend (When this creature enters, target player may put Toothy into their hand from their library, then shuffle.)\nIf one or more counters would be put on a permanent your team controls, that many plus one of each of those kinds of counters are put on that permanent instead."; const TOOTHY: &str = "Partner with Pir, Imaginative Rascal (When this creature enters, target player may put Pir into their hand from their library, then shuffle.)\nWhenever you draw a card, put a +1/+1 counter on Toothy."; const AKROMA: &str = "Flying, first strike, vigilance, trample\nAt the beginning of each combat, until end of turn, each other creature you control gets +1/+1 if it has flying, +1/+1 if it has first strike, and so on for double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, protection, reach, trample, vigilance, and partner."; const WILSON: &str = "This spell can't be countered.\nReach, vigilance, trample\nChoose a Background (You can have a Background as a second commander.)"; const ROSE: &str = "Rose Tyler gets +1/+1 for each time counter on it.\nDoctor's companion (You can have two commanders if the other is the Doctor.)"; const CREATURE: &str = "Legendary Creature — Human"; fn p(text: &str, type_line: &str) -> Partner { parse(text, type_line).expect("should have a partner ability") } #[test] fn parses_each_kind() { assert_eq!(p(KRAUM, CREATURE), Partner::Partner); assert_eq!(p(TEVESH, "Legendary Planeswalker — Tevesh"), Partner::Partner); assert_eq!(p(ELLIE, CREATURE), Partner::Variant("Survivors".into())); assert_eq!(p(SOPHINA, CREATURE), Partner::Variant("Friends forever".into())); assert_eq!(p(PIR, CREATURE), Partner::With("Toothy, Imaginary Friend".into())); assert_eq!(p(WILSON, CREATURE), Partner::ChoosesBackground); assert_eq!(p(ROSE, CREATURE), Partner::DoctorsCompanion); assert_eq!(p("", "Legendary Enchantment — Background"), Partner::Background); assert_eq!(p("", "Legendary Creature — Time Lord Doctor"), Partner::Doctor); } #[test] fn mentions_and_near_misses_are_not_abilities() { assert_eq!(parse(AKROMA, CREATURE), None); assert_eq!(parse("", "Enchantment — Background"), None, "not legendary"); assert_eq!(parse("", "Legendary Creature — Human Doctor"), None, "not a Time Lord"); assert_eq!(parse("", CREATURE), None); } #[test] fn pairing_rules() { let pair = |a: (&str, &str, &str), b: (&str, &str, &str)| { pairs_with((a.0, &p(a.1, a.2)), (b.0, &p(b.1, b.2))) }; let ellie = ("Ellie, Brick Master", ELLIE, CREATURE); let joel = ("Joel, Resolute Survivor", JOEL, CREATURE); let sophina = ("Sophina, Spearsage Deserter", SOPHINA, CREATURE); let kraum = ("Kraum, Ludevic's Opus", KRAUM, CREATURE); let tevesh = ("Tevesh Szat, Doom of Fools", TEVESH, CREATURE); let pir = ("Pir, Imaginative Rascal", PIR, CREATURE); let toothy = ("Toothy, Imaginary Friend", TOOTHY, CREATURE); let wilson = ("Wilson, Refined Grizzly", WILSON, CREATURE); let giants = ("Raised by Giants", "", "Legendary Enchantment — Background"); let rose = ("Rose Tyler", ROSE, CREATURE); let doctor = ("The Tenth Doctor", "", "Legendary Creature — Time Lord Doctor"); assert!(pair(ellie, joel) && pair(joel, ellie)); assert!(!pair(ellie, sophina), "different variants"); assert!(!pair(ellie, kraum), "variant vs plain partner"); assert!(pair(kraum, tevesh)); assert!(!pair(kraum, kraum), "a card can't partner itself"); assert!(pair(pir, toothy) && pair(toothy, pir)); assert!(!pair(pir, kraum)); assert!(pair(wilson, giants) && pair(giants, wilson)); assert!(!pair(kraum, giants)); assert!(pair(rose, doctor) && pair(doctor, rose)); assert!(!pair(wilson, doctor)); } }