nsaroiu's picture
Deploy 41a05c7
08b6c63 verified
Raw History Blame Contribute Delete
4.98 kB
// 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<ApiError> {
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<Completion> {
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<Config> {
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<CardSummary[]> {
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}"`)}`
}