Add card USD pricing and a deck maybeboard

Prices come from Scryfall's prices.usd on printings, shown as a hover badge
on card tiles. The price is picked independently of whichever printing
supplies the display image (the cheapest known price across all printings),
since the display printing is often a promo/variant with no listed price.

The maybeboard lets a deck track cards under consideration without counting
them in deck analysis stats or library assignment totals. Cards move between
mainboard and maybeboard via a per-card toggle; maybeboarding the commander
clears commander status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:32:25 -05:00
parent e3f8a299de
commit cf9c930b02
14 changed files with 327 additions and 48 deletions

View File

@@ -16,6 +16,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0001_init.sql"),
include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.sql"),
include_str!("sql/library/0004_deck_maybeboard.sql"),
];
/// The attached alias under which the reference db is available at runtime.
@@ -34,6 +35,19 @@ pub fn image_subquery(column: &str) -> String {
)
}
/// Correlated subquery selecting the cheapest known USD price across *all*
/// printings of the oracle card aliased `oc` — deliberately independent of
/// [`image_subquery`]'s printing choice, since the printing that happens to
/// supply the display image (first by rowid) is often a promo/variant with no
/// listed price even when other printings of the same card do have one.
/// Returns NULL (read as 0.0) when no printing has a known price.
pub fn price_subquery() -> String {
format!(
"(SELECT MIN(p.price_usd) FROM {REF_DB}.printings p
WHERE p.oracle_id = oc.oracle_id AND p.price_usd > 0)"
)
}
/// Create (if needed) and open a reference.db, applying the reference schema.
/// Used by the ingester and by test fixtures.
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {

View File

@@ -70,8 +70,8 @@ impl Catalog {
)?;
let new_id = self.conn.last_insert_rowid();
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
SELECT ?1, oracle_id, quantity, is_commander FROM deck_cards WHERE deck_id = ?2",
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards WHERE deck_id = ?2",
params![new_id, id],
)?;
self.get_deck(new_id)
@@ -86,6 +86,7 @@ impl Catalog {
oracle_id: &str,
quantity: i64,
is_commander: bool,
is_maybeboard: bool,
) -> Result<()> {
if quantity <= 0 {
self.conn.execute(
@@ -95,12 +96,13 @@ impl Catalog {
return Ok(());
}
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
VALUES (?1, ?2, ?3, ?4)
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander",
params![deck_id, oracle_id, quantity, is_commander as i64],
is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard",
params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64],
)?;
Ok(())
}
@@ -112,14 +114,16 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price},
dc.is_maybeboard
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let rows = stmt
.query_map(params![deck_id], |r| {
@@ -138,7 +142,9 @@ impl Catalog {
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
produces_colorless: r.get::<_, i64>(12)? != 0,
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
},
is_maybeboard: r.get::<_, i64>(14)? != 0,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -211,7 +217,7 @@ mod tests {
cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
cat.set_deck_card(elves.id, "llanowar-elves", 4, false, false)
.unwrap();
// card_decks reports membership.
@@ -238,8 +244,8 @@ mod tests {
let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", None).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false, false).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
@@ -257,7 +263,7 @@ mod tests {
assert_eq!(d.format, "");
cat.set_deck_format(d.id, "Commander").unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
let d = cat.get_deck(d.id).unwrap().unwrap();
@@ -289,8 +295,8 @@ mod tests {
// serra-angel intentionally NOT owned.
let d = cat.create_deck("Mixed", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
assert_eq!(contents.len(), 2);
@@ -305,16 +311,49 @@ mod tests {
assert!(unowned[0].is_commander);
}
#[test]
fn maybeboard_cards_are_flagged_and_excluded_from_assignment() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
let d = cat.create_deck("Maybe Deck", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap(); // mainboard
cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap(); // maybeboard
let contents = cat.deck_contents(d.id).unwrap();
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!elves.is_maybeboard);
assert!(sol.is_maybeboard);
// Maybeboard membership doesn't count as an assigned copy, so owning
// exactly 1 Sol Ring and maybeboarding it isn't over-assignment.
let lib = cat.list_library().unwrap();
let sol_lib = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol_lib.quantity_assigned, 0);
assert!(!sol_lib.is_over_assigned());
// Flipping a card from maybeboard back to mainboard updates in place.
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
assert!(!sol.is_maybeboard);
let lib = cat.list_library().unwrap();
let sol_lib = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol_lib.quantity_assigned, 1);
}
#[test]
fn setting_zero_removes_and_cascade_on_delete() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("Temp", None).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false, false).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 1, false, false).unwrap();
cat.delete_deck(d.id).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
}
@@ -341,8 +380,8 @@ mod tests {
let src = cat.create_deck("Original", Some("llanowar-elves")).unwrap();
cat.set_deck_format(src.id, "Commander").unwrap();
cat.set_deck_card(src.id, "llanowar-elves", 2, true).unwrap();
cat.set_deck_card(src.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(src.id, "llanowar-elves", 2, true, false).unwrap();
cat.set_deck_card(src.id, "lightning-bolt", 1, false, false).unwrap();
let copy = cat.duplicate_deck(src.id, "Original (copy)").unwrap();
assert_eq!(copy.name, "Original (copy)");

View File

@@ -19,12 +19,13 @@ impl Catalog {
let mut sql = format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
oc.produced_mana, oc.produces_colorless, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
);
let mut vals: Vec<Value> = Vec::new();
@@ -228,7 +229,7 @@ mod tests {
assert_eq!(bolt, vec!["Lightning Bolt"]);
let deck = cat.create_deck("Mono-R", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
let in_deck = names(&cat.search_library(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()

View File

@@ -612,6 +612,8 @@ struct ScryPrinting {
image_uris: Option<ImageUris>,
#[serde(default)]
card_faces: Option<Vec<PrintFace>>,
#[serde(default)]
prices: Option<ScryPrices>,
}
#[derive(Deserialize)]
@@ -620,6 +622,12 @@ struct ImageUris {
normal: Option<String>,
}
#[derive(Deserialize)]
struct ScryPrices {
#[serde(default)]
usd: Option<String>,
}
#[derive(Deserialize)]
struct PrintFace {
#[serde(default)]
@@ -632,8 +640,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
)?;
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
if cancel.is_cancelled() {
@@ -664,6 +672,12 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
.unwrap_or_default();
let price_usd = p
.prices
.as_ref()
.and_then(|pr| pr.usd.as_ref())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
ins.execute(params![
p.id,
oracle_id,
@@ -673,6 +687,7 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
p.rarity,
image,
back_image,
price_usd,
])?;
count += 1;
Ok(())
@@ -953,4 +968,34 @@ mod tests {
assert_eq!(front, "http://img/split-combined");
assert_eq!(back, "", "split cards have no distinct back face");
}
#[test]
fn captures_usd_price_defaulting_to_zero_when_absent() {
let printings = r#"[
{"id":"p-priced","oracle_id":"oid-priced","set":"m19","collector_number":"1",
"rarity":"common","prices":{"usd":"12.34","usd_foil":"20.00"}},
{"id":"p-null","oracle_id":"oid-null","set":"m19","collector_number":"2",
"rarity":"common","prices":{"usd":null}},
{"id":"p-noprices","oracle_id":"oid-noprices","set":"m19","collector_number":"3",
"rarity":"common"}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_printings(&conn, printings.as_bytes(), &CancelToken::new()).unwrap();
let fetch = |id: &str| -> f64 {
conn.query_row(
"SELECT price_usd FROM printings WHERE scryfall_id=?1",
params![id],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(fetch("p-priced"), 12.34);
assert_eq!(fetch("p-null"), 0.0);
assert_eq!(fetch("p-noprices"), 0.0);
}
}

View File

@@ -11,8 +11,8 @@ use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`]
/// and the criteria search.
/// produces_colorless, price_usd)` into its library parts. Shared by
/// [`Catalog::list_library`] and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
@@ -32,6 +32,7 @@ pub(crate) fn row_to_library_parts(
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
},
))
}
@@ -74,13 +75,14 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
oc.produced_mana, oc.produces_colorless
oc.produced_mana, oc.produces_colorless, {price}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let rows = stmt
.query_map([], row_to_library_parts)?
@@ -199,9 +201,12 @@ impl Catalog {
}
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
let mut stmt = self
.conn
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?;
// Maybeboard cards aren't actually committed to a deck, so they
// shouldn't count against a card's owned copies.
let mut stmt = self.conn.prepare(
"SELECT oracle_id, SUM(quantity) FROM deck_cards
WHERE is_maybeboard = 0 GROUP BY oracle_id",
)?;
let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
@@ -292,6 +297,54 @@ mod tests {
assert_eq!(elves.card.back_image_uri, "");
}
#[test]
fn price_usd_is_plumbed_through() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.connection()
.execute(
"UPDATE ref.printings SET price_usd = 12.34 WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
cat.add_owned("sol-ring", 1).unwrap();
cat.add_owned("llanowar-elves", 1).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.price_usd, 12.34);
// Fixture cards default to no known price.
let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap();
assert_eq!(elves.card.price_usd, 0.0);
}
/// Regression test: a card whose *displayed* printing (the one supplying
/// `image_uri`) has no known price shouldn't show as priceless if a
/// different printing does — e.g. a promo/showcase variant with no
/// listed USD price, while the regular printing has one.
#[test]
fn price_uses_cheapest_available_printing_even_if_display_printing_has_none() {
let fx = test_support::make();
let cat = &fx.catalog;
// The fixture's only printing ("print-sol-ring") supplies the image
// and has no price. Add a second, imageless printing with a price.
cat.connection()
.execute(
"INSERT INTO ref.printings (scryfall_id, oracle_id, price_usd)
VALUES ('print-sol-ring-promo', 'sol-ring', 5.67)",
[],
)
.unwrap();
cat.add_owned("sol-ring", 1).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.card.price_usd, 5.67);
// Image still comes from the original (imaged) printing.
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn location_is_stored_and_listed() {
let fx = test_support::make();

View File

@@ -99,6 +99,9 @@ pub struct CardRef {
/// `produced_mana` including "C"), tracked separately since colorless
/// isn't a WUBRG bit.
pub produces_colorless: bool,
/// USD market price (Scryfall's `prices.usd`) for the same printing that
/// supplied `image_uri`, or 0 if unknown.
pub price_usd: f64,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
@@ -125,6 +128,10 @@ pub struct DeckEntry {
/// Copies of this card in the deck.
pub quantity: i64,
pub is_commander: bool,
/// On the maybeboard: tracked against the deck for consideration, but
/// excluded from deck analysis stats and library assignment totals —
/// it isn't actually part of the deck.
pub is_maybeboard: bool,
/// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64,
}

View File

@@ -34,10 +34,11 @@ impl NameIndex {
pub fn build(conn: &Connection) -> Result<Self> {
let mut stmt = conn.prepare(&format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price}
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"),
back = crate::db::image_subquery("back_image_uri")
back = crate::db::image_subquery("back_image_uri"),
price = crate::db::price_subquery()
))?;
let entries = stmt
.query_map([], |r| {
@@ -53,6 +54,7 @@ impl NameIndex {
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
produces_colorless: r.get::<_, i64>(9)? != 0,
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
},
})
})?

View File

@@ -0,0 +1,4 @@
-- v4 — maybeboard: cards tracked against a deck for consideration but not
-- counted as part of it (excluded from deck analysis stats and from
-- library assignment totals).
ALTER TABLE deck_cards ADD COLUMN is_maybeboard INTEGER NOT NULL DEFAULT 0;

View File

@@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS printings (
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
rarity TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT '',
back_image_uri TEXT NOT NULL DEFAULT '' -- transform/MDFC/reversible back face
back_image_uri TEXT NOT NULL DEFAULT '', -- transform/MDFC/reversible back face
price_usd REAL NOT NULL DEFAULT 0 -- Scryfall "prices.usd", 0 if unknown
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);