2026-07-21 21:04:36 -05:00
//! Deck synergy scoring and card suggestions.
//!
//! Given a deck, suggests cards from the whole reference database that fit its
//! strategy, with an explainable score broken into four families — **Tribal**
//! (shared creature types), **Mechanic** (shared non-evergreen keywords),
//! **Archetype** (amplifying strategy overlap via oracle-text patterns), and
//! **Mana base** (colors the deck casts but can't produce) — plus Scryfall's
//! own `edhrec_rank` as a popularity tiebreaker.
//!
//! Everything is derived from data already ingested from Scryfall's bulk files
//! (there is no runtime network access or external synergy database). Two
//! choices keep this aging well as new sets are printed:
//!
//! - **Keywords use a denylist, not an allowlist.** Scryfall's keyword array
//! (in [`card_keywords`]) mixes true keywords (Flying) with ability words
//! that name archetypes (Landfall, Magecraft, Convoke...). Rather than
//! curating which keywords are archetypes — a list that must chase every new
//! mechanic — we exclude a small stable set of generic evergreen keywords
//! ([`EVERGREEN_KEYWORDS`]) and treat every *other* keyword the deck leans on
//! as a synergy signal. New mechanics are covered automatically the moment
//! their cards are ingested.
//! - **The one curated list ([`ARCHETYPES`]) matches payoffs, not actions, and
//! only covers amplifying strategies** that were never given a keyword
//! (Sacrifice, Tokens, Graveyard...). Matching reward phrasing ("whenever you
//! gain life") rather than commonplace action phrasing ("gain life") keeps it
//! discriminating; scoping it to amplifying archetypes (not universal
//! goodstuff like removal/ramp) keeps suggestions synergistic rather than
//! generic. These are old, stable strategies, so this list needs review
//! roughly never.
//!
//! [`card_keywords`]: crate::db
use std ::collections ::{ HashMap , HashSet } ;
use rusqlite ::params ;
use rusqlite ::types ::Value ;
use serde ::Serialize ;
use crate ::db ::REF_DB ;
use crate ::error ::{ CoreError , Result } ;
use crate ::model ::{ CardRef , ColorSet , Legalities , WUBRG } ;
use crate ::Catalog ;
/// Curated, evergreen amplifying archetypes with no official keyword, detected
/// by payoff/enabler phrasing in oracle text. Bit position = array index,
/// packed into the `oracle_cards.themes` column at ingest. Deliberately narrow
/// and payoff-focused (see module docs): substring matching over rules text is
/// inherently fuzzy — false positives/negatives are expected and acceptable;
/// this is one signal among four, not truth.
pub const ARCHETYPES : [ ( & str , & [ & str ] ) ; 12 ] = [
( " Sacrifice " , & [ " sacrifice a creature " , " sacrifice another creature " , " whenever a creature you control dies " , " whenever another creature you control dies " , " sacrifice a permanent " ] ) ,
( " Graveyard " , & [ " return target creature card from your graveyard " , " from your graveyard to the battlefield " , " cards in your graveyard " , " creature card from your graveyard " ] ) ,
( " Tokens " , & [ " create a " , " create two " , " create three " , " create x " , " for each creature token " , " for each token you control " ] ) ,
( " +1/+1 Counters " , & [ " +1/+1 counter " ] ) ,
( " Artifacts " , & [ " whenever an artifact " , " for each artifact you control " , " artifacts you control " , " an artifact enters " ] ) ,
( " Enchantments " , & [ " whenever an enchantment " , " for each enchantment you control " , " enchantments you control " , " an enchantment enters " ] ) ,
( " Spellslinger " , & [ " whenever you cast an instant or sorcery " , " whenever you cast a noncreature spell " , " for each instant and sorcery " , " instant and sorcery cards " ] ) ,
( " Lifegain " , & [ " whenever you gain life " , " if you gained life " , " life you gained this turn " , " for each 1 life " ] ) ,
( " Extra Combat " , & [ " additional combat phase " , " an additional combat " ] ) ,
( " Voltron " , & [ " equipped creature " , " enchanted creature you control " , " whenever equipped creature " , " aura you control " ] ) ,
( " Mill " , & [ " puts the top " , " mills " , " milled " , " into their graveyard from the top of their library " ] ) ,
( " Blink " , & [ " exile it, then return it to the battlefield " , " exile target creature you control, then return " , " exile any number of target creatures you control " ] ) ,
] ;
/// Generic evergreen keywords that do NOT indicate a build-around strategy;
/// every keyword in Scryfall's array that is NOT on this list is treated as a
/// synergy signal (see module docs). Stable — the evergreen set changes rarely
/// and deliberately.
pub const EVERGREEN_KEYWORDS : [ & str ; 20 ] = [
" Flying " , " Trample " , " Deathtouch " , " Vigilance " , " Haste " , " Lifelink " ,
" Menace " , " Reach " , " Defender " , " First strike " , " Double strike " ,
" Hexproof " , " Indestructible " , " Flash " , " Ward " , " Protection " , " Shroud " ,
" Fear " , " Intimidate " , " Skulk " ,
] ;
/// Pack the [`ARCHETYPES`] bits a card's oracle text matches. Called at ingest
/// (keyword synergy is queried live from `card_keywords`, not baked in here, so
/// new mechanics need no re-ingest schema change).
pub fn compute_themes ( oracle_text : & str ) -> i64 {
let lower = oracle_text . to_lowercase ( ) ;
let mut mask = 0 i64 ;
for ( i , ( _ , phrases ) ) in ARCHETYPES . iter ( ) . enumerate ( ) {
if phrases . iter ( ) . any ( | p | lower . contains ( p ) ) {
mask | = 1 < < i ;
}
}
mask
}
/// One scored reason a card was suggested (e.g. `Tribal` / "Shares creature
/// type: Elf (9 in deck)").
#[ derive(Debug, Clone, Serialize) ]
pub struct SynergyReason {
pub category : String ,
pub detail : String ,
}
/// A suggested card with its synergy score and the reasons behind it. Unlike
/// [`crate::model::LibraryCard`]/[`crate::model::DeckEntry`], the `card` is
/// nested rather than `#[serde(flatten)]`-ed: this is a single-purpose result
/// carrying a paragraph of reasons, not a general card row reused everywhere,
/// so nesting reads cleaner.
#[ derive(Debug, Clone, Serialize) ]
pub struct CardSuggestion {
pub card : CardRef ,
pub quantity_owned : i64 ,
pub score : f64 ,
pub reasons : Vec < SynergyReason > ,
/// Scryfall's EDHREC popularity rank (lower = more common in Commander);
/// a sort tiebreaker, not a scored category. `None` when unranked.
pub edhrec_rank : Option < i64 > ,
}
// Scoring weights, ordered by reliability x synergy strength. Mana is lowest
// because it's utility/fixing, not amplifying synergy, but is kept because it
// was explicitly requested. Coarse on purpose: many candidates tie exactly,
// which lets `edhrec_rank` float the played staples of a strategy to the top.
const TRIBAL_POINTS : f64 = 4.0 ;
const MECHANIC_POINTS : f64 = 3.0 ;
const ARCHETYPE_POINTS : f64 = 2.0 ;
const MANA_POINTS : f64 = 1.0 ;
const TRIBAL_CAP : usize = 2 ;
const MECHANIC_CAP : usize = 2 ;
const ARCHETYPE_CAP : usize = 3 ;
/// `edhrec_rank` cutoff below which a card counts as "widely played" — kept
/// in sync with the frontend's "Popular" badge threshold (`SuggestionsModal.tsx`).
/// Used by `unusual` mode to both exclude staples from the candidate pool and
/// to invert the popularity tiebreak toward the least-played matches.
const UNUSUAL_RANK_THRESHOLD : i64 = 2000 ;
/// A candidate row pulled from the reference db before scoring.
struct Candidate {
card : CardRef ,
themes : i64 ,
edhrec_rank : Option < i64 > ,
quantity_owned : i64 ,
}
impl Catalog {
/// Suggest up to `limit` cards (skipping the first `offset`, for a
/// "suggest more" continuation) that synergize with `deck_id`, scored and
/// explained across the four synergy families. Pulls from the whole
/// reference db (ownership is flagged via `quantity_owned`, not filtered),
/// excludes cards already on any board of the deck, and hard-filters to the
/// deck's (or commander's) color identity.
///
/// Recomputes target signals and rescoring from scratch each call — cheap
/// enough for an on-demand action, not a hot path — so `offset` is only a
/// stable continuation as long as the deck's own contents haven't changed
/// between calls (adding a suggested card mid-session removes it from the
/// candidate pool and can shift later pages by one; acceptable for this
/// feature's scope).
///
/// When `unusual` is set, candidates ranked below [`UNUSUAL_RANK_THRESHOLD`]
/// (Scryfall's own popularity signal) or entirely unranked are favored
/// instead of the usual "float the staples" behavior, surfacing
/// underappreciated cards that still fit the deck's strategy.
pub fn suggest_cards (
& self ,
deck_id : i64 ,
limit : usize ,
offset : usize ,
unusual : bool ,
) -> Result < Vec < CardSuggestion > > {
// The `themes`/`edhrec_rank` columns only exist in a reference.db built
// after this feature shipped; an older ingest lacks them. Detect that
// and return a clear, actionable message instead of a raw "no such
// column" error (reference.db is rebuilt, not migrated, on ingest).
let has_synergy_columns : bool = self . conn . query_row (
& format! ( " SELECT COUNT(*) FROM pragma_table_info('oracle_cards', ' {REF_DB} ') WHERE name = 'themes' " ) ,
[ ] ,
| r | r . get ::< _ , i64 > ( 0 ) ,
) ? > 0 ;
if ! has_synergy_columns {
return Err ( CoreError ::Other (
" Card suggestions need updated card data — click \" Update card data \" to enable them. " . into ( ) ,
) ) ;
}
let Some ( deck ) = self . get_deck ( deck_id ) ? else {
return Ok ( vec! [ ] ) ;
} ;
let contents : Vec < _ > = self
. deck_contents ( deck_id ) ?
. into_iter ( )
. filter ( | e | ! e . is_maybeboard )
. collect ( ) ;
if contents . is_empty ( ) {
return Ok ( vec! [ ] ) ;
}
let commander_oid = deck . commander_oracle_id . clone ( ) ;
// Color identity for the hard filter: the commander's, or (no
// commander) the union of every deck card's identity — otherwise a
// mono-color deck would be offered all five colors.
let identity_mask : u8 = match & commander_oid {
Some ( cmd ) = > contents
. iter ( )
. find ( | e | & e . card . oracle_id = = cmd )
. map ( | e | e . card . color_identity . 0 )
. unwrap_or ( 0 ) ,
None = > contents . iter ( ) . fold ( 0 u8 , | 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 = [ 0 i64 ; ARCHETYPES . len ( ) ] ;
let mut archetype_mask = 0 i64 ;
{
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 = [ 0 i64 ; 5 ] ;
let mut consumption = [ 0 i64 ; 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 = 0 u8 ;
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 } ,
2026-07-22 20:55:17 -05:00
oc . themes , oc . edhrec_rank , COALESCE ( lc . quantity_owned , 0 ) , { horizontal }
2026-07-21 21:04:36 -05:00
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 ( " ,
2026-07-22 22:05:44 -05:00
image = self . image_expr ( " image_uri " ) ,
back = self . image_expr ( " back_image_uri " ) ,
2026-07-21 21:04:36 -05:00
price = crate ::db ::price_subquery ( ) ,
2026-07-22 20:55:17 -05:00
horizontal = self . horizontal_col ( ) ,
2026-07-21 21:04:36 -05:00
) ;
let mut vals : Vec < Value > = vec! [
Value ::Integer ( deck_id ) ,
Value ::Integer ( ( ! identity_mask & 0b11111 ) as i64 ) ,
] ;
let mut branches : Vec < String > = Vec ::new ( ) ;
if ! tribal_targets . is_empty ( ) {
let ph = placeholders ( tribal_targets . len ( ) ) ;
branches . push ( format! (
" EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes cs WHERE cs.oracle_id = oc.oracle_id AND cs.subtype IN ({ph})) "
) ) ;
vals . extend ( tribal_targets . iter ( ) . map ( | s | Value ::Text ( s . clone ( ) ) ) ) ;
}
if ! mechanic_targets . is_empty ( ) {
let ph = placeholders ( mechanic_targets . len ( ) ) ;
branches . push ( format! (
" EXISTS (SELECT 1 FROM {REF_DB}.card_keywords ck WHERE ck.oracle_id = oc.oracle_id AND ck.keyword IN ({ph})) "
) ) ;
vals . extend ( mechanic_targets . iter ( ) . map ( | k | Value ::Text ( k . clone ( ) ) ) ) ;
}
if archetype_mask ! = 0 {
branches . push ( " (oc.themes & ?) != 0 " . into ( ) ) ;
vals . push ( Value ::Integer ( archetype_mask ) ) ;
}
if mana_mask ! = 0 {
branches . push ( " (oc.produced_mana & ?) != 0 " . into ( ) ) ;
vals . push ( Value ::Integer ( mana_mask as i64 ) ) ;
}
sql . push_str ( & branches . join ( " OR " ) ) ;
sql . push ( ')' ) ;
if unusual {
sql . push_str ( " AND (oc.edhrec_rank IS NULL OR oc.edhrec_rank >= ?) " ) ;
vals . push ( Value ::Integer ( UNUSUAL_RANK_THRESHOLD ) ) ;
}
let candidates : Vec < Candidate > = {
let mut stmt = self . conn . prepare ( & sql ) ? ;
let rows = stmt . query_map ( rusqlite ::params_from_iter ( vals ) , | r | {
Ok ( Candidate {
card : CardRef {
oracle_id : r . get ( 0 ) ? ,
name : r . get ( 1 ) ? ,
mana_value : r . get ( 2 ) ? ,
colors : ColorSet ( r . get ::< _ , i64 > ( 3 ) ? as u8 ) ,
color_identity : ColorSet ( r . get ::< _ , i64 > ( 4 ) ? as u8 ) ,
type_line : r . get ( 5 ) ? ,
image_uri : r . get ::< _ , Option < String > > ( 6 ) ? . unwrap_or_default ( ) ,
back_image_uri : r . get ::< _ , Option < String > > ( 7 ) ? . unwrap_or_default ( ) ,
produced_mana : ColorSet ( r . get ::< _ , i64 > ( 8 ) ? as u8 ) ,
produces_colorless : r . get ::< _ , i64 > ( 9 ) ? ! = 0 ,
legalities : Legalities ( r . get ::< _ , i64 > ( 10 ) ? ) ,
price_usd : r . get ::< _ , Option < f64 > > ( 11 ) ? . unwrap_or_default ( ) ,
2026-07-22 20:55:17 -05:00
horizontal : r . get ::< _ , i64 > ( 15 ) ? ! = 0 ,
2026-07-21 21:04:36 -05:00
} ,
themes : r . get ( 12 ) ? ,
edhrec_rank : r . get ( 13 ) ? ,
quantity_owned : r . get ( 14 ) ? ,
} )
} ) ? ;
rows . collect ::< std ::result ::Result < Vec < _ > , _ > > ( ) ?
} ;
// Batch-load each candidate's subtypes/keywords (avoids per-card N+1).
let cand_ids : Vec < String > = candidates . iter ( ) . map ( | c | c . card . oracle_id . clone ( ) ) . collect ( ) ;
let sub_map = self . batch_attr ( & cand_ids , " card_subtypes " , " subtype " ) ? ;
let kw_map = self . batch_attr ( & cand_ids , " card_keywords " , " keyword " ) ? ;
let empty : Vec < String > = Vec ::new ( ) ;
let mut out : Vec < CardSuggestion > = Vec ::new ( ) ;
for cand in candidates {
let oid = & cand . card . oracle_id ;
let mut score = 0.0 ;
let mut reasons = Vec ::new ( ) ;
// Tribal
let mut tribal_hits : Vec < & String > = sub_map
. get ( oid )
. unwrap_or ( & empty )
. iter ( )
. filter ( | s | tribal_targets . contains ( * s ) )
. collect ( ) ;
tribal_hits . sort ( ) ;
for s in tribal_hits . into_iter ( ) . take ( TRIBAL_CAP ) {
score + = TRIBAL_POINTS ;
let detail = if commander_subtypes . contains ( s ) {
format! ( " Matches your commander's type: {s} " )
} else {
let n = tribal_counts . get ( s ) . copied ( ) . unwrap_or ( 0 ) ;
format! ( " Shares creature type: {s} ( {n} in deck) " )
} ;
reasons . push ( SynergyReason { category : " Tribal " . into ( ) , detail } ) ;
}
// Mechanic
let mut mech_hits : Vec < & String > = kw_map
. get ( oid )
. unwrap_or ( & empty )
. iter ( )
. filter ( | k | mechanic_targets . contains ( * k ) )
. collect ( ) ;
mech_hits . sort ( ) ;
for k in mech_hits . into_iter ( ) . take ( MECHANIC_CAP ) {
score + = MECHANIC_POINTS ;
let detail = if commander_keywords . contains ( k ) {
format! ( " Matches your commander's keyword: {k} " )
} else {
let n = keyword_counts . get ( k ) . copied ( ) . unwrap_or ( 0 ) ;
format! ( " Shares keyword: {k} ( {n} in deck) " )
} ;
reasons . push ( SynergyReason { category : " Mechanic " . into ( ) , detail } ) ;
}
// Archetype
let matched = cand . themes & archetype_mask ;
let mut arche = 0 ;
for ( i , ( name , _ ) ) in ARCHETYPES . iter ( ) . enumerate ( ) {
if arche > = ARCHETYPE_CAP {
break ;
}
if matched & ( 1 < < i ) ! = 0 {
score + = ARCHETYPE_POINTS ;
reasons . push ( SynergyReason {
category : " Archetype " . into ( ) ,
detail : format ! ( " {name} payoff/enabler " ) ,
} ) ;
arche + = 1 ;
}
}
// Mana base
let mana_hit = cand . card . produced_mana . 0 & mana_mask ;
if mana_hit ! = 0 {
score + = MANA_POINTS ;
let letters : String = WUBRG
. iter ( )
. filter ( | ( bit , _ ) | mana_hit & bit ! = 0 )
. map ( | ( _ , c ) | format! ( " {{ {c} }} " ) )
. collect ( ) ;
reasons . push ( SynergyReason {
category : " Mana base " . into ( ) ,
detail : format ! ( " Produces {letters}, which your deck can't currently make " ) ,
} ) ;
}
if score < = 0.0 {
continue ;
}
out . push ( CardSuggestion {
card : cand . card ,
quantity_owned : cand . quantity_owned ,
score ,
reasons ,
edhrec_rank : cand . edhrec_rank ,
} ) ;
}
out . sort_by ( | a , b | {
b . score
. partial_cmp ( & a . score )
. unwrap_or ( std ::cmp ::Ordering ::Equal )
. then_with ( | | rank_key ( a . edhrec_rank , unusual ) . cmp ( & rank_key ( b . edhrec_rank , unusual ) ) )
. then_with ( | | a . card . name . to_lowercase ( ) . cmp ( & b . card . name . to_lowercase ( ) ) )
} ) ;
let out = out . into_iter ( ) . skip ( offset ) . take ( limit ) . collect ( ) ;
Ok ( out )
}
/// Count, per attribute value, how many of a deck's non-maybeboard cards
/// have it. `table`/`column` are fixed call-site literals (safe to
/// interpolate). `creatures_only` restricts to cards whose type line is a
/// creature (for tribal subtypes — keeps land/artifact subtypes out).
fn deck_attr_counts (
& self ,
deck_id : i64 ,
table : & str ,
column : & str ,
creatures_only : bool ,
) -> Result < HashMap < String , i64 > > {
let creature_filter = if creatures_only {
" AND oc.type_line LIKE '%Creature%' "
} else {
" "
} ;
let mut stmt = self . conn . prepare ( & format! (
" SELECT x.{column}, COUNT(DISTINCT dc.oracle_id) AS n
FROM deck_cards dc
JOIN { REF_DB } . oracle_cards oc ON oc . oracle_id = dc . oracle_id
JOIN { REF_DB } . { table } x ON x . oracle_id = dc . oracle_id
WHERE dc . deck_id = ? 1 AND dc . is_maybeboard = 0 { creature_filter }
GROUP BY x . { column } "
) ) ? ;
let rows = stmt . query_map ( params! [ deck_id ] , | r | Ok ( ( r . get ::< _ , String > ( 0 ) ? , r . get ::< _ , i64 > ( 1 ) ? ) ) ) ? ;
let mut map = HashMap ::new ( ) ;
for row in rows {
let ( k , n ) = row ? ;
map . insert ( k , n ) ;
}
Ok ( map )
}
fn creature_subtypes_of ( & self , oracle_id : & str ) -> Result < HashSet < String > > {
let mut stmt = self . conn . prepare ( & format! (
" SELECT cs.subtype FROM {REF_DB}.card_subtypes cs
JOIN { REF_DB } . oracle_cards oc ON oc . oracle_id = cs . oracle_id
WHERE cs . oracle_id = ? 1 AND oc . type_line LIKE ' % Creature % ' "
) ) ? ;
let rows = stmt . query_map ( params! [ oracle_id ] , | r | r . get ::< _ , String > ( 0 ) ) ? ;
Ok ( rows . collect ::< std ::result ::Result < HashSet < _ > , _ > > ( ) ? )
}
fn keywords_of ( & self , oracle_id : & str ) -> Result < HashSet < String > > {
let mut stmt = self
. conn
. prepare ( & format! ( " SELECT keyword FROM {REF_DB} .card_keywords WHERE oracle_id = ?1 " ) ) ? ;
let rows = stmt . query_map ( params! [ oracle_id ] , | r | r . get ::< _ , String > ( 0 ) ) ? ;
Ok ( rows . collect ::< std ::result ::Result < HashSet < _ > , _ > > ( ) ? )
}
/// Load an attribute (`subtype`/`keyword`) for many oracle_ids at once,
/// chunked under SQLite's bound-variable limit.
fn batch_attr (
& self ,
ids : & [ String ] ,
table : & str ,
column : & str ,
) -> Result < HashMap < String , Vec < String > > > {
let mut map : HashMap < String , Vec < String > > = HashMap ::new ( ) ;
for chunk in ids . chunks ( 900 ) {
let ph = placeholders ( chunk . len ( ) ) ;
let mut stmt = self . conn . prepare ( & format! (
" SELECT oracle_id, {column} FROM {REF_DB}.{table} WHERE oracle_id IN ({ph}) "
) ) ? ;
let vals : Vec < Value > = chunk . iter ( ) . map ( | s | Value ::Text ( s . clone ( ) ) ) . collect ( ) ;
let rows = stmt . query_map ( rusqlite ::params_from_iter ( vals ) , | r | {
Ok ( ( r . get ::< _ , String > ( 0 ) ? , r . get ::< _ , String > ( 1 ) ? ) )
} ) ? ;
for row in rows {
let ( oid , v ) = row ? ;
map . entry ( oid ) . or_default ( ) . push ( v ) ;
}
}
Ok ( map )
}
}
fn is_evergreen ( keyword : & str ) -> bool {
EVERGREEN_KEYWORDS . iter ( ) . any ( | e | e . eq_ignore_ascii_case ( keyword ) )
}
fn placeholders ( n : usize ) -> String {
std ::iter ::repeat ( " ? " ) . take ( n ) . collect ::< Vec < _ > > ( ) . join ( " , " )
}
/// Sort key for the edhrec_rank tiebreak. Normally pushes unranked cards
/// (`None`) to the end, so within a tied score the played staples float up.
/// In `unusual` mode this is inverted — unranked cards sort first, followed
/// by the highest (least popular) ranks — so the least-played matches float
/// up instead, which is the entire point of that mode.
fn rank_key ( rank : Option < i64 > , unusual : bool ) -> i64 {
if unusual {
rank . map ( | r | - r ) . unwrap_or ( i64 ::MIN )
} else {
rank . unwrap_or ( i64 ::MAX )
}
}
#[ cfg(test) ]
mod tests {
use super ::* ;
use crate ::model ::ColorSet ;
use crate ::test_support ;
/// Raw-insert an extra reference card (the shared fixture doesn't cover
/// themes/edhrec_rank/produced_mana). Mirrors library.rs's raw-SQL test
/// style; keeps `test_support` untouched.
#[ allow(clippy::too_many_arguments) ]
fn add_ref_card (
cat : & Catalog ,
oid : & str ,
name : & str ,
type_line : & str ,
identity : & str ,
produced : & str ,
subtypes : & [ & str ] ,
keywords : & [ & str ] ,
themes : i64 ,
rank : Option < i64 > ,
) {
let conn = cat . connection ( ) ;
conn . execute (
" INSERT INTO ref.oracle_cards
( oracle_id , name , name_lower , type_line , colors , color_identity , produced_mana , themes , edhrec_rank )
VALUES ( ? 1 , ? 2 , ? 3 , ? 4 , ? 5 , ? 6 , ? 7 , ? 8 , ? 9 ) " ,
params! [
oid ,
name ,
name . to_lowercase ( ) ,
type_line ,
ColorSet ::parse ( identity ) . 0 as i64 ,
ColorSet ::parse ( identity ) . 0 as i64 ,
ColorSet ::parse ( produced ) . 0 as i64 ,
themes ,
rank ,
] ,
)
. unwrap ( ) ;
conn . execute (
" INSERT INTO ref.printings(scryfall_id,oracle_id,image_uri) VALUES (?1,?2,?3) " ,
params! [ format! ( " p- {oid} " ) , oid , format! ( " http://img/ {oid} " ) ] ,
)
. unwrap ( ) ;
for s in subtypes {
conn . execute (
" INSERT INTO ref.card_subtypes(oracle_id,subtype) VALUES (?1,?2) " ,
params! [ oid , s ] ,
)
. unwrap ( ) ;
}
for k in keywords {
conn . execute (
" INSERT INTO ref.card_keywords(oracle_id,keyword) VALUES (?1,?2) " ,
params! [ oid , k ] ,
)
. unwrap ( ) ;
}
}
#[ test ]
fn compute_themes_matches_payoff_phrasing_not_actions ( ) {
// Sacrifice payoff phrasing sets its bit.
let m = compute_themes ( " Whenever a creature you control dies, draw a card. " ) ;
assert_ne! ( m & 1 , 0 ) ; // Sacrifice is bit 0
// Plain "gain life" must NOT trip the Lifegain bit (payoff phrasing only).
let lifegain_bit = 1 < < ARCHETYPES . iter ( ) . position ( | ( n , _ ) | * n = = " Lifegain " ) . unwrap ( ) ;
assert_eq! ( compute_themes ( " You gain 3 life. " ) & lifegain_bit , 0 ) ;
assert_ne! ( compute_themes ( " Whenever you gain life, put a +1/+1 counter. " ) & lifegain_bit , 0 ) ;
// Vanilla text sets nothing.
assert_eq! ( compute_themes ( " Flying, vigilance " ) , 0 ) ;
}
#[ test ]
fn tribal_suggestion_with_reasons_excludes_deck_cards_and_breaks_ties_by_rank ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
// Three Elf creatures in the deck (fixture has two Elves; add a third).
add_ref_card ( cat , " elf-c " , " Elf Cleric " , " Creature — Elf Cleric " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , None ) ;
// Two unowned Elf candidates with different popularity.
add_ref_card ( cat , " elf-lord " , " Elf Lord " , " Creature — Elf " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , Some ( 5 ) ) ;
add_ref_card ( cat , " elf-rare " , " Obscure Elf " , " Creature — Elf " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , Some ( 9000 ) ) ;
let d = cat . create_deck ( " Elfball " , None ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " llanowar-elves " , 1 , false , false ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " deathrite-shaman " , 1 , false , false ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " elf-c " , 1 , false , false ) . unwrap ( ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) ;
let ids : Vec < & str > = out . iter ( ) . map ( | s | s . card . oracle_id . as_str ( ) ) . collect ( ) ;
// Both Elf candidates appear with a Tribal reason mentioning Elf.
assert! ( ids . contains ( & " elf-lord " ) ) ;
assert! ( ids . contains ( & " elf-rare " ) ) ;
let lord = out . iter ( ) . find ( | s | s . card . oracle_id = = " elf-lord " ) . unwrap ( ) ;
assert! ( lord . reasons . iter ( ) . any ( | r | r . category = = " Tribal " & & r . detail . contains ( " Elf " ) ) ) ;
// Cards already in the deck are never suggested.
assert! ( ! ids . contains ( & " llanowar-elves " ) ) ;
assert! ( ! ids . contains ( & " deathrite-shaman " ) ) ;
// Equal score → the lower edhrec_rank sorts first.
let li = ids . iter ( ) . position ( | x | * x = = " elf-lord " ) . unwrap ( ) ;
let ri = ids . iter ( ) . position ( | x | * x = = " elf-rare " ) . unwrap ( ) ;
assert! ( li < ri ) ;
// `unusual` mode excludes the widely-played candidate entirely and
// keeps the obscure one.
add_ref_card ( cat , " elf-unranked " , " Unranked Elf " , " Creature — Elf " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , None ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , true ) . unwrap ( ) ;
let ids : Vec < & str > = out . iter ( ) . map ( | s | s . card . oracle_id . as_str ( ) ) . collect ( ) ;
assert! ( ! ids . contains ( & " elf-lord " ) , " rank 5 is well below the unusual threshold " ) ;
assert! ( ids . contains ( & " elf-rare " ) ) ;
assert! ( ids . contains ( & " elf-unranked " ) ) ;
// Equal score → the tiebreak flips toward the least popular first.
let ui = ids . iter ( ) . position ( | x | * x = = " elf-unranked " ) . unwrap ( ) ;
let ri = ids . iter ( ) . position ( | x | * x = = " elf-rare " ) . unwrap ( ) ;
assert! ( ui < ri , " unranked should float above a merely-obscure ranked card " ) ;
}
#[ test ]
fn small_tribal_splash_does_not_trigger_tribe ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
// A deck with just the two fixture Elves plus many non-Elf creatures,
// so Elf (2) is below the proportional threshold.
let d = cat . create_deck ( " Goodstuff " , None ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " llanowar-elves " , 1 , false , false ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " deathrite-shaman " , 1 , false , false ) . unwrap ( ) ;
for i in 0 .. 10 {
let oid = format! ( " beast- {i} " ) ;
add_ref_card ( cat , & oid , & format! ( " Beast {i} " ) , " Creature " , " G " , " " , & [ ] , & [ ] , 0 , None ) ;
cat . set_deck_card ( d . id , & oid , 1 , false , false ) . unwrap ( ) ;
}
// An Elf candidate whose only possible signal is tribal.
add_ref_card ( cat , " lone-elf " , " Lone Elf " , " Creature — Elf " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , None ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) ;
assert! ( ! out . iter ( ) . any ( | s | s . card . oracle_id = = " lone-elf " ) ) ;
}
#[ test ]
fn mechanic_uses_denylist_evergreen_excluded_others_included ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
let d = cat . create_deck ( " Landfall " , None ) . unwrap ( ) ;
// Three deck creatures with both Landfall (mechanic) and Flying (evergreen).
for i in 0 .. 3 {
let oid = format! ( " land-guy- {i} " ) ;
add_ref_card ( cat , & oid , & format! ( " Land Guy {i} " ) , " Creature — Elemental " , " G " , " " , & [ ] , & [ " Landfall " , " Flying " ] , 0 , None ) ;
cat . set_deck_card ( d . id , & oid , 1 , false , false ) . unwrap ( ) ;
}
add_ref_card ( cat , " landfall-payoff " , " Landfall Payoff " , " Creature — Elemental " , " G " , " " , & [ ] , & [ " Landfall " ] , 0 , None ) ;
add_ref_card ( cat , " just-a-flyer " , " Just A Flyer " , " Creature — Bird " , " G " , " " , & [ ] , & [ " Flying " ] , 0 , None ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) ;
let payoff = out . iter ( ) . find ( | s | s . card . oracle_id = = " landfall-payoff " ) ;
assert! ( payoff . is_some ( ) , " non-evergreen keyword should drive suggestions " ) ;
assert! ( payoff . unwrap ( ) . reasons . iter ( ) . any ( | r | r . category = = " Mechanic " & & r . detail . contains ( " Landfall " ) ) ) ;
// Flying alone (evergreen) is not a synergy signal.
assert! ( ! out . iter ( ) . any ( | s | s . card . oracle_id = = " just-a-flyer " ) ) ;
}
#[ test ]
fn commander_type_is_always_a_target_and_off_color_is_excluded ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
// Mono-green Elf commander, single card in the deck.
let d = cat . create_deck ( " Commander Elf " , Some ( " llanowar-elves " ) ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " llanowar-elves " , 1 , true , false ) . unwrap ( ) ;
cat . set_deck_commander ( d . id , Some ( " llanowar-elves " ) ) . unwrap ( ) ;
add_ref_card ( cat , " green-elf " , " Green Elf " , " Creature — Elf " , " G " , " " , & [ " Elf " ] , & [ ] , 0 , None ) ;
add_ref_card ( cat , " black-elf " , " Black Elf " , " Creature — Elf " , " B " , " " , & [ " Elf " ] , & [ ] , 0 , None ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) ;
let green = out . iter ( ) . find ( | s | s . card . oracle_id = = " green-elf " ) ;
assert! ( green . is_some ( ) , " commander's own type should be a tribal target " ) ;
assert! ( green . unwrap ( ) . reasons . iter ( ) . any ( | r | r . detail . contains ( " commander's type " ) ) ) ;
// Off-color-identity candidate is hard-excluded.
assert! ( ! out . iter ( ) . any ( | s | s . card . oracle_id = = " black-elf " ) ) ;
}
#[ test ]
fn mana_base_fit_flags_a_missing_color ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
// Deathrite Shaman casts B and G but the deck produces neither.
let d = cat . create_deck ( " BG " , None ) . unwrap ( ) ;
cat . set_deck_card ( d . id , " deathrite-shaman " , 1 , false , false ) . unwrap ( ) ;
add_ref_card ( cat , " swamp-dork " , " Swamp Dork " , " Creature — Elf " , " B " , " B " , & [ " Elf " ] , & [ ] , 0 , None ) ;
let out = cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) ;
let dork = out . iter ( ) . find ( | s | s . card . oracle_id = = " swamp-dork " ) . unwrap ( ) ;
assert! ( dork . reasons . iter ( ) . any ( | r | r . category = = " Mana base " & & r . detail . contains ( " {B} " ) ) ) ;
}
#[ test ]
fn empty_deck_returns_no_suggestions ( ) {
let fx = test_support ::make ( ) ;
let cat = & fx . catalog ;
let d = cat . create_deck ( " Empty " , None ) . unwrap ( ) ;
assert! ( cat . suggest_cards ( d . id , 50 , 0 , false ) . unwrap ( ) . is_empty ( ) ) ;
}
}