import type { CardInfo, Completion, DeckCard } from '../api' export const DECK_SIZE = 100 // Display order for result groups (the server assigns each card's category). export const CATEGORY_ORDER = [ 'Creature', 'Planeswalker', 'Battle', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other', ] const CATEGORY_LABELS: Record = { Creature: 'Creatures', Planeswalker: 'Planeswalkers', Battle: 'Battles', Instant: 'Instants', Sorcery: 'Sorceries', Artifact: 'Artifacts', Enchantment: 'Enchantments', Land: 'Lands', Other: 'Other', } /** Cards the user can lock and ban: everything except basic * lands, which are either the user's own (always kept) or recalculated by * the server to hit the land target. */ export function isAdjustable(card: DeckCard): boolean { return card.source !== 'basic' && !card.type_line.startsWith('Basic') } export function categoryLabel(category: string): string { return CATEGORY_LABELS[category] ?? category } export interface CardGroup { category: string cards: DeckCard[] count: number } /** Groups cards by category; within a group, nonland cards sort by mana * value then name, lands by name with basics last. The user's basics and * added basics of the same name are merged into one row. */ export function groupCards(cards: DeckCard[]): CardGroup[] { const merged: DeckCard[] = [] for (const card of cards) { const existing = card.category === 'Land' && merged.find((c) => c.name === card.name) if (existing) { existing.quantity += card.quantity if (card.source === 'basic') existing.source = 'basic' } else { merged.push({ ...card }) } } const groups = new Map() for (const card of merged) { const key = CATEGORY_ORDER.includes(card.category) ? card.category : 'Other' groups.set(key, [...(groups.get(key) ?? []), card]) } const isBasic = (c: DeckCard) => c.type_line.startsWith('Basic') return CATEGORY_ORDER.filter((cat) => groups.has(cat)).map((category) => { const list = groups.get(category)! list.sort((a, b) => category === 'Land' ? Number(isBasic(a)) - Number(isBasic(b)) || a.name.localeCompare(b.name) : a.cmc - b.cmc || a.name.localeCompare(b.name), ) return { category, cards: list, count: list.reduce((n, c) => n + c.quantity, 0) } }) } /** Plain "1 Card Name" list, commander(s) first -- the format Moxfield, * Archidekt and most other deckbuilders accept on import. */ export function toDecklistText(completion: Completion): string { const lines = completion.commanders.map((c) => `1 ${c.name}`) lines.push('') for (const group of groupCards(completion.cards)) { for (const card of group.cards) lines.push(`${card.quantity} ${card.name}`) } return lines.join('\n') + '\n' } /** "Meren of Clan Nel Toth" -> "Meren decklist"; partners joined with "+". */ export function exportFileName(completion: Completion, extension: string): string { return `${completion.commanders.map((c) => c.name.split(',')[0]).join(' + ')} decklist.${extension}` } export interface DeckSummary { total: number lands: number /** Sum of the cheapest printing of every card, commanders included. */ price_usd: number /** Cards (by quantity) with no known price, left out of price_usd. */ unpriced: number } /** The deck header's numbers, from the cards as they are now: the server's * `summary` describes the deck it returned, which hand edits change. Same * math as the server's Completion.to_dict. */ export function summarize(completion: Completion): DeckSummary { const everything = [...completion.commanders, ...completion.cards] const priced = everything.reduce((sum, c) => sum + (c.price ? c.price.usd * c.quantity : 0), 0) return { total: everything.reduce((n, c) => n + c.quantity, 0), lands: completion.cards.reduce((n, c) => n + (c.category === 'Land' ? c.quantity : 0), 0), price_usd: Math.round(priced * 100) / 100, unpriced: everything.reduce((n, c) => n + (c.price ? 0 : c.quantity), 0), } } /** Sets how many copies of `name` the deck has, as the user's own card * (kept by every reroll). Rows of the same card (your basics plus the ones * the server added) collapse into one; 0 removes it. */ export function withQuantity(cards: DeckCard[], name: string, quantity: number): DeckCard[] { const first = cards.find((c) => c.name === name) const rest = cards.filter((c) => c.name !== name) if (!first || quantity <= 0) return rest return [...rest, { ...first, quantity, source: 'input', score: null }] } /** Adds one copy of a card as the user's own (locked by the caller). */ export function withCardAdded(cards: DeckCard[], card: CardInfo): DeckCard[] { const copies = cards.filter((c) => c.name === card.name).reduce((n, c) => n + c.quantity, 0) if (copies > 0) return withQuantity(cards, card.name, copies + 1) return [...cards, { ...card, quantity: 1, source: 'input', score: null, locked: true }] } export function withoutCard(cards: DeckCard[], name: string): DeckCard[] { return cards.filter((c) => c.name !== name) } /** How many cards can be locked: the deck minus its commanders (99, or 98 * with partners), so a deck over 100 can always be cut down to 100 without * touching a locked card. Mirrors the server's check. */ export function lockCap(completion: Completion): number { return DECK_SIZE - completion.commanders.length } /** Locked cards, counting copies (5 locked Relentless Rats use 5 of the cap). */ export function lockedCount(cards: DeckCard[], lockedNames: Set, banned: Set): number { return cards.reduce((n, c) => n + (isAdjustable(c) && lockedNames.has(c.name) && !banned.has(c.name) ? c.quantity : 0), 0) }