Honor cards' own copy limits in deck legality

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:10:23 +00:00
parent 99a579a697
commit ea689b0f2b
9 changed files with 176 additions and 18 deletions

View File

@@ -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<i64> {
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<i64> {
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);
}
}

View File

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

View File

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

View File

@@ -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<i64> {
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#"[

View File

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

View File

@@ -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<i64>,
}
/// A building/built deck's claim on a card's copies.

View File

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

View File

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

View File

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