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