// Typed client for the FastAPI server (server/app.py). export type CardSource = 'input' | 'synergy' | 'staple' | 'basic' /** Real-deck-frequency classification, independent of `source` -- the * synergy phase is unrestricted and routinely picks real staples on their * own merit, so a card can be source: 'synergy' and tier: 'staple' at * once. null for basics (outside the tokenizer vocab, so untiered). */ export type CardTier = 'staple' | 'mid' | 'long_tail' /** What it takes to show a card: its row (cost) and hover preview (text, * stats, price). Deck cards and search results both carry it. */ export interface CardInfo { name: string type_line: string mana_cost: string cmc: number category: string oracle_text: string tier: CardTier | null /** Per-face power/toughness, loyalty, or defense, e.g. ["4/3", null]. */ stats: (string | null)[] /** Cheapest paper printing, if Scryfall has a price for it. */ price: CardPrice | null color_identity: string[] /** A deck may hold more than one copy (basics, Relentless Rats, ...). */ multiples_allowed: boolean } export interface DeckCard extends CardInfo { quantity: number source: CardSource score: number | null locked: boolean } export interface CardPrice { usd: number set: string finish: 'nonfoil' | 'foil' | 'etched' } export interface Completion { commanders: DeckCard[] cards: DeckCard[] warnings: string[] summary: { total: number lands: number target_lands: number added: number basics_added: 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 } /** When the price data was built (ISO time), or null if there is none yet. */ prices_updated: string | null } /** A search result: a full CardInfo, so it can be shown anywhere a deck card is. */ export interface CardSummary extends CardInfo { banned: boolean } export interface SearchOptions { commanderOnly?: boolean /** Only cards within this color identity, e.g. "BG" ("" = colorless). */ colors?: string /** Also offer basic lands (they're not otherwise searchable). */ basics?: boolean } export interface UnresolvedCard { name: string line: number | null // null = a commander field, not a decklist line suggestions: string[] } export interface LockedCard { name: string quantity: number source: CardSource /** False: kept by this request without being locked (your basics, or the * rest of a deck over 100), so it can be cut. Default true. */ locked?: boolean } export interface CompleteRequest { commanders: string[] decklist: string target_lands: number | null prune: boolean /** Cards kept from the current deck when rerolling. */ locked?: LockedCard[] /** Cards the model must never suggest (the user's ban list). */ exclude?: string[] } export class ApiError extends Error { unresolved: UnresolvedCard[] constructor(message: string, unresolved: UnresolvedCard[] = []) { super(message) this.unresolved = unresolved } } async function parseError(res: Response): Promise { try { const body = await res.json() if (typeof body.error === 'string') return new ApiError(body.error, body.unresolved ?? []) // FastAPI request-validation errors: {detail: [{msg, loc}, ...]} if (Array.isArray(body.detail)) return new ApiError(body.detail.map((d: { msg: string }) => d.msg).join('; ')) } catch { // fall through } return new ApiError(`Request failed (${res.status}).`) } export async function completeDeck(req: CompleteRequest, signal?: AbortSignal): Promise { const res = await fetch('/api/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), signal, }) if (!res.ok) throw await parseError(res) return res.json() } export interface Config { default_target_lands: number min_target_lands: number /** When the price data was built (ISO time), or null if there is none yet. */ prices_updated: string | null } export async function getConfig(signal?: AbortSignal): Promise { const res = await fetch('/api/config', { signal }) if (!res.ok) throw await parseError(res) return res.json() } export async function searchCards(q: string, options: SearchOptions = {}, signal?: AbortSignal): Promise { const params = new URLSearchParams({ q, commander: String(options.commanderOnly ?? false), limit: '8' }) if (options.colors !== undefined) params.set('colors', options.colors) if (options.basics) params.set('basics', 'true') const res = await fetch(`/api/cards?${params}`, { signal }) if (!res.ok) throw await parseError(res) return (await res.json()).cards } export function scryfallPageUrl(name: string): string { return `https://scryfall.com/search?q=${encodeURIComponent(`!"${name}"`)}` }