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…

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.
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.
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.
hooks/register.tsx 397 lines1// 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}
397hooks/client/types.ts 300 lines1// 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}
300hooks/client/game.ts 2787 lines1// 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 lines1// 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}
90hooks/client/local/index.ts 178 lines1// 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}
178hooks/client/scheduler.ts 281 lines1// 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}
281hooks/ui/band.tsx 562 lines1// 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}
562hooks/ui/ceremony.tsx 400 lines1// 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}
400hooks/ui/pane.tsx 160 lines1// 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
160hooks/ui/launcher.tsx 32 lines1// 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}
32hooks/ui/card-hit-state.ts 29 lines1// 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}
29hooks/core/api.ts 520 lines1// 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