SLOPSHOPPER

idlecrash

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

newpanecommandtoastnetworktimer
★ 1v0.3.1MITupdated 2026-10-06grozoww/idlecrash/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · idlecrash
│ ┃ IdleCrash ✕ › fix the failing auth test and add an audit log call │ ┃ IDLECRASH fake tokens only │ ┃ Waiting for Claude to start working. ⏺ Read(src/auth.ts) │ ┃ You can only play while Claude is busy. ⎿ Read 6 lines │ ┃ /idlecrash off stops it opening by itself ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /idlecrash │ ⎿ idlecrash: IdleCrash is open. You can play while Claude is worki │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · IdleCrash
IDLECRASH fake tokens only Waiting for Claude to start working. You can only play while Claude is busy. /idlecrash off stops it opening by itself
README

IdleCrash

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">

  • No real money. Tokens are fake. They cannot be bought, sold or withdrawn.
  • Only while Claude works. Betting is open while any of your Claude Code sessions is working and locks when the last one stops. An open bet stays in its round: you can still cash out until the plane crashes (an auto cash-out still works), and the round plays out on screen. If it crashes first, the stake is lost.
  • 5 to 10 people per table. Rounds run on the server's clock, so nobody waits for anybody. Bots may fill up to 30% of a table and are marked with a gear (⚙).
  • Terminal or desktop app. In a terminal and in the desktop app the game is a pane next to the chat. It does not play in editors or on a phone.

The plane over the runway, the houses, the mountains, the clouds, then the fall

Install

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.

Play

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:

KeyDoes
bBet your stake in the betting phase
cCash out while the plane flies
1 2 3 4Stake: 10, 50, 100, 500
xCycle the auto cash-out: off, 1.5x, 2x, 3x, 5x, 10x
rBet 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 players

The server

The 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.

What the server sees

  • Your nickname, a random anonymous id and secret (made on first use, kept in the plugin's store on your machine), your bets and cash-outs.
  • Which of your Claude Code sessions are working, as a yes or no with a random session id. Never what they work on.
  • Your IP address, like any web server. It is used for rate limiting in memory and not written to disk.

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.

Run a server

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
EnvDefault
PORT8787
HOST0.0.0.0Use 127.0.0.1 behind a reverse proxy
DATA_DIR./dataWhere accounts.json is kept
BOT_PCT30Bots are at most this percent of a table
TRUST_PROXYunsetSet to 1 behind a proxy so limits use X-Forwarded-For
ACCOUNTS_PER_IP_HOUR10New 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.

How it works

  • Each round has a betting phase (8 s), a flight and a crash. The server picks the crash point after betting closes: the chance of reaching x is 0.99 / x, so the house edge is 1%. It never sends the crash point before the crash.
  • The mod asks the server for the table about three times a second while the plane flies and once a second otherwise, and draws the plane itself from the server's clock. The server, not your screen, decides whether a cash-out came in time.
  • The mod tells the server every 2 seconds that a session is working. If the beats stop (Claude Code closed or crashed), the table locks by itself. In a terminal the pane draws the picture with a 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.
  • A player is whoever holds the account secret; there is no login. Lose the plugin's store and you start again with a new account.

Develop

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.

License

MIT, see LICENSE.

Source 12 files
hooks/register.tsx 817 lines
1import { 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}
817
hooks/shared/lib.ts 86 lines
1// 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)
86
hooks/shared/scene.ts 479 lines
1// 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}
479
hooks/shared/protocol.ts 21 lines
1// 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'
21
hooks/util.ts 31 lines
1import { 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}
31
hooks/desk.ts 210 lines
1// 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}
210
hooks/desk-svg.ts 225 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
225
hooks/pixels.ts 42 lines
1// 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}
42
hooks/hit-client.tsx 26 lines
1// 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
26
hooks/scene-client.tsx 64 lines
1// 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
64
hooks/shared/types.ts 39 lines
1// 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}
39
types/index.d.ts 64 lines
1export 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