diff --git a/crates/mtgc-core/src/copy_limit.rs b/crates/mtgc-core/src/copy_limit.rs new file mode 100644 index 0000000..57db5f0 --- /dev/null +++ b/crates/mtgc-core/src/copy_limit.rs @@ -0,0 +1,59 @@ +//! Per-card exceptions to a format's copy limit, read from oracle text. +//! +//! A handful of cards override the usual "one copy" (Commander) or "four +//! copies" (60-card formats) rule: "A deck can have any number of cards named +//! Relentless Rats.", "…up to nine cards named Nazgûl.", or the stricter +//! "…only one card named Once More with Feeling.". Parsed once at ingest into +//! `oracle_cards.copy_limit`; the frontend's legality check applies it. + +/// The card's own copy limit: `Some(0)` for any number, `Some(n)` for at most +/// `n`, `None` when the format's rule applies. +pub fn parse(oracle_text: &str) -> Option { + let text = oracle_text.to_lowercase(); + let rest = &text[text.find("deck can have ")? + "deck can have ".len()..]; + if rest.starts_with("any number of cards named") { + return Some(0); + } + if rest.starts_with("only one card named") || rest.starts_with("only one copy of this card") { + return Some(1); + } + let rest = rest.strip_prefix("up to ")?; + let (count, rest) = rest.split_once(' ')?; + if !rest.starts_with("cards named") { + return None; + } + count.parse().ok().or_else(|| number_word(count)) +} + +fn number_word(word: &str) -> Option { + const WORDS: [&str; 20] = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", + "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty", + ]; + WORDS.iter().position(|w| *w == word).map(|i| i as i64 + 1) +} + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn reads_each_real_wording() { + assert_eq!(parse("A deck can have any number of cards named Relentless Rats.\nRelentless Rats gets +1/+1 for each other creature on the battlefield named Relentless Rats."), Some(0)); + assert_eq!(parse("Cycling {W}{U}\nA deck can have any number of cards named Cid, Timeless Artificer."), Some(0)); + assert_eq!(parse("Menace\nA deck can have up to nine cards named Nazgûl."), Some(9)); + assert_eq!(parse("A deck can have up to seven cards named Seven Dwarves."), Some(7)); + assert_eq!(parse("A deck can have up to 12 cards named Test Card."), Some(12)); + assert_eq!(parse("A deck can have only one card named Once More with Feeling."), Some(1)); + assert_eq!(parse("Megalegendary (Your deck can have only one copy of this card.)"), Some(1)); + } + + #[test] + fn ignores_cards_without_an_exception() { + assert_eq!(parse("Flying"), None); + assert_eq!(parse(""), None); + // Mentions the phrase, but not as a copy limit. + assert_eq!(parse("Your deck can have cards of any color identity."), None); + assert_eq!(parse("A deck can have up to nine creatures."), None); + } +} diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index fd39f05..e0e3206 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -159,7 +159,8 @@ 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, dc.quantity_pulled, COALESCE(lc.location, '') + oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, ''), + {copy_limit} 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 @@ -170,7 +171,8 @@ impl Catalog { image = self.image_expr("image_uri"), back = self.image_expr("back_image_uri"), price = crate::db::price_subquery(), - horizontal = self.horizontal_col() + horizontal = self.horizontal_col(), + copy_limit = self.copy_limit_col() ))?; let rows = stmt .query_map(rusqlite::params_from_iter(deck_id), |r| { @@ -200,6 +202,7 @@ impl Catalog { partner, quantity_pulled: r.get(20)?, location: r.get(21)?, + copy_limit: r.get(22)?, quantity_spare: 0, quantity_in_box: 0, claimed_by: Vec::new(), @@ -955,4 +958,21 @@ mod tests { assert_eq!(copy.status, DeckStatus::Idea); assert_eq!(cat.deck_contents(copy.id).unwrap()[0].quantity_pulled, 0); } + + #[test] + fn deck_entries_carry_the_cards_copy_limit() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.connection() + .execute("UPDATE ref.oracle_cards SET copy_limit = 0 WHERE oracle_id = 'llanowar-elves'", []) + .unwrap(); + let d = cat.create_deck("Rats", None).unwrap(); + cat.set_deck_card(d.id, "llanowar-elves", 20, false, false).unwrap(); + cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); + + let contents = cat.deck_contents(d.id).unwrap(); + let limit = |oid: &str| contents.iter().find(|e| e.card.oracle_id == oid).unwrap().copy_limit; + assert_eq!(limit("llanowar-elves"), Some(0)); + assert_eq!(limit("sol-ring"), None); + } } diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index a0b2762..8ee4038 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -1098,6 +1098,8 @@ mod tests { cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap(); let contents = cat.deck_contents(deck.id).unwrap(); assert!(!contents[0].card.horizontal); + // Nor `copy_limit`, which shipped later still: no card has an exception. + assert_eq!(contents[0].copy_limit, None); let index = cat.build_name_index().unwrap(); let found = index.search("Sol Ring", 5); diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 3fcffb4..38a994b 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -477,8 +477,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) "INSERT OR IGNORE INTO oracle_cards (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text, produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal, - image_uri,back_image_uri,default_scryfall_id,power,toughness,vanilla) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)", + image_uri,back_image_uri,default_scryfall_id,power,toughness,vanilla,copy_limit) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; @@ -649,6 +649,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) power, toughness, vanilla as i64, + crate::copy_limit::parse(&oracle_text), ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { @@ -1284,6 +1285,32 @@ mod tests { assert_eq!(van_rank, None); } + #[test] + fn captures_copy_limit_exceptions() { + let oracle = r#"[ + {"oracle_id":"oid-rats","name":"Relentless Rats","layout":"normal","cmc":3.0, + "type_line":"Creature — Rat","color_identity":["B"],"keywords":[], + "oracle_text":"Relentless Rats gets +1/+1 for each other creature on the battlefield named Relentless Rats.\nA deck can have any number of cards named Relentless Rats."}, + {"oracle_id":"oid-nazgul","name":"Nazgûl","layout":"normal","cmc":3.0, + "type_line":"Creature — Wraith Knight","color_identity":["B"],"keywords":[], + "oracle_text":"Deathtouch\nA deck can have up to nine cards named Nazgûl."}, + {"oracle_id":"oid-bears","name":"Grizzly Bears","layout":"normal","cmc":2.0, + "type_line":"Creature — Bear","color_identity":["G"],"keywords":[]} + ]"#; + + let dir = tempfile::tempdir().unwrap(); + let conn = open_reference_for_build(&dir.path().join("reference.db")).unwrap(); + load_oracle_cards(&conn, jsonl(oracle).as_bytes(), &CancelToken::new()).unwrap(); + + let limit = |id: &str| -> Option { + conn.query_row("SELECT copy_limit FROM oracle_cards WHERE oracle_id=?1", params![id], |r| r.get(0)) + .unwrap() + }; + assert_eq!(limit("oid-rats"), Some(0)); + assert_eq!(limit("oid-nazgul"), Some(9)); + assert_eq!(limit("oid-bears"), None); + } + #[test] fn captures_horizontal_flag_for_battle_and_planar_layouts_only() { let oracle = r#"[ diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 3870fc8..4a7657a 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -5,6 +5,7 @@ //! Scryfall ingester. It has no Tauri (or any GUI) dependency so it stays //! unit-testable and reusable when the app is ported to mobile. +pub mod copy_limit; pub mod db; pub mod decklist; pub mod decks; @@ -63,6 +64,8 @@ pub struct Catalog { /// printing picker's labels and ordering. Another later reference-schema /// revision; without it the picker still works, just less informatively. pub(crate) has_printing_details: bool, + /// Whether `oracle_cards.copy_limit` exists — see [`Catalog::copy_limit_col`]. + pub(crate) has_copy_limit: bool, /// What a re-ingest would add to the attached reference db — see /// [`db::missing_reference_columns`]. Cached at open time; the reference db /// only changes by being replaced, which reopens the catalog. @@ -80,6 +83,7 @@ impl Catalog { db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?; let has_vanilla = db::has_reference_column(&conn, "oracle_cards", "vanilla")?; let has_printing_details = db::has_reference_column(&conn, "printings", "released_at")?; + let has_copy_limit = db::has_reference_column(&conn, "oracle_cards", "copy_limit")?; let missing_reference_columns = db::missing_reference_columns(&conn)?; Ok(Self { conn, @@ -88,6 +92,7 @@ impl Catalog { has_universes_beyond, has_vanilla, has_printing_details, + has_copy_limit, missing_reference_columns, }) } @@ -107,6 +112,17 @@ impl Catalog { } } + /// SQL expression for `oracle_cards.copy_limit`, for a `SELECT` aliased + /// `oc`. An older reference.db without it reads as `NULL` — no card has + /// an exception — until the card data is re-imported. + pub(crate) fn copy_limit_col(&self) -> &'static str { + if self.has_copy_limit { + "oc.copy_limit" + } else { + "NULL" + } + } + /// SQL expression selecting `column` ("image_uri" or "back_image_uri") /// for the oracle card aliased `oc`. See [`db::image_expr`]. pub(crate) fn image_expr(&self, column: &str) -> String { diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 497e7ff..803c069 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -242,6 +242,10 @@ pub struct DeckEntry { /// Where the card's library copies are stored (e.g. "Box 1"). #[serde(default)] pub location: String, + /// The card's own exception to the format's copy limit: `None` when the + /// format's rule applies, 0 for any number, else the most copies allowed. + #[serde(default)] + pub copy_limit: Option, } /// A building/built deck's claim on a card's copies. diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 2caf1b9..3d7c061 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -50,7 +50,11 @@ CREATE TABLE IF NOT EXISTS oracle_cards ( -- every face together — an Adventure or Prepared card's creature half can be -- vanilla while the column still holds the attached spell's text, so the -- property isn't recoverable from this table alone. - vanilla INTEGER NOT NULL DEFAULT 0 + vanilla INTEGER NOT NULL DEFAULT 0, + -- The card's own exception to the format's copy limit ("A deck can have + -- any number of cards named …"): NULL when the format's rule applies, 0 + -- for any number, else the most copies allowed. See `copy_limit::parse`. + copy_limit INTEGER ); 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); diff --git a/src/api.ts b/src/api.ts index 8bc759a..4e9cf29 100644 --- a/src/api.ts +++ b/src/api.ts @@ -62,6 +62,10 @@ export interface DeckEntry extends CardRef { claimed_by: DeckHolding[]; /** Where the card's library copies are stored ("" if unset). */ location: string; + /** The card's own exception to the format's copy limit ("A deck can have + * any number of cards named …"): null when the format's rule applies, 0 + * for any number, else the most copies allowed. */ + copy_limit: number | null; } export interface DeckHolding { diff --git a/src/deckAnalysis.ts b/src/deckAnalysis.ts index 2503fa8..b9b8ef1 100644 --- a/src/deckAnalysis.ts +++ b/src/deckAnalysis.ts @@ -237,6 +237,29 @@ export const COMMANDER_DECK_SIZE = 100; export const CONSTRUCTED_MIN_DECK_SIZE = 60; export const CONSTRUCTED_MAX_COPIES = 4; +/** A basic land by supertype, so snow basics ("Basic Snow Land — Forest") + * and Wastes count too. */ +function isBasicLand(e: DeckEntry): boolean { + const supertypes = e.type_line.split(" // ")[0].split("—")[0].split(/\s+/); + return supertypes.includes("Basic") && supertypes.includes("Land"); +} + +/** + * Most copies of a card a deck may have: no limit for basic lands and for + * cards that say "a deck can have any number of cards named …", the card's + * own limit when it states one ("up to nine", "only one"), else the format's. + */ +function copyLimit(e: DeckEntry, formatLimit: number): number { + if (isBasicLand(e) || e.copy_limit === 0) return Infinity; + return e.copy_limit ?? formatLimit; +} + +function copiesMessage(e: DeckEntry, limit: number, formatRule: string): string { + const own = limit === 1 ? `only one card named ${e.name}` : `at most ${limit} cards named ${e.name}`; + const rule = e.copy_limit ? `a deck can have ${own}` : formatRule; + return `${e.name} has ${e.quantity} copies; ${rule}.`; +} + /** * Format-legality check for the deck as a whole: structural deck-building * rules (size, singleton/copy limits, commander color identity) plus @@ -251,13 +274,6 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec const issues: LegalityIssue[] = []; const cards = contents.filter((e) => !e.is_maybeboard); - const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land"); - - // NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners, - // Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the - // singleton/copy-limit rule via oracle text ("a deck can have any number - // of cards named..."). Detecting this needs oracle_text on CardRef, which - // isn't wired through yet — known limitation, not blocking this feature. if (COMMANDER_FORMATS.includes(deck.format)) { // Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is @@ -290,12 +306,13 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec for (const e of cards) { if (e.is_commander) continue; - if (!isBasicLand(e) && e.quantity > 1) { + const limit = copyLimit(e, 1); + if (e.quantity > limit) { issues.push({ category: "copies", - message: `${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`, + message: copiesMessage(e, limit, `${deck.format} decks allow only 1 (except basic lands)`), oracleId: e.oracle_id, - excess: e.quantity - 1, + excess: e.quantity - limit, }); } } @@ -341,12 +358,17 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec } for (const e of cards) { - if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) { + const limit = copyLimit(e, CONSTRUCTED_MAX_COPIES); + if (e.quantity > limit) { issues.push({ category: "copies", - message: `${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`, + message: copiesMessage( + e, + limit, + `${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands)`, + ), oracleId: e.oracle_id, - excess: e.quantity - CONSTRUCTED_MAX_COPIES, + excess: e.quantity - limit, }); } }