Add colorless filters, and make set listings filterable
Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
This commit is contained in:
@@ -84,6 +84,19 @@ fn push_card_filters(
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
}
|
||||
if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.printings p
|
||||
WHERE p.oracle_id = oc.oracle_id AND p.set_code = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(code.clone()));
|
||||
}
|
||||
if filter.colorless_identity {
|
||||
sql.push_str(" AND oc.color_identity = 0");
|
||||
}
|
||||
if filter.colorless_colors {
|
||||
sql.push_str(" AND oc.colors = 0");
|
||||
}
|
||||
if let Some(c) = filter.colors_exact {
|
||||
sql.push_str(" AND oc.colors = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
@@ -303,6 +316,111 @@ mod tests {
|
||||
assert!(all.contains(&"Serra Angel".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
// Fixture cards have no printings; give two of them one.
|
||||
for (oid, set) in [("serra-angel", "m19"), ("lightning-bolt", "lea")] {
|
||||
conn.execute(
|
||||
"INSERT INTO ref.printings(scryfall_id, oracle_id, set_code) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![format!("p-{oid}"), oid, set],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let in_m19 = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
set_code: Some("m19".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(in_m19, vec!["Serra Angel".to_string()]);
|
||||
|
||||
// Set codes are case-insensitive: the UI shows them uppercased.
|
||||
assert_eq!(
|
||||
fx.catalog
|
||||
.search_library(&LibraryFilter { set_code: Some("M19".into()), ..Default::default() })
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// And it composes with other criteria rather than overriding them.
|
||||
let none = fx
|
||||
.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
set_code: Some("m19".into()),
|
||||
colorless_identity: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(none.is_empty(), "Serra Angel is white, so the two together match nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorless_filters_are_distinct_from_an_empty_colour_selection() {
|
||||
let fx = owned_fixture();
|
||||
// Sol Ring is the only colourless fixture card.
|
||||
let identity = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
colorless_identity: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(identity, vec!["Sol Ring".to_string()]);
|
||||
|
||||
let colors = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
colorless_colors: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(colors, vec!["Sol Ring".to_string()]);
|
||||
|
||||
// Selecting no colours at all still means "don't filter by colour" —
|
||||
// that's the ambiguity these fields exist to resolve.
|
||||
let unfiltered = fx.catalog.search_library(&LibraryFilter::default()).unwrap();
|
||||
assert_eq!(unfiltered.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colorless_identity_still_admits_a_card_whose_own_colors_differ() {
|
||||
let fx = owned_fixture();
|
||||
// Contrived but real: a card printed colourless that carries a coloured
|
||||
// identity (e.g. a hybrid ability). Identity is what Commander polices.
|
||||
fx.catalog
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE ref.oracle_cards SET colors = 0 WHERE oracle_id = 'lightning-bolt'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let by_colors = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter { colorless_colors: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(by_colors.contains(&"Lightning Bolt".to_string()));
|
||||
|
||||
let by_identity = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter { colorless_identity: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
!by_identity.contains(&"Lightning Bolt".to_string()),
|
||||
"its red identity still bars it from a colourless commander"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_range_treats_an_unknown_price_as_free() {
|
||||
let fx = owned_fixture();
|
||||
|
||||
@@ -314,6 +314,20 @@ pub struct LibraryFilter {
|
||||
/// Only cards that can head a Commander deck.
|
||||
#[serde(default)]
|
||||
pub can_be_commander: 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".
|
||||
#[serde(default)]
|
||||
pub colorless_identity: bool,
|
||||
/// Only cards printed in this set (matched against `printings.set_code`).
|
||||
/// Lets the release browser reuse the whole criteria pipeline instead of
|
||||
/// reimplementing it over a live Scryfall response.
|
||||
pub set_code: Option<String>,
|
||||
/// Only cards that are themselves colourless (`colors` empty). Distinct
|
||||
/// from `colorless_identity`: a card can be colourless yet carry a coloured
|
||||
/// identity through mana symbols in its rules text.
|
||||
#[serde(default)]
|
||||
pub colorless_colors: bool,
|
||||
/// Printed power/toughness range. Cards whose printed value isn't a plain
|
||||
/// number ("*", "1+*") are excluded from these, since they can't be
|
||||
/// compared — see `oracle_cards.power`.
|
||||
|
||||
Reference in New Issue
Block a user