From 99a579a697f1258d5d04fbac6743cd019c75cbcd Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Mon, 26 Oct 2026 22:35:41 +0000 Subject: [PATCH] Deck status (Idea/Building/Built/Retired) and pulled-card tracking Only building and built decks now claim owned copies, so several idea decks can share one copy without an over-assignment warning. Deck cards track how many copies are physically pulled: physical decks list cards to pull with their source (a storage box, or another deck to take a copy from), and idea decks split cards into available / in other decks / not owned. The deck list shows status chips with to-pull counts, filters by status, and collapses retired decks. Status and pulled counts sync, which bumps the library document to version 4. Existing decks migrate as ideas. Closes #25 Co-Authored-By: Claude Opus 5.5 --- crates/mtgc-core/src/db.rs | 1 + crates/mtgc-core/src/decklist.rs | 1 + crates/mtgc-core/src/decks.rs | 310 +++++++++++++++++- crates/mtgc-core/src/filters.rs | 34 +- crates/mtgc-core/src/lib.rs | 4 +- crates/mtgc-core/src/library.rs | 66 ++-- crates/mtgc-core/src/model.rs | 82 ++++- .../src/sql/library/0008_deck_status.sql | 12 + crates/mtgc-core/src/sync.rs | 112 ++++++- crates/mtgc-core/src/synergy.rs | 1 + src-tauri/src/commands.rs | 26 +- src-tauri/src/lib.rs | 3 + src/api.ts | 53 +++ src/components.tsx | 27 +- src/deckAnalysis.ts | 5 +- src/styles.css | 72 ++++ src/views/CardSearch.tsx | 4 +- src/views/DeckList.tsx | 85 ++++- src/views/DeckView.tsx | 288 +++++++++++++++- src/views/LibraryBrowser.tsx | 4 +- 20 files changed, 1087 insertions(+), 103 deletions(-) create mode 100644 crates/mtgc-core/src/sql/library/0008_deck_status.sql diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 295679a..69a81aa 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -20,6 +20,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[ include_str!("sql/library/0005_deck_uuid.sql"), include_str!("sql/library/0006_printings.sql"), include_str!("sql/library/0007_deck_cover.sql"), + include_str!("sql/library/0008_deck_status.sql"), ]; /// The attached alias under which the reference db is available at runtime. diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index eedc515..88f9d93 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -877,6 +877,7 @@ mod tests { commander_oracle_id: None, notes: String::new(), cover_oracle_id: None, + status: Default::default(), }; let text = format_deck_text(&deck, &[], &HashMap::new()); assert_eq!(text, "// mtgc deck export\n"); diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 8b52afe..fd39f05 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -6,7 +6,9 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities}; +use crate::model::{ + CardRef, ColorSet, Deck, DeckEntry, DeckHolding, DeckStatus, Legalities, PHYSICAL_STATUSES_SQL, +}; use crate::partner::{pairs_with, Partner}; use crate::Catalog; @@ -29,7 +31,7 @@ impl Catalog { Ok(self .conn .query_row( - "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks WHERE id = ?1", + "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id, status FROM decks WHERE id = ?1", params![id], Self::map_deck, ) @@ -38,7 +40,7 @@ impl Catalog { pub fn list_decks(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id + "SELECT id, name, format, commander_oracle_id, notes, cover_oracle_id, status FROM decks ORDER BY name COLLATE NOCASE", )?; let rows = stmt @@ -64,7 +66,8 @@ impl Catalog { /// Duplicate a deck under a new name: same format/commander/notes/cover, plus a /// copy of every deck_cards row (quantity, flags, chosen printing). Fails if - /// `new_name` is already taken. + /// `new_name` is already taken. The copy starts as an idea: it's a new list, + /// not a second set of physical cards. pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result { let src = self .get_deck(id)? @@ -89,7 +92,8 @@ impl Catalog { /// Set how many copies of a card belong to a deck. `quantity <= 0` removes /// the card from the deck. Upserts on (deck_id, oracle_id), leaving any - /// chosen printing in place. + /// chosen printing in place. Pulled copies beyond the new quantity (all of + /// them, on the maybeboard) go back to the box. pub fn set_deck_card( &self, deck_id: i64, @@ -111,7 +115,9 @@ impl Catalog { ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander, - is_maybeboard = excluded.is_maybeboard", + is_maybeboard = excluded.is_maybeboard, + quantity_pulled = CASE WHEN excluded.is_maybeboard THEN 0 + ELSE MIN(deck_cards.quantity_pulled, excluded.quantity) END", params![deck_id, oracle_id, quantity, is_commander as i64, is_maybeboard as i64], )?; Ok(()) @@ -153,7 +159,7 @@ impl Catalog { CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END, oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id, - oc.oracle_text, dc.deck_id + oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, '') 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 @@ -192,17 +198,172 @@ impl Catalog { is_maybeboard: r.get::<_, i64>(15)? != 0, preferred_scryfall_id: r.get(17)?, partner, + quantity_pulled: r.get(20)?, + location: r.get(21)?, + quantity_spare: 0, + quantity_in_box: 0, + claimed_by: Vec::new(), }; Ok((r.get(19)?, entry)) })? .collect::, _>>()?; + + // Fill in where each card's copies physically are, relative to the + // deck it's listed in. + let holdings = self.physical_holdings()?; + let mut rows = rows; + for (deck_id, e) in &mut rows { + let held = holdings.get(&e.card.oracle_id).map(Vec::as_slice).unwrap_or_default(); + let others = held.iter().filter(|h| h.deck_id != *deck_id); + let committed_elsewhere: i64 = others.clone().map(|h| h.quantity).sum(); + let pulled_total: i64 = held.iter().map(|h| h.pulled).sum(); + e.quantity_spare = (e.quantity_owned - committed_elsewhere).max(0); + e.quantity_in_box = (e.quantity_owned - pulled_total).max(0); + e.claimed_by = others.cloned().collect(); + } Ok(rows) } + /// Every building/built deck's main-board claim on each card, keyed by + /// oracle id. + fn physical_holdings(&self) -> Result>> { + let mut stmt = self.conn.prepare(&format!( + "SELECT dc.oracle_id, d.id, d.name, dc.quantity, dc.quantity_pulled + FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id + WHERE dc.is_maybeboard = 0 AND d.status IN {PHYSICAL_STATUSES_SQL} + ORDER BY d.name COLLATE NOCASE" + ))?; + let mut map: HashMap> = HashMap::new(); + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + DeckHolding { deck_id: r.get(1)?, name: r.get(2)?, quantity: r.get(3)?, pulled: r.get(4)? }, + )) + })?; + for row in rows { + let (oid, h) = row?; + map.entry(oid).or_default().push(h); + } + Ok(map) + } + + /// Owned copies of a card not pulled into any deck. + fn copies_in_box(&self, oracle_id: &str) -> Result { + Ok(self.conn.query_row( + &format!( + "SELECT COALESCE((SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1), 0) + - COALESCE((SELECT SUM(dc.quantity_pulled) FROM deck_cards dc + JOIN decks d ON d.id = dc.deck_id + WHERE dc.oracle_id = ?1 AND d.status IN {PHYSICAL_STATUSES_SQL}), 0)" + ), + params![oracle_id], + |r| r.get::<_, i64>(0), + )? + .max(0)) + } + + /// Change a deck's status. Leaving building/built puts every pulled copy + /// back in its box. Entering it with `assembled` records the deck as + /// already put together: each card is marked pulled as far as the copies + /// in the box allow. + pub fn set_deck_status(&self, deck_id: i64, status: DeckStatus, assembled: bool) -> Result<()> { + let tx = self.nested_tx()?; + self.conn.execute( + "UPDATE decks SET status = ?1 WHERE id = ?2", + params![status.as_str(), deck_id], + )?; + if !status.is_physical() { + self.conn.execute( + "UPDATE deck_cards SET quantity_pulled = 0 WHERE deck_id = ?1", + params![deck_id], + )?; + } else if assembled { + let cards: Vec<(String, i64, i64)> = { + let mut stmt = self.conn.prepare( + "SELECT oracle_id, quantity, quantity_pulled FROM deck_cards + WHERE deck_id = ?1 AND is_maybeboard = 0", + )?; + let rows = stmt.query_map(params![deck_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + rows.collect::>()? + }; + for (oid, quantity, pulled) in cards { + let pulled = quantity.min(pulled + self.copies_in_box(&oid)?); + self.conn.execute( + "UPDATE deck_cards SET quantity_pulled = ?1 WHERE deck_id = ?2 AND oracle_id = ?3", + params![pulled, deck_id, oid], + )?; + } + } + tx.commit()?; + Ok(()) + } + + /// Record how many copies of a card are physically in a building/built + /// deck. Clamped to the deck's quantity, and an increase to the copies + /// actually in the box. Returns the count stored. + pub fn set_deck_card_pulled(&self, deck_id: i64, oracle_id: &str, pulled: i64) -> Result { + let tx = self.nested_tx()?; + let row: Option<(i64, i64, String)> = self + .conn + .query_row( + "SELECT dc.quantity, dc.quantity_pulled, d.status FROM deck_cards dc + JOIN decks d ON d.id = dc.deck_id + WHERE dc.deck_id = ?1 AND dc.oracle_id = ?2 AND dc.is_maybeboard = 0", + params![deck_id, oracle_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + let Some((quantity, current, status)) = row else { + return Ok(0); + }; + if !DeckStatus::parse(&status).is_physical() { + return Ok(0); + } + let mut pulled = pulled.clamp(0, quantity); + if pulled > current { + pulled = pulled.min(current + self.copies_in_box(oracle_id)?); + } + self.conn.execute( + "UPDATE deck_cards SET quantity_pulled = ?1 WHERE deck_id = ?2 AND oracle_id = ?3", + params![pulled, deck_id, oracle_id], + )?; + tx.commit()?; + Ok(pulled) + } + + /// Move one pulled copy of a card from one deck to another, leaving a + /// copy to pull in the deck it came from. A no-op (returning false) + /// unless the source has a copy pulled and the destination is short one. + pub fn take_deck_card_copy(&self, from_deck: i64, to_deck: i64, oracle_id: &str) -> Result { + if from_deck == to_deck { + return Ok(false); + } + let tx = self.nested_tx()?; + let taken = self.conn.execute( + "UPDATE deck_cards SET quantity_pulled = quantity_pulled - 1 + WHERE deck_id = ?1 AND oracle_id = ?2 AND quantity_pulled > 0", + params![from_deck, oracle_id], + )?; + let given = self.conn.execute( + &format!( + "UPDATE deck_cards SET quantity_pulled = quantity_pulled + 1 + WHERE deck_id = ?1 AND oracle_id = ?2 AND is_maybeboard = 0 + AND quantity_pulled < quantity + AND (SELECT status FROM decks WHERE id = ?1) IN {PHYSICAL_STATUSES_SQL}" + ), + params![to_deck, oracle_id], + )?; + if taken == 0 || given == 0 { + return Ok(false); // dropping `tx` rolls back the half that applied + } + tx.commit()?; + Ok(true) + } + /// The decks a given card is currently assigned to. pub fn card_decks(&self, oracle_id: &str) -> Result> { let mut stmt = self.conn.prepare( - "SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id + "SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes, d.cover_oracle_id, d.status FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id WHERE dc.oracle_id = ?1 ORDER BY d.name COLLATE NOCASE", @@ -221,6 +382,7 @@ impl Catalog { commander_oracle_id: r.get(3)?, notes: r.get(4)?, cover_oracle_id: r.get(5)?, + status: DeckStatus::parse(&r.get::<_, String>(6)?), }) } @@ -323,6 +485,7 @@ mod tests { cat.add_owned("llanowar-elves", 4).unwrap(); let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap(); + cat.set_deck_status(elves.id, crate::model::DeckStatus::Built, false).unwrap(); cat.set_deck_card(elves.id, "llanowar-elves", 4, false, false) .unwrap(); @@ -377,6 +540,8 @@ mod tests { let a = cat.create_deck("Deck A", None).unwrap(); let b = cat.create_deck("Deck B", None).unwrap(); + cat.set_deck_status(a.id, crate::model::DeckStatus::Built, false).unwrap(); + cat.set_deck_status(b.id, crate::model::DeckStatus::Building, 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(); @@ -451,6 +616,7 @@ mod tests { cat.add_owned("sol-ring", 1).unwrap(); let d = cat.create_deck("Maybe Deck", None).unwrap(); + cat.set_deck_status(d.id, crate::model::DeckStatus::Built, false).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 @@ -661,4 +827,132 @@ mod tests { .collect(); assert_eq!(found, vec!["llanowar-elves"], "same variant only, and not itself"); } + + #[test] + fn idea_and_retired_decks_claim_no_copies() { + use crate::model::DeckStatus; + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("sol-ring", 1).unwrap(); + + // The report this exists for: one copy, several decks still on paper. + let a = cat.create_deck("Idea A", None).unwrap(); + let b = cat.create_deck("Idea B", None).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 sol = |cat: &crate::Catalog| { + cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == "sol-ring").unwrap() + }; + assert_eq!(sol(cat).quantity_assigned, 0); + assert!(sol(cat).physical_decks.is_empty()); + let in_b = |cat: &crate::Catalog| cat.deck_contents(b.id).unwrap().remove(0); + assert_eq!(in_b(cat).quantity_spare, 1); + + // Building A claims the copy, so B no longer has one spare. + cat.set_deck_status(a.id, DeckStatus::Built, false).unwrap(); + assert_eq!(sol(cat).quantity_assigned, 1); + assert_eq!(sol(cat).physical_decks, vec!["Idea A".to_string()]); + assert_eq!(in_b(cat).quantity_spare, 0); + + // Retiring it releases the claim again. + cat.set_deck_status(a.id, DeckStatus::Retired, false).unwrap(); + assert_eq!(sol(cat).quantity_assigned, 0); + assert_eq!(in_b(cat).quantity_spare, 1); + } + + #[test] + fn status_changes_fill_and_clear_pulled_copies() { + use crate::model::DeckStatus; + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("sol-ring", 1).unwrap(); + cat.add_owned("llanowar-elves", 2).unwrap(); + let d = cat.create_deck("Deck", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_card(d.id, "llanowar-elves", 4, false, false).unwrap(); // only 2 owned + cat.set_deck_card(d.id, "serra-angel", 1, false, false).unwrap(); // not owned + cat.set_deck_card(d.id, "lightning-bolt", 1, false, true).unwrap(); // maybeboard + + let pulled = |cat: &crate::Catalog, oid: &str| { + cat.deck_contents(d.id).unwrap().into_iter().find(|e| e.card.oracle_id == oid).unwrap().quantity_pulled + }; + + // Starting to build pulls nothing yet. + cat.set_deck_status(d.id, DeckStatus::Building, false).unwrap(); + assert_eq!(pulled(cat, "sol-ring"), 0); + + // "Already assembled" pulls as far as owned copies allow. + cat.set_deck_status(d.id, DeckStatus::Built, true).unwrap(); + assert_eq!(pulled(cat, "sol-ring"), 1); + assert_eq!(pulled(cat, "llanowar-elves"), 2); + assert_eq!(pulled(cat, "serra-angel"), 0); + assert_eq!(pulled(cat, "lightning-bolt"), 0); + let elves = cat.list_library().unwrap().into_iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap(); + assert_eq!(elves.quantity_in_decks, 2); + + // Lowering the quantity clamps the pulled count; maybeboarding clears it. + cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap(); + assert_eq!(pulled(cat, "llanowar-elves"), 1); + cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap(); + assert_eq!(pulled(cat, "sol-ring"), 0); + + // Retiring puts everything back in the box. + cat.set_deck_status(d.id, DeckStatus::Retired, false).unwrap(); + assert_eq!(pulled(cat, "llanowar-elves"), 0); + } + + #[test] + fn pulling_is_limited_to_copies_in_the_box_and_can_take_from_another_deck() { + use crate::model::DeckStatus; + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("sol-ring", 1).unwrap(); + let a = cat.create_deck("A", None).unwrap(); + let b = cat.create_deck("B", None).unwrap(); + for d in [&a, &b] { + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_status(d.id, DeckStatus::Building, false).unwrap(); + } + + // A pulls the only copy; B can't pull a copy that isn't in the box. + assert_eq!(cat.set_deck_card_pulled(a.id, "sol-ring", 1).unwrap(), 1); + assert_eq!(cat.set_deck_card_pulled(b.id, "sol-ring", 1).unwrap(), 0); + + let entry = |id: i64| cat.deck_contents(id).unwrap().remove(0); + assert_eq!(entry(b.id).quantity_in_box, 0); + assert_eq!(entry(b.id).claimed_by.len(), 1); + assert_eq!(entry(b.id).claimed_by[0].name, "A"); + assert_eq!(entry(b.id).claimed_by[0].pulled, 1); + assert_eq!(entry(a.id).location, ""); + + // B takes it from A, which is left with a copy to pull. + assert!(cat.take_deck_card_copy(a.id, b.id, "sol-ring").unwrap()); + assert_eq!(entry(a.id).quantity_pulled, 0); + assert_eq!(entry(b.id).quantity_pulled, 1); + // B is already full, and A has nothing left to give. + assert!(!cat.take_deck_card_copy(a.id, b.id, "sol-ring").unwrap()); + assert_eq!(entry(b.id).quantity_pulled, 1); + + // Idea decks can't hold pulled copies at all. + cat.set_deck_status(a.id, DeckStatus::Idea, false).unwrap(); + assert!(!cat.take_deck_card_copy(b.id, a.id, "sol-ring").unwrap()); + assert_eq!(entry(b.id).quantity_pulled, 1, "a failed move leaves the source alone"); + assert_eq!(cat.set_deck_card_pulled(a.id, "sol-ring", 1).unwrap(), 0); + } + + #[test] + fn duplicates_start_as_ideas() { + use crate::model::DeckStatus; + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("sol-ring", 1).unwrap(); + let d = cat.create_deck("Built", None).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_status(d.id, DeckStatus::Built, true).unwrap(); + + let copy = cat.duplicate_deck(d.id, "Copy").unwrap(); + assert_eq!(copy.status, DeckStatus::Idea); + assert_eq!(cat.deck_contents(copy.id).unwrap()[0].quantity_pulled, 0); + } } diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index e579443..a0b2762 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -195,31 +195,38 @@ fn push_card_filters( } } -/// `spares_only`: more copies owned than the decks' main boards use. Both -/// searches alias the library row `lc` (LEFT JOINed by the reference search, -/// hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`], which -/// is what the over-assigned ⚠ is computed from. +/// `spares_only`: more copies owned than the building/built decks' main boards +/// use. Both searches alias the library row `lc` (LEFT JOINed by the reference +/// search, hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`], +/// which is what the over-assigned ⚠ is computed from. const SPARES_PREDICATE: &str = " AND COALESCE(lc.quantity_owned, 0) > COALESCE( - (SELECT SUM(dc.quantity) FROM deck_cards dc - WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0), 0)"; + (SELECT SUM(dc.quantity) FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id + WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0 + AND d.status IN ('building', 'built')), 0)"; /// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck /// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`], /// and [`Catalog::list_library`]. -fn enrich_with_deck_context( +pub(crate) fn enrich_with_deck_context( cat: &Catalog, rows: Vec<(String, i64, String, crate::model::CardRef)>, ) -> Result> { let deck_names = cat.deck_names_by_oracle()?; let assigned = cat.assigned_totals_by_oracle()?; + let pulled = cat.pulled_totals_by_oracle()?; Ok(rows .into_iter() - .map(|(oid, qty, location, card)| LibraryCard { - quantity_owned: qty, - decks: deck_names.get(&oid).cloned().unwrap_or_default(), - quantity_assigned: *assigned.get(&oid).unwrap_or(&0), - location, - card, + .map(|(oid, qty, location, card)| { + let decks = deck_names.get(&oid).map(Vec::as_slice).unwrap_or_default(); + LibraryCard { + quantity_owned: qty, + decks: decks.iter().map(|(name, _)| name.clone()).collect(), + physical_decks: decks.iter().filter(|(_, p)| *p).map(|(name, _)| name.clone()).collect(), + quantity_assigned: *assigned.get(&oid).unwrap_or(&0), + quantity_in_decks: *pulled.get(&oid).unwrap_or(&0), + location, + card, + } }) .collect()) } @@ -933,6 +940,7 @@ mod tests { cat.add_owned("serra-angel", 1).unwrap(); // in no deck → spare cat.add_owned("llanowar-elves", 1).unwrap(); // only on a maybeboard → spare let deck = cat.create_deck("Deck", None).unwrap(); + cat.set_deck_status(deck.id, crate::model::DeckStatus::Built, false).unwrap(); cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap(); cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap(); cat.set_deck_card(deck.id, "llanowar-elves", 1, false, true).unwrap(); diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index d98a43d..3870fc8 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -30,8 +30,8 @@ use rusqlite::Connection; pub use error::{CoreError, Result}; pub use http::fetch_image; pub use model::{ - CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy, - LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, + CardRef, ColorSet, Deck, DeckEntry, DeckHolding, DeckStatus, LegalStatus, Legalities, + LibraryCard, LibraryCopy, LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, }; pub use search::NameIndex; pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason}; diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index b143694..6dbd100 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -6,7 +6,9 @@ use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; -use crate::model::{CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; +use crate::model::{ + CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, PHYSICAL_STATUSES_SQL, WUBRG, +}; use crate::Catalog; /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, @@ -78,9 +80,6 @@ impl Catalog { /// List every owned card (quantity_owned > 0) with deck context, ordered by /// name. This is the "browse my library" view without extra criteria. pub fn list_library(&self) -> Result> { - let deck_names = self.deck_names_by_oracle()?; - let assigned = self.assigned_totals_by_oracle()?; - 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}, @@ -97,17 +96,7 @@ impl Catalog { let rows = stmt .query_map([], row_to_library_parts)? .collect::, _>>()?; - - Ok(rows - .into_iter() - .map(|(oid, qty, location, card)| LibraryCard { - quantity_owned: qty, - decks: deck_names.get(&oid).cloned().unwrap_or_default(), - quantity_assigned: *assigned.get(&oid).unwrap_or(&0), - location, - card, - }) - .collect()) + crate::filters::enrich_with_deck_context(self, rows) } /// Aggregate stats about the owned library, for the sidebar summary. @@ -216,29 +205,44 @@ impl Catalog { Ok(()) } - /// Map each owned/assigned oracle_id to the deck names it appears in. - pub(crate) fn deck_names_by_oracle(&self) -> Result>> { - let mut stmt = self.conn.prepare( - "SELECT dc.oracle_id, d.name + /// Map each owned/assigned oracle_id to the deck names it appears in, + /// each flagged with whether that deck physically exists. + pub(crate) fn deck_names_by_oracle(&self) -> Result>> { + let mut stmt = self.conn.prepare(&format!( + "SELECT dc.oracle_id, d.name, d.status IN {PHYSICAL_STATUSES_SQL} FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id - ORDER BY d.name COLLATE NOCASE", - )?; - let mut map: HashMap> = HashMap::new(); - let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + ORDER BY d.name COLLATE NOCASE" + ))?; + let mut map: HashMap> = HashMap::new(); + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, bool>(2)?)) + })?; for row in rows { - let (oid, name) = row?; - map.entry(oid).or_default().push(name); + let (oid, name, physical) = row?; + map.entry(oid).or_default().push((name, physical)); } Ok(map) } + /// Copies each card's building/built decks claim. Idea and retired decks + /// don't hold cards, and maybeboard cards aren't actually committed to a + /// deck, so neither counts against a card's owned copies. pub(crate) fn assigned_totals_by_oracle(&self) -> Result> { - // 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", - )?; + self.physical_totals_by_oracle("dc.quantity") + } + + /// Copies of each card physically pulled into decks. + pub(crate) fn pulled_totals_by_oracle(&self) -> Result> { + self.physical_totals_by_oracle("dc.quantity_pulled") + } + + fn physical_totals_by_oracle(&self, column: &str) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT dc.oracle_id, SUM({column}) FROM deck_cards dc + JOIN decks d ON d.id = dc.deck_id + WHERE dc.is_maybeboard = 0 AND d.status IN {PHYSICAL_STATUSES_SQL} + GROUP BY dc.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 { diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 2753bf0..497e7ff 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -188,12 +188,19 @@ pub struct LibraryCard { pub quantity_owned: i64, /// Names of decks this card is currently assigned to. pub decks: Vec, - /// Total copies assigned across all decks. If it exceeds `quantity_owned`, - /// the physical copies are over-committed. + /// Copies claimed by building/built decks' main boards (idea and retired + /// decks claim nothing). If it exceeds `quantity_owned`, the physical + /// copies are over-committed. pub quantity_assigned: i64, /// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for /// cards not currently in a deck. pub location: String, + /// The subset of `decks` that physically exist (building or built). + #[serde(default)] + pub physical_decks: Vec, + /// Copies physically pulled into decks; the rest are at `location`. + #[serde(default)] + pub quantity_in_decks: i64, } /// A card in a deck, whether or not the user owns it. @@ -218,6 +225,75 @@ pub struct DeckEntry { /// zone with a second commander. #[serde(default)] pub partner: Option, + /// Copies physically in this deck; the rest are still to be pulled. Only + /// meaningful for building/built decks. + #[serde(default)] + pub quantity_pulled: i64, + /// Owned copies not claimed by any *other* building/built deck. + #[serde(default)] + pub quantity_spare: i64, + /// Owned copies not pulled into any deck — still in their box. + #[serde(default)] + pub quantity_in_box: i64, + /// The *other* building/built decks claiming copies — those with copies + /// pulled are where to take one from when none are left in the box. + #[serde(default)] + pub claimed_by: Vec, + /// Where the card's library copies are stored (e.g. "Box 1"). + #[serde(default)] + pub location: String, +} + +/// A building/built deck's claim on a card's copies. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeckHolding { + pub deck_id: i64, + pub name: String, + /// Copies the deck's main board lists. + pub quantity: i64, + /// Of those, copies physically in the deck. + pub pulled: i64, +} + +/// Where a deck stands physically. Only building and built decks hold cards +/// and claim owned copies; ideas and retired decks are just lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeckStatus { + #[default] + Idea, + Building, + Built, + Retired, +} + +/// SQL list of the statuses for which [`DeckStatus::is_physical`] holds. +pub const PHYSICAL_STATUSES_SQL: &str = "('building', 'built')"; + +impl DeckStatus { + pub fn as_str(self) -> &'static str { + match self { + DeckStatus::Idea => "idea", + DeckStatus::Building => "building", + DeckStatus::Built => "built", + DeckStatus::Retired => "retired", + } + } + + /// Unknown values (say, from a newer build) read as an idea, which holds + /// nothing — the safe direction to be wrong in. + pub fn parse(s: &str) -> Self { + match s { + "building" => DeckStatus::Building, + "built" => DeckStatus::Built, + "retired" => DeckStatus::Retired, + _ => DeckStatus::Idea, + } + } + + pub fn is_physical(self) -> bool { + matches!(self, DeckStatus::Building | DeckStatus::Built) + } } /// One printing of a card, for the printing picker and copy labels. @@ -272,6 +348,8 @@ pub struct Deck { /// The card whose art headlines the deck in the deck list, if chosen; /// `None` leaves the pick to the UI. pub cover_oracle_id: Option, + #[serde(default)] + pub status: DeckStatus, } /// Aggregate stats about the owned library, for a dashboard-style summary. diff --git a/crates/mtgc-core/src/sql/library/0008_deck_status.sql b/crates/mtgc-core/src/sql/library/0008_deck_status.sql new file mode 100644 index 0000000..c613216 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0008_deck_status.sql @@ -0,0 +1,12 @@ +-- v8 — deck status and pulled copies. +-- +-- `status` says whether a deck physically exists: 'idea' (theory-crafting) and +-- 'retired' (taken apart) hold no cards; 'building' and 'built' do, and only +-- they claim owned copies. Existing decks start as ideas, so nothing is +-- considered physically moved until the user says so. +ALTER TABLE decks ADD COLUMN status TEXT NOT NULL DEFAULT 'idea'; + +-- How many of a deck card's copies are physically in the deck. Short of +-- `quantity`, the rest are still to be pulled from their box (or another +-- deck). Always 0 outside building/built decks and on the maybeboard. +ALTER TABLE deck_cards ADD COLUMN quantity_pulled INTEGER NOT NULL DEFAULT 0; diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs index 8a847e6..1726df1 100644 --- a/crates/mtgc-core/src/sync.rs +++ b/crates/mtgc-core/src/sync.rs @@ -44,6 +44,7 @@ use rusqlite::Connection; use serde::{Deserialize, Serialize}; use crate::error::Result; +use crate::model::DeckStatus; pub mod account; pub mod remote; @@ -56,8 +57,9 @@ pub mod remote; /// dropped printing data would publish its loss as a deletion on the next /// sync, so documents that carry any are marked 2 and older builds refuse /// them — while libraries that don't use printings stay readable everywhere. -/// Version 3 does the same for deck covers. -pub const DOC_VERSION: u32 = 3; +/// Version 3 does the same for deck covers, and 4 for deck statuses and +/// pulled copies. +pub const DOC_VERSION: u32 = 4; /// The format of a document with no printing data in it. const BASE_VERSION: u32 = 1; @@ -85,6 +87,9 @@ pub struct DeckCardEntry { /// Scryfall id of the printing whose art this slot shows. #[serde(default, skip_serializing_if = "Option::is_none")] pub printing: Option, + /// Copies physically in the deck (see migration 0008). + #[serde(default, skip_serializing_if = "is_zero")] + pub pulled: i64, } /// The [`CardEntry::printings`] key for a copy group: `"/"`. @@ -112,6 +117,8 @@ pub struct DeckDoc { /// The oracle id of the card chosen to headline the deck in the deck list. #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub status: DeckStatus, #[serde(default)] pub cards: BTreeMap, } @@ -146,6 +153,15 @@ fn is_false(b: &bool) -> bool { !*b } +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_zero(n: &i64) -> bool { + *n == 0 +} + +fn is_default(v: &T) -> bool { + *v == T::default() +} + impl LibraryDoc { pub fn to_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) @@ -169,9 +185,17 @@ impl LibraryDoc { self.cards.is_empty() && self.decks.is_empty() } - /// The lowest format that can hold this document: 3 once any deck has a - /// cover, 2 once it carries any printing data, else 1. See [`DOC_VERSION`]. + /// The lowest format that can hold this document: 4 once any deck has a + /// status or pulled copies, 3 once any deck has a cover, 2 once it carries + /// any printing data, else 1. See [`DOC_VERSION`]. pub fn required_version(&self) -> u32 { + if self + .decks + .values() + .any(|d| d.status != DeckStatus::default() || d.cards.values().any(|c| c.pulled != 0)) + { + return 4; + } if self.decks.values().any(|d| d.cover.is_some()) { return 3; } @@ -425,6 +449,9 @@ fn merge_deck( cover: merge_field(&b.cover, &o.cover, &t.cover, || { report.conflicts.push(format!("cover of deck \"{name}\"")) }), + status: merge_field(&b.status, &o.status, &t.status, || { + report.conflicts.push(format!("status of deck \"{name}\"")) + }), cards: BTreeMap::new(), }; @@ -493,7 +520,7 @@ pub fn read_doc(conn: &Connection) -> Result { } } - let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id FROM decks")?; + let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes, cover_oracle_id, status FROM decks")?; let mut rows = stmt.query([])?; let mut by_id: BTreeMap = BTreeMap::new(); while let Some(row) = rows.next()? { @@ -508,13 +535,15 @@ pub fn read_doc(conn: &Connection) -> Result { commander: row.get(4)?, notes: row.get(5)?, cover: row.get(6)?, + status: DeckStatus::parse(&row.get::<_, String>(7)?), cards: BTreeMap::new(), }, ); } let mut stmt = conn.prepare( - "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id + "SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, + quantity_pulled FROM deck_cards", )?; let mut rows = stmt.query([])?; @@ -531,6 +560,7 @@ pub fn read_doc(conn: &Connection) -> Result { commander: row.get::<_, i64>(3)? != 0, maybeboard: row.get::<_, i64>(4)? != 0, printing: row.get(5)?, + pulled: row.get(6)?, }, ); } @@ -596,35 +626,47 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { for (uuid, deck) in &doc.decks { tx.execute( - "INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO decks(uuid, name, format, commander_oracle_id, notes, cover_oracle_id, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(uuid) DO UPDATE SET name = excluded.name, format = excluded.format, commander_oracle_id = excluded.commander_oracle_id, notes = excluded.notes, - cover_oracle_id = excluded.cover_oracle_id", - rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes, deck.cover], + cover_oracle_id = excluded.cover_oracle_id, + status = excluded.status", + rusqlite::params![ + uuid, + deck.name, + deck.format, + deck.commander, + deck.notes, + deck.cover, + deck.status.as_str() + ], )?; let deck_id: i64 = tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?; for (oracle_id, card) in &deck.cards { tx.execute( - "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, + preferred_scryfall_id, quantity_pulled) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander, is_maybeboard = excluded.is_maybeboard, - preferred_scryfall_id = excluded.preferred_scryfall_id", + preferred_scryfall_id = excluded.preferred_scryfall_id, + quantity_pulled = excluded.quantity_pulled", rusqlite::params![ deck_id, oracle_id, card.quantity, card.commander as i64, card.maybeboard as i64, - card.printing + card.printing, + card.pulled ], )?; } @@ -960,6 +1002,44 @@ mod tests { assert_eq!(report.conflicts, vec!["cover of deck \"Fub\"".to_string()]); } + #[test] + fn version_4_is_used_only_once_a_deck_has_a_status_or_pulled_copies() { + let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); + d.decks.get_mut("u1").unwrap().cover = Some("a".into()); + assert_eq!(d.required_version(), 3); + assert!(!d.to_json().unwrap().contains("status")); + + d.decks.get_mut("u1").unwrap().status = DeckStatus::Built; + assert_eq!(d.required_version(), 4); + + d.decks.get_mut("u1").unwrap().status = DeckStatus::Idea; + d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().pulled = 1; + assert_eq!(d.required_version(), 4); + + let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default()); + let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap(); + assert_eq!(back, merged); + } + + #[test] + fn deck_status_merges_like_other_deck_fields() { + let base = doc(&[], &[("u1", "Fub", &[])]); + let mut ours = base.clone(); + let mut theirs = base.clone(); + ours.decks.get_mut("u1").unwrap().status = DeckStatus::Built; + theirs.decks.get_mut("u1").unwrap().name = "Renamed".into(); + let (merged, report) = merge3(&base, &ours, &theirs); + assert_eq!(merged.decks["u1"].status, DeckStatus::Built); + assert_eq!(merged.decks["u1"].name, "Renamed"); + assert!(report.conflicts.is_empty()); + + let mut theirs = base.clone(); + theirs.decks.get_mut("u1").unwrap().status = DeckStatus::Retired; + let (merged, report) = merge3(&base, &ours, &theirs); + assert_eq!(merged.decks["u1"].status, DeckStatus::Retired); + assert_eq!(report.conflicts, vec!["status of deck \"Fub\"".to_string()]); + } + #[test] fn a_version_1_document_carries_no_printing_fields() { // Older builds must see exactly what they always saw. @@ -990,9 +1070,13 @@ mod tests { c.set_deck_cover(deck.id, Some("card-a")).unwrap(); c.set_owned("card-a", 4).unwrap(); c.set_location("card-a", "Box 1").unwrap(); + c.set_deck_status(deck.id, DeckStatus::Building, false).unwrap(); + c.set_deck_card_pulled(deck.id, "card-a", 1).unwrap(); let before = read_doc(&c.conn).unwrap(); assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a")); + assert_eq!(before.decks.values().next().unwrap().status, DeckStatus::Building); + assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1); // Round-trip through JSON too, so the serde attributes are exercised. let text = before.to_json().unwrap(); let parsed = LibraryDoc::from_json(&text).unwrap(); diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs index f261624..24dcfd6 100644 --- a/crates/mtgc-core/src/synergy.rs +++ b/crates/mtgc-core/src/synergy.rs @@ -874,6 +874,7 @@ mod tests { } cat.add_owned("owned-elf", 2).unwrap(); let other = cat.create_deck("Other", None).unwrap(); + cat.set_deck_status(other.id, crate::model::DeckStatus::Built, false).unwrap(); cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap(); let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 70c491a..1b0dac2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::sync::Mutex; use mtgc_core::{ - CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets, + CardRef, CardSuggestion, Deck, DeckEntry, DeckStatus, LibraryCard, LibraryCopy, LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, SuggestOptions, }; use serde::Serialize; @@ -329,6 +329,30 @@ pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> { .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn set_deck_status(db: Db, deck_id: i64, status: DeckStatus, assembled: bool) -> Res<()> { + let s = db.lock().unwrap(); + s.catalog()? + .set_deck_status(deck_id, status, assembled) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_deck_card_pulled(db: Db, deck_id: i64, oracle_id: String, pulled: i64) -> Res { + let s = db.lock().unwrap(); + s.catalog()? + .set_deck_card_pulled(deck_id, &oracle_id, pulled) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res { + let s = db.lock().unwrap(); + s.catalog()? + .take_deck_card_copy(from_deck_id, to_deck_id, &oracle_id) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn set_deck_cover(db: Db, deck_id: i64, oracle_id: Option) -> Res<()> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0067fb4..d13d3c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,6 +108,9 @@ pub fn run() { commands::duplicate_deck, commands::set_deck_format, commands::set_deck_cover, + commands::set_deck_status, + commands::set_deck_card_pulled, + commands::take_deck_card_copy, commands::set_deck_commander, commands::set_deck_commanders, commands::search_partners, diff --git a/src/api.ts b/src/api.ts index cfcd75e..8bc759a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -25,7 +25,12 @@ export interface CardRef { export interface LibraryCard extends CardRef { quantity_owned: number; decks: string[]; + /** The subset of `decks` that physically exist (building or built). */ + physical_decks: string[]; + /** Copies claimed by building/built decks (idea/retired decks claim none). */ quantity_assigned: number; + /** Copies physically pulled into decks; the rest are at `location`. */ + quantity_in_decks: number; location: string; } @@ -46,8 +51,41 @@ export interface DeckEntry extends CardRef { /** The printing whose art this slot shows; `image_uri` already reflects it. */ preferred_scryfall_id: string | null; partner: Partner | null; + /** Copies physically in this deck; meaningful for building/built decks. */ + quantity_pulled: number; + /** Owned copies not claimed by any *other* building/built deck. */ + quantity_spare: number; + /** Owned copies not pulled into any deck — still in their box. */ + quantity_in_box: number; + /** The *other* building/built decks claiming copies; those with copies + * pulled are where to take one from when the box is empty. */ + claimed_by: DeckHolding[]; + /** Where the card's library copies are stored ("" if unset). */ + location: string; } +export interface DeckHolding { + deck_id: number; + name: string; + /** Copies the deck's main board lists. */ + quantity: number; + /** Of those, copies physically in the deck. */ + pulled: number; +} + +/** Where a deck stands physically. Building and built decks hold cards and + * claim owned copies; ideas and retired decks are just lists. */ +export type DeckStatus = "idea" | "building" | "built" | "retired"; + +export const DECK_STATUSES: { value: DeckStatus; label: string }[] = [ + { value: "idea", label: "Idea" }, + { value: "building", label: "Building" }, + { value: "built", label: "Built" }, + { value: "retired", label: "Retired" }, +]; + +export const isPhysical = (s: DeckStatus) => s === "building" || s === "built"; + /** One printing of a card, for the printing picker and copy labels. */ export interface PrintingRef { scryfall_id: string; @@ -82,6 +120,9 @@ export interface LibraryCopy { export type PreviewCard = CardRef & { quantity_owned?: number; decks?: string[]; + physical_decks?: string[]; + quantity_in_decks?: number; + location?: string; preferred_scryfall_id?: string | null; }; @@ -107,6 +148,7 @@ export interface Deck { notes: string; /** The card chosen to headline the deck in the deck list; null = automatic. */ cover_oracle_id: string | null; + status: DeckStatus; } export interface DeckImportResult { @@ -497,6 +539,17 @@ export const api = { invoke("duplicate_deck", { deckId, newName }), setDeckFormat: (deckId: number, format: string) => invoke("set_deck_format", { deckId, format }), + /** Change a deck's status. Leaving building/built returns pulled copies to + * their boxes; entering it with `assembled` marks the copies in the box as + * already in the deck. */ + setDeckStatus: (deckId: number, status: DeckStatus, assembled = false) => + invoke("set_deck_status", { deckId, status, assembled }), + /** Record copies physically in a deck; returns the (clamped) count stored. */ + setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) => + invoke("set_deck_card_pulled", { deckId, oracleId, pulled }), + /** Move one pulled copy between decks; false if there was nothing to move. */ + takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) => + invoke("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }), /** Choose the card headlining the deck in the deck list; null = automatic. */ setDeckCover: (deckId: number, oracleId: string | null) => invoke("set_deck_cover", { deckId, oracleId }), diff --git a/src/components.tsx b/src/components.tsx index 6ce5f05..1692235 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -816,18 +816,23 @@ export function DeckChip({ name, className = "deck-chip", onOpen, + idea, }: { name: string; className?: string; onOpen?: () => void; + /** The deck is an idea or retired: listed, but holding no copies. */ + idea?: boolean; }) { const openDeck = useContext(OpenDeckCtx); - if (!openDeck) return {name}; + if (idea) className += " deck-chip-idea"; + const note = idea ? " (not built — holds no copies)" : ""; + if (!openDeck) return {name}; return ( + {physical && ( + <> + + + + )} +

