//! 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::{Deserialize, 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, /// The specific thing matched — "Dragon", "Flying", "Landfall". Carried /// separately from `detail` so the UI can filter on it: knowing a /// suggestion is an Archetype hit is rarely enough, since a deck usually /// has several and you want one of them. pub subject: 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, /// Copies other decks' main boards already use. Never this deck's: cards /// already in it aren't suggested. Lets the UI tell a spare copy from one /// you'd have to pull out of another deck. pub quantity_assigned: 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; /// How [`Catalog::suggest_cards`] picks its candidate pool. #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(default)] pub struct SuggestOptions { /// Favor cards that fit but aren't widely played — see /// [`UNUSUAL_RANK_THRESHOLD`]. pub unusual: bool, /// Only cards you own at least one copy of: build from the collection /// rather than a shopping list. pub owned_only: bool, } /// 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`, and filtered /// only on request — see [`SuggestOptions`]), /// 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 `opts.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. `opts.owned_only` narrows the pool to cards you own. pub fn suggest_cards( &self, deck_id: i64, limit: usize, offset: usize, opts: SuggestOptions, ) -> Result> { let SuggestOptions { unusual, owned_only } = opts; // 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), {horizontal} 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 = self.image_expr("image_uri"), back = self.image_expr("back_image_uri"), price = crate::db::price_subquery(), horizontal = self.horizontal_col(), ); 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)); } if owned_only { // Filtered before scoring and paging, so "Suggest more" keeps // finding owned cards instead of pages thinning out to nothing. sql.push_str(" AND COALESCE(lc.quantity_owned, 0) > 0"); } 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(), horizontal: r.get::<_, i64>(15)? != 0, }, 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 assigned = self.assigned_totals_by_oracle()?; 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(), subject: s.clone(), 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(), subject: k.clone(), 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(), subject: (*name).to_string(), 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(), subject: letters.clone(), detail: format!("Produces {letters}, which your deck can't currently make"), }); } if score <= 0.0 { continue; } out.push(CardSuggestion { quantity_assigned: assigned.get(oid).copied().unwrap_or(0), 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, SuggestOptions::default()).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, SuggestOptions { unusual: true, ..Default::default() }).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, SuggestOptions::default()).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, SuggestOptions::default()).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, SuggestOptions::default()).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, SuggestOptions::default()).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, SuggestOptions::default()).unwrap().is_empty()); } #[test] fn owned_only_keeps_your_cards_and_reports_copies_other_decks_use() { let fx = test_support::make(); let cat = &fx.catalog; add_ref_card(cat, "elf-c", "Elf Cleric", "Creature — Elf Cleric", "G", "", &["Elf"], &[], 0, None); add_ref_card(cat, "owned-elf", "Owned Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); add_ref_card(cat, "shop-elf", "Shop Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); let d = cat.create_deck("Elfball", None).unwrap(); for oid in ["llanowar-elves", "deathrite-shaman", "elf-c"] { cat.set_deck_card(d.id, oid, 1, false, false).unwrap(); } cat.add_owned("owned-elf", 2).unwrap(); let other = cat.create_deck("Other", None).unwrap(); cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap(); let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); assert!(all.iter().any(|s| s.card.oracle_id == "shop-elf")); let owned = cat .suggest_cards(d.id, 50, 0, SuggestOptions { owned_only: true, ..Default::default() }) .unwrap(); let ids: Vec<&str> = owned.iter().map(|s| s.card.oracle_id.as_str()).collect(); assert_eq!(ids, vec!["owned-elf"]); assert_eq!(owned[0].quantity_owned, 2); assert_eq!(owned[0].quantity_assigned, 1, "one copy is already in the other deck"); } #[cfg(test)] mod subject_tests { use super::*; #[test] fn a_reason_names_the_specific_thing_it_matched() { // The subject is what the UI filters on, so it must be the bare // "Dragon"/"Landfall" rather than the sentence shown to the user — // otherwise picking one archetype would mean matching prose. let r = SynergyReason { category: "Archetype".into(), subject: "Landfall".into(), detail: "Landfall payoff/enabler".into(), }; assert_eq!(r.subject, "Landfall"); assert!(r.detail.contains(&r.subject), "detail should still read naturally"); } } }