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>,
|
vals: &mut Vec<Value>,
|
||||||
filter: &LibraryFilter,
|
filter: &LibraryFilter,
|
||||||
has_universes_beyond: bool,
|
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
|
// The four card-origin flags all arrived in the same reference-schema
|
||||||
// revision, so one capability check covers them. Skipped entirely against
|
// revision, so one capability check covers them. Skipped entirely against
|
||||||
// an older db — referencing a missing column would make every search fail
|
// an older db — referencing a missing column would make every search fail
|
||||||
@@ -188,7 +192,7 @@ impl Catalog {
|
|||||||
horizontal = self.horizontal_col()
|
horizontal = self.horizontal_col()
|
||||||
);
|
);
|
||||||
let mut vals: Vec<Value> = Vec::new();
|
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 {
|
if let Some(deck_id) = filter.in_deck {
|
||||||
sql.push_str(
|
sql.push_str(
|
||||||
@@ -248,7 +252,7 @@ impl Catalog {
|
|||||||
horizontal = self.horizontal_col()
|
horizontal = self.horizontal_col()
|
||||||
);
|
);
|
||||||
let mut vals: Vec<Value> = Vec::new();
|
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 ?");
|
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));
|
vals.push(Value::Integer(limit as i64));
|
||||||
@@ -316,6 +320,28 @@ mod tests {
|
|||||||
assert!(all.contains(&"Serra Angel".to_string()));
|
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]
|
#[test]
|
||||||
fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() {
|
fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() {
|
||||||
let fx = owned_fixture();
|
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
|
"INSERT OR IGNORE INTO oracle_cards
|
||||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
|
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
|
||||||
image_uri,back_image_uri,default_scryfall_id,power,toughness)
|
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)",
|
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 =
|
let mut ins_fts =
|
||||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
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())),
|
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![
|
let changed = ins_card.execute(params![
|
||||||
oracle_id,
|
oracle_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -630,6 +648,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
default_scryfall_id,
|
default_scryfall_id,
|
||||||
power,
|
power,
|
||||||
toughness,
|
toughness,
|
||||||
|
vanilla as i64,
|
||||||
])?;
|
])?;
|
||||||
// Only emit satellite rows the first time we see this oracle_id.
|
// Only emit satellite rows the first time we see this oracle_id.
|
||||||
if changed == 1 {
|
if changed == 1 {
|
||||||
@@ -1088,6 +1107,51 @@ mod tests {
|
|||||||
assert!(oracle_text.contains("draw a card"));
|
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]
|
#[test]
|
||||||
fn captures_produced_mana_including_colorless() {
|
fn captures_produced_mana_including_colorless() {
|
||||||
let oracle = r#"[
|
let oracle = r#"[
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ pub struct Catalog {
|
|||||||
/// db. Cached at open time so the filter can be skipped (rather than
|
/// db. Cached at open time so the filter can be skipped (rather than
|
||||||
/// failing every query) against a db built before it shipped.
|
/// failing every query) against a db built before it shipped.
|
||||||
pub(crate) has_universes_beyond: bool,
|
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 {
|
impl Catalog {
|
||||||
@@ -61,11 +65,13 @@ impl Catalog {
|
|||||||
let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?;
|
let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?;
|
||||||
let has_universes_beyond =
|
let has_universes_beyond =
|
||||||
db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?;
|
db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?;
|
||||||
|
let has_vanilla = db::has_reference_column(&conn, "oracle_cards", "vanilla")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn,
|
conn,
|
||||||
has_horizontal_column,
|
has_horizontal_column,
|
||||||
has_oracle_images,
|
has_oracle_images,
|
||||||
has_universes_beyond,
|
has_universes_beyond,
|
||||||
|
has_vanilla,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,12 @@ pub struct LibraryFilter {
|
|||||||
/// Only cards that can head a Commander deck.
|
/// Only cards that can head a Commander deck.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub can_be_commander: bool,
|
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
|
/// Only cards with an empty colour identity — what a colourless commander
|
||||||
/// can actually play. The colour bitmasks can't express this: 0 there means
|
/// can actually play. The colour bitmasks can't express this: 0 there means
|
||||||
/// "no filter", not "no colours".
|
/// "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
|
-- "can't be compared numerically", and such cards drop out of a
|
||||||
-- power/toughness range filter rather than being coerced to 0.
|
-- power/toughness range filter rather than being coerced to 0.
|
||||||
power REAL,
|
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_name_lower ON oracle_cards(name_lower);
|
||||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface LibraryFilter {
|
|||||||
legal_in?: string | null;
|
legal_in?: string | null;
|
||||||
/** Only cards that can head a Commander deck. */
|
/** Only cards that can head a Commander deck. */
|
||||||
can_be_commander?: boolean;
|
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).
|
/** Only cards printed in this set (matched against printings.set_code).
|
||||||
* Lets the release browser reuse the whole criteria pipeline. */
|
* Lets the release browser reuse the whole criteria pipeline. */
|
||||||
set_code?: string | null;
|
set_code?: string | null;
|
||||||
|
|||||||
@@ -1449,6 +1449,13 @@ const ICON_CROWN = icon(
|
|||||||
<line x1="5" y1="21" x2="19" y2="21" />
|
<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
|
* Card-origin and numeric criteria shared by the Library and Card Search
|
||||||
@@ -1467,6 +1474,7 @@ export type CardCriteria = {
|
|||||||
priceMax: string;
|
priceMax: string;
|
||||||
legalIn: string;
|
legalIn: string;
|
||||||
canBeCommander: boolean;
|
canBeCommander: boolean;
|
||||||
|
vanillaOnly: boolean;
|
||||||
powerMin: string;
|
powerMin: string;
|
||||||
powerMax: string;
|
powerMax: string;
|
||||||
toughMin: string;
|
toughMin: string;
|
||||||
@@ -1481,6 +1489,7 @@ export const EMPTY_CRITERIA: CardCriteria = {
|
|||||||
priceMax: "",
|
priceMax: "",
|
||||||
legalIn: "",
|
legalIn: "",
|
||||||
canBeCommander: false,
|
canBeCommander: false,
|
||||||
|
vanillaOnly: false,
|
||||||
powerMin: "",
|
powerMin: "",
|
||||||
powerMax: "",
|
powerMax: "",
|
||||||
toughMin: "",
|
toughMin: "",
|
||||||
@@ -1511,6 +1520,7 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
|||||||
price_max: num(c.priceMax),
|
price_max: num(c.priceMax),
|
||||||
legal_in: c.legalIn || null,
|
legal_in: c.legalIn || null,
|
||||||
can_be_commander: c.canBeCommander,
|
can_be_commander: c.canBeCommander,
|
||||||
|
vanilla_only: c.vanillaOnly,
|
||||||
power_min: num(c.powerMin),
|
power_min: num(c.powerMin),
|
||||||
power_max: num(c.powerMax),
|
power_max: num(c.powerMax),
|
||||||
toughness_min: num(c.toughMin),
|
toughness_min: num(c.toughMin),
|
||||||
@@ -1529,6 +1539,7 @@ export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
|||||||
export function criteriaActive(c: CardCriteria): boolean {
|
export function criteriaActive(c: CardCriteria): boolean {
|
||||||
return (
|
return (
|
||||||
c.canBeCommander ||
|
c.canBeCommander ||
|
||||||
|
c.vanillaOnly ||
|
||||||
c.legalIn !== "" ||
|
c.legalIn !== "" ||
|
||||||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
||||||
(v) => v.trim() !== "",
|
(v) => v.trim() !== "",
|
||||||
@@ -1663,6 +1674,13 @@ export function CardCriteriaFields({
|
|||||||
on={c.canBeCommander}
|
on={c.canBeCommander}
|
||||||
onToggle={() => onChange({ canBeCommander: !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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user