diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 984058e..7730e06 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -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 { diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 0246868..a45b5d9 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -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>(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>(13)?.unwrap_or_default(), }, + is_maybeboard: r.get::<_, i64>(14)? != 0, }) })? .collect::, _>>()?; @@ -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)"); diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 127c8ad..de5230b 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -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 = 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() diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 6d9c530..a3fe730 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -612,6 +612,8 @@ struct ScryPrinting { image_uris: Option, #[serde(default)] card_faces: Option>, + #[serde(default)] + prices: Option, } #[derive(Deserialize)] @@ -620,6 +622,12 @@ struct ImageUris { normal: Option, } +#[derive(Deserialize)] +struct ScryPrices { + #[serde(default)] + usd: Option, +} + #[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::().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); + } } diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index 788c7a7..19e0a8f 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -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>(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>(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> { - 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(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 8879f7b..c9ac275 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -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, } diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index 23c69ab..5caaca5 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -34,10 +34,11 @@ impl NameIndex { pub fn build(conn: &Connection) -> Result { 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>(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>(10)?.unwrap_or_default(), }, }) })? diff --git a/crates/mtgc-core/src/sql/library/0004_deck_maybeboard.sql b/crates/mtgc-core/src/sql/library/0004_deck_maybeboard.sql new file mode 100644 index 0000000..43632b6 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0004_deck_maybeboard.sql @@ -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; diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 1dd9b49..2118fa4 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -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); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0e2bafb..17e53e1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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()) } diff --git a/src/api.ts b/src/api.ts index 6f85f46..3116e51 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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("set_deck_card", { deckId, oracleId, quantity, isCommander }), + isMaybeboard = false, + ) => invoke("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }), deckContents: (deckId: number) => invoke("deck_contents", { deckId }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), }; diff --git a/src/components.tsx b/src/components.tsx index 7b47bd3..386b62f 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -49,6 +49,20 @@ const ICON_CLOSE = icon( , ); +const ICON_HELP_CIRCLE = icon( + <> + + + + , +); +const ICON_ARROW_LEFT_CIRCLE = icon( + <> + + + + , +); /** * 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 &&
{cornerLeft}
} {corner &&
{corner}
} + {card.price_usd > 0 && ( +
+ ${card.price_usd.toFixed(2)} +
+ )} {footer &&
{footer}
} diff --git a/src/styles.css b/src/styles.css index 34c7f7c..17e652d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 5a39f84..8b702fb 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -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([]); const [cmdQuery, setCmdQuery] = useState(""); const [cmdResults, setCmdResults] = useState([]); + const [maybeQuery, setMaybeQuery] = useState(""); + const [maybeResults, setMaybeResults] = useState([]); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(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 ? ★ commander : undefined} + footer={ + <> + {e.is_commander && ★ commander} + + setMaybeboard(e, !e.is_maybeboard)} /> + + } /> ))} @@ -477,6 +531,7 @@ export function DeckView() { Type In deck Owned + @@ -510,6 +565,9 @@ export function DeckView() { /> {e.quantity_owned} + + setMaybeboard(e, !e.is_maybeboard)} /> + ))} @@ -737,6 +795,23 @@ export function DeckView() { /> {cardsGroup(unowned)} + +
+

+ Maybeboard {maybeboard.length} +

+

+ Cards under consideration — not counted in deck analysis. +

+ + {cardsGroup(maybeboard)} +
)}