diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 266fd9f..2d74457 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -438,6 +438,10 @@ struct ScryOracle { /// "restricted"). See [`crate::model::Legalities`]. #[serde(default)] legalities: std::collections::HashMap, + /// Scryfall's EDHREC popularity rank (lower = more played); absent for + /// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`. + #[serde(default)] + edhrec_rank: Option, } #[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) { + 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. diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 1e33940..a8a3397 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -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. /// diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index e68a89f..06e2aac 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -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); diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs new file mode 100644 index 0000000..10efe1e --- /dev/null +++ b/crates/mtgc-core/src/synergy.rs @@ -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, + /// Scryfall's EDHREC popularity rank (lower = more common in Commander); + /// a sort tiebreaker, not a scored category. `None` when unranked. + pub edhrec_rank: Option, +} + +// 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, + 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> { + // 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 = 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 = 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 = vec![ + Value::Integer(deck_id), + Value::Integer((!identity_mask & 0b11111) as i64), + ]; + let mut branches: Vec = 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 = { + 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>(6)?.unwrap_or_default(), + back_image_uri: r.get::<_, Option>(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>(11)?.unwrap_or_default(), + }, + themes: r.get(12)?, + edhrec_rank: r.get(13)?, + quantity_owned: r.get(14)?, + }) + })?; + rows.collect::, _>>()? + }; + + // Batch-load each candidate's subtypes/keywords (avoids per-card N+1). + let cand_ids: Vec = 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 = Vec::new(); + + let mut out: Vec = 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> { + 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> { + 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::, _>>()?) + } + + fn keywords_of(&self, oracle_id: &str) -> Result> { + 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::, _>>()?) + } + + /// 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>> { + let mut map: HashMap> = 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 = 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::>().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, 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, + ) { + 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()); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 39a8511..3cf9c19 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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> { 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> { + 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> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a15da86..ce1fb6f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 26a7437..1063081 100644 --- a/src/App.tsx +++ b/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(null); const [libStats, setLibStats] = useState(null); @@ -140,6 +147,44 @@ export default function App() { const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); const lastProgress = useRef(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 } | null>( + null, + ); + const [snackbarVisible, setSnackbarVisible] = useState(false); + const snackbarTimer = useRef | 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) { + 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" ? ( ) : tab === "library" ? ( - + ) : ( - + )} + {undo && snackbarVisible && ( + + )} ); diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index c128f34..f2adcfe 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -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 } | 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") }, diff --git a/src/SuggestionsModal.tsx b/src/SuggestionsModal.tsx new file mode 100644 index 0000000..0c29278 --- /dev/null +++ b/src/SuggestionsModal.tsx @@ -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(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState>(new Set()); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [activeCategories, setActiveCategories] = useState>(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 ( + +
+ +
+ {error ? ( +

{error}

+ ) : suggestions === null ? ( +

Analyzing your deck…

+ ) : suggestions.length === 0 ? ( +

+ {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."} +

+ ) : ( + <> + {availableCategories.length > 1 && ( +
+ + Filter by why: + + {availableCategories.map((cat) => ( + + ))} + {activeCategories.size > 0 && ( + + )} +
+ )} + + {visible && visible.length === 0 ? ( +

No suggestions match the selected filters.

+ ) : ( +
    + {visible?.map((s) => ( +
  • +
    + +
    +
    + + {s.card.name} + {s.quantity_owned > 0 ? ( + Owned ×{s.quantity_owned} + ) : ( + + Not owned + + )} + {s.edhrec_rank !== null && s.edhrec_rank < 2000 && ( + + ★ Popular + + )} + + {s.score.toFixed(0)} +
    +
    + {s.reasons.map((r, i) => ( + + {r.category} + {r.detail} + + ))} +
    +
    +
    + + +
    +
    +
  • + ))} +
+ )} + + {hasMore && ( +
+ +
+ )} + + )} +
+ +
+
+ ); +} diff --git a/src/api.ts b/src/api.ts index c87b0fa..72b12c8 100644 --- a/src/api.ts +++ b/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("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }), deckContents: (deckId: number) => invoke("deck_contents", { deckId }), + suggestCards: (deckId: number, offset = 0, unusual = false) => + invoke("suggest_cards", { deckId, offset, unusual }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), exportDeck: (deckId: number, path: string) => invoke("export_deck", { deckId, path }), diff --git a/src/components.tsx b/src/components.tsx index 3d540c8..3295ece 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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 ( +
+ {message} + + +
+ ); +} + /** 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({ diff --git a/src/styles.css b/src/styles.css index b5c58e8..c2332a5 100644 --- a/src/styles.css +++ b/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; } diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index e175e7b..6f239dd 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -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([]); const [selected, setSelected] = usePersistedState("deck-selected", null); const [newName, setNewName] = useState(""); @@ -336,6 +343,7 @@ export function DeckView() { const [view, setView] = useViewMode("deck-view"); const [groupBy, setGroupBy] = usePersistedState("deck-group", "none"); const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false); + const [filterQuery, setFilterQuery] = useState(""); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(null); @@ -357,6 +365,7 @@ export function DeckView() { const [handOpen, setHandOpen] = useState(false); const [hand, setHand] = useState([]); const [libraryStack, setLibraryStack] = useState([]); + 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() {

{deck.name}

+
- {COMMANDER_FORMATS.includes(deck.format) && commanderEntry && ( - - )} +
+ {COMMANDER_FORMATS.includes(deck.format) && commanderEntry && ( +
+ +
+ )} - {/* Deck metadata: format + (for Commander/Brawl) the commander card. */} -
- + {/* Deck metadata: format + (for Commander/Brawl) the commander card. */} +
+
+ - {COMMANDER_FORMATS.includes(deck.format) && ( -
- {!commanderEntry && No commander set yet.} - + {COMMANDER_FORMATS.includes(deck.format) && ( +
+ {!commanderEntry && No commander set yet.} + +
+ )} +
+
+ + {contents.length > 0 && ( +
+
+
Deck analysis
+
+
+
+ + + + +
+
Color identity
+ +
Mana production
+ +
Mana consumption
+ +
+
+
Mana curve (nonland)
+ +
+
+
+
+ )} + + {deck.format !== "Other" && ( +
+
+
+ + {deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"} + +
+ + {deckLegality.status === "legal" + ? "Legal" + : deckLegality.status === "illegal" + ? "Illegal" + : "Not checked"} + + + {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."} + +
+
+ {deckLegality.status === "illegal" && ( +
    + {deckLegality.reasons.map((r, i) => ( +
  • {r}
  • + ))} +
+ )} +
)}
- {contents.length > 0 && ( -
-
Deck analysis
-
-
-
- - - - -
-
Color identity
- -
Mana production
- -
Mana consumption
- -
-
-
Mana curve (nonland)
- -
-
-
- )} - - {deck.format !== "Other" && ( -
-
- - {deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"} - -
- - {deckLegality.status === "legal" - ? "Legal" - : deckLegality.status === "illegal" - ? "Illegal" - : "Not checked"} - - - {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."} - -
-
- {deckLegality.status === "illegal" && ( -
    - {deckLegality.reasons.map((r, i) => ( -
  • {r}
  • - ))} -
- )} -
- )} -
@@ -1007,6 +1056,12 @@ export function DeckView() { Combine sections + setFilterQuery(e.target.value)} + placeholder="Filter cards in deck…" + style={{ width: 200 }} + /> @@ -1157,6 +1212,13 @@ export function DeckView() { onClose={() => setHandOpen(false)} /> )} + {suggestOpen && deck && ( + setSuggestOpen(false)} + onCardAdded={loadContents} + /> + )}
); } diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index d74781c..d6cc9e9 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -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; +}) { 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); }