A Commander/Brawl deck can now have two commanders when their abilities allow it. Partner abilities are parsed from oracle text (Scryfall's keyword list files every "Partner—Survivors"-style variant under plain "Partner"), and a second search box offers only cards that can pair with the commander. The legality check accepts a valid pair and checks color identity against both; suggestions and decklist import/export handle both commanders. Closes #4
154 lines
8.4 KiB
Rust
154 lines
8.4 KiB
Rust
//! 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<Partner> {
|
|
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));
|
|
}
|
|
}
|