Add deck analysis panel with mana production/consumption bars and oracle text search

Threads a new produced_mana/produces_colorless field through the whole card
read path (ingest, library, filters, decks, search) so the deck view can
show what colors a deck's manabase actually produces, alongside its existing
color identity. Also adds a mana consumption bar (colored casting costs of
nonland spells) and stacks the mana curve histogram by color. Adds an
oracle-text substring filter to the library search, mirroring the existing
location filter. Rearranges the deck sidebar list into two rows (name, then
format chip + actions) so long names and action buttons no longer collide,
and removes the drop shadows from the commander hero panel/image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:54:47 -05:00
parent 4ac536caa2
commit e3f8a299de
17 changed files with 1054 additions and 161 deletions

View File

@@ -112,7 +112,7 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
@@ -136,6 +136,8 @@ impl Catalog {
type_line: r.get(8)?,
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
produces_colorless: r.get::<_, i64>(12)? != 0,
},
})
})?

View File

@@ -18,7 +18,8 @@ impl Catalog {
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
let mut sql = format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back}
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",
@@ -85,6 +86,10 @@ impl Catalog {
sql.push_str(" AND LOWER(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
}
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?");
vals.push(Value::Text(format!("%{}%", txt.to_lowercase())));
}
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
@@ -265,4 +270,38 @@ mod tests {
}).unwrap();
assert_eq!(all.len(), 5);
}
#[test]
fn oracle_text_filter_is_case_insensitive_substring() {
let fx = owned_fixture();
let cat = &fx.catalog;
// "Add {G}." (Llanowar Elves) and "Add {C}{C}." (Sol Ring) both mention "add".
let add_matches = names(&cat.search_library(&LibraryFilter {
oracle_text: Some("add".into()),
..Default::default()
}).unwrap());
assert_eq!(add_matches, vec!["Llanowar Elves", "Sol Ring"]);
// Case-insensitive.
let damage = names(&cat.search_library(&LibraryFilter {
oracle_text: Some("DAMAGE".into()),
..Default::default()
}).unwrap());
assert_eq!(damage, vec!["Lightning Bolt"]);
// Deathrite Shaman has empty oracle_text in the fixture, so it never matches.
let none = cat.search_library(&LibraryFilter {
oracle_text: Some("nonexistent ability".into()),
..Default::default()
}).unwrap();
assert!(none.is_empty());
// Empty/whitespace text is treated as "no filter".
let all = cat.search_library(&LibraryFilter {
oracle_text: Some(" ".into()),
..Default::default()
}).unwrap();
assert_eq!(all.len(), 5);
}
}

View File

