A Commander/Brawl deck can now have two commanders when their abilities allow it. Partner abilities are parsed from oracle text (Scryfall's keyword list files every "Partner—Survivors"-style variant under plain "Partner"), and a second search box offers only cards that can pair with the commander. The legality check accepts a valid pair and checks color identity against both; suggestions and decklist import/export handle both commanders. Closes #4
910 lines
40 KiB
Rust
910 lines
40 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![]);
|
|
}
|
|
|
|
// The commanders: both halves of a partner pair, or just the one.
|
|
let mut commander_oids: Vec<String> = contents
|
|
.iter()
|
|
.filter(|e| e.is_commander)
|
|
.map(|e| e.card.oracle_id.clone())
|
|
.collect();
|
|
if commander_oids.is_empty() {
|
|
commander_oids.extend(deck.commander_oracle_id.clone());
|
|
}
|
|
|
|
// Color identity for the hard filter: the commanders' combined, 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 = if commander_oids.is_empty() {
|
|
contents.iter().fold(0u8, |m, e| m | e.card.color_identity.0)
|
|
} else {
|
|
contents
|
|
.iter()
|
|
.filter(|e| commander_oids.contains(&e.card.oracle_id))
|
|
.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 mut commander_subtypes = HashSet::new();
|
|
for cmd in &commander_oids {
|
|
commander_subtypes.extend(self.creature_subtypes_of(cmd)?);
|
|
}
|
|
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 mut commander_keywords = HashSet::new();
|
|
for cmd in &commander_oids {
|
|
commander_keywords.extend(self.keywords_of(cmd)?);
|
|
}
|
|
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 partner_commanders_combine_their_identities() {
|
|
let fx = test_support::make();
|
|
let cat = &fx.catalog;
|
|
// Green Elf and white Angel as partner commanders: G+W is allowed.
|
|
let d = cat.create_deck("Partners", None).unwrap();
|
|
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
|
|
cat.set_deck_card(d.id, "serra-angel", 1, false, false).unwrap();
|
|
cat.set_deck_commanders(d.id, &["llanowar-elves", "serra-angel"]).unwrap();
|
|
|
|
add_ref_card(cat, "white-elf", "White Elf", "Creature — Elf", "W", "", &["Elf"], &[], 0, None);
|
|
add_ref_card(cat, "black-elf", "Black Elf", "Creature — Elf", "B", "", &["Elf"], &[], 0, None);
|
|
|
|
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
|
assert!(
|
|
out.iter().any(|s| s.card.oracle_id == "white-elf"),
|
|
"the partner's white widens the identity, and the Elf type still counts"
|
|
);
|
|
assert!(!out.iter().any(|s| s.card.oracle_id == "black-elf"));
|
|
}
|
|
|
|
#[test]
|
|
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");
|
|
}
|
|
}
|
|
}
|