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

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