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/0001_init.sql"),
include_str!("sql/library/0002_location.sql"), include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.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. /// 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. /// Create (if needed) and open a reference.db, applying the reference schema.
/// Used by the ingester and by test fixtures. /// Used by the ingester and by test fixtures.
pub fn open_reference_for_build(path: &Path) -> Result<Connection> { 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(); let new_id = self.conn.last_insert_rowid();
self.conn.execute( self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander) "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
SELECT ?1, oracle_id, quantity, is_commander FROM deck_cards WHERE deck_id = ?2", SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards WHERE deck_id = ?2",
params![new_id, id], params![new_id, id],
)?; )?;
self.get_deck(new_id) self.get_deck(new_id)
@@ -86,6 +86,7 @@ impl Catalog {
oracle_id: &str, oracle_id: &str,
quantity: i64, quantity: i64,
is_commander: bool, is_commander: bool,
is_maybeboard: bool,
) -> Result<()> { ) -> Result<()> {
if quantity <= 0 { if quantity <= 0 {
self.conn.execute( self.conn.execute(
@@ -95,12 +96,13 @@ impl Catalog {
return Ok(()); return Ok(());
} }
self.conn.execute( self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander) "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
VALUES (?1, ?2, ?3, ?4) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity, quantity = excluded.quantity,
is_commander = excluded.is_commander", is_commander = excluded.is_commander,
params![deck_id, oracle_id, quantity, is_commander as i64], is_maybeboard = excluded.is_maybeboard",
params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64],
)?; )?;
Ok(()) Ok(())
} }
@@ -112,14 +114,16 @@ impl Catalog {
let mut stmt = self.conn.prepare(&format!( let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0), "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.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 FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id 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 LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1 WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE", ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"), 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 let rows = stmt
.query_map(params![deck_id], |r| { .query_map(params![deck_id], |r| {
@@ -138,7 +142,9 @@ impl Catalog {
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(), back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8), produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
produces_colorless: r.get::<_, i64>(12)? != 0, 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<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
@@ -211,7 +217,7 @@ mod tests {
cat.add_owned("llanowar-elves", 4).unwrap(); cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).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(); .unwrap();
// card_decks reports membership. // card_decks reports membership.
@@ -238,8 +244,8 @@ mod tests {
let a = cat.create_deck("Deck A", None).unwrap(); let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", 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(a.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap(); cat.set_deck_card(b.id, "sol-ring", 1, false, false).unwrap();
let lib = cat.list_library().unwrap(); let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
@@ -257,7 +263,7 @@ mod tests {
assert_eq!(d.format, ""); assert_eq!(d.format, "");
cat.set_deck_format(d.id, "Commander").unwrap(); 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(); cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
let d = cat.get_deck(d.id).unwrap().unwrap(); let d = cat.get_deck(d.id).unwrap().unwrap();
@@ -289,8 +295,8 @@ mod tests {
// serra-angel intentionally NOT owned. // serra-angel intentionally NOT owned.
let d = cat.create_deck("Mixed", None).unwrap(); 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, "llanowar-elves", 4, false, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap(); cat.set_deck_card(d.id, "serra-angel", 1, true, false).unwrap();
let contents = cat.deck_contents(d.id).unwrap(); let contents = cat.deck_contents(d.id).unwrap();
assert_eq!(contents.len(), 2); assert_eq!(contents.len(), 2);
@@ -305,16 +311,49 @@ mod tests {
assert!(unowned[0].is_commander); 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] #[test]
fn setting_zero_removes_and_cascade_on_delete() { fn setting_zero_removes_and_cascade_on_delete() {
let fx = test_support::make(); let fx = test_support::make();
let cat = &fx.catalog; let cat = &fx.catalog;
let d = cat.create_deck("Temp", None).unwrap(); 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", 2, false, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap(); cat.set_deck_card(d.id, "lightning-bolt", 0, false, false).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty()); 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(); cat.delete_deck(d.id).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty()); 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(); let src = cat.create_deck("Original", Some("llanowar-elves")).unwrap();
cat.set_deck_format(src.id, "Commander").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, "llanowar-elves", 2, true, false).unwrap();
cat.set_deck_card(src.id, "lightning-bolt", 1, false).unwrap(); cat.set_deck_card(src.id, "lightning-bolt", 1, false, false).unwrap();
let copy = cat.duplicate_deck(src.id, "Original (copy)").unwrap(); let copy = cat.duplicate_deck(src.id, "Original (copy)").unwrap();
assert_eq!(copy.name, "Original (copy)"); assert_eq!(copy.name, "Original (copy)");

View File

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

View File

@@ -612,6 +612,8 @@ struct ScryPrinting {
image_uris: Option<ImageUris>, image_uris: Option<ImageUris>,
#[serde(default)] #[serde(default)]
card_faces: Option<Vec<PrintFace>>, card_faces: Option<Vec<PrintFace>>,
#[serde(default)]
prices: Option<ScryPrices>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -620,6 +622,12 @@ struct ImageUris {
normal: Option<String>, normal: Option<String>,
} }
#[derive(Deserialize)]
struct ScryPrices {
#[serde(default)]
usd: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct PrintFace { struct PrintFace {
#[serde(default)] #[serde(default)]
@@ -632,8 +640,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
{ {
let mut ins = conn.prepare( let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings "INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri) (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)", VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
)?; )?;
stream_json_array::<_, ScryPrinting, _>(reader, |p| { stream_json_array::<_, ScryPrinting, _>(reader, |p| {
if cancel.is_cancelled() { 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(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone()) .and_then(|i| i.normal.clone())
.unwrap_or_default(); .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![ ins.execute(params![
p.id, p.id,
oracle_id, oracle_id,
@@ -673,6 +687,7 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
p.rarity, p.rarity,
image, image,
back_image, back_image,
price_usd,
])?; ])?;
count += 1; count += 1;
Ok(()) Ok(())
@@ -953,4 +968,34 @@ mod tests {
assert_eq!(front, "http://img/split-combined"); assert_eq!(front, "http://img/split-combined");
assert_eq!(back, "", "split cards have no distinct back face"); 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, /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana, /// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
/// produces_colorless)` into its library parts. Shared by [`Catalog::list_library`] /// produces_colorless, price_usd)` into its library parts. Shared by
/// and the criteria search. /// [`Catalog::list_library`] and the criteria search.
pub(crate) fn row_to_library_parts( pub(crate) fn row_to_library_parts(
r: &rusqlite::Row, r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> { ) -> 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(), back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8), produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0, 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!( let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location, "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.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 FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0 WHERE lc.quantity_owned > 0
ORDER BY oc.name COLLATE NOCASE", ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery("image_uri"), 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 let rows = stmt
.query_map([], row_to_library_parts)? .query_map([], row_to_library_parts)?
@@ -199,9 +201,12 @@ impl Catalog {
} }
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> { pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
let mut stmt = self // Maybeboard cards aren't actually committed to a deck, so they
.conn // shouldn't count against a card's owned copies.
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?; 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 mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows { for row in rows {
@@ -292,6 +297,54 @@ mod tests {
assert_eq!(elves.card.back_image_uri, ""); 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] #[test]
fn location_is_stored_and_listed() { fn location_is_stored_and_listed() {
let fx = test_support::make(); let fx = test_support::make();

View File

@@ -99,6 +99,9 @@ pub struct CardRef {
/// `produced_mana` including "C"), tracked separately since colorless /// `produced_mana` including "C"), tracked separately since colorless
/// isn't a WUBRG bit. /// isn't a WUBRG bit.
pub produces_colorless: bool, 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. /// 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. /// Copies of this card in the deck.
pub quantity: i64, pub quantity: i64,
pub is_commander: bool, 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). /// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64, pub quantity_owned: i64,
} }

View File

@@ -34,10 +34,11 @@ impl NameIndex {
pub fn build(conn: &Connection) -> Result<Self> { pub fn build(conn: &Connection) -> Result<Self> {
let mut stmt = conn.prepare(&format!( let mut stmt = conn.prepare(&format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, "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", FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery("image_uri"), 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 let entries = stmt
.query_map([], |r| { .query_map([], |r| {
@@ -53,6 +54,7 @@ impl NameIndex {
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(), back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8), produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
produces_colorless: r.get::<_, i64>(9)? != 0, 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" finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
rarity TEXT NOT NULL DEFAULT '', rarity TEXT NOT NULL DEFAULT '',
image_uri 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); CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);

View File

@@ -207,10 +207,11 @@ pub fn set_deck_card(
oracle_id: String, oracle_id: String,
quantity: i64, quantity: i64,
is_commander: bool, is_commander: bool,
is_maybeboard: bool,
) -> Res<()> { ) -> Res<()> {
let s = db.lock().unwrap(); let s = db.lock().unwrap();
s.catalog()? s.catalog()?
.set_deck_card(deck_id, &oracle_id, quantity, is_commander) .set_deck_card(deck_id, &oracle_id, quantity, is_commander, is_maybeboard)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View File

@@ -14,6 +14,7 @@ export interface CardRef {
back_image_uri: string; // back face for transform/MDFC/reversible cards, else "" back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
produced_mana: number; // WUBRG bitmask of mana this card can tap for produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C} produces_colorless: boolean; // can produce {C}
price_usd: number; // USD market price, or 0 if unknown
} }
export interface LibraryCard extends CardRef { export interface LibraryCard extends CardRef {
@@ -26,6 +27,9 @@ export interface LibraryCard extends CardRef {
export interface DeckEntry extends CardRef { export interface DeckEntry extends CardRef {
quantity: number; quantity: number;
is_commander: boolean; is_commander: boolean;
/** On the maybeboard: tracked against the deck but excluded from deck
* analysis stats and library assignment totals. */
is_maybeboard: boolean;
quantity_owned: number; quantity_owned: number;
} }
@@ -142,7 +146,8 @@ export const api = {
oracleId: string, oracleId: string,
quantity: number, quantity: number,
isCommander = false, isCommander = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }), isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }), deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }), cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
}; };

View File

@@ -49,6 +49,20 @@ const ICON_CLOSE = icon(
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</>, </>,
); );
const ICON_HELP_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>,
);
const ICON_ARROW_LEFT_CIRCLE = icon(
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 8 8 12 12 16" />
<line x1="16" y1="12" x2="8" y2="12" />
</>,
);
/** /**
* A small round icon-only button, used consistently for lightweight actions * A small round icon-only button, used consistently for lightweight actions
@@ -92,6 +106,8 @@ export const Icons = {
copy: ICON_COPY, copy: ICON_COPY,
pencil: ICON_PENCIL, pencil: ICON_PENCIL,
close: ICON_CLOSE, close: ICON_CLOSE,
helpCircle: ICON_HELP_CIRCLE,
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
}; };
// --- Dialogs ------------------------------------------------------------ // --- Dialogs ------------------------------------------------------------
@@ -974,6 +990,11 @@ export function CardTile({
)} )}
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>} {cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
{corner && <div className="tile-corner">{corner}</div>} {corner && <div className="tile-corner">{corner}</div>}
{card.price_usd > 0 && (
<div className="tile-corner-bottom-right">
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
</div>
)}
</div> </div>
{footer && <div className="tile-footer">{footer}</div>} {footer && <div className="tile-footer">{footer}</div>}
</div> </div>

View File

@@ -1359,6 +1359,17 @@ tbody tr:hover {
.tile-selected .tile-corner-left { .tile-selected .tile-corner-left {
opacity: 1; opacity: 1;
} }
.tile-corner-bottom-right {
position: absolute;
bottom: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-right,
.tile:focus-within .tile-corner-bottom-right {
opacity: 1;
}
.tile-selected .tile-img { .tile-selected .tile-img {
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md); box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
} }

View File

@@ -23,6 +23,13 @@ import {
useViewMode, useViewMode,
} from "../components"; } from "../components";
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
function boardToggleProps(e: DeckEntry) {
return e.is_maybeboard
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
}
type DeckDialog = type DeckDialog =
| { kind: "delete"; deck: Deck } | { kind: "delete"; deck: Deck }
| { kind: "rename"; deck: Deck } | { kind: "rename"; deck: Deck }
@@ -55,11 +62,12 @@ interface DeckStats {
/** /**
* Deck composition stats, excluding the commander (shown separately in its * Deck composition stats, excluding the commander (shown separately in its
* own hero display) — matches the usual "99 + commander" framing even for * own hero display) and the maybeboard (cards under consideration, not
* non-Commander decks, where it's just "everything but the flagged card". * actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/ */
function computeDeckStats(contents: DeckEntry[]): DeckStats { function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander); const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0); const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land")); const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land")); const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
@@ -208,6 +216,8 @@ export function DeckView() {
const [anyResults, setAnyResults] = useState<CardRef[]>([]); const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState(""); const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]); const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const [maybeQuery, setMaybeQuery] = useState("");
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set()); const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
@@ -278,6 +288,13 @@ export function DeckView() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [anyQuery]); }, [anyQuery]);
useEffect(() => {
const q = maybeQuery.trim();
if (!q) return setMaybeResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
return () => clearTimeout(t);
}, [maybeQuery]);
useEffect(() => { useEffect(() => {
const q = cmdQuery.trim(); const q = cmdQuery.trim();
if (!q) return setCmdResults([]); if (!q) return setCmdResults([]);
@@ -297,7 +314,7 @@ export function DeckView() {
if (selected === null) return; if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id); const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1; const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false); await api.setDeckCard(selected, card.oracle_id, qty, false, false);
setLibQuery(""); setLibQuery("");
setLibResults([]); setLibResults([]);
setAnyQuery(""); setAnyQuery("");
@@ -305,10 +322,40 @@ export function DeckView() {
await loadContents(); await loadContents();
} }
async function addToMaybeboard(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false, true);
setMaybeQuery("");
setMaybeResults([]);
await loadContents();
}
/** Move a card between the mainboard and the maybeboard. Clears commander
* status (and the deck's commander, if this was it) when maybeboarding. */
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
if (selected === null) return;
const isCommander = isMaybeboard ? false : entry.is_commander;
await api.setDeckCard(selected, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
if (isMaybeboard && deck?.commander_oracle_id === entry.oracle_id) {
await api.setDeckCommander(selected, null);
await loadDecks();
}
await loadContents();
}
async function setQty(oracleId: string, qty: number) { async function setQty(oracleId: string, qty: number) {
if (selected === null) return; if (selected === null) return;
const clamped = Math.max(0, qty); const clamped = Math.max(0, qty);
await api.setDeckCard(selected, oracleId, clamped, false); const existing = contents.find((e) => e.oracle_id === oracleId);
await api.setDeckCard(
selected,
oracleId,
clamped,
existing?.is_commander ?? false,
existing?.is_maybeboard ?? false,
);
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e))); setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
// Re-adding during the grace period below cancels the pending removal. // Re-adding during the grace period below cancels the pending removal.
@@ -408,10 +455,10 @@ export function DeckView() {
async function setCommander(card: CardRef) { async function setCommander(card: CardRef) {
if (selected === null) return; if (selected === null) return;
// Ensure the commander is in the deck, then flag it. // Ensure the card is in the deck (mainboard, not maybeboard) and flagged
if (!contents.find((e) => e.oracle_id === card.oracle_id)) { // commander — covers both "not in the deck yet" and "was maybeboarded".
await api.setDeckCard(selected, card.oracle_id, 1, true); const existing = contents.find((e) => e.oracle_id === card.oracle_id);
} await api.setDeckCard(selected, card.oracle_id, existing?.quantity ?? 1, true, false);
await api.setDeckCommander(selected, card.oracle_id); await api.setDeckCommander(selected, card.oracle_id);
setCmdQuery(""); setCmdQuery("");
setCmdResults([]); setCmdResults([]);
@@ -426,8 +473,9 @@ export function DeckView() {
await loadDecks(); await loadDecks();
} }
const owned = contents.filter((e) => e.quantity_owned > 0); const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0);
const unowned = contents.filter((e) => e.quantity_owned === 0); const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0);
const maybeboard = contents.filter((e) => e.is_maybeboard);
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id); const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
const deckStats = useMemo(() => computeDeckStats(contents), [contents]); const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
@@ -458,7 +506,13 @@ export function DeckView() {
pending={pendingRemoval.has(e.oracle_id)} pending={pendingRemoval.has(e.oracle_id)}
/> />
} }
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined} footer={
<>
{e.is_commander && <span className="tile-badge">★ commander</span>}
<span className="spacer" />
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</>
}
/> />
))} ))}
</div> </div>
@@ -477,6 +531,7 @@ export function DeckView() {
<th>Type</th> <th>Type</th>
<th>In deck</th> <th>In deck</th>
<th>Owned</th> <th>Owned</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -510,6 +565,9 @@ export function DeckView() {
/> />
</td> </td>
<td>{e.quantity_owned}</td> <td>{e.quantity_owned}</td>
<td>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -737,6 +795,23 @@ export function DeckView() {
/> />
{cardsGroup(unowned)} {cardsGroup(unowned)}
</section> </section>
<section style={{ marginTop: 26 }}>
<h3>
Maybeboard <span className="badge">{maybeboard.length}</span>
</h3>
<p className="hint" style={{ marginTop: -4 }}>
Cards under consideration — not counted in deck analysis.
</p>
<CardSearchBox
placeholder="Search all cards to add one to the maybeboard…"
value={maybeQuery}
onChange={setMaybeQuery}
results={maybeResults}
onPick={addToMaybeboard}
/>
{cardsGroup(maybeboard)}
</section>
</> </>
)} )}
</div> </div>