Files
mtgc/src/deckAnalysis.ts
Garrett Mills eb342f5fbb 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>
2026-10-26 21:44:16 +00:00

440 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure deck analysis shared by the deck builder and the deck list: composition
// stats, format legality, commander pairing, and the deck list's per-deck
// summary.
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api";
/** Copies of a deck card you'd need to acquire to cover the deck. */
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
export interface DeckStats {
totalCards: number;
uniqueCards: number;
landCount: number;
avgManaValue: number;
/** USD for every copy in the deck, commander included; cards without a
* known price count as $0. */
totalCost: number;
curve: {
label: string;
count: number;
colorCounts: [number, number, number, number, number];
colorlessCount: number;
}[];
colorCounts: [number, number, number, number, number];
colorlessCount: number;
manaProduction: [number, number, number, number, number];
colorlessProduction: number;
manaConsumption: [number, number, number, number, number];
colorlessConsumption: number;
}
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) and the maybeboard (cards under consideration, not
* actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/
export function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
const avgManaValue = nonlandCount
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
: 0;
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
const curveBuckets = new Map(
bucketLabels.map((l) => [
l,
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
]),
);
for (const e of nonlands) {
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
const bucket = curveBuckets.get(label)!;
bucket.count += e.quantity;
if (e.color_identity === 0) {
bucket.colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
});
}
}
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessCount = 0;
for (const e of cards) {
if (e.color_identity === 0) {
colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
});
}
}
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessProduction = 0;
for (const e of cards) {
if (e.produces_colorless) colorlessProduction += e.quantity;
COLORS.forEach((c, i) => {
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
});
}
// Consumption: colored cost requirements of the spells actually cast —
// lands are excluded since they have no colored casting cost.
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessConsumption = 0;
for (const e of nonlands) {
if (e.colors === 0) {
colorlessConsumption += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
});
}
}
// Unlike the rest, cost counts the commander: it's a card you have to buy.
const totalCost = contents
.filter((e) => !e.is_maybeboard)
.reduce((sum, e) => sum + e.price_usd * e.quantity, 0);
return {
totalCards,
uniqueCards: cards.length,
landCount,
avgManaValue,
totalCost,
curve,
colorCounts,
colorlessCount,
manaProduction,
colorlessProduction,
manaConsumption,
colorlessConsumption,
};
}
/** "$84.50", or whole dollars once it's big enough that cents are noise. */
export function formatCost(usd: number): string {
return `$${usd.toLocaleString(undefined, {
minimumFractionDigits: usd >= 100 ? 0 : 2,
maximumFractionDigits: usd >= 100 ? 0 : 2,
})}`;
}
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
* crates/mtgc-core/src/partner.rs — keep the two in step. */
export function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
const p = a.partner;
const q = b.partner;
if (!p || !q) return false;
switch (p.kind) {
case "partner":
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
case "variant":
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
case "with":
return q.kind === "with" && p.name === b.name && q.name === a.name;
case "chooses_background":
return q.kind === "background";
case "background":
return q.kind === "chooses_background";
case "doctors_companion":
return q.kind === "doctor";
case "doctor":
return q.kind === "doctors_companion";
}
}
/** What the second-commander search box asks for, given the first's ability. */
export function partnerPlaceholder(partner: Partner): string {
switch (partner.kind) {
case "chooses_background":
return "Search to add a Background…";
case "background":
return "Search to add a commander that chooses a Background…";
case "doctors_companion":
return "Search to add a Doctor…";
case "doctor":
return "Search to add a Doctor's companion…";
case "with":
return `Search to add ${partner.name}…`;
case "variant":
return `Search to add a partner (${partner.name})…`;
case "partner":
return "Search to add a partner…";
}
}
export const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
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";
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;
export const CONSTRUCTED_MIN_DECK_SIZE = 60;
export const CONSTRUCTED_MAX_COPIES = 4;
/**
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", issues: [] };
}
const issues: LegalityIssue[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
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) {
issues.push({ category: "commander", message: `No commander is set.` });
} else if (commanders.length > 2) {
issues.push({ category: "commander", message: `More than two cards are marked as commanders.` });
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
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) {
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,
});
}
}
if (commanders.length > 0) {
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
const whose = commanders.length > 1 ? "commanders'" : "commander's";
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~identity;
if (outsideIdentity !== 0) {
issues.push({
category: "color",
message: `${e.name}'s color identity is outside the ${whose} color identity.`,
oracleId: e.oracle_id,
});
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
issues.push({ category: "banned", message: `${e.name} is banned in ${deck.format}.`, oracleId: e.oracle_id });
} else if (status === "restricted" && e.quantity > 1) {
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 {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// 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) {
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) {
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") {
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.
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: issues.length ? "illegal" : "legal", issues };
}
/** What the deck list shows for one deck. */
export interface DeckSummary {
/** The card whose art headlines the deck, or null for an empty deck. */
cover: DeckEntry | null;
/** The primary commander first, then its partner. */
commanders: DeckEntry[];
/** WUBRG mask: the commanders' identity, else the whole mainboard's. */
colors: number;
/** Mainboard copies, commander included. */
size: number;
/** The size the format calls for (a minimum for 60-card formats), or null
* when there's no format to measure against. */
target: number | null;
/** Fraction of the mainboard's copies you own, 0–1 (1 for an empty deck). */
owned: number;
cost: number;
legality: DeckLegality;
maybeboardCount: number;
}
/**
* The card whose art headlines a deck: the chosen cover while it's still on
* the mainboard, then the primary commander, then the priciest nonland (the
* card most likely to be the deck's centrepiece), then any card at all.
*/
export function pickCover(deck: Deck, contents: DeckEntry[]): DeckEntry | null {
const main = contents.filter((e) => !e.is_maybeboard);
const byId = (id: string | null) => (id ? main.find((e) => e.oracle_id === id) : undefined);
const priciest = (cards: DeckEntry[]) =>
cards.reduce<DeckEntry | null>((best, e) => (!best || e.price_usd > best.price_usd ? e : best), null);
return (
byId(deck.cover_oracle_id) ??
byId(deck.commander_oracle_id) ??
main.find((e) => e.is_commander) ??
priciest(main.filter((e) => !e.type_line.includes("Land"))) ??
priciest(main) ??
priciest(contents)
);
}
export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
const main = contents.filter((e) => !e.is_maybeboard);
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck.commander_oracle_id);
const commanders = main.filter((e) => e.is_commander).sort((a, b) => isPrimary(b) - isPrimary(a));
const colorSource = commanders.length ? commanders : main;
const size = main.reduce((n, e) => n + e.quantity, 0);
const ownedCopies = main.reduce((n, e) => n + Math.min(e.quantity, e.quantity_owned), 0);
const target = COMMANDER_FORMATS.includes(deck.format)
? COMMANDER_DECK_SIZE
: deck.format && deck.format !== "Other"
? CONSTRUCTED_MIN_DECK_SIZE
: null;
return {
cover: pickCover(deck, contents),
commanders,
colors: colorSource.reduce((mask, e) => mask | e.color_identity, 0),
size,
target,
owned: size ? ownedCopies / size : 1,
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
legality: computeDeckLegality(deck, contents),
maybeboardCount: contents.length - main.length,
};
}