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

@@ -18,7 +18,11 @@ fn push_card_filters(
vals: &mut Vec<Value>,
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<Value> = 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<Value> = 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();

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

View File

@@ -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,
})
}

View File

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

View File

@@ -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);

View File

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

View File

@@ -1449,6 +1449,13 @@ const ICON_CROWN = icon(
<line x1="5" y1="21" x2="19" y2="21" />
</>,
);
/** An empty rules box — a vanilla creature, all body and no text. */
const ICON_BLANK_BOX = icon(
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
</>,
);
/**
* 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<LibraryFilter> {
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<LibraryFilter> {
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 })}
/>
<IconToggle
icon={ICON_BLANK_BOX}
label="Only vanilla creatures"
hint="Creatures with an empty rules box. Includes Adventure and Prepared cards whose creature half has no abilities."
on={c.vanillaOnly}
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
/>
</div>
</div>
</>