Deck builder: select illegal cards, with legality issues grouped by category

Legality issues now carry a category (deck size, commander, color identity,
too many copies, banned/restricted) and the offending card. The legality
tile groups them and offers per-category and all-issue selection; over-limit
copies select only the extras, so bulk remove trims to the legal count.

Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:44:16 +00:00
parent cf49d2a68d
commit eb342f5fbb
4 changed files with 226 additions and 48 deletions

View File

@@ -188,9 +188,49 @@ export const FORMATS = [
];
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
export type LegalityCategory = "size" | "commander" | "color" | "copies" | "banned";
/** Issue groups in display order. Selectable ones are tied to specific cards,
* so the deck view can select those cards for removal. */
export const LEGALITY_CATEGORIES: { key: LegalityCategory; label: string; selectable: boolean }[] = [
{ key: "size", label: "Deck size", selectable: false },
{ key: "commander", label: "Commander", selectable: false },
{ key: "color", label: "Color identity", selectable: true },
{ key: "copies", label: "Too many copies", selectable: true },
{ key: "banned", label: "Banned / restricted", selectable: true },
];
export interface LegalityIssue {
category: LegalityCategory;
message: string;
/** The offending card, for card-level issues. */
oracleId?: string;
/** Copies over the limit; absent means the whole entry is illegal. */
excess?: number;
}
export interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
issues: LegalityIssue[];
}
/**
* The cards to select to resolve card-level issues (optionally only those in
* `categories`): oracle id → copies to remove, or null for the whole entry.
* A card that is illegal outright wins over one that merely has extra copies.
*/
export function illegalSelection(
issues: LegalityIssue[],
categories?: LegalityCategory[],
): Map<string, number | null> {
const out = new Map<string, number | null>();
for (const i of issues) {
if (!i.oracleId || (categories && !categories.includes(i.category))) continue;
const prev = out.get(i.oracleId);
if (i.excess === undefined || prev === null) out.set(i.oracleId, null);
else out.set(i.oracleId, Math.max(prev ?? 0, i.excess));
}
return out;
}
export const COMMANDER_DECK_SIZE = 100;
@@ -206,10 +246,10 @@ export const CONSTRUCTED_MAX_COPIES = 4;
*/
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
return { status: "unchecked", issues: [] };
}
const reasons: string[] = [];
const issues: LegalityIssue[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
@@ -229,27 +269,34 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
issues.push({
category: "size",
message: `Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
});
}
// Two commanders are allowed when their partner abilities pair them up.
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
issues.push({ category: "commander", message: `No commander is set.` });
} else if (commanders.length > 2) {
reasons.push(`More than two cards are marked as commanders.`);
issues.push({ category: "commander", message: `More than two cards are marked as commanders.` });
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
issues.push({
category: "commander",
message: `${commanders[0].name} and ${commanders[1].name} can't be commanders together.`,
});
}
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
issues.push({
category: "copies",
message: `${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
oracleId: e.oracle_id,
excess: e.quantity - 1,
});
}
}
@@ -260,7 +307,11 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
issues.push({
category: "color",
message: `${e.name}'s color identity is outside the ${whose} color identity.`,
oracleId: e.oracle_id,
});
}
}
}
@@ -268,11 +319,14 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
issues.push({ category: "banned", message: `${e.name} is banned in ${deck.format}.`, oracleId: e.oracle_id });
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
issues.push({
category: "banned",
message: `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
oracleId: e.oracle_id,
excess: e.quantity - 1,
});
}
}
} else {
@@ -280,35 +334,42 @@ export function computeDeckLegality(deck: Deck | null | undefined, contents: Dec
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
issues.push({
category: "size",
message: `Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
});
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
issues.push({
category: "copies",
message: `${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
oracleId: e.oracle_id,
excess: e.quantity - CONSTRUCTED_MAX_COPIES,
});
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
issues.push({ category: "banned", message: `${e.name} is banned in ${deck.format}.`, oracleId: e.oracle_id });
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
issues.push({
category: "banned",
message: `${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
oracleId: e.oracle_id,
excess: e.quantity - 1,
});
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
return { status: issues.length ? "illegal" : "legal", issues };
}