File size: 4,981 Bytes
8538f10
 
 
 
2fd2eab
 
 
 
 
 
15c3f7a
 
 
8538f10
 
 
 
 
 
15c3f7a
8538f10
 
 
 
08b6c63
 
 
8538f10
 
15c3f7a
 
 
 
 
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15c3f7a
 
8538f10
 
 
08b6c63
 
 
 
 
 
 
 
8538f10
 
 
 
 
 
 
 
 
 
08b6c63
 
 
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b6c63
 
 
 
8538f10
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// 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}"`)}`
}