Crash with fake tokens against other people who are also waiting for Claude: in a pane, in a terminal and in the desktop app.

Bet fake tokens in a Crash game with other people who are also waiting for Claude.
You send Claude a task. A pixel-art 1960s prop plane takes off. Everyone at your table bets before take-off and cashes out before the plane crashes. When Claude finishes, betting locks and you are back to work.
<img src="docs/pane.png" alt="The game pane in the desktop app: the plane at 3.19x, the Cash out button, the stakes, the table with two bots and the last crashes" width="380">

You need Claude Code 2.1.291 or newer. Mods are early access, so the API can change between releases and a newer build may need an update of this mod.
claude plugin marketplace add grozoww/idlecrash
claude plugin install idlecrash@grozoww-mods
Restart Claude Code and send it any task. The game opens by itself the first time Claude starts working. Type /idlecrash to open it yourself at any time.
To remove it: claude plugin uninstall idlecrash.
In a terminal the game is a pane. It opens by itself in a window at least 144 columns wide; in a narrower one, type /idlecrash. Click the pane, or press ctrl+x and then tab, to give it the keyboard. Then:
| Key | Does |
|---|---|
b | Bet your stake in the betting phase |
c | Cash out while the plane flies |
1 2 3 4 | Stake: 10, 50, 100, 500 |
x | Cycle the auto cash-out: off, 1.5x, 2x, 3x, 5x, 10x |
r | Bet again every round |
In the desktop app the game is a pane in the side panel: the picture, the table and the buttons. Click the buttons. The app sends typed keys to the chat, so a pane gets no keyboard there. The pane opens by itself when Claude starts working; type /idlecrash if it does not. While you hold the mouse button down the app pauses the whole side panel, and it catches up when you let go.
Auto cash-out is paid by the server at exactly that number, so you can leave the game alone. You start with 1000 tokens. If you go broke you get 200 back, at most once every 10 minutes. The payout is the stake times the multiplier, rounded down: 50 cashed out at 2.00x pays 100, a profit of 50.
Commands in Claude Code:
/idlecrash opens the game pane/idlecrash off and /idlecrash on: open the game by itself when Claude starts working, or not/idlecrash name <nickname> changes your nickname/idlecrash top shows the richest playersThe mod plays on a server run by the author, at https://178-105-28-170.sslip.io. It is a small machine and has no promise of uptime. When it is down the mod does nothing and the pane says it cannot reach it. The address is the serverUrl setting:
claude plugin install idlecrash@grozoww-mods --config serverUrl=https://your-server.example
or later in /config. You can run your own: see Run a server.
It never sees your prompts, your code, your files or anything else about your session. The mod only talks to the one address in serverUrl, and it runs no program on your machine. Nicknames show to everyone at your table and on the top list: keep them friendly.
The server is one Bun process with no database. Accounts live in a JSON file. It speaks HTTP and nothing else: no page, no WebSocket.
cd server
bun src/server.ts # the API on http://localhost:8787
| Env | Default | |
|---|---|---|
PORT | 8787 | |
HOST | 0.0.0.0 | Use 127.0.0.1 behind a reverse proxy |
DATA_DIR | ./data | Where accounts.json is kept |
BOT_PCT | 30 | Bots are at most this percent of a table |
TRUST_PROXY | unset | Set to 1 behind a proxy so limits use X-Forwarded-For |
ACCOUNTS_PER_IP_HOUR | 10 | New accounts per address per hour |
Put it behind HTTPS before sharing it. The mod sends a secret in a header, so plain http:// is only fine for localhost. deploy/ has a cloud-init file, a systemd unit, a Caddyfile and a one-command deploy for a small Ubuntu machine.
Point your mod at it with serverUrl (for a local one, http://localhost:8787).
To try it alone with a lively table, start six fake players for 50 seconds:
bun server/scripts/smoke.ts http://localhost:8787 6
To work on the server without Claude Code, bun server/scripts/fake-mod.ts makes an account, sits at a table and says "Claude is working" every 2 seconds.
x is 0.99 / x, so the house edge is 1%. It never sends the crash point before the crash.Client, which has its own frame clock, so the buttons are not redrawn while the plane moves. In the desktop app the picture is a vector image the mod redraws eight times a second; a Client cannot hold an image there, and nothing in an image can be pressed, so each button is a picture with the same picture lit over it on hover and a Client over both that catches the click.bun test ./server ./shared # engine, scene, end-to-end tests (they start a server on a spare port)
cd mod && claude plugin validate . && claude plugin test .
bun tools/preview-scene.ts # draws the pixel art to /tmp/idlecrash-scene.png
bun tools/preview-desk.ts # draws the desktop pane to /tmp/idlecrash-pane.html (the file says how to make the PNG)
bun tools/sync-shared.ts # after editing shared/: copies it into the mod
claude --plugin-dir "$PWD/mod" # load the mod from this checkout
shared/ holds the scene and helpers the mod uses. A plugin installed from a marketplace gets only its own folder, so mod/hooks/shared/ is a generated copy; bun test fails when it is stale.
MIT, see LICENSE.
hooks/register.tsx 817 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feed, Local, Snapshot, Status } from '../types'
5import { AUTOS, STAKES, cleanName, fmt, pad, padStart, parseSnapshot, sign, times } from './shared/lib'
6import { drawScene, sceneInputFor } from './shared/scene'
7import type { SceneProps } from './shared/scene'
8import { PROTOCOL, PROTOCOL_HEADER } from './shared/protocol'
9import { cleanBase, skewOf, skewText } from './util'
10import { CAPTION, PICTURE, controlsOf, deskView } from './desk'
11import type { ButtonSpec, DeskPress } from './desk'
12import { buttonSvg, captionSvg, overlayOf, stageSvg, statusSvg, tableSvg } from './desk-svg'
13import { picturePaths } from './pixels'
14
15const PANE = 'idlecrash'
16const DEFAULT_BASE = 'http://localhost:8787'
17const BEAT_MS = 2000
18const PAINT_MS = 125 // the desktop pane's picture is redrawn this often: `ui.invalidate` allows ten a second
19
20const feed = atom({ plugin: 'idlecrash', key: 'feed' } as const, { snap: null, offset: 0 } as Feed)
21const local = atom({ plugin: 'idlecrash', key: 'local' } as const, { stake: 50, auto: 2, isRebet: false } as Local)
22const status = atom(
23 { plugin: 'idlecrash', key: 'status' } as const,
24 { isWorking: false, isJoined: false, isWatching: false, error: null, note: null, balanceAtStart: null, summary: null } as Status,
25)
26
27const ERRORS: Record<string, string> = {
28 closed: 'Bets are closed this round',
29 'already-bet': 'You already have a bet',
30 'no-bet': 'No open bet',
31 'too-late': 'Too late, it crashed',
32 'bad-amount': 'That stake is not allowed',
33 'bad-auto': 'That auto cash-out is not allowed',
34 poor: 'Not enough tokens',
35 'not-seated': 'Not seated, rejoining',
36 'rate-limited': 'Slow down, server is busy',
37 locked: 'Claude is not working, betting is locked',
38 'mod-too-old': 'This mod is too old for the server: update it',
39}
40
41/** A refusal the player can read: the server's code is shown when we have no words for it. */
42const refusal = (code: unknown, fallback: string): string => ERRORS[String(code)] ?? `${fallback} (${String(code)})`
43
44/** `t0` and `t1` are our clock before and after the call, kept only where the offset is measured. */
45type Reply = { status: number; json: any; t0?: number; t1?: number }
46
47let base = DEFAULT_BASE
48let isBaseValid = true
49let nickOption = ''
50let isAutoOpenOption = true
51let timer: { cancel: () => void } | null = null
52let paintTimer: { cancel: () => void } | null = null // redraws the desktop pane for its moving picture
53let creds: { id: string; secret: string } | null = null
54let isBusy = false
55let isMuted = false // the person closed the pane during this turn
56let lastPoll = 0
57let lastJoinTry = 0
58let failures = 0
59let toldNarrow = false
60let skew: string | null = null // set while the server and this mod are of different ages: what the pane says about it
61let toldSkew = false
62let notedKey = ''
63let rebetRound = -1
64let isActing = false // a bet or cash-out is in flight
65let beatTimer: { cancel: () => void } | null = null // tells the server Claude is working
66let sessionId = 'default'
67let isWorking = false
68let startBalance: number | null = null
69let watchUntil = 0 // the pane stops watching a round that has not ended by then
70let lastBalance: number | null = null
71let lastSignature = ''
72const samples: { rtt: number; offset: number }[] = []
73
74/** The server's clock minus ours, from the call that came back fastest of the last few. */
75function bestOffset(rtt: number, offset: number): number {
76 samples.push({ rtt, offset })
77 if (samples.length > 8) samples.shift()
78 let best = samples[0]!
79 for (const sample of samples) if (sample.rtt < best.rtt) best = sample
80 return best.offset
81}
82
83/** What the player can see of a snapshot. Not the clock, so a quiet table makes no redraw. */
84const signature = (snap: Snapshot): string =>
85 JSON.stringify([snap.table, snap.players, snap.history, snap.you.name, snap.you.balance, snap.you.bet, snap.limits])
86
87// ---- talking to the server ------------------------------------------------
88
89async function api(
90 $: EngineInterface,
91 method: string,
92 path: string,
93 body?: unknown,
94 isTimed = false,
95): Promise<Reply> {
96 if (!isBaseValid) throw new Error('invalid serverUrl')
97 const headers: Record<string, string> = { 'content-type': 'application/json', [PROTOCOL_HEADER]: String(PROTOCOL) }
98 if (creds) headers.authorization = `Bearer ${creds.id}.${creds.secret}`
99 // A press must reach the server first: no clock reads in front of the request.
100 const t0 = isTimed ? await $.clock.now() : undefined
101 const res = await $.http.fetch(`${base}${path}`, {
102 method,
103 headers,
104 body: body === undefined ? undefined : JSON.stringify(body),
105 })
106 const t1 = isTimed ? await $.clock.now() : undefined
107 let json: any = null
108 try {
109 json = JSON.parse(res.text)
110 } catch {
111 json = null
112 }
113 const kind = skewOf(res.status, json)
114 skew = kind ? skewText(kind, base) : null
115 return { status: res.status, json, t0, t1 }
116}
117
118/** What the pane says when a call failed: that the server and the mod do not fit, or that we cannot reach it. */
119const trouble = (fallback: string): string => skew ?? fallback
120
121async function setNote($: EngineInterface, text: string): Promise<void> {
122 await update($, status, s => ({ ...s, note: text }))
123 $.clock.after(4000, () => {
124 void update($, status, s => (s.note === text ? { ...s, note: null } : s))
125 })
126}
127
128/** Stores the server's view, tells the player what just happened, and rebets if asked. */
129async function apply($: EngineInterface, reply: Reply): Promise<Snapshot | null> {
130 const snap = parseSnapshot(reply.json?.snapshot)
131 if (!snap) return null
132 const prev = await read($, feed)
133 let offset = prev.offset
134 if (reply.t0 !== undefined && reply.t1 !== undefined) {
135 offset = bestOffset(reply.t1 - reply.t0, snap.now - (reply.t0 + reply.t1) / 2)
136 }
137 // Write only what changed: every write redraws the pane, and a redraw can eat a click.
138 const sig = signature(snap)
139 if (sig !== lastSignature || Math.abs(offset - prev.offset) > 150) {
140 lastSignature = sig
141 await update($, feed, () => ({ snap, offset }))
142 }
143 const before = await read($, status)
144 if (before.balanceAtStart === null || before.error !== null) {
145 await update($, status, s => ({ ...s, balanceAtStart: s.balanceAtStart ?? snap.you.balance, error: null }))
146 }
147
148 const mine = snap.you.bet
149 const round = snap.table.round
150 if (mine && mine.cash !== null && notedKey !== `${round}:won`) {
151 notedKey = `${round}:won`
152 await setNote($, `Won ${fmt(mine.payout)} at ${times(mine.cash)} (profit ${sign(mine.payout - mine.amount)})`)
153 } else if (mine && mine.cash === null && snap.table.phase === 'crashed' && notedKey !== `${round}:lost`) {
154 notedKey = `${round}:lost`
155 await setNote($, `Crashed at ${times(snap.table.crashPoint ?? 1)}. Lost ${fmt(mine.amount)}`)
156 }
157
158 const wish = await read($, local)
159 const st = await read($, status)
160 const left = snap.table.bettingEndsAt - ((await $.clock.now()) + offset)
161 if (
162 wish.isRebet &&
163 st.isWorking &&
164 snap.table.phase === 'betting' &&
165 !mine &&
166 left > 700 &&
167 rebetRound !== round &&
168 prev.snap !== null
169 ) {
170 rebetRound = round
171 await placeBet($)
172 }
173 return snap
174}
175
176async function join($: EngineInterface): Promise<void> {
177 lastJoinTry = await $.clock.now()
178 if (!creds) {
179 const saved = (await $.store.get('creds')) as { id?: string; secret?: string } | undefined
180 if (saved?.id && saved?.secret) creds = { id: saved.id, secret: saved.secret }
181 }
182 const nick = cleanName((await $.store.get('nickname')) ?? nickOption)
183 try {
184 const reply = await api(
185 $,
186 'POST',
187 '/join',
188 { id: creds?.id, secret: creds?.secret, name: nick || undefined },
189 true,
190 )
191 if (!reply.json?.ok) throw new Error('refused')
192 if (reply.json.creds?.id && reply.json.creds?.secret) {
193 creds = { id: String(reply.json.creds.id), secret: String(reply.json.creds.secret) }
194 await $.store.set('creds', creds)
195 }
196 await apply($, reply)
197 failures = 0
198 await update($, status, s => ({ ...s, isJoined: true, error: null }))
199 } catch {
200 await update($, status, s => ({ ...s, isJoined: false, error: trouble(`Cannot reach ${base}`) }))
201 }
202}
203
204async function leave($: EngineInterface): Promise<{ balance: number; refunded: number; cashed: number } | null> {
205 if (!creds) return null
206 try {
207 const reply = await api($, 'POST', '/leave')
208 await update($, status, s => ({ ...s, isJoined: false }))
209 if (!reply.json?.ok) return null
210 return {
211 balance: Number(reply.json.balance) || 0,
212 refunded: Number(reply.json.refunded) || 0,
213 cashed: Number(reply.json.cashed) || 0,
214 }
215 } catch {
216 await update($, status, s => ({ ...s, isJoined: false }))
217 return null
218 }
219}
220
221// ---- what the buttons do --------------------------------------------------
222
223async function placeBet($: EngineInterface): Promise<void> {
224 if (isActing) return
225 isActing = true
226 try {
227 const wish = await read($, local)
228 const reply = await api($, 'POST', '/bet', { amount: wish.stake, auto: wish.auto })
229 if (reply.json?.ok) await apply($, reply)
230 else await setNote($, refusal(reply.json?.error, 'Bet refused'))
231 } catch {
232 await setNote($, trouble('Server did not answer'))
233 } finally {
234 isActing = false
235 }
236}
237
238async function cashOut($: EngineInterface): Promise<void> {
239 if (isActing) return
240 isActing = true
241 try {
242 // The request goes first. The note only says it is on its way.
243 const pending = api($, 'POST', '/cashout')
244 const hint = setNote($, 'Cashing out…')
245 const reply = await pending
246 await hint
247 if (reply.json?.ok) await apply($, reply)
248 else await setNote($, refusal(reply.json?.error, 'Cash out refused'))
249 } catch {
250 await setNote($, trouble('Server did not answer'))
251 } finally {
252 isActing = false
253 }
254}
255
256// ---- the turn ------------------------------------------------------------
257
258function stopTimers(): void {
259 timer?.cancel()
260 timer = null
261 paintTimer?.cancel()
262 paintTimer = null
263}
264
265/** The pane stops watching: back to the summary. */
266async function endWatching($: EngineInterface): Promise<void> {
267 stopTimers()
268 await update($, status, s => ({ ...s, isWatching: false, isJoined: false }))
269}
270
271async function tick($: EngineInterface): Promise<void> {
272 if (isBusy) return
273 isBusy = true
274 try {
275 const st = await read($, status)
276 if (!st.isWorking && !st.isWatching) return
277 const pane = (await $.ui.panes()).find(p => p.id === PANE)
278 if (!pane || !pane.isPlaced) {
279 if (st.isWatching) await endWatching($)
280 else if (st.isJoined) await leave($)
281 return
282 }
283 const now = await $.clock.now()
284 if (st.isWatching && (now > watchUntil || !st.isJoined)) return void (await endWatching($))
285 if (!st.isJoined) {
286 if (now - lastJoinTry > 3000) await join($)
287 return
288 }
289 const phase = (await read($, feed)).snap?.table.phase
290 if (now - lastPoll < (phase === 'running' ? 380 : 1100)) return
291 lastPoll = now
292 try {
293 const reply = await api($, 'GET', '/state', undefined, true)
294 if (reply.status === 409 || reply.status === 401) {
295 if (reply.status === 401) creds = null
296 // A watcher's round is over: the server took the seat away.
297 if (st.isWatching) await endWatching($)
298 else await update($, status, s => ({ ...s, isJoined: false }))
299 return
300 }
301 if (!(await apply($, reply))) throw new Error('bad reply')
302 failures = 0
303 } catch {
304 failures += 1
305 if (failures >= 3 || skew) await update($, status, s => ({ ...s, error: trouble(`Cannot reach ${base}`) }))
306 }
307 } finally {
308 isBusy = false
309 }
310}
311
312// ---- the account and "Claude is working" --------------------------------------------
313
314/** Finds the player's account, or makes one the first time. The secret stays in this plugin's store. */
315async function ensureAccount($: EngineInterface): Promise<boolean> {
316 if (!creds) {
317 const saved = (await $.store.get('creds')) as { id?: string; secret?: string } | undefined
318 if (saved?.id && saved?.secret) creds = { id: saved.id, secret: saved.secret }
319 }
320 const nick = cleanName((await $.store.get('nickname')) ?? nickOption)
321 const reply = await api($, 'POST', '/account', { id: creds?.id, secret: creds?.secret, name: nick || undefined })
322 if (!reply.json?.ok) return false
323 if (reply.json.creds?.id && reply.json.creds?.secret) {
324 creds = { id: String(reply.json.creds.id), secret: String(reply.json.creds.secret) }
325 await $.store.set('creds', creds)
326 }
327 return true
328}
329
330/** The terminal and the desktop app have a pane of ours. An editor or a phone has none, and the game does not play there. */
331async function hasPaneHere($: EngineInterface): Promise<boolean> {
332 const surfaces = await $.session.surfaces()
333 return surfaces.includes('terminal') || surfaces.includes('desktop')
334}
335
336/** Every 2 seconds while a turn runs. The server opens betting on this and locks it when it stops. */
337async function beat($: EngineInterface): Promise<void> {
338 if (!isWorking) return
339 try {
340 if (!creds && !(await ensureAccount($))) throw new Error('no account')
341 const reply = await api($, 'POST', '/presence', { working: true, session: sessionId })
342 if (reply.status === 401) {
343 creds = null // the server does not know us any more: make a new account on the next beat
344 await $.store.delete('creds')
345 throw new Error('unauthorized')
346 }
347 if (!reply.json?.ok) throw new Error('refused')
348 const balance = Number(reply.json.balance) || 0
349 startBalance ??= balance
350 lastBalance = balance
351 } catch {
352 // The server did not answer: the next beat tries again, and the pane says when it cannot reach it.
353 // A server and a mod that do not fit are said once a turn, in a toast: the pane may be closed, and then
354 // nothing else would tell the player why nothing works.
355 if (skew && !toldSkew) {
356 toldSkew = true
357 $.ui.toast(skew, { timeoutMs: 10_000 })
358 }
359 }
360}
361
362// ---- the turn ------------------------------------------------------------------------
363
364async function begin($: EngineInterface): Promise<void> {
365 if (!(await hasPaneHere($))) return // an editor or a phone: there is nothing to play on
366 isMuted = false
367 toldNarrow = false
368 toldSkew = false
369 failures = 0
370 notedKey = ''
371 rebetRound = -1
372 lastSignature = ''
373 samples.length = 0
374 startBalance = null
375 isWorking = true
376 sessionId = await $.session.id()
377 const surfaces = await $.session.surfaces()
378 await update($, status, () => ({
379 isWorking: true,
380 isJoined: false,
381 isWatching: false,
382 error: null,
383 note: null,
384 balanceAtStart: null,
385 summary: null,
386 }))
387 if (base.startsWith('http://') && !/^http:\/\/(localhost|127\.0\.0\.1)/.test(base)) {
388 $.ui.log('idlecrash: the server URL is plain http, so your play-account secret is not encrypted', { to: 'debug' })
389 }
390 beatTimer?.cancel()
391 beatTimer = $.clock.every(BEAT_MS, () => {
392 void beat($)
393 })
394 await beat($)
395 const wantsOpen = ((await $.store.get('autoOpen')) ?? isAutoOpenOption) !== false
396 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
397 if (wantsOpen || isOpen) {
398 const opened = await $.ui.open({ id: PANE, title: 'IdleCrash' })
399 if (!opened.isPlaced && !toldNarrow) {
400 toldNarrow = true
401 $.ui.toast(
402 surfaces.includes('terminal')
403 ? 'IdleCrash: the window is narrower than 144 columns: type /idlecrash to open the game'
404 : 'IdleCrash: type /idlecrash to open the game',
405 )
406 }
407 }
408 timer?.cancel()
409 timer = $.clock.every(200, () => {
410 void tick($)
411 })
412 // The desktop pane's picture is an Svg the plugin redraws; the terminal's is a Client that moves on its own.
413 paintTimer?.cancel()
414 paintTimer =
415 surfaces.includes('desktop')
416 ? $.clock.every(PAINT_MS, () => {
417 $.ui.invalidate('ui.render')
418 })
419 : null
420}
421
422async function finish($: EngineInterface): Promise<void> {
423 beatTimer?.cancel()
424 beatTimer = null
425 if (!isWorking) {
426 stopTimers()
427 return
428 }
429 isWorking = false
430 // Stopping the heartbeat locks new bets. An open bet stays in its round and can still be cashed out.
431 let text = 'Claude finished, betting is locked.'
432 try {
433 const reply = await api($, 'POST', '/presence', { working: false, session: sessionId })
434 if (reply.json?.ok) {
435 const balance = Number(reply.json.balance) || lastBalance || 0
436 text += ` Balance ${fmt(balance)}`
437 if (startBalance !== null) text += ` (${sign(balance - startBalance)} this turn)`
438 text += '.'
439 }
440 } catch {
441 // The server did not answer: it locks by itself when the beats stop.
442 }
443 // The pane stays and keeps showing this round to its end; the server holds the seat for it.
444 const isWatching = (await read($, status)).isJoined
445 if (isWatching) watchUntil = (await $.clock.now()) + 120_000
446 else stopTimers()
447 await update($, status, s => ({ ...s, isWorking: false, isWatching, isJoined: isWatching, summary: text, note: null }))
448 $.ui.toast(text, { timeoutMs: 6000 })
449}
450
451export const register: Register = (on, options) => {
452 const clean = cleanBase(options.serverUrl || DEFAULT_BASE)
453 isBaseValid = clean !== null
454 base = clean ?? DEFAULT_BASE
455 nickOption = String(options.nickname ?? '')
456 isAutoOpenOption = options.autoOpen !== false
457
458 // ---- hooks ----------------------------------------------------------------
459
460 on('session.start', async ($, e, next) => {
461 await $.command.register({
462 name: 'idlecrash',
463 description: 'IdleCrash: bet fake tokens while Claude works',
464 argumentHint: '[on|off|name <nick>|top]',
465 immediate: true,
466 })
467 await update($, status, () => ({
468 isWorking: false,
469 isJoined: false,
470 isWatching: false,
471 error: null,
472 note: null,
473 balanceAtStart: null,
474 summary: null,
475 }))
476 return next(e)
477 })
478
479 on('turn.start', async ($, e, next) => {
480 const result = await next(e)
481 $.clock.after(1, () => {
482 void begin($)
483 })
484 return result
485 })
486
487 on('turn.complete', async ($, e, next) => {
488 const result = await next(e)
489 if (e.agentId === undefined) {
490 $.clock.after(1, () => {
491 void finish($)
492 })
493 }
494 return result
495 })
496
497 on('ui.close', { id: PANE }, async ($, e, next) => {
498 if (e.origin.kind === 'person') {
499 isMuted = true
500 $.clock.after(1, () => {
501 void leave($)
502 })
503 }
504 return next(e)
505 })
506
507 on('command.run', { command: 'idlecrash' }, async ($, e) => {
508 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
509 if (!isBaseValid) return { text: 'IdleCrash: serverUrl must look like https://host or http://host:port. Fix it in /config.' }
510 switch (sub.toLowerCase()) {
511 case 'off':
512 await $.store.set('autoOpen', false)
513 return { text: 'IdleCrash will not open by itself. Type /idlecrash to open it.' }
514 case 'on':
515 await $.store.set('autoOpen', true)
516 return { text: 'IdleCrash will open by itself when Claude starts working.' }
517 case 'name': {
518 const nick = cleanName(rest.join(' '))
519 if (nick.length < 2) return { text: 'Usage: /idlecrash name <nickname> (2-16 letters or digits)' }
520 await $.store.set('nickname', nick)
521 try {
522 await ensureAccount($)
523 } catch {
524 return { text: `Nickname saved as ${nick}; the server is not reachable now, so it will apply later.` }
525 }
526 return { text: `Nickname set to ${nick}.` }
527 }
528 case 'top': {
529 try {
530 const res = await $.http.fetch(`${base}/top`)
531 const parsed = JSON.parse(res.text)
532 const top: { name: string; balance: number }[] = Array.isArray(parsed?.top) ? parsed.top : []
533 if (top.length === 0) return { text: 'Nobody on the board yet.' }
534 const lines = top.slice(0, 10).map((p, i) => `${i + 1}. ${cleanName(p.name)} ${fmt(Number(p.balance) || 0)}`)
535 return { text: ['IdleCrash top:', ...lines].join('\n') }
536 } catch {
537 return { text: `Cannot reach ${base}` }
538 }
539 }
540 case 'help':
541 return {
542 text: [
543 'IdleCrash: bet fake tokens while Claude works.',
544 '/idlecrash open the game (a pane, in a terminal or in the desktop app)',
545 '/idlecrash on|off open it by itself when Claude starts working, or not',
546 '/idlecrash name <nick> /idlecrash top',
547 'Terminal, keys in the pane (click it or ctrl+x tab first): b bet, c cash out, 1-4 stake, x auto cash-out, r rebet.',
548 'Desktop app: click the buttons.',
549 ].join('\n'),
550 }
551 default: {
552 if (!(await hasPaneHere($))) return { text: 'IdleCrash plays in a terminal or in the desktop app: this window has no pane for it.' }
553 isMuted = false
554 const opened = await $.ui.open({ id: PANE, title: 'IdleCrash', focus: true })
555 const st = await read($, status)
556 if (!opened.isPlaced) return { text: 'IdleCrash: the window is too narrow for the pane.' }
557 const keys = (await $.session.surfaces()).includes('terminal') ? ' b bet, c cash out, 1-4 stake, x auto, r rebet.' : ''
558 return {
559 text: st.isWorking
560 ? `IdleCrash is open.${keys || ' Click the buttons.'}`
561 : 'IdleCrash is open. You can play while Claude is working.',
562 }
563 }
564 }
565 })
566
567 // A click the desktop pane's click catchers post. It is input from code: only what the table allows now is done.
568 on('ui.message', async ($, e, next) => {
569 if (e.requestId !== PANE || !e.element.startsWith('btn-')) return next(e)
570 const press = (e.data ?? {}) as { a?: DeskPress['a']; v?: unknown }
571 const st = await read($, status)
572 const snap = (await read($, feed)).snap
573 const mine = snap?.you.bet ?? null
574 const canBet = st.isWorking && snap?.table.phase === 'betting' && !mine
575 const canCash = snap?.table.phase === 'running' && !!mine && mine.cash === null // never needs Claude to be working
576 if (press.a === 'bet') {
577 if (canBet) await placeBet($)
578 } else if (press.a === 'cash') {
579 if (canCash) await cashOut($)
580 } else if (!st.isWorking) {
581 // Nothing to set while Claude is not working: it cannot bet.
582 } else if (press.a === 'stake') {
583 const stake = press.v
584 if (typeof stake === 'number' && STAKES.includes(stake)) await update($, local, w => ({ ...w, stake }))
585 } else if (press.a === 'auto') {
586 await update($, local, w => ({ ...w, auto: AUTOS[(AUTOS.indexOf(w.auto) + 1) % AUTOS.length] ?? null }))
587 } else if (press.a === 'rebet') {
588 await update($, local, w => ({ ...w, isRebet: !w.isRebet }))
589 }
590 return next(e)
591 })
592
593 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
594 const { Box, Text, Button } = $.ui.resolve(e)
595 const f = await read($, feed)
596 const wish = await read($, local)
597 const st = await read($, status)
598 const width = Math.max(30, (e.props.bodyColumns ?? 40) - 1)
599 const snap = f.snap
600
601 const title = (
602 <Box>
603 <Text bold>IDLECRASH</Text>
604 <Text dimColor> fake tokens only</Text>
605 </Box>
606 )
607
608 if (e.surface === 'desktop') {
609 // The pictures are Svg in this tree and are redrawn many times a second, so nothing that is pressed lives
610 // here: a button in this tree would blink at every redraw. Each button is a Client, redrawn on change.
611 const { Client, Svg } = $.ui.resolve(e)
612 const view = deskView(snap, wish, st, (await $.clock.now()) + f.offset)
613 const controls = controlsOf(view.controls)
614 let stage: string
615 if (view.scene) {
616 const sceneProps: SceneProps = { ...view.scene, columns: PICTURE.width, rows: PICTURE.height / 2 }
617 const { input, multiplier, serverNow } = sceneInputFor(sceneProps, 0)
618 const overlay = overlayOf(view.scene, multiplier, serverNow)
619 const picture = picturePaths(drawScene(input, PICTURE.width, PICTURE.height), PICTURE.width, PICTURE.height)
620 stage = stageSvg({ head: view.head, picture, big: overlay.big, bigColor: overlay.color, sub: overlay.sub, watch: view.watch, message: null })
621 } else {
622 stage = stageSvg({ head: view.head, picture: null, big: '', bigColor: '', sub: '', watch: null, message: view.message })
623 }
624 // A button is a picture, the same picture lit over it while the pointer is on it (the app does that, no code of
625 // ours), and a Client over both that catches the click. Nothing in an Svg can be pressed.
626 const face = (b: ButtonSpec) => (
627 <Box key={`w-${b.id}`} width={b.share}>
628 <Svg source={buttonSvg(b, 'rest')} alt={b.label} />
629 {!b.isDisabled && (
630 <Box position="absolute" top={0} left={0} right={0} bottom={0} display="none" hover={{ display: 'flex' }}>
631 <Svg source={buttonSvg(b, 'hot')} alt={`${b.label}, lit`} />
632 </Box>
633 )}
634 {!b.isDisabled && (
635 <Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="hidden">
636 <Client key={`btn-${b.id}`} module="./hit-client.tsx" props={{ press: b.press }} width="100%" height={12} />
637 </Box>
638 )}
639 </Box>
640 )
641 return (
642 <Box flexDirection="column" rowGap={1}>
643 <Svg source={stage} alt={view.message ? `${view.message.title}. ${view.message.text}` : 'IdleCrash: the plane and the multiplier'} />
644 {controls && (
645 <Box flexDirection="column">
646 {'button' in controls.main ? face(controls.main.button) : <Svg source={statusSvg(controls.main.text, controls.main.color)} alt={controls.main.text} />}
647 <Box>
648 <Box width={CAPTION.share}>
649 <Svg source={captionSvg('stake')} alt="stake" />
650 </Box>
651 {controls.stakes.map(face)}
652 </Box>
653 <Box>{controls.options.map(face)}</Box>
654 <Text color={view.controls.isNoticeError ? 'error' : 'warning'}>{view.controls.notice || ' '}</Text>
655 </Box>
656 )}
657 {view.table && <Svg source={tableSvg(view.table)} alt="The table: players and the last crashes" />}
658 <Text dimColor>Fake tokens only. Nothing here is worth anything. Bets are open only while Claude is working.</Text>
659 </Box>
660 )
661 }
662
663 if (e.surface !== 'terminal') return <Text>IdleCrash plays in a terminal or in the desktop app.</Text>
664
665 if (!st.isWorking && !st.isWatching && st.summary) {
666 return (
667 <Box flexDirection="column">
668 {title}
669 <Text color="warning">{st.summary}</Text>
670 <Text dimColor>Betting opens again when Claude starts the next turn.</Text>
671 <Text dimColor>{snap && snap.history.length > 0 ? `last crashes: ${snap.history.slice(0, 8).map(point => point.toFixed(2)).join(' ')}` : ' '}</Text>
672 </Box>
673 )
674 }
675 if (!st.isWorking && !st.isWatching) {
676 return (
677 <Box flexDirection="column">
678 {title}
679 <Text>Waiting for Claude to start working.</Text>
680 <Text dimColor>You can only play while Claude is busy.</Text>
681 <Text dimColor>/idlecrash off stops it opening by itself</Text>
682 </Box>
683 )
684 }
685 if (!snap || !st.isJoined) {
686 return (
687 <Box flexDirection="column">
688 {title}
689 {st.error ? <Text color="error">{st.error}</Text> : <Text dimColor>Taking a seat…</Text>}
690 </Box>
691 )
692 }
693
694 // Nothing below changes between two redraws unless the table changed: the moving parts
695 // (plane, multiplier, countdown) live in the picture, which animates on its own.
696 const phase = snap.table.phase
697 const mine = snap.you.bet
698 const isLive = !!mine && mine.cash === null && phase !== 'crashed'
699 const delta = st.balanceAtStart === null ? 0 : snap.you.balance - st.balanceAtStart
700 const seated = snap.table.humans + snap.table.bots
701 const columns = Math.min(56, Math.max(28, width))
702 const rowsOfPicture = 14
703 const sceneProps: SceneProps = {
704 phase,
705 round: snap.table.round,
706 columns,
707 rows: rowsOfPicture,
708 growth: snap.growth,
709 runStartedAt: snap.table.runStartedAt,
710 crashedAt: snap.table.crashedAt,
711 crashPoint: snap.table.crashPoint,
712 bettingEndsAt: snap.table.bettingEndsAt,
713 serverNow: (await $.clock.now()) + f.offset,
714 stake: isLive && phase === 'running' ? mine!.amount : 0,
715 }
716
717 const { Client } = $.ui.resolve(e)
718 const picture = (
719 <Client key="scene" module="./scene-client.tsx" props={sceneProps} width={columns} height={rowsOfPicture + 1} />
720 )
721
722 const rows = snap.players.map(p => {
723 const name = pad(`${p.you ? '›' : ' '}${p.bot ? '⚙' : ' '}${p.name}`, 12)
724 let result = ''
725 let color: string | undefined
726 if (p.bet > 0) {
727 if (p.cash !== null) {
728 result = `${times(p.cash)} → ${fmt(p.payout)}`
729 color = 'success'
730 } else if (phase === 'crashed') {
731 result = 'lost'
732 color = 'error'
733 } else {
734 result = phase === 'betting' ? 'in' : '…'
735 }
736 }
737 return (
738 <Box>
739 <Text bold={p.you}>{name} {padStart(p.bet > 0 ? fmt(p.bet) : '-', 5)} </Text>
740 <Text color={color} dimColor={!color}>{result}</Text>
741 </Box>
742 )
743 })
744
745 const room = Math.max(3, Math.floor((width - 6) / 6))
746
747 // An open bet can be cashed out while the plane flies, whether Claude is still working or not.
748 let action
749 if (isLive && phase === 'running') {
750 action = <Button key="cash" label="c · CASH OUT" hotkey="c" variant="primary" onPress={() => cashOut($)} />
751 } else if (mine && mine.cash !== null) {
752 action = <Text color="success">cashed out {times(mine.cash)}: paid {fmt(mine.payout)}</Text>
753 } else if (!st.isWorking) {
754 action = <Text color="warning">{isLive ? 'Claude finished: betting is locked. Your bet is still in: cash out before the crash.' : 'Claude finished: betting is locked. Watching this round.'}</Text>
755 } else if (phase === 'betting' && !mine) {
756 action = <Button key="bet" label={`b · Bet ${fmt(wish.stake)}`} hotkey="b" variant="primary" onPress={() => placeBet($)} />
757 } else if (mine && phase === 'betting') {
758 action = <Text color="warning">bet placed: {fmt(mine.amount)}{mine.auto ? ` · auto ${times(mine.auto)}` : ''}</Text>
759 } else {
760 action = <Text dimColor>next round soon</Text>
761 }
762
763 return (
764 <Box flexDirection="column">
765 {title}
766 {picture}
767 <Text dimColor>table {snap.table.id} · {seated} seated{snap.table.bots > 0 ? ` (${snap.table.bots} bots ⚙)` : ''}</Text>
768 <Box>
769 <Text>balance <Text bold>{fmt(snap.you.balance)}</Text></Text>
770 <Text color={delta >= 0 ? 'success' : 'error'}> {sign(delta)} this turn</Text>
771 </Box>
772 <Box flexDirection="column">{rows}</Box>
773 <Box>
774 <Text dimColor>last </Text>
775 {snap.history.slice(0, room).map(point => (
776 <Text color={point >= 2 ? 'success' : 'error'}>{point.toFixed(2)} </Text>
777 ))}
778 </Box>
779 <Box marginTop={1}>{action}</Box>
780 <Box columnGap={2}>
781 <Text dimColor>{st.isWorking ? 'stake' : ' '}</Text>
782 {(st.isWorking ? STAKES : []).map((s, i) => (
783 <Button
784 key={`stake${i + 1}`}
785 plain
786 hotkey={String(i + 1)}
787 label={wish.stake === s ? `[${fmt(s)}]` : fmt(s)}
788 dimColor={wish.stake !== s}
789 onPress={() => update($, local, w => ({ ...w, stake: s }))}
790 />
791 ))}
792 </Box>
793 <Box columnGap={3}>
794 {st.isWorking && (<Button
795 key="auto"
796 plain
797 hotkey="x"
798 label={`auto-out ${wish.auto ? times(wish.auto) : 'off'}`}
799 onPress={() =>
800 update($, local, w => ({ ...w, auto: AUTOS[(AUTOS.indexOf(w.auto) + 1) % AUTOS.length] ?? null }))
801 }
802 />)}
803 {st.isWorking && (<Button
804 key="rebet"
805 plain
806 hotkey="r"
807 label={`rebet ${wish.isRebet ? 'on' : 'off'}`}
808 onPress={() => update($, local, w => ({ ...w, isRebet: !w.isRebet }))}
809 />)}
810 </Box>
811 <Text color={st.error ? 'error' : 'warning'}>{st.error ?? st.note ?? (st.isWorking ? ' ' : (st.summary ?? ' '))}</Text>
812 <Text dimColor>{st.isWorking ? 'Claude is working. Bets lock when it finishes.' : isLive && phase === 'running' ? 'Claude finished: betting is locked. Your bet is still in: cash out before the crash.' : ' '}</Text>
813 </Box>
814 )
815 })
816}
817hooks/shared/lib.ts 86 lines1// GENERATED from /shared by tools/sync-shared.ts. Edit the original, not this copy.
2import type { Phase, Row, Snapshot } from './types'
3
4export const STAKES = [10, 50, 100, 500]
5export const AUTOS: (number | null)[] = [null, 1.5, 2, 3, 5, 10]
6
7export const fmt = (n: number): string => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
8export const times = (m: number): string => `${m.toFixed(2)}x`
9export const sign = (n: number): string => (n >= 0 ? `+${fmt(n)}` : `-${fmt(-n)}`)
10
11/** What a stake pays at a multiplier: whole tokens, rounded down, as the server does. */
12export const payoutFor = (amount: number, multiplier: number): number =>
13 Math.floor((amount * Math.round(multiplier * 100)) / 100)
14
15export const seconds = (ms: number): number => Math.max(0, Math.ceil(ms / 1000))
16
17/** The multiplier the server's clock reads `elapsedMs` after the flight began. */
18export const multiplierAt = (elapsedMs: number, growth: number): number =>
19 Math.floor(100 * Math.exp((growth * Math.max(0, elapsedMs)) / 1000) + 1e-9) / 100
20
21/** Everything from the network is drawn in a terminal: keep letters, digits and a few marks. */
22export function cleanName(raw: unknown): string {
23 const text = typeof raw === 'string' ? raw : ''
24 return text
25 .replace(/[^\p{L}\p{N} _.\-]/gu, '')
26 .trim()
27 .slice(0, 16)
28}
29
30const num = (v: unknown, fallback = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
31const nul = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
32const PHASES: Phase[] = ['betting', 'running', 'crashed']
33
34/** Reads a server reply as data. Anything off-shape is dropped, never trusted. */
35export function parseSnapshot(raw: unknown): Snapshot | null {
36 const s = raw as Record<string, any> | null
37 if (!s || typeof s !== 'object' || !s.table || !s.you) return null
38 const phase: Phase = PHASES.includes(s.table.phase) ? s.table.phase : 'betting'
39 const players: Row[] = (Array.isArray(s.players) ? s.players : []).slice(0, 12).map((p: any) => ({
40 name: cleanName(p?.name) || '?',
41 bot: p?.bot === true,
42 you: p?.you === true,
43 bet: num(p?.bet),
44 cash: nul(p?.cash),
45 payout: num(p?.payout),
46 }))
47 const bet = s.you.bet
48 return {
49 now: num(s.now),
50 growth: num(s.growth, 0.12),
51 table: {
52 id: String(s.table.id ?? '').replace(/[^\w-]/g, '').slice(0, 8),
53 round: num(s.table.round),
54 phase,
55 phaseStartedAt: num(s.table.phaseStartedAt),
56 bettingEndsAt: num(s.table.bettingEndsAt),
57 runStartedAt: nul(s.table.runStartedAt),
58 crashedAt: nul(s.table.crashedAt),
59 crashPoint: nul(s.table.crashPoint),
60 humans: num(s.table.humans),
61 bots: num(s.table.bots),
62 },
63 players,
64 history: (Array.isArray(s.history) ? s.history : []).slice(0, 12).map((h: unknown) => num(h, 1)),
65 you: {
66 name: cleanName(s.you.name) || 'you',
67 balance: num(s.you.balance),
68 bet: bet && typeof bet === 'object'
69 ? { amount: num(bet.amount), auto: nul(bet.auto), cash: nul(bet.cash), payout: num(bet.payout) }
70 : null,
71 refillAt: nul(s.you.refillAt),
72 },
73 limits: { minBet: num(s.limits?.minBet, 10), maxBet: num(s.limits?.maxBet, 1000) },
74 }
75}
76
77/** What the player sees right now, from the last snapshot and the clock. */
78export function liveMultiplier(snap: Snapshot, serverNow: number): number | null {
79 if (snap.table.phase === 'crashed') return snap.table.crashPoint
80 if (snap.table.phase !== 'running' || snap.table.runStartedAt === null) return null
81 return multiplierAt(serverNow - snap.table.runStartedAt, snap.growth)
82}
83
84export const pad = (s: string, n: number): string => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
85export const padStart = (s: string, n: number): string => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
86hooks/shared/scene.ts 479 lines1// GENERATED from /shared by tools/sync-shared.ts. Edit the original, not this copy.
2// The pixel-art picture in the IdleCrash pane: a plane takes off from a runway,
3// climbs past houses, mountains and clouds, and falls when the round crashes.
4//
5// Pure functions. A frame depends only on its input, so the same input draws
6// the same picture. `drawScene` returns palette indices, one byte per pixel;
7// pixels.ts turns them into terminal cells or an SVG.
8
9import { multiplierAt } from './lib'
10
11export type ScenePhase = 'betting' | 'running' | 'crashed'
12
13export type SceneInput = {
14 phase: ScenePhase
15 /** The live multiplier while flying, the crash point once crashed. */
16 multiplier: number
17 /** Milliseconds since the flight began (frozen at the crash). */
18 runMs: number
19 /** Milliseconds since the crash; 0 before it. */
20 sinceCrashMs: number
21 /** An animation clock: propeller, drifting clouds, smoke. */
22 tMs: number
23 /** The round, so every round has other scenery. */
24 seed: number
25}
26
27/** What the scene needs to know about the round, as plain data. */
28export type SceneProps = {
29 phase: ScenePhase
30 round: number
31 /** Width in terminal columns, which is also the picture's width in pixels. */
32 columns: number
33 /** Terminal rows of picture: the scene is twice as many pixels tall. */
34 rows: number
35 growth: number
36 runStartedAt: number | null
37 crashedAt: number | null
38 crashPoint: number | null
39 bettingEndsAt: number
40 /** Our estimate of the server's clock at the moment these props were made, in ms. */
41 serverNow: number
42 /** What the player has riding on this round, 0 for nothing. */
43 stake: number
44}
45
46/**
47 * The scene's input `elapsedMs` after the props were made. Time comes from the
48 * props plus what has passed since, never from a wall clock the two sides might
49 * not share.
50 */
51export function sceneInputFor(
52 props: SceneProps,
53 elapsedMs: number,
54): { input: SceneInput; multiplier: number; serverNow: number } {
55 const serverNow = props.serverNow + Math.max(0, elapsedMs)
56 const started = props.runStartedAt ?? serverNow
57 const runMs = props.phase === 'crashed' ? (props.crashedAt ?? serverNow) - started : serverNow - started
58 const multiplier =
59 props.phase === 'crashed' ? (props.crashPoint ?? 1) : props.phase === 'running' ? multiplierAt(runMs, props.growth) : 1
60 return {
61 serverNow,
62 multiplier,
63 input: {
64 phase: props.phase,
65 multiplier,
66 runMs: Math.max(0, runMs),
67 sinceCrashMs: props.phase === 'crashed' ? Math.max(0, serverNow - (props.crashedAt ?? serverNow)) : 0,
68 tMs: serverNow % 10_000_000,
69 seed: props.round,
70 },
71 }
72}
73
74// ---- palette -----------------------------------------------------------------
75
76const DEFS = [
77 ['clear', '#000000'],
78 ['sky0', '#9bdcff'], ['sky1', '#72c4f7'], ['sky2', '#4aa6e6'], ['sky3', '#3182cf'],
79 ['sky4', '#2255a4'], ['sky5', '#17336f'], ['sky6', '#0c1a42'],
80 ['star', '#dfe8ff'], ['sun', '#fff0a0'],
81 ['cloud', '#ffffff'], ['cloudShade', '#c9d8ee'],
82 ['far', '#7a8fb8'], ['farShade', '#5f739c'], ['near', '#52745f'], ['nearShade', '#3d5a4a'],
83 ['snow', '#f4f8ff'], ['snowShade', '#c4d2ea'],
84 ['grass', '#58b84c'], ['grassDark', '#3d8f3a'], ['dirt', '#7a5230'],
85 ['runway', '#4b4b55'], ['runwayLine', '#eeeeee'],
86 ['wallCream', '#ecd9a9'], ['wallWhite', '#f1f1ea'], ['wallPeach', '#efc29b'], ['wallShade', '#bfa877'],
87 ['roofRed', '#c8402f'], ['roofBlue', '#3f6db3'], ['roofBrown', '#8a5a3b'], ['roofDark', '#7d2a20'],
88 ['window', '#5ec8ff'], ['door', '#6b3e1f'],
89 ['leaf', '#2f8a38'], ['leafDark', '#226a2a'], ['trunk', '#6b3e1f'],
90 ['body', '#f3ead2'], ['bodyShade', '#c9bd9d'], ['accent', '#c8402f'], ['glass', '#5ec8ff'],
91 ['wing', '#e4dcc4'], ['wingShade', '#a99f84'], ['strut', '#5b6070'],
92 ['cowl', '#8e97a8'], ['cowlHi', '#d3dae6'], ['nose', '#ffd54a'],
93 ['prop', '#2f333c'], ['propBlur', '#8a93a3'], ['wheel', '#23232a'], ['hub', '#aeb6c4'],
94 ['fireY', '#ffe14d'], ['fireO', '#ff9a1f'], ['fireR', '#e8401c'],
95 ['smoke0', '#3a3a42'], ['smoke1', '#62626d'], ['smoke2', '#92929e'], ['smoke3', '#b9b9c4'],
96] as const
97
98export type ColorName = (typeof DEFS)[number][0]
99
100const INDEX = {} as Record<ColorName, number>
101DEFS.forEach(([name], i) => {
102 INDEX[name] = i
103})
104
105export const PALETTE: string[] = DEFS.map(([, hex]) => hex)
106export const colorIndex = (name: ColorName): number => INDEX[name]
107
108const SKY: ColorName[] = ['sky0', 'sky1', 'sky2', 'sky3', 'sky4', 'sky5', 'sky6']
109
110// ---- small helpers ------------------------------------------------------------
111
112const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
113
114/** A triangle wave in 0..1, peaking at 0.5 of each period. */
115const tri = (p: number): number => 1 - Math.abs(2 * (p - Math.floor(p)) - 1)
116
117/** A repeatable pseudo-random number in 0..1 from two integers. */
118function hash(a: number, b: number): number {
119 let x = Math.imul((a | 0) ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul((b | 0) + 0x7f4a7c15, 0xc2b2ae35)
120 x ^= x >>> 15
121 x = Math.imul(x, 0x2c1b3c6d)
122 x ^= x >>> 12
123 return (x >>> 0) / 4294967296
124}
125
126class Canvas {
127 readonly px: Uint8Array
128
129 constructor(
130 readonly width: number,
131 readonly height: number,
132 ) {
133 this.px = new Uint8Array(width * height)
134 }
135
136 set(x: number, y: number, name: ColorName): void {
137 if (x >= 0 && x < this.width && y >= 0 && y < this.height) this.px[y * this.width + x] = INDEX[name]
138 }
139
140 rect(x: number, y: number, w: number, h: number, name: ColorName): void {
141 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, name)
142 }
143
144 disc(cx: number, cy: number, r: number, name: ColorName): void {
145 for (let j = -r; j <= r; j++) {
146 for (let i = -r; i <= r; i++) if (i * i + j * j <= r * r + r * 0.5) this.set(cx + i, cy + j, name)
147 }
148 }
149}
150
151// ---- sprites -----------------------------------------------------------------
152
153type Sprite = { rows: string[]; colors: Record<string, ColorName> }
154
155function stamp(c: Canvas, sprite: Sprite, x: number, y: number): void {
156 sprite.rows.forEach((row, j) => {
157 for (let i = 0; i < row.length; i++) {
158 const name = sprite.colors[row[i]!]
159 if (name) c.set(x + i, y + j, name)
160 }
161 })
162}
163
164const ROOFS: ColorName[] = ['roofRed', 'roofBlue', 'roofBrown']
165const WALLS: ColorName[] = ['wallCream', 'wallWhite', 'wallPeach']
166
167const houseSprite = (roof: ColorName, wall: ColorName): Sprite => ({
168 rows: ['..RRR..', '.RRRRr.', 'RRRRRrr', '.WWWWw.', '.WoWow.', '.WWWDw.'],
169 colors: { R: roof, r: 'roofDark', W: wall, w: 'wallShade', o: 'window', D: 'door' },
170})
171
172const TREE: Sprite = {
173 rows: ['.L.', 'LLL', 'LLl', '.T.', '.T.'],
174 colors: { L: 'leaf', l: 'leafDark', T: 'trunk' },
175}
176const BUSH: Sprite = { rows: ['.L.', 'LLl'], colors: { L: 'leaf', l: 'leafDark' } }
177
178// A 1960s single-engine prop plane facing right: high wing on struts, a round
179// cowling, a two-blade propeller and fixed wheels.
180const PLANE: string[] = [
181 '.RR...............',
182 '.RRR..............',
183 '.RRRR..WWWWWWWWWW.',
184 '.RRRBBBSBBBBBSBCDD',
185 'RBBBBBGGGGBBBGGCDD',
186 'BBBBBBGGGGBBBGGCDN',
187 'bTTTTTTTTTTTTTTCDN',
188 '.bbbbbbbbbbbbbbbDD',
189 '.....wwwwwwwww....',
190 '.......K.....K.K..',
191 '.......KH....KH.KH',
192]
193const PLANE_COLORS: Record<string, ColorName> = {
194 R: 'accent', B: 'body', b: 'bodyShade', T: 'accent', G: 'glass', W: 'wing', w: 'wingShade',
195 S: 'strut', C: 'cowlHi', D: 'cowl', N: 'nose', K: 'wheel', H: 'hub',
196}
197const PLANE_W = 18
198const PLANE_H = PLANE.length
199/** The row of the nose, where the propeller turns. */
200const NOSE_ROW = 5
201
202// ---- the world ---------------------------------------------------------------
203
204const GROUND = 4 // rows of runway, grass and dirt at the bottom while on the ground
205const CAM_LAG = 10 // how far above the ground the plane stays on screen once climbing
206const RUNWAY_FROM = -40
207const RUNWAY_TO = 22
208const HOUSES_FROM = 26
209const BOOM_MS = 700
210
211/**
212 * How high the plane flies at a multiplier, in pixels. Half of all rounds crash
213 * before 2x, so the houses, mountains and clouds all have to pass by early:
214 * about 1.3x is above the houses, 2x above the mountains, 3x in the clouds,
215 * and 12x at the top of the sky.
216 */
217export function planeAltitude(multiplier: number): number {
218 const r = Math.log(Math.max(1, multiplier)) / Math.log(12)
219 return 85 * Math.min(1.15, Math.pow(r, 0.8))
220}
221
222/** Pixels the world has scrolled by since take-off. */
223const distanceAt = (runMs: number): number => {
224 const t = Math.min(60, Math.max(0, runMs) / 1000)
225 return 14 * t + 1.2 * t * t
226}
227
228/** How long the fall lasts from a given altitude. A crash on the runway has none. */
229export const fallMsFor = (alt: number): number => (alt < 0.5 ? 0 : Math.min(1800, 350 + alt * 22))
230
231type Mode = 'parked' | 'flying' | 'falling' | 'boom' | 'wreck'
232
233type Frame = {
234 mode: Mode
235 alt: number
236 cam: number
237 dist: number
238 tilt: number // + nose up, - nose down
239 fallMs: number
240 alt0: number
241}
242
243export function modeOf(input: SceneInput): Mode {
244 if (input.phase === 'betting') return 'parked'
245 if (input.phase === 'running') return 'flying'
246 const fall = fallMsFor(planeAltitude(input.multiplier))
247 if (input.sinceCrashMs < fall) return 'falling'
248 return input.sinceCrashMs < fall + BOOM_MS ? 'boom' : 'wreck'
249}
250
251function frameOf(input: SceneInput): Frame {
252 const mode = modeOf(input)
253 const alt0 = input.phase === 'betting' ? 0 : planeAltitude(input.multiplier)
254 const dist = input.phase === 'betting' ? 0 : distanceAt(input.runMs)
255 const fallMs = fallMsFor(alt0)
256 if (mode === 'parked') return { mode, alt: 0, cam: 0, dist, tilt: 0, fallMs, alt0 }
257 if (mode === 'flying') {
258 return { mode, alt: alt0, cam: Math.max(0, alt0 - CAM_LAG), dist, tilt: 0.25 * clamp(alt0 / 6, 0, 1), fallMs, alt0 }
259 }
260 if (mode === 'falling') {
261 const u = input.sinceCrashMs / fallMs
262 const alt = alt0 * (1 - u * u)
263 return { mode, alt, cam: Math.max(0, alt - CAM_LAG), dist, tilt: -0.55, fallMs, alt0 }
264 }
265 return { mode, alt: 0, cam: 0, dist, tilt: 0, fallMs, alt0 }
266}
267
268const planeX = (width: number): number => Math.floor(width * 0.28)
269const groundRow = (c: Canvas, f: Frame): number => c.height - GROUND + Math.round(f.cam)
270
271function drawSky(c: Canvas, f: Frame, seed: number): void {
272 for (let y = 0; y < c.height; y++) {
273 const alt = f.cam + (c.height - GROUND - 1 - y)
274 const band = clamp(Math.floor(alt / 16), 0, 6)
275 const edge = alt - band * 16
276 for (let x = 0; x < c.width; x++) {
277 const shade = edge >= 13 && band < 6 && (x + y) % 2 === 0 ? band + 1 : band
278 let name: ColorName = SKY[shade]!
279 if (shade >= 5 && hash(x + Math.floor(f.dist * 0.03), Math.floor(alt) + seed * 31) > 0.985) name = 'star'
280 c.px[y * c.width + x] = INDEX[name]
281 }
282 }
283 if (f.cam < 45) {
284 const cy = Math.round(c.height - GROUND - 1 - (21 - f.cam * 0.2))
285 c.disc(Math.floor(c.width * 0.8), cy, 3, 'sun')
286 }
287}
288
289function drawMountains(c: Canvas, f: Frame, seed: number, isFar: boolean): void {
290 const scrollX = isFar ? 0.2 : 0.45
291 const scrollY = isFar ? 0.45 : 0.7
292 const base = isFar ? 3 : 1
293 const amp = isFar ? 15 : 9
294 const period = isFar ? 44 : 28
295 const snowAt = isFar ? 13 : 8
296 const shift = hash(seed, isFar ? 11 : 12) * 10
297 const top = (wx: number): number =>
298 base +
299 amp * (0.6 * tri(wx / period + shift) + 0.28 * tri(wx / (period * 0.43) + shift * 2.1) + 0.12 * tri(wx / (period * 0.19) + 0.7))
300 for (let x = 0; x < c.width; x++) {
301 const wx = Math.floor(x + f.dist * scrollX)
302 const t = top(wx)
303 const topRow = Math.round(c.height - GROUND - 1 - (t - f.cam * scrollY))
304 const isShaded = top(wx + 1) < t
305 for (let y = Math.max(0, topRow); y < c.height; y++) {
306 const depth = y - topRow
307 let name: ColorName
308 if (t > snowAt && depth < Math.min(3, (t - snowAt) / 2 + 1)) name = isShaded ? 'snowShade' : 'snow'
309 else if (isFar) name = isShaded ? 'farShade' : 'far'
310 else name = isShaded ? 'nearShade' : 'near'
311 c.px[y * c.width + x] = INDEX[name]
312 }
313 }
314}
315
316function drawCloud(c: Canvas, x: number, bottom: number, width: number): void {
317 const widths = [Math.round(width * 0.45), Math.round(width * 0.75), width, width - 2]
318 widths.forEach((w, r) => {
319 const left = x + Math.floor((width - w) / 2) + (r === 0 ? -Math.floor(width * 0.12) : 0)
320 c.rect(left, bottom - 3 + r, w, 1, r === 3 ? 'cloudShade' : 'cloud')
321 })
322}
323
324function drawClouds(c: Canvas, f: Frame, seed: number, tMs: number, isLow: boolean): void {
325 const scrollX = isLow ? 0.5 : 0.8
326 const scrollY = isLow ? 0.4 : 1
327 const spacing = isLow ? 26 : 30
328 const drift = (tMs / 1000) * 1.5
329 const at = f.dist * scrollX + drift
330 const first = Math.floor((at - 20) / spacing) - 1
331 const last = Math.floor((at + c.width) / spacing) + 1
332 for (let k = first; k <= last; k++) {
333 if (hash(seed + (isLow ? 7 : 3), k) < (isLow ? 0.45 : 0.3)) continue
334 const wx = k * spacing + hash(k, seed) * spacing * 0.6
335 const wy = isLow ? 12 + hash(seed, k + 5) * 6 : 34 + hash(k, seed + 9) * 50
336 const width = 8 + Math.floor(hash(seed + k, 13) * 9)
337 const x = Math.round(wx - at)
338 const bottom = Math.round(c.height - GROUND - 1 - (wy - f.cam * scrollY))
339 drawCloud(c, x, bottom, width)
340 }
341}
342
343function drawGround(c: Canvas, f: Frame, seed: number): void {
344 const top = groundRow(c, f)
345 if (top >= c.height) return
346 const toScreen = (wx: number): number => Math.round(wx - f.dist + planeX(c.width))
347 for (let y = Math.max(0, top); y < c.height; y++) {
348 const name: ColorName = y === top ? 'grass' : y === top + 1 ? 'grassDark' : 'dirt'
349 c.rect(0, y, c.width, 1, name)
350 }
351 const from = toScreen(RUNWAY_FROM)
352 const to = toScreen(RUNWAY_TO)
353 for (let x = Math.max(0, from); x < Math.min(c.width, to); x++) {
354 const wx = x + f.dist - planeX(c.width)
355 c.set(x, top, 'runway')
356 c.set(x, top + 1, (((Math.round(wx) % 8) + 8) % 8) < 4 ? 'runwayLine' : 'runway')
357 }
358 const slot = 13
359 const first = Math.floor((f.dist - planeX(c.width) - 8) / slot)
360 const last = Math.floor((f.dist + c.width) / slot) + 1
361 for (let k = first; k <= last; k++) {
362 const wx = k * slot + 3 + Math.floor(hash(seed, k + 100) * 5)
363 if (wx < HOUSES_FROM) continue
364 const kind = hash(seed + 5, k)
365 const x = toScreen(wx)
366 if (kind < 0.5) {
367 const roof = ROOFS[Math.floor(hash(k, seed + 1) * ROOFS.length)]!
368 const wall = WALLS[Math.floor(hash(k, seed + 2) * WALLS.length)]!
369 stamp(c, houseSprite(roof, wall), x, top - 6)
370 } else if (kind < 0.8) stamp(c, TREE, x, top - 5)
371 else if (kind < 0.92) stamp(c, BUSH, x, top - 2)
372 }
373}
374
375function drawPlane(c: Canvas, f: Frame, input: SceneInput): void {
376 const x0 = planeX(c.width)
377 const onGround = f.alt < 1.5
378 const bob = f.mode === 'flying' && !onGround ? Math.round(Math.sin(input.tMs / 230)) : 0
379 const bottom = c.height - GROUND - 1 - Math.round(f.alt - f.cam) + bob
380 const lift = (col: number): number => Math.round(col * f.tilt)
381 const burning = f.mode === 'falling'
382 const rowY = (r: number, col: number): number => bottom - (PLANE_H - 1 - r) - lift(col)
383 for (let r = 0; r < PLANE_H; r++) {
384 for (let col = 0; col < PLANE_W; col++) {
385 const ch = PLANE[r]![col]!
386 if (ch === '.') continue
387 let name = PLANE_COLORS[ch]!
388 if (burning && hash(col + r * 13, Math.floor(input.tMs / 80)) > 0.78) name = hash(col, r + Math.floor(input.tMs / 60)) > 0.5 ? 'fireO' : 'fireY'
389 c.set(x0 + col, rowY(r, col), name)
390 }
391 }
392 // The propeller turns: blades up and down, a blur, blades edge-on, a blur.
393 if (f.mode !== 'wreck' && f.mode !== 'boom') {
394 const px = x0 + PLANE_W
395 const cy = rowY(NOSE_ROW, PLANE_W - 1)
396 const step = Math.floor(input.tMs / 55) % 4
397 if (step === 0) {
398 for (let dy = -4; dy <= 4; dy++) c.set(px, cy + dy, 'prop')
399 } else if (step === 2) {
400 c.set(px, cy - 1, 'prop')
401 c.set(px, cy, 'prop')
402 c.set(px, cy + 1, 'prop')
403 } else {
404 for (let dy = -4; dy <= 4; dy++) if (dy !== 0 || step === 1) c.set(px, cy + dy, 'propBlur')
405 c.set(px - 1, cy - 2, 'propBlur')
406 c.set(px - 1, cy + 2, 'propBlur')
407 }
408 }
409 // A thin white trail behind a plane that is climbing.
410 if (f.mode === 'flying' && !onGround) {
411 for (let i = 2; i < 10; i += 2) c.set(x0 - i, bottom - 5 + Math.round(i * f.tilt * 0.6), 'cloud')
412 }
413 // Fire and smoke behind a plane that is falling.
414 if (f.mode === 'falling') {
415 const u = input.sinceCrashMs / f.fallMs
416 for (let i = 1; i <= 11; i++) {
417 const earlier = Math.max(0, u - i * 0.03)
418 const alt = f.alt0 * (1 - earlier * earlier)
419 const y = c.height - GROUND - 1 - Math.round(alt - f.cam) - 4
420 const jitter = Math.floor(hash(i, Math.floor(input.tMs / 90)) * 3) - 1
421 const name: ColorName = i < 3 ? 'fireY' : i < 5 ? 'fireO' : i < 7 ? 'fireR' : i < 9 ? 'smoke1' : 'smoke0'
422 c.set(x0 - 1 - i * 2, y + jitter, name)
423 if (i > 2) c.set(x0 - 2 - i * 2, y + jitter + 1, i < 8 ? 'smoke2' : 'smoke1')
424 }
425 }
426}
427
428function drawBoom(c: Canvas, f: Frame, input: SceneInput): void {
429 const cx = planeX(c.width) + 8
430 const cy = c.height - GROUND - 2
431 const since = input.sinceCrashMs - f.fallMs
432 if (f.mode === 'boom') {
433 const grow = Math.min(7, 1 + Math.floor(since / 70))
434 const fade = since > BOOM_MS - 200 ? Math.floor((since - (BOOM_MS - 200)) / 70) : 0
435 const r = Math.max(2, grow - fade)
436 c.disc(cx, cy, r, 'fireR')
437 c.disc(cx, cy, Math.max(1, r - 2), 'fireO')
438 c.disc(cx, cy, Math.max(1, r - 4), 'fireY')
439 for (let i = 0; i < 10; i++) {
440 const angle = hash(i, Math.floor(since / 60)) * Math.PI * 2
441 const reach = r + 1 + Math.floor(hash(i + 20, Math.floor(since / 60)) * 3)
442 c.set(cx + Math.round(Math.cos(angle) * reach), cy + Math.round(Math.sin(angle) * reach), i % 2 === 0 ? 'fireO' : 'smoke1')
443 }
444 return
445 }
446 // The wreck: a dark heap, a flicker of fire, a column of smoke.
447 c.rect(cx - 4, cy, 9, 2, 'smoke0')
448 c.rect(cx - 2, cy - 1, 5, 1, 'smoke0')
449 c.set(cx - 2, cy, 'wheel')
450 c.set(cx + 3, cy - 1, 'accent')
451 for (let i = 0; i < 4; i++) {
452 const on = hash(i, Math.floor(input.tMs / 120)) > 0.35
453 if (on) c.set(cx - 2 + i * 2, cy - 2, i % 2 === 0 ? 'fireO' : 'fireY')
454 }
455 for (let k = 0; k < 9; k++) {
456 const age = (input.tMs / 130 + k) % 9
457 const y = Math.round(cy - 3 - age * 1.9)
458 const x = cx + Math.round(Math.sin(age * 0.9 + k) * (0.6 + age * 0.35))
459 const name: ColorName = age < 2.5 ? 'smoke0' : age < 5 ? 'smoke1' : age < 7.5 ? 'smoke2' : 'smoke3'
460 c.set(x, y, name)
461 c.set(x + 1, y, name)
462 }
463}
464
465/** One frame of the scene: `width` x `height` palette indices, row by row. */
466export function drawScene(input: SceneInput, width: number, height: number): Uint8Array {
467 const c = new Canvas(width, height)
468 const f = frameOf(input)
469 drawSky(c, f, input.seed)
470 drawMountains(c, f, input.seed, true)
471 drawMountains(c, f, input.seed, false)
472 drawClouds(c, f, input.seed, input.tMs, true)
473 drawClouds(c, f, input.seed, input.tMs, false)
474 drawGround(c, f, input.seed)
475 if (f.mode === 'boom' || f.mode === 'wreck') drawBoom(c, f, input)
476 else drawPlane(c, f, input)
477 return c.px
478}
479hooks/shared/protocol.ts 21 lines1// GENERATED from /shared by tools/sync-shared.ts. Edit the original, not this copy.
2// What the mod and the server agree on, so that a server and a mod of different ages say so instead of failing
3// in odd ways. The mod puts PROTOCOL in the header `x-idlecrash-protocol` of every call; the server puts its own in
4// every reply (`protocol`). A mod from before this had no header: it counts as 0, and so does a server with no `protocol`.
5//
6// Change PROTOCOL only when a mod and a server of different ages can no longer play together:
7// - the server drops something old mods use: raise PROTOCOL, and raise MIN_MOD_PROTOCOL to it;
8// - the mod needs something only a new server has: raise PROTOCOL, and raise MIN_SERVER_PROTOCOL to it.
9// Anything else (a new look, a new rule that old mods can live with) leaves all three alone.
10// shared/versions.test.ts checks that the numbers are in order.
11
12export const PROTOCOL = 1
13
14/** The oldest mod the server still serves. An older one is told to update (426, `mod-too-old`). */
15export const MIN_MOD_PROTOCOL = 0
16
17/** The oldest server the mod still works with. Against an older one the pane says the server is behind. */
18export const MIN_SERVER_PROTOCOL = 0
19
20export const PROTOCOL_HEADER = 'x-idlecrash-protocol'
21hooks/util.ts 31 lines1import { MIN_SERVER_PROTOCOL } from './shared/protocol'
2
3/** The address of the game server, or null when it is not an http(s) address. */
4export function cleanBase(raw: unknown): string | null {
5 const text = typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
6 return /^https?:\/\/[A-Za-z0-9.-]+(:\d{1,5})?$/.test(text) ? text : null
7}
8
9/** How the mod and the server differ in age: the server will not serve this mod, or the mod needs a newer server. */
10export type Skew = 'mod-too-old' | 'server-too-old'
11
12/**
13 * Whether a reply says the mod and the server do not fit. Only the game server's own JSON counts: an error page from
14 * the proxy while the server restarts says nothing about versions.
15 */
16export function skewOf(status: number, json: unknown, minServer: number = MIN_SERVER_PROTOCOL): Skew | null {
17 const reply = json && typeof json === 'object' ? (json as { ok?: unknown; error?: unknown; protocol?: unknown }) : null
18 if (!reply || typeof reply.ok !== 'boolean') return null
19 if (status === 426 || reply.error === 'mod-too-old') return 'mod-too-old'
20 // A server from before the protocol says nothing: that is 0.
21 if (Number(reply.protocol ?? 0) < minServer) return 'server-too-old'
22 return null
23}
24
25/** What the pane says about it. */
26export function skewText(skew: Skew, base: string): string {
27 return skew === 'mod-too-old'
28 ? 'This mod is too old for the server. Update it with: claude plugin update idlecrash@grozoww-mods, then restart Claude Code.'
29 : `The server at ${base} is older than this mod needs. Try again later.`
30}
31hooks/desk.ts 210 lines1// The desktop pane, as data. Its pictures (the stage and the table, see desk-svg.ts) are drawn by the plugin,
2// redrawn many times a second; the Client is the buttons, and gets `DeskControls`. Those change only when the
3// table or the player's picks do, so the buttons are not redrawn under the pointer.
4// The Client posts the player's presses back (see `DeskPress`); it never reaches the plugin's `$`.
5import type { Local, Phase, Snapshot, Status } from '../types'
6import type { Head, Table } from './desk-svg'
7import { STAKES, fmt, payoutFor, seconds, times } from './shared/lib'
8import type { SceneProps } from './shared/scene'
9
10/** The picture is drawn at this size whatever the pane's width: the app scales it. The terminal draws it this size too. */
11export const PICTURE = { width: 56, height: 28 } as const
12
13/** The picture's inputs. */
14export type DeskScene = Omit<SceneProps, 'columns' | 'rows'>
15
16export type DeskControls = {
17 /** The table is on: show the buttons. */
18 isLive: boolean
19 isWorking: boolean
20 phase: Phase
21 mine: Snapshot['you']['bet']
22 stake: number
23 auto: number | null
24 isRebet: boolean
25 notice: string
26 isNoticeError: boolean
27}
28
29/** The one line under the terminal's picture: the countdown, the multiplier with what a cash-out pays, or the crash. */
30export function pictureLine(scene: DeskScene, multiplier: number, serverNow: number): { text: string; color: 'warning' | 'success' | 'error' } {
31 if (scene.phase === 'betting') return { text: `BETS OPEN ${seconds(scene.bettingEndsAt - serverNow)}s`, color: 'warning' }
32 if (scene.phase === 'running') {
33 const pays = scene.stake > 0 ? ` cash out now: ${fmt(payoutFor(scene.stake, multiplier))}` : ''
34 return { text: `▲ ${times(multiplier)}${pays}`, color: 'success' }
35 }
36 return { text: `✖ CRASHED at ${times(multiplier)}`, color: 'error' }
37}
38
39/** What the Client posts. The plugin checks every field: this is input from code, not a fact. */
40export type DeskPress = { a: 'bet' | 'cash' | 'auto' | 'rebet' } | { a: 'stake'; v: number }
41
42/** One button of the desktop pane: what it says, how it looks, and what it posts. */
43export type ButtonSpec = {
44 id: string
45 label: string
46 kind: 'bet' | 'cash' | 'plain'
47 /** A stake that is picked, an option that is on. */
48 isOn: boolean
49 /** Claude is not working: drawn dim, with nothing over it to press. */
50 isDisabled: boolean
51 press: DeskPress
52 /** Its share of the row, as the pane's layout wants it: the shares of a row add up to 100%. */
53 share: string
54 /** Its width in the picture's own units. A row is `ROW_UNITS` wide, so every picture scales by the same factor. */
55 units: number
56 /** Its height in the same units. */
57 height: number
58}
59
60export const ROW_UNITS = 704
61const MAIN_HEIGHT = 60
62const CHIP_HEIGHT = 52
63
64const unitsOf = (percent: number): number => Math.round((ROW_UNITS * percent) / 100)
65
66const button = (id: string, label: string, kind: ButtonSpec['kind'], isOn: boolean, isDisabled: boolean, press: DeskPress, percent: number, height: number): ButtonSpec => ({
67 id,
68 label,
69 kind,
70 isOn,
71 isDisabled,
72 press,
73 share: `${percent}%`,
74 units: unitsOf(percent),
75 height,
76})
77
78/** The word in front of the stakes: a picture of its own, so that the row keeps its shares when the pane shrinks. */
79export const CAPTION = { share: '12%', units: unitsOf(12), height: CHIP_HEIGHT }
80
81/**
82 * What the player can press, always in the same places: the main slot (a button, or a card of the same height when
83 * there is nothing to press), then the stakes and the two options. When Claude is not working they stay, dim, so the
84 * pane does not jump when a turn starts or ends.
85 */
86export type Controls = {
87 main: { button: ButtonSpec } | { text: string; color: 'warning' | 'success' | 'dim' }
88 stakes: ButtonSpec[]
89 options: ButtonSpec[]
90}
91
92export function controlsOf(c: DeskControls): Controls | null {
93 if (!c.isLive) return null
94 const off = !c.isWorking
95 const isLive = !!c.mine && c.mine.cash === null && c.phase !== 'crashed'
96 let main: Controls['main']
97 // An open bet can be cashed out while the plane flies, whether Claude is still working or not.
98 if (isLive && c.phase === 'running') main = { button: button('cash', 'Cash out', 'cash', false, false, { a: 'cash' }, 100, MAIN_HEIGHT) }
99 else if (!off && c.phase === 'betting' && !c.mine) main = { button: button('bet', `Bet ${fmt(c.stake)}`, 'bet', false, false, { a: 'bet' }, 100, MAIN_HEIGHT) }
100 else main = statusOf(c) ?? { text: ' ', color: 'dim' }
101 return {
102 main,
103 stakes: STAKES.map((s, i) => button(`stake${i + 1}`, fmt(s), 'plain', c.stake === s, off, { a: 'stake', v: s }, 22, CHIP_HEIGHT)),
104 options: [
105 button('auto', `auto-out ${c.auto ? times(c.auto) : 'off'}`, 'plain', c.auto !== null, off, { a: 'auto' }, 55, CHIP_HEIGHT),
106 button('rebet', `rebet ${c.isRebet ? 'on' : 'off'}`, 'plain', c.isRebet, off, { a: 'rebet' }, 45, CHIP_HEIGHT),
107 ],
108 }
109}
110
111/** The line that stands where the main button is not: what happened to the bet, or why there is none. */
112export function statusOf(c: DeskControls): { text: string; color: 'warning' | 'success' | 'dim' } | null {
113 if (!c.isLive) return null
114 const { mine, phase } = c
115 if (mine && mine.cash !== null) return { text: `cashed out ${times(mine.cash)}: paid ${fmt(mine.payout)}`, color: 'success' }
116 if (!c.isWorking) {
117 return mine
118 ? { text: 'Betting is locked. Your bet is still in.', color: 'warning' }
119 : { text: 'Betting is locked until Claude works again.', color: 'dim' }
120 }
121 if (phase === 'betting' && !mine) return null
122 if (mine && phase === 'running') return null
123 if (mine && phase === 'betting') return { text: `bet placed: ${fmt(mine.amount)}${mine.auto ? ` · auto ${times(mine.auto)}` : ''}`, color: 'warning' }
124 return { text: 'next round soon', color: 'dim' }
125}
126
127export type DeskView = {
128 controls: DeskControls
129 head: Head
130 scene: DeskScene | null
131 /** Instead of the scene: what to wait for, or what went wrong. */
132 message: { title: string; text: string } | null
133 /** Claude has stopped and the round is being watched out. */
134 watch: string | null
135 table: Table | null
136}
137
138const OFF: DeskControls = {
139 isLive: false,
140 isWorking: false,
141 phase: 'betting',
142 mine: null,
143 stake: 0,
144 auto: null,
145 isRebet: false,
146 notice: '',
147 isNoticeError: false,
148}
149
150/** The same cases as the web page and the terminal's pane, as data. `serverNow` is our estimate of the server's clock. */
151export function deskView(snap: Snapshot | null, wish: Local, st: Status, serverNow: number): DeskView {
152 const head: Head = {
153 pill: st.isWorking ? { text: '● Claude is working', isOn: true } : { text: 'Claude is idle', isOn: false },
154 balance: snap ? snap.you.balance : null,
155 delta: snap && st.balanceAtStart !== null ? snap.you.balance - st.balanceAtStart : null,
156 }
157 const quiet = (message: DeskView['message']): DeskView => ({ controls: OFF, head, scene: null, message, watch: null, table: null })
158
159 if (!st.isWorking && !st.isWatching && st.summary) return quiet({ title: 'Betting is locked', text: st.summary })
160 if (!st.isWorking && !st.isWatching) {
161 return quiet({ title: 'Claude is idle', text: 'Betting is open only while Claude works. Send it a task and come back.' })
162 }
163 if (!snap || !st.isJoined) {
164 return quiet(st.error ? { title: st.error, text: 'Trying to reach the server.' } : { title: 'Taking a seat…', text: '' })
165 }
166
167 const phase = snap.table.phase
168 const mine = snap.you.bet
169 const isLive = !!mine && mine.cash === null && phase !== 'crashed'
170 return {
171 controls: {
172 isLive: true,
173 isWorking: st.isWorking,
174 phase,
175 mine,
176 stake: wish.stake,
177 auto: wish.auto,
178 isRebet: wish.isRebet,
179 notice: st.error ?? st.note ?? (st.isWorking ? '' : (st.summary ?? '')),
180 isNoticeError: !!st.error,
181 },
182 head,
183 scene: {
184 phase,
185 round: snap.table.round,
186 growth: snap.growth,
187 runStartedAt: snap.table.runStartedAt,
188 crashedAt: snap.table.crashedAt,
189 crashPoint: snap.table.crashPoint,
190 bettingEndsAt: snap.table.bettingEndsAt,
191 serverNow,
192 stake: isLive && phase === 'running' ? mine!.amount : 0,
193 },
194 message: null,
195 watch: st.isWorking
196 ? null
197 : isLive
198 ? 'Claude finished: betting is locked. Your bet is still in: cash out before the crash.'
199 : 'Claude finished: betting is locked. Watching this round.',
200 table: {
201 id: snap.table.id,
202 seated: snap.table.humans + snap.table.bots,
203 bots: snap.table.bots,
204 players: snap.players,
205 history: snap.history,
206 phase,
207 },
208 }
209}
210hooks/desk-svg.ts 225 lines1// The desktop pane's pictures, as SVG in the web page's colors: the stage (header, scene, the big number)
2// and the table (players, last crashes). Each is drawn on a dark card of its own, so it reads the same in a
3// light app and in a dark one. They are not pressable (nothing in an Svg is), so the buttons are a Client.
4import type { Phase, Row } from '../types'
5import { fmt, payoutFor, seconds, sign, times } from './shared/lib'
6import { CAPTION, PICTURE, ROW_UNITS } from './desk'
7import type { ButtonSpec, DeskScene } from './desk'
8
9const COLOR = {
10 bg: '#12141b',
11 panel: '#1b1f2c',
12 panel2: '#232839',
13 line: '#2d3550',
14 ink: '#eceff8',
15 dim: '#8a93ab',
16 green: '#5fe0a0',
17 red: '#ff6f61',
18 amber: '#ffc857',
19} as const
20
21const FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
22/** One scene pixel is this many SVG units. The app scales the whole card to its pane. */
23const SCALE = 12
24/** Both cards are this wide, so they line up. */
25export const WIDTH = 704
26const HEAD = 46 // the header's height above the stage
27const CHAR = 0.6 // a monospace character's width, in ems
28
29export const esc = (s: string): string =>
30 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
31
32const text = (x: number, y: number, size: number, fill: string, content: string, extra = ''): string =>
33 `<text x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" fill="${fill}"${extra ? ` ${extra}` : ''}>${esc(content)}</text>`
34
35/** Words onto lines of at most `max` characters; a longer word keeps its own line. */
36export function wrap(words: string, max: number): string[] {
37 const lines: string[] = []
38 let line = ''
39 for (const word of words.split(/\s+/).filter(Boolean)) {
40 if (line && line.length + 1 + word.length > max) {
41 lines.push(line)
42 line = word
43 } else line = line ? `${line} ${word}` : word
44 }
45 if (line) lines.push(line)
46 return lines
47}
48
49const svg = (width: number, height: number, body: string): string =>
50 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
51
52// ---- the stage ---------------------------------------------------------------------
53
54/** The big number over the picture and the line under it, as the web page shows them. */
55export function overlayOf(scene: DeskScene, multiplier: number, now: number): { big: string; color: string; sub: string } {
56 if (scene.phase === 'betting') return { big: `${seconds(scene.bettingEndsAt - now)}s`, color: COLOR.amber, sub: 'BETS OPEN' }
57 if (scene.phase === 'running') {
58 const sub = scene.stake > 0 ? `your bet pays ${fmt(payoutFor(scene.stake, multiplier))}` : ''
59 return { big: times(multiplier), color: multiplier >= 2 ? COLOR.green : '#fff', sub }
60 }
61 return { big: times(scene.crashPoint ?? multiplier), color: COLOR.red, sub: 'CRASHED' }
62}
63
64export type Head = {
65 pill: { text: string; isOn: boolean }
66 balance: number | null
67 delta: number | null
68}
69
70export type Stage = {
71 head: Head
72 /** The scene's paths (`picturePaths`), or none while there is no round to show. */
73 picture: string | null
74 big: string
75 bigColor: string
76 sub: string
77 /** Claude has stopped and the round is being watched out. */
78 watch: string | null
79 /** Instead of the picture: what to wait for, or what went wrong. */
80 message: { title: string; text: string } | null
81}
82
83function header(head: Head): string {
84 const pillW = Math.round(head.pill.text.length * 13 * CHAR + 20)
85 let out = text(14, 30, 20, COLOR.ink, 'IDLECRASH', 'font-weight="700" letter-spacing="2.8"')
86 out += `<rect x="161" y="14" width="${pillW}" height="22" rx="11" fill="${COLOR.panel2}"/>`
87 out += text(171, 29, 13, head.pill.isOn ? COLOR.green : COLOR.amber, head.pill.text)
88 const delta =
89 head.balance !== null && head.delta !== null
90 ? `<tspan font-size="13" dx="8" fill="${head.delta >= 0 ? COLOR.green : COLOR.red}">${esc(sign(head.delta))} this turn</tspan>`
91 : ''
92 out += `<text x="${WIDTH - 14}" y="30" text-anchor="end" font-family="${FONT}" font-size="16" fill="${COLOR.ink}">balance <tspan font-weight="700">${head.balance === null ? '–' : esc(fmt(head.balance))}</tspan>${delta}</text>`
93 return out
94}
95
96export function stageSvg(stage: Stage): string {
97 const pictureW = PICTURE.width * SCALE
98 const pictureH = PICTURE.height * SCALE
99 const panelH = stage.picture !== null ? pictureH + 16 : 150
100 const height = HEAD + panelH + 4
101 let body = `<rect width="${WIDTH}" height="${height}" rx="8" fill="${COLOR.bg}"/>${header(stage.head)}`
102 body += `<rect x="1" y="${HEAD}" width="${WIDTH - 2}" height="${panelH}" rx="6" fill="${COLOR.panel}" stroke="${COLOR.line}" stroke-width="2"/>`
103
104 if (stage.picture !== null) {
105 const x = (WIDTH - pictureW) / 2
106 body += `<g transform="translate(${x} ${HEAD + 8}) scale(${SCALE})" shape-rendering="crispEdges">${stage.picture}</g>`
107 // The number has a dark outline instead of the page's text shadow, so it reads on sky and on grass.
108 const outline = (w: number): string => `stroke="#000" stroke-opacity=".55" stroke-width="${w}" stroke-linejoin="round" paint-order="stroke"`
109 body += text(x + 14, HEAD + 62, 44, stage.bigColor, stage.big, `font-weight="800" ${outline(6)}`)
110 if (stage.sub) body += text(x + 14, HEAD + 86, 14, '#fff', stage.sub, outline(4))
111 if (stage.watch) {
112 const lines = wrap(stage.watch, 40)
113 const w = Math.round(Math.max(...lines.map(l => l.length)) * 12 * CHAR + 20)
114 const left = x + pictureW - 14 - w
115 body += `<rect x="${left}" y="${HEAD + 16}" width="${w}" height="${lines.length * 16 + 10}" rx="4" fill="#0e1018" fill-opacity=".8" stroke="${COLOR.line}"/>`
116 lines.forEach((line, i) => {
117 body += text(left + 10, HEAD + 34 + i * 16, 12, COLOR.amber, line)
118 })
119 }
120 } else if (stage.message) {
121 body += text(WIDTH / 2, HEAD + 62, 20, COLOR.ink, stage.message.title, 'font-weight="700" text-anchor="middle"')
122 wrap(stage.message.text, 72)
123 .slice(0, 3)
124 .forEach((line, i) => {
125 body += text(WIDTH / 2, HEAD + 94 + i * 22, 16, COLOR.dim, line, 'text-anchor="middle"')
126 })
127 }
128 return svg(WIDTH, height, body)
129}
130
131// ---- the table ---------------------------------------------------------------------
132
133export type Table = {
134 id: string
135 seated: number
136 bots: number
137 players: Row[]
138 history: number[]
139 phase: Phase
140}
141
142const ROW = 28
143const GAP = 10 // between the two cards
144const SIZE = 16 // the text of both cards
145
146/** The players, then the last crashes under them: the pane has more room down than across. */
147export function tableSvg(table: Table): string {
148 const title = (label: string): string => text(14, 30, 13, COLOR.dim, label.toUpperCase(), 'letter-spacing="1.6"')
149
150 let players = title(`table ${table.id} · ${table.seated} seated${table.bots ? ` (${table.bots} bots)` : ''}`)
151 table.players.forEach((p, i) => {
152 const y = 64 + i * ROW
153 players += text(14, y, SIZE, COLOR.ink, `${p.bot ? '⚙ ' : ''}${p.name}${p.you ? ' (you)' : ''}`, p.you ? 'font-weight="700"' : '')
154 players += text(360, y, SIZE, COLOR.ink, p.bet > 0 ? fmt(p.bet) : '–', 'text-anchor="end"')
155 if (p.bet > 0 && p.cash !== null) players += text(384, y, SIZE, COLOR.green, `${times(p.cash)} → ${fmt(p.payout)}`)
156 else if (p.bet > 0 && table.phase === 'crashed') players += text(384, y, SIZE, COLOR.red, 'lost')
157 else if (p.bet > 0) players += text(384, y, SIZE, COLOR.dim, table.phase === 'betting' ? 'in' : '…')
158 })
159 const playersH = 64 + (Math.max(1, table.players.length) - 1) * ROW + 20
160
161 // The last crashes wrap like the page's chips do.
162 const chipH = 28
163 let chips = ''
164 let x = 14
165 let y = 46
166 for (const point of table.history) {
167 const label = point.toFixed(2)
168 const w = Math.round(label.length * SIZE * CHAR + 20)
169 if (x + w > WIDTH - 14 && x > 14) {
170 x = 14
171 y += chipH + 8
172 }
173 chips += `<rect x="${x}" y="${y}" width="${w}" height="${chipH}" rx="5" fill="${COLOR.panel2}"/>`
174 chips += text(x + 10, y + 20, SIZE, point >= 2 ? COLOR.green : COLOR.red, label)
175 x += w + 8
176 }
177 const historyH = table.history.length > 0 ? y + chipH + 18 : 60
178
179 const card = (at: number, height: number, inner: string): string =>
180 `<g transform="translate(0 ${at})"><rect x="1" y="1" width="${WIDTH - 2}" height="${height - 2}" rx="6" fill="${COLOR.panel}" stroke="${COLOR.line}" stroke-width="2"/>${inner}</g>`
181 const body = card(0, playersH, players) + card(playersH + GAP, historyH, title('last crashes') + chips)
182 return svg(WIDTH, playersH + GAP + historyH, body)
183}
184
185// ---- the buttons ----------------------------------------------------------------------
186
187const BLUE = '#6cb8ff'
188const LOOK = {
189 bet: { fill: '#2d6a4f', hot: '#37805f', line: '#3f9b73', hotLine: '#5fe0a0', ink: '#eceff8' },
190 cash: { fill: '#8a5a12', hot: '#a06b18', line: '#d99a2b', hotLine: COLOR.amber, ink: '#fff3d6' },
191 plain: { fill: COLOR.panel2, hot: '#2b3248', line: COLOR.line, hotLine: BLUE, ink: COLOR.ink },
192} as const
193
194/**
195 * A button as a picture, at rest or lit. The pane shows the lit one over the other while the pointer is on the
196 * button (see the Box with `hover` in register.tsx), so the change takes no code of ours and no round trip.
197 * Nothing in an Svg can be pressed: a Client over the picture catches the click.
198 */
199export function buttonSvg(b: ButtonSpec, view: 'rest' | 'hot'): string {
200 const look = LOOK[b.kind]
201 const isLit = view === 'hot' && !b.isDisabled
202 const fill = b.isDisabled ? COLOR.panel : isLit ? look.hot : look.fill
203 const line = b.isDisabled ? COLOR.line : b.isOn ? COLOR.amber : isLit ? look.hotLine : look.line
204 const ink = b.isDisabled ? COLOR.dim : b.isOn ? COLOR.amber : look.ink
205 const size = b.kind === 'plain' ? 20 : 24
206 const body =
207 `<rect x="3" y="3" width="${b.units - 6}" height="${b.height - 6}" rx="9" fill="${fill}" stroke="${line}" stroke-width="3"/>` +
208 text(b.units / 2, b.height / 2 + size * 0.34, size, ink, b.label, `text-anchor="middle"${b.kind === 'plain' ? '' : ' font-weight="700"'}`)
209 return svg(b.units, b.height, body)
210}
211
212/** The main slot when there is nothing to press: a card as tall as the button, so nothing below it moves. */
213export function statusSvg(message: string, color: 'warning' | 'success' | 'dim'): string {
214 const ink = color === 'warning' ? COLOR.amber : color === 'success' ? COLOR.green : COLOR.dim
215 const body =
216 `<rect x="3" y="3" width="${ROW_UNITS - 6}" height="54" rx="9" fill="${COLOR.panel}" stroke="${COLOR.line}" stroke-width="3"/>` +
217 text(ROW_UNITS / 2, 36, 18, ink, message, 'text-anchor="middle"')
218 return svg(ROW_UNITS, 60, body)
219}
220
221/** The word in front of the stakes. */
222export function captionSvg(label: string): string {
223 return svg(CAPTION.units, CAPTION.height, text(4, CAPTION.height / 2 + 6, 16, COLOR.dim, label))
224}
225hooks/pixels.ts 42 lines1// Turns the scene's palette indices into something a surface can draw.
2import { PALETTE } from './shared/scene'
3
4export type Run = { text: string; fg: string; bg: string }
5
6/**
7 * The scene as SVG paths, one per color, in scene pixels: put them in a group that is scaled up. Runs of one
8 * color merge along a row, so a whole scene is a few kilobytes, and nothing leaves a gap the way glyph rows do.
9 */
10export function picturePaths(px: Uint8Array, width: number, height: number): string {
11 const paths = new Map<number, string>()
12 for (let y = 0; y < height; y++) {
13 for (let x = 0; x < width; ) {
14 const color = px[y * width + x]!
15 let len = 1
16 while (x + len < width && px[y * width + x + len] === color) len += 1
17 paths.set(color, `${paths.get(color) ?? ''}M${x} ${y}h${len}v1h-${len}z`)
18 x += len
19 }
20 }
21 let body = ''
22 for (const [color, d] of paths) body += `<path fill="${PALETTE[color]}" d="${d}"/>`
23 return body
24}
25
26/**
27 * One row of terminal cells from two rows of pixels. Each cell is a half block:
28 * the top pixel is its foreground, the bottom pixel its background. Cells with
29 * the same colors are merged into one run.
30 */
31export function cellRow(px: Uint8Array, width: number, row: number): Run[] {
32 const runs: Run[] = []
33 for (let x = 0; x < width; x++) {
34 const fg = PALETTE[px[row * 2 * width + x]!]!
35 const bg = PALETTE[px[(row * 2 + 1) * width + x]!]!
36 const last = runs[runs.length - 1]
37 if (last && last.fg === fg && last.bg === bg) last.text += '▀'
38 else runs.push({ text: '▀', fg, bg })
39 }
40 return runs
41}
42hooks/hit-client.tsx 26 lines1// Draws nothing. It lies over the picture of a button and posts the button's press when the pointer goes down and up
2// on it. Nothing in an Svg can be pressed, but a Client is told where the pointer is, so this is the button.
3// It is 12 rows tall whatever the picture is, and the Box it sits in clips it to the picture: a percentage height
4// collapses to one row. It cannot call the plugin: a press is posted as data (`DeskPress`) and the plugin acts on it.
5import type { ClientModule } from 'claude-code'
6
7import type { DeskPress } from './desk'
8
9const Hit: ClientModule<{ press: DeskPress }, boolean> = (props, surface) => {
10 const { Box } = surface.elements
11 // Set on every render, so a press posts what the button says now, not what it said when it appeared.
12 surface.onPointer(e => {
13 const isInside = e.x >= 0 && e.y >= 0 && e.x < surface.columns && e.y < surface.rows
14 const down = surface.state ?? false
15 if (e.type === 'down' && e.button === 'left') {
16 if (!down) surface.setState(true)
17 } else if (e.type === 'up') {
18 if (down && isInside) surface.post(props.press)
19 if (down) surface.setState(false)
20 } else if (e.type === 'leave' && down) surface.setState(false)
21 })
22 return <Box width="100%" height={12} />
23}
24
25export default Hit
26hooks/scene-client.tsx 64 lines1// The animated picture, drawn on the surface itself. It runs its own frame
2// clock, so the plugin never redraws the pane (and its buttons) to move a plane.
3import type { ClientModule } from 'claude-code'
4
5import { pictureLine } from './desk'
6import { cellRow } from './pixels'
7import { drawScene, sceneInputFor } from './shared/scene'
8import type { SceneProps } from './shared/scene'
9
10const FRAME_MS = 100
11
12/** `n` frames since the props arrived, `at` and `t` wall-clock readings of then and now. */
13type Clock = { n: number; at: number; t: number; stamp: number }
14
15const Scene: ClientModule<SceneProps, Clock> = (props, surface) => {
16 const { Box, Text } = surface.elements
17 const state = surface.state
18 if (state === undefined) {
19 const t = Date.now()
20 surface.setState({ n: 0, at: t, t, stamp: props.serverNow })
21 surface.every(FRAME_MS, () => {
22 const cur = surface.state
23 if (cur) surface.setState({ ...cur, n: cur.n + 1, t: Date.now() })
24 })
25 } else if (state.stamp !== props.serverNow) {
26 // New props from the plugin: measure time from here.
27 const t = Date.now()
28 surface.setState({ n: 0, at: t, t, stamp: props.serverNow })
29 }
30 const clock = surface.state ?? { n: 0, at: 0, t: 0, stamp: props.serverNow }
31 // Whichever ran further: the frames we counted, or the wall clock (if frames stalled).
32 const elapsed = clock.stamp === props.serverNow ? Math.max(clock.n * FRAME_MS, clock.t - clock.at) : 0
33 const { input, multiplier, serverNow } = sceneInputFor(props, elapsed)
34 const px = drawScene(input, props.columns, props.rows * 2)
35
36 const { text, color } = pictureLine(props, multiplier, serverNow)
37 const line = (
38 <Text bold color={color}>
39 {text}
40 </Text>
41 )
42
43 const rows = []
44 for (let r = 0; r < props.rows; r++) {
45 rows.push(
46 <Box>
47 {cellRow(px, props.columns, r).map(run => (
48 <Text color={run.fg} backgroundColor={run.bg}>
49 {run.text}
50 </Text>
51 ))}
52 </Box>,
53 )
54 }
55 return (
56 <Box flexDirection="column">
57 {rows}
58 {line}
59 </Box>
60 )
61}
62
63export default Scene
64hooks/shared/types.ts 39 lines1// GENERATED from /shared by tools/sync-shared.ts. Edit the original, not this copy.
2export type Phase = 'betting' | 'running' | 'crashed'
3
4export type Row = {
5 name: string
6 bot: boolean
7 you: boolean
8 bet: number
9 cash: number | null
10 payout: number
11}
12
13/** What the server sends about a table. The mod reads it as data, never as code. */
14export type Snapshot = {
15 now: number
16 growth: number
17 table: {
18 id: string
19 round: number
20 phase: Phase
21 phaseStartedAt: number
22 bettingEndsAt: number
23 runStartedAt: number | null
24 crashedAt: number | null
25 crashPoint: number | null
26 humans: number
27 bots: number
28 }
29 players: Row[]
30 history: number[]
31 you: {
32 name: string
33 balance: number
34 bet: { amount: number; auto: number | null; cash: number | null; payout: number } | null
35 refillAt: number | null
36 }
37 limits: { minBet: number; maxBet: number }
38}
39types/index.d.ts 64 lines1export type Phase = 'betting' | 'running' | 'crashed'
2
3export type Row = {
4 name: string
5 bot: boolean
6 you: boolean
7 bet: number
8 cash: number | null
9 payout: number
10}
11
12export type Snapshot = {
13 now: number
14 growth: number
15 table: {
16 id: string
17 round: number
18 phase: Phase
19 phaseStartedAt: number
20 bettingEndsAt: number
21 runStartedAt: number | null
22 crashedAt: number | null
23 crashPoint: number | null
24 humans: number
25 bots: number
26 }
27 players: Row[]
28 history: number[]
29 you: {
30 name: string
31 balance: number
32 bet: { amount: number; auto: number | null; cash: number | null; payout: number } | null
33 refillAt: number | null
34 }
35 limits: { minBet: number; maxBet: number }
36}
37
38/** The last server view, and how far the server's clock is ahead of ours. */
39export type Feed = { snap: Snapshot | null; offset: number }
40
41/** What the player picked: stake, auto cash-out target, rebet each round. */
42export type Local = { stake: number; auto: number | null; isRebet: boolean }
43
44export type Status = {
45 /** A turn is running: the only time anyone may play. */
46 isWorking: boolean
47 /** Seated at a table on the server. */
48 isJoined: boolean
49 /** Claude stopped, but the round is still on: the pane keeps showing it, without betting. */
50 isWatching: boolean
51 error: string | null
52 note: string | null
53 /** The balance when this turn's play began, to show what the wait was worth. */
54 balanceAtStart: number | null
55 /** Set when the turn ended while playing: betting is locked until the next turn. */
56 summary: string | null
57}
58
59declare module 'claude-code' {
60 interface PluginState {
61 idlecrash: { feed: Feed; local: Local; status: Status }
62 }
63}
64