From bfad4c1f27f1897020b4158dda5aaa90d0c830a0 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 23 Oct 2026 22:29:56 -0500 Subject: [PATCH] =?UTF-8?q?Partner=20commanders:=20Partner,=20Partner?= =?UTF-8?q?=E2=80=94,=20Partner=20with,=20Background,=20Doctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/mtgc-core/src/decklist.rs | 39 ++++---- crates/mtgc-core/src/decks.rs | 129 +++++++++++++++++++++++++- crates/mtgc-core/src/lib.rs | 1 + crates/mtgc-core/src/model.rs | 4 + crates/mtgc-core/src/partner.rs | 153 +++++++++++++++++++++++++++++++ crates/mtgc-core/src/synergy.rs | 61 ++++++++---- src-tauri/src/commands.rs | 26 ++++++ src-tauri/src/lib.rs | 2 + src/api.ts | 14 +++ src/styles.css | 4 + src/views/DeckView.tsx | 147 +++++++++++++++++++++++++---- 11 files changed, 526 insertions(+), 54 deletions(-) create mode 100644 crates/mtgc-core/src/partner.rs diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index 3f8803e..f60cf65 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -393,9 +393,10 @@ impl Catalog { /// names that don't resolve are skipped (not a hard failure) and /// collected into the returned list, alongside the created `Deck`. /// - /// Only the first resolved Commander-section line becomes the actual - /// commander; any further Commander-section lines are folded into the - /// mainboard at their given quantity rather than dropped or erroring. + /// The first two resolved Commander-section lines become the commanders + /// (a partner pair, which the legality check vets); any further + /// Commander-section lines are folded into the mainboard at their given + /// quantity rather than dropped or erroring. pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec)> { let parsed = parse_deck_text(text); let deck = self.create_deck(name, None)?; @@ -404,7 +405,7 @@ impl Catalog { } let mut unresolved = Vec::new(); - let mut commander_oracle_id: Option = None; + let mut commanders: Vec = Vec::new(); { let mut add = |line: &DecklistLine, is_commander: bool, is_maybeboard: bool| -> Result> { @@ -419,11 +420,12 @@ impl Catalog { Ok(Some(oid)) }; - for (i, line) in parsed.commander.iter().enumerate() { - let is_first = i == 0; - let oid = add(line, is_first, false)?; - if is_first { - commander_oracle_id = oid; + for line in &parsed.commander { + let is_commander = commanders.len() < 2; + if let Some(oid) = add(line, is_commander, false)? { + if is_commander { + commanders.push(oid); + } } } for line in &parsed.mainboard { @@ -433,8 +435,9 @@ impl Catalog { add(line, false, true)?; } } - if let Some(oid) = &commander_oracle_id { - self.set_deck_commander(deck.id, Some(oid))?; + if !commanders.is_empty() { + let ids: Vec<&str> = commanders.iter().map(String::as_str).collect(); + self.set_deck_commanders(deck.id, &ids)?; } let deck = self @@ -895,17 +898,19 @@ mod tests { } #[test] - fn import_deck_extra_commander_lines_fold_into_mainboard() { + fn import_deck_takes_two_commanders_and_folds_the_rest_into_mainboard() { let fx = test_support::make(); let cat = &fx.catalog; - let text = "Commander\n1 Serra Angel\n1 Sol Ring\n"; - let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap(); + let text = "Commander\n1 Serra Angel\n1 Llanowar Elves\n1 Sol Ring\n"; + let (deck, unresolved) = cat.import_deck("Three Commanders", text).unwrap(); assert!(unresolved.is_empty()); + assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel")); let contents = cat.deck_contents(deck.id).unwrap(); - let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap(); - assert!(angel.is_commander); - let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap(); + let find = |oid: &str| contents.iter().find(|e| e.card.oracle_id == oid).unwrap(); + assert!(find("serra-angel").is_commander); + assert!(find("llanowar-elves").is_commander, "a partner pair imports as two commanders"); + let sol = find("sol-ring"); assert!(!sol.is_commander); assert!(!sol.is_maybeboard); } diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 6ef9645..f8867f0 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -5,6 +5,7 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities}; +use crate::partner::{pairs_with, Partner}; use crate::Catalog; impl Catalog { @@ -129,7 +130,8 @@ impl Catalog { COALESCE(NULLIF(pp.image_uri, ''), {image}), CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END, oc.produced_mana, oc.produces_colorless, - oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id + oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id, + oc.oracle_text FROM deck_cards dc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id @@ -143,6 +145,8 @@ impl Catalog { ))?; let rows = stmt .query_map(params![deck_id], |r| { + let type_line: String = r.get(8)?; + let partner = crate::partner::parse(&r.get::<_, String>(18)?, &type_line); Ok(DeckEntry { quantity: r.get(0)?, is_commander: r.get::<_, i64>(1)? != 0, @@ -153,7 +157,7 @@ impl Catalog { mana_value: r.get(5)?, colors: ColorSet(r.get::<_, i64>(6)? as u8), color_identity: ColorSet(r.get::<_, i64>(7)? as u8), - type_line: r.get(8)?, + type_line, image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(10)?.unwrap_or_default(), produced_mana: ColorSet(r.get::<_, i64>(11)? as u8), @@ -164,6 +168,7 @@ impl Catalog { }, is_maybeboard: r.get::<_, i64>(15)? != 0, preferred_scryfall_id: r.get(17)?, + partner, }) })? .collect::, _>>()?; @@ -206,16 +211,24 @@ impl Catalog { /// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks /// the corresponding deck_cards row as the commander when present. pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> { + self.set_deck_commanders(deck_id, oracle_id.as_slice()) + } + + /// Set the deck's commanders: one, or two partners, or none. The first is + /// the deck's primary commander (`commander_oracle_id`); every one listed + /// is flagged on its deck_cards row when present, and no other card is. + /// Whether the cards may actually pair is the legality check's business. + pub fn set_deck_commanders(&self, deck_id: i64, oracle_ids: &[&str]) -> Result<()> { self.conn.execute( "UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2", - params![oracle_id, deck_id], + params![oracle_ids.first(), deck_id], )?; - // Reset any existing commander flag, then set it on the chosen card. + // Reset any existing commander flags, then set them on the chosen cards. self.conn.execute( "UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1", params![deck_id], )?; - if let Some(oid) = oracle_id { + for oid in oracle_ids { self.conn.execute( "UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2", params![deck_id, oid], @@ -223,6 +236,44 @@ impl Catalog { } Ok(()) } + + /// Narrow `candidates` (typically a name search) to the cards that could be + /// a second commander alongside `commander_oracle_id`, keeping their order. + pub fn partner_candidates( + &self, + commander_oracle_id: &str, + candidates: Vec, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT name, oracle_text, type_line FROM {REF_DB}.oracle_cards WHERE oracle_id = ?1" + ))?; + let mut lookup = |oid: &str| -> Result> { + let row = stmt + .query_row(params![oid], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)) + }) + .optional()?; + Ok(row.and_then(|(name, text, type_line)| { + crate::partner::parse(&text, &type_line).map(|p| (name, p)) + })) + }; + let Some((cmd_name, cmd_partner)) = lookup(commander_oracle_id)? else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for card in candidates { + if out.len() >= limit { + break; + } + if let Some((name, partner)) = lookup(&card.oracle_id)? { + if pairs_with((&cmd_name, &cmd_partner), (&name, &partner)) { + out.push(card); + } + } + } + Ok(out) + } } #[cfg(test)] @@ -456,4 +507,72 @@ mod tests { // Duplicating into an already-taken name fails (UNIQUE). assert!(cat.duplicate_deck(src.id, "Original").is_err()); } + + #[test] + fn set_deck_commanders_flags_each_and_keeps_the_first_as_primary() { + let fx = test_support::make(); + let cat = &fx.catalog; + let d = cat.create_deck("Pair", None).unwrap(); + for oid in ["serra-angel", "llanowar-elves", "sol-ring"] { + cat.set_deck_card(d.id, oid, 1, false, false).unwrap(); + } + let commanders = |id| -> Vec { + let mut v: Vec<_> = cat + .deck_contents(id) + .unwrap() + .into_iter() + .filter(|e| e.is_commander) + .map(|e| e.card.oracle_id) + .collect(); + v.sort(); + v + }; + + cat.set_deck_commanders(d.id, &["serra-angel", "llanowar-elves"]).unwrap(); + assert_eq!(cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id.as_deref(), Some("serra-angel")); + assert_eq!(commanders(d.id), vec!["llanowar-elves", "serra-angel"]); + + // Dropping the primary promotes the partner. + cat.set_deck_commanders(d.id, &["llanowar-elves"]).unwrap(); + assert_eq!(cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id.as_deref(), Some("llanowar-elves")); + assert_eq!(commanders(d.id), vec!["llanowar-elves"]); + + cat.set_deck_commander(d.id, None).unwrap(); + assert_eq!(cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id, None); + assert!(commanders(d.id).is_empty()); + } + + #[test] + fn deck_entries_carry_partner_and_candidates_are_filtered_by_it() { + use crate::partner::Partner; + let fx = test_support::make(); + let cat = &fx.catalog; + let conn = cat.connection(); + conn.execute( + "UPDATE ref.oracle_cards SET oracle_text = 'Flying\nPartner—Survivors (You can have two commanders if both have this ability.)' + WHERE oracle_id IN ('serra-angel', 'llanowar-elves')", + [], + ) + .unwrap(); + conn.execute( + "UPDATE ref.oracle_cards SET oracle_text = 'Partner—Friends forever' + WHERE oracle_id = 'deathrite-shaman'", + [], + ) + .unwrap(); + + let d = cat.create_deck("Survivors", None).unwrap(); + cat.set_deck_card(d.id, "serra-angel", 1, true, false).unwrap(); + let contents = cat.deck_contents(d.id).unwrap(); + assert_eq!(contents[0].partner, Some(Partner::Variant("Survivors".into()))); + + let all = cat.build_name_index().unwrap().search("e", 50); + let found: Vec<_> = cat + .partner_candidates("serra-angel", all, 10) + .unwrap() + .into_iter() + .map(|c| c.oracle_id) + .collect(); + assert_eq!(found, vec!["llanowar-elves"], "same variant only, and not itself"); + } } diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 162115a..5167c2e 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod library; pub mod manabox; pub mod model; pub mod news; +pub mod partner; pub mod printings; pub mod search; pub mod sync; diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 94f992f..b2ab0e4 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -214,6 +214,10 @@ pub struct DeckEntry { /// card's `image_uri`/`back_image_uri` already reflect it (falling back /// to the default when the id no longer resolves). pub preferred_scryfall_id: Option, + /// The card's partner-style ability, which lets it share the command + /// zone with a second commander. + #[serde(default)] + pub partner: Option, } /// One printing of a card, for the printing picker and copy labels. diff --git a/crates/mtgc-core/src/partner.rs b/crates/mtgc-core/src/partner.rs new file mode 100644 index 0000000..0519eca --- /dev/null +++ b/crates/mtgc-core/src/partner.rs @@ -0,0 +1,153 @@ +//! 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)); + } +} diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs index 91d4b3b..f261624 100644 --- a/crates/mtgc-core/src/synergy.rs +++ b/crates/mtgc-core/src/synergy.rs @@ -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 = 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 = 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 = 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(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 16a1827..99c82cd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -329,6 +329,32 @@ pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option) -> Re .map_err(|e| e.to_string()) } +/// Set the deck's commanders — one, or a partner pair — first is primary. +#[tauri::command] +pub fn set_deck_commanders(db: Db, deck_id: i64, oracle_ids: Vec) -> Res<()> { + let s = db.lock().unwrap(); + let ids: Vec<&str> = oracle_ids.iter().map(String::as_str).collect(); + s.catalog()? + .set_deck_commanders(deck_id, &ids) + .map_err(|e| e.to_string()) +} + +/// Name search narrowed to cards that could partner with the given commander. +/// Searches wide before filtering, since most name matches won't qualify. +#[tauri::command] +pub fn search_partners( + db: Db, + commander_oracle_id: String, + query: String, + limit: usize, +) -> Res> { + let s = db.lock().unwrap(); + let candidates = s.index()?.search(&query, 500); + s.catalog()? + .partner_candidates(&commander_oracle_id, candidates, limit) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn set_deck_card( db: Db, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0f5ae5..a0e712d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,8 @@ pub fn run() { commands::duplicate_deck, commands::set_deck_format, commands::set_deck_commander, + commands::set_deck_commanders, + commands::search_partners, commands::set_deck_card, commands::deck_contents, commands::suggest_cards, diff --git a/src/api.ts b/src/api.ts index d90cce0..a32294e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -29,6 +29,13 @@ export interface LibraryCard extends CardRef { location: string; } +/** A partner-style ability, letting a card share the command zone with a + * second commander. Mirrors `mtgc_core::partner::Partner`. */ +export type Partner = + | { kind: "partner" | "chooses_background" | "background" | "doctors_companion" | "doctor" } + /** "Partner—Survivors" and the like (`variant`); "Partner with " (`with`). */ + | { kind: "variant" | "with"; name: string }; + export interface DeckEntry extends CardRef { quantity: number; is_commander: boolean; @@ -38,6 +45,7 @@ export interface DeckEntry extends CardRef { quantity_owned: number; /** The printing whose art this slot shows; `image_uri` already reflects it. */ preferred_scryfall_id: string | null; + partner: Partner | null; } /** One printing of a card, for the printing picker and copy labels. */ @@ -487,6 +495,12 @@ export const api = { invoke("set_deck_format", { deckId, format }), setDeckCommander: (deckId: number, oracleId: string | null) => invoke("set_deck_commander", { deckId, oracleId }), + /** One commander, or a partner pair; the first is the deck's primary. */ + setDeckCommanders: (deckId: number, oracleIds: string[]) => + invoke("set_deck_commanders", { deckId, oracleIds }), + /** Name search narrowed to cards that could partner with the commander. */ + searchPartners: (commanderOracleId: string, query: string, limit: number) => + invoke("search_partners", { commanderOracleId, query, limit }), setDeckCard: ( deckId: number, oracleId: string, diff --git a/src/styles.css b/src/styles.css index e34aedd..8c49eb8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2587,6 +2587,10 @@ tbody tr:hover { } .deck-top-commander { grid-area: commander; + /* One hero per commander: a partner pair stacks. */ + display: flex; + flex-direction: column; + gap: 16px; } .deck-top-meta { grid-area: meta; diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 18ef79e..9fc272b 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -1,6 +1,17 @@ import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, libraryKey, PreviewContext } from "../api"; +import { + api, + CardRef, + COLORS, + Deck, + DeckEntry, + DeckImportResult, + legalStatusFor, + libraryKey, + Partner, + PreviewContext, +} from "../api"; import { CardSearchBox, CardThumb, @@ -201,11 +212,55 @@ function CommanderHero({ {card.type_line} - + ); } +/** Whether two cards can be commanders together. Mirrors `pairs_with` in + * crates/mtgc-core/src/partner.rs — keep the two in step. */ +function pairsWith(a: DeckEntry, b: DeckEntry): boolean { + const p = a.partner; + const q = b.partner; + if (!p || !q) return false; + switch (p.kind) { + case "partner": + return q.kind === "partner" && a.oracle_id !== b.oracle_id; + case "variant": + return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id; + case "with": + return q.kind === "with" && p.name === b.name && q.name === a.name; + case "chooses_background": + return q.kind === "background"; + case "background": + return q.kind === "chooses_background"; + case "doctors_companion": + return q.kind === "doctor"; + case "doctor": + return q.kind === "doctors_companion"; + } +} + +/** What the second-commander search box asks for, given the first's ability. */ +function partnerPlaceholder(partner: Partner): string { + switch (partner.kind) { + case "chooses_background": + return "Search to add a Background…"; + case "background": + return "Search to add a commander that chooses a Background…"; + case "doctors_companion": + return "Search to add a Doctor…"; + case "doctor": + return "Search to add a Doctor's companion…"; + case "with": + return `Search to add ${partner.name}…`; + case "variant": + return `Search to add a partner (${partner.name})…`; + case "partner": + return "Search to add a partner…"; + } +} + // How long a card decremented to 0 copies lingers (dimmed, still adjustable) // before actually dropping out of the deck — same grace-period pattern as // the Library tab, so a mistaken removal can be undone with +. @@ -273,13 +328,15 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[ ); } + // Two commanders are allowed when their partner abilities pair them up. const commanders = cards.filter((e) => e.is_commander); if (commanders.length === 0) { reasons.push(`No commander is set.`); - } else if (commanders.length > 1) { - reasons.push(`More than one card is marked as commander.`); + } else if (commanders.length > 2) { + reasons.push(`More than two cards are marked as commanders.`); + } else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) { + reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`); } - const commander = commanders[0]; for (const e of cards) { if (e.is_commander) continue; @@ -290,12 +347,14 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[ } } - if (commander) { + if (commanders.length > 0) { + const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0); + const whose = commanders.length > 1 ? "commanders'" : "commander's"; for (const e of cards) { if (e.is_commander) continue; - const outsideIdentity = e.color_identity & ~commander.color_identity; + const outsideIdentity = e.color_identity & ~identity; if (outsideIdentity !== 0) { - reasons.push(`${e.name}'s color identity is outside the commander's color identity.`); + reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`); } } } @@ -389,6 +448,8 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { const [anyResults, setAnyResults] = useState([]); const [cmdQuery, setCmdQuery] = useState(""); const [cmdResults, setCmdResults] = useState([]); + const [partnerQuery, setPartnerQuery] = useState(""); + const [partnerResults, setPartnerResults] = useState([]); const [maybeQuery, setMaybeQuery] = useState(""); const [maybeResults, setMaybeResults] = useState([]); @@ -497,6 +558,16 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { return () => clearTimeout(t); }, [cmdQuery]); + // The primary commander, whose partner ability the second search box serves. + const partnerOf = + deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null; + useEffect(() => { + const q = partnerQuery.trim(); + if (!q || !partnerOf) return setPartnerResults([]); + const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80); + return () => clearTimeout(t); + }, [partnerQuery, partnerOf]); + async function createDeck() { if (!newName.trim()) return; const d = await api.createDeck(newName.trim(), null); @@ -563,13 +634,16 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { } /** Move a card between the mainboard and the maybeboard. Clears commander - * status (and the deck's commander, if this was it) when maybeboarding. */ + * status (dropping it from the deck's commanders) when maybeboarding. */ async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) { if (selected === null) return; const isCommander = isMaybeboard ? false : entry.is_commander; await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard); - if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) { - await api.setDeckCommander(selected, null); + if (isMaybeboard && entry.is_commander) { + await api.setDeckCommanders( + selected, + commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id), + ); await loadDecks(); } await loadContents(); @@ -721,16 +795,40 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { // commander — covers both "not in the deck yet" and "was maybeboarded". const existing = contents.find((e) => e.oracle_id === card.oracle_id); await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false); - await api.setDeckCommander(selected, card.oracle_id); + // Keep the partner only if it can still pair with the new commander; if + // not, it stays in the deck as an ordinary card. + const picked = (await api.deckContents(selected)).find((e) => e.oracle_id === card.oracle_id); + const keepPartner = + partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry); + await api.setDeckCommanders( + selected, + keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id], + ); setCmdQuery(""); setCmdResults([]); await loadContents(); await loadDecks(); } - async function clearCommander() { + async function addPartner(card: CardRef) { + if (selected === null || !commanderEntry) return; + const existing = contents.find((e) => e.oracle_id === card.oracle_id); + await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false); + await api.setDeckCommanders(selected, [commanderEntry.oracle_id, card.oracle_id]); + setPartnerQuery(""); + setPartnerResults([]); + await loadContents(); + await loadDecks(); + } + + /** Stop treating a card as a commander; it stays in the deck. Clearing the + * primary commander promotes its partner. */ + async function clearCommander(entry: DeckEntry) { if (selected === null) return; - await api.setDeckCommander(selected, null); + await api.setDeckCommanders( + selected, + commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id), + ); await loadContents(); await loadDecks(); } @@ -747,7 +845,13 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e)); const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e)); const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e)); - const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id); + // The primary commander first, then its partner, if any. + const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id); + const commanders = contents + .filter((e) => e.is_commander && !e.is_maybeboard) + .sort((a, b) => isPrimary(b) - isPrimary(a)); + const commanderEntry: DeckEntry | undefined = commanders[0]; + const partnerEntry: DeckEntry | undefined = commanders[1]; const deckStats = useMemo(() => computeDeckStats(contents), [contents]); const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]); @@ -977,7 +1081,9 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
- + {commanders.map((c) => ( + clearCommander(c)} preview={preview} /> + ))}
)} @@ -1009,6 +1115,15 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { results={cmdResults} onPick={setCommander} /> + {commanderEntry?.partner && !partnerEntry && ( + + )}
)}