Add deck synergy suggestions, a responsive deck layout, and undoable library bulk-add
Suggestion scoring is fully offline (tribal/mechanic/archetype/mana-base signals derived at ingest, no external synergy database), with an "unusual picks" mode that surfaces underappreciated cards instead of staples. The deck page's top panels now use a CSS grid so wide windows don't waste horizontal space. Library's bulk add-to-deck can clobber existing deck quantities, so it now offers an undo (snackbar + command palette, the latter with no timeout) that restores each card's prior state exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -438,6 +438,10 @@ struct ScryOracle {
|
||||
/// "restricted"). See [`crate::model::Legalities`].
|
||||
#[serde(default)]
|
||||
legalities: std::collections::HashMap<String, String>,
|
||||
/// Scryfall's EDHREC popularity rank (lower = more played); absent for
|
||||
/// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`.
|
||||
#[serde(default)]
|
||||
edhrec_rank: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -457,8 +461,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
let mut ins_card = conn.prepare(
|
||||
"INSERT OR IGNORE INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||
produced_mana,produces_colorless,legalities)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
|
||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
@@ -538,6 +542,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
|
||||
|
||||
let legalities = Legalities::from_scryfall(&c.legalities);
|
||||
let themes = crate::synergy::compute_themes(&oracle_text);
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
@@ -551,6 +556,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
produced_mana.0 as i64,
|
||||
produces_colorless as i64,
|
||||
legalities.0,
|
||||
themes,
|
||||
c.edhrec_rank,
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
@@ -971,6 +978,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_themes_and_edhrec_rank() {
|
||||
let oracle = r#"[
|
||||
{"oracle_id":"oid-sac","name":"Sac Outlet","cmc":2.0,"color_identity":["B"],"keywords":[],
|
||||
"oracle_text":"Whenever a creature you control dies, each opponent loses 1 life.","edhrec_rank":1200},
|
||||
{"oracle_id":"oid-vanilla","name":"Grizzly Bears","cmc":2.0,"color_identity":["G"],"keywords":[],
|
||||
"oracle_text":"You gain 2 life."}
|
||||
]"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("reference.db");
|
||||
let conn = open_reference_for_build(&dest).unwrap();
|
||||
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
|
||||
|
||||
let fetch = |id: &str| -> (i64, Option<i64>) {
|
||||
conn.query_row(
|
||||
"SELECT themes, edhrec_rank FROM oracle_cards WHERE oracle_id=?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (sac_themes, sac_rank) = fetch("oid-sac");
|
||||
assert_ne!(sac_themes & 1, 0, "Sacrifice bit set from payoff phrasing");
|
||||
assert_eq!(sac_rank, Some(1200));
|
||||
|
||||
let (van_themes, van_rank) = fetch("oid-vanilla");
|
||||
// "You gain 2 life." is an action, not the "whenever you gain life"
|
||||
// payoff, so no theme bit; and no edhrec_rank present → NULL.
|
||||
assert_eq!(van_themes, 0);
|
||||
assert_eq!(van_rank, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_back_face_image_for_double_faced_cards() {
|
||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod ingest;
|
||||
pub mod library;
|
||||
pub mod model;
|
||||
pub mod search;
|
||||
pub mod synergy;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -25,6 +26,7 @@ pub use model::{
|
||||
LibraryFilter, LibraryStats,
|
||||
};
|
||||
pub use search::NameIndex;
|
||||
pub use synergy::{CardSuggestion, SynergyReason};
|
||||
|
||||
/// A handle to an open catalog: the library db with the reference db attached.
|
||||
///
|
||||
|
||||
@@ -13,7 +13,9 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
oracle_text TEXT NOT NULL DEFAULT '',
|
||||
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
|
||||
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
||||
legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||
legalities INTEGER NOT NULL DEFAULT 0, -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||
themes INTEGER NOT NULL DEFAULT 0, -- archetype bitmask, see synergy::ARCHETYPES
|
||||
edhrec_rank INTEGER -- Scryfall EDHREC popularity rank; NULL if unranked
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||
|
||||
791
crates/mtgc-core/src/synergy.rs
Normal file
791
crates/mtgc-core/src/synergy.rs
Normal file
@@ -0,0 +1,791 @@
|
||||
//! Deck synergy scoring and card suggestions.
|
||||
//!
|
||||
//! Given a deck, suggests cards from the whole reference database that fit its
|
||||
//! strategy, with an explainable score broken into four families — **Tribal**
|
||||
//! (shared creature types), **Mechanic** (shared non-evergreen keywords),
|
||||
//! **Archetype** (amplifying strategy overlap via oracle-text patterns), and
|
||||
//! **Mana base** (colors the deck casts but can't produce) — plus Scryfall's
|
||||
//! own `edhrec_rank` as a popularity tiebreaker.
|
||||
//!
|
||||
//! Everything is derived from data already ingested from Scryfall's bulk files
|
||||
//! (there is no runtime network access or external synergy database). Two
|
||||
//! choices keep this aging well as new sets are printed:
|
||||
//!
|
||||
//! - **Keywords use a denylist, not an allowlist.** Scryfall's keyword array
|
||||
//! (in [`card_keywords`]) mixes true keywords (Flying) with ability words
|
||||
//! that name archetypes (Landfall, Magecraft, Convoke...). Rather than
|
||||
//! curating which keywords are archetypes — a list that must chase every new
|
||||
//! mechanic — we exclude a small stable set of generic evergreen keywords
|
||||
//! ([`EVERGREEN_KEYWORDS`]) and treat every *other* keyword the deck leans on
|
||||
//! as a synergy signal. New mechanics are covered automatically the moment
|
||||
//! their cards are ingested.
|
||||
//! - **The one curated list ([`ARCHETYPES`]) matches payoffs, not actions, and
|
||||
//! only covers amplifying strategies** that were never given a keyword
|
||||
//! (Sacrifice, Tokens, Graveyard...). Matching reward phrasing ("whenever you
|
||||
//! gain life") rather than commonplace action phrasing ("gain life") keeps it
|
||||
//! discriminating; scoping it to amplifying archetypes (not universal
|
||||
//! goodstuff like removal/ramp) keeps suggestions synergistic rather than
|
||||
//! generic. These are old, stable strategies, so this list needs review
|
||||
//! roughly never.
|
||||
//!
|
||||
//! [`card_keywords`]: crate::db
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::params;
|
||||
use rusqlite::types::Value;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::{CardRef, ColorSet, Legalities, WUBRG};
|
||||
use crate::Catalog;
|
||||
|
||||
/// Curated, evergreen amplifying archetypes with no official keyword, detected
|
||||
/// by payoff/enabler phrasing in oracle text. Bit position = array index,
|
||||
/// packed into the `oracle_cards.themes` column at ingest. Deliberately narrow
|
||||
/// and payoff-focused (see module docs): substring matching over rules text is
|
||||
/// inherently fuzzy — false positives/negatives are expected and acceptable;
|
||||
/// this is one signal among four, not truth.
|
||||
pub const ARCHETYPES: [(&str, &[&str]); 12] = [
|
||||
("Sacrifice", &["sacrifice a creature", "sacrifice another creature", "whenever a creature you control dies", "whenever another creature you control dies", "sacrifice a permanent"]),
|
||||
("Graveyard", &["return target creature card from your graveyard", "from your graveyard to the battlefield", "cards in your graveyard", "creature card from your graveyard"]),
|
||||
("Tokens", &["create a", "create two", "create three", "create x", "for each creature token", "for each token you control"]),
|
||||
("+1/+1 Counters", &["+1/+1 counter"]),
|
||||
("Artifacts", &["whenever an artifact", "for each artifact you control", "artifacts you control", "an artifact enters"]),
|
||||
("Enchantments", &["whenever an enchantment", "for each enchantment you control", "enchantments you control", "an enchantment enters"]),
|
||||
("Spellslinger", &["whenever you cast an instant or sorcery", "whenever you cast a noncreature spell", "for each instant and sorcery", "instant and sorcery cards"]),
|
||||
("Lifegain", &["whenever you gain life", "if you gained life", "life you gained this turn", "for each 1 life"]),
|
||||
("Extra Combat", &["additional combat phase", "an additional combat"]),
|
||||
("Voltron", &["equipped creature", "enchanted creature you control", "whenever equipped creature", "aura you control"]),
|
||||
("Mill", &["puts the top", "mills", "milled", "into their graveyard from the top of their library"]),
|
||||
("Blink", &["exile it, then return it to the battlefield", "exile target creature you control, then return", "exile any number of target creatures you control"]),
|
||||
];
|
||||
|
||||
/// Generic evergreen keywords that do NOT indicate a build-around strategy;
|
||||
/// every keyword in Scryfall's array that is NOT on this list is treated as a
|
||||
/// synergy signal (see module docs). Stable — the evergreen set changes rarely
|
||||
/// and deliberately.
|
||||
pub const EVERGREEN_KEYWORDS: [&str; 20] = [
|
||||
"Flying", "Trample", "Deathtouch", "Vigilance", "Haste", "Lifelink",
|
||||
"Menace", "Reach", "Defender", "First strike", "Double strike",
|
||||
"Hexproof", "Indestructible", "Flash", "Ward", "Protection", "Shroud",
|
||||
"Fear", "Intimidate", "Skulk",
|
||||
];
|
||||
|
||||
/// Pack the [`ARCHETYPES`] bits a card's oracle text matches. Called at ingest
|
||||
/// (keyword synergy is queried live from `card_keywords`, not baked in here, so
|
||||
/// new mechanics need no re-ingest schema change).
|
||||
pub fn compute_themes(oracle_text: &str) -> i64 {
|
||||
let lower = oracle_text.to_lowercase();
|
||||
let mut mask = 0i64;
|
||||
for (i, (_, phrases)) in ARCHETYPES.iter().enumerate() {
|
||||
if phrases.iter().any(|p| lower.contains(p)) {
|
||||
mask |= 1 << i;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// One scored reason a card was suggested (e.g. `Tribal` / "Shares creature
|
||||
/// type: Elf (9 in deck)").
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SynergyReason {
|
||||
pub category: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// A suggested card with its synergy score and the reasons behind it. Unlike
|
||||
/// [`crate::model::LibraryCard`]/[`crate::model::DeckEntry`], the `card` is
|
||||
/// nested rather than `#[serde(flatten)]`-ed: this is a single-purpose result
|
||||
/// carrying a paragraph of reasons, not a general card row reused everywhere,
|
||||
/// so nesting reads cleaner.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CardSuggestion {
|
||||
pub card: CardRef,
|
||||
pub quantity_owned: i64,
|
||||
pub score: f64,
|
||||
pub reasons: Vec<SynergyReason>,
|
||||
/// Scryfall's EDHREC popularity rank (lower = more common in Commander);
|
||||
/// a sort tiebreaker, not a scored category. `None` when unranked.
|
||||
pub edhrec_rank: Option<i64>,
|
||||
}
|
||||
|
||||
// Scoring weights, ordered by reliability x synergy strength. Mana is lowest
|
||||
// because it's utility/fixing, not amplifying synergy, but is kept because it
|
||||
// was explicitly requested. Coarse on purpose: many candidates tie exactly,
|
||||
// which lets `edhrec_rank` float the played staples of a strategy to the top.
|
||||
const TRIBAL_POINTS: f64 = 4.0;
|
||||
const MECHANIC_POINTS: f64 = 3.0;
|
||||
const ARCHETYPE_POINTS: f64 = 2.0;
|
||||
const MANA_POINTS: f64 = 1.0;
|
||||
const TRIBAL_CAP: usize = 2;
|
||||
const MECHANIC_CAP: usize = 2;
|
||||
const ARCHETYPE_CAP: usize = 3;
|
||||
|
||||
/// `edhrec_rank` cutoff below which a card counts as "widely played" — kept
|
||||
/// in sync with the frontend's "Popular" badge threshold (`SuggestionsModal.tsx`).
|
||||
/// Used by `unusual` mode to both exclude staples from the candidate pool and
|
||||
/// to invert the popularity tiebreak toward the least-played matches.
|
||||
const UNUSUAL_RANK_THRESHOLD: i64 = 2000;
|
||||
|
||||
/// A candidate row pulled from the reference db before scoring.
|
||||
struct Candidate {
|
||||
card: CardRef,
|
||||
themes: i64,
|
||||
edhrec_rank: Option<i64>,
|
||||
quantity_owned: i64,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Suggest up to `limit` cards (skipping the first `offset`, for a
|
||||
/// "suggest more" continuation) that synergize with `deck_id`, scored and
|
||||
/// explained across the four synergy families. Pulls from the whole
|
||||
/// reference db (ownership is flagged via `quantity_owned`, not filtered),
|
||||
/// excludes cards already on any board of the deck, and hard-filters to the
|
||||
/// deck's (or commander's) color identity.
|
||||
///
|
||||
/// Recomputes target signals and rescoring from scratch each call — cheap
|
||||
/// enough for an on-demand action, not a hot path — so `offset` is only a
|
||||
/// stable continuation as long as the deck's own contents haven't changed
|
||||
/// between calls (adding a suggested card mid-session removes it from the
|
||||
/// candidate pool and can shift later pages by one; acceptable for this
|
||||
/// feature's scope).
|
||||
///
|
||||
/// When `unusual` is set, candidates ranked below [`UNUSUAL_RANK_THRESHOLD`]
|
||||
/// (Scryfall's own popularity signal) or entirely unranked are favored
|
||||
/// instead of the usual "float the staples" behavior, surfacing
|
||||
/// underappreciated cards that still fit the deck's strategy.
|
||||
pub fn suggest_cards(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
unusual: bool,
|
||||
) -> Result<Vec<CardSuggestion>> {
|
||||
// The `themes`/`edhrec_rank` columns only exist in a reference.db built
|
||||
// after this feature shipped; an older ingest lacks them. Detect that
|
||||
// and return a clear, actionable message instead of a raw "no such
|
||||
// column" error (reference.db is rebuilt, not migrated, on ingest).
|
||||
let has_synergy_columns: bool = self.conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_table_info('oracle_cards', '{REF_DB}') WHERE name = 'themes'"),
|
||||
[],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if !has_synergy_columns {
|
||||
return Err(CoreError::Other(
|
||||
"Card suggestions need updated card data — click \"Update card data\" to enable them.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(deck) = self.get_deck(deck_id)? else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let contents: Vec<_> = self
|
||||
.deck_contents(deck_id)?
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_maybeboard)
|
||||
.collect();
|
||||
if contents.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let commander_oid = deck.commander_oracle_id.clone();
|
||||
|
||||
// Color identity for the hard filter: the commander's, 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
|
||||
.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),
|
||||
};
|
||||
|
||||
// --- Tribal targets: dominant creature subtypes ---
|
||||
let creature_count = contents
|
||||
.iter()
|
||||
.filter(|e| e.card.type_line.contains("Creature"))
|
||||
.count();
|
||||
let tribal_threshold = std::cmp::max(3, ((creature_count as f64) * 0.15).ceil() as i64);
|
||||
let tribal_counts = self.deck_attr_counts(
|
||||
deck_id,
|
||||
"card_subtypes",
|
||||
"subtype",
|
||||
/* creatures_only = */ true,
|
||||
)?;
|
||||
let commander_subtypes = match &commander_oid {
|
||||
Some(cmd) => self.creature_subtypes_of(cmd)?,
|
||||
None => HashSet::new(),
|
||||
};
|
||||
let mut tribal_targets: HashSet<String> = tribal_counts
|
||||
.iter()
|
||||
.filter(|(_, &n)| n >= tribal_threshold)
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
tribal_targets.extend(commander_subtypes.iter().cloned());
|
||||
|
||||
// --- 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 mechanic_targets: HashSet<String> = keyword_counts
|
||||
.iter()
|
||||
.filter(|(k, &n)| n >= 3 && !is_evergreen(k))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for k in &commander_keywords {
|
||||
if !is_evergreen(k) {
|
||||
mechanic_targets.insert(k.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Archetype targets: theme bits on >=2 cards, or the commander's ---
|
||||
let mut theme_counts = [0i64; ARCHETYPES.len()];
|
||||
let mut archetype_mask = 0i64;
|
||||
{
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT oc.themes, dc.is_commander
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
WHERE dc.deck_id = ?1 AND dc.is_maybeboard = 0"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![deck_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? != 0)))?;
|
||||
for row in rows {
|
||||
let (themes, is_cmd) = row?;
|
||||
for (i, c) in theme_counts.iter_mut().enumerate() {
|
||||
if themes & (1 << i) != 0 {
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
if is_cmd {
|
||||
archetype_mask |= themes;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, &c) in theme_counts.iter().enumerate() {
|
||||
if c >= 2 {
|
||||
archetype_mask |= 1 << i;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mana-base targets: colors cast but not produced ---
|
||||
let mut production = [0i64; 5];
|
||||
let mut consumption = [0i64; 5];
|
||||
for e in contents.iter().filter(|e| !e.is_commander) {
|
||||
let q = e.quantity;
|
||||
for (i, (bit, _)) in WUBRG.iter().enumerate() {
|
||||
if e.card.produced_mana.0 & *bit != 0 {
|
||||
production[i] += q;
|
||||
}
|
||||
}
|
||||
if !e.card.type_line.contains("Land") {
|
||||
for (i, (bit, _)) in WUBRG.iter().enumerate() {
|
||||
if e.card.colors.0 & *bit != 0 {
|
||||
consumption[i] += q;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut mana_mask = 0u8;
|
||||
for (i, (bit, _)) in WUBRG.iter().enumerate() {
|
||||
if consumption[i] > 0 && production[i] == 0 {
|
||||
mana_mask |= *bit;
|
||||
}
|
||||
}
|
||||
|
||||
if tribal_targets.is_empty()
|
||||
&& mechanic_targets.is_empty()
|
||||
&& archetype_mask == 0
|
||||
&& mana_mask == 0
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// --- Candidate query (dynamic, mirroring filters.rs::search_library) ---
|
||||
let mut sql = format!(
|
||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line,
|
||||
{image}, {back}, oc.produced_mana, oc.produces_colorless, oc.legalities, {price},
|
||||
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0)
|
||||
FROM {REF_DB}.oracle_cards oc
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||
WHERE oc.oracle_id NOT IN (SELECT oracle_id FROM deck_cards WHERE deck_id = ?)
|
||||
AND (oc.color_identity & ?) = 0
|
||||
AND (",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
);
|
||||
let mut vals: Vec<Value> = vec![
|
||||
Value::Integer(deck_id),
|
||||
Value::Integer((!identity_mask & 0b11111) as i64),
|
||||
];
|
||||
let mut branches: Vec<String> = Vec::new();
|
||||
if !tribal_targets.is_empty() {
|
||||
let ph = placeholders(tribal_targets.len());
|
||||
branches.push(format!(
|
||||
"EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes cs WHERE cs.oracle_id = oc.oracle_id AND cs.subtype IN ({ph}))"
|
||||
));
|
||||
vals.extend(tribal_targets.iter().map(|s| Value::Text(s.clone())));
|
||||
}
|
||||
if !mechanic_targets.is_empty() {
|
||||
let ph = placeholders(mechanic_targets.len());
|
||||
branches.push(format!(
|
||||
"EXISTS (SELECT 1 FROM {REF_DB}.card_keywords ck WHERE ck.oracle_id = oc.oracle_id AND ck.keyword IN ({ph}))"
|
||||
));
|
||||
vals.extend(mechanic_targets.iter().map(|k| Value::Text(k.clone())));
|
||||
}
|
||||
if archetype_mask != 0 {
|
||||
branches.push("(oc.themes & ?) != 0".into());
|
||||
vals.push(Value::Integer(archetype_mask));
|
||||
}
|
||||
if mana_mask != 0 {
|
||||
branches.push("(oc.produced_mana & ?) != 0".into());
|
||||
vals.push(Value::Integer(mana_mask as i64));
|
||||
}
|
||||
sql.push_str(&branches.join(" OR "));
|
||||
sql.push(')');
|
||||
if unusual {
|
||||
sql.push_str(" AND (oc.edhrec_rank IS NULL OR oc.edhrec_rank >= ?)");
|
||||
vals.push(Value::Integer(UNUSUAL_RANK_THRESHOLD));
|
||||
}
|
||||
|
||||
let candidates: Vec<Candidate> = {
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(vals), |r| {
|
||||
Ok(Candidate {
|
||||
card: CardRef {
|
||||
oracle_id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
mana_value: r.get(2)?,
|
||||
colors: ColorSet(r.get::<_, i64>(3)? as u8),
|
||||
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
|
||||
type_line: r.get(5)?,
|
||||
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
|
||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||
},
|
||||
themes: r.get(12)?,
|
||||
edhrec_rank: r.get(13)?,
|
||||
quantity_owned: r.get(14)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
// Batch-load each candidate's subtypes/keywords (avoids per-card N+1).
|
||||
let cand_ids: Vec<String> = candidates.iter().map(|c| c.card.oracle_id.clone()).collect();
|
||||
let sub_map = self.batch_attr(&cand_ids, "card_subtypes", "subtype")?;
|
||||
let kw_map = self.batch_attr(&cand_ids, "card_keywords", "keyword")?;
|
||||
let empty: Vec<String> = Vec::new();
|
||||
|
||||
let mut out: Vec<CardSuggestion> = Vec::new();
|
||||
for cand in candidates {
|
||||
let oid = &cand.card.oracle_id;
|
||||
let mut score = 0.0;
|
||||
let mut reasons = Vec::new();
|
||||
|
||||
// Tribal
|
||||
let mut tribal_hits: Vec<&String> = sub_map
|
||||
.get(oid)
|
||||
.unwrap_or(&empty)
|
||||
.iter()
|
||||
.filter(|s| tribal_targets.contains(*s))
|
||||
.collect();
|
||||
tribal_hits.sort();
|
||||
for s in tribal_hits.into_iter().take(TRIBAL_CAP) {
|
||||
score += TRIBAL_POINTS;
|
||||
let detail = if commander_subtypes.contains(s) {
|
||||
format!("Matches your commander's type: {s}")
|
||||
} else {
|
||||
let n = tribal_counts.get(s).copied().unwrap_or(0);
|
||||
format!("Shares creature type: {s} ({n} in deck)")
|
||||
};
|
||||
reasons.push(SynergyReason { category: "Tribal".into(), detail });
|
||||
}
|
||||
|
||||
// Mechanic
|
||||
let mut mech_hits: Vec<&String> = kw_map
|
||||
.get(oid)
|
||||
.unwrap_or(&empty)
|
||||
.iter()
|
||||
.filter(|k| mechanic_targets.contains(*k))
|
||||
.collect();
|
||||
mech_hits.sort();
|
||||
for k in mech_hits.into_iter().take(MECHANIC_CAP) {
|
||||
score += MECHANIC_POINTS;
|
||||
let detail = if commander_keywords.contains(k) {
|
||||
format!("Matches your commander's keyword: {k}")
|
||||
} else {
|
||||
let n = keyword_counts.get(k).copied().unwrap_or(0);
|
||||
format!("Shares keyword: {k} ({n} in deck)")
|
||||
};
|
||||
reasons.push(SynergyReason { category: "Mechanic".into(), detail });
|
||||
}
|
||||
|
||||
// Archetype
|
||||
let matched = cand.themes & archetype_mask;
|
||||
let mut arche = 0;
|
||||
for (i, (name, _)) in ARCHETYPES.iter().enumerate() {
|
||||
if arche >= ARCHETYPE_CAP {
|
||||
break;
|
||||
}
|
||||
if matched & (1 << i) != 0 {
|
||||
score += ARCHETYPE_POINTS;
|
||||
reasons.push(SynergyReason {
|
||||
category: "Archetype".into(),
|
||||
detail: format!("{name} payoff/enabler"),
|
||||
});
|
||||
arche += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Mana base
|
||||
let mana_hit = cand.card.produced_mana.0 & mana_mask;
|
||||
if mana_hit != 0 {
|
||||
score += MANA_POINTS;
|
||||
let letters: String = WUBRG
|
||||
.iter()
|
||||
.filter(|(bit, _)| mana_hit & bit != 0)
|
||||
.map(|(_, c)| format!("{{{c}}}"))
|
||||
.collect();
|
||||
reasons.push(SynergyReason {
|
||||
category: "Mana base".into(),
|
||||
detail: format!("Produces {letters}, which your deck can't currently make"),
|
||||
});
|
||||
}
|
||||
|
||||
if score <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
out.push(CardSuggestion {
|
||||
card: cand.card,
|
||||
quantity_owned: cand.quantity_owned,
|
||||
score,
|
||||
reasons,
|
||||
edhrec_rank: cand.edhrec_rank,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| rank_key(a.edhrec_rank, unusual).cmp(&rank_key(b.edhrec_rank, unusual)))
|
||||
.then_with(|| a.card.name.to_lowercase().cmp(&b.card.name.to_lowercase()))
|
||||
});
|
||||
let out = out.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Count, per attribute value, how many of a deck's non-maybeboard cards
|
||||
/// have it. `table`/`column` are fixed call-site literals (safe to
|
||||
/// interpolate). `creatures_only` restricts to cards whose type line is a
|
||||
/// creature (for tribal subtypes — keeps land/artifact subtypes out).
|
||||
fn deck_attr_counts(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
table: &str,
|
||||
column: &str,
|
||||
creatures_only: bool,
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
let creature_filter = if creatures_only {
|
||||
"AND oc.type_line LIKE '%Creature%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT x.{column}, COUNT(DISTINCT dc.oracle_id) AS n
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
JOIN {REF_DB}.{table} x ON x.oracle_id = dc.oracle_id
|
||||
WHERE dc.deck_id = ?1 AND dc.is_maybeboard = 0 {creature_filter}
|
||||
GROUP BY x.{column}"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![deck_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let (k, n) = row?;
|
||||
map.insert(k, n);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn creature_subtypes_of(&self, oracle_id: &str) -> Result<HashSet<String>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT cs.subtype FROM {REF_DB}.card_subtypes cs
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = cs.oracle_id
|
||||
WHERE cs.oracle_id = ?1 AND oc.type_line LIKE '%Creature%'"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![oracle_id], |r| r.get::<_, String>(0))?;
|
||||
Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
|
||||
}
|
||||
|
||||
fn keywords_of(&self, oracle_id: &str) -> Result<HashSet<String>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&format!("SELECT keyword FROM {REF_DB}.card_keywords WHERE oracle_id = ?1"))?;
|
||||
let rows = stmt.query_map(params![oracle_id], |r| r.get::<_, String>(0))?;
|
||||
Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// Load an attribute (`subtype`/`keyword`) for many oracle_ids at once,
|
||||
/// chunked under SQLite's bound-variable limit.
|
||||
fn batch_attr(
|
||||
&self,
|
||||
ids: &[String],
|
||||
table: &str,
|
||||
column: &str,
|
||||
) -> Result<HashMap<String, Vec<String>>> {
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for chunk in ids.chunks(900) {
|
||||
let ph = placeholders(chunk.len());
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT oracle_id, {column} FROM {REF_DB}.{table} WHERE oracle_id IN ({ph})"
|
||||
))?;
|
||||
let vals: Vec<Value> = chunk.iter().map(|s| Value::Text(s.clone())).collect();
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(vals), |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (oid, v) = row?;
|
||||
map.entry(oid).or_default().push(v);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_evergreen(keyword: &str) -> bool {
|
||||
EVERGREEN_KEYWORDS.iter().any(|e| e.eq_ignore_ascii_case(keyword))
|
||||
}
|
||||
|
||||
fn placeholders(n: usize) -> String {
|
||||
std::iter::repeat("?").take(n).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
/// Sort key for the edhrec_rank tiebreak. Normally pushes unranked cards
|
||||
/// (`None`) to the end, so within a tied score the played staples float up.
|
||||
/// In `unusual` mode this is inverted — unranked cards sort first, followed
|
||||
/// by the highest (least popular) ranks — so the least-played matches float
|
||||
/// up instead, which is the entire point of that mode.
|
||||
fn rank_key(rank: Option<i64>, unusual: bool) -> i64 {
|
||||
if unusual {
|
||||
rank.map(|r| -r).unwrap_or(i64::MIN)
|
||||
} else {
|
||||
rank.unwrap_or(i64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::ColorSet;
|
||||
use crate::test_support;
|
||||
|
||||
/// Raw-insert an extra reference card (the shared fixture doesn't cover
|
||||
/// themes/edhrec_rank/produced_mana). Mirrors library.rs's raw-SQL test
|
||||
/// style; keeps `test_support` untouched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_ref_card(
|
||||
cat: &Catalog,
|
||||
oid: &str,
|
||||
name: &str,
|
||||
type_line: &str,
|
||||
identity: &str,
|
||||
produced: &str,
|
||||
subtypes: &[&str],
|
||||
keywords: &[&str],
|
||||
themes: i64,
|
||||
rank: Option<i64>,
|
||||
) {
|
||||
let conn = cat.connection();
|
||||
conn.execute(
|
||||
"INSERT INTO ref.oracle_cards
|
||||
(oracle_id,name,name_lower,type_line,colors,color_identity,produced_mana,themes,edhrec_rank)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![
|
||||
oid,
|
||||
name,
|
||||
name.to_lowercase(),
|
||||
type_line,
|
||||
ColorSet::parse(identity).0 as i64,
|
||||
ColorSet::parse(identity).0 as i64,
|
||||
ColorSet::parse(produced).0 as i64,
|
||||
themes,
|
||||
rank,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ref.printings(scryfall_id,oracle_id,image_uri) VALUES (?1,?2,?3)",
|
||||
params![format!("p-{oid}"), oid, format!("http://img/{oid}")],
|
||||
)
|
||||
.unwrap();
|
||||
for s in subtypes {
|
||||
conn.execute(
|
||||
"INSERT INTO ref.card_subtypes(oracle_id,subtype) VALUES (?1,?2)",
|
||||
params![oid, s],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for k in keywords {
|
||||
conn.execute(
|
||||
"INSERT INTO ref.card_keywords(oracle_id,keyword) VALUES (?1,?2)",
|
||||
params![oid, k],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_themes_matches_payoff_phrasing_not_actions() {
|
||||
// Sacrifice payoff phrasing sets its bit.
|
||||
let m = compute_themes("Whenever a creature you control dies, draw a card.");
|
||||
assert_ne!(m & 1, 0); // Sacrifice is bit 0
|
||||
// Plain "gain life" must NOT trip the Lifegain bit (payoff phrasing only).
|
||||
let lifegain_bit = 1 << ARCHETYPES.iter().position(|(n, _)| *n == "Lifegain").unwrap();
|
||||
assert_eq!(compute_themes("You gain 3 life.") & lifegain_bit, 0);
|
||||
assert_ne!(compute_themes("Whenever you gain life, put a +1/+1 counter.") & lifegain_bit, 0);
|
||||
// Vanilla text sets nothing.
|
||||
assert_eq!(compute_themes("Flying, vigilance"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tribal_suggestion_with_reasons_excludes_deck_cards_and_breaks_ties_by_rank() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
// Three Elf creatures in the deck (fixture has two Elves; add a third).
|
||||
add_ref_card(cat, "elf-c", "Elf Cleric", "Creature — Elf Cleric", "G", "", &["Elf"], &[], 0, None);
|
||||
// Two unowned Elf candidates with different popularity.
|
||||
add_ref_card(cat, "elf-lord", "Elf Lord", "Creature — Elf", "G", "", &["Elf"], &[], 0, Some(5));
|
||||
add_ref_card(cat, "elf-rare", "Obscure Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, Some(9000));
|
||||
|
||||
let d = cat.create_deck("Elfball", None).unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "elf-c", 1, false, false).unwrap();
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect();
|
||||
|
||||
// Both Elf candidates appear with a Tribal reason mentioning Elf.
|
||||
assert!(ids.contains(&"elf-lord"));
|
||||
assert!(ids.contains(&"elf-rare"));
|
||||
let lord = out.iter().find(|s| s.card.oracle_id == "elf-lord").unwrap();
|
||||
assert!(lord.reasons.iter().any(|r| r.category == "Tribal" && r.detail.contains("Elf")));
|
||||
// Cards already in the deck are never suggested.
|
||||
assert!(!ids.contains(&"llanowar-elves"));
|
||||
assert!(!ids.contains(&"deathrite-shaman"));
|
||||
// Equal score → the lower edhrec_rank sorts first.
|
||||
let li = ids.iter().position(|x| *x == "elf-lord").unwrap();
|
||||
let ri = ids.iter().position(|x| *x == "elf-rare").unwrap();
|
||||
assert!(li < ri);
|
||||
|
||||
// `unusual` mode excludes the widely-played candidate entirely and
|
||||
// keeps the obscure one.
|
||||
add_ref_card(cat, "elf-unranked", "Unranked Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
let out = cat.suggest_cards(d.id, 50, 0, true).unwrap();
|
||||
let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect();
|
||||
assert!(!ids.contains(&"elf-lord"), "rank 5 is well below the unusual threshold");
|
||||
assert!(ids.contains(&"elf-rare"));
|
||||
assert!(ids.contains(&"elf-unranked"));
|
||||
// Equal score → the tiebreak flips toward the least popular first.
|
||||
let ui = ids.iter().position(|x| *x == "elf-unranked").unwrap();
|
||||
let ri = ids.iter().position(|x| *x == "elf-rare").unwrap();
|
||||
assert!(ui < ri, "unranked should float above a merely-obscure ranked card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_tribal_splash_does_not_trigger_tribe() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
// A deck with just the two fixture Elves plus many non-Elf creatures,
|
||||
// so Elf (2) is below the proportional threshold.
|
||||
let d = cat.create_deck("Goodstuff", None).unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
for i in 0..10 {
|
||||
let oid = format!("beast-{i}");
|
||||
add_ref_card(cat, &oid, &format!("Beast {i}"), "Creature", "G", "", &[], &[], 0, None);
|
||||
cat.set_deck_card(d.id, &oid, 1, false, false).unwrap();
|
||||
}
|
||||
// An Elf candidate whose only possible signal is tribal.
|
||||
add_ref_card(cat, "lone-elf", "Lone Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
assert!(!out.iter().any(|s| s.card.oracle_id == "lone-elf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mechanic_uses_denylist_evergreen_excluded_others_included() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Landfall", None).unwrap();
|
||||
// Three deck creatures with both Landfall (mechanic) and Flying (evergreen).
|
||||
for i in 0..3 {
|
||||
let oid = format!("land-guy-{i}");
|
||||
add_ref_card(cat, &oid, &format!("Land Guy {i}"), "Creature — Elemental", "G", "", &[], &["Landfall", "Flying"], 0, None);
|
||||
cat.set_deck_card(d.id, &oid, 1, false, false).unwrap();
|
||||
}
|
||||
add_ref_card(cat, "landfall-payoff", "Landfall Payoff", "Creature — Elemental", "G", "", &[], &["Landfall"], 0, None);
|
||||
add_ref_card(cat, "just-a-flyer", "Just A Flyer", "Creature — Bird", "G", "", &[], &["Flying"], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let payoff = out.iter().find(|s| s.card.oracle_id == "landfall-payoff");
|
||||
assert!(payoff.is_some(), "non-evergreen keyword should drive suggestions");
|
||||
assert!(payoff.unwrap().reasons.iter().any(|r| r.category == "Mechanic" && r.detail.contains("Landfall")));
|
||||
// Flying alone (evergreen) is not a synergy signal.
|
||||
assert!(!out.iter().any(|s| s.card.oracle_id == "just-a-flyer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commander_type_is_always_a_target_and_off_color_is_excluded() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
// Mono-green Elf commander, single card in the deck.
|
||||
let d = cat.create_deck("Commander Elf", Some("llanowar-elves")).unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 1, true, false).unwrap();
|
||||
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
|
||||
|
||||
add_ref_card(cat, "green-elf", "Green Elf", "Creature — Elf", "G", "", &["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, false).unwrap();
|
||||
let green = out.iter().find(|s| s.card.oracle_id == "green-elf");
|
||||
assert!(green.is_some(), "commander's own type should be a tribal target");
|
||||
assert!(green.unwrap().reasons.iter().any(|r| r.detail.contains("commander's type")));
|
||||
// Off-color-identity candidate is hard-excluded.
|
||||
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();
|
||||
let cat = &fx.catalog;
|
||||
// Deathrite Shaman casts B and G but the deck produces neither.
|
||||
let d = cat.create_deck("BG", None).unwrap();
|
||||
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
add_ref_card(cat, "swamp-dork", "Swamp Dork", "Creature — Elf", "B", "B", &["Elf"], &[], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let dork = out.iter().find(|s| s.card.oracle_id == "swamp-dork").unwrap();
|
||||
assert!(dork.reasons.iter().any(|r| r.category == "Mana base" && r.detail.contains("{B}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_deck_returns_no_suggestions() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Empty", None).unwrap();
|
||||
assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats};
|
||||
use mtgc_core::{
|
||||
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter,
|
||||
LibraryStats,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
@@ -130,6 +133,14 @@ pub fn deck_contents(db: Db, deck_id: i64) -> Res<Vec<DeckEntry>> {
|
||||
s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn suggest_cards(db: Db, deck_id: i64, offset: i64, unusual: bool) -> Res<Vec<CardSuggestion>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.suggest_cards(deck_id, 50, offset.max(0) as usize, unusual)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_library(db: Db, filter: LibraryFilter) -> Res<Vec<LibraryCard>> {
|
||||
let s = db.lock().unwrap();
|
||||
|
||||
@@ -41,6 +41,7 @@ pub fn run() {
|
||||
commands::set_deck_commander,
|
||||
commands::set_deck_card,
|
||||
commands::deck_contents,
|
||||
commands::suggest_cards,
|
||||
commands::card_decks,
|
||||
commands::export_deck,
|
||||
commands::import_deck,
|
||||
|
||||
54
src/App.tsx
54
src/App.tsx
@@ -8,6 +8,7 @@ import {
|
||||
IconButton,
|
||||
Icons,
|
||||
StatTile,
|
||||
UndoBar,
|
||||
usePersistedState,
|
||||
} from "./components";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -133,6 +134,12 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
|
||||
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
|
||||
}
|
||||
|
||||
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
|
||||
// This only controls the snackbar's visibility — the underlying undo action
|
||||
// stays available in the command palette well past this (see `undo` below),
|
||||
// so missing the snackbar isn't the last chance to undo.
|
||||
const UNDO_SNACKBAR_MS = 8000;
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
|
||||
@@ -140,6 +147,44 @@ export default function App() {
|
||||
const [progress, setProgress] = useState<IngestProgress | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const lastProgress = useRef<IngestProgress | null>(null);
|
||||
// Lives here (not in LibraryBrowser) so it's reachable from the command
|
||||
// palette, which is mounted at the app level and outlives any one tab's
|
||||
// view across tab switches. Unlike the snackbar, this has no timeout —
|
||||
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
|
||||
// so an undo that's missed in the snackbar is still reachable via Alt+/.
|
||||
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<void> } | null>(
|
||||
null,
|
||||
);
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Bumped once an undo actually runs, so views mounted elsewhere than where
|
||||
// the undo was offered (e.g. the Decks tab, when the undo came from a
|
||||
// Library bulk add-to-deck) know to refetch instead of showing stale data
|
||||
// until the user leaves and comes back.
|
||||
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
|
||||
|
||||
function offerUndo(message: string, run: () => void | Promise<void>) {
|
||||
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
||||
setUndo({ message, run });
|
||||
setSnackbarVisible(true);
|
||||
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
|
||||
}
|
||||
|
||||
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
|
||||
function dismissUndo() {
|
||||
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
||||
setSnackbarVisible(false);
|
||||
setUndo(null);
|
||||
}
|
||||
|
||||
function runUndo() {
|
||||
if (!undo) return;
|
||||
const { run } = undo;
|
||||
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
|
||||
setSnackbarVisible(false);
|
||||
setUndo(null);
|
||||
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
|
||||
}
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setStatus(await api.getStatus());
|
||||
@@ -393,18 +438,23 @@ export default function App() {
|
||||
) : tab === "add" ? (
|
||||
<CardEntry />
|
||||
) : tab === "library" ? (
|
||||
<LibraryBrowser />
|
||||
<LibraryBrowser onOfferUndo={offerUndo} />
|
||||
) : (
|
||||
<DeckView />
|
||||
<DeckView refreshSignal={deckRefreshSignal} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{undo && snackbarVisible && (
|
||||
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
|
||||
)}
|
||||
<CommandPalette
|
||||
onNavigate={setTab}
|
||||
onUpdateCardData={updateCardData}
|
||||
onOpenLibrary={openLibrary}
|
||||
onNewLibrary={newLibrary}
|
||||
undo={undo}
|
||||
onRunUndo={runUndo}
|
||||
/>
|
||||
</HoverPreviewProvider>
|
||||
);
|
||||
|
||||
@@ -30,18 +30,25 @@ function subsequenceMatch(query: string, text: string): boolean {
|
||||
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
|
||||
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
|
||||
* selecting a card opens the existing click-to-zoom preview modal without
|
||||
* changing tabs.
|
||||
* changing tabs. When an undoable action's grace window hasn't lapsed yet,
|
||||
* an "Undo: …" entry is listed first, so Alt+/ then Enter is a fast way to
|
||||
* undo without hunting for the floating snackbar's button.
|
||||
*/
|
||||
export function CommandPalette({
|
||||
onNavigate,
|
||||
onUpdateCardData,
|
||||
onOpenLibrary,
|
||||
onNewLibrary,
|
||||
undo,
|
||||
onRunUndo,
|
||||
}: {
|
||||
onNavigate: (tab: Tab) => void;
|
||||
onUpdateCardData: () => void;
|
||||
onOpenLibrary: () => void;
|
||||
onNewLibrary: () => void;
|
||||
/** The most recent undoable action, if its window hasn't lapsed yet. */
|
||||
undo: { message: string; run: () => void | Promise<void> } | null;
|
||||
onRunUndo: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -83,6 +90,7 @@ export function CommandPalette({
|
||||
}
|
||||
|
||||
const staticCommands: PaletteItem[] = [
|
||||
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
|
||||
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
||||
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
||||
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||
|
||||
232
src/SuggestionsModal.tsx
Normal file
232
src/SuggestionsModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, CardSuggestion } from "./api";
|
||||
import { CardThumb, ColorPips, DialogFrame } from "./components";
|
||||
|
||||
// Matches the backend's hardcoded per-page limit (src-tauri/src/commands.rs).
|
||||
// A batch shorter than this means there's nothing left to fetch.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Fixed display order, matching the four synergy families the backend scores.
|
||||
const CATEGORY_ORDER = ["Tribal", "Mechanic", "Archetype", "Mana base"];
|
||||
|
||||
function categorySlug(category: string): string {
|
||||
return category.toLowerCase().replace(/[^a-z]+/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand modal listing scored card suggestions for a deck, each with an
|
||||
* explainable synergy breakdown and "Add to deck"/"Add to maybeboard"
|
||||
* actions. Fetches a page on open; toggleable chips filter which reason
|
||||
* categories must be present (client-side, over everything loaded so far);
|
||||
* "Suggest more" fetches and appends the next page. Adding a card removes
|
||||
* its row in place so several can be added in one sitting. An "Unusual
|
||||
* picks" toggle re-fetches from the backend restricted to (and reordered
|
||||
* toward) cards that aren't widely played, surfacing underappreciated
|
||||
* synergy hits instead of the usual staples — that reordering only makes
|
||||
* sense server-side, before pagination slices the result, so toggling it
|
||||
* resets and re-fetches rather than filtering what's already loaded.
|
||||
*/
|
||||
export function SuggestionsModal({
|
||||
deckId,
|
||||
onClose,
|
||||
onCardAdded,
|
||||
}: {
|
||||
deckId: number;
|
||||
onClose: () => void;
|
||||
onCardAdded: () => void;
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<CardSuggestion[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [activeCategories, setActiveCategories] = useState<Set<string>>(new Set());
|
||||
const [unusual, setUnusual] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSuggestions(null);
|
||||
setError(null);
|
||||
api
|
||||
.suggestCards(deckId, 0, unusual)
|
||||
.then((s) => {
|
||||
if (cancelled) return;
|
||||
setSuggestions(s);
|
||||
setHasMore(s.length === PAGE_SIZE);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [deckId, unusual]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!suggestions) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const more = await api.suggestCards(deckId, suggestions.length, unusual);
|
||||
setSuggestions((cur) => {
|
||||
const seen = new Set((cur ?? []).map((s) => s.card.oracle_id));
|
||||
return [...(cur ?? []), ...more.filter((s) => !seen.has(s.card.oracle_id))];
|
||||
});
|
||||
setHasMore(more.length === PAGE_SIZE);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function add(s: CardSuggestion, maybeboard: boolean) {
|
||||
const oid = s.card.oracle_id;
|
||||
setBusy((b) => new Set(b).add(oid));
|
||||
try {
|
||||
await api.setDeckCard(deckId, oid, 1, false, maybeboard);
|
||||
onCardAdded();
|
||||
setSuggestions((cur) => (cur ? cur.filter((x) => x.card.oracle_id !== oid) : cur));
|
||||
} finally {
|
||||
setBusy((b) => {
|
||||
const next = new Set(b);
|
||||
next.delete(oid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCategory(cat: string) {
|
||||
setActiveCategories((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const availableCategories = suggestions
|
||||
? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat)))
|
||||
: [];
|
||||
const visible =
|
||||
suggestions && activeCategories.size > 0
|
||||
? suggestions.filter((s) => s.reasons.some((r) => activeCategories.has(r.category)))
|
||||
: suggestions;
|
||||
|
||||
return (
|
||||
<DialogFrame title="Suggested cards" onCancel={onClose} wide>
|
||||
<div className="row" style={{ gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-chip ${unusual ? "active" : ""}`}
|
||||
onClick={() => setUnusual((u) => !u)}
|
||||
title="Only show cards that fit your deck's strategy but aren't widely played — cool, underappreciated picks."
|
||||
>
|
||||
✦ Unusual picks
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="hint">{error}</p>
|
||||
) : suggestions === null ? (
|
||||
<p className="hint">Analyzing your deck…</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<p className="hint">
|
||||
{unusual
|
||||
? "No unusual picks found — try turning off \"Unusual picks\" to see the widely-played suggestions too."
|
||||
: "No suggestions yet — add a few cards (and a commander, if it's a Commander deck) so there's a strategy to build on."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{availableCategories.length > 1 && (
|
||||
<div className="suggestion-filters">
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Filter by why:
|
||||
</span>
|
||||
{availableCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
className={`filter-chip filter-chip-${categorySlug(cat)} ${
|
||||
activeCategories.has(cat) ? "active" : ""
|
||||
}`}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
{activeCategories.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-chip-clear"
|
||||
onClick={() => setActiveCategories(new Set())}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible && visible.length === 0 ? (
|
||||
<p className="hint">No suggestions match the selected filters.</p>
|
||||
) : (
|
||||
<ul className="result-list suggestion-list">
|
||||
{visible?.map((s) => (
|
||||
<li key={s.card.oracle_id}>
|
||||
<div className="suggestion-row">
|
||||
<CardThumb card={s.card} />
|
||||
<div className="suggestion-main">
|
||||
<div className="suggestion-head">
|
||||
<ColorPips mask={s.card.color_identity} />
|
||||
<span className="card-name">{s.card.name}</span>
|
||||
{s.quantity_owned > 0 ? (
|
||||
<span className="badge">Owned ×{s.quantity_owned}</span>
|
||||
) : (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Not owned
|
||||
</span>
|
||||
)}
|
||||
{s.edhrec_rank !== null && s.edhrec_rank < 2000 && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
★ Popular
|
||||
</span>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<span className="suggestion-score">{s.score.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className="suggestion-reasons">
|
||||
{s.reasons.map((r, i) => (
|
||||
<span key={i} className={`reason-chip reason-chip-${categorySlug(r.category)}`}>
|
||||
<span className="reason-chip-category">{r.category}</span>
|
||||
{r.detail}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="suggestion-actions">
|
||||
<button onClick={() => add(s, false)} disabled={busy.has(s.card.oracle_id)}>
|
||||
Add to deck
|
||||
</button>
|
||||
<button onClick={() => add(s, true)} disabled={busy.has(s.card.oracle_id)}>
|
||||
Add to maybeboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12 }}>
|
||||
<button onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Loading…" : "Suggest more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button className="primary" onClick={onClose} autoFocus>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
15
src/api.ts
15
src/api.ts
@@ -52,6 +52,19 @@ export interface LibraryImportResult {
|
||||
unresolved: string[];
|
||||
}
|
||||
|
||||
export interface SynergyReason {
|
||||
category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base"
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CardSuggestion {
|
||||
card: CardRef;
|
||||
quantity_owned: number;
|
||||
score: number;
|
||||
reasons: SynergyReason[];
|
||||
edhrec_rank: number | null;
|
||||
}
|
||||
|
||||
export interface LibraryFilter {
|
||||
text?: string | null;
|
||||
colors_exact?: number | null;
|
||||
@@ -186,6 +199,8 @@ export const api = {
|
||||
isMaybeboard = false,
|
||||
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
|
||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||
suggestCards: (deckId: number, offset = 0, unusual = false) =>
|
||||
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, unusual }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
|
||||
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
|
||||
|
||||
@@ -279,6 +279,31 @@ export function InfoDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** Floating snackbar offering to reverse the caller's last action. Auto-
|
||||
* dismisses on a timer managed by the caller (this component is purely
|
||||
* presentational) — pass `null`/omit rendering it once the window lapses. */
|
||||
export function UndoBar({
|
||||
message,
|
||||
onUndo,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string;
|
||||
onUndo: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="undo-bar">
|
||||
<span>{message}</span>
|
||||
<button className="primary" onClick={onUndo}>
|
||||
Undo
|
||||
</button>
|
||||
<button className="undo-bar-close" onClick={onDismiss} title="Dismiss">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
|
||||
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
|
||||
export function SampleHandModal({
|
||||
|
||||
212
src/styles.css
212
src/styles.css
@@ -689,7 +689,6 @@ h4 {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.legality-tile.legal {
|
||||
background: rgba(75, 176, 115, 0.16);
|
||||
@@ -1049,6 +1048,135 @@ li:focus-within .row-actions {
|
||||
.deck-list-actions-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
|
||||
* synergy reason chips per row, with stacked actions on the right. Denser
|
||||
* than the default .result-list row (smaller padding/gaps, chips instead of
|
||||
* a bulleted list) since a card can carry several reasons at once. */
|
||||
.result-list.suggestion-list li {
|
||||
cursor: default;
|
||||
align-items: stretch;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.suggestion-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.suggestion-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.suggestion-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.suggestion-score {
|
||||
font-weight: 700;
|
||||
color: var(--accent-hover);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.suggestion-reasons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.reason-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px 1px 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
background: var(--bg-elev);
|
||||
color: var(--muted);
|
||||
}
|
||||
.reason-chip-category {
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
.reason-chip-tribal .reason-chip-category {
|
||||
background: var(--accent);
|
||||
}
|
||||
.reason-chip-mechanic .reason-chip-category {
|
||||
background: var(--mtg-u);
|
||||
}
|
||||
.reason-chip-archetype .reason-chip-category {
|
||||
background: var(--warn);
|
||||
}
|
||||
.reason-chip-mana-base .reason-chip-category {
|
||||
background: var(--success);
|
||||
}
|
||||
.suggestion-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.suggestion-actions button {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Toggleable "filter by why" chips above the suggestion list — same color
|
||||
* coding as .reason-chip-category, but as clickable, stateful buttons. */
|
||||
.suggestion-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.filter-chip {
|
||||
border-radius: 999px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-elev);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.filter-chip:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.filter-chip.active {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.filter-chip-tribal.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
.filter-chip-mechanic.active {
|
||||
background: var(--mtg-u);
|
||||
}
|
||||
.filter-chip-archetype.active {
|
||||
background: var(--warn);
|
||||
}
|
||||
.filter-chip-mana-base.active {
|
||||
background: var(--success);
|
||||
}
|
||||
.filter-chip-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
@@ -1176,6 +1304,37 @@ tbody tr:hover {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
/* Floating "Undo" snackbar (e.g. after a bulk add-to-deck). Fixed so it stays
|
||||
reachable regardless of scroll position; sits below dialogs (z-index 400+)
|
||||
since it's only ever shown against the current view, not over a modal. */
|
||||
.undo-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 28px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 300;
|
||||
}
|
||||
.undo-bar-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.undo-bar-close:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Filters panel
|
||||
============================================================ */
|
||||
@@ -1317,7 +1476,10 @@ tbody tr:hover {
|
||||
}
|
||||
.hover-card {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
/* Above .dialog-backdrop (400) — cards inside a dialog (e.g. the
|
||||
suggestions modal) can be hovered for a preview without it appearing
|
||||
behind the dialog it's in. */
|
||||
z-index: 450;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
@@ -1581,7 +1743,6 @@ tbody tr:hover {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
/* Separates the deck-level info panels (commander, format, analysis,
|
||||
legality) from the card-management controls + lists below. */
|
||||
@@ -1590,6 +1751,48 @@ tbody tr:hover {
|
||||
border-top: 1px solid var(--border-soft);
|
||||
margin: 4px 0 18px;
|
||||
}
|
||||
|
||||
/* The four top-of-page info panels (commander, meta, analysis, legality).
|
||||
Single column matches the existing top-to-bottom reading order on
|
||||
narrow/portrait viewports; on wide viewports the compact panels move
|
||||
into a side column so the analysis panel (the one panel that benefits
|
||||
from extra width) gets the rest of the row instead of everything
|
||||
stretching out with wasted horizontal space. grid-template-areas (not
|
||||
flex wrapping) is what lets the two-column arrangement reorder visually
|
||||
without touching DOM order. */
|
||||
.deck-top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"commander"
|
||||
"meta"
|
||||
"stats"
|
||||
"legality";
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.deck-top-commander {
|
||||
grid-area: commander;
|
||||
}
|
||||
.deck-top-meta {
|
||||
grid-area: meta;
|
||||
}
|
||||
.deck-top-stats {
|
||||
grid-area: stats;
|
||||
}
|
||||
.deck-top-legality {
|
||||
grid-area: legality;
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.deck-top-grid {
|
||||
grid-template-columns: minmax(360px, 560px) 1fr;
|
||||
grid-template-areas:
|
||||
"commander stats"
|
||||
"meta stats"
|
||||
"legality stats";
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
.commander-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1607,7 +1810,6 @@ tbody tr:hover {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.commander-hero-img {
|
||||
width: 84px;
|
||||
@@ -1662,7 +1864,6 @@ tbody tr:hover {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.deck-stats-head {
|
||||
font-size: 11px;
|
||||
@@ -1686,6 +1887,7 @@ tbody tr:hover {
|
||||
.deck-stats-curve {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
import { SuggestionsModal } from "../SuggestionsModal";
|
||||
|
||||
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
|
||||
function boardToggleProps(e: DeckEntry) {
|
||||
@@ -327,8 +328,14 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[
|
||||
* groupable by card attribute; "Combine sections" merges these two into one
|
||||
* list. The maybeboard is always shown as its own separate section. Deck-level
|
||||
* format + commander metadata sit at the top.
|
||||
*
|
||||
* `refreshSignal` is bumped by `App` when something outside this view's own
|
||||
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
|
||||
* from the command palette while this view is already mounted — so the
|
||||
* card list, mana curve, and legality tile recompute immediately instead of
|
||||
* only on the next mount.
|
||||
*/
|
||||
export function DeckView() {
|
||||
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
|
||||
const [newName, setNewName] = useState("");
|
||||
@@ -336,6 +343,7 @@ export function DeckView() {
|
||||
const [view, setView] = useViewMode("deck-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [dialog, setDialog] = useState<DeckDialog>(null);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
|
||||
@@ -357,6 +365,7 @@ export function DeckView() {
|
||||
const [handOpen, setHandOpen] = useState(false);
|
||||
const [hand, setHand] = useState<DeckEntry[]>([]);
|
||||
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
|
||||
const deck = decks.find((d) => d.id === selected) ?? null;
|
||||
|
||||
@@ -387,8 +396,22 @@ export function DeckView() {
|
||||
setHandOpen(false);
|
||||
setHand([]);
|
||||
setLibraryStack([]);
|
||||
setFilterQuery("");
|
||||
}, [loadContents]);
|
||||
|
||||
// Refetch on an external refresh signal without resetting the UI state
|
||||
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
||||
// selection, or open sample hand). Skips the first render so it doesn't
|
||||
// duplicate the mount-time fetch above.
|
||||
const skipFirstRefresh = useRef(true);
|
||||
useEffect(() => {
|
||||
if (skipFirstRefresh.current) {
|
||||
skipFirstRefresh.current = false;
|
||||
return;
|
||||
}
|
||||
loadContents();
|
||||
}, [refreshSignal]);
|
||||
|
||||
// Cancel any in-flight removal timers on unmount so they don't fire against
|
||||
// an unmounted component.
|
||||
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
||||
@@ -671,9 +694,18 @@ export function DeckView() {
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0);
|
||||
const maybeboard = contents.filter((e) => e.is_maybeboard);
|
||||
// Quick-search over cards already in the deck/maybeboard — a display
|
||||
// filter only, distinct from the CardSearchBox boxes below (which search
|
||||
// the library/reference db to find NEW cards to add).
|
||||
function matchesFilter(e: DeckEntry): boolean {
|
||||
const q = filterQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return e.name.toLowerCase().includes(q) || e.type_line.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
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);
|
||||
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
||||
@@ -863,6 +895,13 @@ export function DeckView() {
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setSuggestOpen(true)}
|
||||
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
|
||||
>
|
||||
{Icons.cards}
|
||||
Suggest cards
|
||||
</button>
|
||||
<button onClick={() => exportDeck(deck)}>
|
||||
{Icons.download}
|
||||
Export deck
|
||||
@@ -877,111 +916,121 @@ export function DeckView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
)}
|
||||
<div className="deck-top-grid">
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<div className="deck-top-commander">
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span className="muted">Format</span>
|
||||
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f || "—"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-top-meta">
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span className="muted">Format</span>
|
||||
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f || "—"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||
<CardSearchBox
|
||||
placeholder={
|
||||
commanderEntry
|
||||
? "Search to change the commander…"
|
||||
: "Search to set the commander…"
|
||||
}
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
onPick={setCommander}
|
||||
/>
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||
<CardSearchBox
|
||||
placeholder={
|
||||
commanderEntry
|
||||
? "Search to change the commander…"
|
||||
: "Search to set the commander…"
|
||||
}
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
onPick={setCommander}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contents.length > 0 && (
|
||||
<div className="deck-top-stats">
|
||||
<div className="deck-stats">
|
||||
<div className="deck-stats-head">Deck analysis</div>
|
||||
<div className="deck-stats-body">
|
||||
<div className="deck-stats-numbers">
|
||||
<div className="stat-tiles-row">
|
||||
<StatTile value={deckStats.totalCards} label="cards" />
|
||||
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||
<StatTile value={deckStats.landCount} label="lands" />
|
||||
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||
</div>
|
||||
<div className="deck-stats-curve-label">Color identity</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.colorCounts}
|
||||
colorlessCount={deckStats.colorlessCount}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana production</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaProduction}
|
||||
colorlessCount={deckStats.colorlessProduction}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaConsumption}
|
||||
colorlessCount={deckStats.colorlessConsumption}
|
||||
/>
|
||||
</div>
|
||||
<div className="deck-stats-curve">
|
||||
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||
<ManaCurve buckets={deckStats.curve} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deck.format !== "Other" && (
|
||||
<div className="deck-top-legality">
|
||||
<div className={`legality-tile ${deckLegality.status}`}>
|
||||
<div className="legality-tile-head">
|
||||
<span className="legality-tile-icon">
|
||||
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
||||
</span>
|
||||
<div className="legality-tile-info">
|
||||
<span className="legality-tile-status">
|
||||
{deckLegality.status === "legal"
|
||||
? "Legal"
|
||||
: deckLegality.status === "illegal"
|
||||
? "Illegal"
|
||||
: "Not checked"}
|
||||
</span>
|
||||
<span className="legality-tile-detail">
|
||||
{deckLegality.status === "legal"
|
||||
? `Meets all ${deck.format} deck-building rules.`
|
||||
: deckLegality.status === "illegal"
|
||||
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
||||
: deck.format
|
||||
? `Legality isn't checked for "${deck.format}".`
|
||||
: "Set a format to check legality."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{deckLegality.status === "illegal" && (
|
||||
<ul className="legality-tile-reasons">
|
||||
{deckLegality.reasons.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contents.length > 0 && (
|
||||
<div className="deck-stats">
|
||||
<div className="deck-stats-head">Deck analysis</div>
|
||||
<div className="deck-stats-body">
|
||||
<div className="deck-stats-numbers">
|
||||
<div className="stat-tiles-row">
|
||||
<StatTile value={deckStats.totalCards} label="cards" />
|
||||
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||
<StatTile value={deckStats.landCount} label="lands" />
|
||||
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||
</div>
|
||||
<div className="deck-stats-curve-label">Color identity</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.colorCounts}
|
||||
colorlessCount={deckStats.colorlessCount}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana production</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaProduction}
|
||||
colorlessCount={deckStats.colorlessProduction}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaConsumption}
|
||||
colorlessCount={deckStats.colorlessConsumption}
|
||||
/>
|
||||
</div>
|
||||
<div className="deck-stats-curve">
|
||||
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||
<ManaCurve buckets={deckStats.curve} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deck.format !== "Other" && (
|
||||
<div className={`legality-tile ${deckLegality.status}`}>
|
||||
<div className="legality-tile-head">
|
||||
<span className="legality-tile-icon">
|
||||
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
||||
</span>
|
||||
<div className="legality-tile-info">
|
||||
<span className="legality-tile-status">
|
||||
{deckLegality.status === "legal"
|
||||
? "Legal"
|
||||
: deckLegality.status === "illegal"
|
||||
? "Illegal"
|
||||
: "Not checked"}
|
||||
</span>
|
||||
<span className="legality-tile-detail">
|
||||
{deckLegality.status === "legal"
|
||||
? `Meets all ${deck.format} deck-building rules.`
|
||||
: deckLegality.status === "illegal"
|
||||
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
||||
: deck.format
|
||||
? `Legality isn't checked for "${deck.format}".`
|
||||
: "Set a format to check legality."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{deckLegality.status === "illegal" && (
|
||||
<ul className="legality-tile-reasons">
|
||||
{deckLegality.reasons.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="deck-section-divider" />
|
||||
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
@@ -1007,6 +1056,12 @@ export function DeckView() {
|
||||
Combine sections
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
placeholder="Filter cards in deck…"
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span className="spacer" />
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
@@ -1157,6 +1212,13 @@ export function DeckView() {
|
||||
onClose={() => setHandOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{suggestOpen && deck && (
|
||||
<SuggestionsModal
|
||||
deckId={deck.id}
|
||||
onClose={() => setSuggestOpen(false)}
|
||||
onCardAdded={loadContents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,19 @@ const REMOVAL_GRACE_MS = 5000;
|
||||
|
||||
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
|
||||
|
||||
/** Browse & filter the owned library by name and card criteria. */
|
||||
export function LibraryBrowser() {
|
||||
/**
|
||||
* Browse & filter the owned library by name and card criteria.
|
||||
*
|
||||
* `onOfferUndo` is threaded in from `App` (rather than owning an `UndoBar`
|
||||
* here) so the resulting "Undo" is also reachable from the command palette,
|
||||
* which is mounted at the app level and outlives this view across tab
|
||||
* switches.
|
||||
*/
|
||||
export function LibraryBrowser({
|
||||
onOfferUndo,
|
||||
}: {
|
||||
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [commander, setCommander] = useState(0);
|
||||
const [colorsInclude, setColorsInclude] = useState(0);
|
||||
@@ -178,6 +189,12 @@ export function LibraryBrowser() {
|
||||
* cards already in the deck rather than skipping them, so it doubles as a
|
||||
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
|
||||
* deck's maybeboard instead of the mainboard.
|
||||
*
|
||||
* Since this overwrites rather than skips, it can clobber an existing
|
||||
* in-deck quantity/board — so it snapshots each target's prior deck_cards
|
||||
* row (or absence of one) first and offers an "Undo" that restores exactly
|
||||
* that prior state (deleting the row again for cards that weren't in the
|
||||
* deck before).
|
||||
*/
|
||||
async function bulkAddToDeck(mode: "one" | "all") {
|
||||
if (bulkDeckId === "") return;
|
||||
@@ -186,6 +203,8 @@ export function LibraryBrowser() {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||
const before = await api.deckContents(deck.id);
|
||||
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
|
||||
await Promise.all(
|
||||
targets.map((r) =>
|
||||
api.setDeckCard(
|
||||
@@ -198,6 +217,24 @@ export function LibraryBrowser() {
|
||||
),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onOfferUndo(
|
||||
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
|
||||
async () => {
|
||||
await Promise.all(
|
||||
targets.map((r) => {
|
||||
const prev = prevByOracle.get(r.oracle_id);
|
||||
return api.setDeckCard(
|
||||
deck.id,
|
||||
r.oracle_id,
|
||||
prev?.quantity ?? 0,
|
||||
prev?.is_commander ?? false,
|
||||
prev?.is_maybeboard ?? false,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user