diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 12dfc8f..ac01cde 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -18,7 +18,11 @@ fn push_card_filters( vals: &mut Vec, filter: &LibraryFilter, has_universes_beyond: bool, + has_vanilla: bool, ) { + if has_vanilla && filter.vanilla_only { + sql.push_str(" AND oc.vanilla = 1"); + } // The four card-origin flags all arrived in the same reference-schema // revision, so one capability check covers them. Skipped entirely against // an older db — referencing a missing column would make every search fail @@ -188,7 +192,7 @@ impl Catalog { horizontal = self.horizontal_col() ); let mut vals: Vec = Vec::new(); - push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond); + push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla); if let Some(deck_id) = filter.in_deck { sql.push_str( @@ -248,7 +252,7 @@ impl Catalog { horizontal = self.horizontal_col() ); let mut vals: Vec = Vec::new(); - push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond); + push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla); sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); vals.push(Value::Integer(limit as i64)); @@ -316,6 +320,28 @@ mod tests { assert!(all.contains(&"Serra Angel".to_string())); } + #[test] + fn vanilla_only_keeps_flagged_creatures_and_is_off_by_default() { + let fx = owned_fixture(); + fx.catalog + .conn + .execute( + "UPDATE ref.oracle_cards SET vanilla = 1 WHERE oracle_id = 'llanowar-elves'", + [], + ) + .unwrap(); + + let got = names( + &fx.catalog + .search_library(&LibraryFilter { vanilla_only: true, ..Default::default() }) + .unwrap(), + ); + assert_eq!(got, vec!["Llanowar Elves".to_string()]); + + let all = names(&fx.catalog.search_library(&LibraryFilter::default()).unwrap()); + assert!(all.contains(&"Serra Angel".to_string()), "off unless asked for"); + } + #[test] fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() { let fx = owned_fixture(); diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 948576c..d65b30e 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -477,8 +477,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) "INSERT OR IGNORE INTO oracle_cards (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text, produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal, - image_uri,back_image_uri,default_scryfall_id,power,toughness) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)", + image_uri,back_image_uri,default_scryfall_id,power,toughness,vanilla) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; @@ -610,6 +610,24 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) c.toughness.as_deref().or_else(|| front.and_then(|f| f.toughness.as_deref())), ); + // "Vanilla": a creature whose printed rules box is empty. Judged from + // the front face's *own* text, never the joined `oracle_text` above — + // an Adventure or Prepared card is a vanilla creature with a spell + // stapled on, and the joined column always carries that spell's text. + // The front face is the right one to ask: it's the half that resolves + // as the creature, and a transforming card is cast from its front. + let front_text = match front { + Some(f) => f.oracle_text.clone().unwrap_or_default(), + None => oracle_text.clone(), + }; + let vanilla = front_type_line + .split('—') + .next() + .unwrap_or("") + .split_whitespace() + .any(|w| w == "Creature") + && front_text.trim().is_empty(); + let changed = ins_card.execute(params![ oracle_id, c.name, @@ -630,6 +648,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) default_scryfall_id, power, toughness, + vanilla as i64, ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { @@ -1088,6 +1107,51 @@ mod tests { assert!(oracle_text.contains("draw a card")); } + #[test] + fn vanilla_is_judged_from_the_creature_face_not_the_joined_text() { + // The `oracle_text` column joins every face, so an Adventure card's + // stored text always contains the spell half — the creature half's + // emptiness is only visible in `card_faces`. Scute Swarm-style bodies + // with a spell attached are exactly the case the filter must catch. + let oracle = r#"[ + {"oracle_id":"oid-bears","name":"Grizzly Bears","cmc":2.0,"colors":["G"], + "color_identity":["G"],"type_line":"Creature — Bear","keywords":[], + "power":"2","toughness":"2"}, + {"oracle_id":"oid-adv","name":"Lovestruck Beast","cmc":3.0,"colors":["G"], + "color_identity":["G"],"keywords":[], + "type_line":"Creature — Beast // Sorcery — Adventure", + "card_faces":[ + {"type_line":"Creature — Beast","colors":["G"],"power":"5","toughness":"5"}, + {"type_line":"Sorcery — Adventure","colors":["G"], + "oracle_text":"Create a 1/1 white Human creature token."} + ]}, + {"oracle_id":"oid-flyer","name":"Serra Angel","cmc":5.0,"colors":["W"], + "color_identity":["W"],"type_line":"Creature — Angel","keywords":["Flying"], + "oracle_text":"Flying, vigilance","power":"4","toughness":"4"}, + {"oracle_id":"oid-land","name":"Wastes","cmc":0.0,"colors":[], + "color_identity":[],"type_line":"Land","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, jsonl(oracle).as_bytes(), &CancelToken::new()).unwrap(); + + let vanilla = |id: &str| -> i64 { + conn.query_row( + "SELECT vanilla FROM oracle_cards WHERE oracle_id = ?1", + params![id], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(vanilla("oid-bears"), 1, "a plain vanilla creature"); + assert_eq!(vanilla("oid-adv"), 1, "adventure whose creature half is vanilla"); + assert_eq!(vanilla("oid-flyer"), 0, "a creature with abilities"); + assert_eq!(vanilla("oid-land"), 0, "a textless card that isn't a creature"); + } + #[test] fn captures_produced_mana_including_colorless() { let oracle = r#"[ diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 7411754..286632a 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -50,6 +50,10 @@ pub struct Catalog { /// db. Cached at open time so the filter can be skipped (rather than /// failing every query) against a db built before it shipped. pub(crate) has_universes_beyond: bool, + /// Whether `oracle_cards.vanilla` exists. A separate check from + /// [`Self::has_universes_beyond`]: it shipped in a later reference-schema + /// revision, so a db can have one without the other. + pub(crate) has_vanilla: bool, } impl Catalog { @@ -61,11 +65,13 @@ impl Catalog { let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?; let has_universes_beyond = db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?; + let has_vanilla = db::has_reference_column(&conn, "oracle_cards", "vanilla")?; Ok(Self { conn, has_horizontal_column, has_oracle_images, has_universes_beyond, + has_vanilla, }) } diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 1b12d4a..b35398b 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -314,6 +314,12 @@ pub struct LibraryFilter { /// Only cards that can head a Commander deck. #[serde(default)] pub can_be_commander: bool, + /// Only "vanilla" creatures — a creature with an empty rules box. Includes + /// Adventure and Prepared cards whose creature half has no abilities, which + /// is why this reads a precomputed column instead of testing `oracle_text`: + /// that column joins every face, so the attached spell's text is in it. + #[serde(default)] + pub vanilla_only: bool, /// Only cards with an empty colour identity — what a colourless commander /// can actually play. The colour bitmasks can't express this: 0 there means /// "no filter", not "no colours". diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 1374fa4..381e95c 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -44,7 +44,13 @@ CREATE TABLE IF NOT EXISTS oracle_cards ( -- "can't be compared numerically", and such cards drop out of a -- power/toughness range filter rather than being coerced to 0. power REAL, - toughness REAL + toughness REAL, + -- Boolean: a creature with no rules text whatsoever ("vanilla"). Derived at + -- ingest from the *front face* rather than from `oracle_text`, which joins + -- every face together — an Adventure or Prepared card's creature half can be + -- vanilla while the column still holds the attached spell's text, so the + -- property isn't recoverable from this table alone. + vanilla INTEGER NOT NULL DEFAULT 0 ); 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); diff --git a/src/api.ts b/src/api.ts index dc53c45..2c142f5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -114,6 +114,8 @@ export interface LibraryFilter { legal_in?: string | null; /** Only cards that can head a Commander deck. */ can_be_commander?: boolean; + /** Only creatures with an empty rules box (Adventure/Prepared creature halves count). */ + vanilla_only?: boolean; /** Only cards printed in this set (matched against printings.set_code). * Lets the release browser reuse the whole criteria pipeline. */ set_code?: string | null; diff --git a/src/components.tsx b/src/components.tsx index 75427c3..4a0ead1 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1449,6 +1449,13 @@ const ICON_CROWN = icon( , ); +/** An empty rules box — a vanilla creature, all body and no text. */ +const ICON_BLANK_BOX = icon( + <> + + + , +); /** * Card-origin and numeric criteria shared by the Library and Card Search @@ -1467,6 +1474,7 @@ export type CardCriteria = { priceMax: string; legalIn: string; canBeCommander: boolean; + vanillaOnly: boolean; powerMin: string; powerMax: string; toughMin: string; @@ -1481,6 +1489,7 @@ export const EMPTY_CRITERIA: CardCriteria = { priceMax: "", legalIn: "", canBeCommander: false, + vanillaOnly: false, powerMin: "", powerMax: "", toughMin: "", @@ -1511,6 +1520,7 @@ export function applyCardCriteria(c: CardCriteria): Partial { price_max: num(c.priceMax), legal_in: c.legalIn || null, can_be_commander: c.canBeCommander, + vanilla_only: c.vanillaOnly, power_min: num(c.powerMin), power_max: num(c.powerMax), toughness_min: num(c.toughMin), @@ -1529,6 +1539,7 @@ export function applyCardCriteria(c: CardCriteria): Partial { export function criteriaActive(c: CardCriteria): boolean { return ( c.canBeCommander || + c.vanillaOnly || c.legalIn !== "" || [c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some( (v) => v.trim() !== "", @@ -1663,6 +1674,13 @@ export function CardCriteriaFields({ on={c.canBeCommander} onToggle={() => onChange({ canBeCommander: !c.canBeCommander })} /> + onChange({ vanillaOnly: !c.vanillaOnly })} + />