Files
mtgc/crates/mtgc-core/src/library.rs
Garrett Mills d3e8a8f25a Prefer Scryfall's representative printing for default card images
Card previews were picking whichever printing happened to load first
during ingest (ORDER BY rowid over every printing, promos and foreign
versions included), so a card's default image was effectively random.
Capture the representative printing's images straight from the Oracle
Cards bulk pass instead, and prefer that over the old printings-table
guess, falling back gracefully on an older reference.db that predates
these columns.

Also add docs/printing-tracking-design.md, a design (not yet
implemented) for optionally tracking which specific printing/finish a
library or deck card is.
2026-07-22 22:05:44 -05:00

453 lines
18 KiB
Rust

//! Ownership operations: what cards the user has and how many.
use std::collections::HashMap;
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::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, legalities, price_usd, horizontal)` into its library
/// parts. Shared by [`Catalog::list_library`] and the criteria search. The
/// caller's `SELECT` must put `horizontal` (or [`Catalog::horizontal_col`]'s
/// `0` fallback) last, since it's not part of the base reference schema on
/// every db (see `Catalog::has_horizontal_column`).
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
let oracle_id: String = r.get(0)?;
Ok((
oracle_id.clone(),
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
CardRef {
oracle_id,
name: r.get(3)?,
mana_value: r.get(4)?,
colors: ColorSet(r.get::<_, i64>(5)? as u8),
color_identity: ColorSet(r.get::<_, i64>(6)? as u8),
type_line: r.get(7)?,
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
produces_colorless: r.get::<_, i64>(11)? != 0,
legalities: Legalities(r.get::<_, i64>(12)?),
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
horizontal: r.get::<_, i64>(14)? != 0,
},
))
}
impl Catalog {
/// Add `delta` copies of a card to the library (increment, creating the row
/// if needed). `delta` may be negative to decrement; the quantity floors at 0.
/// Returns the new owned quantity.
pub fn add_owned(&self, oracle_id: &str, delta: i64) -> Result<i64> {
let current: i64 = self
.conn
.query_row(
"SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1",
params![oracle_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(0);
self.set_owned(oracle_id, current + delta)
}
/// Set the absolute owned quantity for a card (0 removes ownership but keeps
/// any deck assignments referencing it).
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
let q = quantity.max(0);
self.conn.execute(
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
params![oracle_id, q],
)?;
Ok(q)
}
/// 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},
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
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 = self.image_expr("image_uri"),
back = self.image_expr("back_image_uri"),
price = crate::db::price_subquery(),
horizontal = self.horizontal_col()
))?;
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())
}
/// Aggregate stats about the owned library, for the sidebar summary.
pub fn library_stats(&self) -> Result<LibraryStats> {
let unique_cards: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM library_cards WHERE quantity_owned > 0",
[],
|r| r.get(0),
)?;
let total_copies: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(quantity_owned), 0) FROM library_cards",
[],
|r| r.get(0),
)?;
let deck_count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM decks", [], |r| r.get(0))?;
let mut color_counts = [0i64; 5];
let mut colorless_count = 0i64;
{
let mut stmt = self.conn.prepare(&format!(
"SELECT oc.colors, lc.quantity_owned
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0"
))?;
let rows =
stmt.query_map([], |r| Ok((r.get::<_, i64>(0)? as u8, r.get::<_, i64>(1)?)))?;
for row in rows {
let (colors, qty) = row?;
if colors == 0 {
colorless_count += qty;
} else {
for (i, (bit, _)) in WUBRG.iter().enumerate() {
if colors & bit != 0 {
color_counts[i] += qty;
}
}
}
}
}
Ok(LibraryStats {
unique_cards,
total_copies,
deck_count,
color_counts,
colorless_count,
})
}
/// Distinct type/subtype/keyword values among owned cards, for filter
/// autocomplete.
pub fn library_facets(&self) -> Result<LibraryFacets> {
Ok(LibraryFacets {
types: self.distinct_owned_facet("card_types", "type")?,
subtypes: self.distinct_owned_facet("card_subtypes", "subtype")?,
keywords: self.distinct_owned_facet("card_keywords", "keyword")?,
})
}
/// Distinct type/subtype/keyword values across the *whole* reference db
/// (every downloaded card, owned or not) — the Card Search tab's
/// equivalent of [`Catalog::library_facets`].
pub fn reference_facets(&self) -> Result<LibraryFacets> {
Ok(LibraryFacets {
types: self.distinct_facet("card_types", "type")?,
subtypes: self.distinct_facet("card_subtypes", "subtype")?,
keywords: self.distinct_facet("card_keywords", "keyword")?,
})
}
/// `table`/`column` are always one of the three hardcoded call sites above
/// (never user input), so interpolating them into the query is safe.
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT DISTINCT x.{column}
FROM {REF_DB}.{table} x
WHERE x.oracle_id IN (SELECT oracle_id FROM library_cards WHERE quantity_owned > 0)
ORDER BY x.{column} COLLATE NOCASE"
))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// Same as [`Catalog::distinct_owned_facet`] but unfiltered by ownership.
fn distinct_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT DISTINCT x.{column}
FROM {REF_DB}.{table} x
ORDER BY x.{column} COLLATE NOCASE"
))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
/// Set the free-text storage location for a card (creates the library row if
/// absent). Empty string clears it.
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO library_cards(oracle_id, location) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET location = excluded.location",
params![oracle_id, location],
)?;
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
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)?)))?;
for row in rows {
let (oid, name) = row?;
map.entry(oid).or_default().push(name);
}
Ok(map)
}
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",
)?;
let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
let (oid, total) = row?;
map.insert(oid, total);
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn add_increment_and_floor() {
let fx = test_support::make();
let cat = &fx.catalog;
assert_eq!(cat.add_owned("sol-ring", 1).unwrap(), 1);
assert_eq!(cat.add_owned("sol-ring", 2).unwrap(), 3);
assert_eq!(cat.add_owned("sol-ring", -10).unwrap(), 0); // floors at 0
assert_eq!(cat.set_owned("sol-ring", 4).unwrap(), 4);
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.quantity_owned, 4);
assert!(sol.decks.is_empty());
// Image URL is plumbed through from the printings table.
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn library_facets_only_include_owned_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
// Own llanowar-elves (Creature — Elf Druid) and lightning-bolt
// (Instant); leave serra-angel (Creature — Angel, Flying/Vigilance)
// unowned.
cat.add_owned("llanowar-elves", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
let facets = cat.library_facets().unwrap();
assert_eq!(facets.types, vec!["Creature", "Instant"]);
assert_eq!(facets.subtypes, vec!["Druid", "Elf"]);
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
}
#[test]
fn reference_facets_include_unowned_cards() {
let fx = test_support::make();
let cat = &fx.catalog;
// Nothing owned at all — reference_facets should still see everything
// in the fixture, unlike library_facets.
let facets = cat.reference_facets().unwrap();
assert_eq!(facets.types, vec!["Artifact", "Creature", "Instant"]);
assert!(facets.keywords.contains(&"Flying".to_string()));
assert!(facets.keywords.contains(&"Vigilance".to_string()));
}
#[test]
fn library_stats_aggregates_counts_and_colors() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("llanowar-elves", 2).unwrap(); // G
cat.add_owned("deathrite-shaman", 1).unwrap(); // BG
cat.add_owned("sol-ring", 3).unwrap(); // colorless
cat.create_deck("Deck A", None).unwrap();
cat.create_deck("Deck B", None).unwrap();
let stats = cat.library_stats().unwrap();
assert_eq!(stats.unique_cards, 3);
assert_eq!(stats.total_copies, 6);
assert_eq!(stats.deck_count, 2);
assert_eq!(stats.colorless_count, 3);
// WUBRG order: W, U, B, R, G.
assert_eq!(stats.color_counts, [0, 0, 1, 0, 3]);
}
#[test]
fn oracle_cards_image_wins_over_the_printings_pick() {
// The bug this guards: `printings` can hold an arbitrary (promo,
// showcase, foreign-language...) printing per card. `oracle_cards`'s
// own image — Scryfall's representative-printing pick, captured at
// ingest — must win when present, rather than whatever `printings`
// subquery would otherwise resolve to (first by rowid).
let fx = test_support::make();
let cat = &fx.catalog;
cat.connection()
.execute(
"UPDATE ref.printings SET image_uri = 'http://img/promo-variant' \
WHERE oracle_id = 'sol-ring'",
[],
)
.unwrap();
cat.connection()
.execute(
"UPDATE ref.oracle_cards SET image_uri = 'http://img/normal' \
WHERE oracle_id = 'sol-ring'",
[],
)
.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.image_uri, "http://img/normal");
}
#[test]
fn back_image_uri_is_plumbed_through() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.connection()
.execute(
"UPDATE ref.printings SET back_image_uri = 'http://img/back' \
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.back_image_uri, "http://img/back");
// Single-faced cards have no back image.
let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap();
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();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "Box 1");
// Clearing works.
cat.set_location("sol-ring", "").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "");
}
#[test]
fn list_is_ordered_and_excludes_zero() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("serra-angel", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
cat.set_owned("sol-ring", 0).unwrap();
let names: Vec<_> = cat
.list_library()
.unwrap()
.into_iter()
.map(|c| c.card.name)
.collect();
assert_eq!(names, vec!["Lightning Bolt", "Serra Angel"]);
}
}