SLOPSHOPPER

Spinlings

A creature card game above your prompt. Wild creatures, Rivals and other players' teams come to battle while Claude works. Catch, fuse, trade and gift…

newpanebandspinnercommandtoast
★ 1v0.2.16MITupdated 2026-10-06416rehman/spinlings/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinlings
│ ┃ Spinlings ✕ › fix the failing auth test and add an audit log call │ ┃ Team Collection Discoveries Community │ ┃ Topsy-Turvy ▪ Online · spinlings.dev ▾ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Getting your collection… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ It takes a moment and asks nothing of you. Y ⏺ Bash(bun test) │ ┃ pack waits in the band. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Close ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spin │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Spinlings ⟨Claude Code's own drawing⟩

Draws

Pane · Spinlings
Team Collection Discoveries Community Topsy-Turvy ▪ Online · spinlings.dev ▾ Getting your collection… It takes a moment and asks nothing of you. Your welcome pack waits in the band. Close v0.2.16
Prompt hint
Spinlings ⟨Claude Code's own drawing⟩
README

Spinlings

Pixel creatures turn up while Claude works. Catch them, collect cards, build a team and meet rivals in a small game above the prompt. Open /spin for your team, collection, discoveries and community. Packs give one card; you choose where it belongs in your team and what price to ask on the market.

Spinlings is a Claude Code mod for Claude Code 2.1.287 or later, including compatible Code sessions in Claude Desktop. It uses the mod interface; it does not add chat skills or a connector. The pane and the inline game work together, and /spin quiet hides the game when you want quiet.

Install

claude plugin marketplace add 416rehman/spinlings
claude plugin install spinlings@spinlings

Start a new Claude Code session, then run /spin. Choose the shared online world or keep an independent collection on this computer. /spin world opens that choice again; /spin world online returns to Spinlings, /spin world offline uses the local world, and /spin world <server> reviews a community server before connecting.

Online players can trade, sell cards, challenge teams and share a public profile. Save a passkey to return to the same collection from the browser or another computer. Ordinary rarity and card finishes are separate: Alt colour changes the creature's colours, while Foil adds a rainbow frame.

Data handling

The online world sends pseudonymous game requests to https://spinlings.dev: account sessions, card and pack ids, team choices, battle timing inputs and the model family for joining, pack charges and battles. It fetches game state, frozen season art, battle results and public community data. A community world sends the same game data to the server you explicitly approve. Offline play keeps its independent save on this computer and sends nothing.

Spinlings reads local session signals to animate the game. It never reads or sends conversations, tool contents, files, repository paths, Claude identity or costs. Passkeys are optional and handled on the selected world's website. Privacy and retention describes saved game data, public fields and deletion. Source review notes explain the bundled mod, local controls, tests and assets.

The command.run hook is filtered to the literal spin command. It reads only that player's game arguments, such as pack, world or redeem, and passes the event onward. It never runs a shell, process, tool, agent, MCP call or model, and never runs an instruction received from a server. Turn/session hooks read only the signals listed in the privacy policy. UI hooks draw the game and handle its own controls; they pass Claude's drawing onward without reading the composer or conversation.

The HTTP adapter calls only the chosen game world's API. Its default host is https://spinlings.dev; an explicitly approved community world uses the player's chosen HTTPS origin, with HTTP allowed only for loopback development. URLs cannot contain credentials. Responses are validated game data, not executable code. There are no analytics, third-party asset requests or background requests in the offline world. Local audio is synthesized WAV data, and the arena images are bundled PNG bytes.

Storage belongs to this plugin: game settings, an offline save, cached game appearances, and separate game sessions for each chosen world. The session credential is created by the game server; it is not taken from Claude, the user's environment or another application's files. Online requests use that world's own session, never the offline save or another world's session. The mod has no filesystem, environment, process, tool, model or MCP capability.

Game responses pass strict schema checks. The record validator uses Object.getPrototypeOf only to compare an input's prototype with Object.prototype and null, rejecting class instances and custom-prototype records. It does not mutate prototypes or traverse to constructors. The manifest's supported types field declares all 13 plugin-owned state keys for strict validation; it grants no extra capability.

Rendering identifiers are not credentials or destinations. tokens.ts contains colours and spacing, UI keys identify controls, and http://www.w3.org/2000/svg is the standard SVG namespace. The frozen naming generator's vocabulary and tokenization use ordinary words, not machine secrets. The frozen catalogue fetches validated season data through the same game-world adapter; it does not obtain a credential from that generator.

Play and browse · Game guide · Privacy policy · Privacy details · Support · Private security reports · Source and screenshots

Released under the MIT license. Created by 416rehman.

