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 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:35:41 +00:00
parent eb342f5fbb
commit 99a579a697
20 changed files with 1087 additions and 103 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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<Vec<Deck>> {
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<Deck> {
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::<std::result::Result<Vec<_>, _>>()?;
// 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<HashMap<String, Vec<DeckHolding>>> {
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<String, Vec<DeckHolding>> = 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<i64> {
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::<std::result::Result<_, _>>()?
};
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<i64> {
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<bool> {
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<Vec<Deck>> {
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);
}
}

View File

@@ -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<Vec<LibraryCard>> {
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();

View File

@@ -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};

View File

@@ -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<Vec<LibraryCard>> {
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::<std::result::Result<Vec<_>, _>>()?;
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<HashMap<String, Vec<String>>> {
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<HashMap<String, Vec<(String, bool)>>> {
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<String, Vec<String>> = 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<String, Vec<(String, bool)>> = 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<HashMap<String, i64>> {
// 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<HashMap<String, i64>> {
self.physical_totals_by_oracle("dc.quantity_pulled")
}
fn physical_totals_by_oracle(&self, column: &str) -> Result<HashMap<String, i64>> {
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 {

View File

@@ -188,12 +188,19 @@ pub struct LibraryCard {
pub quantity_owned: i64,
/// Names of decks this card is currently assigned to.
pub decks: Vec<String>,
/// 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<String>,
/// 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<crate::partner::Partner>,
/// 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<DeckHolding>,
/// 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<String>,
#[serde(default)]
pub status: DeckStatus,
}
/// Aggregate stats about the owned library, for a dashboard-style summary.

View File

@@ -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;

View File

@@ -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<String>,
/// 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: `"<scryfall_id>/<finish>"`.
@@ -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<String>,
#[serde(default, skip_serializing_if = "is_default")]
pub status: DeckStatus,
#[serde(default)]
pub cards: BTreeMap<String, DeckCardEntry>,
}
@@ -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<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
impl LibraryDoc {
pub fn to_json(&self) -> Result<String> {
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<LibraryDoc> {
}
}
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<i64, String> = BTreeMap::new();
while let Some(row) = rows.next()? {
@@ -508,13 +535,15 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
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<LibraryDoc> {
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();

View File

@@ -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();