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

@@ -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<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [selectedCards, setSelectedCards] = useState<Set<string>>(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<Map<string, number>>(new Map());
const [bulkBusy, setBulkBusy] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(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<string, number>();
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({
)}
</span>
}
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
footer={
e.is_commander || partialRemoval.has(e.oracle_id) ? (
<span className="row" style={{ gap: 4 }}>
{e.is_commander && <span className="tile-badge">★ commander</span>}
{partialRemoval.has(e.oracle_id) && (
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
)}
</span>
) : undefined
}
/>
))}
</div>
@@ -631,6 +693,11 @@ function DeckBuilder({
<td className="card-name">
{e.is_commander && <span title="Commander">★ </span>}
{e.name}
{partialRemoval.has(e.oracle_id) && (
<span className="badge" style={{ marginLeft: 6 }}>
{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}
</span>
)}
</td>
<td className="card-type">{e.type_line}</td>
<td data-label="In deck">
@@ -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."}
</span>
</div>
{illegalSelection(deckLegality.issues).size > 0 && (
<>
<span className="spacer" />
<button className="legality-tile-select" onClick={() => selectIllegal()}>
Select illegal cards
</button>
</>
)}
</div>
{deckLegality.status === "illegal" && (
<ul className="legality-tile-reasons">
{deckLegality.reasons.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
{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 (
<div key={cat.key} className="legality-tile-group">
<div className="legality-tile-group-head">
<span>{cat.label}</span>
{cat.selectable && cardCount > 0 && (
<button className="legality-tile-select" onClick={() => selectIllegal([cat.key])}>
Select ({cardCount})
</button>
)}
</div>
<ul className="legality-tile-reasons">
{issues.map((i, n) => (
<li key={n}>{i.message}</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
@@ -873,7 +964,10 @@ function DeckBuilder({
{selectedCards.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selectedCards.size} selected</span>
<span className="bulk-bar-count">
{selectedCards.size} selected
{partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`}
</span>
<div className="bulk-bar-actions">
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
Remove from deck
@@ -887,7 +981,7 @@ function DeckBuilder({
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
<button onClick={clearSelection} disabled={bulkBusy}>
Clear
</button>
</div>