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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user