Files
mtgc/crates/mtgc-core/src/synergy.rs
Garrett Mills 067e85dc69 Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
  owned than the decks' main boards use, the opposite of the ⚠
  over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
  Card Search and the set browser all get them. Any printing counts,
  since the library tracks cards rather than printings; within a set
  it's that set's printing. Arena-only printings don't count for a card
  that also exists on paper, so a digital re-rarity can't make a paper
  common read as rare.

Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
  cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
  "N spare" / "all in other decks". suggest_cards now takes a
  SuggestOptions struct instead of a bare `unusual` flag.

Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.

Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00

881 lines
39 KiB
Rust

//! 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<SynergyReason>,
/// Scryfall's EDHREC popularity rank (lower = more common in Commander);
/// a sort tiebreaker, not a scored category. `None` when unranked.
pub edhrec_rank: Option<i64>,
}
// Scoring weights, ordered by reliability x synergy strength. Mana is lowest
// because it's utility/fixing, not amplifying synergy, but is kept because it
// was explicitly requested. Coarse on purpose: many candidates tie exactly,
// which lets `edhrec_rank` float the played staples of a strategy to the top.
const TRIBAL_POINTS: f64 = 4.0;
const MECHANIC_POINTS: f64 = 3.0;
const ARCHETYPE_POINTS: f64 = 2.0;
const MANA_POINTS: f64 = 1.0;
const TRIBAL_CAP: usize = 2;
const MECHANIC_CAP: usize = 2;
const ARCHETYPE_CAP: usize = 3;
/// `edhrec_rank` cutoff below which a card counts as "widely played" — kept
/// in sync with the frontend's "Popular" badge threshold (`SuggestionsModal.tsx`).
/// Used by `unusual` mode to both exclude staples from the candidate pool and
/// to invert the popularity tiebreak toward the least-played matches.
const UNUSUAL_RANK_THRESHOLD: i64 = 2000;
/// 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<i64>,
quantity_owned: i64,
}
impl Catalog {
/// Suggest up to `limit` cards (skipping the first `offset`, for a
/// "suggest more" continuation) that synergize with `deck_id`, scored and
/// explained across the four synergy families. Pulls from the whole
/// reference db (ownership is flagged via `quantity_owned`, 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<Vec<CardSuggestion>> {
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<String> = tribal_counts
.iter()
.filter(|(_, &n)| n >= tribal_threshold)
.map(|(s, _)| s.clone())
.collect();
tribal_targets.extend(commander_subtypes.iter().cloned());
// --- Mechanic targets: non-evergreen keywords ---
let keyword_counts = self.deck_attr_counts(deck_id, "card_keywords", "keyword", false)?;
let commander_keywords = match &commander_oid {
Some(cmd) => self.keywords_of(cmd)?,
None => HashSet::new(),
};
let mut mechanic_targets: HashSet<String> = keyword_counts
.iter()
.filter(|(k, &n)| n >= 3 && !is_evergreen(k))
.map(|(k, _)| k.clone())
.collect();
for k in &commander_keywords {
if !is_evergreen(k) {
mechanic_targets.insert(k.clone());
}
}
// --- Archetype targets: theme bits on >=2 cards, or the commander's ---
let mut theme_counts = [0i64; ARCHETYPES.len()];
let mut archetype_mask = 0i64;
{
let mut stmt = self.conn.prepare(&format!(
"SELECT oc.themes, dc.is_commander
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1 AND dc.is_maybeboard = 0"
))?;
let rows = stmt
.query_map(params![deck_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? != 0)))?;
for row in rows {
let (themes, is_cmd) = row?;
for (i, c) in theme_counts.iter_mut().enumerate() {
if themes & (1 << i) != 0 {
*c += 1;
}
}
if is_cmd {
archetype_mask |= themes;
}
}
}
for (i, &c) in theme_counts.iter().enumerate() {
if c >= 2 {
archetype_mask |= 1 << i;
}
}
// --- Mana-base targets: colors cast but not produced ---
let mut production = [0i64; 5];
let mut consumption = [0i64; 5];
for e in contents.iter().filter(|e| !e.is_commander) {
let q = e.quantity;
for (i, (bit, _)) in WUBRG.iter().enumerate() {
if e.card.produced_mana.0 & *bit != 0 {
production[i] += q;
}
}
if !e.card.type_line.contains("Land") {
for (i, (bit, _)) in WUBRG.iter().enumerate() {
if e.card.colors.0 & *bit != 0 {
consumption[i] += q;
}
}
}
}
let mut mana_mask = 0u8;
for (i, (bit, _)) in WUBRG.iter().enumerate() {
if consumption[i] > 0 && production[i] == 0 {
mana_mask |= *bit;
}
}
if tribal_targets.is_empty()
&& mechanic_targets.is_empty()
&& archetype_mask == 0
&& mana_mask == 0
{
return Ok(vec![]);
}
// --- Candidate query (dynamic, mirroring filters.rs::search_library) ---
let mut sql = format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line,
{image}, {back}, oc.produced_mana, oc.produces_colorless, oc.legalities, {price},
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0), {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<Value> = vec![
Value::Integer(deck_id),
Value::Integer((!identity_mask & 0b11111) as i64),
];
let mut branches: Vec<String> = Vec::new();
if !tribal_targets.is_empty() {
let ph = placeholders(tribal_targets.len());
branches.push(format!(
"EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes cs WHERE cs.oracle_id = oc.oracle_id AND cs.subtype IN ({ph}))"
));
vals.extend(tribal_targets.iter().map(|s| Value::Text(s.clone())));
}
if !mechanic_targets.is_empty() {
let ph = placeholders(mechanic_targets.len());
branches.push(format!(
"EXISTS (SELECT 1 FROM {REF_DB}.card_keywords ck WHERE ck.oracle_id = oc.oracle_id AND ck.keyword IN ({ph}))"
));
vals.extend(mechanic_targets.iter().map(|k| Value::Text(k.clone())));
}
if archetype_mask != 0 {
branches.push("(oc.themes & ?) != 0".into());
vals.push(Value::Integer(archetype_mask));
}
if mana_mask != 0 {
branches.push("(oc.produced_mana & ?) != 0".into());
vals.push(Value::Integer(mana_mask as i64));
}
sql.push_str(&branches.join(" OR "));
sql.push(')');
if unusual {
sql.push_str(" AND (oc.edhrec_rank IS NULL OR oc.edhrec_rank >= ?)");
vals.push(Value::Integer(UNUSUAL_RANK_THRESHOLD));
}
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<Candidate> = {
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(vals), |r| {
Ok(Candidate {
card: CardRef {
oracle_id: r.get(0)?,
name: r.get(1)?,
mana_value: r.get(2)?,
colors: ColorSet(r.get::<_, i64>(3)? as u8),
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
type_line: r.get(5)?,
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
produces_colorless: r.get::<_, i64>(9)? != 0,
legalities: Legalities(r.get::<_, i64>(10)?),
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
horizontal: r.get::<_, i64>(15)? != 0,
},
themes: r.get(12)?,
edhrec_rank: r.get(13)?,
quantity_owned: r.get(14)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
// Batch-load each candidate's subtypes/keywords (avoids per-card N+1).
let cand_ids: Vec<String> = candidates.iter().map(|c| c.card.oracle_id.clone()).collect();
let sub_map = self.batch_attr(&cand_ids, "card_subtypes", "subtype")?;
let kw_map = self.batch_attr(&cand_ids, "card_keywords", "keyword")?;
let assigned = self.assigned_totals_by_oracle()?;
let empty: Vec<String> = Vec::new();
let mut out: Vec<CardSuggestion> = Vec::new();
for cand in candidates {
let oid = &cand.card.oracle_id;
let mut score = 0.0;
let mut reasons = Vec::new();
// Tribal
let mut tribal_hits: Vec<&String> = sub_map
.get(oid)
.unwrap_or(&empty)
.iter()
.filter(|s| tribal_targets.contains(*s))
.collect();
tribal_hits.sort();
for s in tribal_hits.into_iter().take(TRIBAL_CAP) {
score += TRIBAL_POINTS;
let detail = if commander_subtypes.contains(s) {
format!("Matches your commander's type: {s}")
} else {
let n = tribal_counts.get(s).copied().unwrap_or(0);
format!("Shares creature type: {s} ({n} in deck)")
};
reasons.push(SynergyReason {
category: "Tribal".into(),
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<HashMap<String, i64>> {
let creature_filter = if creatures_only {
"AND oc.type_line LIKE '%Creature%'"
} else {
""
};
let mut stmt = self.conn.prepare(&format!(
"SELECT x.{column}, COUNT(DISTINCT dc.oracle_id) AS n
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
JOIN {REF_DB}.{table} x ON x.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1 AND dc.is_maybeboard = 0 {creature_filter}
GROUP BY x.{column}"
))?;
let rows = stmt.query_map(params![deck_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
let mut map = HashMap::new();
for row in rows {
let (k, n) = row?;
map.insert(k, n);
}
Ok(map)
}
fn creature_subtypes_of(&self, oracle_id: &str) -> Result<HashSet<String>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT cs.subtype FROM {REF_DB}.card_subtypes cs
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = cs.oracle_id
WHERE cs.oracle_id = ?1 AND oc.type_line LIKE '%Creature%'"
))?;
let rows = stmt.query_map(params![oracle_id], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
}
fn keywords_of(&self, oracle_id: &str) -> Result<HashSet<String>> {
let mut stmt = self
.conn
.prepare(&format!("SELECT keyword FROM {REF_DB}.card_keywords WHERE oracle_id = ?1"))?;
let rows = stmt.query_map(params![oracle_id], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
}
/// Load an attribute (`subtype`/`keyword`) for many oracle_ids at once,
/// chunked under SQLite's bound-variable limit.
fn batch_attr(
&self,
ids: &[String],
table: &str,
column: &str,
) -> Result<HashMap<String, Vec<String>>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for chunk in ids.chunks(900) {
let ph = placeholders(chunk.len());
let mut stmt = self.conn.prepare(&format!(
"SELECT oracle_id, {column} FROM {REF_DB}.{table} WHERE oracle_id IN ({ph})"
))?;
let vals: Vec<Value> = chunk.iter().map(|s| Value::Text(s.clone())).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(vals), |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?;
for row in rows {
let (oid, v) = row?;
map.entry(oid).or_default().push(v);
}
}
Ok(map)
}
}
fn is_evergreen(keyword: &str) -> bool {
EVERGREEN_KEYWORDS.iter().any(|e| e.eq_ignore_ascii_case(keyword))
}
fn placeholders(n: usize) -> String {
std::iter::repeat("?").take(n).collect::<Vec<_>>().join(",")
}
/// Sort key for the edhrec_rank tiebreak. Normally pushes unranked cards
/// (`None`) to the end, so within a tied score the played staples float up.
/// In `unusual` mode this is inverted — unranked cards sort first, followed
/// by the highest (least popular) ranks — so the least-played matches float
/// up instead, which is the entire point of that mode.
fn rank_key(rank: Option<i64>, unusual: bool) -> i64 {
if unusual {
rank.map(|r| -r).unwrap_or(i64::MIN)
} else {
rank.unwrap_or(i64::MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ColorSet;
use crate::test_support;
/// Raw-insert an extra reference card (the shared fixture doesn't cover
/// themes/edhrec_rank/produced_mana). Mirrors library.rs's raw-SQL test
/// style; keeps `test_support` untouched.
#[allow(clippy::too_many_arguments)]
fn add_ref_card(
cat: &Catalog,
oid: &str,
name: &str,
type_line: &str,
identity: &str,
produced: &str,
subtypes: &[&str],
keywords: &[&str],
themes: i64,
rank: Option<i64>,
) {
let conn = cat.connection();
conn.execute(
"INSERT INTO ref.oracle_cards
(oracle_id,name,name_lower,type_line,colors,color_identity,produced_mana,themes,edhrec_rank)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
params![
oid,
name,
name.to_lowercase(),
type_line,
ColorSet::parse(identity).0 as i64,
ColorSet::parse(identity).0 as i64,
ColorSet::parse(produced).0 as i64,
themes,
rank,
],
)
.unwrap();
conn.execute(
"INSERT INTO ref.printings(scryfall_id,oracle_id,image_uri) VALUES (?1,?2,?3)",
params![format!("p-{oid}"), oid, format!("http://img/{oid}")],
)
.unwrap();
for s in subtypes {
conn.execute(
"INSERT INTO ref.card_subtypes(oracle_id,subtype) VALUES (?1,?2)",
params![oid, s],
)
.unwrap();
}
for k in keywords {
conn.execute(
"INSERT INTO ref.card_keywords(oracle_id,keyword) VALUES (?1,?2)",
params![oid, k],
)
.unwrap();
}
}
#[test]
fn compute_themes_matches_payoff_phrasing_not_actions() {
// Sacrifice payoff phrasing sets its bit.
let m = compute_themes("Whenever a creature you control dies, draw a card.");
assert_ne!(m & 1, 0); // Sacrifice is bit 0
// Plain "gain life" must NOT trip the Lifegain bit (payoff phrasing only).
let lifegain_bit = 1 << ARCHETYPES.iter().position(|(n, _)| *n == "Lifegain").unwrap();
assert_eq!(compute_themes("You gain 3 life.") & lifegain_bit, 0);
assert_ne!(compute_themes("Whenever you gain life, put a +1/+1 counter.") & lifegain_bit, 0);
// Vanilla text sets nothing.
assert_eq!(compute_themes("Flying, vigilance"), 0);
}
#[test]
fn tribal_suggestion_with_reasons_excludes_deck_cards_and_breaks_ties_by_rank() {
let fx = test_support::make();
let cat = &fx.catalog;
// Three Elf creatures in the deck (fixture has two Elves; add a third).
add_ref_card(cat, "elf-c", "Elf Cleric", "Creature — Elf Cleric", "G", "", &["Elf"], &[], 0, None);
// Two unowned Elf candidates with different popularity.
add_ref_card(cat, "elf-lord", "Elf Lord", "Creature — Elf", "G", "", &["Elf"], &[], 0, Some(5));
add_ref_card(cat, "elf-rare", "Obscure Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, Some(9000));
let d = cat.create_deck("Elfball", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
cat.set_deck_card(d.id, "elf-c", 1, false, false).unwrap();
let out = cat.suggest_cards(d.id, 50, 0, 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");
}
}
}