Decks tab: a deck list page, with the builder on its own screen
The deck list is no longer a sidebar pinned beside the builder; on every screen size the Decks tab opens to a deck list page (DeckList), and picking a deck opens its builder full-width with an "All decks" back button. The list shows each deck as an art tile (or a compact row, toggleable) with its colors, size against its format, owned %, cost and legality, and can be searched, filtered by format and sorted (recently opened, name, format, size, cost, owned %). Rename/duplicate/export/delete live in each deck's menu and beside the builder's title; New deck now asks for a format too, defaulting to the last one used. A deck's cover art is the commander, else its priciest nonland, unless a card is chosen with "Use as cover" in the card preview. The cover is stored in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a document carrying one is version 3, so the sync server must be updated before clients publish covers. The list's stats come from one all_deck_contents query, summarised with the builder's own analysis code, now in deckAnalysis.ts. Closes #9 Closes #11 Closes #12
This commit is contained in:
378
src/deckAnalysis.ts
Normal file
378
src/deckAnalysis.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user