From ea689b0f2b59fa7a7a050435ab0221b7fa812b8f Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Mon, 26 Oct 2026 23:10:23 +0000 Subject: [PATCH] Honor cards' own copy limits in deck legality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards like Relentless Rats (any number), Nazgûl (up to nine), and Once More with Feeling (only one) override the format's copy limit. The limit is parsed from oracle text at card-data import into a new oracle_cards.copy_limit column; older reference dbs fall back to the format rule until re-imported. The legality check and illegal-card selection use it. Also count snow basics as basic lands. Closes #27 Co-Authored-By: Claude Opus 5.5 --- crates/mtgc-core/src/copy_limit.rs | 59 +++++++++++++++++++ crates/mtgc-core/src/decks.rs | 24 +++++++- crates/mtgc-core/src/filters.rs | 2 + crates/mtgc-core/src/ingest.rs | 31 +++++++++- crates/mtgc-core/src/lib.rs | 16 +++++ crates/mtgc-core/src/model.rs | 4 ++ crates/mtgc-core/src/sql/reference_schema.sql | 6 +- src/api.ts | 4 ++ src/deckAnalysis.ts | 48 +++++++++++---- 9 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 crates/mtgc-core/src/copy_limit.rs 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, }); } }