+ ) : ( + cardsGroup(toPull) + )} + + +
+

+ In deck {inDeck.length} +

+ {cardsGroup(inDeck)} +
+ ) : ( <>

- In your library {owned.length} + Available {available.length}

+

+ Owned, with copies no built deck is using. +

- {cardsGroup(owned)} + {cardsGroup(available)}
+ {elsewhere.length > 0 && ( +
+

+ In other decks {elsewhere.length} +

+

+ Owned, but your copies are used by decks you're building or have built. +

+ {cardsGroup(elsewhere)} +
+ )} +

Not owned {unowned.length} @@ -1059,6 +1303,32 @@ function DeckBuilder({ onClose={() => setHandOpen(false)} /> )} + {statusDialog?.kind === "assemble" && ( + setStatusDialog(null)}> +

+ Are this deck's cards already together? If so, the copies in your library are recorded as in the + deck. Otherwise, they're listed under "To pull" until you take them out of their boxes. +

+
+ + + +
+
+ )} + {statusDialog?.kind === "release" && ( + applyStatus(statusDialog.to)} + onCancel={() => setStatusDialog(null)} + /> + )} {suggestOpen && ( setRefreshKey((k) => k + 1)} /> ) : ( - c.decks.map((d) => ) + c.decks.map((d) => ) ); // Under the spares filter, how many copies are free — only worth saying for @@ -308,7 +308,7 @@ export function LibraryBrowser({ const spareNote = (c: LibraryCard) => criteria.sparesOnly && c.quantity_assigned > 0 && ( - + {c.quantity_owned - c.quantity_assigned} spare );