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>
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
// 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,
|
||
};
|
||
}
|