Source 74 files
hooks/register.tsx 397 lines
1// Spinlings: every hook and every $ call of the mod lives in this file (SPEC 11). The hooks read only the shape of the
2// session (SPEC 10): the model's family, local effort glow, whether Claude's main turn runs, rate-limit fullness and
3// compaction. Never tool.call, prompt.submit or a permission request; never prompts, answers, files or
4// cost. Session and turn hooks pass their events on and never wait on the network: automatic requests run on clock
5// callbacks. Explicit game commands and controls may await their requested operation. client/game.ts holds the game;
6// this file only lends it $ through Fx.
7import { atom } from 'claude-code'
8import type { Atom, EngineInterface, Register } from 'claude-code'
9import type {
10  BandState, BandView, BattleDriver, Chime, El, Fx, GameState, LocalBackendFactory, MomentDriver, PaneView, RevealDriver,
11  Slots, StateKey, Surface,
12} from './client/types.ts'
13import { INITIAL, createGame, spinnerSuffix } from './client/game.ts'
14import { chimeClip } from './client/chimes.ts'
15import { createLocalBackend } from './client/local/index.ts'
16import { momentDriver, playBattle } from './client/scheduler.ts'
17import { band } from './ui/band.tsx'
18import { ceremony } from './ui/ceremony.tsx'
19import { pane } from './ui/pane.tsx'
20import { launcher } from './ui/launcher.tsx'
21import type { LauncherState } from './ui/launcher.tsx'
22import { prepareCardHit, clearCardHits } from './ui/card-hit-state.ts'
23
24// The slots: the band (ui/band*.tsx, client/scheduler.ts), prompt launcher, pane and its ceremonies
25// (ui/pane*.tsx, ui/ceremony*.tsx), and the offline world (client/local/**).
26const BAND: BandView = band
27const BATTLE_DRIVER: BattleDriver = playBattle
28const MOMENT_DRIVER: MomentDriver = momentDriver
29const PANE: PaneView = pane
30const REVEAL_DRIVER: RevealDriver = ceremony
31const LOCAL: LocalBackendFactory = createLocalBackend
32
33const SLOTS: Slots = { local: LOCAL, battle: BATTLE_DRIVER, reveal: REVEAL_DRIVER, moments: MOMENT_DRIVER }
34
35const PANE_ID = 'spinlings'
36
37const accountAtom = atom({ plugin: 'spinlings', key: 'account' } as const, INITIAL.account)
38const meAtom = atom({ plugin: 'spinlings', key: 'me' } as const, INITIAL.me)
39const cardsAtom = atom({ plugin: 'spinlings', key: 'cards' } as const, INITIAL.cards)
40const signalsAtom = atom({ plugin: 'spinlings', key: 'signals' } as const, INITIAL.signals)
41const battleAtom = atom({ plugin: 'spinlings', key: 'battle' } as const, INITIAL.battle)
42const momentsAtom = atom({ plugin: 'spinlings', key: 'moments' } as const, INITIAL.moments)
43const revealAtom = atom({ plugin: 'spinlings', key: 'reveal' } as const, INITIAL.reveal)
44const socialAtom = atom({ plugin: 'spinlings', key: 'social' } as const, INITIAL.social)
45const paneAtom = atom({ plugin: 'spinlings', key: 'pane' } as const, INITIAL.pane)
46const prefsAtom = atom({ plugin: 'spinlings', key: 'prefs' } as const, INITIAL.prefs)
47const presenceAtom = atom({ plugin: 'spinlings', key: 'presence' } as const, INITIAL.presence)
48const privacyAtom = atom({ plugin: 'spinlings', key: 'privacy' } as const, INITIAL.privacy)
49const clockAtom = atom({ plugin: 'spinlings', key: 'clock' } as const, INITIAL.clock)
50
51type Engine = EngineInterface
52type Fn<K extends StateKey> = (v: GameState[K]) => GameState[K]
53
54// ---------- $.state, by key (refs stay literal for the validator) ----------
55
56/** The directory scanner requires plain API calls in this file, including state helper calls. */
57async function stateRead($: Engine, key: StateKey): Promise<{ value: unknown; version: number }> {
58  switch (key) {
59    case 'account': return $.state.get({ plugin: 'spinlings', key: 'account' })
60    case 'me': return $.state.get({ plugin: 'spinlings', key: 'me' })
61    case 'cards': return $.state.get({ plugin: 'spinlings', key: 'cards' })
62    case 'signals': return $.state.get({ plugin: 'spinlings', key: 'signals' })
63    case 'battle': return $.state.get({ plugin: 'spinlings', key: 'battle' })
64    case 'moments': return $.state.get({ plugin: 'spinlings', key: 'moments' })
65    case 'reveal': return $.state.get({ plugin: 'spinlings', key: 'reveal' })
66    case 'social': return $.state.get({ plugin: 'spinlings', key: 'social' })
67    case 'pane': return $.state.get({ plugin: 'spinlings', key: 'pane' })
68    case 'prefs': return $.state.get({ plugin: 'spinlings', key: 'prefs' })
69    case 'presence': return $.state.get({ plugin: 'spinlings', key: 'presence' })
70    case 'privacy': return $.state.get({ plugin: 'spinlings', key: 'privacy' })
71    case 'clock': return $.state.get({ plugin: 'spinlings', key: 'clock' })
72  }
73  throw new Error(`no state ${String(key)}`)
74}
75
76async function stateWrite($: Engine, key: StateKey, value: unknown, version: number): Promise<boolean> {
77  switch (key) {
78    case 'account': return (await $.state.set({ plugin: 'spinlings', key: 'account' }, value as GameState['account'], { ifVersion: version })).isSet
79    case 'me': return (await $.state.set({ plugin: 'spinlings', key: 'me' }, value as GameState['me'], { ifVersion: version })).isSet
80    case 'cards': return (await $.state.set({ plugin: 'spinlings', key: 'cards' }, value as GameState['cards'], { ifVersion: version })).isSet
81    case 'signals': return (await $.state.set({ plugin: 'spinlings', key: 'signals' }, value as GameState['signals'], { ifVersion: version })).isSet
82    case 'battle': return (await $.state.set({ plugin: 'spinlings', key: 'battle' }, value as GameState['battle'], { ifVersion: version })).isSet
83    case 'moments': return (await $.state.set({ plugin: 'spinlings', key: 'moments' }, value as GameState['moments'], { ifVersion: version })).isSet
84    case 'reveal': return (await $.state.set({ plugin: 'spinlings', key: 'reveal' }, value as GameState['reveal'], { ifVersion: version })).isSet
85    case 'social': return (await $.state.set({ plugin: 'spinlings', key: 'social' }, value as GameState['social'], { ifVersion: version })).isSet
86    case 'pane': return (await $.state.set({ plugin: 'spinlings', key: 'pane' }, value as GameState['pane'], { ifVersion: version })).isSet
87    case 'prefs': return (await $.state.set({ plugin: 'spinlings', key: 'prefs' }, value as GameState['prefs'], { ifVersion: version })).isSet
88    case 'presence': return (await $.state.set({ plugin: 'spinlings', key: 'presence' }, value as GameState['presence'], { ifVersion: version })).isSet
89    case 'privacy': return (await $.state.set({ plugin: 'spinlings', key: 'privacy' }, value as GameState['privacy'], { ifVersion: version })).isSet
90    case 'clock': return (await $.state.set({ plugin: 'spinlings', key: 'clock' }, value as GameState['clock'], { ifVersion: version })).isSet
91  }
92  throw new Error(`no state ${String(key)}`)
93}
94
95/** These atoms have no shape tags; match the SDK's undefined-only initial-value fallback. */
96async function read<T>($: Engine, source: Atom<T>): Promise<T> {
97  const held = await stateRead($, source.ref.key as StateKey)
98  return held.value === undefined ? source.initial : held.value as T
99}
100
101/** Match the SDK's atomic update: reread and rerun the pure callback on each version miss, up to 64. */
102async function update<T>($: Engine, target: Atom<T>, change: (value: T) => T): Promise<T> {
103  for (let tries = 0; tries < 64; tries += 1) {
104    const held = await stateRead($, target.ref.key as StateKey)
105    const current = held.value === undefined ? target.initial : held.value as T
106    const changed = change(current)
107    if (await stateWrite($, target.ref.key as StateKey, changed, held.version)) return changed
108  }
109  throw new Error('update: the value was written by another every time it was read, up ' +
110    'to the bound on tries; nothing was written')
111}
112
113async function stateGet<K extends StateKey>($: Engine, key: K): Promise<GameState[K]> {
114  switch (key as StateKey) {
115    case 'account': return (await read($, accountAtom)) as GameState[K]
116    case 'me': return (await read($, meAtom)) as GameState[K]
117    case 'cards': return (await read($, cardsAtom)) as GameState[K]
118    case 'signals': return (await read($, signalsAtom)) as GameState[K]
119    case 'battle': return (await read($, battleAtom)) as GameState[K]
120    case 'moments': return (await read($, momentsAtom)) as GameState[K]
121    case 'reveal': return (await read($, revealAtom)) as GameState[K]
122    case 'social': return (await read($, socialAtom)) as GameState[K]
123    case 'pane': return (await read($, paneAtom)) as GameState[K]
124    case 'prefs': return (await read($, prefsAtom)) as GameState[K]
125    case 'presence': return (await read($, presenceAtom)) as GameState[K]
126    case 'privacy': return (await read($, privacyAtom)) as GameState[K]
127    case 'clock': return (await read($, clockAtom)) as GameState[K]
128  }
129  throw new Error(`no state ${String(key)}`)
130}
131
132async function stateUpdate<K extends StateKey>($: Engine, key: K, fn: (v: GameState[K]) => GameState[K]): Promise<GameState[K]> {
133  switch (key as StateKey) {
134    case 'account': return (await update($, accountAtom, fn as unknown as Fn<'account'>)) as GameState[K]
135    case 'me': return (await update($, meAtom, fn as unknown as Fn<'me'>)) as GameState[K]
136    case 'cards': return (await update($, cardsAtom, fn as unknown as Fn<'cards'>)) as GameState[K]
137    case 'signals': return (await update($, signalsAtom, fn as unknown as Fn<'signals'>)) as GameState[K]
138    case 'battle': return (await update($, battleAtom, fn as unknown as Fn<'battle'>)) as GameState[K]
139    case 'moments': return (await update($, momentsAtom, fn as unknown as Fn<'moments'>)) as GameState[K]
140    case 'reveal': return (await update($, revealAtom, fn as unknown as Fn<'reveal'>)) as GameState[K]
141    case 'social': return (await update($, socialAtom, fn as unknown as Fn<'social'>)) as GameState[K]
142    case 'pane': return (await update($, paneAtom, fn as unknown as Fn<'pane'>)) as GameState[K]
143    case 'prefs': return (await update($, prefsAtom, fn as unknown as Fn<'prefs'>)) as GameState[K]
144    case 'presence': return (await update($, presenceAtom, fn as unknown as Fn<'presence'>)) as GameState[K]
145    case 'privacy': return (await update($, privacyAtom, fn as unknown as Fn<'privacy'>)) as GameState[K]
146    case 'clock': return (await update($, clockAtom, fn as unknown as Fn<'clock'>)) as GameState[K]
147  }
148  throw new Error(`no state ${String(key)}`)
149}
150
151async function readAll($: Engine): Promise<GameState> {
152  const [account, me, cards, signals, battle, moments, reveal, social, pane, prefs, presence, privacy, clock] = await Promise.all([
153    read($, accountAtom), read($, meAtom), read($, cardsAtom), read($, signalsAtom), read($, battleAtom),
154    read($, momentsAtom), read($, revealAtom), read($, socialAtom), read($, paneAtom), read($, prefsAtom),
155    read($, presenceAtom), read($, privacyAtom), read($, clockAtom),
156  ])
157  return { account, me, cards, signals, battle, moments, reveal, social, pane, prefs, presence, privacy, clock } as GameState
158}
159
160/** The band also watches the inline reveal and its shared flip progress. */
161async function readBand($: Engine): Promise<BandState & { clock: number }> {
162  const [account, me, cards, signals, battle, moments, prefs, clock, reveal, pane] = await Promise.all([
163    read($, accountAtom), read($, meAtom), read($, cardsAtom), read($, signalsAtom), read($, battleAtom), read($, momentsAtom),
164    read($, prefsAtom), read($, clockAtom), read($, revealAtom), read($, paneAtom),
165  ])
166  return { account, me, cards, signals, battle, moments, prefs, clock, reveal, pane } as BandState & { clock: number }
167}
168
169/** Only local, already-loaded state for the composer; rendering never asks for a board. */
170async function readLauncher($: Engine): Promise<LauncherState> {
171  const [account, me, battle, prefs, signals, social] = await Promise.all([
172    read($, accountAtom), read($, meAtom), read($, battleAtom), read($, prefsAtom), read($, signalsAtom), read($, socialAtom),
173  ])
174  return { account, me, battle, prefs, signals, social } as LauncherState
175}
176
177// ---------- effects: the only door from the game to $ ----------
178
179function random(): number {
180  return crypto.getRandomValues(new Uint32Array(1))[0]! / 2 ** 32
181}
182
183/** Off by default; quiet silences it too. Playback runs on its own and a failure stays silent. */
184async function chime($: Engine, cue: Chime): Promise<void> {
185  try {
186    const prefs = await read($, prefsAtom)
187    if (!prefs.sound || prefs.quiet) return
188    const clip = chimeClip(cue)
189    await $.audio.play({ base64: clip.base64, mime: 'audio/wav' }, { gain: 0.8 })
190  } catch {
191    // no player on this machine, or the clip could not play
192  }
193}
194
195async function blit($: Engine, requestId: string | null, key: string, cells: string): Promise<boolean> {
196  if (!requestId) return false
197  try {
198    return !(await $.ui.blit({ requestId, key, cells })).deny
199  } catch {
200    return false
201  }
202}
203
204/** The effects over `$`; `surface`, where the band or the pane drew, is where their presses copy. */
205function fxOf($: Engine, surface?: Surface): Fx {
206  return {
207    now: () => $.clock.now(),
208    random,
209    after: (ms, fn) => $.clock.after(Math.max(0, Math.round(ms)), fn),
210    every: (ms, fn) => $.clock.every(Math.max(1, Math.round(ms)), fn),
211    fetch: async (url, init) => {
212      const res = await $.http.fetch(url, init)
213      return { status: res.status, ok: res.ok, headers: res.headers, text: res.text }
214    },
215    store: {
216      get: key => $.store.get(key),
217      set: (key, value) => $.store.set(key, value),
218      delete: key => $.store.delete(key),
219      keys: async () => [...(await $.store.keys())],
220    },
221    state: {
222      get: key => stateGet($, key),
223      update: (key, fn) => stateUpdate($, key, fn),
224    },
225    ui: {
226      toast: text => $.ui.toast(text),
227      status: text => $.ui.status(text),
228      log: text => $.ui.log(text),
229      copy: async text => {
230        try {
231          return (await $.ui.copy(surface ? { text, surface } : { text })).isCopied
232        } catch {
233          return false
234        }
235      },
236      openPane: async () => {
237        try {
238          return (await $.ui.open({ id: PANE_ID, title: 'Spinlings', focus: true, closeOnEscape: true })).isPlaced
239        } catch {
240          return false
241        }
242      },
243      closePane: async () => {
244        try {
245          await $.ui.close({ id: PANE_ID })
246        } catch {
247          // already closed
248        }
249      },
250      blit: (site, key, cells) => blit($, game.sites()[site], key, cells),
251      sound: cue => { void chime($, cue) },
252    },
253  }
254}
255
256/** Runs a hook's own work so that nothing it does can stop the event: failures go to the debug log only. */
257async function quietly($: Engine, what: string, work: () => Promise<unknown>): Promise<void> {
258  try {
259    await work()
260  } catch (err) {
261    $.ui.log(`spinlings: ${what} skipped (${err instanceof Error ? err.name : 'error'})`, { to: 'debug' })
262  }
263}
264
265async function modelOf($: Engine): Promise<string | null> {
266  try {
267    return await $.session.model()
268  } catch {
269    return null
270  }
271}
272
273let game = createGame({ slots: SLOTS })
274
275// no plugin options: the world and the server are switched with /spin world and /spin server
276export const register: Register = on => {
277  game = createGame({ slots: SLOTS })
278
279  on('session.start', async ($, e, next) => {
280    await quietly($, 'command', () => $.command.register({ name: 'spin', description: 'Spinlings: your team, cards, album and trades', argumentHint: '[battle | pack | team | trade | gift | claim | world | quiet | privacy | …]', immediate: true }))
281    const model = await modelOf($)
282    await quietly($, 'boot', () => game.boot(fxOf($), { model }))
283    return next(e)
284  })
285
286  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
287    await quietly($, 'reseed', () => game.reseed(fxOf($)))
288    return next(e)
289  })
290
291  on('session.end', async ($, e, next) => {
292    await quietly($, 'end', () => game.end(fxOf($)))
293    return next(e)
294  })
295
296  on('turn.start', async ($, e, next) => {
297    await quietly($, 'turn', () => game.turnStarted(fxOf($)))
298    return next(e)
299  })
300
301  on('turn.step', async function* ($, e, next) {
302    if (e.agentId === undefined) await quietly($, 'step', () => game.turnStep(fxOf($), e.model, e.effort))
303    return yield* next(e)
304  })
305
306  on('turn.complete', async ($, e, next) => {
307    if (e.agentId === undefined) await quietly($, 'turn', () => game.turnCompleted(fxOf($), e.reason))
308    return next(e)
309  })
310
311  on('session.measure', async ($, e, next) => {
312    // rate-limit fullness only: context fill and cost are never read (SPEC 10)
313    const limits = e.rateLimits
314    await quietly($, 'measure', () => game.measured(fxOf($), limits))
315    return next(e)
316  })
317
318  on('session.compact', async ($, e, next) => {
319    const r = await next(e)
320    const trigger = e.trigger
321    await quietly($, 'compact', () => game.compacted(fxOf($), trigger))
322    return r
323  })
324
325  on('command.run', { command: 'spin' }, async ($, e) => {
326    await quietly($, 'command', () => game.command(fxOf($), e.args))
327    return {}
328  })
329
330  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
331    let keep = false
332    await quietly($, 'close', async () => { keep = await game.paneClosing(fxOf($), e.origin.kind === 'person') })
333    // answering without next keeps the pane open: esc went back one view
334    if (keep) return { value: undefined }
335    clearCardHits(true)
336    return next(e)
337  })
338
339  on('ui.message', { component: 'Pane', requestId: PANE_ID, module: 'hooks/ui/card-hit.tsx' }, async ($, e, next) => {
340    const press = e.surface === 'desktop' ? prepareCardHit(e.element, e.data) : null
341    if (press) {
342      $.clock.after(0, () => { void quietly($, 'card press', async () => {
343        await press(key => $.ui.focus({ requestId: PANE_ID, key }))
344      }) })
345    }
346    return next(e)
347  })
348
349  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
350    if (e.props.hasSurvey) return next(e)
351    game.site('band', e.requestId)
352    const state = await readBand($)
353    const tree = BAND({
354      el: $.ui.resolve(e) as unknown as El, surface: e.surface, columns: e.props.bodyColumns, rows: e.props.maxRows,
355      now: state.clock, actions: game.actions(fxOf($, e.surface)), isWorking: e.props.isWorking, state,
356    })
357    return tree ?? next(e)
358  })
359
360  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
361    game.site('pane', e.requestId)
362    const state = await readAll($)
363    clearCardHits(e.surface !== 'desktop')
364    return PANE({
365      el: $.ui.resolve(e) as unknown as El, surface: e.surface, columns: e.props.bodyColumns, rows: e.props.scroll.bodyRows,
366      now: state.clock, actions: game.actions(fxOf($, e.surface)), focused: e.props.isFocused, placement: e.props.placement, state, hitAreas: e.surface === 'desktop',
367    })
368  })
369
370  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
371    if (e.surface !== 'terminal') return next(e)
372    const theirs = await next(e)
373    const state = await readLauncher($)
374    const fx = fxOf($, e.surface)
375    const tree = launcher({ el: $.ui.resolve(e) as unknown as El, state, theirs, open: () => { void game.actions(fx).open() } })
376    game.launcherDrawn(fx, e.surface, !state.prefs.quiet)
377    return tree
378  })
379
380  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
381    if (e.surface !== 'desktop') return next(e)
382    const theirs = await next(e)
383    const state = await readLauncher($)
384    const fx = fxOf($, e.surface)
385    const tree = launcher({ el: $.ui.resolve(e) as unknown as El, state, theirs, open: () => { void game.actions(fx).open() } })
386    game.launcherDrawn(fx, e.surface, !state.prefs.quiet)
387    return tree
388  })
389
390  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
391    const battle = await read($, battleAtom)
392    const prefs = await read($, prefsAtom)
393    const suffix = spinnerSuffix(battle as GameState['battle'], e.props.suffix, e.props.mode, prefs.quiet)
394    return suffix === null ? next(e) : next({ ...e, props: { ...e.props, suffix } })
395  })
396}
397
hooks/client/types.ts 300 lines
1// The mod's internal contracts: the Backend both worlds implement, the effects register.tsx injects (Fx), the
2// game state (the $.state contract under plain names), the Actions views call, and the slots the band, the pane and
3// the offline engine plug into. No $ here: register.tsx is the only file that touches it (SPEC 11).
4import type { Elements, RenderElement } from 'claude-code'
5import type { ApiErrorCode, ApiOp, ApiRequest, ApiResponse, SpinlingsApi } from '../core/api.ts'
6import type { Family } from '../core/types.ts'
7import type {
8  SpinAccount, SpinBattle, SpinBattleLog, SpinBoardName, SpinBoardPeriod, SpinHoldAction, SpinMarketQuery, SpinMarketWant,
9  SpinMe, SpinMoment, SpinPane, SpinPrefs, SpinPresence, SpinRarity, SpinReveal, SpinSent, SpinSignals, SpinSocial, SpinTab,
10  SpinView, SpinWorld, SpinCard, SpinCommunitySection,
11} from '../../types/index.d.ts'
12
13export type {
14  SpinAccount as Account, SpinBattle as Battle, SpinBoardName as BoardName, SpinBoardPeriod as BoardPeriod, SpinCommunitySection as CommunitySection,
15  SpinCatch as Catch, SpinHold as Hold, SpinHoldAction as HoldAction, SpinLink as Link, SpinListing as Listing,
16  SpinMarketQuery as MarketQuery, SpinMarketWant as MarketWant, SpinMoment as Moment, SpinOutcome as Outcome,
17  SpinPane as PaneUi, SpinPlayerStats as PlayerStats, SpinPrefs as Prefs, SpinPresence as Presence,
18  SpinRankRow as RankRow, SpinRankings as Rankings, SpinReveal as Reveal, SpinSale as Sale, SpinSent as Sent,
19  SpinSignals as Signals, SpinSignIn as SignIn, SpinSocial as Social, SpinTab as Tab, SpinView as View, SpinWorld as World,
20} from '../../types/index.d.ts'
21
22// ---------- backends ----------
23
24/** One rulebook, two backends (SPEC 28): RemoteBackend over HTTP, LocalBackend offline. Both reject with BackendError. */
25export type Backend = SpinlingsApi & {
26  /** every operation through one door: what RemoteBackend's methods and a test's fake both call */
27  call<K extends ApiOp>(op: K, req: ApiRequest<K>): Promise<ApiResponse<K>>
28}
29
30/**
31 * Why a call failed. `code` is the server's error code, or one of the client's own: `unavailable` when the server
32 * could not be used (no network, a timeout, a redirect, a body over 256 KB, an answer that is not the documented
33 * shape); `kind` says which. Offline, operations the local world lacks answer `not_allowed`. `retryAfterMs`: how long
34 * the server's Retry-After asked to wait before trying again, or null when it named no wait.
35 */
36export type FailureKind = 'refused' | 'network' | 'timeout' | 'redirect' | 'too_large' | 'bad_response' | 'unauthorized'
37export class BackendError extends Error {
38  readonly code: ApiErrorCode
39  readonly kind: FailureKind
40  readonly status: number
41  readonly retryAfterMs: number | null
42  constructor(code: ApiErrorCode, kind: FailureKind, status: number, message: string, retryAfterMs: number | null = null) {
43    super(message)
44    this.name = 'BackendError'
45    this.code = code
46    this.kind = kind
47    this.status = status
48    this.retryAfterMs = retryAfterMs
49  }
50}
51
52export function isBackendError(e: unknown): e is BackendError {
53  return e instanceof BackendError
54}
55
56/** The server could not be used at all (as opposed to it saying no). */
57export function isUnreachable(e: unknown): boolean {
58  return e instanceof BackendError && (e.kind === 'network' || e.kind === 'timeout' || e.kind === 'redirect' || e.kind === 'too_large'
59    || e.kind === 'bad_response' || (e.kind === 'refused' && e.status >= 500))
60}
61
62/** What the offline engine (client/local/**) gets: its save under `offline:v1`, time, randomness, the model's family. */
63export type LocalDeps = {
64  /** the save as stored, or undefined; sessions share the store, so read right before every write */
65  load(): Promise<unknown>
66  save(save: unknown): Promise<void>
67  now(): Promise<number>
68  /** uniform [0, 1) from crypto.getRandomValues: offline randomness (SPEC 28) */
69  random(): number
70  /** the family of the model in use, for a save created on the first me() (the starter team, the welcome packs) */
71  family(): Family
72}
73/** The offline engine's entry point: a Backend implementing every API_ROUTES[op].offline operation, `not_allowed` for the rest. */
74export type LocalBackendFactory = (deps: LocalDeps) => Backend
75
76// ---------- effects ----------
77
78export type Timer = { cancel(): void }
79/** The four original chimes (SPEC 13.12), synthesized locally from the notes in client/chimes.ts. */
80export type Chime = 'rare' | 'legendary' | 'evolve' | 'first'
81export type HttpInit = { method: string; headers: Record<string, string>; body?: string }
82export type HttpAnswer = { status: number; ok: boolean; headers: Record<string, string>; text: string }
83export type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
84
85/** The game's state: one $.state value per key (types/index.d.ts). */
86export type GameState = {
87  account: SpinAccount
88  me: SpinMe | null
89  cards: SpinCard[]
90  signals: SpinSignals
91  battle: SpinBattle | null
92  moments: SpinMoment[]
93  reveal: SpinReveal | null
94  social: SpinSocial
95  pane: SpinPane
96  prefs: SpinPrefs
97  presence: SpinPresence
98  privacy: SpinSent[]
99  clock: number
100}
101export type StateKey = keyof GameState
102
103/**
104 * Everything the game does to the world, injected by register.tsx from `$` (and by tests from fakes). The game
105 * itself never reads a clock, rolls a die or touches the network on its own.
106 */
107export type Fx = {
108  /** epoch ms ($.clock.now) */
109  now(): Promise<number>
110  /** uniform [0, 1) from crypto.getRandomValues */
111  random(): number
112  /** once, after ms, outside any hook ($.clock.after); timers end with the module (a reload) */
113  after(ms: number, fn: () => void): Timer
114  every(ms: number, fn: () => void): Timer
115  /** one request through $.http.fetch; rejects when the host could not send it */
116  fetch(url: string, init: HttpInit): Promise<HttpAnswer>
117  store: {
118    get(key: string): Promise<unknown>
119    set(key: string, value: unknown): Promise<void>
120    delete(key: string): Promise<void>
121    keys(): Promise<string[]>
122  }
123  /** $.state: reads one moment; update retries fn on a version conflict, so fn must be pure */
124  state: {
125    get<K extends StateKey>(key: K): Promise<GameState[K]>
126    update<K extends StateKey>(key: K, fn: (v: GameState[K]) => GameState[K]): Promise<GameState[K]>
127  }
128  ui: {
129    toast(text: string): void
130    /** the status line; undefined clears it */
131    status(text: string | undefined): void
132    /** a dim transcript line the model never reads (command output) */
133    log(text: string): void
134    /**
135     * Puts text on the clipboard of the surface the press came from: true only when it got there. A surface may have
136     * no way to it yet (the desktop), so a false shows the text to copy by hand and never claims a copy.
137     */
138    copy(text: string): Promise<boolean>
139    /** opens the pane with the keyboard; false when the surface could not place it */
140    openPane(): Promise<boolean>
141    closePane(): Promise<void>
142    /** repaints a mounted Raster of the band or the pane (the last drawn instance) without a render pass */
143    blit(site: 'band' | 'pane', key: string, cells: string): Promise<boolean>
144    /** one chime, only with sound on and not quiet; never waits, and a failure stays silent */
145    sound(cue: Chime): void
146  }
147}
148
149// ---------- what views get ----------
150
151/** The element table from $.ui.resolve(e), completed: a name the surface lacks draws nothing (Raster on desktop, Svg on terminal). */
152export type El = Elements['terminal'] & Pick<Elements['desktop'], 'Svg'>
153
154/** Everything the views can do; each returns once its work settled, errors already shown (pane message or a band line). */
155export type WorldChoice = { world: SpinWorld; server: string; origin?: string }
156
157export type Actions = {
158  // band
159  /** [1] Now!: makes the special firing in the round being animated a Perfect one */
160  press(): Promise<void>
161  /** catch choice [1]-[3] */
162  pickCatch(index: number): Promise<void>
163  /** a band moment's primary action ([1] or [o]) */
164  act(momentId: string): Promise<void>
165  /** esc, Later, or the band's own timer */
166  dismiss(momentId: string): Promise<void>
167
168  // pane navigation
169  /** opens the pane (from a press, so it seats at any width), optionally on a tab and a view */
170  open(to?: { tab?: SpinTab; view?: SpinView; community?: SpinCommunitySection; revealId?: string }): Promise<void>
171  close(): Promise<void>
172  tab(tab: SpinTab): Promise<void>
173  /** Switches Community's local section; its card/player details still use the stack. */
174  community(section: SpinCommunitySection): Promise<void>
175  push(view: SpinView): Promise<void>
176  back(): Promise<void>
177  /** pane-local changes: filters, page, an offer being built */
178  pane(fn: (p: SpinPane) => SpinPane): Promise<void>
179  /** starts or completes a 2-second hold (SPEC 21.8): the first press arms, a press after 2 s does it */
180  hold(action: SpinHoldAction, target: string): Promise<void>
181
182  // collection
183  openPack(packId?: string, inline?: boolean): Promise<void>
184  /** the reveal ceremony: flip the next card, or finish */
185  flip(revealId?: string): Promise<void>
186  doneReveal(revealId?: string): Promise<void>
187  /** picker is local context only: a stale card/slot choice never sends a team request. */
188  setTeam(cardIds: string[], picker?: { cardId: string; slot: number; team: string[]; world: SpinWorld; server: string }): Promise<void>
189  setForTrade(cardId: string, forTrade: boolean): Promise<void>
190  craft(speciesId: string, rarity: SpinRarity): Promise<void>
191  buyPack(family?: Family): Promise<void>
192  share(cardId?: string): Promise<void>
193  /** copies only the current online player's public profile URL */
194  shareProfile(): Promise<void>
195  /** puts the update command on the clipboard (the footer's version chip); the pane says whether it took */
196  copyUpdate(): Promise<void>
197
198  // battles
199  /** a duel now (`/spin battle`), or a revenge on a handle from a defense notice */
200  duel(revenge?: string): Promise<void>
201  /** a friendly duel against one player's saved team, picked by handle (`/spin duel <handle>`, Challenge buttons) */
202  challenge(handle: string): Promise<void>
203
204  // the market and the boards (online only)
205  /** reads the market for the pane's filters; `more` appends the next page */
206  market(more?: boolean): Promise<void>
207  /** lists one of your cards: whole sparks (0: a card only) and the card it asks for, if any */
208  list(cardId: string, price: number, want: SpinMarketWant | null): Promise<void>
209  /** buys a listing, handing over `cardId` when the listing wants a card */
210  buy(listingId: string, cardId: string | null): Promise<void>
211  /** reads the recent sale prices of one species (the sell view's hints), leaving the Market section as it is */
212  prices(species: string): Promise<void>
213  /** reads one leaderboard */
214  rankings(board: SpinBoardName, period: SpinBoardPeriod): Promise<void>
215  /** the market's filter chips: changes them and reads the market again */
216  marketFilter(change: Partial<SpinMarketQuery & { mine: boolean }>): Promise<void>
217
218  // trading (online only; offline shows the one-line "needs the online world")
219  profile(handle: string): Promise<void>
220  load(what: 'board' | 'trader' | 'leaderboard' | 'devices'): Promise<void>
221  offer(to: string, give: string[], get: string[]): Promise<void>
222  respond(offerId: string, answer: 'accept' | 'decline'): Promise<void>
223  counter(offerId: string, give: string[], get: string[]): Promise<void>
224  claim(code: string): Promise<void>
225  redeem(code: string): Promise<void>
226  wishlist(species: string[]): Promise<void>
227  trade(dealId: string, cardIds: string[]): Promise<void>
228
229  // world, account, privacy
230  /** online always means spinlings.dev; an address first gets the community notice. */
231  world(target: string, choice?: WorldChoice): Promise<void>
232  /** connects to a community server after its one-time notice */
233  connect(origin: string, choice?: WorldChoice): Promise<void>
234  passkey(kind: 'add' | 'signin'): Promise<void>
235  rerollHandle(): Promise<void>
236  leaderboard(optIn: boolean): Promise<void>
237  prefs(change: Partial<SpinPrefs>): Promise<void>
238}
239
240/** What a view builder gets besides the state it draws. */
241export type ViewEnv = {
242  el: El
243  surface: Surface
244  /** cells across (e.props.bodyColumns) */
245  columns: number
246  /** rows the site may take: the band's maxRows, the pane's bodyRows */
247  rows: number
248  /** state.clock: the minute clock, for timers and "today" */
249  now: number
250  actions: Actions
251}
252
253export type BandState = Pick<GameState, 'account' | 'me' | 'signals' | 'battle' | 'moments' | 'prefs' | 'cards'> & Partial<Pick<GameState, 'reveal' | 'pane'>>
254/** The band (ui/band*.tsx): null when nothing is live, so the engine's own band shows. */
255export type BandView = (env: ViewEnv & { state: BandState; isWorking: boolean }) => RenderElement | null
256/** The pane (ui/pane*.tsx): always a tree; header, body, hint row on every tab (SPEC 21). */
257export type PaneView = (env: ViewEnv & { state: GameState; focused: boolean; placement: 'dock' | 'inline'; hitAreas?: boolean }) => RenderElement
258
259// ---------- drivers: timed sequences the band and the pane plug in ----------
260
261/** A live battle's controls, handed to the battle driver. Every call reads fresh state. */
262export type BattleControl = {
263  battle(): Promise<SpinBattle | null>
264  /** what to show: core simulateBattle(setup, inputs so far) when live; the server's log (after finishing) when not */
265  log(): Promise<SpinBattleLog>
266  phase(phase: SpinBattle['phase']): Promise<void>
267  /** a state transition: rounds 1..n are on screen */
268  show(rounds: number): Promise<void>
269  /** ms per round: game.ts ROUND_MS, one pace for every battle */
270  paceMs(): Promise<number>
271  /** finishes on the server once allowed (finishAfter) and hands the outcome to the band; resolves when done */
272  settle(): Promise<void>
273}
274/** Plays one battle from rustle to settle (client/scheduler.ts). Resolves when the battle is settled or gone. */
275export type BattleDriver = (fx: Fx, ctl: BattleControl) => Promise<void>
276
277/** The reveal ceremony's controls, handed to the reveal driver. */
278export type RevealControl = {
279  reveal(): Promise<SpinReveal | null>
280  flipped(): Promise<number>
281  /** a state transition: n cards face up */
282  flip(n: number): Promise<void>
283  /** a state transition: the pack's tear has played, so the strip of backs comes out */
284  tear(): Promise<void>
285  motion(): Promise<boolean>
286}
287/** Auto-flips the reveal (every 1.2 s by default, SPEC 13.5); stops when the reveal closes. */
288export type RevealDriver = (fx: Fx, ctl: RevealControl) => Promise<void>
289
290/** Animates the band's moments without a battle (the welcome, a ready pack, a present); runs for the module's life. */
291export type MomentDriver = (fx: Fx) => Promise<void>
292
293/** What register.tsx's slots hand the game. */
294export type Slots = {
295  local: LocalBackendFactory
296  battle: BattleDriver
297  reveal: RevealDriver
298  moments: MomentDriver | null
299}
300
hooks/client/game.ts 2787 lines
1// The orchestrator: zero-friction first run (SPEC 34), the two worlds and the switch between them (28), the version
2// handshake (32), presence and pack charging (6), waiting battles on the encounter timing (13), the content-blind
3// signal handlers (10), and every action the band and the pane can take. Pure in the sense the mod needs: it touches
4// the world only through the injected Fx, so it never reads a clock, rolls a die or sends a request on its own.
5import type { ApiOp, ApiRequest, ApiResponse, CardsResponse, ListingView, MeResponse, Notice, VersionResponse } from '../core/api.ts'
6import { API_ROUTES } from '../core/api.ts'
7import type { BattleCard, BattleLog, Card, Family, Rarity } from '../core/types.ts'
8import { RULES_VERSION, perfectRounds, simulateBattle } from '../core/battle.ts'
9import { cardName, rarityRank, toBattleCard } from '../core/cards.ts'
10import { ECONOMY, finishAfter, leagueOf } from '../core/economy.ts'
11import { FAMILY_INFO, familyOfModel } from '../core/families.ts'
12import { DEFAULT_SERVER } from '../core/servers.ts'
13import { GENERATOR_VERSION } from '../core/species.ts'
14import { emojiMosaic, miniSprite, spriteFor } from '../core/sprite.ts'
15import { DAY_MS, seasonOf, utcDay } from '../core/world.ts'
16import { findCard, parseCommand } from './commands.ts'
17import { effortOf } from './effort.ts'
18import { hostOf, pageUrl, parseSent, pushSent, serverOrigin } from './net.ts'
19import {
20  CLIENT_VERSION, UPDATE_COMMAND, createRemoteBackend, forgetToken, joinServer, loadToken, saveToken, versionDue, versionStatus,
21} from './remote.ts'
22import type { RemoteDeps } from './remote.ts'
23import {
24  BEGINNER_BATTLES, HEARTBEAT_MS, REACTION_MS, afterCharge, chargeDue, encounterDue, leaseFor, mayHold, nextCheckIn,
25  reactionLine, restingUntil, tickPresence, workedAfter,
26} from './session.ts'
27import type { ServerMeta, StoredPrefs, StoredPresence } from './store.ts'
28import { KEYS, cacheRecord, readCache, readMeta, readOfflineMeta, readPrefs, readPresence, serverKeys } from './store.ts'
29import type {
30  Account, Actions, Backend, Battle, BattleControl, BoardName, BoardPeriod, Catch, Chime, Fx, GameState, HoldAction, MarketQuery,
31  MarketWant, Moment, Outcome, PaneUi, Reveal, RevealControl, Sent, SignIn, Slots, StateKey, Surface, Tab, Timer, View, World, WorldChoice, CommunitySection,
32} from './types.ts'
33import { BackendError, isBackendError, isUnreachable } from './types.ts'
34import { dayLabel, dots, safe, title } from './text.ts'
35import { createFrozenCatalog, createSeasonCache } from './frozen.ts'
36import { teamSlotChoices } from './team-slots.ts'
37import type { FrozenCatalog } from './frozen.ts'
38
39const B = ECONOMY.battle
40const PANE_REFRESH_MS = 5 * 60_000
41const HOLD_MS = 2000
42const PACK_READY_MS = 6000
43const HINT_MS = 10_000
44/** After a failed version handshake, /spin version reports the last answer at once for this long rather than wait again. */
45const VERSION_RETRY_MS = 10 * 60_000
46/** A picked catch waits this long for its answer before the band lets it go (the request times out sooner). */
47const CATCH_WAIT_MS = 20_000
48const POLL_MS = ECONOMY.client.pollEveryMs
49const OFFLINE_FALLBACK = 'Playing offline · /spin world online when you\'re connected'
50const MAX_AGENTS = 32
51
52/**
53 * How long a round plays: one pace for every battle. A battle is finished on the server once its rounds have played,
54 * so a pace that followed the effort setting would tell the server that setting through the request's timing, and the
55 * input window of every round has to have closed before the request leaves (SPEC 15, 20.2).
56 */
57export const ROUND_MS = ECONOMY.battle.roundMs
58
59// ---------- the initial state of every $.state value ----------
60
61export const ONLINE_FEATURES_UNKNOWN = '*'
62export const OFFLINE_FEATURES = ['rivals', 'trader', 'mythics', 'seasons']
63
64/** The Market section's chips before any is pressed: everything, newest first. */
65export const MARKET_DEFAULT: MarketQuery & { mine: boolean } = {
66  family: 'all', rarity: 'all', kind: 'all', sort: 'newest', shiny: false, foil: false, mine: false,
67}
68
69/** The pane's market chips, the defaults for a $.state value from before they existed. */
70export function marketChips(p: Pick<PaneUi, 'market'>): MarketQuery & { mine: boolean } {
71  return p.market ?? MARKET_DEFAULT
72}
73
74/** Older pane state used a separate Market tab; Community now owns the same filters and listings. */
75export function communitySection(p: Pick<PaneUi, 'tab' | 'community'>): CommunitySection {
76  return p.tab === 'market' ? 'market' : p.community ?? 'profile'
77}
78
79export const INITIAL: GameState = {
80  account: {
81    world: 'online', server: DEFAULT_SERVER, host: hostOf(DEFAULT_SERVER), community: false, link: 'starting', note: '',
82    readOnly: false, latest: null, features: [ONLINE_FEATURES_UNKNOWN], signIn: null, devices: null,
83  },
84  me: null,
85  cards: [],
86  signals: { family: 'sonnet', working: false, turnStartedAt: null, worked: 0, cheering: 0, restingUntil: null },
87  battle: null,
88  moments: [],
89  reveal: null,
90  social: { board: null, profile: null, trader: null, leaderboard: null, rankings: null, market: null, gift: null, loading: [] },
91  pane: {
92    tab: 'team', stack: [], family: 'all', rarity: 'all', album: 'haiku', page: 0, flipped: 0, hold: null, hello: false,
93    showUpdate: false, message: '', tone: 'warn', toCopy: '', community: 'profile', boards: { board: 'rating', period: 'all' }, market: MARKET_DEFAULT, busy: null, busySince: 0,
94  },
95  prefs: { quiet: false, motion: true, sound: false },
96  presence: { minutes: 0, need: ECONOMY.packs.presenceMinutes, blocked: null },
97  privacy: [],
98  clock: 0,
99}
100
101// ---------- pure helpers the views share ----------
102
103/** A feature the active server lists (or an unknown list, before the handshake). Hidden, not broken, when missing. */
104export function hasFeature(account: Account, feature: string): boolean {
105  return account.features.includes(ONLINE_FEATURES_UNKNOWN) || account.features.includes(feature)
106}
107
108/** Needs the online world: API_ROUTES says the offline backend lacks it. */
109export function needsOnline(op: ApiOp): boolean {
110  return !API_ROUTES[op].offline
111}
112
113/** A card's name at its stage, '?' when its species cannot be read (an unknown season the server has not sent yet). */
114export function nameOf(card: Pick<Card, 'species' | 'form' | 'stage'>): string {
115  try {
116    return safe(cardName(card), 24)
117  } catch {
118    return '?'
119  }
120}
121
122/** `soft-otter-42`, `Rival Thistlewick` or `wild Fogmaw`. */
123export function opponentLabel(opponent: Battle['opponent'], lead: Pick<Card, 'species' | 'form' | 'stage'> | null): string {
124  if (opponent.kind === 'player') return safe(opponent.handle, 40)
125  if (opponent.kind === 'rival') return `Rival ${safe(opponent.name, 30)}`
126  return lead ? `wild ${nameOf(lead)}` : 'wild creatures'
127}
128
129const logs = new Map<string, BattleLog>()
130
131/** What a live battle shows: core simulateBattle(setup, inputs) memoised; the server's log when rules differ. */
132export function battleLog(b: Battle): BattleLog | null {
133  if (!b.live) return b.log as BattleLog | null
134  const key = `${b.id}|${b.inputs.join(',')}`
135  let log = logs.get(key)
136  if (!log) {
137    if (logs.size >= 16) logs.clear()
138    log = simulateBattle(b.setup as never, b.inputs)
139    logs.set(key, log)
140  }
141  return log
142}
143
144/** The round `[1] Now!` is for: the round being animated when the attacker's special fires in it, else null. */
145export function perfectRound(b: Battle | null): number | null {
146  if (!b || !b.live || b.phase !== 'fight') return null
147  const log = battleLog(b)
148  const r = b.shown + 1
149  if (!log || r > log.rounds.length || b.inputs.includes(r)) return null
150  return perfectRounds(log).includes(r) ? r : null
151}
152
153/** The chime a reveal earns (SPEC 13.12): a legendary or a Mythic, a first discovery, then rare and up; else none. */
154export function chimeFor(cards: readonly Pick<Card, 'rarity' | 'species' | 'firstFind'>[]): Chime | null {
155  if (cards.some(c => c.rarity === 'legendary' || c.species === 'mythic')) return 'legendary'
156  if (cards.some(c => c.firstFind)) return 'first'
157  if (cards.some(c => c.rarity !== 'common')) return 'rare'
158  return null
159}
160
161/**
162 * The catch options in the order the band offers them, as the server's indexes: the rarest first, then shiny, then
163 * the highest level. So `1`, the primary choice, is also the pick made when nobody chooses (SPEC 21.7).
164 */
165export function catchOrder(options: readonly Pick<Card, 'rarity' | 'shiny' | 'level'>[]): number[] {
166  const score = (x: Pick<Card, 'rarity' | 'shiny' | 'level'>) => rarityRank(x.rarity) * 1000 + (x.shiny ? 100 : 0) + x.level
167  return options.map((_, i) => i).sort((a, b) => score(options[b]!) - score(options[a]!) || a - b)
168}
169
170/** The catch option picked when nobody chooses: the first the band offers. */
171export function rarestIndex(options: readonly Pick<Card, 'rarity' | 'shiny' | 'level'>[]): number {
172  return catchOrder(options)[0] ?? 0
173}
174
175/** Lower shows first; a catch waiting on a choice or an answer goes before everything (headMoment). */
176const MOMENT_RANK: Record<Moment['kind'], number> = {
177  outcome: 1, evolve: 2, 'pack-ready': 3, market: 3.5, present: 4, 'needs-online': 5, server: 5, welcome: 5.5, line: 6, passkey: 8,
178  update: 9,
179}
180
181/**
182 * A card that makes a collection worth keeping safe (SPEC 30): a legendary or a Mythic, a foil or a shiny, and, when
183 * caught, anything rare or better. Its arrival is when the passkey is offered.
184 */
185export function worthKeeping(c: Pick<Card, 'rarity' | 'species' | 'shiny' | 'foil'>, caught = false): boolean {
186  return c.rarity === 'legendary' || c.species === 'mythic' || c.foil === true || c.shiny || (caught && rarityRank(c.rarity) >= rarityRank('rare'))
187}
188
189/** GET /v1/market's query for the Market section's chips: only the fields that narrow, `after` for the next page. */
190export function marketRequest(q: MarketQuery, after?: string): ApiRequest<'market'> {
191  return {
192    ...(q.family !== 'all' ? { family: q.family } : {}),
193    ...(q.rarity !== 'all' ? { rarity: q.rarity } : {}),
194    ...(q.kind !== 'all' ? { kind: q.kind } : {}),
195    ...(q.shiny ? { shiny: true } : {}),
196    ...(q.foil ? { foil: true } : {}),
197    sort: q.sort,
198    ...(after ? { after } : {}),
199  }
200}
201
202/** Recent sales by species: what came last first, at most 200 species kept. */
203export function mergePrices<T extends { species: string }>(kept: readonly T[] | undefined, fresh: readonly T[]): T[] {
204  return [...fresh, ...(kept ?? []).filter(p => !fresh.some(r => r.species === p.species))].slice(0, 200)
205}
206
207const sameQuery = (a: MarketQuery, b: MarketQuery) =>
208  a.family === b.family && a.rarity === b.rarity && a.kind === b.kind && a.sort === b.sort && a.shiny === b.shiny && a.foil === b.foil
209
210/** The listings `before` held that `after` no longer does: what sold or lapsed since. */
211export function goneListings(before: readonly ListingView[] | undefined, after: readonly ListingView[] | undefined): ListingView[] {
212  const still = new Set((after ?? []).map(l => l.id))
213  return (before ?? []).filter(l => !still.has(l.id))
214}
215
216/** The sparks a sale notice states ("sold for 75 sparks", "sold for a card in return": 0); null when it says neither. */
217export function soldPrice(text: string): number | null {
218  const n = /sold for ([\d,]+) sparks/.exec(text)
219  if (n) return Number(n[1]!.replace(/,/g, ''))
220  return /for a card in return/.test(text) ? 0 : null
221}
222
223/**
224 * Which gone listing each new sale or lapse notice is about, by evidence rather than order (notices name no listing):
225 * a sale takes the one listing at the price it states, a lapse the one listing past its 14 days. With no single match
226 * the notice gets no card, so the band says "Your card" rather than name the wrong creature; a sale keeps its stated
227 * price either way. `notices` oldest first.
228 */
229export function pairMarketNotices(notices: readonly Notice[], gone: readonly ListingView[], now: number): Map<string, { card: BattleCard | null; price: number }> {
230  const pool = [...gone]
231  const out = new Map<string, { card: BattleCard | null; price: number }>()
232  const take = (match: (l: ListingView) => boolean): ListingView | null => {
233    const hits = pool.filter(match)
234    if (hits.length !== 1) return null
235    pool.splice(pool.indexOf(hits[0]!), 1)
236    return hits[0]!
237  }
238  const today = utcDay(now)
239  const lapsed = (l: ListingView) => today >= utcDay(Date.parse(`${l.day}T00:00:00Z`) + ECONOMY.market.ttlMs + DAY_MS)
240  // sales first: a price is the stronger evidence, and a listing sold leaves the lapses fewer to choose from
241  for (const n of notices) {
242    if (n.kind !== 'market-sold') continue
243    const price = soldPrice(n.text)
244    // a line in words this client cannot read: only a listing alone in the pool is sure
245    const l = take(x => price === null || x.price === price)
246    out.set(n.id, { card: l ? toBattleCard(l.card) : null, price: price ?? l?.price ?? 0 })
247  }
248  for (const n of notices) {
249    if (n.kind !== 'market-expired') continue
250    const l = take(lapsed)
251    out.set(n.id, { card: l ? toBattleCard(l.card) : null, price: 0 })
252  }
253  return out
254}
255
256/**
257 * The moment the band shows now: a catch choice first, then a result, an evolution, a ready pack, a present, the
258 * welcome, a notice, the passkey offer, the update line; the oldest first within a kind. Null when the queue is empty.
259 */
260export function headMoment(list: readonly Moment[]): Moment | null {
261  let best: Moment | null = null
262  let rank = Infinity
263  for (const m of list) {
264    const r = m.kind === 'outcome' && (m.outcome.catch.status === 'choose' || m.outcome.catch.status === 'catching') ? 0
265      : m.kind === 'line' && m.tone !== 'notice' ? MOMENT_RANK.line + 0.5 : MOMENT_RANK[m.kind]
266    if (r < rank) { best = m; rank = r }
267  }
268  return best
269}
270
271/**
272 * The newer mod the active world knows of (SPEC 32): online only, and null while this one is current or before the
273 * first handshake. A `$.state` value from before the field existed reads as null.
274 */
275export function newerMod(account: Pick<Account, 'world'> & { latest?: string | null }): string | null {
276  return account.world === 'online' && typeof account.latest === 'string' && account.latest !== '' ? safe(account.latest, 48) : null
277}
278
279/** A pack indicator never advertises a signed-out or changing account's cached collection. */
280export function packReady(s: Pick<GameState, 'account' | 'me' | 'prefs'>): boolean {
281  return !s.prefs.quiet && (s.account.link === 'ready' || s.account.link === 'unreachable') && (s.me?.packs.length ?? 0) > 0
282}
283
284/** Passive fallback only: the host supplies the plugin name; a waiting pack adds a small dot, otherwise nothing. */
285export function statusLine(s: Pick<GameState, 'account' | 'me' | 'battle' | 'prefs' | 'signals'>): string | undefined {
286  return packReady(s) ? '▪' : undefined
287}
288
289/** The spinner's suffix during a battle (SPEC 10): only the phase mode is read, never the spinner's words. */
290export function spinnerSuffix(b: Battle | null, base: string, mode: string, quiet: boolean): string | null {
291  if (!b || quiet || mode === 'tool-input') return null
292  return `${base} · ${opponentLabel(b.opponent, b.setup.defender[0] ?? null)}`
293}
294
295/** The share text (SPEC 9): an 8x8 mosaic, the name, family, rarity and finish, and the card page (or "offline save"). */
296export function shareText(card: Card, world: World, origin: string): string {
297  let mosaic = ''
298  try {
299    mosaic = emojiMosaic(miniSprite(spriteFor(card)))
300  } catch {
301    mosaic = ''
302  }
303  const finish = dots(card.shiny && 'Alt colour', card.foil && 'Foil')
304  const line = dots(nameOf(card), FAMILY_INFO[card.family].name, title(card.rarity), finish)
305  const link = world === 'online' ? pageUrl(origin, 'c', card.id) : `offline save · ${DEFAULT_SERVER}`
306  return [mosaic, line, link].filter(s => s !== '').join('\n')
307}
308
309/** Plain words for a failure (SPEC 21.5). */
310export function failureText(err: unknown, host: string): string {
311  if (!isBackendError(err)) return 'Something went wrong. Try again in a moment.'
312  switch (err.code) {
313    case 'insufficient_sparks': return 'Not enough sparks for that yet.'
314    case 'rate_limited': return 'Not just yet. Your team needs a breather.'
315    case 'cap_reached': return safe(err.message, 80) || 'There is no room for that right now.'
316    case 'not_found': return 'That is not there any more.'
317    case 'conflict': return 'That already happened.'
318    case 'not_allowed': return 'That can\'t be done here.'
319    case 'expired': return 'That has expired.'
320    case 'upgrade_required': return `This version is read-only on ${host} · ${UPDATE_COMMAND}`
321    case 'unauthorized': return `This computer is signed out of ${host}.`
322    case 'unavailable': return isUnreachable(err) ? `Can't reach ${host} right now.` : safe(err.message, 80)
323    default: return safe(err.message, 80) || 'That did not work.'
324  }
325}
326
327/**
328 * `/spin version` (SPEC 32): this mod's version and world; online, the server's host and its server, rules and
329 * generator versions, then "Up to date." or the update line. Offline names no server, since none is asked.
330 */
331export function versionReport(account: Pick<Account, 'world' | 'host' | 'community'>, v: VersionResponse | null, client = CLIENT_VERSION): string {
332  const ours = (word: string, theirs: number, mine: number) => `${word} ${theirs}${theirs === mine ? '' : ` (this mod: ${mine})`}`
333  if (account.world === 'offline') {
334    return [`Spinlings ${client} · offline, so no server is asked`, `Rules ${RULES_VERSION} · generator ${GENERATOR_VERSION}`].join('\n')
335  }
336  const host = safe(account.host, 80)
337  const where = `online on ${host}${account.community ? ' (a community server)' : ''}`
338  if (!v) return [`Spinlings ${client} · ${where}`, `Can't tell whether an update is out until ${host} answers.`].join('\n')
339  const s = versionStatus(v, client)
340  // a version is named only when it is a release: a pre-release tag is the server's free text
341  const status = s.readOnly ? `Read-only on ${host} until you update${s.target ? ` to ${s.target}` : ''} · ${UPDATE_COMMAND}`
342    : s.target ? `Spinlings ${s.target} is out · ${UPDATE_COMMAND}`
343    : 'Up to date.'
344  return [
345    `Spinlings ${client} · ${where} · server ${safe(v.server, 48)}`,
346    dots(ours('Rules', v.rules, RULES_VERSION), ours('generator', v.generator, GENERATOR_VERSION)),
347    status,
348  ].join('\n')
349}
350
351/** Far more pages of GET /v1/cards than any collection needs, so a server that never ends cannot hold the mod. */
352export const CARD_PAGES = 100
353
354/**
355 * Every card, a page at a time (SPEC 32), oldest first. A collection that changed between pages is read again from the
356 * start, up to 3 times; one still changing keeps what arrived under the first page's version, so the next refresh,
357 * which sees a newer version, reads it again.
358 */
359export async function allCards(backend: Pick<Backend, 'cards'>): Promise<CardsResponse> {
360  for (let tries = 1; ; tries++) {
361    const first = await backend.cards({})
362    const cards = [...first.cards]
363    let { next } = first
364    let moved = false
365    for (let page = 1; next !== undefined && page < CARD_PAGES && !moved; page++) {
366      const more = await backend.cards({ after: next })
367      moved = more.version !== first.version
368      cards.push(...more.cards)
369      next = more.next
370    }
371    if (!moved || tries === 3) return { cards, version: first.version }
372  }
373}
374
375// ---------- the game ----------
376
377export type GameOptions = {
378  slots: Slots
379  /** test seam */
380  remote?: (deps: RemoteDeps) => Backend
381}
382
383export type Game = {
384  /** session.start, and again after every reload of the module */
385  boot(fx: Fx, e: { model: string | null }): Promise<void>
386  /** classic.SessionStart after /clear, /resume or /branch: $.state starts over, the module does not */
387  reseed(fx: Fx): Promise<void>
388  /** session.end: hand the presence lamp back (the whole chain has 1.5 s) */
389  end(fx: Fx): Promise<void>
390  turnStarted(fx: Fx): Promise<void>
391  turnStep(fx: Fx, model: string, effort?: unknown): Promise<void>
392  turnCompleted(fx: Fx, reason: string): Promise<void>
393  agentStarted(fx: Fx, agentId: string): Promise<void>
394  agentFinished(fx: Fx, agentId: string): Promise<void>
395  measured(fx: Fx, limits: readonly { percentUsed: number; resetsAt?: string }[]): Promise<void>
396  compacted(fx: Fx, trigger: string): Promise<void>
397  heartbeat(fx: Fx): Promise<void>
398  command(fx: Fx, args: string): Promise<void>
399  /** ui.close for the pane: true keeps it open (esc went back one view) */
400  paneClosing(fx: Fx, byPerson: boolean): Promise<boolean>
401  actions(fx: Fx): Actions
402  /** Supported composer sites actually drawn this conversation replace the session's plain-text status row. */
403  launcherDrawn(fx: Fx, surface: Surface, shown: boolean): void
404  /** the band and pane instances last drawn, for blit */
405  site(kind: 'band' | 'pane', requestId: string): void
406  sites(): { band: string | null; pane: string | null }
407}
408
409export function createGame(o: GameOptions): Game {
410  // in-memory only; every reload starts these over, and boot() rebuilds what matters from $.state and $.store
411  const rt = {
412    fx: null as Fx | null,
413    holder: '',
414    heartbeat: null as Timer | null,
415    turnSeq: 0,
416    encounter: null as Timer | null,
417    driving: null as string | null,
418    revealing: null as string | null,
419    revealSeq: 0,
420    revealEpoch: 0,
421    revealId: null as string | null,
422    revealContinuation: null as { from: string; to: string; before: number; after: number } | null,
423    openingPack: null as (() => boolean) | null,
424    /** Same-server sign-in/reset also invalidates a pending pack action. */
425    packAccount: 0,
426    /** New world choices and cancelled notices invalidate earlier asynchronous choices. */
427    worldChoice: 0,
428    worldPreparing: null as { seq: number; origin: string | null; chooser: boolean } | null,
429    /** the band's moment driver runs once per load of the module */
430    animating: false,
431    /**
432     * Moves on whenever the world or the server in play changes (setAccount): work begun for the one left behind (a
433     * connect, a refresh, the sign-in poll, a battle) sees that, sends nothing more and drops what it brings back.
434     */
435    place: 0,
436    /** the connect under way, and the place it is for */
437    connecting: null as { place: number; done: Promise<void> } | null,
438    poll: null as Timer | null,
439    expiry: new Map<string, Timer>(),
440    agents: new Map<string, number>(),
441    /** battles whose catch request is in flight in this load of the module */
442    catching: new Set<string>(),
443    /** a buy or a listing in flight: a second press of 1 while it runs sends nothing */
444    trading: false,
445    /** when the Market section's listings were last read in this load of the module (0: never) */
446    marketAt: 0,
447    status: null as string | undefined | null,
448    launchers: new Set<Surface>(),
449    /** server clock minus local clock, from the last me() */
450    skew: 0,
451    lastRefresh: 0,
452    /** when the last version handshake failed in this load: /spin version does not wait on another for a while */
453    versionFailedAt: -Infinity,
454    local: null as Backend | null,
455    remote: null as { origin: string; backend: Backend; frozen: FrozenCatalog } | null,
456    sites: { band: null as string | null, pane: null as string | null },
457    family: 'sonnet' as Family,
458  }
459
460  const cur = (fx: Fx) => rt.fx ?? fx
461  const enter = (fx: Fx) => { rt.fx = fx }
462  const seasonCache = createSeasonCache({
463    get: key => rt.fx!.store.get(key), set: (key, value) => rt.fx!.store.set(key, value),
464    delete: key => rt.fx!.store.delete(key), keys: () => rt.fx!.store.keys(),
465  })
466
467  // ---------- state ----------
468
469  const get = <K extends StateKey>(fx: Fx, k: K) => fx.state.get(k)
470  const upd = <K extends StateKey>(fx: Fx, k: K, fn: (v: GameState[K]) => GameState[K]) => {
471    return fx.state.update(k, value => {
472      const next = fn(value)
473      if (k === 'pane' && rt.worldPreparing?.chooser && rt.worldPreparing.seq === rt.worldChoice
474        && (value as PaneUi).stack.at(-1)?.kind === 'world' && (next as PaneUi).stack.at(-1)?.kind !== 'world') {
475        rt.worldChoice++
476        rt.worldPreparing = null
477      }
478      if (k === 'reveal') {
479        const before = (value as Reveal | null)?.id ?? null
480        rt.revealId = (next as Reveal | null)?.id ?? null
481        if (before !== rt.revealId) rt.revealEpoch++
482      }
483      return next
484    })
485  }
486  const put = <K extends StateKey>(fx: Fx, k: K, v: GameState[K]) => upd(fx, k, () => v)
487  const sleep = (fx: Fx, ms: number) => new Promise<void>(r => { fx.after(Math.max(0, ms), r) })
488  /**
489   * For work about to begin: true while the world and the server in play stay the ones in play now. Taken before the
490   * account is read, so a switch landing during that read shows too. State written after an await checks it, inside
491   * the update where it can, so nothing lands in a world switched to since (SPEC 28, 33).
492   */
493  const stays = (): (() => boolean) => {
494    const place = rt.place
495    return () => rt.place === place
496  }
497
498  const packStays = (): (() => boolean) => {
499    const here = stays(), account = rt.packAccount
500    return () => here() && rt.packAccount === account
501  }
502
503  /** Nested pack helpers cannot write or open UI for an account that has since been left. */
504  function packFx(fx: Fx, ok: () => boolean): Fx {
505    return { ...fx,
506      state: { get: key => fx.state.get(key), update: (key, fn) => fx.state.update(key, value => ok() ? fn(value) : value) },
507      store: { ...fx.store,
508        set: async (key, value) => { if (ok()) await fx.store.set(key, value) },
509        delete: async key => { if (ok()) await fx.store.delete(key) },
510      },
511      ui: { ...fx.ui,
512        openPane: async () => ok() ? fx.ui.openPane() : false,
513        toast: text => { if (ok()) fx.ui.toast(text) }, status: text => { if (ok()) fx.ui.status(text) },
514        log: text => { if (ok()) fx.ui.log(text) }, sound: cue => { if (ok()) fx.ui.sound(cue) },
515        blit: async (site, key, cells) => ok() ? fx.ui.blit(site, key, cells) : false,
516      },
517    }
518  }
519
520  async function publish(fx: Fx): Promise<void> {
521    const [account, me, battle, prefs, signals] = await Promise.all([
522      get(fx, 'account'), get(fx, 'me'), get(fx, 'battle'), get(fx, 'prefs'), get(fx, 'signals'),
523    ])
524    const text = rt.launchers.size > 0 ? undefined : statusLine({ account, me, battle, prefs, signals })
525    if (text !== rt.status) {
526      rt.status = text
527      fx.ui.status(text)
528    }
529  }
530
531  async function prefsRecord(fx: Fx): Promise<StoredPrefs> {
532    return readPrefs(await fx.store.get(KEYS.prefs))
533  }
534  async function savePrefs(fx: Fx, change: Partial<StoredPrefs>): Promise<StoredPrefs> {
535    const next = { ...(await prefsRecord(fx)), ...change }
536    await fx.store.set(KEYS.prefs, next)
537    return next
538  }
539  async function metaOf(fx: Fx, origin: string): Promise<ServerMeta> {
540    return readMeta(await fx.store.get(KEYS.meta(origin)))
541  }
542  async function saveMeta(fx: Fx, origin: string, change: Partial<ServerMeta>): Promise<void> {
543    await fx.store.set(KEYS.meta(origin), { ...(await metaOf(fx, origin)), ...change })
544  }
545
546  // ---------- backends ----------
547
548  function local(): Backend {
549    if (!rt.local) {
550      rt.local = o.slots.local({
551        load: () => rt.fx!.store.get(KEYS.offline),
552        save: save => rt.fx!.store.set(KEYS.offline, save),
553        now: () => rt.fx!.now(),
554        random: () => rt.fx!.random(),
555        family: () => rt.family,
556      })
557    }
558    return rt.local
559  }
560
561  function remote(origin: string): Backend {
562    if (rt.remote?.origin !== origin) {
563      const make = o.remote ?? createRemoteBackend
564      const raw = make({
565          origin,
566          fetch: (url, init) => rt.fx!.fetch(url, init),
567          token: () => loadToken(rt.fx!.store, origin),
568          now: () => rt.fx!.now(),
569          after: (ms, fn) => rt.fx!.after(ms, fn),
570          sent: entry => recordSent(rt.fx!, entry),
571        })
572      const frozen = createFrozenCatalog({
573        read: season => seasonCache.read(origin, season),
574        write: (season, data) => seasonCache.write(origin, season, data),
575        fetch: season => raw.season({ season }),
576        now: () => rt.fx!.now(),
577        changed: species => upd(rt.fx!, 'account', a =>
578          a.world === 'online' && a.server === origin ? { ...a, species } : a).then(() => undefined),
579      })
580      const call: Backend['call'] = async (op, req) => {
581        const ok = stays()
582        const answer = await raw.call(op, req)
583        if (!ok() || op === 'version' || op === 'season' || op === 'challenge') return answer
584        return frozen.hydrate(answer, [seasonOf(await rt.fx!.now())], ok)
585      }
586      const backend = { call } as Backend
587      for (const op of Object.keys(API_ROUTES) as ApiOp[]) backend[op] = (req: never) => call(op, req) as never
588      rt.remote = { origin, backend, frozen }
589    }
590    return rt.remote.backend
591  }
592
593  /** The active world's backend, its account, and `ok` while that world and server stay in play (stays). */
594  async function backendOf(fx: Fx): Promise<{ backend: Backend; account: Account; ok: () => boolean }> {
595    const ok = stays()
596    const account = await get(fx, 'account')
597    return { backend: account.world === 'offline' ? local() : remote(account.server), account, ok }
598  }
599
600  async function recordSent(fx: Fx, entry: Sent): Promise<void> {
601    const log = pushSent(await get(fx, 'privacy'), entry)
602    await put(fx, 'privacy', log)
603    await fx.store.set(KEYS.privacy, log)
604  }
605
606  // ---------- moments (the band's queue) ----------
607
608  async function pushMoment(fx: Fx, m: Moment): Promise<void> {
609    await upd(fx, 'moments', list => [...list.filter(x => x.id !== m.id), m].slice(-16))
610    scheduleExpiry(fx, m)
611  }
612
613  function scheduleExpiry(fx: Fx, m: Moment): void {
614    rt.expiry.get(m.id)?.cancel()
615    rt.expiry.delete(m.id)
616    if (m.until === null) return
617    void fx.now().then(now => {
618      rt.expiry.set(m.id, fx.after(Math.max(0, m.until! - now), () => { void expire(cur(fx), m.id) }))
619    })
620  }
621
622  async function expire(fx: Fx, id: string): Promise<void> {
623    rt.expiry.delete(id)
624    const now = await fx.now()
625    const m = (await get(fx, 'moments')).find(x => x.id === id)
626    if (!m || m.until === null || m.until > now + 50) return
627    if (m.kind === 'outcome' && m.outcome.catch.status === 'choose') {
628      await pickCatch(fx, rarestIndex(m.outcome.catch.options))
629      return
630    }
631    await dropMoment(fx, id)
632  }
633
634  async function dropMoment(fx: Fx, id: string): Promise<void> {
635    rt.expiry.get(id)?.cancel()
636    rt.expiry.delete(id)
637    await upd(fx, 'moments', list => list.filter(x => x.id !== id))
638  }
639
640  async function line(fx: Fx, text: string, tone: 'hint' | 'reaction' | 'notice' = 'notice', ms: number | null = HINT_MS): Promise<void> {
641    const now = await fx.now()
642    await pushMoment(fx, { kind: 'line', id: `line:${tone}:${text}`, tone, text, until: ms === null ? null : now + ms })
643  }
644
645  /** A hint shown once ever, at the moment it matters (SPEC 21.3). */
646  async function hintOnce(fx: Fx, id: string, text: string): Promise<void> {
647    const prefs = await prefsRecord(fx)
648    if (prefs.hints.includes(id)) return
649    await savePrefs(fx, { hints: [...prefs.hints, id] })
650    await line(fx, text, 'hint')
651  }
652
653  /**
654   * On boot: drop what expired while the module was gone, re-arm the rest. A catch whose answer was lost with the old
655   * module (a reload mid-request) asks again; the server's catch is one-shot, so a repeat says how it went.
656   */
657  async function sweepMoments(fx: Fx): Promise<void> {
658    const now = await fx.now()
659    for (const m of await get(fx, 'moments')) {
660      if (m.kind === 'outcome' && m.outcome.catch.status === 'catching' && !rt.catching.has(m.outcome.battleId)) {
661        const { battleId } = m.outcome, index = m.outcome.catch.index
662        await setCatch(fx, battleId, m.outcome.catch, now + CATCH_WAIT_MS)
663        rt.catching.add(battleId)
664        fx.after(0, () => { void catchNow(cur(fx), battleId, index, true) })
665      } else if (m.until !== null && m.until <= now) await expire(fx, m.id)
666      else scheduleExpiry(fx, m)
667    }
668  }
669
670  // ---------- pane feedback ----------
671
672  /** The pane's one feedback line: a warning unless it says something went right (`good`). */
673  async function message(fx: Fx, text: string, tone: 'warn' | 'good' = 'warn'): Promise<void> {
674    await upd(fx, 'pane', p => ({ ...p, message: text, tone, busy: null }))
675  }
676
677  /**
678   * One operation on the active world's backend, with the pane's busy line and plain-words errors. An online-only
679   * operation in the offline world shows the one-line "needs the online world" instead (SPEC 28). An answer or a
680   * failure that comes back once play moved to another world or server is dropped: the caller gets null.
681   */
682  async function run<K extends ApiOp>(fx: Fx, op: K, req: ApiRequest<K>, label: string, valid: () => boolean = () => true): Promise<ApiResponse<K> | null> {
683    if (!valid()) return null
684    const { backend, account, ok: here } = await backendOf(fx)
685    const ok = () => valid() && here()
686    if (!ok()) return null
687    if (account.world === 'offline' && needsOnline(op)) {
688      await pushMoment(fx, { kind: 'needs-online', id: 'needs-online', until: (await fx.now()) + HINT_MS })
689      await message(fx, 'This needs the online world.')
690      return null
691    }
692    if (account.world === 'online' && account.readOnly && API_ROUTES[op].method !== 'GET' && op !== 'deleteMe') {
693      await message(fx, failureText(new BackendError('upgrade_required', 'refused', 426, ''), account.host))
694      return null
695    }
696    const now = await fx.now()
697    if (!ok()) return null
698    await upd(fx, 'pane', p => ok() ? { ...p, busy: label, busySince: now, message: '', toCopy: '' } : p)
699    if (!ok()) return null
700    try {
701      const res = await backend.call(op, req)
702      await upd(fx, 'pane', p => (ok() && p.busy === label ? { ...p, busy: null } : p))
703      return ok() ? res : null
704    } catch (err) {
705      if (ok()) await message(fx, failureText(err, account.host))
706      await noteFailure(fx, err, ok)
707      return null
708    }
709  }
710
711  /** The server no longer knows this machine's session (unused for 180 days, reset elsewhere, a wiped server). */
712  const signedOut = (host: string) => `This computer is signed out of ${host} · /spin world online starts fresh`
713
714  /**
715   * A failure that says something about the link itself: the link of the world and server it came from (`ok`), so a
716   * request still out when play moved on never marks the world switched to.
717   */
718  async function noteFailure(fx: Fx, err: unknown, ok: () => boolean): Promise<void> {
719    if (!isBackendError(err)) return
720    if (err.code === 'unauthorized') await upd(fx, 'account', a => (ok() ? { ...a, link: 'signed-out', note: signedOut(a.host) } : a))
721    else if (err.code === 'upgrade_required') await upd(fx, 'account', a => (ok() ? { ...a, readOnly: true } : a))
722    else if (isUnreachable(err)) await upd(fx, 'account', a => (ok() ? { ...a, link: a.link === 'ready' ? 'unreachable' : a.link, note: `Can't reach ${a.host} right now` } : a))
723  }
724
725  // ---------- me and cards ----------
726
727  /**
728   * The player as `origin` answered (null: the offline world), while `ok` says that world and server are still in
729   * play; the cache it fills is that origin's alone.
730   */
731  async function setMe(fx: Fx, me: MeResponse, origin: string | null, ok: () => boolean): Promise<void> {
732    const now = await fx.now()
733    if (!ok()) return
734    let took = false
735    const before = await get(fx, 'me')
736    await upd(fx, 'me', v => {
737      took = ok()
738      return took ? me : v
739    })
740    if (!took) return
741    rt.skew = me.now - now
742    rt.lastRefresh = now
743    if (me.packs.length < ECONOMY.packs.bank) await unblock(fx, 'bank')
744    if (origin !== null) {
745      const cache = readCache(await fx.store.get(KEYS.cache(origin)))
746      await fx.store.set(KEYS.cache(origin), cacheRecord(me, cache?.cards ?? null))
747    }
748    // a welcome whose pack is gone (opened in another session) has nothing left to open
749    const w = (await get(fx, 'moments')).find(m => m.kind === 'welcome')
750    if (w?.kind === 'welcome' && w.packId && !me.packs.some(p => p.id === w.packId)) await welcomeDone(fx, false)
751    // a trade accepted while away arrives as a wrapped present (SPEC 13.9); a sale or a lapse shows the creature itself
752    if (before) {
753      const seen = new Set(before.notices.map(n => n.id))
754      const fresh = me.notices.filter(n => !seen.has(n.id)).reverse()
755      // notices come newest first; each sale or lapse is paired with its listing by price and age, not by position
756      const paired = pairMarketNotices(fresh, goneListings(before.listings, me.listings), me.now)
757      for (const n of fresh) {
758        if (n.kind === 'offer-accepted' && n.handle) {
759          await pushMoment(fx, { kind: 'present', id: `present:${n.id}`, from: n.handle, cardIds: [], until: null })
760          await valueMoment(fx, null)
761        } else if (n.kind === 'market-sold' || n.kind === 'market-expired') {
762          const { card, price } = paired.get(n.id) ?? { card: null, price: 0 }
763          const sold = n.kind === 'market-sold'
764          await pushMoment(fx, {
765            kind: 'market', id: `market:${n.id}`, outcome: sold ? 'sold' : 'expired', card,
766            handle: sold ? n.handle ?? null : null, price, until: null,
767          })
768          if (sold) await valueMoment(fx, card)
769        }
770      }
771    }
772  }
773
774  // ---------- the passkey offer at a moment worth keeping (SPEC 30) ----------
775
776  /**
777   * The passkey offer when something just made the collection worth keeping: online on a server with passkeys, not
778   * yet backed up, at most once a UTC day (a "Later" waits for the next such moment), never once a passkey is saved.
779   */
780  async function valueMoment(fx: Fx, card: BattleCard | null, valid: () => boolean = () => true): Promise<void> {
781    const [account, me] = await Promise.all([get(fx, 'account'), get(fx, 'me')])
782    if (!valid() || account.world !== 'online' || !me || account.link !== 'ready' || account.readOnly || !hasFeature(account, 'passkey')) return
783    if (account.backedUp || (account.devices?.passkeys ?? 0) > 0) return
784    const meta = await metaOf(fx, account.server)
785    if (!valid()) return
786    const today = utcDay(await fx.now())
787    if (!valid() || meta.passkeyDay === 'saved' || meta.passkeyDay === today) return
788    const moments = await get(fx, 'moments')
789    if (!valid() || moments.some(m => m.kind === 'passkey')) return
790    const latest = await metaOf(fx, account.server)
791    if (!valid()) return
792    await fx.store.set(KEYS.meta(account.server), { ...latest, passkeyDay: today })
793    if (!valid()) return
794    const moment: Moment = { kind: 'passkey', id: 'passkey', until: null, ...(card ? { card: toBattleCard(card) } : {}) }
795    let added = false
796    await upd(fx, 'moments', list => {
797      added = valid() && !list.some(m => m.kind === 'passkey')
798      return added ? [...list, moment].slice(-16) : list
799    })
800    if (added) scheduleExpiry(fx, moment)
801  }
802
803  /** This account's passkey is saved, or known to be gone: the header's marker and the offers follow. */
804  async function setBackedUp(fx: Fx, origin: string, saved: boolean): Promise<void> {
805    const meta = await metaOf(fx, origin)
806    if (saved && meta.passkeyDay !== 'saved') await saveMeta(fx, origin, { passkeyDay: 'saved' })
807    if (!saved && meta.passkeyDay === 'saved') await saveMeta(fx, origin, { passkeyDay: '' })
808    await upd(fx, 'account', a => (a.world === 'online' && a.server === origin ? { ...a, backedUp: saved } : a))
809    if (saved) await dropMoment(fx, 'passkey')
810  }
811
812  /** The collection from `backend`, the world `origin` names (null: offline), unless `ok` says play has moved on. */
813  async function loadCards(fx: Fx, backend: Backend, origin: string | null, ok: () => boolean, force = false): Promise<void> {
814    const me = await get(fx, 'me')
815    const cards = await get(fx, 'cards')
816    const cache = origin !== null ? readCache(await fx.store.get(KEYS.cache(origin))) : null
817    if (!ok()) return
818    if (!force && me && cache?.cards && cache.cards.version === me.player.cardsVersion && cards.length > 0) {
819      await ensureSeasons(fx, cards)
820      return
821    }
822    const res = await allCards(backend)
823    let took = false
824    await upd(fx, 'cards', v => {
825      took = ok()
826      return took ? res.cards : v
827    })
828    if (!took) return
829    if (origin !== null && me) await fx.store.set(KEYS.cache(origin), cacheRecord(me, res))
830    await ensureSeasons(fx, res.cards)
831  }
832
833  /** me, then cards when they moved: after every change and every few minutes. */
834  async function refresh(fx: Fx): Promise<void> {
835    const { backend, account, ok } = await backendOf(fx)
836    const origin = account.world === 'online' ? account.server : null
837    try {
838      const before = await get(fx, 'me')
839      const had = new Set((await get(fx, 'cards')).map(c => c.id))
840      const me = await backend.me({})
841      await setMe(fx, me, origin, ok)
842      await loadCards(fx, backend, origin, ok, !before || before.player.cardsVersion !== me.player.cardsVersion)
843      if (ok()) await wrapPresent(fx, had)
844      await upd(fx, 'account', a => (ok() && a.link === 'unreachable' ? { ...a, link: 'ready', note: '' } : a))
845    } catch (err) {
846      await noteFailure(fx, err, ok)
847    }
848    await publish(fx)
849  }
850
851  /**
852   * A trade accepted while away arrives as a notice and, in the same refresh, as new cards: they go inside the waiting
853   * present, so opening it is a 1-card (or few-card) reveal (SPEC 13.9).
854   */
855  async function wrapPresent(fx: Fx, had: ReadonlySet<string>): Promise<void> {
856    if (had.size === 0) return
857    const empty = (await get(fx, 'moments')).filter(m => m.kind === 'present' && m.cardIds.length === 0)
858    const target = empty.at(-1)
859    if (!target) return
860    const fresh = (await get(fx, 'cards')).filter(c => !had.has(c.id)).map(c => c.id).slice(0, 3)
861    if (fresh.length === 0) return
862    await upd(fx, 'moments', list => list.map(m => (m.id === target.id && m.kind === 'present' ? { ...m, cardIds: fresh } : m)))
863  }
864
865  /** Frozen seasons are authoritative even when the current generator versions happen to match. */
866  async function ensureSeasons(fx: Fx, cards: readonly Card[]): Promise<void> {
867    const ok = stays()
868    const account = await get(fx, 'account')
869    if (account.world === 'offline') {
870      const catalog = createFrozenCatalog({
871        read: async () => null, write: async () => undefined,
872        fetch: season => local().season({ season }), now: () => fx.now(),
873        changed: species => upd(fx, 'account', a => ok() ? { ...a, species } : a),
874      })
875      await catalog.hydrate(cards, [seasonOf(await fx.now())], ok)
876      return
877    }
878    remote(account.server)
879    const resolved = await rt.remote!.frozen.hydrate(cards, [seasonOf(await fx.now())], ok)
880    await upd(fx, 'cards', current => ok() ? resolved as Card[] : current)
881  }
882
883  // ---------- boot and the first run (SPEC 34) ----------
884
885  async function boot(fx: Fx, e: { model: string | null }): Promise<void> {
886    enter(fx)
887    const now = await fx.now()
888    if (!rt.holder) rt.holder = Math.floor(fx.random() * 2 ** 48).toString(36) + now.toString(36)
889    if (e.model) rt.family = familyOfModel(e.model)
890    const prefs = await prefsRecord(fx)
891    // the world and the server are the player's own choices, made with /spin world and /spin server (SPEC 33, 34); a
892    // first run plays online. 0.1.0's world and server_url options are gone: what they last applied stays in prefs as
893    // it was, and a server chosen through server_url (before /spin server chose one) is still the server in play
894    const firstRun = prefs.world === null
895    const world: World = prefs.world ?? 'online'
896    const origin = serverOrigin(prefs.server ?? prefs.serverOption ?? DEFAULT_SERVER)
897    if (firstRun) await savePrefs(fx, { world })
898    await put(fx, 'prefs', { quiet: prefs.quiet, motion: prefs.motion, sound: prefs.sound })
899    await upd(fx, 'signals', s => ({ ...s, family: rt.family }))
900    await put(fx, 'clock', now)
901    await put(fx, 'privacy', parseSent(await fx.store.get(KEYS.privacy)))
902    // a reload into another world or server (switched in another session) brings nothing of the old one along: not its
903    // collection, its moments, or a battle to finish against the other (SPEC 28, 33)
904    const before = await get(fx, 'account')
905    const moved = before.world !== world || (world === 'online' && origin !== null && before.server !== origin)
906    await setAccount(fx, world, origin)
907    if (moved) await clearWorldState(fx)
908    if (world === 'online' && origin && !(await get(fx, 'me'))) {
909      const cache = readCache(await fx.store.get(KEYS.cache(origin)))
910      if (cache) {
911        await put(fx, 'me', cache.me)
912        if (cache.cards) await put(fx, 'cards', cache.cards.cards)
913      }
914    }
915    rt.heartbeat?.cancel()
916    rt.heartbeat = fx.every(HEARTBEAT_MS, () => { void heartbeat(cur(fx)) })
917    await sweepMoments(fx)
918    const battle = await get(fx, 'battle')
919    if (battle) drive(fx, battle.id)
920    const reveal = await get(fx, 'reveal')
921    if (reveal && !reveal.packId) revealDrive(fx, reveal.id)
922    if (!rt.animating && o.slots.moments) {
923      rt.animating = true
924      const driver = o.slots.moments
925      fx.after(0, () => { void driver(cur(fx)).catch(() => undefined).finally(() => { rt.animating = false }) })
926    }
927    await publish(fx)
928    // the join and the first answers never hold up the first prompt
929    fx.after(0, () => { void connect(cur(fx), { firstRun, explicit: false, fallback: firstRun }) })
930  }
931
932  /** Online with a stored server address Spinlings can't use (setAccount without an origin): no server is asked anything. */
933  const badAddress = (a: Account) => a.link === 'unreachable' && a.note.startsWith('The server address')
934
935  async function setAccount(fx: Fx, world: World, origin: string | null, intent?: WorldIntent): Promise<boolean> {
936    const current = await get(fx, 'account')
937    if (intent && !intent.before()) return false
938    const server = origin ?? current.server
939    // offline, the server is only where going online will go: the world in play stays as it is
940    const moving = current.world !== world || (world === 'online' && current.server !== server)
941    // work for the place being left stops now, and again once the account says so, for any that read it just before
942    if (moving && !intent) rt.place++
943    const here = stays()
944    // a usable address where prefs held one that isn't: the link starts over, even on the same server
945    const kept = (a: Account) => a.world === world && !(origin && badAddress(a))
946    let accepted = false
947    let fence: number | null = null
948    await upd(fx, 'account', a => {
949      accepted = !intent || intent.current() && (fence === null ? here() : rt.place === fence)
950      if (accepted && intent) {
951        if (moving && fence === null) { rt.place++; fence = rt.place }
952        intent.commit(rt.place)
953      }
954      return !accepted ? a : ({
955        ...a, world, server, host: hostOf(server), community: server !== DEFAULT_SERVER,
956        link: world === 'online' && !origin ? 'unreachable' : kept(a) && (world === 'offline' || a.server === server) ? a.link : 'starting',
957        note: world === 'online' && !origin ? 'The server address in settings is not one Spinlings can use (https only)' : kept(a) ? a.note : '',
958        features: world === 'offline' ? OFFLINE_FEATURES : a.world === 'offline' || a.server !== server ? [ONLINE_FEATURES_UNKNOWN] : a.features,
959        species: world === 'offline' || a.server !== server ? undefined : a.species,
960        readOnly: world === 'offline' ? false : a.readOnly,
961        // what a server said about newer mods holds for that server alone; the handshake says it again
962        latest: world === 'offline' || a.server !== server ? null : a.latest ?? null,
963      })
964    })
965    if (intent && (!accepted || !intent.current())) return false
966    if (moving && accepted) rt.place++
967    if (intent) intent.commit(rt.place)
968    return accepted
969  }
970
971  async function reseed(fx: Fx): Promise<void> {
972    rt.launchers.clear()
973    rt.status = null
974    await boot(fx, { model: null })
975  }
976
977  /**
978   * How a connect was asked for: the first run (a failed join plays offline, SPEC 34.4), an explicit switch (a failed
979   * join returns to the offline world with a line), or neither (a failure leaves the cache showing as unreachable).
980   */
981  type How = { firstRun: boolean; explicit: boolean; fallback: boolean }
982
983  /**
984   * Brings the active world up: online, a silent join without a session or the stored one; offline, the local save.
985   * One connect at a time per world and server: a call for the one under way shares it, and a call after a switch
986   * starts its own at once, while the one for the place left behind stops at its next step.
987   */
988  function connect(fx: Fx, how: How): Promise<void> {
989    const place = rt.place
990    if (rt.connecting?.place === place) return rt.connecting.done
991    const done: Promise<void> = connectNow(fx, how, () => rt.place === place).finally(() => {
992      if (rt.connecting?.done === done) rt.connecting = null
993    })
994    rt.connecting = { place, done }
995    return done
996  }
997
998  /** The join's pause between slices of its proof of work: once play moved elsewhere the work stops, the join unsent. */
999  async function joinPause(fx: Fx, ok: () => boolean): Promise<void> {
1000    await sleep(fx, 0)
1001    if (!ok()) throw new BackendError('unavailable', 'refused', 0, 'The join was called off')
1002  }
1003
1004  /**
1005   * One connect, for the world and server in play when it began (`ok`): every network wait is followed by a check, and
1006   * once play moved on it stops, sends nothing more and writes no state. A join that already went through keeps its
1007   * session for its own server, which no other world reads.
1008   */
1009  async function connectNow(fx: Fx, how: How, ok: () => boolean): Promise<void> {
1010    const account = await get(fx, 'account')
1011    if (!ok()) return
1012    if (account.world === 'offline') return connectOffline(fx, ok)
1013    if (badAddress(account)) return publish(fx)
1014    const origin = account.server
1015    const backend = remote(origin)
1016    const link = (change: (a: Account) => Partial<Account>) => upd(fx, 'account', a => (ok() ? { ...a, ...change(a) } : a))
1017    await handshake(fx, origin)
1018    const meta = await metaOf(fx, origin)
1019    const token = await loadToken(fx.store, origin)
1020    if (!ok()) return
1021    if (!token) {
1022      if (meta.deleted && !how.explicit) {
1023        await link(() => ({ link: 'signed-out', note: 'Your online account was deleted · /spin world online starts fresh' }))
1024        return publish(fx)
1025      }
1026      await link(() => ({ link: 'joining', note: '' }))
1027      try {
1028        if ((await get(fx, 'account')).readOnly) throw new BackendError('upgrade_required', 'refused', 426, 'This version is too old for the server')
1029        const joined = await joinServer(backend, rt.family, () => joinPause(fx, ok), ok)
1030        await saveToken(fx.store, origin, joined.token)
1031        await saveMeta(fx, origin, { deleted: false, welcomed: false })
1032        await setMe(fx, joined.me, origin, ok)
1033        await link(() => ({ link: 'ready', note: '' }))
1034        await loadCards(fx, backend, origin, ok, true)
1035      } catch (err) {
1036        if (!ok()) return
1037        if (how.fallback) return fallbackOffline(fx, how.firstRun, err, ok)
1038        await link(a => ({ link: 'unreachable', note: failureText(err, a.host) }))
1039        return publish(fx)
1040      }
1041    } else {
1042      try {
1043        await setMe(fx, await backend.me({}), origin, ok)
1044        await link(() => ({ link: 'ready', note: '' }))
1045        await loadCards(fx, backend, origin, ok)
1046      } catch (err) {
1047        if (isBackendError(err) && err.code === 'unauthorized') await link(a => ({ link: 'signed-out', note: signedOut(a.host) }))
1048        else await link(a => ({ link: 'unreachable', note: failureText(err, a.host) }))
1049        return publish(fx)
1050      }
1051    }
1052    if (!ok()) return
1053    // whether this account is backed up, as this machine last heard: the header's marker until a passkey is saved
1054    const saved = (await metaOf(fx, origin)).passkeyDay === 'saved'
1055    await upd(fx, 'account', a => (ok() ? { ...a, backedUp: saved } : a))
1056    if (!(await metaOf(fx, origin)).version) await handshake(fx, origin)
1057    if (ok()) await welcome(fx)
1058    await publish(fx)
1059  }
1060
1061  async function connectOffline(fx: Fx, ok: () => boolean): Promise<void> {
1062    const backend = local()
1063    try {
1064      await setMe(fx, await backend.me({}), null, ok)
1065      await loadCards(fx, backend, null, ok, true)
1066      await upd(fx, 'account', a => (ok() ? { ...a, link: 'ready', note: '' } : a))
1067      if (ok()) await welcome(fx)
1068    } catch (err) {
1069      // an unreadable save, or one from a newer mod, is left as it is and says so (SPEC 32)
1070      const note = (isBackendError(err) && safe(err.message, 120)) || 'The offline world could not be opened'
1071      await upd(fx, 'account', a => (ok() ? { ...a, link: 'unreachable', note } : a))
1072    }
1073    await publish(fx)
1074  }
1075
1076  /** A join that failed where it should play offline instead (`ok`: the connect it ended is still the one in play). */
1077  async function fallbackOffline(fx: Fx, firstRun: boolean, err: unknown, ok: () => boolean): Promise<void> {
1078    const account = await get(fx, 'account')
1079    if (!ok()) return
1080    await setAccount(fx, 'offline', account.server)
1081    const here = stays()
1082    await savePrefs(fx, { world: 'offline' })
1083    await clearWorldState(fx)
1084    await connectOffline(fx, here)
1085    if (!here()) return
1086    // on the first run the welcome itself says so, so the payoff is never held back by a line (SPEC 34.3, 34.4)
1087    const welcoming = firstRun && (await get(fx, 'moments')).some(m => m.kind === 'welcome')
1088    if (welcoming) await upd(fx, 'moments', list => list.map(m => (m.kind === 'welcome' ? { ...m, note: OFFLINE_FALLBACK } : m)))
1089    else await line(fx, firstRun ? OFFLINE_FALLBACK : `${failureText(err, account.host)} Still offline.`, 'notice', 15_000)
1090  }
1091
1092  /** The welcome (SPEC 34.3): once per world, until acted on or dismissed. */
1093  async function welcome(fx: Fx): Promise<void> {
1094    const me = await get(fx, 'me')
1095    const account = await get(fx, 'account')
1096    if (!me) return
1097    const welcomed = account.world === 'offline'
1098      ? readOfflineMeta(await fx.store.get(KEYS.offlineMeta)).welcomed
1099      : (await metaOf(fx, account.server)).welcomed
1100    if (welcomed) return
1101    const pack = me.packs.find(p => p.source === 'welcome') ?? null
1102    if (!pack && me.player.battles > 0) return markWelcomed(fx)
1103    await pushMoment(fx, { kind: 'welcome', id: 'welcome', packId: pack?.id ?? null, until: null })
1104  }
1105
1106  async function markWelcomed(fx: Fx): Promise<void> {
1107    const account = await get(fx, 'account')
1108    if (account.world === 'offline') {
1109      await fx.store.set(KEYS.offlineMeta, { ...readOfflineMeta(await fx.store.get(KEYS.offlineMeta)), welcomed: true })
1110    } else {
1111      await saveMeta(fx, account.server, { welcomed: true })
1112    }
1113  }
1114
1115  /** The version handshake, once a UTC day (SPEC 32). */
1116  async function handshake(fx: Fx, origin: string): Promise<void> {
1117    const now = await fx.now()
1118    const today = utcDay(now)
1119    const meta = await metaOf(fx, origin)
1120    let version = meta.version
1121    if (!version || versionDue(meta.versionDay, today) || meta.versionClient !== CLIENT_VERSION) {
1122      try {
1123        version = await remote(origin).version({})
1124        await saveMeta(fx, origin, { version, versionDay: today, versionClient: CLIENT_VERSION })
1125      } catch {
1126        // an older server without /v1/version, or a blip: keep the last answer
1127        rt.versionFailedAt = now
1128      }
1129    }
1130    if (!version) return
1131    const v = versionStatus(version)
1132    // what a server says holds for that server alone: an answer that lands after a switch is kept in its meta only
1133    let here = false
1134    await upd(fx, 'account', a => {
1135      here = a.world === 'online' && a.server === origin
1136      return here ? { ...a, readOnly: v.readOnly, features: v.features, latest: v.target } : a
1137    })
1138    if (!here) return
1139    if (v.update) {
1140      const prefs = await prefsRecord(fx)
1141      if (prefs.updateSeen !== v.update) {
1142        await savePrefs(fx, { updateSeen: v.update })
1143        await pushMoment(fx, { kind: 'update', id: `update:${v.update}`, version: v.update, until: null })
1144      }
1145    }
1146    const cards = await get(fx, 'cards')
1147    if (cards.length > 0) await ensureSeasons(fx, cards)
1148  }
1149
1150  async function clearWorldState(fx: Fx, intent?: WorldIntent): Promise<void> {
1151    if (intent && !intent.current()) return
1152    rt.packAccount++
1153    intent?.accountCleared(rt.packAccount)
1154    // a passkey flow belongs to the account it began for: its poll stops, and its page is no longer offered
1155    rt.poll?.cancel()
1156    rt.poll = null
1157    await put(fx, 'me', null)
1158    await put(fx, 'cards', [])
1159    await put(fx, 'battle', null)
1160    await put(fx, 'reveal', null)
1161    await put(fx, 'social', INITIAL.social)
1162    await upd(fx, 'moments', list => list.filter(m => m.kind === 'update' || !!intent && m.kind === 'server'
1163      && rt.worldPreparing?.seq === rt.worldChoice && m.origin === rt.worldPreparing.origin))
1164    await upd(fx, 'pane', p => ({ ...p, stack: [], hold: null, busy: null, message: '', toCopy: '' }))
1165    await upd(fx, 'account', a => {
1166      const { backedUp: _, ...rest } = a
1167      return { ...rest, signIn: null, devices: null }
1168    })
1169    if (intent && !intent.current()) return
1170    rt.packAccount++
1171    intent?.accountCleared(rt.packAccount)
1172  }
1173
1174  // ---------- signals (SPEC 10) ----------
1175
1176  /**
1177   * A turn starts: the encounter clock picks up from the working time earlier turns left (SPEC 13), so short turns add
1178   * up to an encounter instead of each starting over at 0.
1179   */
1180  async function turnStarted(fx: Fx): Promise<void> {
1181    enter(fx)
1182    const now = await fx.now()
1183    const seq = ++rt.turnSeq
1184    const s = await upd(fx, 'signals', x => ({ ...x, working: true, turnStartedAt: now, worked: x.worked ?? 0 }))
1185    rt.encounter?.cancel()
1186    rt.encounter = fx.after(nextCheckIn(s.worked ?? 0), () => { void encounterCheck(cur(fx), seq) })
1187  }
1188
1189  async function encounterCheck(fx: Fx, seq: number): Promise<void> {
1190    if (seq !== rt.turnSeq) return
1191    const signals = await get(fx, 'signals')
1192    if (!signals.working || signals.turnStartedAt === null) return
1193    const now = await fx.now()
1194    const worked = workedAfter(signals.worked ?? 0, signals.turnStartedAt, now)
1195    const kind = await encounterNow(fx, now, worked)
1196    if (kind) {
1197      await startBattle(fx, kind)
1198      if (await get(fx, 'battle')) return
1199    }
1200    if (seq === rt.turnSeq) rt.encounter = fx.after(nextCheckIn(worked) || B.encounterEveryMs, () => { void encounterCheck(cur(fx), seq) })
hooks/client/chimes.ts 90 lines
1// Original chimes as inspectable notes and synthesis, not bundled recordings. Pure and deterministic;
2// the optional audio effect sends the resulting WAV bytes to the engine's local player only.
3import type { Chime } from './types.ts'
4
5const RATE = 22_050
6const PEAK = 0.45
7
8type Note = { at: number; hz: number; ms: number; level?: number; glideTo?: number; shimmer?: boolean }
9
10export const CHIME_NAMES = ['rare', 'legendary', 'evolve', 'first'] as const
11const C6 = 1046.5, E6 = 1318.5, G5 = 784, G6 = 1568, B6 = 1975.5, C7 = 2093, D7 = 2349.3
12const NOTES: Record<Chime, Note[]> = {
13  rare: [{ at: 0, hz: E6, ms: 320, level: 0.8 }, { at: 90, hz: B6, ms: 380 }],
14  legendary: [
15    { at: 0, hz: C6, ms: 420, level: 0.7 }, { at: 85, hz: E6, ms: 420, level: 0.75 }, { at: 170, hz: G6, ms: 460, level: 0.8 },
16    { at: 255, hz: C7, ms: 900, shimmer: true },
17  ],
18  evolve: [{ at: 0, hz: G5, ms: 560, glideTo: G6, level: 0.75 }, { at: 470, hz: D7, ms: 420, shimmer: true }],
19  first: [
20    { at: 0, hz: G5, ms: 500, level: 0.6 }, { at: 60, hz: C6, ms: 520, level: 0.65 }, { at: 120, hz: E6, ms: 560, level: 0.7 },
21    { at: 300, hz: G6, ms: 950, shimmer: true },
22  ],
23}
24
25/** A soft bell: a few partials, a 6 ms attack and an exponential fade, so nothing clicks. */
26function bell(out: Float64Array, n: Note): void {
27  const start = Math.round((n.at / 1000) * RATE)
28  const len = Math.round((n.ms / 1000) * RATE)
29  const level = n.level ?? 1
30  const partials: [number, number][] = [[1, 1], [2, 0.32], [3, 0.12], [4.2, 0.05]]
31  let phase = 0
32  for (let i = 0; i < len && start + i < out.length; i++) {
33    const t = i / RATE
34    const hz = n.glideTo ? n.hz * Math.pow(n.glideTo / n.hz, Math.min(1, t / (len / RATE) / 0.7)) : n.hz
35    phase += (2 * Math.PI * hz) / RATE
36    const attack = Math.min(1, t / 0.006)
37    const fade = Math.exp(-t * (5.5 / (n.ms / 1000)))
38    const tremolo = n.shimmer ? 0.85 + 0.15 * Math.sin(2 * Math.PI * 7 * t) : 1
39    let v = 0
40    for (const [k, a] of partials) v += a * Math.sin(phase * k)
41    out[start + i]! += v * attack * fade * tremolo * level
42  }
43}
44
45function render(notes: Note[]): Float64Array {
46  const ms = Math.max(...notes.map(n => n.at + n.ms))
47  const out = new Float64Array(Math.ceil((ms / 1000) * RATE))
48  for (const n of notes) bell(out, n)
49  let max = 0
50  for (const v of out) max = Math.max(max, Math.abs(v))
51  if (max > 0) for (let i = 0; i < out.length; i++) out[i] = (out[i]! / max) * PEAK
52  return out
53}
54
55/** 16-bit PCM mono, little-endian: the same bytes as the original shipped recordings. */
56export function chimeWav(cue: Chime): Uint8Array {
57  const samples = render(NOTES[cue])
58  const bytes = new Uint8Array(44 + samples.length * 2)
59  const v = new DataView(bytes.buffer)
60  const text = (at: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(at + i, s.charCodeAt(i)) }
61  text(0, 'RIFF'); v.setUint32(4, 36 + samples.length * 2, true); text(8, 'WAVE')
62  text(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true)
63  v.setUint32(24, RATE, true); v.setUint32(28, RATE * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true)
64  text(36, 'data'); v.setUint32(40, samples.length * 2, true)
65  samples.forEach((s, i) => v.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, s)) * 32_767), true))
66  return bytes
67}
68
69const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
70function base64(bytes: Uint8Array): string {
71  const out: string[] = []
72  for (let i = 0; i < bytes.length; i += 3) {
73    const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
74    out.push(ALPHABET[n >>> 18]!, ALPHABET[(n >>> 12) & 63]!,
75      i + 1 < bytes.length ? ALPHABET[(n >>> 6) & 63]! : '=', i + 2 < bytes.length ? ALPHABET[n & 63]! : '=')
76  }
77  return out.join('')
78}
79
80const cached = new Map<Chime, string>()
81/** Immutable encoded clips are made once, on the first enabled playback of each cue. */
82export function chimeClip(cue: Chime): { base64: string; mime: 'audio/wav' } {
83  let bytes = cached.get(cue)
84  if (!bytes) {
85    bytes = base64(chimeWav(cue))
86    cached.set(cue, bytes)
87  }
88  return { base64: bytes, mime: 'audio/wav' }
89}
90
hooks/client/local/index.ts 178 lines
1// LocalBackend: the offline world (SPEC 28). It answers every operation API_ROUTES marks `offline` from the save under
2// `offline:v1`, using only the shared core rules, crypto randomness and the injected clock; everything else is
3// `not_allowed`. It never touches the network. Each operation goes through one door, like RemoteBackend's: the
4// request is checked by the same strict schema, the save is loaded right before it is written (sessions share the
5// store), the touch runs, the operation decides, the answer is checked by the same response schema, and only then is
6// the save written, once. A save another session wrote meanwhile sends the operation round again on the fresh one.
7import type { ApiOp, ApiRequest, ApiResponse } from '../../core/api.ts'
8import { API_ROUTES } from '../../core/api.ts'
9import type { Card } from '../../core/types.ts'
10import { isFamily } from '../../core/families.ts'
11import { parsePathParam, parseRequest, parseResponse } from '../../core/schemas.ts'
12import { GENERATOR_VERSION, seasonSpecies, resolveCards } from '../../core/species.ts'
13import { seasonOf, worldOf } from '../../core/world.ts'
14import type { Backend, LocalDeps } from '../types.ts'
15import { BackendError } from '../types.ts'
16import { catchCreature, finishBattle, startBattle } from './battles.ts'
17import { buyPack, chargePack, craft, fuseCards, openPack, recycle, setTeam, trader, traderDeal } from './collection.ts'
18import type { LocalState } from './save.ts'
19import { encodeState, LIMITS, openSave, stampOf } from './save.ts'
20import type { Ctx } from './state.ts'
21import { meView, randomId, refuse, TEXT } from './state.ts'
22import { firstRun, touch } from './touch.ts'
23
24export { LIMITS, SAVE_VERSION } from './save.ts'
25export { OFFLINE_HANDLE } from './state.ts'
26
27type Handler<K extends ApiOp> = (s: LocalState, ctx: Ctx, req: ApiRequest<K>) => ApiResponse<K>
28type OfflineOps = {
29  [K in ApiOp]?: { write: boolean; run: Handler<K> }
30}
31
32const read = <K extends ApiOp>(run: Handler<K>) => ({ write: false, run })
33const write = <K extends ApiOp>(run: Handler<K>) => ({ write: true, run })
34
35/** Every offline operation (deleteMe aside), and whether it changes the save beyond the touch. */
36const OPS: OfflineOps = {
37  season: read<'season'>((s, ctx, req) => {
38    const season = Number(req.season)
39    if (season > seasonOf(ctx.now)) refuse('not_found', 'That season has not begun')
40    const generator = s.generators[String(season)] ?? GENERATOR_VERSION
41    return { season, generator, species: [...seasonSpecies(season, ctx.catalog, generator)] }
42  }),
43  world: read<'world'>((_s, ctx) => ({ ...worldOf(ctx.now, ctx.catalog), players: 1 })),
44  me: read<'me'>((s, ctx) => meView(s, ctx.now)),
45  cards: read<'cards'>(s => ({ cards: s.cards, version: s.cardsVersion })),
46  trader: read<'trader'>((s, ctx) => trader(s, ctx)),
47  chargePack: write<'chargePack'>(chargePack),
48  buyPack: write<'buyPack'>(buyPack),
49  openPack: write<'openPack'>(openPack),
50  setTeam: write<'setTeam'>(setTeam),
51  startBattle: write<'startBattle'>(startBattle),
52  finishBattle: write<'finishBattle'>(finishBattle),
53  catchCreature: write<'catchCreature'>(catchCreature),
54  fuse: write<'fuse'>(fuseCards),
55  recycle: write<'recycle'>(recycle),
56  craft: write<'craft'>(craft),
57  traderDeal: write<'traderDeal'>(traderDeal),
58}
59
60const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
61
62/** The request as RemoteBackend would send it: path parameters by their formats, the body by its strict schema. */
63function checkRequest(op: ApiOp, req: unknown): Record<string, unknown> {
64  const bad = (): never => refuse('bad_request', 'That request is not one the offline world takes')
65  if (!isRecord(req)) return bad()
66  const rest: Record<string, unknown> = { ...req }
67  const params: Record<string, unknown> = {}
68  for (const [, name] of API_ROUTES[op].path.matchAll(/:([A-Za-z]+)/g)) {
69    const v = rest[name!]
70    delete rest[name!]
71    if (typeof v !== 'string' && typeof v !== 'number') return bad()
72    try {
73      parsePathParam(name!, String(v))
74    } catch {
75      return bad()
76    }
77    params[name!] = v
78  }
79  try {
80    return { ...(parseRequest(op, rest) as Record<string, unknown>), ...params }
81  } catch {
82    return bad()
83  }
84}
85
86/** The answer as RemoteBackend would read it; an answer that is not the documented shape never gets saved. */
87function checkResponse<K extends ApiOp>(op: K, out: ApiResponse<K>): ApiResponse<K> {
88  try {
89    return parseResponse(op, out) as ApiResponse<K>
90  } catch {
91    return refuse('unavailable', TEXT.stumbled)
92  }
93}
94
95/**
96 * The offline engine's entry point (client/types.ts LocalBackendFactory): a Backend over the injected save, clock,
97 * randomness and family. Calls run one at a time, in order.
98 */
99export function createLocalBackend(deps: LocalDeps): Backend {
100  let queue: Promise<unknown> = Promise.resolve()
101
102  function serial<T>(work: () => Promise<T>): Promise<T> {
103    const next = queue.then(work, work)
104    queue = next.catch(() => undefined)
105    return next
106  }
107
108  async function transact<K extends ApiOp>(op: K, req: ApiRequest<K>): Promise<ApiResponse<K>> {
109    const now = await deps.now()
110    const rng = () => deps.random()
111    const ctx: Ctx = { now, rng, id: () => randomId(rng) }
112    const entry = OPS[op] as { write: boolean; run: Handler<K> } | undefined
113    if (!entry) return refuse('not_allowed', TEXT.online)
114    for (let attempt = 0; ; attempt++) {
115      const raw = await deps.load()
116      const opened = openSave(raw)
117      if (opened.kind === 'newer') return refuse('unavailable', TEXT.newer)
118      if (opened.kind === 'unreadable') return refuse('unavailable', TEXT.unreadable)
119      const fresh = opened.kind === 'empty'
120      const family = deps.family()
121      const s = opened.kind === 'ok' ? opened.state : firstRun(ctx, isFamily(family) ? family : 'sonnet')
122      ctx.catalog = s.catalog
123      const touched = touch(s, ctx)
124      const answer = resolveCards(checkResponse(op, entry.run(s, ctx, req)), s.catalog)
125      if (!fresh && !touched && !entry.write) return answer
126      const encoded = encodeState(s, randomId(rng))
127      const size = JSON.stringify(encoded).length
128      if (size > LIMITS.bytes && (fresh || size > JSON.stringify(raw).length)) {
129        if (!entry.write) return answer
130        return refuse('cap_reached', TEXT.full)
131      }
132      // another session wrote since this one read: decide again on what it wrote
133      if (attempt < 2 && stampOf(await deps.load()) !== (opened.kind === 'ok' ? opened.stamp : null)) continue
134      try {
135        await deps.save(encoded)
136      } catch {
137        // the touch alone can wait for the next write; a change the player asked for cannot pretend to have happened
138        if (entry.write) return refuse('cap_reached', TEXT.storeFull)
139      }
140      return answer
141    }
142  }
143
144  async function call<K extends ApiOp>(op: K, req: ApiRequest<K>): Promise<ApiResponse<K>> {
145    return serial(async (): Promise<ApiResponse<K>> => {
146      if (!Object.hasOwn(API_ROUTES, op) || !API_ROUTES[op].offline) return refuse('not_allowed', TEXT.online)
147      const checked = checkRequest(op, req) as ApiRequest<K>
148      try {
149        if (op === 'deleteMe') {
150          // the player asked: the whole save goes, whatever state it is in
151          await deps.save(null)
152          return { deleted: true } as ApiResponse<K>
153        }
154        return await transact(op, checked)
155      } catch (err) {
156        // a store that failed, or a rule that tripped: one plain line, and the save as it was
157        if (err instanceof BackendError) throw err
158        return refuse('unavailable', TEXT.stumbled)
159      }
160    })
161  }
162
163  const api: Record<string, unknown> = { call }
164  for (const op of Object.keys(API_ROUTES) as ApiOp[]) api[op] = (req: never) => call(op, req)
165  return api as unknown as Backend
166}
167
168/**
169 * The cards to suggest recycling when the collection nears its limit (SPEC 28): plain commons that are free to go and
170 * not on the team, oldest first.
171 */
172export function recycleSuggestions(cards: readonly Card[], team: readonly string[], count = 20): Card[] {
173  return cards
174    .filter(c => c.rarity === 'common' && !c.bound && !c.shiny && !c.foil && c.state === 'owned' && !team.includes(c.id) && /^s\d/.test(c.species))
175    .sort((a, b) => a.mintedAt - b.mintedAt || (a.id < b.id ? -1 : 1))
176    .slice(0, count)
177}
178
hooks/client/scheduler.ts 281 lines
1// How the band plays a battle and its ceremonies (SPEC 5, 13, 14): the battle driver for register.tsx's slot, from
2// the rustle to the settle, then the catch's wobble beats, a Mythic's farewell and each evolution. On the terminal it
3// blits the frames client/battleview.ts draws, at 24 a second; the desktop's Svg animates itself, so there it only
4// keeps the beat. Text changes only at state transitions: the battle's phase and rounds through BattleControl, and a
5// ceremony's stage through the clock, stamped at each boundary. When a creature finds you is client/session.ts's.
6import type { BattleCard, BattleLog } from '../core/types.ts'
7import { FRAME_MS, TIMING, pixelCells } from './anim.ts'
8import type { EvolveMoment, OutcomeMoment, RoundPlan } from './battleview.ts'
9import {
10  EVOLVE_SHOW, KEYS, catchCard, catchFrame, catchPreMs, drawnLayout, drawCount, evolveFrame, fightCells,
11  fledFrame, foreshadowOf, hatchFrame, leadCard, logOf, lookAt, outcomeAnchor, packFamily, packFrame, presentFrame,
12  revealFrame, revealHoldMs, roundPlan, rustleFrame, rustleMs, spriteOf,
13} from './battleview.ts'
14import { chimeFor, headMoment } from './game.ts'
15import type { Battle, BattleDriver, Fx, Moment } from './types.ts'
16
17const sleep = (fx: Fx, ms: number) => new Promise<void>(r => { fx.after(Math.max(0, ms), r) })
18
19async function motionOn(fx: Fx): Promise<boolean> {
20  const p = await fx.state.get('prefs')
21  return p.motion && !p.quiet
22}
23
24/** Moves the clock the band reads to now, so a ceremony's text changes stage on time. */
25async function stamp(fx: Fx): Promise<number> {
26  const now = await fx.now()
27  await fx.state.update('clock', c => Math.max(c, now))
28  return now
29}
30
31/** Moments a ceremony is playing, so two drivers never animate one at once. */
32const playing = new Set<string>()
33
34type Frame = Map<string, string>
35
36/**
37 * Plays an animation for `ms()` ms (re-read every frame: a press can lengthen a round). Each frame asks `step(t)`
38 * whether the moment is still on, and `draw(t)` for the Rasters it shows; only changed cells are blitted, and every
39 * cell again after the band redraws (a redraw puts back its resting frame). Off the terminal or with motion off it
40 * only waits, checking a few times a second. False when `step` said the moment is gone.
41 */
42async function play(fx: Fx, ms: () => number, step: (t: number) => Promise<boolean>, draw: ((t: number) => Frame) | null): Promise<boolean> {
43  const start = await fx.now()
44  const motion = await motionOn(fx)
45  const sent = new Map<string, string>()
46  let drawn = drawCount()
47  for (;;) {
48    const t = (await fx.now()) - start
49    if (t >= ms()) return true
50    if (!(await step(t))) return false
51    const blitting = motion && draw !== null && drawnLayout().surface === 'terminal'
52    if (blitting) {
53      if (drawCount() !== drawn) { sent.clear(); drawn = drawCount() }
54      const frame = draw!(t)
55      const changed = [...frame].filter(([k, c]) => sent.get(k) !== c)
56      const ok = await Promise.all(changed.map(([k, c]) => fx.ui.blit('band', k, c)))
57      changed.forEach(([k, c], i) => { if (ok[i]) sent.set(k, c); else sent.delete(k) })
58    }
59    await sleep(fx, Math.min(blitting ? FRAME_MS : 250, Math.max(1, ms() - t)))
60  }
61}
62
63const one = (key: string, cells: string): Frame => new Map([[key, cells]])
64
65// ---------- the battle ----------
66
67/**
68 * The battle driver (register.tsx's BATTLE_DRIVER): the rustle with its foreshadowing, the reveal's flash, every
69 * round at its pace with its hits, numbers, drains and knock-outs, then the settle and the ceremonies.
70 * A live battle re-simulates on a press; one whose rules differ animates the server's log once it is in.
71 */
72export const playBattle: BattleDriver = async (fx, ctl) => {
73  const first = await ctl.battle()
74  if (!first) return
75  const id = first.id
76  const alive = async () => (await ctl.battle()) !== null
77  const lead = first.setup.defender[0] ?? null
78  if (first.phase === 'rustle') {
79    const ms = rustleMs(first)
80    const fore = foreshadowOf(lead)
81    const px = lead ? spriteOf(lead, 'mini') : null
82    const still = ms - TIMING.pause
83    if (!(await play(fx, () => ms, alive, px ? t => one(KEYS.lead, pixelCells(rustleFrame(px, fore, Math.min(t, still), lead!.id)).cells) : null))) return
84    await ctl.phase('reveal')
85  }
86  if ((await ctl.battle())?.phase === 'reveal') {
87    const b = (await ctl.battle())!
88    const px = lead ? spriteOf(lead, 'mini') : null
89    const ms = TIMING.reveal + revealHoldMs(b)
90    if (!(await play(fx, () => ms, alive, px ? t => one(KEYS.lead, pixelCells(revealFrame(px, lead, t, lead!.id)).cells) : null))) return
91    await ctl.phase('fight')
92  }
93  if (!(await fight(fx, ctl))) return
94  if (!(await ctl.battle())) return
95  await ctl.settle()
96  await ceremonies(fx, id)
97}
98
99async function fight(fx: Fx, ctl: Parameters<BattleDriver>[1]): Promise<boolean> {
100  for (;;) {
101    let b = await ctl.battle()
102    if (!b) return false
103    if (b.phase === 'finishing') return true
104    const log = await ctl.log() as BattleLog
105    b = await ctl.battle()
106    if (!b) return false
107    if (b.shown >= log.rounds.length) return true
108    const r = b.shown + 1
109    const pace = await ctl.paceMs()
110    let plan: RoundPlan | null = roundPlan(b, log, r, pace)
111    if (!plan) return true
112    let inputs = b.inputs.join(',')
113    const ok = await play(fx, () => plan!.ms, async () => {
114      const cur: Battle | null = await ctl.battle()
115      if (!cur) return false
116      if (cur.inputs.join(',') !== inputs) {
117        // a press re-simulates: the round plays on with the Perfect special's numbers
118        inputs = cur.inputs.join(',')
119        plan = roundPlan(cur, (logOf(cur) ?? log) as BattleLog, r, pace) ?? plan
120      }
121      return true
122    }, t => fightCells(plan!.fighters, { a: lookAt(plan!, 'a', t), d: lookAt(plan!, 'd', t) }, drawnLayout().columns))
123    if (!ok) return false
124    await ctl.show(r)
125  }
126}
127
128// ---------- ceremonies after a battle ----------
129
130const belongs = (m: Moment, battleId: string, evolves: ReadonlySet<string>) =>
131  (m.kind === 'outcome' && m.outcome.battleId === battleId) || (m.kind === 'evolve' && evolves.has(m.id))
132
133/**
134 * After the settle: the catch ceremony (or the choice's countdown), a Mythic's farewell, then each evolution, as each
135 * reaches the head of the band's queue. Ends when this battle's moments are gone, a new battle starts, or after 90 s.
136 */
137export async function ceremonies(fx: Fx, battleId: string): Promise<void> {
138  const until = (await fx.now()) + 90_000
139  const evolves = new Set((await fx.state.get('moments')).filter(m => m.kind === 'evolve').map(m => m.id))
140  const done = new Set<string>()
141  while ((await fx.now()) < until) {
142    if (await fx.state.get('battle')) return
143    const moments = await fx.state.get('moments')
144    if (!moments.some(m => belongs(m, battleId, evolves))) return
145    const head = headMoment(moments)
146    const key = head ? stageKey(head) : ''
147    if (head && belongs(head, battleId, evolves) && !done.has(key) && !playing.has(head.id)) {
148      playing.add(head.id)
149      try {
150        if (head.kind === 'outcome') await outcomeCeremony(fx, head)
151        else if (head.kind === 'evolve') await evolveCeremony(fx, head)
152      } finally {
153        playing.delete(head.id)
154      }
155      done.add(key)
156      continue
157    }
158    await sleep(fx, 250)
159  }
160}
161
162const stageKey = (m: Moment) => (m.kind === 'outcome' ? `${m.id}|${m.outcome.catch.status}` : m.id)
163
164async function current(fx: Fx, id: string): Promise<Moment | null> {
165  return (await fx.state.get('moments')).find(m => m.id === id) ?? null
166}
167
168async function outcomeCeremony(fx: Fx, m: OutcomeMoment): Promise<void> {
169  const c = m.outcome.catch
170  if (c.status === 'choose') {
171    // the countdown to the rarest pick ticks once a second until a choice is made
172    for (;;) {
173      const now = await current(fx, m.id)
174      if (!now || now.kind !== 'outcome' || now.outcome.catch.status !== 'choose' || (await fx.state.get('battle'))) return
175      await stamp(fx)
176      await sleep(fx, 1000)
177    }
178  }
179  const card = catchCard(m)
180  // the chime comes with the result, never before the wobble beats end
181  const gotcha = () => { const cue = c.status === 'caught' ? chimeFor([c.card]) : null; if (cue) fx.ui.sound(cue) }
182  if (!(await motionOn(fx))) { gotcha(); return }
183  if (c.status === 'catching' && card) {
184    // the server has not answered yet: the card keeps wobbling
185    const px = spriteOf(card, 'mini')
186    await play(fx, () => 15_000, async () => {
187      const now = await current(fx, m.id)
188      return !!now && now.kind === 'outcome' && now.outcome.catch.status === 'catching'
189    }, t => one(KEYS.art, pixelCells(catchFrame(px, card, t, 'pending')).cells))
190    return
191  }
192  if ((c.status === 'caught' || c.status === 'slipped') && card) {
193    const anchor = outcomeAnchor(m)
194    if (anchor === null) return
195    const px = spriteOf(card, 'mini')
196    const result = c.status
197    const pre = catchPreMs(card)
198    const end = pre + 2 * TIMING.spinFrame + (result === 'caught' ? TIMING.sparkle : 900)
199    let revealed = false
200    await stamp(fx)
201    const begin = await fx.now()
202    const offset = Math.max(0, begin - anchor)
203    await play(fx, () => end - offset, async t => {
204      if (!revealed && t + offset >= pre) { revealed = true; gotcha(); await stamp(fx) }
205      return !!(await current(fx, m.id))
206    }, t => one(KEYS.art, pixelCells(catchFrame(px, card, t + offset, result)).cells))
207    if (!revealed) { gotcha(); await stamp(fx) }
208    return
209  }
210  if (c.status === 'fled' && m.outcome.lead) {
211    const lead: BattleCard = m.outcome.lead
212    const px = spriteOf(lead, 'mini')
213    await play(fx, () => TIMING.fled, async () => !!(await current(fx, m.id)), t => one(KEYS.art, pixelCells(fledFrame(px, t, lead.id)).cells))
214  }
215}
216
217async function evolveCeremony(fx: Fx, m: EvolveMoment): Promise<void> {
218  fx.ui.sound('evolve')
219  if (!(await motionOn(fx))) return
220  const card = (await fx.state.get('cards')).find(x => x.id === m.cardId)
221  const t0 = await fx.now()
222  const leaves = t0 + EVOLVE_SHOW
223  // the moment now starts with its ceremony: the band reads the start from its `until`
224  await fx.state.update('moments', list => list.map(x => (x.id === m.id && x.kind === 'evolve' ? { ...x, until: leaves } : x)))
225  await stamp(fx)
226  const from = card ? spriteOf({ ...card, stage: Math.max(1, m.stage - 1) as 1 | 2 | 3 }, 'mini') : null
227  const to = card ? spriteOf({ ...card, stage: m.stage }, 'mini') : null
228  let revealed = false
229  const still = async () => {
230    const now = await current(fx, m.id)
231    return !!now && now.kind === 'evolve' && now.until === leaves
232  }
233  const ok = await play(fx, () => TIMING.evolve + TIMING.evolveFlash + TIMING.sparkle, async t => {
234    if (!revealed && t >= TIMING.evolve) { revealed = true; await stamp(fx) }
235    return still()
236  }, from && to ? t => one(KEYS.art, pixelCells(evolveFrame(from, to, t)).cells) : null)
237  if (!revealed) await stamp(fx)
238  if (!ok) return
239  // it leaves on its own new time (the game's timer was set for the old one)
240  await sleep(fx, Math.max(0, leaves - (await fx.now())))
241  if (await still()) await fx.state.update('moments', list => list.filter(x => !(x.id === m.id && x.kind === 'evolve' && x.until === leaves)))
242}
243
244// ---------- the moments without a battle ----------
245
246/**
247 * The band's other moments on the terminal: the welcome's egg hatching, a ready pack's glow, a present's wobble.
248 * A long-lived loop for a boot-time slot; the desktop needs none (its Svg animates itself), and without it these
249 * moments simply hold their resting frame.
250 */
251export async function momentDriver(fx: Fx): Promise<void> {
252  const shown = new Set<string>()
253  for (;;) {
254    if (await fx.state.get('battle')) { await sleep(fx, 1000); continue }
255    const state = { me: await fx.state.get('me'), cards: await fx.state.get('cards'), signals: await fx.state.get('signals') }
256    const head = headMoment(await fx.state.get('moments'))
257    const mine = head && (head.kind === 'welcome' ? !shown.has(head.id) : head.kind === 'pack-ready' || head.kind === 'present')
258    if (!head || !mine || playing.has(head.id) || !(await motionOn(fx)) || drawnLayout().surface !== 'terminal') { await sleep(fx, 500); continue }
259    const id = head.id
260    const on = async () => !(await fx.state.get('battle')) && headMoment(await fx.state.get('moments'))?.id === id
261    playing.add(id)
262    try {
263      if (head.kind === 'welcome') {
264        const lead = leadCard(state)
265        const px = lead ? spriteOf(lead, 'mini') : null
266        const family = lead?.family ?? state.signals.family
267        await play(fx, () => TIMING.hatch + TIMING.sparkle, on, t => one(KEYS.art, pixelCells(hatchFrame(px, family, t)).cells))
268        shown.add(id)
269      } else if (head.kind === 'pack-ready') {
270        const family = packFamily(state)
271        await play(fx, () => 6000, on, t => one(KEYS.art, pixelCells(packFrame(family, t)).cells))
272      } else {
273        await play(fx, () => 3200, on, t => one(KEYS.art, pixelCells(presentFrame(t)).cells))
274      }
275    } finally {
276      playing.delete(id)
277    }
278    await sleep(fx, 100)
279  }
280}
281
hooks/ui/band.tsx 562 lines
1// The band above the prompt (SPEC 9, 13, 14, 21, 34): terminal moments fit four rows. Desktop battles use one shared
2// arena and native controls, with compact words below 40 columns. The terminal draws each moment's resting frame
3// as keyed Rasters that client/scheduler.ts animates by blit; the desktop draws one Svg per moment that animates
4// itself. Every band button uses a digit, the only keys an empty prompt hands to the band: 1 is the one primary
5// action, 2 the secondary, 1 to 3 a catch choice. Returns null when nothing is live, so the engine's band shows.
6import type { RenderElement } from 'claude-code'
7import type { BattleCard, Card } from '../core/types.ts'
8import { FAMILY_INFO } from '../core/families.ts'
9import type { Pixels } from '../core/sprite.ts'
10import { TIMING, blank, pixelCells, silhouette, sparkles } from '../client/anim.ts'
11import type { Line, OutcomeMoment, Side } from '../client/battleview.ts'
12import {
13  KEYS, MINI, bandKind, battleWords, catchCard, catchFrame, catchPreMs, choiceSeconds, evolveFrame, evolveGains,
14  evolveStage, EVOLVE_SHOW, fightersAt, fighterCells, fighterLayout, fledFrame, foreshadowOf, gainsWords, hatchFrame,
15  leadCard, lineWidth, logOf, noteLayout, outcomeAnchor, outcomeBadges, outcomeDetail, outcomeHeadline,
16  outcomeStage, packFamily, packFrame, presentFrame, rarityWords, restLook, revealFrame, roundOnScreen, roundPlan,
17  rustleFrame, SHADOW, spriteOf,
18} from '../client/battleview.ts'
19import { ROUND_MS, catchOrder, headMoment, nameOf, opponentLabel } from '../client/game.ts'
20import { RULE_INFO } from '../core/world.ts'
21import { effortLook } from '../client/effort.ts'
22import { hostOf } from '../client/net.ts'
23import { UPDATE_COMMAND } from '../client/remote.ts'
24import { fit, plural, safe } from '../client/text.ts'
25import { grouped } from '../client/viewmodels.ts'
26import type { Actions, Battle, BandView, El, Moment, Reveal, Surface } from '../client/types.ts'
27import {
28  ART_K, artSide, catchSvg, creatureSvg, evolveSvg, fledSvg, hatchSvg, optionSvg, packSvg, presentSvg,
29  revealSvg, rustleSvg,
30} from './band-art.tsx'
31import { FAMILY_COLOR, FAMILY_MARK, INK, MARK, MYTHIC_COLOR, RARITY_COLOR, RARITY_INITIAL, SPACE } from './tokens.ts'
32import { arenaSize, arenaSvg } from './arena-duel.ts'
33
34type Env = Parameters<BandView>[0]
35/** `rows`: the host's height budget; the terminal caps it at 4 and small windows keep actions first. */
36type Ctx = { el: El; surface: Surface; columns: number; rows: number; actions: Actions; motion: boolean; now: number }
37
38/** Columns a desktop plate takes, as text beside it counts them. */
39const DESK_ART_COLUMNS = 9
40
41// ---------- small pieces ----------
42
43/** A styled line: one Text of runs, cut with an ellipsis at the band's edge. */
44function line(el: El, l: Line, key?: string): RenderElement {
45  const { Text } = el
46  const runs = l.filter(s => s.text !== '')
47  if (runs.length === 0) return <Text> </Text>
48  if (runs.length === 1 && !runs[0]!.color && !runs[0]!.bold && !runs[0]!.dim) return <Text wrap="truncate-end">{runs[0]!.text}</Text>
49  const parts = runs.map(s => {
50    const props: { color?: string; bold?: boolean; dimColor?: boolean } = {}
51    if (s.color) props.color = s.color
52    if (s.bold) props.bold = true
53    if (s.dim) props.dimColor = true
54    return <Text {...props}>{s.text}</Text>
55  })
56  return key ? <Text key={key} wrap="truncate-end">{...parts}</Text> : <Text wrap="truncate-end">{...parts}</Text>
57}
58
59const blankRow = (el: El) => <el.Text> </el.Text>
60
61/** Guidance wraps onto a second row, except in a short window, where every row is one row. */
62const wraps = (c: Ctx) => (c.rows < 4 ? 'truncate-end' : 'wrap')
63
64/** Terminal art: a keyed Raster of pixels (8x8 minis are 8 x 4 cells). */
65function raster(el: El, key: string, px: Pixels): RenderElement {
66  const r = pixelCells(px)
67  return <el.Raster key={key} columns={r.columns} rows={r.rows} cells={r.cells} />
68}
69
70function svg(el: El, source: string, alt: string, size: { w: number; height: number }, motion: boolean): RenderElement {
71  return motion
72    ? <el.Svg source={source} alt={alt} width={size.w} height={size.height} isInteractive={true} />
73    : <el.Svg source={source} alt={alt} width={size.w} height={size.height} />
74}
75
76const plateSize = (k = ART_K) => ({ w: artSide(k), height: artSide(k) })
77
78/**
79 * Art beside a column of rows, the moment's actions last: the band's one shape for moments. In a window shorter than
80 * four rows the art stays out and the actions come first, so the digit that presses them is always in view (a bare
81 * digit only reaches the buttons inside the band's window).
82 */
83function beside(c: Ctx, art: RenderElement | null, rows: RenderElement[], o: { act?: RenderElement | null; rails?: string } = {}): RenderElement {
84  const { Box, Text } = c.el
85  if (c.rows < 4) return compact(c, [o.act ?? null, ...rows])
86  const rail = o.rails ? <Box flexDirection="column" flexShrink={0}>{...[0, 1, 2, 3].map(() => <Text color={o.rails}>▎</Text>)}</Box> : null
87  return (
88    <Box flexDirection="row" columnGap={SPACE.tight} width={c.columns}>
89      {rail}
90      {art ? <Box flexShrink={0}>{art}</Box> : null}
91      <Box flexDirection="column" flexGrow={1} flexShrink={1}>{...rows}{o.act ?? null}</Box>
92      {rail}
93    </Box>
94  )
95}
96
97/** The compact band: one row each, as many as the window holds. */
98function compact(c: Ctx, rows: (RenderElement | null)[]): RenderElement {
99  return <c.el.Box flexDirection="column" width={c.columns}>{...rows.filter((r): r is RenderElement => r !== null).slice(0, Math.max(1, c.rows))}</c.el.Box>
100}
101
102/**
103 * A row of the moment's actions: 1 the primary, 2 the secondary. The row may not wrap onto a line the band does not
104 * have, so a long primary takes its `short` label when both would not fit across the column they sit in (beside the
105 * moment's `art` in a full band, the whole width in a short window).
106 */
107function actionsRow(c: Ctx, m: Moment, primary: string | null, secondary: string | null, short?: string, art = false): RenderElement {
108  const { Box, Button } = c.el
109  const across = (primary ? 3 + primary.length : 0) + (secondary ? SPACE.loose + 3 + secondary.length : 0)
110  const room = c.rows < 4 ? c.columns : textWidth(c, art)
111  const label = primary && short && across > room ? short : primary
112  return (
113    <Box flexDirection="row" columnGap={SPACE.loose} flexWrap="wrap">
114      {label ? <Button key={`act-${m.id}`} label={label} hotkey="1" plain variant="primary" onPress={() => { void c.actions.act(m.id) }} /> : null}
115      {secondary ? <Button key={`dismiss-${m.id}`} label={secondary} hotkey="2" plain dimColor onPress={() => { void c.actions.dismiss(m.id) }} /> : null}
116    </Box>
117  )
118}
119
120const textWidth = (c: Ctx, art: boolean) => Math.max(10, c.columns - (art ? (c.surface === 'terminal' ? MINI : DESK_ART_COLUMNS) + SPACE.tight : 0))
121
122// ---------- the band ----------
123
124/** The band slot (register.tsx): a live battle over everything, else the moment at the head of the queue. */
125export const band: BandView = env => {
126  noteLayout(env.columns, env.surface)
127  const { state } = env
128  const motion = state.prefs.motion && !state.prefs.quiet
129  const budget = Number.isFinite(env.rows) && env.rows >= 1 ? Math.floor(env.rows) : 4
130  const rows = env.surface === 'terminal' ? Math.min(4, budget) : budget
131  const c: Ctx = { el: env.el, surface: env.surface, columns: Math.max(20, env.columns), rows, actions: env.actions, motion, now: env.now }
132  if (state.battle) return battleBand(c, env, state.battle)
133  const choice = state.moments.find(m => m.kind === 'outcome' && m.outcome.catch.status === 'choose')
134  if (choice?.kind === 'outcome') return outcomeBand(c, env, choice)
135  if (state.reveal?.inline && !state.prefs.quiet) return packBand(c, env, state.reveal)
136  // quiet silences the band except the result of a battle the player started themselves
137  const top = state.pane?.stack.at(-1)
138  const moments = state.moments.filter(m => (!state.prefs.quiet || m.kind === 'outcome')
139    && !(m.kind === 'server' && top?.kind === 'world' && top.origin === m.origin))
140  const m = headMoment(moments)
141  if (m) return momentBand(c, env, m)
142  if (!state.prefs.quiet && state.account.link === 'joining' && !state.me) return hatching(c, env)
143  return null
144}
145
146/** A pack opened above the composer keeps its ceremony and progress when moved to the sidebar. */
147function packBand(c: Ctx, env: Env, r: Reveal): RenderElement {
148  const { Box, Text, Button } = c.el
149  const i = Math.min(r.cards.length, env.state.pane?.flipped ?? 0)
150  const pending = !!r.packId, complete = !pending && i >= r.cards.length
151  const card = i > 0 ? r.cards[i - 1] : null
152  const family = r.family ?? env.state.signals.family
153  const art = card ? creatureArt(c, card, complete)
154    : c.surface === 'terminal' ? raster(c.el, KEYS.art, packFrame(family, 0))
155      : svg(c.el, packSvg(family), `${FAMILY_INFO[family].name} pack`, plateSize(), false)
156  const sidebar = () => { void c.actions.open({ view: { kind: 'reveal' }, revealId: r.id }) }
157  const controls = <Box flexDirection="row" columnGap={SPACE.loose} flexWrap="wrap">
158    {complete
159      ? <Button key="inline-pack-sidebar" label="Sidebar" hotkey="1" plain variant="primary" onPress={sidebar} />
160      : <Button key="inline-pack-open" label={pending ? 'Open' : c.columns < 50 ? 'Next' : 'Flip next'} hotkey="1" plain variant="primary" onPress={() => { void c.actions.flip(r.id) }} />}
161    <Button key="inline-pack-close" label="Close" hotkey="2" plain dimColor onPress={() => { void c.actions.doneReveal(r.id) }} />
162    {!complete ? <Button key="inline-pack-sidebar" label="Sidebar" hotkey="3" plain onPress={sidebar} /> : null}
163  </Box>
164  return beside(c, art, [
165    <Text bold wrap="truncate-end">{card ? nameOf(card) : `${FAMILY_INFO[family].name} pack`}</Text>,
166    <Text dimColor wrap="truncate-end">{env.state.pane?.message || (pending ? env.state.pane?.busy ? 'Opening…' : 'Ready when you are' : card ? rarityWords(card) : 'Opening…')}</Text>,
167    <Text dimColor wrap="truncate-end">{pending ? '' : complete ? `${plural(r.cards.length, 'card')} added to Collection` : `${i} of ${r.cards.length} revealed`}</Text>,
168  ], { act: controls })
169}
170
171// ---------- battles ----------
172
173/** More effort brightens the local band; no added animation beats or game signals. */
174function battleHeader(c: Ctx, env: Env, text: string): RenderElement {
175  const look = effortLook(env.state.signals.effort)
176  return <c.el.Text color={look.vivid ? FAMILY_COLOR[env.state.signals.family] : undefined} dimColor={!look.vivid} bold={look.bold} wrap="truncate-end">{text}</c.el.Text>
177}
178
179function battleBand(c: Ctx, env: Env, b: Battle): RenderElement {
180  const { el } = c
181  const { Box, Text, Button } = el
182  const words = battleWords(b, env.state)
183  const lead = b.setup.defender[0] ?? null
184  const cheering = env.state.signals.cheering
185  if (c.rows < 4) return compactBattle(c, env, words)
186  if (b.phase === 'rustle' || b.phase === 'reveal') {
187    const fore = foreshadowOf(lead)
188    // the roamer turns the band's border gold: rails on the terminal, the plate's own border on the desktop
189    const gold = fore.gold && b.phase === 'rustle' && c.surface === 'terminal' ? INK.accent : undefined
190    let art: RenderElement | null = null
191    if (lead) {
192      if (c.surface === 'terminal') {
193        const px = spriteOf(lead, 'mini')
194        art = raster(el, KEYS.lead, b.phase === 'rustle' ? rustleFrame(px, fore, 0, lead.id) : revealFrame(px, lead, c.motion ? 0 : 10_000, lead.id))
195      } else {
196        art = svg(el, b.phase === 'rustle' ? rustleSvg(lead, fore) : revealSvg(lead, ART_K, c.motion), b.phase === 'rustle' ? 'a rustling shadow' : `${nameOf(lead)}, ${rarityWords(lead)}`, plateSize(), c.motion)
197      }
198    }
199    return beside(c, art, [
200      battleHeader(c, env, fit(words.header, textWidth(c, true))),
201      line(el, words.banner),
202      line(el, words.extra),
203      cheering > 0 ? <Text color={INK.accent}>{`+${cheering} cheering`}</Text> : blankRow(el),
204    ], { rails: gold })
205  }
206  const log = logOf(b)
207  const r = log && b.shown >= log.rounds.length ? log.rounds.length + 1 : roundOnScreen(b, log)
208  const f = fightersAt(b, log, r)
209  const look = (side: Side) => restLook(f[side]?.hp ?? 0, (f[side]?.hp ?? 1) <= 0)
210  const controls = words.now || cheering > 0
211    ? (
212      <Box flexDirection="row" columnGap={SPACE.loose}>
213        {words.now ? <Button key="now" label="Now!" hotkey="1" plain variant="primary" onPress={() => { void c.actions.press() }} /> : null}
214        {cheering > 0 ? <Text color={INK.accent}>{`+${cheering} cheering`}</Text> : null}
215      </Box>
216    )
217    : blankRow(el)
218  const kind = bandKind(c.columns)
219  if (c.surface !== 'terminal') {
220    if (c.columns < 40 || c.rows < 12) return compactBattle(c, env, words)
221    const plan = log && r <= log.rounds.length && b.phase === 'fight' ? roundPlan(b, log, r, ROUND_MS) : null
222    const special = plan?.hits.find(hit => hit.actor === 'a' && hit.action.move === 'special')
223    const start = plan && b.inputs.includes(r) && special ? Math.max(0, special.at - 2 * TIMING.windup) : 0
224    const scene = arenaSvg({ columns: c.columns, arena: b.setup.arena, rule: b.setup.rule,
225      opponent: opponentLabel(b.opponent, lead), fighters: f, plan, start, motion: c.motion, effort: env.state.signals.effort, instance: `${b.id}/${r}` })
226    const sceneSize = arenaSize(c.columns)
227    const health = (side: Side) => f[side] ? `${f[side]!.name}, ${Math.ceil(f[side]!.hp)} of ${f[side]!.maxHp} HP` : 'empty'
228    const streak = env.state.me?.player.streak ?? 0
229    const progress = `${words.round ? `Round ${words.round}` : 'Preparing'}${!b.friendly && streak > 0 ? ` · Streak ${streak}` : ''}`
230    // The streak has its own badge; preserve the battle's step-ins and Perfect story without repeating it.
231    const extra = words.extra.filter(s => !s.text.startsWith('streak '))
232    const story = line(el, storyOf({ ...words, extra }))
233    const storyRow = <Box flexDirection="row" columnGap={SPACE.tight} flexGrow={1} flexShrink={1}>
234      {words.now ? <Box flexShrink={0}><Button key="now" label="Now!" hotkey="1" plain variant="primary" onPress={() => { void c.actions.press() }} /></Box> : null}
235      <Box flexGrow={1} flexShrink={1}>{story}</Box>
236      {cheering > 0 ? <Text color={INK.accent} wrap="truncate-end">{`+${cheering} cheering`}</Text> : null}
237    </Box>
238    return (
239      <Box flexDirection="column" width={c.columns}>
240        <Box flexDirection="row" columnGap={SPACE.tight}>
241          <Box flexGrow={1} flexShrink={1}><Text bold wrap="truncate-end">{`vs ${opponentLabel(b.opponent, lead)}`}</Text></Box>
242          {c.columns < 80 ? <Box flexShrink={0}><Text color={FAMILY_COLOR[b.setup.arena]}>{`${FAMILY_INFO[b.setup.arena].name} arena`}</Text></Box> : null}
243          <Box flexShrink={0}><Button key="battle-today" label={RULE_INFO[b.setup.rule].name} hotkey="2" plain dimColor onPress={() => { void c.actions.open({ view: { kind: 'today', rule: b.setup.rule } }) }} /></Box>
244        </Box>
245        <el.Svg source={scene} alt={`${words.header}; ${health('a')}; ${health('d')}`} width={sceneSize.w} height={sceneSize.height} />
246        <Box flexDirection="row" columnGap={SPACE.loose}>{storyRow}<Box flexShrink={0}><Text dimColor>{progress}</Text></Box></Box>
247      </Box>
248    )
249  }
250  if (kind === 'wide') {
251    const cells = (side: Side) => fighterCells(f[side], look(side), fighterLayout('wide', side))
252    const a = cells('a'), d = cells('d')
253    return (
254      <Box flexDirection="row" columnGap={SPACE.tight} width={c.columns}>
255        <Box flexShrink={0}><el.Raster key={KEYS.a} columns={a.columns} rows={a.rows} cells={a.cells} /></Box>
256        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
257          {battleHeader(c, env, words.header)}
258          {line(el, words.banner)}
259          {line(el, words.extra)}
260          {controls}
261        </Box>
262        <Box flexShrink={0}><el.Raster key={KEYS.d} columns={d.columns} rows={d.rows} cells={d.cells} /></Box>
263      </Box>
264    )
265  }
266  const w = lineWidth(c.columns)
267  const art = fighterCells(f.d, look('d'), fighterLayout('art', 'd'))
268  const aLine = fighterCells(f.a, look('a'), fighterLayout('line', 'a', w))
269  const dLine = fighterCells(f.d, look('d'), fighterLayout('line', 'd', w))
270  return (
271    <Box flexDirection="row" columnGap={SPACE.tight} width={c.columns}>
272      <Box flexShrink={0}><el.Raster key={KEYS.dArt} columns={art.columns} rows={art.rows} cells={art.cells} /></Box>
273      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
274        {battleHeader(c, env, words.header)}
275        <el.Raster key={KEYS.aLine} columns={aLine.columns} rows={1} cells={aLine.cells} />
276        <el.Raster key={KEYS.dLine} columns={dLine.columns} rows={1} cells={dLine.cells} />
277        {narrowStory(c, words)}
278      </Box>
279    </Box>
280  )
281}
282
283const storyOf = (words: ReturnType<typeof battleWords>): Line =>
284  words.banner.length && words.extra.length ? [...words.banner, { text: ' · ', dim: true }, ...words.extra] : [...words.banner, ...words.extra]
285
286/** `1: Now!` first on a row, then `rest`: the press is always the row's first thing. */
287function nowRow(c: Ctx, rest: RenderElement): RenderElement {
288  const { Box, Button } = c.el
289  return (
290    <Box flexDirection="row" columnGap={SPACE.tight}>
291      <Box flexShrink={0}><Button key="now" label="Now!" hotkey="1" plain variant="primary" onPress={() => { void c.actions.press() }} /></Box>
292      <Box flexShrink={1}>{rest}</Box>
293    </Box>
294  )
295}
296
297/** The narrow band's last row: `1: Now!` when it is live, then the round's story. */
298function narrowStory(c: Ctx, words: ReturnType<typeof battleWords>): RenderElement {
299  const story = line(c.el, storyOf(words))
300  return words.now ? nowRow(c, story) : story
301}
302
303/** A battle in a window under four rows: the words alone, `1: Now!` on the first row so its digit always reaches it. */
304function compactBattle(c: Ctx, env: Env, words: ReturnType<typeof battleWords>): RenderElement {
305  const header = battleHeader(c, env, words.header)
306  return compact(c, [words.now ? nowRow(c, header) : header, line(c.el, storyOf(words))])
307}
308
309// ---------- moments ----------
310
311function momentBand(c: Ctx, env: Env, m: Moment): RenderElement {
312  const { el } = c
313  const { Text } = el
314  switch (m.kind) {
315    case 'welcome': {
316      const lead = leadCard(env.state)
317      const family = lead?.family ?? env.state.signals.family
318      const art = c.surface === 'terminal'
319        ? raster(el, KEYS.art, hatchFrame(lead ? spriteOf(lead, 'mini') : null, family, 10_000))
320        : svg(el, hatchSvg(lead ? spriteOf(lead, 'full') : null, family, c.motion), lead ? `${nameOf(lead)} hatched` : 'an egg', plateSize(), c.motion)
321      // a first run that fell back to the offline world says so here, never ahead of the payoff (SPEC 34.4)
322      const sub = m.note ? safe(m.note, 80) : lead ? `${nameOf(lead)} leads your team of three` : 'Your team of three is ready'
323      return beside(c, art, [
324        <Text bold>{`${MARK.sparkle} A Spinling hatched!`}</Text>,
325        <Text dimColor wrap="truncate-end">{sub}</Text>,
326      ], { act: actionsRow(c, m, 'Open your welcome pack', 'Close', 'Open pack', true) })
327    }
328    case 'outcome': return outcomeBand(c, env, m)
329    case 'evolve': return evolveBand(c, env, m)
330    case 'pack-ready': {
331      const family = packFamily(env.state)
332      const art = c.surface === 'terminal' ? raster(el, KEYS.art, packFrame(family, 0)) : svg(el, packSvg(family), `a ${FAMILY_INFO[family].name} pack`, plateSize(), c.motion)
333      return beside(c, art, [
334        <Text bold>A pack is ready!</Text>,
335        <Text dimColor wrap="truncate-end">{`${plural(m.count, 'pack')} waiting · it charged while you were here`}</Text>,
336      ], { act: actionsRow(c, m, 'Open', 'Close') })
337    }
338    case 'present': {
339      const art = c.surface === 'terminal' ? raster(el, KEYS.art, presentFrame(0)) : svg(el, presentSvg(), 'a wrapped present', plateSize(), c.motion)
340      return beside(c, art, [
341        <Text bold wrap="truncate-end">{`${safe(m.from, 40)} sent you a gift!`}</Text>,
342        <Text dimColor wrap="truncate-end">It is wrapped and waiting</Text>,
343      ], { act: actionsRow(c, m, 'Open', null) })
344    }
345    case 'update': return beside(c, null, [
346      <Text wrap="truncate-end">{`Spinlings ${safe(m.version, 20)} is out`}</Text>,
347      <Text dimColor wrap="truncate-end">{UPDATE_COMMAND}</Text>,
348    ], { act: actionsRow(c, m, 'Got it', null) })
349    case 'passkey': {
350      // the card that made the collection worth keeping, shown with the plain truth about where it lives (SPEC 30)
351      const x = m.card ?? null
352      const art = x ? creatureArt(c, x, true) : null
353      return beside(c, art, [
354        x
355          ? <Text wrap="truncate-end"><Text bold color={rarityTint(x)}>{nameOf(x)}</Text><Text bold> lives only on this computer</Text></Text>
356          : <Text bold wrap="truncate-end">Your cards live only on this computer</Text>,
357        <Text dimColor wrap={wraps(c)}>A passkey keeps your collection safe · no email, no password</Text>,
358      ], { act: actionsRow(c, m, 'Save with a passkey', 'Later', 'Save', art !== null) })
359    }
360    case 'market': {
361      const x = m.card
362      const art = x ? creatureArt(c, x, m.outcome === 'sold') : null
363      const name = x ? nameOf(x) : 'Your card'
364      const sold = m.outcome === 'sold'
365      return beside(c, art, [
366        sold
367          ? <Text wrap="truncate-end"><Text bold color={INK.good}>Sold! </Text><Text bold>{name}</Text><Text>{m.handle ? ` went to ${safe(m.handle, 40)}` : ''}</Text></Text>
368          : <Text wrap="truncate-end"><Text bold>{name}</Text><Text> came home from the market</Text></Text>,
369        sold && m.price > 0
370          ? <Text><Text color={INK.accent}>{`+${MARK.spark} ${grouped(m.price)}`}</Text><Text dimColor> sparks</Text></Text>
371          : <Text dimColor wrap="truncate-end">{sold ? 'The card they gave is in your collection' : 'Nobody took it in 14 days. List it again any time.'}</Text>,
372      ], {
373        // 1 goes where the card is now: a card home again opens its page, anything else your listings
374        act: actionsRow(c, m, !sold && x && env.state.cards.some(k => k.id === x.id) ? 'Look' : 'Your listings', 'Got it'),
375      })
376    }
377    case 'needs-online': return beside(c, null, [
378      <Text wrap="truncate-end">This needs the online world</Text>,
379    ], { act: actionsRow(c, m, 'Join online (fresh collection)', 'Stay offline', 'Join online') })
380    case 'server': {
381      const host = hostOf(m.origin)
382      const a = env.state.account
383      // Only an online server already in play is informational; Connect enters that online world from offline too.
384      const act = a.world === 'online' && a.server === m.origin ? actionsRow(c, m, 'Got it', null)
385        : actionsRow(c, m, 'Connect', 'Cancel')
386      return beside(c, null, [
387        <Text bold wrap="truncate-end">{`${safe(host, 60)} is a community server run by someone else`}</Text>,
388        <Text dimColor wrap={wraps(c)}>{c.surface !== 'terminal' || c.columns >= 100
389          ? 'It receives the same anonymous game data as spinlings.dev, and never anything about your work.'
390          : 'Same anonymous game data as spinlings.dev, never your work.'}</Text>,
391      ], { act })
392    }
393    case 'line': {
394      const props: { dimColor?: boolean; italic?: boolean } = {}
395      if (m.tone !== 'notice') props.dimColor = true
396      if (m.tone === 'reaction') props.italic = true
397      return <el.Box width={c.columns}><Text {...props} wrap="truncate-end">{safe(m.text, 200)}</Text></el.Box>
398    }
399  }
400}
401
402const rarityTint = (x: Pick<Card, 'species' | 'rarity'>) => (x.species === 'mythic' ? MYTHIC_COLOR : RARITY_COLOR[x.rarity])
403
404/** A creature on the band's plate: its mini Raster on the terminal, its framed art on the desktop, sparkling when good news. */
405function creatureArt(c: Ctx, x: BattleCard, sparkle: boolean): RenderElement {
406  const { el } = c
407  if (c.surface === 'terminal') {
408    const px = spriteOf(x, 'mini')
409    return raster(el, KEYS.art, sparkle ? sparkles(px, 0.4, x.id, { count: 2, color: 0xfff0a8, reach: 1 }) : px)
410  }
411  return svg(el, creatureSvg(spriteOf(x, 'full'), rarityTint(x), ART_K, { sparkle: sparkle && c.motion }), nameOf(x), plateSize(), c.motion)
412}
413
414function hatching(c: Ctx, env: Env): RenderElement {
415  const { el } = c
416  const family = env.state.signals.family
417  const art = c.surface === 'terminal' ? raster(el, KEYS.art, hatchFrame(null, family, 0)) : svg(el, hatchSvg(null, family, c.motion), 'an egg', plateSize(), c.motion)
418  return beside(c, art, [<el.Text dimColor>{`${MARK.sparkle} Something is hatching…`}</el.Text>])
419}
420
421/** The rest frame of a catch on the terminal, and its desktop plate. */
422function catchArt(c: Ctx, m: OutcomeMoment, stage: ReturnType<typeof outcomeStage>): RenderElement | null {
423  const { el } = c
424  const card = catchCard(m)
425  if (!card) return null
426  const anchor = outcomeAnchor(m)
427  const since = anchor === null ? 0 : Math.max(0, c.now - anchor)
428  const status = m.outcome.catch.status
429  const result = status === 'caught' ? 'caught' : status === 'slipped' ? 'slipped' : 'pending'
430  if (c.surface === 'terminal') {
431    const px = spriteOf(card, 'mini')
432    let frame: Pixels
433    if (stage === 'catching') frame = catchFrame(px, card, status === 'catching' ? 4 * TIMING.spinFrame : since, result === 'pending' ? 'pending' : result)
434    else if (stage === 'caught') frame = sparkles(px, 0.4, card.id, { count: 2, color: 0xfff0a8, reach: 1 })
435    else frame = silhouette(px, SHADOW)
436    return raster(el, KEYS.art, frame)
437  }
438  const color = card.species === 'mythic' ? MYTHIC_COLOR : RARITY_COLOR[card.rarity]
439  if (stage === 'catching') return svg(el, catchSvg(card, result, status === 'catching' ? 0 : since), `catching ${nameOf(card)}`, plateSize(), c.motion)
440  if (stage === 'caught' && c.motion && anchor !== null && since < catchPreMs(card) + 2000) return svg(el, catchSvg(card, 'caught', since), `${nameOf(card)} caught`, plateSize(), c.motion)
441  if (stage === 'caught') return svg(el, creatureSvg(spriteOf(card, 'full'), color, ART_K, { sparkle: c.motion }), `${nameOf(card)} caught`, plateSize(), c.motion)
442  if (stage === 'slipped' && c.motion && anchor !== null && since < catchPreMs(card) + 1400) return svg(el, catchSvg(card, 'slipped', since), `${nameOf(card)} slipped away`, plateSize(), c.motion)
443  return svg(el, creatureSvg(silhouette(spriteOf(card, 'full'), SHADOW), '#3a3646'), `${nameOf(card)} slipped away`, plateSize(), c.motion)
444}
445
446function outcomeBand(c: Ctx, env: Env, m: OutcomeMoment): RenderElement {
447  const { el } = c
448  const { Box, Text, Button } = el
449  const stage = outcomeStage(m, c.now, c.motion)
450  const o = m.outcome
451  const badges = outcomeBadges(o)
452  if (stage === 'choose' && o.catch.status === 'choose') {
453    // rarest first, so 1, the primary, is also the one kept if you look away; each button names its server index
454    const all = o.catch.options
455    const order = catchOrder(all).slice(0, 3)
456    const options = order.map(k => all[k]!)
457    const pick = (i: number) => () => { void c.actions.pickCatch(order[i]!) }
458    const secs = choiceSeconds(m, c.now)
459    const hint = `${fit(nameOf(options[0]!), 12)}${secs > 0 && secs <= 20 ? ` in ${secs} s` : ''} if you look away`
460    if (c.columns >= 100 && c.rows >= 4) {
461      const tiles = options.map((x, i) => {
462        const art = c.surface === 'terminal' ? raster(el, KEYS.option(i), spriteOf(x, 'mini')) : svg(el, optionSvg(x), `${nameOf(x)}, ${rarityWords(x)}`, plateSize(2), false)
463        return (
464          <Box key={`option-${i}`} flexDirection="row" columnGap={SPACE.tight} flexShrink={0}>
465            <Box flexShrink={0}>{art}</Box>
466            <Box flexDirection="column" width={13}>
467              <Button key={`catch-${order[i]}`} label={fit(nameOf(x), 10)} hotkey={String(i + 1)} plain variant={i === 0 ? 'primary' : 'secondary'} onPress={pick(i)} />
468              <Text color={x.species === 'mythic' ? MYTHIC_COLOR : RARITY_COLOR[x.rarity]} wrap="truncate-end">{rarityWords(x)}</Text>
469              <Text dimColor>{`Lv ${x.level}`}</Text>
470            </Box>
471          </Box>
472        )
473      })
474      return (
475        <Box flexDirection="row" columnGap={SPACE.loose} width={c.columns}>
476          <Box flexDirection="column" flexShrink={1} flexGrow={1}>
477            <Text bold wrap="truncate-end">Won! Pick one to keep</Text>
478            <Text dimColor wrap="truncate-end">{hint}</Text>
479          </Box>
480          {...tiles}
481        </Box>
482      )
483    }
484    // narrow: the creature kept if you look away beside the choices; a short window fits the three on one row
485    const best = options[0]!
486    const preview = c.surface === 'terminal' ? raster(el, KEYS.option(0), spriteOf(best, 'mini')) : svg(el, optionSvg(best), `${nameOf(best)}, ${rarityWords(best)}`, plateSize(2), false)
487    const room = c.rows < 4 ? Math.max(4, Math.floor((c.columns - SPACE.loose * (options.length - 1)) / options.length) - 5) : 12
488    return beside(c, preview, [
489      <Text bold wrap="truncate-end">Won! Pick one to keep</Text>,
490      <Text dimColor wrap="truncate-end">{hint}</Text>,
491    ], {
492      act: (
493        <Box flexDirection="row" columnGap={SPACE.loose} flexWrap="wrap">
494          {...options.map((x, i) => (
495            <Box key={`option-${i}`} flexDirection="row" columnGap={SPACE.tight} flexShrink={0}>
496              <Button key={`catch-${order[i]}`} label={fit(nameOf(x), room)} hotkey={String(i + 1)} plain variant={i === 0 ? 'primary' : 'secondary'} onPress={pick(i)} />
497              <Text color={x.species === 'mythic' ? MYTHIC_COLOR : RARITY_COLOR[x.rarity]}>{x.species === 'mythic' ? 'M' : RARITY_INITIAL[x.rarity]}</Text>
498            </Box>
499          ))}
500        </Box>
501      ),
502    })
503  }
504  let art: RenderElement | null
505  if (stage === 'catching' || stage === 'caught' || stage === 'slipped') art = catchArt(c, m, stage)
506  else if (stage === 'fled') {
507    const card = o.lead
508    art = !card ? null : c.surface === 'terminal'
509      ? raster(el, KEYS.art, c.motion ? fledFrame(spriteOf(card, 'mini'), Math.max(0, c.now - (outcomeAnchor(m) ?? c.now)), card.id) : blank(MINI, MINI))
510      : svg(el, fledSvg(card, Math.max(0, c.now - (outcomeAnchor(m) ?? c.now)), c.motion), 'gone into the static', plateSize(), c.motion)
511  } else {
512    const hero = (o.bounty ?? leadCard(env.state) ?? o.lead) as Card | null
513    if (!hero) art = null
514    else if (c.surface === 'terminal') {
515      const px = spriteOf(hero, 'mini')
516      art = raster(el, KEYS.art, o.result === 'win' ? sparkles(px, 0.4, hero.id, { count: 2, color: 0xfff0a8, reach: 1 }) : px)
517    } else {
518      art = svg(el, creatureSvg(spriteOf(hero, 'full'), FAMILY_COLOR[hero.family], ART_K, { sparkle: o.result === 'win' && c.motion }), nameOf(hero), plateSize(), c.motion)
519    }
520  }
521  const head = outcomeHeadline(m, stage)
522  const detail = outcomeDetail(m, stage, env.state.account.world === 'offline')
523  const rows: RenderElement[] = [line(el, head)]
524  if (detail.length > 0) rows.push(line(el, detail))
525  if (stage !== 'catching' && badges.length > 0) rows.push(line(el, badges))
526  return beside(c, art, rows.slice(0, 3), { act: stage === 'catching' ? null : actionsRow(c, m, stage === 'caught' ? 'See it' : 'Open', null) })
527}
528
529function evolveBand(c: Ctx, env: Env, m: Extract<Moment, { kind: 'evolve' }>): RenderElement {
530  const { el } = c
531  const { Text } = el
532  const card = env.state.cards.find(x => x.id === m.cardId) as Card | undefined
533  const stage = evolveStage(m, c.now, c.motion)
534  const since = m.until === null ? TIMING.evolve : Math.max(0, c.now - (m.until - EVOLVE_SHOW))
535  let art: RenderElement | null = null
536  if (card) {
537    const prev = Math.max(1, m.stage - 1) as 1 | 2 | 3
538    if (c.surface === 'terminal') {
539      const from = spriteOf({ ...card, stage: prev }, 'mini'), to = spriteOf({ ...card, stage: m.stage }, 'mini')
540      art = raster(el, KEYS.art, stage === 'evolving' ? evolveFrame(from, to, since) : to)
541    } else {
542      const from = spriteOf({ ...card, stage: prev }, 'full'), to = spriteOf({ ...card, stage: m.stage }, 'full')
543      const color = card.species === 'mythic' ? MYTHIC_COLOR : RARITY_COLOR[card.rarity]
544      art = stage === 'evolving' || (c.motion && since < TIMING.evolve + 1500)
545        ? svg(el, evolveSvg(from, to, stage === 'evolving' ? since : TIMING.evolve, color), `${safe(m.from, 24)} evolving`, plateSize(), c.motion)
546        : svg(el, creatureSvg(to, color, ART_K, { sparkle: c.motion }), safe(m.to, 24), plateSize(), c.motion)
547    }
548  }
549  if (stage === 'evolving') {
550    return beside(c, art, [
551      <Text bold wrap="truncate-end">{`What? ${safe(m.from, 24)} is evolving!`}</Text>,
552      <Text dimColor wrap="truncate-end">{m.stage === 3 ? 'Its final stage' : 'Its next stage'}</Text>,
553    ])
554  }
555  const gains = card ? gainsWords(evolveGains(card, m.stage)) : ''
556  const raised = card?.raisedIn ? `Raised under ${FAMILY_INFO[card.raisedIn].name}` : ''
557  const rows: RenderElement[] = [<Text bold wrap="truncate-end">{`${safe(m.from, 24)} evolved into ${safe(m.to, 24)}!`}</Text>]
558  if (gains) rows.push(<Text color={INK.good} wrap="truncate-end">{gains}</Text>)
559  if (raised) rows.push(<Text dimColor wrap="truncate-end">{raised}</Text>)
560  return beside(c, art, rows, { act: actionsRow(c, m, 'Look', null) })
561}
562
hooks/ui/ceremony.tsx 400 lines
1// The reveal ceremonies (SPEC 13.5, 13.9, 13.10, 14, 25): the pack (a package that tears open, glowing backs that flip
2// one by one, layered reveals, a summary), the present, the fusion or drop egg, a craft and a bounty. The view draws
3// each state; the driver paces the states and, on the terminal, blits the frames in between (build-up, pause, flash,
4// celebration). On the desktop each state is one SMIL Svg that plays the same beats. Everything auto-advances; with
5// motion off every card is face up at once. The last reveal stays to re-read until Done.
6import type { RenderElement } from 'claude-code'
7import type { Card } from '../core/types.ts'
8import { geneScore, look } from '../core/cards.ts'
9import { FAMILY_INFO } from '../core/families.ts'
10import { TRAITS } from '../core/traits.ts'
11import type { Fx, Reveal, RevealControl, RevealDriver } from '../client/types.ts'
12import { bar, dots, fit, plural } from '../client/text.ts'
13import { packsLine, perRow, revealLine, revealSummary, revealTitle } from '../client/viewmodels.ts'
14import { artPixels, artSvg, card, displayName, frameOf, isMythic, rarityColor, rarityLabel, stamps } from './card.tsx'
15import type { CardFace } from './card.tsx'
16import { TIMING, tint } from '../client/anim.ts'
17import type { Cells } from './ceremony-art.tsx'
18import {
19  backStage, eggStage, faceStage, flipStage, mergeStage, packStage, presentStage, singleMs, slotCells, spinStage, stageCells,
20  svgFace, svgGenes, svgPackage, svgSingle, svgSlotBack, svgTurn,
21} from './ceremony-art.tsx'
22import type { Ctx, Shown } from './pane-kit.tsx'
23import { TILE, actions, btn, cardRow, column, grid, line, para, tile } from './pane-kit.tsx'
24import { FAMILY_COLOR, FAMILY_MARK, INK, MYTHIC_COLOR, RARITY_COLOR, SPACE, SVG_SCALE, hexInt } from './tokens.ts'
25
26const STAGE_SCALE = 8
27const SINGLE = new Set<Reveal['kind']>(['egg', 'present', 'bounty', 'craft'])
28type SingleKind = 'egg' | 'present' | 'bounty' | 'craft'
29
30const isSingle = (r: Reveal) => r.cards.length === 1 && SINGLE.has(r.kind)
31const isFusion = (c: Card) => c.species === 'fusion'
32
33const TRINKET: Record<string, string> = { hat: 'wears a tiny hat', bow: 'wears a bow', flower: 'wears a flower', scarf: 'wears a scarf', monocle: 'wears a gold monocle' }
34
35function trinketOf(c: Card): string {
36  try {
37    return TRINKET[look(c).trinket] ?? ''
38  } catch {
39    return ''
40  }
41}
42
43/** How many extra beats a card earns: NEW, first in the world, foil, shiny, high genes, a trinket (SPEC 14). */
44export function layers(c: Card, r: Reveal): number {
45  return [r.fresh.includes(c.species), !!c.firstFind, !!c.foil, c.shiny, geneScore(c.genes) >= 80, trinketOf(c) !== ''].filter(Boolean).length
46}
47
48/** How long a turned card holds the stage, celebrating, before the next one builds up. */
49export const holdMs = (c: Card, r: Reveal) => TIMING.hold[c.rarity] + layers(c, r) * TIMING.layer
50
51/** A pack before its tear has played: the package alone, the strip of backs not out yet. */
52const sealedPack = (r: Reveal, i: number) => r.kind === 'pack' && i === 0 && !r.torn
53
54// ---------- what the stage shows at rest, per state ----------
55
56/** The stage's resting picture: the wrapper before anything flips, then the last card turned face up. */
57export function stillStage(r: Reveal, i: number): Cells {
58  const c = r.cards[Math.max(0, Math.min(r.cards.length - 1, i - 1))]
59  if (i >= 1 && c) return stageCells(artPixels(c), frameOf(c))
60  const first = r.cards[0]
61  if (r.kind === 'pack' && r.family) return packStage(r.family, r.torn ? 3 : 0)
62  if (!first) return packStage(r.family ?? 'sonnet', 3)
63  if (isSingle(r) && r.kind === 'present') return presentStage(0)
64  if (isSingle(r) && r.kind === 'egg') return eggStage(first, 0, 0)
65  return backStage(first, 0)
66}
67
68/** The desktop's state: the same beats the driver plays on the terminal, from the last turned card to the next flip. */
69function stageSvg(c: Ctx, r: Reveal, i: number): string {
70  const first = r.cards[0]
71  if (sealedPack(r, i) && r.family) return svgPackage(r.family, STAGE_SCALE, c.motion && !r.packId)
72  if (!first) return svgPackage(r.family ?? 'sonnet', STAGE_SCALE, false)
73  if (isSingle(r)) return svgSingle(r.kind as SingleKind, first, STAGE_SCALE, c.motion, isFusion(first))
74  const prev = i >= 1 ? r.cards[i - 1]! : null
75  const next = r.cards[i]
76  if (!next) return svgFace(prev ?? first, STAGE_SCALE)
77  return svgTurn(next, STAGE_SCALE, c.motion, prev ? { card: prev, hold: holdMs(prev, r) } : null)
78}
79
80function stage(c: Ctx, r: Reveal, i: number): RenderElement {
81  if (c.surface === 'terminal') {
82    const s = stillStage(r, i)
83    return <c.el.Raster key="cer-stage" columns={s.columns} rows={s.rows} cells={s.cells} />
84  }
85  const side = 18 * STAGE_SCALE
86  const alt = i >= 1 ? `${displayName(r.cards[i - 1]!)}, ${rarityLabel(r.cards[i - 1]!)}` : revealTitle(r)
87  return <c.el.Svg source={stageSvg(c, r, i)} alt={alt} width={side} height={side} isInteractive={c.motion && !r.packId} />
88}
89
90function slot(c: Ctx, card: CardFace, up: boolean, k: number): RenderElement {
91  if (c.surface === 'terminal') {
92    const s = slotCells(card, up)
93    return <c.el.Raster key={`cer-slot-${k}`} columns={s.columns} rows={s.rows} cells={s.cells} />
94  }
95  const side = 8 * SVG_SCALE.mini
96  const source = up ? artSvg(artPixels(card, true), null, SVG_SCALE.mini, false) : svgSlotBack(card, SVG_SCALE.mini, c.motion)
97  return <c.el.Svg source={source} alt={up ? displayName(card) : 'a card face down'} width={side} height={side} isInteractive={c.motion && !up} />
98}
99
100/**
101 * The strip of the whole pack: faces for what is turned, glowing backs for what waits (one glowing gold, say). Below
102 * the strip's width it wraps onto a second row rather than overflowing.
103 */
104function strip(c: Ctx, r: Reveal, i: number): RenderElement {
105  return <c.el.Box flexDirection="row" flexWrap="wrap" columnGap={SPACE.tight} width={c.columns}>{...r.cards.map((x, k) => slot(c, x, k < i, k))}</c.el.Box>
106}
107
108/** The last revealed card's layers, one per line: name, rarity and badges, family and level, genes, traits, trinket. */
109function info(c: Ctx, r: Reveal, x: Card, width: number): RenderElement {
110  const { Box, Text, Svg } = c.el
111  const genes = geneScore(x.genes)
112  const traits = x.traits.map(t => (TRAITS as Record<string, { name: string } | undefined>)[t]?.name ?? t).join(', ')
113  const badges = dots(r.fresh.includes(x.species) && 'NEW', x.firstFind && '★ FIRST IN THE WORLD')
114  const marks = stamps(x, { offline: c.offline }).filter(s => !s.includes('First Discovered'))
115  const trinket = trinketOf(x)
116  return (
117    <Box flexDirection="column" width={width}>
118      <Text bold color={rarityColor(x)} wrap="truncate-end">{fit(displayName(x, 32), width)}</Text>
119      <Text wrap="truncate-end" color={rarityColor(x)}>{fit(rarityLabel(x), width)}</Text>
120      {badges ? <Text wrap="truncate-end" color={INK.accent}>{fit(badges, width)}</Text> : null}
121      <Text wrap="truncate-end">
122        <Text color={FAMILY_COLOR[x.family]}>{FAMILY_MARK[x.family]}</Text>
123        <Text>{fit(` ${FAMILY_INFO[x.family].name} · Lv ${x.level}`, width - 1)}</Text>
124      </Text>
125      {c.surface === 'terminal'
126        ? <Text wrap="truncate-end"><Text dimColor>Genes </Text><Text {...(genes >= 80 ? { color: RARITY_COLOR.legendary } : {})}>{bar(genes / 100, 8)}</Text><Text>{` ${genes}%`}</Text></Text>
127        : (
128          <Box flexDirection="row" columnGap={SPACE.tight}>
129            <Text dimColor>Genes</Text>
130            <Svg source={svgGenes(genes, c.motion)} alt={`gene score ${genes}%`} width={96} height={8} isInteractive={c.motion} />
131            <Text>{`${genes}%`}</Text>
132          </Box>
133        )}
134      {traits ? <Text wrap="truncate-end" dimColor>{fit(traits, width)}</Text> : null}
135      {trinket ? <Text wrap="truncate-end" color={INK.accent}>{fit(trinket, width)}</Text> : null}
136      {marks.length > 0 ? <Text wrap="truncate-end" color={INK.accent}>{fit(marks.join(' · '), width)}</Text> : null}
137    </Box>
138  )
139}
140
141/** The words beside the stage before anything is turned. */
142function waiting(c: Ctx, r: Reveal, width: number): RenderElement {
143  const { Box, Text } = c.el
144  const n = r.cards.length
145  const first = r.cards[0]
146  const lead = r.kind === 'pack' ? `${plural(n, 'card')} inside`
147    : r.kind === 'present' ? 'Something is wrapped up for you'
148    : r.kind === 'egg' ? (first && isFusion(first) ? 'Two creatures become one' : 'Something stirs inside')
149    : r.kind === 'bounty' ? 'Spinning out of their corner'
150    : `${plural(n, 'card')} face down`
151  const glowing = r.cards.filter(x => x.rarity !== 'common' || isMythic(x)).length
152  return (
153    <Box flexDirection="column" width={width}>
154      <Text bold wrap="truncate-end">{fit(revealTitle(r), width)}</Text>
155      <Text wrap="truncate-end">{fit(lead, width)}</Text>
156      {n > 1 && glowing > 0 ? <Text dimColor wrap="truncate-end">{fit(`${plural(glowing, 'back')} glowing`, width)}</Text> : null}
157    </Box>
158  )
159}
160
161// ---------- the screens ----------
162
163/** The ceremony body, framed by a round border that turns gold for a legendary (SPEC 13.5). */
164export function ceremonyScreen(c: Ctx, r: Reveal): Shown {
165  if (r.packId) return {
166    body: column(c, [
167      line(c, revealTitle(r), { bold: true }),
168      stage(c, r, 0),
169      para(c, 'Ready when you are.', { dim: true }),
170      actions(c, [
171        btn(c, { key: 'flip', label: 'Open', hotkey: 'o', primary: true, on: () => c.actions.flip(r.id) }),
172        btn(c, { key: 'done', label: 'Close', hotkey: 'd', on: () => c.actions.doneReveal(r.id) }),
173      ]),
174    ]),
175    hints: ['o Open', 'esc Close'],
176  }
177  const i = Math.max(0, c.state.pane.flipped)
178  const n = r.cards.length
179  const inner: Ctx = { ...c, columns: c.columns - 2 }
180  const last = i >= 1 ? r.cards[Math.min(n, i) - 1] : undefined
181  const legendary = !!last && (last.rarity === 'legendary' || isMythic(last))
182  const borderColor = legendary ? (last && isMythic(last) ? MYTHIC_COLOR : RARITY_COLOR.legendary) : INK.muted
183  const body = i >= n || (isSingle(r) && i >= 1) ? summary(inner, r) : flipping(inner, r, i, legendary)
184  return {
185    body: <c.el.Box flexDirection="column" width={c.columns} borderStyle="round" borderColor={borderColor} borderDimColor={!legendary}>{body.body}</c.el.Box>,
186    hints: body.hints,
187  }
188}
189
190function flipping(c: Ctx, r: Reveal, i: number, legendary: boolean): Shown {
191  const { Box, Text } = c.el
192  const n = r.cards.length
193  const wide = c.columns >= 18 + SPACE.loose + 26
194  const width = wide ? c.columns - 18 - SPACE.loose : c.columns
195  const sealed = i === 0 && (sealedPack(r, i) || isSingle(r))
196  const last = i >= 1 ? r.cards[i - 1]! : null
197  const words = last ? info(c, r, last, width) : waiting(c, r, width)
198  const banner = legendary && last ? line(c, isMythic(last) ? 'MYTHIC!' : 'LEGENDARY!', { color: isMythic(last) ? MYTHIC_COLOR : RARITY_COLOR.legendary, bold: true }) : null
199  const go = sealed
200    ? btn(c, { key: 'flip', label: 'Open', hotkey: 'o', primary: true, on: () => c.actions.flip(r.id) })
201    : btn(c, { key: 'flip', label: 'Flip next', hotkey: 'f', on: () => c.actions.flip(r.id) })
202  return {
203    body: column(c, [
204      banner,
205      n > 1 && !sealedPack(r, i) ? strip(c, r, i) : null,
206      <Box flexDirection={wide ? 'row' : 'column'} columnGap={SPACE.loose} rowGap={SPACE.tight}>
207        <Box flexShrink={0}>{stage(c, r, i)}</Box>
208        {words}
209      </Box>,
210      <Box flexDirection="row" columnGap={SPACE.loose}>
211        {go}
212        <Text dimColor>{n > 1 && i > 0 ? `${i} of ${n}` : ''}</Text>
213      </Box>,
214    ]),
215    hints: [sealed ? 'o Open' : 'f Flip next', 'it opens by itself too', 'esc Done'],
216  }
217}
218
219function summary(c: Ctx, r: Reveal): Shown {
220  const n = r.cards.length
221  if (n === 1 && isSingle(r)) {
222    const x = r.cards[0]!
223    return {
224      body: column(c, [
225        line(c, revealLine(r, x), { bold: true }),
226        r.fresh.includes(x.species) ? line(c, 'NEW species for your album', { color: INK.accent }) : null,
227        card(c.el, c.surface, x, 'full', { key: 'cer-card', width: c.columns, motion: c.motion, offline: c.offline, now: c.now }),
228        actions(c, [
229          btn(c, { key: 'share', label: 'Share', hotkey: 's', on: () => c.actions.share(x.id) }),
230          btn(c, { key: 'done', label: 'Done', hotkey: 'd', on: () => c.actions.doneReveal(r.id) }),
231        ]),
232      ]),
233      hints: ['s Share', 'd Done', 'esc Done'],
234    }
235  }
236  const words = revealSummary(r) || packsLine(r.packs) || 'Nothing inside this time.'
237  const per = perRow(c.columns, TILE)
238  const shown = per >= 3
239    ? grid(c, r.cards.map((x, k) => tile(c, x, { key: `sum-${k}`, ...(r.fresh.includes(x.species) ? { note: 'NEW' } : {}), on: () => c.actions.push({ kind: 'card', cardId: x.id }) })), per)
240    : column(c, r.cards.map((x, k) => cardRow(c, x, { key: `sum-${k}`, note: dots(rarityLabel(x), r.fresh.includes(x.species) && 'NEW'), on: () => c.actions.push({ kind: 'card', cardId: x.id }) })), SPACE.none)
241  const team = n > 0 && (r.kind === 'pack' || r.kind === 'trader' || r.kind === 'redeem')
242  return {
243    body: column(c, [
244      line(c, words, { bold: true }),
245      n > 0 ? shown : null,
246      actions(c, [
247        team && !c.state.pane.busy && !c.state.account.readOnly ? btn(c, { key: 'set-team', label: n === 1 ? 'Set in team' : 'Choose cards', hotkey: 't', on: async () => {
248          if (n === 1) await c.actions.push({ kind: 'team-slot', cardId: r.cards[0]!.id })
249          else {
250            await c.actions.doneReveal(r.id)
251            await c.actions.tab('cards')
252          }
253        } }) : null,
254        btn(c, { key: 'done', label: 'Done', hotkey: 'd', on: () => c.actions.doneReveal(r.id) }),
255      ]),
256      team ? para(c, n === 1 ? 'Choose where this creature joins your team.' : 'Pick each card and choose its team slot.', { dim: true }) : null,
257    ]),
258    hints: [team ? n === 1 ? 't Set in team' : 't Choose cards' : '', 'd Done', 'Tab Look at a card'],
259  }
260}
261
262// ---------- the driver ----------
263
264/**
265 * `state` is the state on show (cards turned, the tear), `refused` each key's refused blits in it: a Raster that is not
266 * mounted (the desktop, a hidden pane, the strip before the tear) refuses, and the layout only changes with the state.
267 */
268type Run = { fx: Fx; ctl: RevealControl; id: string; state: string; refused: Map<string, number> }
269type Frame = [key: string, cells: Cells]
270
271/** Refusals after which a key is left alone until the state changes. */
272const GIVE_UP = 8
273
274const sleep = (fx: Fx, ms: number) => new Promise<void>(res => { fx.after(Math.max(0, ms), res) })
275
276async function at(run: Run): Promise<{ r: Reveal; i: number } | null> {
277  const r = await run.ctl.reveal()
278  if (!r || r.id !== run.id) return null
279  return { r, i: await run.ctl.flipped() }
280}
281
282/**
283 * Plays `ms` of frames while the reveal stays at state `i`. False when the reveal closed or someone pressed on past
284 * it. A key whose Raster keeps refusing is skipped for the rest of the state; the others still play.
285 */
286async function play(run: Run, i: number, ms: number, frames: ((t: number, s: number) => Frame[]) | null): Promise<boolean> {
287  const start = await run.fx.now()
288  for (;;) {
289    const now = await at(run)
290    if (!now || now.i !== i) return false
291    const state = `${now.i}|${now.r.torn ? 1 : 0}`
292    if (state !== run.state) {
293      run.state = state
294      run.refused.clear()
295    }
296    const el = (await run.fx.now()) - start
297    if (frames) {
298      for (const [key, cells] of frames(Math.min(1, el / Math.max(1, ms)), el / 1000)) {
299        const refused = run.refused.get(key) ?? 0
300        if (refused >= GIVE_UP) continue
301        if (await run.fx.ui.blit('pane', key, cells.cells)) run.refused.delete(key)
302        else run.refused.set(key, refused + 1)
303      }
304    }
305    if (el >= ms) return true
306    await sleep(run.fx, Math.min(TIMING.tick, ms - el))
307  }
308}
309
310/** The face-down legendary and epic slots keep pulsing in the strip while you wait. */
311function pulses(r: Reveal, i: number, s: number): Frame[] {
312  const out: Frame[] = []
313  r.cards.forEach((x, k) => {
314    if (k >= i && (x.rarity === 'legendary' || x.rarity === 'epic' || isMythic(x))) out.push([`cer-slot-${k}`, slotCells(x, false, s)])
315  })
316  return out.slice(0, 3)
317}
318
319const face = (x: Card) => stageCells(artPixels(x), frameOf(x))
320const BRIGHT = hexInt(INK.bright)
321
322/** One frame of a single-card moment at `ms` into it, ending on the face. */
323function singleFrame(kind: SingleKind, x: Card, ms: number): Cells {
324  const fusion = isFusion(x)
325  let t = ms
326  if (kind === 'egg') {
327    if (fusion) {
328      if (t < TIMING.merge) return mergeStage(x, t / TIMING.merge)
329      t -= TIMING.merge
330    }
331    if (t < 3 * TIMING.wobble) {
332      const q = Math.floor((t % TIMING.wobble) / (TIMING.wobble / 4))
333      return eggStage(x, 0, ([0, -1, 1, 0] as const)[q] ?? 0)
334    }
335    t -= 3 * TIMING.wobble
336    if (t < TIMING.crack) return eggStage(x, 1, 0)
337    t -= TIMING.crack
338    if (t < TIMING.crack) return eggStage(x, 2, 0)
339    t -= TIMING.crack
340  } else if (kind === 'present') {
341    if (t < 2 * TIMING.presentShake) return presentStage(0, ([0, 1, -1, 1] as const)[Math.floor(t / 110) % 4])
342    t -= 2 * TIMING.presentShake
343    if (t < TIMING.unwrap) return presentStage(1)
344    t -= TIMING.unwrap
345    if (t < TIMING.unwrap) return presentStage(2)
346    t -= TIMING.unwrap
347  } else if (kind === 'bounty') {
348    if (t < TIMING.spin) return spinStage(x, t / TIMING.spin)
349    t -= TIMING.spin
350  } else {
351    if (t < TIMING.buildup.rare) return backStage(x, t / 1000)
352    t -= TIMING.buildup.rare
353  }
354  if (t < TIMING.cardFlash * 2) return stageCells(tint(artPixels(x), BRIGHT, 1 - t / (TIMING.cardFlash * 2)), frameOf(x))
355  return face(x)
356}
357
358/** Paces the reveal (registered in register.tsx's REVEAL_DRIVER slot). Resolves when the reveal is done or gone. */
359export const ceremony: RevealDriver = async (fx, ctl) => {
360  const first = await ctl.reveal()
361  if (!first) return
362  const run: Run = { fx, ctl, id: first.id, state: '', refused: new Map() }
363  let tore = false
364  for (;;) {
365    const now = await at(run)
366    if (!now) return
367    const { r, i } = now
368    const n = r.cards.length
369    if (i >= n) return
370    if (!(await ctl.motion())) {
371      await ctl.flip(n)
372      return
373    }
374    if (isSingle(r)) {
375      const x = r.cards[0]!
376      const kind = r.kind as SingleKind
377      if (!(await play(run, 0, singleMs(kind, isFusion(x)), (_, s) => [['cer-stage', singleFrame(kind, x, s * 1000)]]))) continue
378      await ctl.flip(1)
379      await play(run, 1, TIMING.hold[x.rarity], (_, s) => [['cer-card-art', faceStage(x, s)]])
380      return
381    }
382    // the package waits a beat and tears; only then do the five backs come out (the strip, a state of its own)
383    if (sealedPack(r, i) && r.family && !tore) {
384      const family = r.family
385      if (!(await play(run, 0, TIMING.tearDelay, null))) continue
386      if (!(await play(run, 0, TIMING.tear, t => [['cer-stage', packStage(family, Math.min(3, 1 + Math.floor(t * 3)) as 1 | 2 | 3)]]))) continue
387      tore = true
388      await ctl.tear()
389      continue
390    }
391    const x = r.cards[i]!
392    if (!(await play(run, i, TIMING.buildup[x.rarity], (_, s) => [['cer-stage', backStage(x, s)], ...pulses(r, i + 1, s)]))) continue
393    if (!(await play(run, i, TIMING.pause, (_, s) => pulses(r, i + 1, s)))) continue
394    if (!(await play(run, i, TIMING.flip[x.rarity], t => [['cer-stage', flipStage(x, t)]]))) continue
395    await ctl.flip(i + 1)
396    const key = i + 1 < n ? 'cer-stage' : `sum-${i}-art`
397    await play(run, i + 1, holdMs(x, r), (_, s) => [[key, faceStage(x, s)], ...pulses(r, i + 1, s)])
398  }
399}
400
hooks/ui/pane.tsx 160 lines
1// The /spin pane (SPEC 9, 13, 14, 21, 25, 28, 30, 33): the same frame on every screen (header, body, hint row), the
2// four tabs, the views pushed over them (card, fuse, species, profile, gift, privacy, devices), the reveal ceremonies,
3// and the daily hello. Registered in register.tsx's PANE slot; draws only from the state it is handed.
4import type { RenderElement } from 'claude-code'
5import type { GameState, PaneView } from '../client/types.ts'
6import { hasFeature } from '../client/game.ts'
7import { catalogOf } from '../client/frozen.ts'
8import { safe } from '../client/text.ts'
9import { hello, screenOf } from '../client/viewmodels.ts'
10import { ceremonyScreen } from './ceremony.tsx'
11import { privacyScreen, devicesScreen } from './pane-account.tsx'
12import { worldScreen } from './pane-world.tsx'
13import { albumScreen, speciesScreen } from './pane-album.tsx'
14import { cardScreen, cardsScreen, fuseScreen, teamSlotScreen } from './pane-cards.tsx'
15import type { Ctx, Shown } from './pane-kit.tsx'
16import { actions, btn, column, formMini, frame, line, marketOpen, para, playable } from './pane-kit.tsx'
17import { listingScreen, sellScreen } from './pane-market.tsx'
18import { teamScreen } from './pane-team.tsx'
19import { communityScreen, helpScreen, todayScreen } from './pane-community.tsx'
20import { giftScreen, profileScreen } from './pane-trade.tsx'
21import { INK, SPACE } from './tokens.ts'
22
23type Env = Parameters<PaneView>[0]
24
25/**
26 * Nothing to play yet, or no more (a signed-out machine's cached collection): the start screen shows in place of the
27 * tabs, with its own keys, so the tabs carry none. Privacy and devices still open.
28 */
29function startsHere(s: GameState): boolean {
30  const top = screenOf(s.pane).kind
31  return !playable(s) && top !== 'privacy' && top !== 'devices' && top !== 'world'
32}
33
34function ctxOf(env: Env): Ctx {
35  const s = env.state
36  return {
37    el: env.el, surface: env.surface, columns: Math.max(20, Math.floor(env.columns)), rows: env.rows, now: env.now || s.clock,
38    actions: env.actions, state: s, root: screenOf(s.pane).kind === 'tab' && !startsHere(s), offline: s.account.world === 'offline',
39    motion: s.prefs.motion, hitAreas: !!env.hitAreas,
40    hitIntent: JSON.stringify([s.account.world, s.account.server, s.pane.tab, s.pane.community, s.pane.stack]),
41  }
42}
43
44/**
45 * Before there is a collection to play: the silent join, a server that does not answer, an offline save that cannot be
46 * opened, and a machine the server no longer knows (a session unused for 180 days, reset elsewhere, a deleted
47 * account), where starting fresh is the one action and a passkey sign-in the way back to the old collection.
48 */
49function starting(c: Ctx): Shown {
50  const a = c.state.account
51  const online = a.world === 'online'
52  const busy = a.link === 'joining' || a.link === 'starting'
53  const out = a.link === 'signed-out' && online
54  const deleted = out && a.note.startsWith('Your online account was deleted')
55  const lead = a.link === 'joining' ? 'Hatching your first Spinling…'
56    : a.link === 'starting' ? 'Getting your collection…'
57    : !online ? 'Your offline collection could not be opened.'
58    : deleted ? 'Your online account was deleted.'
59    : out ? `This computer is signed out of ${a.host}.`
60    : `Can't reach ${a.host} right now.`
61  const guide = busy ? 'It takes a moment and asks nothing of you. Your welcome pack waits in the band.'
62    : !online ? 'It is left exactly as it was. Update the mod, or join online for a separate collection.'
63    : deleted ? 'Start fresh for a new online collection, or play offline: each world keeps its own.'
64    : out ? 'Start fresh for a new online collection, or sign in with a passkey saved on another computer to play the old one.'
65    : 'Your cards are safe. Playing offline keeps a separate collection on this machine.'
66  // the reason itself, when it says more than the headline (an unreadable save, a server's own words)
67  const detail = !busy && !out && a.note && a.note.replace(/\.$/, '') !== lead.replace(/\.$/, '') ? safe(a.note, 160) : ''
68  const signIn = out && hasFeature(a, 'passkey')
69  return {
70    body: column(c, [
71      line(c, lead, { bold: true }),
72      detail ? para(c, detail, { dim: true }) : null,
73      para(c, guide, { dim: true }),
74      busy ? null : actions(c, [
75        out ? btn(c, { key: 'start-fresh', label: 'Start fresh online', hotkey: '1', primary: true, on: () => c.actions.world('online') }) : null,
76        signIn ? btn(c, { key: 'passkey-signin', label: c.columns < 26 ? 'Use a passkey' : 'Sign in with a passkey', hotkey: 'k', on: () => c.actions.push({ kind: 'devices' }) }) : null,
77        online
78          ? btn(c, { key: 'play-offline', label: 'Play offline', hotkey: 'w', on: () => c.actions.world('offline') })
79          : btn(c, { key: 'join-online', label: 'Join online', hotkey: 'w', on: () => c.actions.world('online') }),
80      ]),
81    ]),
82    hints: busy ? ['esc Close'] : [out ? '1 Start fresh' : '', signIn ? 'k Sign in' : '', online ? 'w Play offline' : 'w Join online', 'esc Close'],
83    bare: true,
84  }
85}
86
87/** The daily hello (SPEC 13.11): the first pane of each UTC day shows the rule and the featured species. */
88function helloBanner(c: Ctx): RenderElement | null {
89  if (!c.state.pane.hello) return null
90  const day = hello(c.now, catalogOf(c.state.account))
91  const { Box } = c.el
92  const name = day.featured ? safe(day.featured.names[day.featured.legendary ? 2 : 0], 24) : ''
93  const beside = c.columns >= 30
94  const inner: Ctx = { ...c, columns: beside ? c.columns - 8 - SPACE.loose : c.columns }
95  return (
96    <Box flexDirection={beside ? 'row' : 'column'} columnGap={SPACE.loose} rowGap={SPACE.tight} width={c.columns}>
97      {day.featured ? <Box flexShrink={0}>{formMini(c, 'hello-featured', day.featured, true)}</Box> : null}
98      {column(inner, [
99        line(inner, `Today: ${day.rule}`, { color: INK.accent }),
100        para(inner, `${day.rule === 'Shiny Hour' ? day.text.replace('shinies', 'Alt colour cards') : day.text}.${name ? ` Featured: ${name}.` : ''}`, { dim: true }),
101        actions(inner, [btn(inner, { key: 'hello-ok', label: 'Got it', dim: true, on: () => c.actions.pane(p => ({ ...p, hello: false })) })]),
102      ], SPACE.none)}
103    </Box>
104  )
105}
106
107function tabScreen(c: Ctx): Shown {
108  const tab = c.state.pane.tab
109  // The old Market tab remains readable as Community's Market section; unavailable sections fall back to Profile.
110  const shown = tab === 'cards' ? cardsScreen(c) : tab === 'album' ? albumScreen(c) : tab === 'trade' || tab === 'market' ? communityScreen(c) : teamScreen(c)
111  return { ...shown, banner: helloBanner(c) }
112}
113
114/** An online-only view in the offline world: one line and the way back (SPEC 28); nothing is asked of a server. */
115function onlineOnly(c: Ctx): Shown {
116  return {
117    body: column(c, [
118      para(c, 'This needs the online world, a collection of its own.', { dim: true }),
119      actions(c, [btn(c, { key: 'join-online', label: 'Join online', hotkey: '1', primary: true, on: () => c.actions.world('online') })]),
120    ]),
121    hints: ['1 Join online', 'esc Back'],
122  }
123}
124
125function route(c: Ctx): Shown {
126  const s = c.state
127  const top = screenOf(s.pane)
128  if (startsHere(s)) return starting(c)
129  switch (top.kind) {
130    case 'tab': return tabScreen(c)
131    case 'reveal': return s.reveal ? ceremonyScreen(c, s.reveal) : tabScreen({ ...c, root: true })
132    case 'card': return cardScreen(c, top.cardId)
133    case 'team-slot': return teamSlotScreen(c, top.cardId, top.chosenSlot)
134    case 'fuse': return fuseScreen(c, top.cardId, top.otherId)
135    case 'species': return speciesScreen(c, top.speciesId)
136    case 'profile': return profileScreen(c, top)
137    case 'gift': return giftScreen(c, top.code)
138    case 'privacy': return privacyScreen(c)
139    case 'devices': return devicesScreen(c)
140    case 'world': return worldScreen(c, top)
141    case 'trades': return communityScreen(c, { section: 'trades' })
142    case 'mine': return communityScreen(c, { section: 'profile' })
143    case 'help': return helpScreen(c)
144    case 'today': return todayScreen(c, top.rule)
145    case 'boards': return c.offline ? onlineOnly(c) : communityScreen(c, { section: 'boards', boards: top })
146    case 'listing': return c.offline ? onlineOnly(c) : listingScreen(c, top)
147    case 'sell': return c.offline ? onlineOnly(c) : sellScreen(c, top)
148  }
149  return tabScreen({ ...c, root: true })
150}
151
152/** One screen of the pane, framed. */
153export function draw(env: Env): RenderElement {
154  const c = ctxOf(env)
155  return frame(c, route(c))
156}
157
158/** The pane (register.tsx's PANE slot). */
159export const pane: PaneView = draw
160
hooks/ui/launcher.tsx 32 lines
1// A native composer control: PromptHint in the terminal, SessionMode in Desktop. Claude's tree stays unread.
2// Desktop shapes SessionMode into one compact line, keeping native buttons and their hosted press handlers.
3import type { RenderElement } from 'claude-code'
4import type { El, GameState } from '../client/types.ts'
5import { packReady } from '../client/game.ts'
6import { MARK, SPACE } from './tokens.ts'
7
8export type LauncherState = Pick<GameState, 'account' | 'me' | 'battle' | 'prefs' | 'signals' | 'social'>
9
10/** One cue at a time. Never fetch a board just to decorate Claude's composer. */
11export function launcherLabel(s: LauncherState): string {
12  if (packReady(s)) return `Spinlings ${MARK.dot}`
13  const board = s.social.rankings, player = s.me?.player, mine = board?.me
14  if (s.account.world === 'online' && s.account.link === 'ready' && s.account.features.includes('stats')
15    && !s.battle && !s.social.loading.includes('rankings') && player?.leaderboard
16    && board?.board === 'rating' && board.period === 'all' && mine?.handle === player.handle
17    && mine.value === player.rating && Number.isSafeInteger(mine.rank) && mine.rank > 0) return `Spinlings #${mine.rank}`
18  return 'Spinlings'
19}
20
21export function launcher(env: { el: El; state: LauncherState; theirs: RenderElement; open(): void }): RenderElement {
22  if (env.state.prefs.quiet) return env.theirs
23  const { Box, Button } = env.el
24  const label = launcherLabel(env.state)
25  return (
26    <Box flexDirection="row" columnGap={SPACE.loose} flexWrap="wrap">
27      <Box flexShrink={0}><Button key="spinlings-launcher" label={label} plain onPress={env.open} /></Box>
28      <Box flexGrow={1} flexShrink={1}>{env.theirs}</Box>
29    </Box>
30  )
31}
32
hooks/ui/card-hit-state.ts 29 lines
1// Only the current pane's local card controls can answer a renderer's null message.
2type Pick = { button: string; intent: string; on: () => unknown; fence: number }
3const picks = new Map<string, Pick>()
4let fence = 0
5
6export function clearCardHits(inactive = false): void {
7  picks.clear()
8  if (inactive) fence++
9}
10
11export function registerCardHit(key: string, button: string, intent: string, on: () => unknown): void {
12  picks.set(key, { button, intent, on, fence })
13}
14
15export function prepareCardHit(key: string, data: unknown): ((focus: (button: string) => Promise<unknown>) => Promise<void>) | null {
16  const pick = data === null ? picks.get(key) : undefined
17  if (!pick) return null
18  const current = () => {
19    const live = picks.get(key)
20    return pick.fence === fence && live !== undefined && live.fence === pick.fence
21      && live.button === pick.button && live.intent === pick.intent
22  }
23  return async focus => {
24    if (!current()) return
25    try { await focus(pick.button) } catch { /* a host may have no focus route */ }
26    if (current()) await pick.on()
27  }
28}
29
hooks/core/api.ts 520 lines
1// The wire contract between the mod and the server: every operation's request and response, the route table, and
2// SpinlingsApi, the one interface both of the mod's backends implement (RemoteBackend over HTTP, LocalBackend
3// offline; SPEC section 28). Runtime validators for these shapes live in core/schemas.ts and are used by BOTH
4// sides: the server parses requests strictly, the client reads responses tolerantly (SPEC section 32).
5import type {
6  BattleCard, BattleLog, BattleResult, BattleSetup, Card, DailyRule, Family, LeagueName, Rarity, Species, TraderDeal,
7} from './types.ts'
8
9/** The wire API version: `/v1`. */
10export const API_VERSION = 1
11
12/** Feature flags a server may list in GET /v1/version; the mod hides what a server does not list. */
13export const FEATURES = [
14  'rivals', 'trader', 'redeem', 'passkey', 'leaderboard', 'handle-reroll', 'mythics', 'seasons',
15  /** the market: listCard, market, buyListing, cancelListing and MeResponse.listings */
16  'market',
17  /** StartBattleRequest.handle: a duel against one player's saved team */
18  'challenge',
19  /** player stats (PlayerView.stats, ProfileResponse.stats) and every leaderboard (rankings) */
20  'stats',
21  /** Private collection and passkey sign-in at /account. */
22  'browser-account',
23  /** POST /v1/me/handle accepts an optional chosen public username. */
24  'custom-handles',
25] as const
26export type Feature = (typeof FEATURES)[number]
27
28export type ApiErrorCode =
29  | 'bad_request' | 'unauthorized' | 'not_found' | 'rate_limited' | 'cap_reached' | 'not_allowed' | 'conflict'
30  | 'insufficient_sparks' | 'expired' | 'too_large' | 'unavailable' | 'upgrade_required'
31
32export type ApiError = { error: { code: ApiErrorCode; message: string } }
33
34/** The player's own view. Other players never see any of it except as their public profile (ProfileResponse). */
35export type PlayerView = {
36  handle: string
37  /** the UTC day from which the handle may be rerolled again (once a week) */
38  handleRerollFrom: string
39  sparks: number
40  rating: number
41  league: LeagueName
42  /** on the public leaderboards, with stats on the profile: on by default, off hides both (SPEC 20) */
43  leaderboard: boolean
44  /** UTC day of joining (day granularity, SPEC section 20) */
45  joinedDay: string
46  /** finished battles in all */
47  battles: number
48  /** always true online: trading has no account limits (a 0.1.0 mod reads it as its old trust gate) */
49  canTrade: boolean
50  team: string[]
51  wishlist: string[]
52  /** bumps whenever any of this player's cards change; refetch /v1/cards when it moves */
53  cardsVersion: number
54  /** consecutive wins; every 3rd pays a streak pack (SPEC section 14) */
55  streak: number
56  /** species ids this player has ever owned (album) */
57  seen: string[]
58  /** the next wild lead is rare or better (the rested bonus, SPEC section 17) */
59  rested: boolean
60  /** the earliest server times a wild battle, a duel and a pack charge are accepted (server pacing, SPEC section 24) */
61  nextWildAt: number
62  nextDuelAt: number
63  nextChargeAt: number
64  /** the player's own stats, all time (servers listing the `stats` feature) */
65  stats?: PlayerStats
66}
67
68/**
69 * Public game numbers (SPEC 8, 20): counts only, never who or when. The player's own are as they are now; everyone
70 * else sees them (on a profile, and on the boards) as they stood at the last UTC midnight.
71 */
72export type PlayerStats = {
73  /** duels against players won and lost, attacking or defending, while the pair limit counts them; never a challenge */
74  duelWins: number
75  duelLosses: number
76  /** distinct players beaten in a duel: a count, never who */
77  playersBeaten: number
78  wildWins: number
79  catches: number
80  /** species ever owned (the album) */
81  speciesCollected: number
82  /** First Discovered stamps */
83  firstFinds: number
84  /** Mythics caught */
85  mythicsFound: number
86  /** cards sold on the market, counted once per buyer */
87  marketSales: number
88}
89
90export type PackSource = 'welcome' | 'charge' | 'bought' | 'daily' | 'bonus' | 'streak' | 'season' | 'trader' | 'promo'
91export type PackView = { id: string; family: Family; source: PackSource; /** UTC day */ day: string }
92
93export type NoticeKind =
94  | 'defense-win' | 'defense-loss' | 'evolved' | 'gift-claimed' | 'gift-returned' | 'offer-received' | 'offer-accepted'
95  | 'offer-declined' | 'offer-expired' | 'bonus-pack' | 'daily-pack' | 'streak-pack' | 'season-end' | 'new-device'
96  | 'market-sold' | 'market-expired'
97  /** the tolerant reader's fallback for a kind this client does not know: show the text only */
98  | 'notice'
99
100export type Notice = {
101  id: string
102  /** UTC day only: shown as "today", "yesterday" or the date, never a time (SPEC section 20) */
103  day: string
104  kind: NoticeKind
105  /** server-composed from fixed templates; the client still sanitizes it */
106  text: string
107  /** the other player, for defense notices (revenge), offers and market sales */
108  handle?: string
109}
110
111export type OfferState = 'open' | 'accepted' | 'declined' | 'cancelled' | 'expired'
112
113export type OfferView = {
114  id: string
115  from: string
116  to: string
117  give: BattleCard[]
118  get: BattleCard[]
119  state: OfferState
120  createdAt: number
121  expiresAt: number
122}
123
124export type GiftView = { code: string; card: Card; createdAt: number; expiresAt: number; claimedBy?: string }
125
126export type ListingState = 'open' | 'sold' | 'cancelled' | 'expired'
127export type ListingKind = 'sparks' | 'swap' | 'both'
128
129/**
130 * The card a listing asks for besides (or instead of) sparks: a species, or a family and/or a minimum rarity, and
131 * optionally shiny and/or foil. At least one field; a species names its family, so never both.
132 */
133export type MarketWant = { species?: string; family?: Family; rarity?: Rarity; shiny?: true; foil?: true }
134
135/** A market listing as anyone sees it (SPEC 8, 20): the public card, the seller's handle, the terms and the day only. */
136export type ListingView = {
137  id: string
138  /** the seller's handle as it was when listed */
139  seller: string
140  card: BattleCard
141  /** sparks the buyer pays the seller; 0 when the listing asks only for a card */
142  price: number
143  /** the card the buyer hands over too; absent when the listing asks only for sparks */
144  want?: MarketWant
145  /** the UTC day it was listed; it lapses at the first midnight 14 days on */
146  day: string
147  state: ListingState
148}
149
150/** One recent sale of a species (SPEC 8): the day, the sparks and the card's kind; never who sold or bought. */
151export type SaleView = { day: string; price: number; rarity: Rarity; shiny: boolean; foil: boolean }
152
153export type MeResponse = {
154  player: PlayerView
155  packs: PackView[]
156  notices: Notice[]
157  offers: { incoming: OfferView[]; outgoing: OfferView[] }
158  gifts: GiftView[]
159  /** the player's own open listings, newest first (servers listing the `market` feature) */
160  listings?: ListingView[]
161  now: number
162}
163
164// ---------- requests and responses ----------
165
166export type EmptyRequest = Record<string, never>
167export type VersionResponse = {
168  api: number
169  server: string
170  rules: number
171  generator: number
172  minClient: string
173  latestClient: string
174  sunset?: { api: number; date: string }
175  features: string[]
176}
177export type SeasonResponse = { season: number; generator: number; species: Species[] }
178export type WorldResponse = { day: string; season: number; rule: DailyRule; featured: string; roamer: string; players: number }
179export type ChallengeResponse = { challenge: string; difficulty: number }
180export type JoinRequest = { challenge: string; nonce: string; family: Family }
181/** `token` is the new session's bearer: returned exactly once, kept only in $.store */
182export type JoinResponse = { token: string; me: MeResponse }
183export type TokenResponse = { token: string }
184export type DevicesResponse = { sessions: number; passkeys: number }
185/** `url` is a page on the same server origin (isOnServer); `pollId` is polled at GET /v1/auth/poll/:pollId */
186export type AuthStartResponse = { url: string; pollId: string }
187export type AuthPollResponse =
188  | { status: 'pending' }
189  /** a passkey was saved to this account */
190  | { status: 'added' }
191  /** a passkey sign-in finished: the new session, delivered exactly once */
192  | { status: 'done'; token: string; me: MeResponse }
193export type HandleRequest = { handle?: string }
194export type HandleResponse = { handle: string; handleRerollFrom: string }
195export type LeaderboardOptRequest = { optIn: boolean }
196export type LeaderboardOptResponse = { leaderboard: boolean }
197export type DeleteResponse = { deleted: true }
198/** GET /v1/cards?after={next}: the page after a previous answer's `next` */
199export type CardsRequest = { after?: string }
200/** The cards oldest first, as many as fit one answer; `next` (absent on the last page) is where the next page starts. */
201export type CardsResponse = { cards: Card[]; version: number; next?: string }
202export type ChargeRequest = { family: Family }
203export type BuyPackRequest = { family: Family }
204export type PacksResponse = { packs: PackView[] }
205export type OpenPackRequest = { packId: string }
206export type OpenPackResponse = { cards: Card[] }
207export type TeamRequest = { cardIds: string[] }
208export type TeamResponse = { team: string[] }
209export type StartBattleRequest = {
210  kind: 'wild' | 'duel'
211  family: Family
212  /** duel this player (a revenge from a defense notice) */
213  revenge?: string
214  /**
215   * duel this player's saved team: a challenge by handle (servers listing the `challenge` feature). Friendly: it
216   * moves no rating or stat and pays XP and a loss's sparks whatever the result (no bounty, streak or packs).
217   */
218  handle?: string
219}
220export type Opponent =
221  | { kind: 'wild' }
222  | { kind: 'player'; handle: string; league: LeagueName }
223  /** a generated Rival trainer, shown as "Rival {name}" */
224  | { kind: 'rival'; name: string; league: LeagueName }
225export type StartBattleResponse = {
226  id: string
227  setup: BattleSetup
228  opponent: Opponent
229  /** auto-filled slots: a tired or missing team card replaced by another card */
230  subs: { slot: number; cardId: string; replaced: string | null }[]
231  /** per defender slot: nobody has obtained this species yet this season (the FIRST IN THE WORLD tease) */
232  firstPossible: boolean[]
233  startedAt: number
234  /** finish is refused before this server time (rounds x 1.5 s with no inputs) */
235  finishAfter: number
236}
237export type FinishBattleRequest = { inputs: number[] }
238export type FinishBattleResponse = {
239  result: BattleResult
240  sparks: number
241  xp: { cardId: string; xp: number; levelsGained: number; evolved: boolean; stage: 1 | 2 | 3 }[]
242  rating: number
243  ratingDelta: number
244  /** a wild win that rolled a catch: pick one within 10 minutes; empty on a slip */
245  catchOptions: BattleCard[]
246  bounty: Card | null
247  dailyWinPack: boolean
248  streak: number
249  streakPack: boolean
250  tired: string[]
251  /** the authoritative log: animate it whenever setup.rules differs from this client's RULES_VERSION */
252  log: BattleLog
253}
254export type CatchRequest = { index: number }
255export type CardResponse = { card: Card }
256export type FuseRequest = { otherId: string }
257export type FuseResponse = { card: Card; consumed: [string, string] }
258export type RecycleResponse = { sparks: number; gained: number }
259export type ForTradeRequest = { forTrade: boolean }
260export type CraftRequest = { speciesId: string; rarity: Rarity }
261export type WishlistRequest = { species: string[] }
262export type WishlistResponse = { wishlist: string[] }
263/**
264 * Exactly what another player may see (SPEC section 20): no dates, activity or arena. `stats` are public game numbers,
265 * absent for a player who is hidden from the leaderboards. The league, `seenCount` and `stats` are as they stood at
266 * the last UTC midnight, so they never move while the player plays.
267 */
268export type ProfileResponse = {
269  handle: string; league: LeagueName; team: BattleCard[]; forTrade: BattleCard[]; seenCount: number; stats?: PlayerStats
270}
271export type TraderDealView = TraderDeal & { used: boolean }
272export type BoardResponse = {
273  matches: { handle: string; theirs: BattleCard; mine: BattleCard }[]
274  recent: { handle: string; card: BattleCard }[]
275  trader: TraderDealView[]
276}
277export type OfferRequest = { to: string; give: string[]; get: string[] }
278export type OfferResponse = { offer: OfferView }
279export type CounterRequest = { give: string[]; get: string[] }
280export type GiftRequest = { cardId: string }
281export type GiftResponse = { gift: GiftView }
282export type ClaimRequest = { code: string }
283/** The rating board's top 50, all time: handle, league and rating of every player who is not hidden, as of the last UTC midnight. */
284export type LeaderboardResponse = { top: { handle: string; league: LeagueName; rating: number }[] }
285export type BoardName = 'rating' | 'beaten' | 'duelWins' | 'species' | 'mythics' | 'sales'
286export type BoardPeriod = 'all' | 'season'
287/** GET /v1/leaderboards?board=&period= (both optional: rating, all) */
288export type RankingsRequest = { board?: BoardName; period?: BoardPeriod }
289/** `rank` is 1 + the number of players with a higher value, so ties share a rank */
290export type RankRow = { rank: number; handle: string; league: LeagueName; value: number }
291/**
292 * The top 50 and the caller's own row, all by the numbers of the last UTC midnight; `me` is absent when the caller is
293 * hidden or had nothing on this board at that midnight.
294 */
295export type RankingsResponse = { board: BoardName; period: BoardPeriod; season: number; top: RankRow[]; me?: RankRow }
296export type MarketSort = 'newest' | 'cheapest' | 'priciest'
297/** GET /v1/market, every field an optional query field; `after` is the last page's `next`, with the same filters and sort */
298export type MarketRequest = {
299  family?: Family
300  rarity?: Rarity
301  /** a card species: a species id, or fusion, mythic or promo */
302  species?: string
303  shiny?: boolean
304  foil?: boolean
305  kind?: ListingKind
306  minPrice?: number
307  maxPrice?: number
308  sort?: MarketSort
309  after?: string
310}
311export type MarketResponse = {
312  listings: ListingView[]
313  next?: string
314  /** for each season species on this page, its last few sales for sparks, newest first */
315  prices: { species: string; sales: SaleView[] }[]
316}
317/** At least one of `price` (whole sparks) and `want`. */
318export type ListCardRequest = { cardId: string; price?: number; want?: MarketWant }
319export type ListingResponse = { listing: ListingView }
320/** `cardId`: the caller's card that matches the listing's want, exactly when it has one */
321export type BuyRequest = { cardId?: string }
322/** the listing, the card bought (now the caller's) and the caller's sparks after paying */
323export type BuyResponse = { listing: ListingView; card: Card; sparks: number }
324export type RedeemRequest = { code: string }
325export type RedeemResponse = { cards: Card[]; packs: PackView[] }
326export type TraderResponse = { day: string; deals: TraderDealView[] }
327export type TraderDealRequest = { cardIds: string[] }
328export type TraderDealResponse = { cards: Card[]; packs: PackView[]; consumed: string[] }
329
330// ---------- the one interface ----------
331
332/**
333 * Every operation, as one method taking one request object: the path parameters (named as in API_ROUTES) plus the
334 * JSON body fields. RemoteBackend maps each call with routeOf; LocalBackend implements the offline subset
335 * (API_ROUTES[op].offline) and answers the rest with `not_allowed`.
336 */
337export interface SpinlingsApi {
338  /** GET /v1/version (public) */
339  version(req: EmptyRequest): Promise<VersionResponse>
340  /** GET /v1/season/:season (public, immutable) */
341  season(req: { season: number }): Promise<SeasonResponse>
342  /** GET /v1/world (public) */
343  world(req: EmptyRequest): Promise<WorldResponse>
344  /** GET /v1/challenge (public) */
345  challenge(req: EmptyRequest): Promise<ChallengeResponse>
346  /** POST /v1/join (public, proof of work) */
347  join(req: JoinRequest): Promise<JoinResponse>
348  /** POST /v1/auth/start (public): sign in on this machine with a saved passkey */
349  authStart(req: EmptyRequest): Promise<AuthStartResponse>
350  /** GET /v1/auth/poll/:pollId (public) */
351  authPoll(req: { pollId: string }): Promise<AuthPollResponse>
352  /** GET /v1/me */
353  me(req: EmptyRequest): Promise<MeResponse>
354  /** DELETE /v1/me */
355  deleteMe(req: EmptyRequest): Promise<DeleteResponse>
356  /** POST /v1/me/token: revokes every session and returns one new token ("Reset access") */
357  resetToken(req: EmptyRequest): Promise<TokenResponse>
358  /** GET /v1/me/devices */
359  devices(req: EmptyRequest): Promise<DevicesResponse>
360  /** POST /v1/me/passkey/start */
361  passkeyStart(req: EmptyRequest): Promise<AuthStartResponse>
362  /** POST /v1/me/handle: a chosen public username, or a random handle with {}, once a week */
363  rerollHandle(req: HandleRequest): Promise<HandleResponse>
364  /** PUT /v1/me/leaderboard */
365  setLeaderboard(req: LeaderboardOptRequest): Promise<LeaderboardOptResponse>
366  /** GET /v1/cards, a page at a time */
367  cards(req: CardsRequest): Promise<CardsResponse>
368  /** POST /v1/packs/charge */
369  chargePack(req: ChargeRequest): Promise<PacksResponse>
370  /** POST /v1/packs/buy */
371  buyPack(req: BuyPackRequest): Promise<PacksResponse>
372  /** POST /v1/packs/open */
373  openPack(req: OpenPackRequest): Promise<OpenPackResponse>
374  /** PUT /v1/team */
375  setTeam(req: TeamRequest): Promise<TeamResponse>
376  /** POST /v1/battles */
377  startBattle(req: StartBattleRequest): Promise<StartBattleResponse>
378  /** POST /v1/battles/:battleId/finish */
379  finishBattle(req: { battleId: string } & FinishBattleRequest): Promise<FinishBattleResponse>
380  /** POST /v1/battles/:battleId/catch */
381  catchCreature(req: { battleId: string } & CatchRequest): Promise<CardResponse>
382  /** POST /v1/cards/:cardId/fuse */
383  fuse(req: { cardId: string } & FuseRequest): Promise<FuseResponse>
384  /** POST /v1/cards/:cardId/recycle */
385  recycle(req: { cardId: string }): Promise<RecycleResponse>
386  /** POST /v1/cards/:cardId/for-trade */
387  setForTrade(req: { cardId: string } & ForTradeRequest): Promise<CardResponse>
388  /** POST /v1/craft */
389  craft(req: CraftRequest): Promise<CardResponse>
390  /** PUT /v1/wishlist */
391  setWishlist(req: WishlistRequest): Promise<WishlistResponse>
392  /** GET /v1/players/:handle */
393  profile(req: { handle: string }): Promise<ProfileResponse>
394  /** GET /v1/leaderboard: the rating board, as 0.1.0 reads it */
395  leaderboard(req: EmptyRequest): Promise<LeaderboardResponse>
396  /** GET /v1/leaderboards: any board, all time or this season, with the caller's own rank */
397  rankings(req: RankingsRequest): Promise<RankingsResponse>
398  /** GET /v1/board */
399  board(req: EmptyRequest): Promise<BoardResponse>
400  /** POST /v1/offers */
401  offer(req: OfferRequest): Promise<OfferResponse>
402  /** POST /v1/offers/:offerId/accept */
403  acceptOffer(req: { offerId: string }): Promise<OfferResponse>
404  /** POST /v1/offers/:offerId/decline */
405  declineOffer(req: { offerId: string }): Promise<OfferResponse>
406  /** POST /v1/offers/:offerId/cancel */
407  cancelOffer(req: { offerId: string }): Promise<OfferResponse>
408  /** POST /v1/offers/:offerId/counter: declines and returns the new offer */
409  counterOffer(req: { offerId: string } & CounterRequest): Promise<OfferResponse>
410  /** POST /v1/gifts */
411  gift(req: GiftRequest): Promise<GiftResponse>
412  /** POST /v1/gifts/:code/cancel */
413  cancelGift(req: { code: string }): Promise<GiftResponse>
414  /** POST /v1/claim */
415  claim(req: ClaimRequest): Promise<CardResponse>
416  /** GET /v1/market: open listings, filtered and sorted, a page at a time */
417  market(req: MarketRequest): Promise<MarketResponse>
418  /** POST /v1/market: lists one of the caller's cards, which waits in escrow */
419  listCard(req: ListCardRequest): Promise<ListingResponse>
420  /** POST /v1/market/:listingId/buy */
421  buyListing(req: { listingId: string } & BuyRequest): Promise<BuyResponse>
422  /** POST /v1/market/:listingId/cancel: the card comes home */
423  cancelListing(req: { listingId: string }): Promise<ListingResponse>
424  /** POST /v1/redeem */
425  redeem(req: RedeemRequest): Promise<RedeemResponse>
426  /** GET /v1/trader */
427  trader(req: EmptyRequest): Promise<TraderResponse>
428  /** POST /v1/trader/:dealId */
429  traderDeal(req: { dealId: string } & TraderDealRequest): Promise<TraderDealResponse>
430}
431
432export type ApiOp = keyof SpinlingsApi
433export type ApiRequest<K extends ApiOp> = Parameters<SpinlingsApi[K]>[0]
434export type ApiResponse<K extends ApiOp> = Awaited<ReturnType<SpinlingsApi[K]>>
435
436export type ApiRoute = {
437  method: 'GET' | 'POST' | 'PUT' | 'DELETE'
438  /** `:name` segments are filled from the request object's fields of that name */
439  path: string
440  /** needs `Authorization: Bearer` */
441  auth: boolean
442  /** implemented by the offline LocalBackend */
443  offline: boolean
444  /** GET only: request fields sent as the query string when present, each checked like a path parameter */
445  query?: readonly string[]
446}
447
448const r = (method: ApiRoute['method'], path: string, auth: boolean, offline: boolean, query?: readonly string[]): ApiRoute =>
449  ({ method, path, auth, offline, ...(query ? { query } : {}) })
450
451export const API_ROUTES: Readonly<Record<ApiOp, ApiRoute>> = {
452  version: r('GET', '/v1/version', false, false),
453  season: r('GET', '/v1/season/:season', false, true),
454  world: r('GET', '/v1/world', false, true),
455  challenge: r('GET', '/v1/challenge', false, false),
456  join: r('POST', '/v1/join', false, false),
457  authStart: r('POST', '/v1/auth/start', false, false),
458  authPoll: r('GET', '/v1/auth/poll/:pollId', false, false),
459  me: r('GET', '/v1/me', true, true),
460  deleteMe: r('DELETE', '/v1/me', true, true),
461  resetToken: r('POST', '/v1/me/token', true, false),
462  devices: r('GET', '/v1/me/devices', true, false),
463  passkeyStart: r('POST', '/v1/me/passkey/start', true, false),
464  rerollHandle: r('POST', '/v1/me/handle', true, false),
465  setLeaderboard: r('PUT', '/v1/me/leaderboard', true, false),
466  cards: r('GET', '/v1/cards', true, true, ['after']),
467  chargePack: r('POST', '/v1/packs/charge', true, true),
468  buyPack: r('POST', '/v1/packs/buy', true, true),
469  openPack: r('POST', '/v1/packs/open', true, true),
470  setTeam: r('PUT', '/v1/team', true, true),
471  startBattle: r('POST', '/v1/battles', true, true),
472  finishBattle: r('POST', '/v1/battles/:battleId/finish', true, true),
473  catchCreature: r('POST', '/v1/battles/:battleId/catch', true, true),
474  fuse: r('POST', '/v1/cards/:cardId/fuse', true, true),
475  recycle: r('POST', '/v1/cards/:cardId/recycle', true, true),
476  setForTrade: r('POST', '/v1/cards/:cardId/for-trade', true, false),
477  craft: r('POST', '/v1/craft', true, true),
478  setWishlist: r('PUT', '/v1/wishlist', true, false),
479  profile: r('GET', '/v1/players/:handle', true, false),
480  leaderboard: r('GET', '/v1/leaderboard', true, false),
481  rankings: r('GET', '/v1/leaderboards', true, false, ['board', 'period']),
482  board: r('GET', '/v1/board', true, false),
483  offer: r('POST', '/v1/offers', true, false),
484  acceptOffer: r('POST', '/v1/offers/:offerId/accept', true, false),
485  declineOffer: r('POST', '/v1/offers/:offerId/decline', true, false),
486  cancelOffer: r('POST', '/v1/offers/:offerId/cancel', true, false),
487  counterOffer: r('POST', '/v1/offers/:offerId/counter', true, false),
488  gift: r('POST', '/v1/gifts', true, false),
489  cancelGift: r('POST', '/v1/gifts/:code/cancel', true, false),
490  claim: r('POST', '/v1/claim', true, false),
491  market: r('GET', '/v1/market', true, false, ['family', 'rarity', 'species', 'shiny', 'foil', 'kind', 'minPrice', 'maxPrice', 'sort', 'after']),
492  listCard: r('POST', '/v1/market', true, false),
493  buyListing: r('POST', '/v1/market/:listingId/buy', true, false),
494  cancelListing: r('POST', '/v1/market/:listingId/cancel', true, false),
495  redeem: r('POST', '/v1/redeem', true, false),
496  trader: r('GET', '/v1/trader', true, true),
497  traderDeal: r('POST', '/v1/trader/:dealId', true, true),
498}
499
500/**
501 * Splits a request object into the filled path, with any query fields present, and the JSON body (every other field).
502 * GET and DELETE carry no body. Path and query values are URI-encoded.
503 */
504export function routeOf<K extends ApiOp>(op: K, req: ApiRequest<K>): { method: ApiRoute['method']; path: string; body: Record<string, unknown> | null } {
505  const route = API_ROUTES[op]
506  const rest: Record<string, unknown> = { ...(req as Record<string, unknown>) }
507  const path = route.path.replace(/:([A-Za-z]+)/g, (_, name: string) => {
508    const v = rest[name]
509    delete rest[name]
510    if (typeof v !== 'string' && typeof v !== 'number') throw new TypeError(`${op}: missing path parameter ${name}`)
511    return encodeURIComponent(String(v))
512  })
513  const query = (route.query ?? []).flatMap(name => {
514    const v = rest[name]
515    delete rest[name]
516    return v === undefined ? [] : [`${name}=${encodeURIComponent(String(v))}`]
517  })
518  return { method: route.method, path: query.length ? `${path}?${query.join('&')}` : path, body: route.method === 'GET' || route.method === 'DELETE' ? null : rest }
519}
520