From eb342f5fbb6cd358cabecfae33948176e9d822e0 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Mon, 26 Oct 2026 21:44:16 +0000 Subject: [PATCH] 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 --- src/deckAnalysis.ts | 117 ++++++++++++++++++++++++++++--------- src/styles.css | 23 ++++++++ src/views/DeckList.tsx | 4 +- src/views/DeckView.tsx | 130 +++++++++++++++++++++++++++++++++++------ 4 files changed, 226 insertions(+), 48 deletions(-) diff --git a/src/deckAnalysis.ts b/src/deckAnalysis.ts index aed4b31..138750a 100644 --- a/src/deckAnalysis.ts +++ b/src/deckAnalysis.ts @@ -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 { + const out = new Map(); + 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 }; } diff --git a/src/styles.css b/src/styles.css index 80115b4..a94e834 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1066,6 +1066,29 @@ h4 { font-size: 12px; color: var(--muted); } +.legality-tile-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.legality-tile-group-head { + display: flex; + align-items: center; + gap: 8px; + margin-left: 54px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +button.legality-tile-select { + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + text-transform: none; + letter-spacing: normal; +} .legality-tile-reasons { margin: 0 0 0 54px; padding-left: 18px; diff --git a/src/views/DeckList.tsx b/src/views/DeckList.tsx index d21d581..fa57ba2 100644 --- a/src/views/DeckList.tsx +++ b/src/views/DeckList.tsx @@ -72,8 +72,8 @@ function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact? // An empty deck is "illegal" only in the sense that it isn't built yet. if (s.legality.status === "unchecked" || s.size === 0) return null; const legal = s.legality.status === "legal"; - const n = s.legality.reasons.length; - const title = legal ? "Meets the format's deck-building rules" : s.legality.reasons.join("\n"); + const n = s.legality.issues.length; + const title = legal ? "Meets the format's deck-building rules" : s.legality.issues.map((i) => i.message).join("\n"); return ( {legal ? "✓" : "⚠"} diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index c6e3437..d6ace85 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -35,6 +35,9 @@ import { computeDeckStats, formatCost, FORMATS, + illegalSelection, + LEGALITY_CATEGORIES, + LegalityCategory, pairsWith, partnerPlaceholder, shortBy, @@ -159,6 +162,8 @@ function CommanderHero({ // the Library tab, so a mistaken removal can be undone with +. const REMOVAL_GRACE_MS = 5000; +const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`; + const STARTING_HAND_SIZE = 7; /** @@ -216,6 +221,9 @@ function DeckBuilder({ const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); const [selectedCards, setSelectedCards] = useState>(new Set()); + // Selected cards where only some copies are marked for removal (the extras + // over a format's copy limit), keyed by oracle id → copies to remove. + const [partialRemoval, setPartialRemoval] = useState>(new Map()); const [bulkBusy, setBulkBusy] = useState(false); const selectAllRef = useRef(null); @@ -260,6 +268,11 @@ function DeckBuilder({ const next = new Set([...s].filter((id) => visible.has(id))); return next.size === s.size ? s : next; }); + setPartialRemoval((m) => { + const visible = new Set(contents.map((e) => e.oracle_id)); + const next = new Map([...m].filter(([id]) => visible.has(id))); + return next.size === m.size ? m : next; + }); }, [contents]); useEffect(() => { @@ -415,6 +428,8 @@ function DeckBuilder({ } function toggleCard(oracleId: string) { + // A manual (de)select always means the whole entry. + dropPartial(oracleId); setSelectedCards((s) => { const next = new Set(s); if (next.has(oracleId)) next.delete(oracleId); @@ -423,7 +438,33 @@ function DeckBuilder({ }); } + function dropPartial(oracleId: string) { + setPartialRemoval((m) => { + if (!m.has(oracleId)) return m; + const next = new Map(m); + next.delete(oracleId); + return next; + }); + } + + function clearSelection() { + setSelectedCards(new Set()); + setPartialRemoval(new Map()); + } + + /** Replace the selection with the cards behind the deck's card-level + * legality issues (optionally only some categories). Over-limit copies + * select just the extras, so removing them leaves the legal count. */ + function selectIllegal(categories?: LegalityCategory[]) { + const sel = illegalSelection(deckLegality.issues, categories); + setSelectedCards(new Set(sel.keys())); + setPartialRemoval( + new Map([...sel].filter((kv): kv is [string, number] => kv[1] !== null)), + ); + } + function toggleSelectAllCards() { + setPartialRemoval(new Map()); setSelectedCards((s) => s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)), ); @@ -432,22 +473,34 @@ function DeckBuilder({ /** Remove every selected card outright. Unlike stepping one card down to 0, * this is a deliberate action, so there's no grace period to linger in. */ async function bulkRemoveFromDeck() { - const ids = new Set(selectedCards); + // Entries with only some copies selected are trimmed rather than removed. + const remaining = new Map(); + for (const id of selectedCards) { + const e = contents.find((c) => c.oracle_id === id); + const n = partialRemoval.get(id); + remaining.set(id, e && n !== undefined ? Math.max(e.quantity - n, 0) : 0); + } setBulkBusy(true); try { await Promise.all( - [...ids].map((id) => { + [...remaining].map(([id, qty]) => { const e = contents.find((c) => c.oracle_id === id); - return api.setDeckCard(deckId, id, 0, e?.is_commander ?? false, e?.is_maybeboard ?? false); + return api.setDeckCard(deckId, id, qty, e?.is_commander ?? false, e?.is_maybeboard ?? false); }), ); - for (const id of ids) { + const removed = new Set([...remaining].filter(([, qty]) => qty === 0).map(([id]) => id)); + for (const id of removed) { const timer = removalTimers.current.get(id); if (timer) clearTimeout(timer); removalTimers.current.delete(id); } - setPendingRemoval((p) => new Set([...p].filter((id) => !ids.has(id)))); - setContents((cs) => cs.filter((e) => !ids.has(e.oracle_id))); + setPendingRemoval((p) => new Set([...p].filter((id) => !removed.has(id)))); + setContents((cs) => + cs + .filter((e) => !removed.has(e.oracle_id)) + .map((e) => (remaining.has(e.oracle_id) ? { ...e, quantity: remaining.get(e.oracle_id)! } : e)), + ); + clearSelection(); } finally { setBulkBusy(false); } @@ -464,7 +517,7 @@ function DeckBuilder({ setBulkBusy(true); try { await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id))); - setSelectedCards(new Set()); + clearSelection(); } finally { setBulkBusy(false); } @@ -590,7 +643,16 @@ function DeckBuilder({ )} } - footer={e.is_commander ? ★ commander : undefined} + footer={ + e.is_commander || partialRemoval.has(e.oracle_id) ? ( + + {e.is_commander && ★ commander} + {partialRemoval.has(e.oracle_id) && ( + {extraCopiesLabel(partialRemoval.get(e.oracle_id)!)} + )} + + ) : undefined + } /> ))} @@ -631,6 +693,11 @@ function DeckBuilder({ {e.is_commander && ★ } {e.name} + {partialRemoval.has(e.oracle_id) && ( + + {extraCopiesLabel(partialRemoval.get(e.oracle_id)!)} + + )} {e.type_line} @@ -815,20 +882,44 @@ function DeckBuilder({ {deckLegality.status === "legal" ? `Meets all ${deck.format} deck-building rules.` : deckLegality.status === "illegal" - ? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:` + ? `${deckLegality.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:` : deck.format ? `Legality isn't checked for "${deck.format}".` : "Set a format to check legality."} + {illegalSelection(deckLegality.issues).size > 0 && ( + <> + + + + )} - {deckLegality.status === "illegal" && ( -
    - {deckLegality.reasons.map((r, i) => ( -
  • {r}
  • - ))} -
- )} + {deckLegality.status === "illegal" && + LEGALITY_CATEGORIES.map((cat) => { + const issues = deckLegality.issues.filter((i) => i.category === cat.key); + if (issues.length === 0) return null; + const cardCount = illegalSelection(issues).size; + return ( +
+
+ {cat.label} + {cat.selectable && cardCount > 0 && ( + + )} +
+
    + {issues.map((i, n) => ( +
  • {i.message}
  • + ))} +
+
+ ); + })} )} @@ -873,7 +964,10 @@ function DeckBuilder({ {selectedCards.size > 0 && (
- {selectedCards.size} selected + + {selectedCards.size} selected + {partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`} +
-