Add a vanilla-creature filter to card and library search

Filtering for creatures with an empty rules box can't be done from
`oracle_cards.oracle_text`: that column joins every face together, so an
Adventure card always carries its spell half's text and a naive `oracle_text
= ''` test would exclude exactly the cards worth including.

Derive it at ingest instead, from the front face's own text — the front is the
half that resolves as the creature, and adventure/prepared/transform cards are
all cast from it. Exposed as `vanilla_only` behind its own `has_vanilla`
capability check rather than folded into `has_universes_beyond`, since it
lands in a later reference-schema revision and a db can have one column
without the other. Needs a card-data re-download before it does anything.
This commit is contained in:
2026-08-14 01:02:29 -05:00
parent d014609244
commit e3725cee55
7 changed files with 133 additions and 5 deletions

View File

@@ -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#"[