Partner commanders: Partner, Partner—<variant>, Partner with, Background, Doctor

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
This commit is contained in:
2026-10-23 22:29:56 -05:00
parent 5f19ddfa4a
commit bfad4c1f27
11 changed files with 526 additions and 54 deletions

View File

@@ -214,18 +214,26 @@ impl Catalog {
return Ok(vec![]);
}
let commander_oid = deck.commander_oracle_id.clone();
// The commanders: both halves of a partner pair, or just the one.
let mut commander_oids: Vec<String> = contents
.iter()
.filter(|e| e.is_commander)
.map(|e| e.card.oracle_id.clone())
.collect();
if commander_oids.is_empty() {
commander_oids.extend(deck.commander_oracle_id.clone());
}
// Color identity for the hard filter: the commander's, or (no
// Color identity for the hard filter: the commanders' combined, or (no
// commander) the union of every deck card's identity — otherwise a
// mono-color deck would be offered all five colors.
let identity_mask: u8 = match &commander_oid {
Some(cmd) => contents
let identity_mask: u8 = if commander_oids.is_empty() {
contents.iter().fold(0u8, |m, e| m | e.card.color_identity.0)
} else {
contents
.iter()
.find(|e| &e.card.oracle_id == cmd)
.map(|e| e.card.color_identity.0)
.unwrap_or(0),
None => contents.iter().fold(0u8, |m, e| m | e.card.color_identity.0),
.filter(|e| commander_oids.contains(&e.card.oracle_id))
.fold(0u8, |m, e| m | e.card.color_identity.0)
};
// --- Tribal targets: dominant creature subtypes ---
@@ -240,10 +248,10 @@ impl Catalog {
"subtype",
/* creatures_only = */ true,
)?;
let commander_subtypes = match &commander_oid {
Some(cmd) => self.creature_subtypes_of(cmd)?,
None => HashSet::new(),
};
let mut commander_subtypes = HashSet::new();
for cmd in &commander_oids {
commander_subtypes.extend(self.creature_subtypes_of(cmd)?);
}
let mut tribal_targets: HashSet<String> = tribal_counts
.iter()
.filter(|(_, &n)| n >= tribal_threshold)
@@ -253,10 +261,10 @@ impl Catalog {
// --- Mechanic targets: non-evergreen keywords ---
let keyword_counts = self.deck_attr_counts(deck_id, "card_keywords", "keyword", false)?;
let commander_keywords = match &commander_oid {
Some(cmd) => self.keywords_of(cmd)?,
None => HashSet::new(),
};
let mut commander_keywords = HashSet::new();
for cmd in &commander_oids {
commander_keywords.extend(self.keywords_of(cmd)?);
}
let mut mechanic_targets: HashSet<String> = keyword_counts
.iter()
.filter(|(k, &n)| n >= 3 && !is_evergreen(k))
@@ -810,6 +818,27 @@ mod tests {
assert!(!out.iter().any(|s| s.card.oracle_id == "black-elf"));
}
#[test]
fn partner_commanders_combine_their_identities() {
let fx = test_support::make();
let cat = &fx.catalog;
// Green Elf and white Angel as partner commanders: G+W is allowed.
let d = cat.create_deck("Partners", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, false, false).unwrap();
cat.set_deck_commanders(d.id, &["llanowar-elves", "serra-angel"]).unwrap();
add_ref_card(cat, "white-elf", "White Elf", "Creature — Elf", "W", "", &["Elf"], &[], 0, None);
add_ref_card(cat, "black-elf", "Black Elf", "Creature — Elf", "B", "", &["Elf"], &[], 0, None);
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
assert!(
out.iter().any(|s| s.card.oracle_id == "white-elf"),
"the partner's white widens the identity, and the Elf type still counts"
);
assert!(!out.iter().any(|s| s.card.oracle_id == "black-elf"));
}
#[test]
fn mana_base_fit_flags_a_missing_color() {
let fx = test_support::make();