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:
@@ -393,9 +393,10 @@ impl Catalog {
|
|||||||
/// names that don't resolve are skipped (not a hard failure) and
|
/// names that don't resolve are skipped (not a hard failure) and
|
||||||
/// collected into the returned list, alongside the created `Deck`.
|
/// collected into the returned list, alongside the created `Deck`.
|
||||||
///
|
///
|
||||||
/// Only the first resolved Commander-section line becomes the actual
|
/// The first two resolved Commander-section lines become the commanders
|
||||||
/// commander; any further Commander-section lines are folded into the
|
/// (a partner pair, which the legality check vets); any further
|
||||||
/// mainboard at their given quantity rather than dropped or erroring.
|
/// 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<String>)> {
|
pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec<String>)> {
|
||||||
let parsed = parse_deck_text(text);
|
let parsed = parse_deck_text(text);
|
||||||
let deck = self.create_deck(name, None)?;
|
let deck = self.create_deck(name, None)?;
|
||||||
@@ -404,7 +405,7 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut unresolved = Vec::new();
|
let mut unresolved = Vec::new();
|
||||||
let mut commander_oracle_id: Option<String> = None;
|
let mut commanders: Vec<String> = Vec::new();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut add = |line: &DecklistLine, is_commander: bool, is_maybeboard: bool| -> Result<Option<String>> {
|
let mut add = |line: &DecklistLine, is_commander: bool, is_maybeboard: bool| -> Result<Option<String>> {
|
||||||
@@ -419,11 +420,12 @@ impl Catalog {
|
|||||||
Ok(Some(oid))
|
Ok(Some(oid))
|
||||||
};
|
};
|
||||||
|
|
||||||
for (i, line) in parsed.commander.iter().enumerate() {
|
for line in &parsed.commander {
|
||||||
let is_first = i == 0;
|
let is_commander = commanders.len() < 2;
|
||||||
let oid = add(line, is_first, false)?;
|
if let Some(oid) = add(line, is_commander, false)? {
|
||||||
if is_first {
|
if is_commander {
|
||||||
commander_oracle_id = oid;
|
commanders.push(oid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for line in &parsed.mainboard {
|
for line in &parsed.mainboard {
|
||||||
@@ -433,8 +435,9 @@ impl Catalog {
|
|||||||
add(line, false, true)?;
|
add(line, false, true)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(oid) = &commander_oracle_id {
|
if !commanders.is_empty() {
|
||||||
self.set_deck_commander(deck.id, Some(oid))?;
|
let ids: Vec<&str> = commanders.iter().map(String::as_str).collect();
|
||||||
|
self.set_deck_commanders(deck.id, &ids)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let deck = self
|
let deck = self
|
||||||
@@ -895,17 +898,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 fx = test_support::make();
|
||||||
let cat = &fx.catalog;
|
let cat = &fx.catalog;
|
||||||
let text = "Commander\n1 Serra Angel\n1 Sol Ring\n";
|
let text = "Commander\n1 Serra Angel\n1 Llanowar Elves\n1 Sol Ring\n";
|
||||||
let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap();
|
let (deck, unresolved) = cat.import_deck("Three Commanders", text).unwrap();
|
||||||
assert!(unresolved.is_empty());
|
assert!(unresolved.is_empty());
|
||||||
|
assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel"));
|
||||||
|
|
||||||
let contents = cat.deck_contents(deck.id).unwrap();
|
let contents = cat.deck_contents(deck.id).unwrap();
|
||||||
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
|
let find = |oid: &str| contents.iter().find(|e| e.card.oracle_id == oid).unwrap();
|
||||||
assert!(angel.is_commander);
|
assert!(find("serra-angel").is_commander);
|
||||||
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
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_commander);
|
||||||
assert!(!sol.is_maybeboard);
|
assert!(!sol.is_maybeboard);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use rusqlite::{params, OptionalExtension};
|
|||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
|
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
|
||||||
|
use crate::partner::{pairs_with, Partner};
|
||||||
use crate::Catalog;
|
use crate::Catalog;
|
||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
@@ -129,7 +130,8 @@ impl Catalog {
|
|||||||
COALESCE(NULLIF(pp.image_uri, ''), {image}),
|
COALESCE(NULLIF(pp.image_uri, ''), {image}),
|
||||||
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
|
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
|
||||||
oc.produced_mana, oc.produces_colorless,
|
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
|
FROM deck_cards dc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
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
|
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||||
@@ -143,6 +145,8 @@ impl Catalog {
|
|||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![deck_id], |r| {
|
.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 {
|
Ok(DeckEntry {
|
||||||
quantity: r.get(0)?,
|
quantity: r.get(0)?,
|
||||||
is_commander: r.get::<_, i64>(1)? != 0,
|
is_commander: r.get::<_, i64>(1)? != 0,
|
||||||
@@ -153,7 +157,7 @@ impl Catalog {
|
|||||||
mana_value: r.get(5)?,
|
mana_value: r.get(5)?,
|
||||||
colors: ColorSet(r.get::<_, i64>(6)? as u8),
|
colors: ColorSet(r.get::<_, i64>(6)? as u8),
|
||||||
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
||||||
type_line: r.get(8)?,
|
type_line,
|
||||||
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||||
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
||||||
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
||||||
@@ -164,6 +168,7 @@ impl Catalog {
|
|||||||
},
|
},
|
||||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||||
preferred_scryfall_id: r.get(17)?,
|
preferred_scryfall_id: r.get(17)?,
|
||||||
|
partner,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -206,16 +211,24 @@ impl Catalog {
|
|||||||
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
|
||||||
/// the corresponding deck_cards row as the commander when present.
|
/// the corresponding deck_cards row as the commander when present.
|
||||||
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
|
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(
|
self.conn.execute(
|
||||||
"UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2",
|
"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(
|
self.conn.execute(
|
||||||
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
|
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
|
||||||
params![deck_id],
|
params![deck_id],
|
||||||
)?;
|
)?;
|
||||||
if let Some(oid) = oracle_id {
|
for oid in oracle_ids {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
|
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
|
||||||
params![deck_id, oid],
|
params![deck_id, oid],
|
||||||
@@ -223,6 +236,44 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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<CardRef>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<CardRef>> {
|
||||||
|
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<Option<(String, Partner)>> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
@@ -456,4 +507,72 @@ mod tests {
|
|||||||
// Duplicating into an already-taken name fails (UNIQUE).
|
// Duplicating into an already-taken name fails (UNIQUE).
|
||||||
assert!(cat.duplicate_deck(src.id, "Original").is_err());
|
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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod library;
|
|||||||
pub mod manabox;
|
pub mod manabox;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod news;
|
pub mod news;
|
||||||
|
pub mod partner;
|
||||||
pub mod printings;
|
pub mod printings;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ pub struct DeckEntry {
|
|||||||
/// card's `image_uri`/`back_image_uri` already reflect it (falling back
|
/// card's `image_uri`/`back_image_uri` already reflect it (falling back
|
||||||
/// to the default when the id no longer resolves).
|
/// to the default when the id no longer resolves).
|
||||||
pub preferred_scryfall_id: Option<String>,
|
pub preferred_scryfall_id: Option<String>,
|
||||||
|
/// The card's partner-style ability, which lets it share the command
|
||||||
|
/// zone with a second commander.
|
||||||
|
#[serde(default)]
|
||||||
|
pub partner: Option<crate::partner::Partner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One printing of a card, for the printing picker and copy labels.
|
/// One printing of a card, for the printing picker and copy labels.
|
||||||
|
|||||||
153
crates/mtgc-core/src/partner.rs
Normal file
153
crates/mtgc-core/src/partner.rs
Normal file
@@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,18 +214,26 @@ impl Catalog {
|
|||||||
return Ok(vec![]);
|
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
|
// commander) the union of every deck card's identity — otherwise a
|
||||||
// mono-color deck would be offered all five colors.
|
// mono-color deck would be offered all five colors.
|
||||||
let identity_mask: u8 = match &commander_oid {
|
let identity_mask: u8 = if commander_oids.is_empty() {
|
||||||
Some(cmd) => contents
|
contents.iter().fold(0u8, |m, e| m | e.card.color_identity.0)
|
||||||
|
} else {
|
||||||
|
contents
|
||||||
.iter()
|
.iter()
|
||||||
.find(|e| &e.card.oracle_id == cmd)
|
.filter(|e| commander_oids.contains(&e.card.oracle_id))
|
||||||
.map(|e| e.card.color_identity.0)
|
.fold(0u8, |m, e| m | e.card.color_identity.0)
|
||||||
.unwrap_or(0),
|
|
||||||
None => contents.iter().fold(0u8, |m, e| m | e.card.color_identity.0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tribal targets: dominant creature subtypes ---
|
// --- Tribal targets: dominant creature subtypes ---
|
||||||
@@ -240,10 +248,10 @@ impl Catalog {
|
|||||||
"subtype",
|
"subtype",
|
||||||
/* creatures_only = */ true,
|
/* creatures_only = */ true,
|
||||||
)?;
|
)?;
|
||||||
let commander_subtypes = match &commander_oid {
|
let mut commander_subtypes = HashSet::new();
|
||||||
Some(cmd) => self.creature_subtypes_of(cmd)?,
|
for cmd in &commander_oids {
|
||||||
None => HashSet::new(),
|
commander_subtypes.extend(self.creature_subtypes_of(cmd)?);
|
||||||
};
|
}
|
||||||
let mut tribal_targets: HashSet<String> = tribal_counts
|
let mut tribal_targets: HashSet<String> = tribal_counts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, &n)| n >= tribal_threshold)
|
.filter(|(_, &n)| n >= tribal_threshold)
|
||||||
@@ -253,10 +261,10 @@ impl Catalog {
|
|||||||
|
|
||||||
// --- Mechanic targets: non-evergreen keywords ---
|
// --- Mechanic targets: non-evergreen keywords ---
|
||||||
let keyword_counts = self.deck_attr_counts(deck_id, "card_keywords", "keyword", false)?;
|
let keyword_counts = self.deck_attr_counts(deck_id, "card_keywords", "keyword", false)?;
|
||||||
let commander_keywords = match &commander_oid {
|
let mut commander_keywords = HashSet::new();
|
||||||
Some(cmd) => self.keywords_of(cmd)?,
|
for cmd in &commander_oids {
|
||||||
None => HashSet::new(),
|
commander_keywords.extend(self.keywords_of(cmd)?);
|
||||||
};
|
}
|
||||||
let mut mechanic_targets: HashSet<String> = keyword_counts
|
let mut mechanic_targets: HashSet<String> = keyword_counts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(k, &n)| n >= 3 && !is_evergreen(k))
|
.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"));
|
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]
|
#[test]
|
||||||
fn mana_base_fit_flags_a_missing_color() {
|
fn mana_base_fit_flags_a_missing_color() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -329,6 +329,32 @@ pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option<String>) -> Re
|
|||||||
.map_err(|e| e.to_string())
|
.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<String>) -> 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<Vec<CardRef>> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub fn set_deck_card(
|
pub fn set_deck_card(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ pub fn run() {
|
|||||||
commands::duplicate_deck,
|
commands::duplicate_deck,
|
||||||
commands::set_deck_format,
|
commands::set_deck_format,
|
||||||
commands::set_deck_commander,
|
commands::set_deck_commander,
|
||||||
|
commands::set_deck_commanders,
|
||||||
|
commands::search_partners,
|
||||||
commands::set_deck_card,
|
commands::set_deck_card,
|
||||||
commands::deck_contents,
|
commands::deck_contents,
|
||||||
commands::suggest_cards,
|
commands::suggest_cards,
|
||||||
|
|||||||
14
src/api.ts
14
src/api.ts
@@ -29,6 +29,13 @@ export interface LibraryCard extends CardRef {
|
|||||||
location: string;
|
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 <name>" (`with`). */
|
||||||
|
| { kind: "variant" | "with"; name: string };
|
||||||
|
|
||||||
export interface DeckEntry extends CardRef {
|
export interface DeckEntry extends CardRef {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
is_commander: boolean;
|
is_commander: boolean;
|
||||||
@@ -38,6 +45,7 @@ export interface DeckEntry extends CardRef {
|
|||||||
quantity_owned: number;
|
quantity_owned: number;
|
||||||
/** The printing whose art this slot shows; `image_uri` already reflects it. */
|
/** The printing whose art this slot shows; `image_uri` already reflects it. */
|
||||||
preferred_scryfall_id: string | null;
|
preferred_scryfall_id: string | null;
|
||||||
|
partner: Partner | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One printing of a card, for the printing picker and copy labels. */
|
/** One printing of a card, for the printing picker and copy labels. */
|
||||||
@@ -487,6 +495,12 @@ export const api = {
|
|||||||
invoke<void>("set_deck_format", { deckId, format }),
|
invoke<void>("set_deck_format", { deckId, format }),
|
||||||
setDeckCommander: (deckId: number, oracleId: string | null) =>
|
setDeckCommander: (deckId: number, oracleId: string | null) =>
|
||||||
invoke<void>("set_deck_commander", { deckId, oracleId }),
|
invoke<void>("set_deck_commander", { deckId, oracleId }),
|
||||||
|
/** One commander, or a partner pair; the first is the deck's primary. */
|
||||||
|
setDeckCommanders: (deckId: number, oracleIds: string[]) =>
|
||||||
|
invoke<void>("set_deck_commanders", { deckId, oracleIds }),
|
||||||
|
/** Name search narrowed to cards that could partner with the commander. */
|
||||||
|
searchPartners: (commanderOracleId: string, query: string, limit: number) =>
|
||||||
|
invoke<CardRef[]>("search_partners", { commanderOracleId, query, limit }),
|
||||||
setDeckCard: (
|
setDeckCard: (
|
||||||
deckId: number,
|
deckId: number,
|
||||||
oracleId: string,
|
oracleId: string,
|
||||||
|
|||||||
@@ -2587,6 +2587,10 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
.deck-top-commander {
|
.deck-top-commander {
|
||||||
grid-area: commander;
|
grid-area: commander;
|
||||||
|
/* One hero per commander: a partner pair stacks. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.deck-top-meta {
|
.deck-top-meta {
|
||||||
grid-area: meta;
|
grid-area: meta;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
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 {
|
import {
|
||||||
CardSearchBox,
|
CardSearchBox,
|
||||||
CardThumb,
|
CardThumb,
|
||||||
@@ -201,11 +212,55 @@ function CommanderHero({
|
|||||||
<span className="commander-hero-type">{card.type_line}</span>
|
<span className="commander-hero-type">{card.type_line}</span>
|
||||||
<ColorPips mask={card.color_identity} />
|
<ColorPips mask={card.color_identity} />
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
|
<IconButton icon={Icons.close} label={`Clear ${card.name} as commander`} onClick={onClear} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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)
|
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
||||||
// before actually dropping out of the deck — same grace-period pattern as
|
// before actually dropping out of the deck — same grace-period pattern as
|
||||||
// the Library tab, so a mistaken removal can be undone with +.
|
// 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);
|
const commanders = cards.filter((e) => e.is_commander);
|
||||||
if (commanders.length === 0) {
|
if (commanders.length === 0) {
|
||||||
reasons.push(`No commander is set.`);
|
reasons.push(`No commander is set.`);
|
||||||
} else if (commanders.length > 1) {
|
} else if (commanders.length > 2) {
|
||||||
reasons.push(`More than one card is marked as commander.`);
|
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) {
|
for (const e of cards) {
|
||||||
if (e.is_commander) continue;
|
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) {
|
for (const e of cards) {
|
||||||
if (e.is_commander) continue;
|
if (e.is_commander) continue;
|
||||||
const outsideIdentity = e.color_identity & ~commander.color_identity;
|
const outsideIdentity = e.color_identity & ~identity;
|
||||||
if (outsideIdentity !== 0) {
|
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<CardRef[]>([]);
|
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
|
||||||
const [cmdQuery, setCmdQuery] = useState("");
|
const [cmdQuery, setCmdQuery] = useState("");
|
||||||
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
||||||
|
const [partnerQuery, setPartnerQuery] = useState("");
|
||||||
|
const [partnerResults, setPartnerResults] = useState<CardRef[]>([]);
|
||||||
const [maybeQuery, setMaybeQuery] = useState("");
|
const [maybeQuery, setMaybeQuery] = useState("");
|
||||||
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
||||||
|
|
||||||
@@ -497,6 +558,16 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [cmdQuery]);
|
}, [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() {
|
async function createDeck() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
const d = await api.createDeck(newName.trim(), null);
|
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
|
/** 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) {
|
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
|
||||||
if (selected === null) return;
|
if (selected === null) return;
|
||||||
const isCommander = isMaybeboard ? false : entry.is_commander;
|
const isCommander = isMaybeboard ? false : entry.is_commander;
|
||||||
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
|
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
|
||||||
if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) {
|
if (isMaybeboard && entry.is_commander) {
|
||||||
await api.setDeckCommander(selected, null);
|
await api.setDeckCommanders(
|
||||||
|
selected,
|
||||||
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
||||||
|
);
|
||||||
await loadDecks();
|
await loadDecks();
|
||||||
}
|
}
|
||||||
await loadContents();
|
await loadContents();
|
||||||
@@ -721,16 +795,40 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
||||||
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
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.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("");
|
setCmdQuery("");
|
||||||
setCmdResults([]);
|
setCmdResults([]);
|
||||||
await loadContents();
|
await loadContents();
|
||||||
await loadDecks();
|
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;
|
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 loadContents();
|
||||||
await loadDecks();
|
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 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 unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
|
||||||
const maybeboard = contents.filter((e) => e.is_maybeboard && 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 deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||||
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
||||||
|
|
||||||
@@ -977,7 +1081,9 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
<div className="deck-top-grid">
|
<div className="deck-top-grid">
|
||||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||||
<div className="deck-top-commander">
|
<div className="deck-top-commander">
|
||||||
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
|
{commanders.map((c) => (
|
||||||
|
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} preview={preview} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1009,6 +1115,15 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
results={cmdResults}
|
results={cmdResults}
|
||||||
onPick={setCommander}
|
onPick={setCommander}
|
||||||
/>
|
/>
|
||||||
|
{commanderEntry?.partner && !partnerEntry && (
|
||||||
|
<CardSearchBox
|
||||||
|
placeholder={partnerPlaceholder(commanderEntry.partner)}
|
||||||
|
value={partnerQuery}
|
||||||
|
onChange={setPartnerQuery}
|
||||||
|
results={partnerResults}
|
||||||
|
onPick={addPartner}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user