379 lines
14 KiB
TypeScript
379 lines
14 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 interface DeckLegality {
|
|||
|
|
status: "legal" | "illegal" | "unchecked";
|
|||
|
|
reasons: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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", reasons: [] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const reasons: string[] = [];
|
|||
|
|
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) {
|
|||
|
|
reasons.push(
|
|||
|
|
`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) {
|
|||
|
|
reasons.push(`No commander is set.`);
|
|||
|
|
} else if (commanders.length > 2) {
|
|||
|
|
reasons.push(`More than two cards are marked as commanders.`);
|
|||
|
|
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
|
|||
|
|
reasons.push(`${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) {
|
|||
|
|
reasons.push(
|
|||
|
|
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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) {
|
|||
|
|
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const e of cards) {
|
|||
|
|
const status = legalStatusFor(e.legalities, deck.format);
|
|||
|
|
if (status === "banned") {
|
|||
|
|
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
|||
|
|
} else if (status === "restricted" && e.quantity > 1) {
|
|||
|
|
reasons.push(
|
|||
|
|
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} 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) {
|
|||
|
|
reasons.push(
|
|||
|
|
`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) {
|
|||
|
|
reasons.push(
|
|||
|
|
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const e of cards) {
|
|||
|
|
const status = legalStatusFor(e.legalities, deck.format);
|
|||
|
|
if (status === "banned") {
|
|||
|
|
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
|||
|
|
} 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.
|
|||
|
|
reasons.push(
|
|||
|
|
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { status: reasons.length ? "illegal" : "legal", reasons };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/** 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,
|
|||
|
|
};
|
|||
|
|
}
|