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:
@@ -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> {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -207,10 +207,11 @@ pub fn set_deck_card(
|
||||
oracle_id: String,
|
||||
quantity: i64,
|
||||
is_commander: bool,
|
||||
is_maybeboard: bool,
|
||||
) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CardRef {
|
||||
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
||||
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
||||
produces_colorless: boolean; // can produce {C}
|
||||
price_usd: number; // USD market price, or 0 if unknown
|
||||
}
|
||||
|
||||
export interface LibraryCard extends CardRef {
|
||||
@@ -26,6 +27,9 @@ export interface LibraryCard extends CardRef {
|
||||
export interface DeckEntry extends CardRef {
|
||||
quantity: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -142,7 +146,8 @@ export const api = {
|
||||
oracleId: string,
|
||||
quantity: number,
|
||||
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 }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,20 @@ const ICON_CLOSE = icon(
|
||||
<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
|
||||
@@ -92,6 +106,8 @@ export const Icons = {
|
||||
copy: ICON_COPY,
|
||||
pencil: ICON_PENCIL,
|
||||
close: ICON_CLOSE,
|
||||
helpCircle: ICON_HELP_CIRCLE,
|
||||
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
|
||||
};
|
||||
|
||||
// --- Dialogs ------------------------------------------------------------
|
||||
@@ -974,6 +990,11 @@ export function CardTile({
|
||||
)}
|
||||
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</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>
|
||||
{footer && <div className="tile-footer">{footer}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1359,6 +1359,17 @@ tbody tr:hover {
|
||||
.tile-selected .tile-corner-left {
|
||||
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 {
|
||||
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
useViewMode,
|
||||
} 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 =
|
||||
| { kind: "delete"; deck: Deck }
|
||||
| { kind: "rename"; deck: Deck }
|
||||
@@ -55,11 +62,12 @@ interface DeckStats {
|
||||
|
||||
/**
|
||||
* Deck composition stats, excluding the commander (shown separately in its
|
||||
* own hero display) — matches the usual "99 + commander" framing even for
|
||||
* non-Commander decks, where it's just "everything but the flagged card".
|
||||
* own hero display) and the maybeboard (cards under consideration, not
|
||||
* 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 {
|
||||
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 lands = 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 [cmdQuery, setCmdQuery] = useState("");
|
||||
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
||||
const [maybeQuery, setMaybeQuery] = useState("");
|
||||
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
||||
|
||||
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
||||
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
@@ -278,6 +288,13 @@ export function DeckView() {
|
||||
return () => clearTimeout(t);
|
||||
}, [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(() => {
|
||||
const q = cmdQuery.trim();
|
||||
if (!q) return setCmdResults([]);
|
||||
@@ -297,7 +314,7 @@ export function DeckView() {
|
||||
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);
|
||||
await api.setDeckCard(selected, card.oracle_id, qty, false, false);
|
||||
setLibQuery("");
|
||||
setLibResults([]);
|
||||
setAnyQuery("");
|
||||
@@ -305,10 +322,40 @@ export function DeckView() {
|
||||
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) {
|
||||
if (selected === null) return;
|
||||
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)));
|
||||
|
||||
// Re-adding during the grace period below cancels the pending removal.
|
||||
@@ -408,10 +455,10 @@ export function DeckView() {
|
||||
|
||||
async function setCommander(card: CardRef) {
|
||||
if (selected === null) return;
|
||||
// Ensure the commander is in the deck, then flag it.
|
||||
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
|
||||
await api.setDeckCard(selected, card.oracle_id, 1, true);
|
||||
}
|
||||
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
|
||||
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
||||
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);
|
||||
setCmdQuery("");
|
||||
setCmdResults([]);
|
||||
@@ -426,8 +473,9 @@ export function DeckView() {
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => e.quantity_owned > 0);
|
||||
const unowned = 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.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 deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||
|
||||
@@ -458,7 +506,13 @@ export function DeckView() {
|
||||
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>
|
||||
@@ -477,6 +531,7 @@ export function DeckView() {
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -510,6 +565,9 @@ export function DeckView() {
|
||||
/>
|
||||
</td>
|
||||
<td>{e.quantity_owned}</td>
|
||||
<td>
|
||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -737,6 +795,23 @@ export function DeckView() {
|
||||
/>
|
||||
{cardsGroup(unowned)}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user