Add deck legality checking, deck/library import-export, and a command palette
Threads Scryfall's per-format legalities through the reference db and adds a deck-page indicator (structural rules + banned/restricted lists) that lists violation reasons inline. Decks and selected library cards can now be exported to/imported from plain-text decklists, with best-effort name resolution and a report of anything that didn't match. Also adds a shuffle- and-draw sample hand modal, and an Alt+/ command palette for jumping to a tab, deck, or card without leaving the keyboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
474
crates/mtgc-core/src/decklist.rs
Normal file
474
crates/mtgc-core/src/decklist.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
//! Plain-text decklist import/export: parsing and formatting for decks
|
||||
//! (with Commander/Deck/Maybeboard sections) and flat library card lists.
|
||||
//!
|
||||
//! Deck export format:
|
||||
//! ```text
|
||||
//! // mtgc deck export
|
||||
//! // Format: Commander
|
||||
//!
|
||||
//! Commander
|
||||
//! 1 Krenko, Mob Boss
|
||||
//!
|
||||
//! Deck
|
||||
//! 1 Sol Ring
|
||||
//! 4 Lightning Bolt
|
||||
//!
|
||||
//! Maybeboard
|
||||
//! 1 Some Card
|
||||
//! ```
|
||||
//! `//` lines are comments (`// Format: X` is recognized specially to
|
||||
//! restore the deck's format on import). Section headers are
|
||||
//! case-insensitive; `Sideboard` is accepted as an input alias for
|
||||
//! `Maybeboard` (compatibility with lists copied from other tools), but
|
||||
//! export always emits `Maybeboard`. Cards before any header, or under
|
||||
//! `Deck`, are mainboard. Quantities accept a bare integer (`4 Foo`) or an
|
||||
//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped,
|
||||
//! never a hard error — see [`parse_deck_text`].
|
||||
//!
|
||||
//! Library export format is the same comment/quantity-name line convention
|
||||
//! with no sections — see [`parse_library_text`].
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{Deck, DeckEntry};
|
||||
use crate::Catalog;
|
||||
|
||||
/// One `<quantity> <name>` line from a decklist, prior to name resolution.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DecklistLine {
|
||||
pub quantity: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The structured result of parsing deck-export text.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ParsedDeckText {
|
||||
/// From a `// Format: X` comment, if present.
|
||||
pub format: Option<String>,
|
||||
/// Commander section lines. Normally 0 or 1 — see
|
||||
/// [`Catalog::import_deck`] for how extra lines are handled.
|
||||
pub commander: Vec<DecklistLine>,
|
||||
pub mainboard: Vec<DecklistLine>,
|
||||
pub maybeboard: Vec<DecklistLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Section {
|
||||
Mainboard,
|
||||
Commander,
|
||||
Maybeboard,
|
||||
}
|
||||
|
||||
/// Parse deck-export text into structured lines. Lenient: blank lines are
|
||||
/// skipped, `//` lines are comments, section headers are case-insensitive,
|
||||
/// and lines that don't parse as `<qty> <name>` are silently skipped rather
|
||||
/// than erroring the whole import.
|
||||
pub fn parse_deck_text(text: &str) -> ParsedDeckText {
|
||||
let mut parsed = ParsedDeckText::default();
|
||||
let mut section = Section::Mainboard;
|
||||
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("//") {
|
||||
let rest = rest.trim();
|
||||
if let Some(fmt) = rest
|
||||
.strip_prefix("Format:")
|
||||
.or_else(|| rest.strip_prefix("format:"))
|
||||
{
|
||||
parsed.format = Some(fmt.trim().to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match line.to_lowercase().as_str() {
|
||||
"commander" => {
|
||||
section = Section::Commander;
|
||||
continue;
|
||||
}
|
||||
"deck" => {
|
||||
section = Section::Mainboard;
|
||||
continue;
|
||||
}
|
||||
"maybeboard" | "sideboard" => {
|
||||
section = Section::Maybeboard;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(card_line) = parse_card_line(line) {
|
||||
match section {
|
||||
Section::Mainboard => parsed.mainboard.push(card_line),
|
||||
Section::Commander => parsed.commander.push(card_line),
|
||||
Section::Maybeboard => parsed.maybeboard.push(card_line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed
|
||||
}
|
||||
|
||||
/// Parse a single `<qty> <name>` line. Accepts a bare integer quantity
|
||||
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`).
|
||||
/// Returns `None` (skip, don't error) if the line doesn't start with a
|
||||
/// recognizable positive quantity or has no name after it.
|
||||
fn parse_card_line(line: &str) -> Option<DecklistLine> {
|
||||
let (head, rest) = line.split_once(char::is_whitespace)?;
|
||||
let head_lower = head.to_lowercase();
|
||||
let qty_str = head_lower
|
||||
.strip_suffix('x')
|
||||
.or_else(|| head_lower.strip_suffix('×'))
|
||||
.unwrap_or(&head_lower);
|
||||
let quantity: i64 = qty_str.parse().ok()?;
|
||||
if quantity <= 0 {
|
||||
return None;
|
||||
}
|
||||
let name = rest.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DecklistLine {
|
||||
quantity,
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse library-export text into `<qty> <name>` lines (no sections). Same
|
||||
/// leniency rules as [`parse_deck_text`].
|
||||
pub fn parse_library_text(text: &str) -> Vec<DecklistLine> {
|
||||
text.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
.filter_map(parse_card_line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render a deck (metadata + contents) as export text. Empty sections (no
|
||||
/// commander, no maybeboard entries) are omitted entirely. The
|
||||
/// `// Format: X` comment is emitted only when `deck.format` is non-empty.
|
||||
pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
|
||||
let mut out = String::from("// mtgc deck export\n");
|
||||
if !deck.format.is_empty() {
|
||||
out.push_str(&format!("// Format: {}\n", deck.format));
|
||||
}
|
||||
|
||||
let commander: Vec<_> = contents.iter().filter(|e| e.is_commander).collect();
|
||||
let maybeboard: Vec<_> = contents.iter().filter(|e| e.is_maybeboard).collect();
|
||||
let mainboard: Vec<_> = contents
|
||||
.iter()
|
||||
.filter(|e| !e.is_commander && !e.is_maybeboard)
|
||||
.collect();
|
||||
|
||||
if !commander.is_empty() {
|
||||
out.push_str("\nCommander\n");
|
||||
for e in &commander {
|
||||
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||
}
|
||||
}
|
||||
if !mainboard.is_empty() {
|
||||
out.push_str("\nDeck\n");
|
||||
for e in &mainboard {
|
||||
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||
}
|
||||
}
|
||||
if !maybeboard.is_empty() {
|
||||
out.push_str("\nMaybeboard\n");
|
||||
for e in &maybeboard {
|
||||
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Render `(name, quantity)` pairs as library-export text.
|
||||
pub fn format_library_text(entries: &[(String, i64)]) -> String {
|
||||
let mut out = String::from("// mtgc library export\n");
|
||||
for (name, qty) in entries {
|
||||
out.push_str(&format!("{qty} {name}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Case-insensitive exact-name lookup against the reference db's
|
||||
/// precomputed `name_lower` column. No fuzzy matching — an unresolved
|
||||
/// name is reported back to the caller rather than guessed at, so a
|
||||
/// typo never silently imports the wrong card.
|
||||
pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"),
|
||||
params![name.trim().to_lowercase()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Import a decklist as a brand-new deck named `name`. Always creates a
|
||||
/// new deck — never merges into an existing one. Best-effort: card
|
||||
/// names that don't resolve are skipped (not a hard failure) and
|
||||
/// collected into the returned list, alongside the created `Deck`.
|
||||
///
|
||||
/// Only the first resolved Commander-section line becomes the actual
|
||||
/// commander; any further Commander-section lines are folded into the
|
||||
/// mainboard at their given quantity rather than dropped or erroring.
|
||||
pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec<String>)> {
|
||||
let parsed = parse_deck_text(text);
|
||||
let deck = self.create_deck(name, None)?;
|
||||
if let Some(fmt) = &parsed.format {
|
||||
self.set_deck_format(deck.id, fmt)?;
|
||||
}
|
||||
|
||||
let mut unresolved = Vec::new();
|
||||
let mut commander_oracle_id: Option<String> = None;
|
||||
|
||||
for (i, line) in parsed.commander.iter().enumerate() {
|
||||
match self.find_oracle_id_by_name(&line.name)? {
|
||||
Some(oid) => {
|
||||
let is_first = i == 0;
|
||||
self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?;
|
||||
if is_first {
|
||||
commander_oracle_id = Some(oid);
|
||||
}
|
||||
}
|
||||
None => unresolved.push(line.name.clone()),
|
||||
}
|
||||
}
|
||||
if let Some(oid) = &commander_oracle_id {
|
||||
self.set_deck_commander(deck.id, Some(oid))?;
|
||||
}
|
||||
|
||||
for line in &parsed.mainboard {
|
||||
match self.find_oracle_id_by_name(&line.name)? {
|
||||
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?,
|
||||
None => unresolved.push(line.name.clone()),
|
||||
}
|
||||
}
|
||||
for line in &parsed.maybeboard {
|
||||
match self.find_oracle_id_by_name(&line.name)? {
|
||||
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?,
|
||||
None => unresolved.push(line.name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let deck = self
|
||||
.get_deck(deck.id)?
|
||||
.expect("just-imported deck must exist");
|
||||
Ok((deck, unresolved))
|
||||
}
|
||||
|
||||
/// Render a deck's current contents as export text.
|
||||
pub fn export_deck_text(&self, deck_id: i64) -> Result<String> {
|
||||
let deck = self
|
||||
.get_deck(deck_id)?
|
||||
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?;
|
||||
let contents = self.deck_contents(deck_id)?;
|
||||
Ok(format_deck_text(&deck, &contents))
|
||||
}
|
||||
|
||||
/// Import a plain-text library list, adding each resolved card's
|
||||
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
|
||||
/// Returns `(name, new_total_quantity_owned)` per applied line, plus
|
||||
/// unresolved names.
|
||||
pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec<String>)> {
|
||||
let lines = parse_library_text(text);
|
||||
let mut added = Vec::new();
|
||||
let mut unresolved = Vec::new();
|
||||
for line in &lines {
|
||||
match self.find_oracle_id_by_name(&line.name)? {
|
||||
Some(oid) => {
|
||||
let new_qty = self.add_owned(&oid, line.quantity)?;
|
||||
added.push((line.name.clone(), new_qty));
|
||||
}
|
||||
None => unresolved.push(line.name.clone()),
|
||||
}
|
||||
}
|
||||
Ok((added, unresolved))
|
||||
}
|
||||
|
||||
/// Render the given owned cards (name + current quantity_owned) as
|
||||
/// library-export text.
|
||||
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
|
||||
let mut entries = Vec::with_capacity(oracle_ids.len());
|
||||
for oid in oracle_ids {
|
||||
let row: Option<(String, i64)> = self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT oc.name, lc.quantity_owned FROM {REF_DB}.oracle_cards oc
|
||||
JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||
WHERE oc.oracle_id = ?1"
|
||||
),
|
||||
params![oid],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(entry) = row {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
|
||||
Ok(format_library_text(&entries))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support;
|
||||
|
||||
#[test]
|
||||
fn parse_card_line_accepts_bare_and_x_prefixed_quantities() {
|
||||
assert_eq!(
|
||||
parse_card_line("4 Lightning Bolt"),
|
||||
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||
);
|
||||
assert_eq!(
|
||||
parse_card_line("4x Lightning Bolt"),
|
||||
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||
);
|
||||
assert_eq!(
|
||||
parse_card_line("4× Lightning Bolt"),
|
||||
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||
);
|
||||
assert_eq!(parse_card_line("not a card line"), None);
|
||||
assert_eq!(parse_card_line("0 Lightning Bolt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_deck_text_tolerates_blanks_comments_and_sideboard_alias() {
|
||||
let text = "// mtgc deck export\n// Format: Commander\n\nCommander\n1 Krenko, Mob Boss\n\nDeck\n1 Sol Ring\n\nSideboard\n1 Some Card\n";
|
||||
let parsed = parse_deck_text(text);
|
||||
assert_eq!(parsed.format.as_deref(), Some("Commander"));
|
||||
assert_eq!(
|
||||
parsed.commander,
|
||||
vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }]
|
||||
);
|
||||
assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]);
|
||||
assert_eq!(
|
||||
parsed.maybeboard,
|
||||
vec![DecklistLine { quantity: 1, name: "Some Card".into() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_deck_text_defaults_unheaded_lines_to_mainboard() {
|
||||
let parsed = parse_deck_text("4 Lightning Bolt\n1 Sol Ring\n");
|
||||
assert_eq!(parsed.mainboard.len(), 2);
|
||||
assert!(parsed.commander.is_empty());
|
||||
assert!(parsed.maybeboard.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_deck_text_omits_empty_sections() {
|
||||
let deck = Deck {
|
||||
id: 1,
|
||||
name: "Test".into(),
|
||||
format: String::new(),
|
||||
commander_oracle_id: None,
|
||||
notes: String::new(),
|
||||
};
|
||||
let text = format_deck_text(&deck, &[]);
|
||||
assert_eq!(text, "// mtgc deck export\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_deck_resolves_names_sets_format_and_commander_and_reports_unresolved() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let text = "// Format: Commander\n\nCommander\n1 Serra Angel\n\nDeck\n4 Lightning Bolt\n1 Totally Fake Card\n\nMaybeboard\n1 Sol Ring\n";
|
||||
|
||||
let (deck, unresolved) = cat.import_deck("Imported", text).unwrap();
|
||||
assert_eq!(deck.format, "Commander");
|
||||
assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel"));
|
||||
assert_eq!(unresolved, vec!["Totally Fake Card".to_string()]);
|
||||
|
||||
let contents = cat.deck_contents(deck.id).unwrap();
|
||||
let bolt = contents.iter().find(|e| e.card.oracle_id == "lightning-bolt").unwrap();
|
||||
assert_eq!(bolt.quantity, 4);
|
||||
assert!(!bolt.is_commander);
|
||||
assert!(!bolt.is_maybeboard);
|
||||
|
||||
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||
assert!(sol.is_maybeboard);
|
||||
|
||||
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
|
||||
assert!(angel.is_commander);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_deck_extra_commander_lines_fold_into_mainboard() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let text = "Commander\n1 Serra Angel\n1 Sol Ring\n";
|
||||
let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap();
|
||||
assert!(unresolved.is_empty());
|
||||
|
||||
let contents = cat.deck_contents(deck.id).unwrap();
|
||||
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
|
||||
assert!(angel.is_commander);
|
||||
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||
assert!(!sol.is_commander);
|
||||
assert!(!sol.is_maybeboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_deck_text_round_trips_through_parser() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let deck = cat.create_deck("Round Trip", Some("serra-angel")).unwrap();
|
||||
cat.set_deck_format(deck.id, "Commander").unwrap();
|
||||
cat.set_deck_card(deck.id, "serra-angel", 1, true, false).unwrap();
|
||||
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
|
||||
cat.set_deck_card(deck.id, "sol-ring", 1, false, true).unwrap();
|
||||
|
||||
let text = cat.export_deck_text(deck.id).unwrap();
|
||||
let parsed = parse_deck_text(&text);
|
||||
assert_eq!(parsed.format.as_deref(), Some("Commander"));
|
||||
assert_eq!(
|
||||
parsed.commander,
|
||||
vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }]
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.mainboard,
|
||||
vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }]
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.maybeboard,
|
||||
vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_library_list_adds_to_existing_ownership_and_reports_unresolved() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("lightning-bolt", 2).unwrap();
|
||||
|
||||
let text = "2 Lightning Bolt\n1 Sol Ring\n1 Not A Real Card\n";
|
||||
let (added, unresolved) = cat.import_library_list(text).unwrap();
|
||||
|
||||
assert_eq!(unresolved, vec!["Not A Real Card".to_string()]);
|
||||
let bolt_added = added.iter().find(|(n, _)| n == "Lightning Bolt").unwrap();
|
||||
assert_eq!(bolt_added.1, 4); // 2 existing + 2 imported
|
||||
let sol_added = added.iter().find(|(n, _)| n == "Sol Ring").unwrap();
|
||||
assert_eq!(sol_added.1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_library_text_formats_only_requested_cards() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("lightning-bolt", 3).unwrap();
|
||||
cat.add_owned("sol-ring", 1).unwrap();
|
||||
|
||||
let text = cat.export_library_text(&["lightning-bolt".to_string()]).unwrap();
|
||||
assert!(text.contains("3 Lightning Bolt"));
|
||||
assert!(!text.contains("Sol Ring"));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
|
||||
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
|
||||
use crate::Catalog;
|
||||
|
||||
impl Catalog {
|
||||
@@ -114,8 +114,8 @@ impl Catalog {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
||||
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price},
|
||||
dc.is_maybeboard
|
||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||
oc.legalities, {price}, dc.is_maybeboard
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||
@@ -142,9 +142,10 @@ impl Catalog {
|
||||
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
||||
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
||||
produces_colorless: r.get::<_, i64>(12)? != 0,
|
||||
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
||||
legalities: Legalities(r.get::<_, i64>(13)?),
|
||||
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
|
||||
},
|
||||
is_maybeboard: r.get::<_, i64>(14)? != 0,
|
||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -236,6 +237,33 @@ mod tests {
|
||||
assert!(!row.is_over_assigned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legalities_are_plumbed_through_deck_contents() {
|
||||
use crate::model::LegalStatus;
|
||||
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
// 2 (Legal) in the "commander" slot (bit offset 12, per LEGALITY_FORMATS).
|
||||
cat.connection()
|
||||
.execute(
|
||||
"UPDATE ref.oracle_cards SET legalities = 4096 WHERE oracle_id = 'sol-ring'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let d = cat.create_deck("Legality Check", None).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
|
||||
|
||||
let contents = cat.deck_contents(d.id).unwrap();
|
||||
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.card.legalities.status_for("Commander"), LegalStatus::Legal);
|
||||
|
||||
// Fixture cards default to no legality data at all.
|
||||
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
|
||||
assert_eq!(elves.card.legalities.status_for("Commander"), LegalStatus::NotLegal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_assignment_detected_across_decks() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -19,7 +19,7 @@ impl Catalog {
|
||||
let mut sql = format!(
|
||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||
oc.produced_mana, oc.produces_colorless, {price}
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||
FROM library_cards lc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||
WHERE lc.quantity_owned > 0",
|
||||
|
||||
@@ -19,7 +19,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::db::open_reference_for_build;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::ColorSet;
|
||||
use crate::model::{ColorSet, Legalities};
|
||||
|
||||
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
|
||||
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
|
||||
@@ -434,6 +434,10 @@ struct ScryOracle {
|
||||
/// top-level-only Scryfall field — not per-face, even on DFCs.
|
||||
#[serde(default)]
|
||||
produced_mana: Option<Vec<String>>,
|
||||
/// Format-slug -> status-string (e.g. "legal"/"not_legal"/"banned"/
|
||||
/// "restricted"). See [`crate::model::Legalities`].
|
||||
#[serde(default)]
|
||||
legalities: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -453,8 +457,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
let mut ins_card = conn.prepare(
|
||||
"INSERT OR IGNORE INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||
produced_mana,produces_colorless)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
produced_mana,produces_colorless,legalities)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
@@ -533,6 +537,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
let produced_mana = ColorSet::from_letters(produced);
|
||||
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
|
||||
|
||||
let legalities = Legalities::from_scryfall(&c.legalities);
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
c.name,
|
||||
@@ -544,6 +550,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
oracle_text,
|
||||
produced_mana.0 as i64,
|
||||
produces_colorless as i64,
|
||||
legalities.0,
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
@@ -922,6 +929,48 @@ mod tests {
|
||||
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_legalities() {
|
||||
let oracle = r#"[
|
||||
{"oracle_id":"oid-banned","name":"Balance","cmc":1.0,"color_identity":["W"],"keywords":[],
|
||||
"legalities":{"standard":"not_legal","modern":"banned","legacy":"banned","vintage":"restricted","commander":"banned"}},
|
||||
{"oracle_id":"oid-legal","name":"Lightning Bolt","cmc":1.0,"color_identity":["R"],"keywords":[],
|
||||
"legalities":{"modern":"legal","legacy":"legal","vintage":"legal","pauper":"legal"}},
|
||||
{"oracle_id":"oid-nolegal","name":"Some Test Card","cmc":1.0,"color_identity":[],"keywords":[]}
|
||||
]"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("reference.db");
|
||||
let conn = open_reference_for_build(&dest).unwrap();
|
||||
|
||||
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
|
||||
|
||||
let fetch = |id: &str| -> Legalities {
|
||||
Legalities(
|
||||
conn.query_row(
|
||||
"SELECT legalities FROM oracle_cards WHERE oracle_id=?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
let balance = fetch("oid-banned");
|
||||
assert_eq!(balance.status_for("Vintage"), crate::model::LegalStatus::Restricted);
|
||||
assert_eq!(balance.status_for("Modern"), crate::model::LegalStatus::Banned);
|
||||
assert_eq!(balance.status_for("Standard"), crate::model::LegalStatus::NotLegal);
|
||||
|
||||
let bolt = fetch("oid-legal");
|
||||
assert_eq!(bolt.status_for("Modern"), crate::model::LegalStatus::Legal);
|
||||
assert_eq!(bolt.status_for("Standard"), crate::model::LegalStatus::NotLegal); // absent key
|
||||
|
||||
let vanilla = fetch("oid-nolegal");
|
||||
for (_, label) in crate::model::LEGALITY_FORMATS {
|
||||
assert_eq!(vanilla.status_for(label), crate::model::LegalStatus::NotLegal); // no legalities object at all
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_back_face_image_for_double_faced_cards() {
|
||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! unit-testable and reusable when the app is ported to mobile.
|
||||
|
||||
pub mod db;
|
||||
pub mod decklist;
|
||||
pub mod decks;
|
||||
pub mod error;
|
||||
pub mod filters;
|
||||
@@ -20,7 +21,8 @@ use rusqlite::Connection;
|
||||
|
||||
pub use error::{CoreError, Result};
|
||||
pub use model::{
|
||||
CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats,
|
||||
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets,
|
||||
LibraryFilter, LibraryStats,
|
||||
};
|
||||
pub use search::NameIndex;
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
|
||||
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, price_usd)` into its library parts. Shared by
|
||||
/// [`Catalog::list_library`] and the criteria search.
|
||||
/// produces_colorless, legalities, price_usd)` into its library parts. Shared
|
||||
/// by [`Catalog::list_library`] and the criteria search.
|
||||
pub(crate) fn row_to_library_parts(
|
||||
r: &rusqlite::Row,
|
||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
||||
@@ -32,7 +32,8 @@ pub(crate) fn row_to_library_parts(
|
||||
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
|
||||
produces_colorless: r.get::<_, i64>(11)? != 0,
|
||||
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
|
||||
legalities: Legalities(r.get::<_, i64>(12)?),
|
||||
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -75,7 +76,7 @@ impl Catalog {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||
oc.produced_mana, oc.produces_colorless, {price}
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||
FROM library_cards lc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||
WHERE lc.quantity_owned > 0
|
||||
|
||||
@@ -75,6 +75,73 @@ impl ColorSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8 formats this app tracks legality for, in a fixed order that defines
|
||||
/// their bit position within [`Legalities`] (2 bits each, in order). This
|
||||
/// order must never change once shipped — it's baked into every ingested
|
||||
/// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the
|
||||
/// display label used throughout the app (matches DeckView's
|
||||
/// `FORMATS`/`COMMANDER_FORMATS`).
|
||||
pub const LEGALITY_FORMATS: [(&str, &str); 8] = [
|
||||
("standard", "Standard"),
|
||||
("pioneer", "Pioneer"),
|
||||
("modern", "Modern"),
|
||||
("legacy", "Legacy"),
|
||||
("vintage", "Vintage"),
|
||||
("pauper", "Pauper"),
|
||||
("commander", "Commander"),
|
||||
("brawl", "Brawl"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegalStatus {
|
||||
NotLegal,
|
||||
Legal,
|
||||
Banned,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
/// Per-format legality, encoded as 2 bits/format x 8 formats packed into one
|
||||
/// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors
|
||||
/// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of
|
||||
/// 1-bit flags, since each format has 4 possible statuses (not-legal / legal
|
||||
/// / banned / restricted), not just a binary flag.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Legalities(pub i64);
|
||||
|
||||
impl Legalities {
|
||||
/// Build from Scryfall's `legalities` object (format-slug -> status-string
|
||||
/// map). Any format not present in `map`, or present with an unrecognized
|
||||
/// status string, decodes as `NotLegal`.
|
||||
pub fn from_scryfall(map: &std::collections::HashMap<String, String>) -> Self {
|
||||
let mut bits = 0i64;
|
||||
for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() {
|
||||
let status: i64 = match map.get(*key).map(String::as_str) {
|
||||
Some("legal") => 1,
|
||||
Some("banned") => 2,
|
||||
Some("restricted") => 3,
|
||||
_ => 0, // "not_legal", or key missing entirely
|
||||
};
|
||||
bits |= status << (i * 2);
|
||||
}
|
||||
Legalities(bits)
|
||||
}
|
||||
|
||||
/// Decode the status for a given format's display label (e.g.
|
||||
/// "Commander", "Modern" — matches DeckView's `FORMATS` strings).
|
||||
/// Unknown labels decode as `NotLegal`.
|
||||
pub fn status_for(self, format_label: &str) -> LegalStatus {
|
||||
let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else {
|
||||
return LegalStatus::NotLegal;
|
||||
};
|
||||
match (self.0 >> (i * 2)) & 0b11 {
|
||||
1 => LegalStatus::Legal,
|
||||
2 => LegalStatus::Banned,
|
||||
3 => LegalStatus::Restricted,
|
||||
_ => LegalStatus::NotLegal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A card as returned by search / library queries. Oracle-level (one per
|
||||
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -102,6 +169,10 @@ pub struct CardRef {
|
||||
/// USD market price (Scryfall's `prices.usd`) for the same printing that
|
||||
/// supplied `image_uri`, or 0 if unknown.
|
||||
pub price_usd: f64,
|
||||
/// Per-format legality (banned/restricted/legal). See
|
||||
/// [`LEGALITY_FORMATS`] for the bit-position order and
|
||||
/// [`Legalities::status_for`] to decode a specific format.
|
||||
pub legalities: Legalities,
|
||||
}
|
||||
|
||||
/// A library row: a card the user owns, with quantity and its deck memberships.
|
||||
@@ -212,3 +283,38 @@ pub struct LibraryFilter {
|
||||
/// Case-insensitive substring match against the card's oracle rules text.
|
||||
pub oracle_text: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn legalities_round_trip_all_statuses() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("standard".to_string(), "legal".to_string());
|
||||
map.insert("modern".to_string(), "banned".to_string());
|
||||
map.insert("vintage".to_string(), "restricted".to_string());
|
||||
// "pauper" deliberately absent
|
||||
|
||||
let legalities = Legalities::from_scryfall(&map);
|
||||
|
||||
assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal);
|
||||
assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned);
|
||||
assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted);
|
||||
assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legalities_unknown_format_label_is_not_legal() {
|
||||
let legalities = Legalities::from_scryfall(&HashMap::new());
|
||||
assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legalities_default_is_all_not_legal() {
|
||||
let legalities = Legalities::default();
|
||||
assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal);
|
||||
assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use rusqlite::Connection;
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::model::{CardRef, ColorSet};
|
||||
use crate::model::{CardRef, ColorSet, Legalities};
|
||||
|
||||
/// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra
|
||||
/// DB round-trip. `AsRef<str>` yields the name that nucleo matches against.
|
||||
@@ -34,7 +34,8 @@ impl NameIndex {
|
||||
pub fn build(conn: &Connection) -> Result<Self> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price}
|
||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||
oc.legalities, {price}
|
||||
FROM {REF_DB}.oracle_cards oc",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
@@ -54,7 +55,8 @@ impl NameIndex {
|
||||
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
|
||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
|
||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
type_line TEXT NOT NULL DEFAULT '',
|
||||
oracle_text TEXT NOT NULL DEFAULT '',
|
||||
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
|
||||
produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C}
|
||||
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
||||
legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||
|
||||
Reference in New Issue
Block a user