// 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 { 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; 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((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, }; }