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

@@ -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.