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:
@@ -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();
|
||||
|
||||
@@ -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#"[
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user