@@ -430,6 +430,10 @@ struct ScryOracle {
keywords: Vec<String>,
#[serde(default)]
card_faces: Option<Vec<ScryFace>>,
/// What colors of mana this card can add (lands, mana rocks, dorks). A
/// top-level-only Scryfall field — not per-face, even on DFCs.
#[serde(default)]
produced_mana: Option<Vec<String>>,
}
#[derive(Deserialize)]
@@ -438,6 +442,8 @@ struct ScryFace {
type_line: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
#[serde(default)]
oracle_text: Option<String>,
}
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
@@ -446,8 +452,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
{
let mut ins_card = conn.prepare(
"INSERT OR IGNORE INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
produced_mana,produces_colorless)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
@@ -502,6 +509,30 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
.unwrap_or_default()
});
// Oracle text: top-level, else join faces — double-faced cards (MDFC,
// transform, etc.) never carry a top-level oracle_text in Scryfall's
// data, only per-face text.
let oracle_text = c.oracle_text.clone().unwrap_or_else(|| {
c.card_faces
.as_ref()
.map(|faces| {
faces
.iter()
.filter_map(|f| f.oracle_text.clone())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
});
// Mana production: colored letters map to the WUBRG bitmask (same as
// colors/identity); "C" (colorless) is tracked separately since it
// isn't a WUBRG bit. Top-level only — Scryfall doesn't put this on
// individual card_faces.
let produced = c.produced_mana.as_deref().unwrap_or(&[]);
let produced_mana = ColorSet::from_letters(produced);
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
let changed = ins_card.execute(params![
oracle_id,
c.name,
@@ -510,7 +541,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
colors.0 as i64,
identity.0 as i64,
type_line,
c.oracle_text.clone().unwrap_or_default(),
oracle_text,
produced_mana.0 as i64,
produces_colorless as i64,
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
@@ -797,6 +830,83 @@ mod tests {
assert_eq!(g as u8, crate::model::G);
}
#[test]
fn double_faced_cards_fall_back_to_card_faces_for_type_colors_and_text() {
// Modal DFCs (and transform cards) never carry a top-level type_line,
// colors, or oracle_text in Scryfall's oracle-cards bulk — only
// per-face fields. Regression test for a bug where oracle_text had no
// fallback (unlike type_line/colors), silently leaving every
// double-faced card's oracle_text empty and unsearchable.
let oracle = r#"[
{"oracle_id":"oid-dfc","name":"Elesh Norn // The Argent Etchings",
"cmc":5.0,"color_identity":["W"],"keywords":[],
"card_faces":[
{"type_line":"Legendary Creature — Phyrexian Praetor","colors":["W"],
"oracle_text":"Whenever a source an opponent controls deals damage to you or a permanent you control, exile that source."},
{"type_line":"Battle — Siege","colors":[],
"oracle_text":"When you defeated The Argent Etchings, draw a card."}
]}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
let (type_line, colors, oracle_text): (String, i64, String) = conn
.query_row(
"SELECT type_line, colors, oracle_text FROM oracle_cards WHERE oracle_id='oid-dfc'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(type_line, "Legendary Creature — Phyrexian Praetor // Battle — Siege");
assert_eq!(colors as u8, crate::model::W);
assert!(oracle_text.contains("an opponent controls deals damage"));
assert!(oracle_text.contains("draw a card"));
}
#[test]
fn captures_produced_mana_including_colorless() {
let oracle = r#"[
{"oracle_id":"oid-dual","name":"Overgrown Tomb","cmc":0.0,
"color_identity":[],"keywords":[],"produced_mana":["B","G"]},
{"oracle_id":"oid-wastes","name":"Wastes","cmc":0.0,
"color_identity":[],"keywords":[],"produced_mana":["C"]},
{"oracle_id":"oid-vanilla","name":"Grizzly Bears","cmc":2.0,
"color_identity":["G"],"keywords":[]}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
let fetch = |id: &str| -> (i64, i64) {
conn.query_row(
"SELECT produced_mana, produces_colorless FROM oracle_cards WHERE oracle_id=?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap()
};
let (dual_mana, dual_colorless) = fetch("oid-dual");
assert_eq!(dual_mana as u8, crate::model::B | crate::model::G);
assert_eq!(dual_colorless, 0);
let (wastes_mana, wastes_colorless) = fetch("oid-wastes");
assert_eq!(wastes_mana, 0); // "C" isn't a WUBRG bit
assert_eq!(wastes_colorless, 1);
let (vanilla_mana, vanilla_colorless) = fetch("oid-vanilla");
assert_eq!(vanilla_mana, 0);
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
}
#[test]
fn captures_back_face_image_for_double_faced_cards() {
// Transform/MDFC cards: two `card_faces`, each with its own image.

View File

@@ -19,7 +19,9 @@ use std::path::Path;
use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFilter, LibraryStats};
pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats,
};
pub use search::NameIndex;
/// A handle to an open catalog: the library db with the reference db attached.
@@ -66,7 +68,7 @@ pub(crate) mod test_support {
}
/// (oracle_id, name, mana_value, colors, color_identity, type_line,
/// types, subtypes, keywords)
/// types, subtypes, keywords, oracle_text)
pub struct SeedCard {
pub oracle_id: &'static str,
pub name: &'static str,
@@ -77,6 +79,7 @@ pub(crate) mod test_support {
pub types: &'static [&'static str],
pub subtypes: &'static [&'static str],
pub keywords: &'static [&'static str],
pub oracle_text: &'static str,
}
pub fn seed_cards() -> Vec<SeedCard> {
@@ -91,6 +94,7 @@ pub(crate) mod test_support {
types: &["Creature"],
subtypes: &["Elf", "Druid"],
keywords: &[],
oracle_text: "{T}: Add {G}.",
},
SeedCard {
oracle_id: "deathrite-shaman",
@@ -102,6 +106,7 @@ pub(crate) mod test_support {
types: &["Creature"],
subtypes: &["Elf", "Shaman"],
keywords: &[],
oracle_text: "",
},
SeedCard {
oracle_id: "serra-angel",
@@ -113,6 +118,7 @@ pub(crate) mod test_support {
types: &["Creature"],
subtypes: &["Angel"],
keywords: &["Flying", "Vigilance"],
oracle_text: "Flying, vigilance",
},
SeedCard {
oracle_id: "lightning-bolt",
@@ -124,6 +130,7 @@ pub(crate) mod test_support {
types: &["Instant"],
subtypes: &[],
keywords: &[],
oracle_text: "Lightning Bolt deals 3 damage to any target.",
},
SeedCard {
oracle_id: "sol-ring",
@@ -135,6 +142,7 @@ pub(crate) mod test_support {
types: &["Artifact"],
subtypes: &[],
keywords: &[],
oracle_text: "{T}: Add {C}{C}.",
},
]
}
@@ -153,8 +161,8 @@ pub(crate) mod test_support {
rconn
.execute(
"INSERT INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line)
VALUES (?1,?2,?3,?4,?5,?6,?7)",
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
rusqlite::params![
c.oracle_id,
c.name,
@@ -162,7 +170,8 @@ pub(crate) mod test_support {
c.mv,
colors,
identity,
c.type_line
c.type_line,
c.oracle_text,
],
)
.unwrap();

View File

@@ -6,12 +6,13 @@ use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryStats, WUBRG};
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri)` into its
/// library parts. Shared by [`Catalog::list_library`] and the criteria search.
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`]
/// and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
@@ -29,6 +30,8 @@ pub(crate) fn row_to_library_parts(
type_line: r.get(7)?,
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
},
))
}
@@ -70,7 +73,8 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back}
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
@@ -144,6 +148,29 @@ impl Catalog {
})
}
/// Distinct type/subtype/keyword values among owned cards, for filter
/// autocomplete.
pub fn library_facets(&self) -> Result<LibraryFacets> {
Ok(LibraryFacets {
types: self.distinct_owned_facet("card_types", "type")?,
subtypes: self.distinct_owned_facet("card_subtypes", "subtype")?,
keywords: self.distinct_owned_facet("card_keywords", "keyword")?,
})
}
/// `table`/`column` are always one of the three hardcoded call sites above
/// (never user input), so interpolating them into the query is safe.
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT DISTINCT x.{column}
FROM {REF_DB}.{table} x
WHERE x.oracle_id IN (SELECT oracle_id FROM library_cards WHERE quantity_owned > 0)
ORDER BY x.{column} COLLATE NOCASE"
))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// Set the free-text storage location for a card (creates the library row if
/// absent). Empty string clears it.
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
@@ -207,6 +234,22 @@ mod tests {
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn library_facets_only_include_owned_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
// Own llanowar-elves (Creature — Elf Druid) and lightning-bolt
// (Instant); leave serra-angel (Creature — Angel, Flying/Vigilance)
// unowned.
cat.add_owned("llanowar-elves", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
let facets = cat.library_facets().unwrap();
assert_eq!(facets.types, vec!["Creature", "Instant"]);
assert_eq!(facets.subtypes, vec!["Druid", "Elf"]);
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
}
#[test]
fn library_stats_aggregates_counts_and_colors() {
let fx = test_support::make();

View File

@@ -91,6 +91,14 @@ pub struct CardRef {
/// URL of the *back face* image for the same printing (transform / modal
/// double-faced / reversible cards), or empty for single-faced cards.
pub back_image_uri: String,
/// WUBRG bitmask of colors this card can *produce* (lands, mana rocks,
/// dorks, etc.) — independent of `colors`/`color_identity`, which
/// describe what color the card *is*, not what it taps for.
pub produced_mana: ColorSet,
/// Whether this card can produce colorless mana (Scryfall's
/// `produced_mana` including "C"), tracked separately since colorless
/// isn't a WUBRG bit.
pub produces_colorless: bool,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
@@ -155,6 +163,16 @@ pub struct LibraryStats {
pub colorless_count: i64,
}
/// Distinct type/subtype/keyword values found among *owned* cards, sorted.
/// Powers autocomplete for the library filter panel — e.g. no point
/// suggesting "Phyrexian" as a subtype if you don't own any.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LibraryFacets {
pub types: Vec<String>,
pub subtypes: Vec<String>,
pub keywords: Vec<String>,
}
/// Criteria for searching the owned library. All fields are optional; an empty
/// filter matches every owned card.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -184,4 +202,6 @@ pub struct LibraryFilter {
/// Case-insensitive substring match against the card's stored location
/// (e.g. "Box 1"). Cards with no location set never match a non-empty value.
pub location: Option<String>,
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
}

View File

@@ -34,7 +34,7 @@ impl NameIndex {
pub fn build(conn: &Connection) -> Result<Self> {
let mut stmt = conn.prepare(&format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
@@ -51,6 +51,8 @@ impl NameIndex {
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,
},
})
})?

View File

@@ -10,7 +10,9 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
type_line TEXT NOT NULL DEFAULT '',
oracle_text TEXT NOT NULL DEFAULT ''
oracle_text TEXT NOT NULL DEFAULT '',
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C}
);
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);