SLOPSHOPPER

block-battle

Falling-block puzzle inside Claude Code: solo Marathon, a bot to beat, 1v1 battles and a leaderboard while a long task runs

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · block-battle
│ ┃ Block Battle ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /cg-block-battle │ ⎿ block-battle: Block Battle open. Click the pane to play; q retur │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Block Battle
▣ client module ./game.tsx
README

Block Battle

A falling-block puzzle you play inside Claude Code while it works on something long. Play solo Marathon, battle other players 1v1 online, and climb the leaderboard. It draws in the terminal and in the Code tab of Claude Desktop.

Run /cg-block-battle and click the pane to start. Left and right move, down drops faster, space drops all the way. Up or x rotates, z rotates the other way, a flips, c holds. p pauses Marathon, q goes back to the menu, Esc leaves the pane.

What it sends over the network

Marathon works offline, and when you are not signed in it sends nothing. Everything else talks to one game server: the official one at https://games.jpoapps.com, or an address you type when you pick Battle. The game only uses HTTPS (plain HTTP only for a server on your own machine, for testing).

  • Sign-in uses GitHub's device flow. The game asks the server for its GitHub app ID, then asks github.com for a code and polls github.com until you approve it. The approval requests no permissions. The resulting GitHub token goes to the game server once, which checks it with GitHub and replies with a session key and your public username. The game stores that session key, never the GitHub token.
  • Signed in, the game sends the server: a request to start a ranked Marathon, your recorded moves at its end (so the server can replay them and check the score), matchmaking and battle updates (attacks and a snapshot of your board), the moves of a finished battle, and leaderboard requests.
  • Signing out tells the server to delete the session.

The game stores, on your machine through Claude Code: the session key for each server you signed in to, and the last server address you typed. It sends a session key only to the server it came from.

The server code is open source at https://github.com/jpo-oss/claude-games-server. It stores your GitHub login, numeric ID and account creation date, scores and battle results, and never logs tokens, request bodies or IP addresses. Full details are in the privacy policy.

What it watches in your session

The game adds one command, /cg-block-battle, and runs no other commands, tools or programs. It listens to three session events only to time one reminder: when you send a prompt and when Claude's turn ends, it can show a single toast after two minutes ("Long task. /cg-block-battle while you wait?"), and while Claude is asking you a question with AskUserQuestion it waits so the toast never covers the question. It passes each of these events on unchanged and never reads, stores or sends what you or Claude wrote.

What else is in this folder

tests/ holds the game's automated tests, run with claude plugin test. They never load in a player's session. They stand in for Claude Code's own events (network replies, stored data, tool calls and commands) so the game can be tested without a network or a real session; the game itself makes none of those calls beyond the ones listed above.

License

MIT

Source 10 files
hooks/register.tsx 719 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpInit, Register, Timer } from 'claude-code'
3
4import type { Battle, BotLevel, ClientMsg, GameView } from '../types'
5import { parseConfig, parseSession, pollToken, requestDeviceCode, sessionKey } from './auth'
6import type { Session } from './auth'
7import { OPP_COLUMNS, TIERS } from './draw'
8import {
9  call,
10  emptyOutbox,
11  mergeIncoming,
12  nextPayload,
13  parseClientMsg,
14  parseLeaderboard,
15  parseMarathonStart,
16  parseQueue,
17  parseSync,
18  queueAttacks,
19  serverUrlOk,
20} from './net'
21import type { Outbox, Reply } from './net'
22import { takeChunk } from './log'
23import type { Assembly, LogMsg } from './log'
24
25const PANE = 'block-battle'
26const NUDGE_MS = 120_000
27const NUDGE_RETRY_MS = 10_000
28const QUEUE_POLL_MS = 1_500
29const QUEUE_GIVE_UP_MS = 120_000
30
31export const idleBattle = (): Battle => ({ status: 'idle', roomId: null, seed: 0, opponent: null, incoming: [], result: null, online: null })
32const OFFICIAL = 'https://games.jpoapps.com'
33const LAST_SERVER = 'lastServer'
34const startView = (): GameView => ({
35  me: null,
36  leaderboard: null,
37  notice: null,
38  battle: idleBattle(),
39  servers: { home: OFFICIAL, last: null, active: OFFICIAL },
40  marathon: null,
41  vsbot: null,
42  uploaded: null,
43})
44
45const view = atom({ plugin: 'block-battle', key: 'view' } as const, startView())
46
47const DOWN = 'Game server unreachable. Solo play still works.'
48const SIGNED_OUT = 'Signed out. Pick Battle or Leaderboard to sign in again.'
49const OUTDATED = 'Block Battle is out of date. Run claude plugin update block-battle@claude-games in your shell, then /reload-plugins.'
50const SIGNING_IN = 'Signing in with GitHub...'
51const NO_SIGNIN = "This server isn't set up for sign-in."
52const BAD_URL = 'The server address must start with https://.'
53const TIMEOUT_MS = 30_000
54const NO_GITHUB = "Couldn't reach GitHub to sign in. Solo play still works."
55const CANCELLED = 'Sign-in was cancelled or expired. Pick Battle or Leaderboard to try again.'
56const signInLine = (uri: string, code: string) => {
57  const line = `Sign in: open ${uri} and enter ${code}`
58  if (rt.base === OFFICIAL) return line
59
60  return `Community server ${new URL(rt.base).host}, run by someone else. It will learn your GitHub username. ${line}`
61}
62const trimSlash = (u: string) => u.replace(/\/+$/, '')
63
64type Owned = { key: string; base: string }
65
66const rt: {
67  base: string
68  hasLoadedLast: boolean
69  // Our server's session key, with the server it belongs to. The GitHub token is never kept: it is exchanged and dropped.
70  session: { base: string; s: Session } | null
71  signIn: Timer | null
72  isSigningIn: boolean
73  // Bumped when the pane closes so a flow still waiting on a request stops when it resumes.
74  signInGen: number
75  signInLine: string | null
76  queueTimer: Timer | null
77  // Bumped on every battle start, stop, reset, server switch and match. An async flow keeps the value it
78  // started with and writes nothing once it has moved on.
79  battleGen: number
80  // The generation whose queue poll is in flight, so a stale one never holds up the next search.
81  polling: number | null
82  isSyncing: boolean
83  outbox: Outbox
84  latest: { snapshot: string; isOver: boolean }
85  opponentLogin: string
86  // The login whose session made the current match; the result is judged against it.
87  myLogin: string
88  lastSyncOk: number
89  isAsking: boolean
90  nudge: Timer | null
91  isTurnOn: boolean
92  wasNudged: boolean
93  // Logs are only taken for marathon and bot game ids the server handed out and rooms we were matched in,
94  // and go to the server that handed them out.
95  games: Owned[]
96  botGames: Owned[]
97  rooms: Owned[]
98  asm: Assembly | null
99  sentKeys: string[]
100  wantsSync: boolean
101  retry: Timer | null
102} = {
103  base: OFFICIAL,
104  hasLoadedLast: false,
105  session: null,
106  signIn: null,
107  isSigningIn: false,
108  signInGen: 0,
109  signInLine: null,
110  queueTimer: null,
111  battleGen: 0,
112  polling: null,
113  isSyncing: false,
114  outbox: emptyOutbox(),
115  latest: { snapshot: '', isOver: false },
116  opponentLogin: '',
117  myLogin: '',
118  lastSyncOk: 0,
119  isAsking: false,
120  nudge: null,
121  isTurnOn: false,
122  wasNudged: false,
123  games: [],
124  botGames: [],
125  rooms: [],
126  asm: null,
127  sentKeys: [],
128  wantsSync: false,
129  retry: null,
130}
131
132const setView = ($: EngineInterface, patch: Partial<GameView>) => update($, view, v => ({ ...v, ...patch }))
133const isLive = (gen: number) => gen === rt.battleGen
134// Battle writes recheck their generation inside the update: the SDK can hold a write after the caller's own check.
135const setViewIf = ($: EngineInterface, gen: number, patch: Partial<GameView>) =>
136  update($, view, v => (isLive(gen) ? { ...v, ...patch } : v))
137const setBattleIf = ($: EngineInterface, gen: number, patch: Partial<Battle>) =>
138  update($, view, v => (isLive(gen) ? { ...v, battle: { ...v.battle, ...patch } } : v))
139
140function noticeFor(r: { status: number; error: string }): string {
141  if (r.status === 0) return DOWN
142  if (r.status === 426) return OUTDATED
143  if (r.status === 401 || r.status === 403) return SIGNED_OUT
144
145  return `Server said: ${r.error}`
146}
147
148// The server can change while the store answers, so the session is only ever returned for the base it was read for.
149// The name shown follows the active server's session on every call, so a sign-in cut short by a close still shows it.
150async function loadSession($: EngineInterface, base = rt.base): Promise<Session | null> {
151  const loaded = rt.session?.base === base ? rt.session.s : parseSession(await $.store.get(sessionKey(base)))
152  if (loaded === null || base !== rt.base) return loaded
153  const s = rt.session?.base === base ? rt.session.s : loaded
154  rt.session = { base, s }
155  const { me } = await read($, view)
156  if (me !== s.login) await update($, view, v => (base === rt.base && rt.session?.s === s ? { ...v, me: s.login } : v))
157
158  return s
159}
160
161function stopSignIn() {
162  rt.signIn?.cancel()
163  rt.signIn = null
164  rt.isSigningIn = false
165  rt.signInLine = null
166  rt.signInGen++
167}
168
169// Returns the battle generation to carry on with, or null once something newer has taken over.
170async function useServer($: EngineInterface, url: string, gen: number): Promise<number | null> {
171  const next = trimSlash(url)
172  if (next !== OFFICIAL) {
173    await $.store.set(LAST_SERVER, next)
174    await update($, view, v => ({ ...v, servers: { ...v.servers, last: next } }))
175  }
176  if (!isLive(gen)) return null
177  if (next === rt.base) return gen
178  stopSignIn()
179  rt.base = next
180  rt.session = null
181  const switched = bump()
182  await update($, view, v => (isLive(switched) ? { ...v, me: null, leaderboard: null, servers: { ...v.servers, active: next } } : v))
183
184  return isLive(switched) ? switched : null
185}
186
187async function loadLast($: EngineInterface) {
188  const last = await $.store.get(LAST_SERVER)
189  if (typeof last === 'string' && serverUrlOk(last)) await update($, view, v => ({ ...v, servers: { ...v.servers, last } }))
190}
191
192// Forgets only the session that was sent: a newer one saved meanwhile stays.
193async function dropSession($: EngineInterface, base: string, sent: string) {
194  if (rt.session?.base === base && rt.session.s.session === sent) rt.session = null
195  if (parseSession(await $.store.get(sessionKey(base)))?.session === sent) {
196    await $.store.delete(sessionKey(base))
197    if (rt.session?.base === base) await $.store.set(sessionKey(base), rt.session.s)
198  }
199  await update($, view, v => (base === rt.base && rt.session?.base !== base ? { ...v, me: null } : v))
200}
201
202async function signOut($: EngineInterface): Promise<string> {
203  const base = rt.base
204  const s = await loadSession($, base)
205  if (s === null) return 'Not signed in to this server.'
206  if (serverUrlOk(base)) {
207    await call((url, init) => $.http.fetch(url, init), base, 'DELETE', '/v1/session', s.session, undefined, signal =>
208      $.clock.sleep(TIMEOUT_MS, { signal }),
209    )
210  }
211  await dropSession($, base, s.session)
212
213  return 'Signed out of Block Battle.'
214}
215
216async function startSignIn($: EngineInterface) {
217  if (rt.isSigningIn) return
218  rt.isSigningIn = true
219  const base = rt.base
220  const gen = ++rt.signInGen
221  const live = () => gen === rt.signInGen
222  const end = (notice: string) => {
223    if (!live()) return
224    rt.isSigningIn = false
225    rt.signIn = null
226    rt.signInLine = null
227
228    return setView($, { notice })
229  }
230  const fetch = (url: string, init?: HttpInit) => $.http.fetch(url, init)
231  const giveUp = (signal: AbortSignal) => $.clock.sleep(TIMEOUT_MS, { signal })
232  try {
233    const config = await call(fetch, base, 'GET', '/v1/config', null, undefined, giveUp)
234    if (!live()) return
235    if (!config.ok) return end(noticeFor(config))
236    const clientId = parseConfig(config.data)
237    if (!clientId) return end(NO_SIGNIN)
238    const code = await requestDeviceCode(fetch, clientId, giveUp)
239    if (!live()) return
240    if (!code) return end(NO_GITHUB)
241    rt.signInLine = signInLine(code.uri, code.userCode)
242    await setView($, { leaderboard: null, notice: rt.signInLine })
243    const startedAt = await $.clock.now()
244    let interval = code.interval
245    const tick = async () => {
246      try {
247        if (!live()) return
248        if ((await $.clock.now()) - startedAt > code.expiresIn * 1000) return end(CANCELLED)
249        const poll = await pollToken(fetch, clientId, code.deviceCode, giveUp)
250        if (!live()) return
251        if (poll.kind === 'pending' || poll.kind === 'slowDown') {
252          if (poll.kind === 'slowDown') interval += 5
253          rt.signIn = $.clock.after(interval * 1000, () => void tick())
254
255          return
256        }
257        if (poll.kind === 'failed') return end(poll.reason === 'error' ? NO_GITHUB : CANCELLED)
258        await setView($, { notice: SIGNING_IN })
259        if (!live()) return
260        const r = await call(fetch, base, 'POST', '/v1/session', null, { githubToken: poll.token }, signal => $.clock.sleep(TIMEOUT_MS, { signal }))
261        if (!live()) return
262        const s = r.ok ? parseSession(r.data) : null
263        if (!s) return end(r.ok ? DOWN : noticeFor(r))
264        rt.session = { base, s }
265        await $.store.set(sessionKey(base), s)
266        if (!live() || base !== rt.base) return
267        await update($, view, v => (live() && base === rt.base && rt.session?.s === s ? { ...v, me: s.login } : v))
268        await end(`Signed in as ${s.login}.`)
269        void prefetchBest($)
270      } catch {
271        await end(NO_GITHUB)
272      }
273    }
274    rt.signIn = $.clock.after(interval * 1000, () => void tick())
275  } catch {
276    await end(NO_GITHUB)
277  }
278}
279
280// A server other than the active one is only called with its own stored session, never with a sign-in.
281// isWanted is asked once the session is in hand, right before anything is sent.
282async function send(
283  $: EngineInterface,
284  method: 'GET' | 'POST' | 'DELETE',
285  path: string,
286  body?: unknown,
287  base = rt.base,
288  isWanted = () => true,
289): Promise<{ r: Reply<unknown>; login: string | null }> {
290  if (!serverUrlOk(base)) return { r: { ok: false, status: -2, error: BAD_URL }, login: null }
291  const s = await loadSession($, base)
292  if (!isWanted()) return { r: { ok: false, status: -3, error: '' }, login: null }
293  if (s === null) {
294    if (base !== rt.base) return { r: { ok: false, status: -1, error: SIGNED_OUT }, login: null }
295    void startSignIn($)
296    return { r: { ok: false, status: -1, error: rt.signInLine ?? SIGNING_IN }, login: null }
297  }
298  const r = await call((url, init) => $.http.fetch(url, init), base, method, path, s.session, body, signal => $.clock.sleep(TIMEOUT_MS, { signal }))
299  if (!r.ok && r.status === 401) await dropSession($, base, s.session)
300
301  return { r, login: s.login }
302}
303
304async function api(
305  $: EngineInterface,
306  method: 'GET' | 'POST' | 'DELETE',
307  path: string,
308  body?: unknown,
309  base = rt.base,
310  isWanted = () => true,
311): Promise<Reply<unknown>> {
312  return (await send($, method, path, body, base, isWanted)).r
313}
314
315function resultFor(winner: string): 'win' | 'loss' | null {
316  if (rt.myLogin !== '' && winner === rt.myLogin) return 'win'
317  if (winner === rt.opponentLogin) return 'loss'
318
319  return null
320}
321
322const fail = (r: { status: number; error: string }) => (r.status < 0 ? r.error : noticeFor(r))
323
324async function loadLeaderboard($: EngineInterface) {
325  const r = await api($, 'GET', '/v1/leaderboard')
326  const board = r.ok ? parseLeaderboard(r.data) : null
327  if (board) await setView($, { leaderboard: board, notice: null })
328  else await setView($, { leaderboard: null, notice: r.ok ? DOWN : fail(r) })
329}
330
331// For the menu's Marathon best only: it never touches the notice line, which a slow reply
332// would otherwise overwrite after the person has moved on (a matchmaking error, say).
333async function prefetchBest($: EngineInterface) {
334  const base = rt.base
335  if ((await loadSession($, base)) === null) return
336  const r = await api($, 'GET', '/v1/leaderboard', undefined, base)
337  const board = r.ok ? parseLeaderboard(r.data) : null
338  if (board && base === rt.base) await setView($, { leaderboard: board })
339}
340
341// A new generation: whatever timers the old one left are stopped with it.
342function bump(): number {
343  rt.queueTimer?.cancel()
344  rt.queueTimer = null
345  rt.retry?.cancel()
346  rt.retry = null
347
348  return ++rt.battleGen
349}
350
351// Returns the generation it set up, or null once something newer has taken over.
352async function resetBattle($: EngineInterface, isLeaving: boolean): Promise<number | null> {
353  const wasQueueing = rt.queueTimer !== null
354  const base = rt.base
355  const gen = bump()
356  rt.outbox = emptyOutbox()
357  rt.latest = { snapshot: '', isOver: false }
358  rt.opponentLogin = ''
359  rt.myLogin = ''
360  await setBattleIf($, gen, { online: null })
361  if (isLeaving && wasQueueing) await api($, 'DELETE', '/v1/battle/queue', undefined, base)
362  if (!isLive(gen)) return null
363  await setBattleIf($, gen, idleBattle())
364
365  return isLive(gen) ? gen : null
366}
367
368async function pollQueue($: EngineInterface, startedAt: number, gen: number) {
369  if (rt.polling === gen || !isLive(gen)) return
370  rt.polling = gen
371  const base = rt.base
372  const live = () => isLive(gen)
373  try {
374    const now = await $.clock.now()
375    if (!live()) return
376    if (now - startedAt > QUEUE_GIVE_UP_MS) {
377      const reset = await resetBattle($, true)
378      if (reset !== null) await setViewIf($, reset, { notice: 'No opponent found. Try again in a moment.' })
379
380      return
381    }
382    const { r, login } = await send($, 'POST', '/v1/battle/queue', undefined, base, live)
383    // A match made for a search the player has left is ignored: the server ends it by forfeit once nobody syncs,
384    // and until then answers this search with 409, which is retried.
385    if (!live()) return
386    if (!r.ok) {
387      if (r.status === 409) return
388      const stopped = bump()
389      await setBattleIf($, stopped, { online: null })
390      if (r.status !== 401 && r.status !== -1) await api($, 'DELETE', '/v1/battle/queue', undefined, base)
391      await setBattleIf($, stopped, idleBattle())
392      await setViewIf($, stopped, { notice: fail(r) })
393
394      return
395    }
396    const q = parseQueue(r.data)
397    if (q?.status === 'waiting') await setBattleIf($, gen, { online: q.online })
398    if (q?.status !== 'matched' || login === null) return
399    const matchedAt = await $.clock.now()
400    if (!live()) return
401    const matched = bump()
402    rt.opponentLogin = q.opponent
403    rt.myLogin = login
404    rt.rooms = [...rt.rooms, { key: q.roomId, base }].slice(-8)
405    rt.lastSyncOk = matchedAt
406    // Room for the big board and the opponent's beside it; a width the person dragged still wins.
407    $.ui.open({ id: PANE, title: 'Block Battle', columns: TIERS.big.columns + OPP_COLUMNS + 1, rows: 46 }).catch(() => undefined)
408    await setBattleIf($, matched, {
409      status: 'matched',
410      roomId: q.roomId,
411      seed: q.seed,
412      opponent: { login: q.opponent, snapshot: '', isOver: false },
413      incoming: [],
414      result: null,
415      online: null,
416    })
417  } finally {
418    if (rt.polling === gen) rt.polling = null
419  }
420}
421
422async function startQueue($: EngineInterface) {
423  const gen = bump()
424  await setViewIf($, gen, { notice: null })
425  if (!isLive(gen)) return
426  await setBattleIf($, gen, { status: 'queueing', online: null })
427  if (!isLive(gen)) return
428  const startedAt = await $.clock.now()
429  if (!isLive(gen)) return
430  rt.queueTimer = $.clock.every(QUEUE_POLL_MS, () => void pollQueue($, startedAt, gen))
431  void pollQueue($, startedAt, gen)
432}
433
434async function flushSync($: EngineInterface) {
435  if (rt.isSyncing) return
436  const gen = rt.battleGen
437  const base = rt.base
438  const live = () => isLive(gen)
439  const { battle } = await read($, view)
440  const room = battle.roomId
441  if (!live() || rt.isSyncing || room === null || battle.status !== 'matched') return
442  rt.isSyncing = true
443  try {
444    const again = (ms: number) => {
445      rt.retry?.cancel()
446      rt.retry = $.clock.after(ms, () => void flushSync($))
447    }
448    const end = (notice: string) => endMatch($, gen, notice)
449    const timedOut = async () => {
450      const now = await $.clock.now()
451      return live() && now - rt.lastSyncOk >= TIMEOUT_MS
452    }
453    // After topping out the Client stops posting syncs, so the hooks keep asking until the result comes or the server has been silent too long.
454    const stalled = async () => {
455      if (!rt.latest.isOver) return
456      if (await timedOut()) await end(DOWN)
457      else if (live()) again(1_000)
458    }
459    const { seq, attacks } = nextPayload(rt.outbox)
460    const path = `/v1/battle/${encodeURIComponent(room)}/sync`
461    const r = await api($, 'POST', path, { seq, attacks, snapshot: rt.latest.snapshot, isOver: rt.latest.isOver }, base)
462    if (!live()) return
463    if (!r.ok) {
464      if (r.status === 404 || r.status === 403) await end('The match is no longer available.')
465      else if (r.status === 401) await end(fail(r))
466      else if (r.status === 0 && !rt.latest.isOver) {
467        if (await timedOut()) await end(DOWN)
468      } else await stalled()
469
470      return
471    }
472    rt.outbox.inflight = null
473    const s = parseSync(r.data)
474    if (!s) return stalled()
475    const now = await $.clock.now()
476    if (!live()) return
477    rt.lastSyncOk = now
478    if (!s.ended && rt.latest.isOver) again(250)
479    const result = s.ended && s.winner !== null ? resultFor(s.winner) : null
480    if (s.ended && result === null) return end('The match ended with no result.')
481    await update($, view, v => {
482      if (!live() || v.battle.roomId !== room) return v
483
484      return {
485        ...v,
486        battle: {
487          ...v.battle,
488          opponent: s.opponent ?? v.battle.opponent,
489          incoming: mergeIncoming(v.battle.incoming, s.incoming),
490          ...(s.ended ? { status: 'ended' as const, result } : {}),
491        },
492      }
493    })
494  } finally {
495    rt.isSyncing = false
496    if (rt.wantsSync) {
497      rt.wantsSync = false
498      void flushSync($)
499    }
500  }
501}
502
503async function endMatch($: EngineInterface, gen: number, notice: string) {
504  if (!isLive(gen)) return
505  const reset = await resetBattle($, false)
506  if (reset !== null) await setViewIf($, reset, { notice })
507}
508
509async function startMarathon($: EngineInterface, nonce: number) {
510  const gen = await resetBattle($, true)
511  if (gen === null) return
512  await setViewIf($, gen, { notice: null })
513  if (!isLive(gen)) return
514  let gameId: string | null = null
515  let seed = 0
516  const base = rt.base
517  const isSignedIn = serverUrlOk(base) && (await loadSession($, base)) !== null
518  if (!isLive(gen)) return
519  if (isSignedIn) {
520    const r = await api($, 'POST', '/v1/marathon', undefined, base)
521    if (!isLive(gen)) return
522    const g = r.ok ? parseMarathonStart(r.data) : null
523    if (g) {
524      ;({ gameId, seed } = g)
525      rt.games = [...rt.games, { key: g.gameId, base }].slice(-8)
526    }
527  }
528  await setViewIf($, gen, { marathon: { nonce, gameId, seed } })
529}
530
531// A ranked game nobody will play still holds one of the 5 open games until it is sent.
532function closeBotGame($: EngineInterface, base: string, gameId: string) {
533  rt.sentKeys = [...rt.sentKeys, gameId].slice(-8)
534
535  return sendLog($, base, '/v1/bot/scores', { gameId, log: { steps: 1, inputs: [] } }, 3, 5_000)
536}
537
538async function startBot($: EngineInterface, level: BotLevel, nonce: number) {
539  const gen = await resetBattle($, true)
540  if (gen === null) return
541  await setViewIf($, gen, { notice: null })
542  if (!isLive(gen)) return
543  // Room for both boards; a width the person dragged still wins.
544  $.ui.open({ id: PANE, title: 'Block Battle', columns: TIERS.big.columns + OPP_COLUMNS + 1, rows: 46 }).catch(() => undefined)
545  let gameId: string | null = null
546  let seed = 0
547  const base = rt.base
548  const isSignedIn = serverUrlOk(base) && (await loadSession($, base)) !== null
549  if (!isLive(gen)) return
550  if (isSignedIn) {
551    const r = await api($, 'POST', '/v1/bot', { level }, base)
552    const g = r.ok ? parseMarathonStart(r.data) : null
553    if (g && !isLive(gen)) return void closeBotGame($, base, g.gameId)
554    if (!isLive(gen)) return
555    if (g) {
556      ;({ gameId, seed } = g)
557      rt.botGames = [...rt.botGames, { key: g.gameId, base }].slice(-8)
558    }
559  }
560  await setViewIf($, gen, { vsbot: { nonce, level, gameId, seed } })
561}
562
563// Retries only when the server could not answer (status 0) or said 503.
564async function sendLog($: EngineInterface, base: string, path: string, body: unknown, tries: number, waitMs: number): Promise<Reply<unknown>> {
565  for (let i = 1; ; i++) {
566    const r = await api($, 'POST', path, body, base)
567    if (r.ok || (r.status !== 0 && r.status !== 503) || i >= tries) return r
568    await $.clock.sleep(waitMs)
569  }
570}
571
572async function takeLog($: EngineInterface, m: LogMsg) {
573  const owners = m.kind === 'marathon' ? rt.games : m.kind === 'bot' ? rt.botGames : rt.rooms
574  const owner = owners.find(o => o.key === m.key)
575  if (!owner) return
576  const r = takeChunk(rt.asm, m)
577  rt.asm = r.asm
578  await setView($, { uploaded: { key: m.key, have: r.have } })
579  if (!r.log || rt.sentKeys.includes(m.key)) return
580  rt.sentKeys = [...rt.sentKeys, m.key].slice(-8)
581  if (m.kind === 'battle') {
582    // The server waits 30 s for the winner's log.
583    await sendLog($, owner.base, `/v1/battle/${encodeURIComponent(m.key)}/log`, { log: r.log }, 5, 3_000)
584    return
585  }
586  const path = m.kind === 'marathon' ? '/v1/scores' : '/v1/bot/scores'
587  const res = await sendLog($, owner.base, path, { gameId: m.key, log: r.log }, 3, 5_000)
588  const board = res.ok ? parseLeaderboard(res.data) : null
589  if (board && owner.base === rt.base) await setView($, { leaderboard: board })
590}
591
592async function handle($: EngineInterface, m: ClientMsg) {
593  if (m.type === 'menu') {
594    if (m.choice === 'leaderboard') return loadLeaderboard($)
595    if (m.choice === 'battle') {
596      const gen = await resetBattle($, true)
597      if (gen === null || (await useServer($, m.server, gen)) === null) return
598
599      return startQueue($)
600    }
601    if (m.choice === 'marathon') return startMarathon($, m.nonce)
602    if (m.choice === 'bot') return startBot($, m.level, m.nonce)
603    await resetBattle($, true)
604
605    return
606  }
607  if (m.type === 'log') return takeLog($, m)
608  if (m.type !== 'sync') return
609  const gen = rt.battleGen
610  const { battle } = await read($, view)
611  if (!isLive(gen) || battle.status !== 'matched') return
612  rt.latest = { snapshot: m.snapshot, isOver: m.isOver }
613  queueAttacks(rt.outbox, m.attacks)
614  if (rt.isSyncing) {
615    rt.wantsSync = true
616
617    return
618  }
619
620  return flushSync($)
621}
622
623// The nudge: one toast per Claude turn that has run two minutes, never over a question dialog.
624function tryNudge($: EngineInterface) {
625  rt.nudge = null
626  if (!rt.isTurnOn || rt.wasNudged) return
627  if (rt.isAsking) {
628    rt.nudge = $.clock.after(NUDGE_RETRY_MS, () => tryNudge($))
629
630    return
631  }
632  rt.wasNudged = true
633  $.ui.toast('Long task. /cg-block-battle while you wait?', { timeoutMs: 8_000 })
634}
635
636export const register: Register = on => {
637  rt.base = OFFICIAL
638
639  on('session.start', async ($, e, next) => {
640    await $.command.register({ name: 'cg-block-battle', description: 'Play Block Battle while Claude works. Add "signout" to sign out' })
641
642    return next(e)
643  })
644
645  on('command.run', { command: 'cg-block-battle' }, async ($, e) => {
646    if (e.args.trim() === 'signout') return { text: await signOut($) }
647    await resetBattle($, true)
648    await setView($, { notice: null })
649    const opened = await $.ui.open({ id: PANE, title: 'Block Battle', focus: true, closeOnEscape: true, holdToasts: true, columns: 66, rows: 46 })
650    if (!opened.isPlaced) return { text: `Block Battle could not be shown here: ${opened.reason}` }
651    // The menu shows the Marathon best, so fetch it now rather than only when Leaderboard is chosen.
652    void prefetchBest($)
653    // The engine refuses $.ui.focus on a Client; only the person's click gives it the keys.
654    return { text: 'Block Battle open. Click the pane to play; q returns to the menu, Esc closes it.' }
655  })
656
657  on('ui.close', async ($, e, next) => {
658    if (e.id === PANE) {
659      stopSignIn()
660      await resetBattle($, true)
661    }
662
663    return next(e)
664  })
665
666  on('ui.message', async ($, e, next) => {
667    if (e.element !== 'game') return next(e)
668    const m = parseClientMsg(e.data)
669    if (m) void handle($, m)
670
671    return next(e)
672  })
673
674  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
675    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
676    const ui = $.ui.resolve(e)
677    if (!rt.hasLoadedLast) {
678      rt.hasLoadedLast = true
679      void loadLast($).catch(() => undefined)
680    }
681    const stored = await read($, view)
682    const v = { ...stored, servers: { ...stored.servers, active: rt.base } }
683
684    // Without a size the region is as tall as what the module draws, so a one-line "too small"
685    // note would measure the region as one line and keep it there.
686    return <ui.Client key="game" module="./game.tsx" props={v} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
687  })
688
689  on('prompt.submit', async ($, e, next) => {
690    if (!rt.isTurnOn) {
691      rt.isTurnOn = true
692      rt.wasNudged = false
693      rt.nudge?.cancel()
694      rt.nudge = $.clock.after(NUDGE_MS, () => tryNudge($))
695    }
696
697    return next(e)
698  })
699
700  on('turn.complete', async ($, e, next) => {
701    if (e.agentId === undefined) {
702      rt.isTurnOn = false
703      rt.nudge?.cancel()
704      rt.nudge = null
705    }
706
707    return next(e)
708  })
709
710  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
711    rt.isAsking = true
712    try {
713      return await next(e)
714    } finally {
715      rt.isAsking = false
716    }
717  })
718}
719
hooks/auth.ts 96 lines
1import { login, withGiveUp } from './net'
2import type { Fetch, GiveUp } from './net'
3
4const DEVICE_URL = 'https://github.com/login/device/code'
5const TOKEN_URL = 'https://github.com/login/oauth/access_token'
6const FORM = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }
7
8export type DeviceCode = { deviceCode: string; userCode: string; uri: string; interval: number; expiresIn: number }
9export type TokenPoll =
10  | { kind: 'token'; token: string }
11  | { kind: 'pending' }
12  | { kind: 'slowDown' }
13  | { kind: 'failed'; reason: 'expired' | 'denied' | 'error' }
14export type Session = { session: string; login: string }
15
16const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
17const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null)
18
19const CLIENT_ID = /^[A-Za-z0-9._-]{1,64}$/
20const SESSION = /^[\x21-\x7e]{1,200}$/
21const never: GiveUp = () => new Promise(() => undefined)
22
23export function parseConfig(data: unknown): string | null {
24  if (!isRecord(data)) return null
25  const id = data.githubClientId
26
27  return typeof id === 'string' && CLIENT_ID.test(id) ? id : null
28}
29
30export const sessionKey = (base: string) => 'session:' + base.replace(/\/+$/, '')
31
32export function parseDeviceCode(data: unknown): DeviceCode | null {
33  if (!isRecord(data)) return null
34  const deviceCode = str(data.device_code)
35  const userCode = str(data.user_code)
36  const uri = str(data.verification_uri)
37  if (!deviceCode || !userCode || !uri) return null
38
39  return {
40    deviceCode,
41    userCode,
42    uri,
43    interval: typeof data.interval === 'number' ? data.interval : 5,
44    expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 900,
45  }
46}
47
48export function parseTokenPoll(data: unknown): TokenPoll {
49  if (!isRecord(data)) return { kind: 'failed', reason: 'error' }
50  const token = str(data.access_token)
51  if (token) return { kind: 'token', token }
52  if (data.error === 'authorization_pending') return { kind: 'pending' }
53  if (data.error === 'slow_down') return { kind: 'slowDown' }
54  if (data.error === 'expired_token') return { kind: 'failed', reason: 'expired' }
55  if (data.error === 'access_denied') return { kind: 'failed', reason: 'denied' }
56
57  return { kind: 'failed', reason: 'error' }
58}
59
60export function parseSession(data: unknown): Session | null {
61  if (!isRecord(data)) return null
62  const session = typeof data.session === 'string' && SESSION.test(data.session) ? data.session : null
63  const name = login(data.login)
64
65  return session && name ? { session, login: name } : null
66}
67
68export async function requestDeviceCode(fetch: Fetch, clientId: string, giveUp: GiveUp = never): Promise<DeviceCode | null> {
69  fetch = withGiveUp(fetch, giveUp)
70  try {
71    const body = new URLSearchParams({ client_id: clientId, scope: '' }).toString()
72    const res = await fetch(DEVICE_URL, { method: 'POST', headers: FORM, body })
73
74    return parseDeviceCode(JSON.parse(res.text))
75  } catch {
76    return null
77  }
78}
79
80// A dropped or timed-out request counts as pending so the next tick asks again.
81export async function pollToken(fetch: Fetch, clientId: string, deviceCode: string, giveUp: GiveUp = never): Promise<TokenPoll> {
82  fetch = withGiveUp(fetch, giveUp)
83  try {
84    const body = new URLSearchParams({
85      client_id: clientId,
86      device_code: deviceCode,
87      grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
88    }).toString()
89    const res = await fetch(TOKEN_URL, { method: 'POST', headers: FORM, body })
90
91    return parseTokenPoll(JSON.parse(res.text))
92  } catch {
93    return { kind: 'pending' }
94  }
95}
96
hooks/draw.ts 299 lines
1import { cellsOf, ghostY } from './engine'
2import type { Active, Cell, Game, Kind } from './engine'
3
4export const COLORS: Record<Kind | 'G', string> = {
5  I: '#00e5ff',
6  O: '#ffd400',
7  T: '#b44cff',
8  S: '#38d64a',
9  Z: '#ff3b3b',
10  J: '#3b6bff',
11  L: '#ff9a1f',
12  G: '#808080',
13}
14
15// The piece colour faded toward the dark board, so ghost and trail are solid blocks rather than
16// shade characters, which most terminal fonts draw as a sparse, broken-looking dot pattern.
17export function fade(hex: string, amount: number): string {
18  const n = parseInt(hex.slice(1), 16)
19  const mix = (c: number, bg: number) => Math.round(bg + (c - bg) * amount)
20  const r = mix((n >> 16) & 255, 0x26)
21  const g = mix((n >> 8) & 255, 0x26)
22  const b = mix(n & 255, 0x26)
23
24  return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')
25}
26
27export const CLEAR_MS = 250
28export const FLASH_MS = 125
29export const TRAIL_MS = 160
30export const LABEL_MS = 1100
31export const LEVEL_MS = 600
32export const GARBAGE_MS = 350
33export const FILL_ROW_MS = 35
34// Past this the fill is full and the label is up: nothing about the game-over screen changes any more.
35export const OVER_FINAL_MS = 700
36
37export const overStep = (overMs: number, dtMs: number): number => (overMs > OVER_FINAL_MS ? overMs : overMs + dtMs)
38
39export type Label = { text: string; color: string; age: number }
40export type Fx = {
41  clear: { rows: number[]; age: number } | null
42  trail: { cols: number[]; from: number; to: number; kind: Kind; age: number } | null
43  labels: Label[]
44  levelUp: number | null
45  garbage: { lines: number; age: number } | null
46}
47export const emptyFx = (): Fx => ({ clear: null, trail: null, labels: [], levelUp: null, garbage: null })
48
49export type Style = { fg?: string; bg?: string; dim?: true; bold?: true; inverse?: true }
50export type Ch = { c: string } & Style
51export type Run = { text: string } & Style
52
53const BOARD_H = 40
54const VISIBLE = 20
55export const FIELD_W = 10
56
57// Layouts, widest first. A tier needs both numbers; below the last one the pane says it is too small.
58// Columns: board column (1 garbage-bar column + framed board) + 1 gap + side column. Battle adds the opponent's board.
59export type Tier = 'big' | 'medium' | 'mini' | 'tiny'
60export const TIERS = {
61  big: { cw: 4, ch: 2, columns: 66, rows: 44, side: 22 },
62  medium: { cw: 2, ch: 1, columns: 48, rows: 25, side: 24 },
63  mini: { cw: 2, ch: 1, columns: 32, rows: 22, side: 10 },
64} as const
65export const OPP_COLUMNS = 13
66// 0 means the region has not been laid out yet (the first frame): draw the smallest thing that cannot overflow.
67export function pickTier(columns: number, rows: number, isBattle: boolean): Tier {
68  const extra = isBattle ? OPP_COLUMNS : 0
69  for (const t of ['big', 'medium'] as const) if (columns >= TIERS[t].columns + extra && rows >= TIERS[t].rows) return t
70  // mini without its side column still needs 21 wide
71  if (columns >= 21 && rows >= TIERS.mini.rows) return 'mini'
72
73  return 'tiny'
74}
75
76const same = (a: Style, b: Style) => a.fg === b.fg && a.bg === b.bg && a.dim === b.dim && a.bold === b.bold && a.inverse === b.inverse
77
78export function runs(row: readonly Ch[]): Run[] {
79  const out: Run[] = []
80  let last: Ch | undefined
81  for (const ch of row) {
82    const { c, ...style } = ch
83    if (last && same(last, ch)) out[out.length - 1]!.text += c
84    else out.push({ text: c, ...style })
85    last = ch
86  }
87
88  return out
89}
90
91const blocks = (text: string, style: Style): Ch[] => [...text].map(c => ({ c, ...style }))
92
93// The engine collapses cleared rows at once; the flash needs them back where they were.
94// New board = n empty rows on top + the survivors, so survivors + full rows at the old indices is the old board.
95export function reconstruct(board: readonly (readonly Cell[])[], rows: readonly number[]): ((Cell | 'F')[])[] | null {
96  const sorted = [...rows].sort((a, b) => a - b)
97  if (sorted.length === 0 || sorted.some((r, i) => !Number.isInteger(r) || r < 0 || r >= BOARD_H || sorted[i - 1] === r)) return null
98  const out: (Cell | 'F')[][] = board.slice(sorted.length).map(r => [...r])
99  for (const r of sorted) out.splice(r, 0, new Array<'F'>(FIELD_W).fill('F'))
100
101  return out.length === BOARD_H ? out : null
102}
103
104export type Line = { text: string; fg?: string; bold?: true; dim?: true; bg?: string }
105
106// A framed box of text as characters: rounded border in `border`, one centred line per entry.
107export function boxChars(lines: readonly Line[], width: number, border: string, bg: string): Ch[][] {
108  const inner = Math.max(0, width - 2)
109  const edge = { fg: border, bg }
110  const rows: Ch[][] = [blocks('╭' + '─'.repeat(inner) + '╮', edge)]
111  for (const l of lines) {
112    const t = l.text.length > inner ? l.text.slice(0, inner) : l.text
113    const left = Math.floor((inner - t.length) / 2)
114    const style: Style = { bg: l.bg ?? bg, ...(l.fg ? { fg: l.fg } : {}), ...(l.bold ? { bold: true as const } : {}), ...(l.dim ? { dim: true as const } : {}) }
115    rows.push([...blocks('│', edge), ...blocks(' '.repeat(left) + t + ' '.repeat(inner - left - t.length), style), ...blocks('│', edge)])
116  }
117  rows.push(blocks('╰' + '─'.repeat(inner) + '╯', edge))
118
119  return rows
120}
121
122const POPUP_BG = '#0b0b12'
123
124// The label as a bordered pill: lit solid when it pops in, plain while it lives, dim as it leaves.
125export function labelBox(label: Label, width: number): Ch[][] {
126  const w = Math.min(width, label.text.length + 4)
127  const line: Line =
128    label.age < 120 ? { text: label.text, fg: '#000000', bg: label.color, bold: true } : { text: label.text, fg: label.color, bold: true, ...(label.age > LABEL_MS * 0.6 ? { dim: true as const } : {}) }
129  const box = boxChars([line], w, label.color, POPUP_BG)
130  if (label.age > LABEL_MS * 0.6) for (const r of box) for (const c of r) c.dim = true
131
132  return box
133}
134
135export type Overlay = { title: string; color: string; lines: readonly string[] }
136
137export function overlayBox(o: Overlay, width: number): Ch[][] {
138  const lines: Line[] = [{ text: '' }, { text: o.title, fg: o.color, bold: true }, { text: '' }, ...o.lines.map((text): Line => ({ text, fg: '#d0d0d8' })), { text: '' }]
139  const w = Math.min(width, Math.max(...lines.map(l => l.text.length)) + 4)
140
141  return boxChars(lines, w, o.color, POPUP_BG)
142}
143
144export function formatMs(ms: number): string {
145  const cs = Math.floor(ms / 10)
146
147  return `${Math.floor(cs / 6000)}:${String(Math.floor(cs / 100) % 60).padStart(2, '0')}.${String(cs % 100).padStart(2, '0')}`
148}
149
150export function vsOverlay(won: boolean, steps: number, ranked: boolean): Overlay {
151  return { title: won ? 'YOU WIN' : 'YOU LOSE', color: won ? '#38d64a' : '#ff3b3b', lines: [won ? `TIME ${formatMs(steps * 16)}` : '', ranked ? '' : 'unranked', 'q menu'] }
152}
153
154const EMPTY_A = '#14141b'
155const EMPTY_B = '#191922'
156
157type Spec = { k: 'solid'; color: string } | { k: 'empty'; x: number; y: number } | { k: 'flash'; color: string } | { k: 'thin' } | { k: 'gap' }
158
159// One text row of one cell; solid cells are one flat colour in every size.
160function rasterCell(s: Spec, cw: number, ch: number, r: number): Ch[] {
161  if (s.k === 'solid') return blocks('█'.repeat(cw), { fg: s.color })
162  if (s.k === 'flash') return blocks('█'.repeat(cw), { fg: s.color, bold: true })
163  if (s.k === 'thin') return r === ch - 1 ? blocks('▂'.repeat(cw), { fg: '#ffffff', dim: true }) : blocks(' '.repeat(cw), {})
164  if (s.k === 'gap') return blocks(' '.repeat(cw), {})
165
166  return blocks(' '.repeat(cw), { bg: (s.x + s.y) % 2 === 0 ? EMPTY_A : EMPTY_B })
167}
168
169export type Size = { cw: number; ch: number }
170export const COMPACT: Size = { cw: 2, ch: 1 }
171
172// Stamp a box of characters onto the grid, clipped to it.
173function stamp(rows: Ch[][], box: readonly Ch[][], x0: number, y0: number) {
174  box.forEach((line, i) => {
175    const row = rows[y0 + i]
176    if (!row) return
177    line.forEach((c, j) => {
178      if (x0 + j >= 0 && x0 + j < row.length) row[x0 + j] = c
179    })
180  })
181}
182
183export function fieldRows(game: Game, fx: Fx, extra: readonly Label[], overMs: number, size: Size = COMPACT, overlay: Overlay | null = null): Ch[][] {
184  const { cw, ch } = size
185  let display: (Cell | 'F')[][] = game.board.map(r => [...r])
186  if (fx.clear) display = reconstruct(game.board, fx.clear.rows) ?? display
187  const clearAge = fx.clear?.age ?? 0
188  const specs: Spec[][] = display.slice(BOARD_H - VISIBLE).map((r, y) =>
189    r.map((cell, x): Spec => {
190      if (cell === 'F') {
191        if (clearAge < FLASH_MS) return { k: 'flash', color: Math.floor(clearAge / 40) % 2 === 0 ? '#ffffff' : '#9fe8ff' }
192        if (clearAge < 190) return { k: 'thin' }
193
194        return { k: 'gap' }
195      }
196      if (cell === null) return { k: 'empty', x, y }
197      if (cell === 'G') {
198        const isRising = fx.garbage && y >= VISIBLE - fx.garbage.lines && fx.garbage.age < GARBAGE_MS
199
200        return { k: 'solid', color: isRising ? '#ff6b6b' : COLORS.G }
201      }
202
203      return { k: 'solid', color: COLORS[cell] }
204    }),
205  )
206  const put = (x: number, y: number, color: string) => {
207    const row = specs[y - (BOARD_H - VISIBLE)]
208    if (row && x >= 0 && x < FIELD_W) row[x] = { k: 'solid', color }
209  }
210  const trail = () => {
211    if (!fx.trail || fx.trail.age >= TRAIL_MS) return
212    const { cols, from, to, kind } = fx.trail
213    for (const x of cols) for (let y = from; y < to; y++) put(x, y, fade(COLORS[kind], 0.15))
214  }
215
216  if (game.active && !game.isOver) {
217    const a: Active = game.active
218    const gy = ghostY(game)
219    if (gy !== null) for (const [x, y] of cellsOf({ ...a, y: gy })) put(x, y, fade(COLORS[a.kind], 0.28))
220    trail()
221    for (const [x, y] of cellsOf(a)) put(x, y, COLORS[a.kind])
222  } else trail()
223
224  // Game over: the pieces drain to their faded tint from the bottom up, and the board stays readable.
225  if (overMs > 0) {
226    const filled = Math.min(VISIBLE, Math.floor(overMs / FILL_ROW_MS))
227    for (let y = VISIBLE - filled; y < VISIBLE; y++) {
228      specs[y] = specs[y]!.map(s => (s.k === 'solid' ? { k: 'solid', color: fade(s.color, 0.3) } : s))
229    }
230  }
231
232  const rows: Ch[][] = specs.flatMap(row => Array.from({ length: ch }, (_, r) => row.flatMap(s => rasterCell(s, cw, ch, r))))
233  const width = FIELD_W * cw
234  const pills = [...extra, ...[...fx.labels].reverse()].slice(0, 4)
235  pills.forEach((label, i) => {
236    const box = labelBox(label, width)
237    stamp(rows, box, Math.floor((width - (box[0]?.length ?? 0)) / 2), 4 * ch + i * 3)
238  })
239  if (overlay) {
240    const box = overlayBox(overlay, width)
241    stamp(rows, box, Math.floor((width - (box[0]?.length ?? 0)) / 2), Math.floor((rows.length - box.length) / 2))
242  }
243
244  return rows
245}
246
247// A piece at rotation 0 centred in a `width`-wide block, two piece rows tall, in the given cell size.
248export function previewRows(kind: Kind, size: Size, width: number, isDim = false): Ch[][] {
249  const { cw, ch } = size
250  const cells = cellsOf({ kind, rotation: 0, x: 0, y: 0 })
251  const minX = Math.min(...cells.map(([x]) => x))
252  const maxX = Math.max(...cells.map(([x]) => x))
253  const minY = Math.min(...cells.map(([, y]) => y))
254  const left = Math.floor((width - (maxX - minX + 1) * cw) / 2)
255  const rows: Ch[][] = Array.from({ length: 2 * ch }, () => blocks(' '.repeat(width), {}))
256  for (const [x, y] of cells) {
257    for (let r = 0; r < ch; r++) {
258      const row = rows[(y - minY) * ch + r]
259      if (row) row.splice(left + (x - minX) * cw, cw, ...rasterCell({ k: 'solid', color: COLORS[kind] }, cw, ch, r).map(c => (isDim ? { ...c, dim: true as const } : c)))
260    }
261  }
262
263  return rows
264}
265
266export const blankRows = (n: number, width: number): Ch[][] => Array.from({ length: n }, () => blocks(' '.repeat(width), {}))
267
268const KINDS = 'IOTSZJL'
269
270// One character per cell: the opponent's 20 x 10 visible board as the server relays it.
271export function snapshotRows(snapshot: string): Ch[][] {
272  const flat = [...snapshot.replace(/\s+/g, '')]
273  const rows: Ch[][] = []
274  for (let y = 0; y < VISIBLE; y++) {
275    const row: Ch[] = []
276    for (let x = 0; x < FIELD_W; x++) {
277      const c = flat[y * FIELD_W + x] ?? '.'
278      if (c === '.' || c === '0' || c === '-' || c === '_') row.push({ c: ' ', bg: (x + y) % 2 === 0 ? EMPTY_A : EMPTY_B })
279      else row.push({ c: '█', fg: KINDS.includes(c) ? COLORS[c as Kind] : COLORS.G })
280    }
281    rows.push(row)
282  }
283
284  return rows
285}
286
287// Block-letter title, five rows of five-wide glyphs.
288const FONT: Record<string, readonly string[]> = {
289  A: [' ███ ', '█   █', '█████', '█   █', '█   █'],
290  B: ['████ ', '█   █', '████ ', '█   █', '████ '],
291  C: [' ████', '█    ', '█    ', '█    ', ' ████'],
292  E: ['█████', '█    ', '████ ', '█    ', '█████'],
293  K: ['█   █', '█  █ ', '███  ', '█  █ ', '█   █'],
294  L: ['█    ', '█    ', '█    ', '█    ', '█████'],
295  O: [' ███ ', '█   █', '█   █', '█   █', ' ███ '],
296  T: ['█████', '  █  ', '  █  ', '  █  ', '  █  '],
297}
298export const bigWord = (word: string): string[] => [0, 1, 2, 3, 4].map(r => [...word].map(c => FONT[c]![r]!).join(' '))
299
hooks/net.ts 248 lines
1import type { HttpInit, HttpResponse } from 'claude-code'
2
3import { CHUNK, MAX_STEPS } from './log'
4import type { LogMsg } from './log'
5import type { BotLevel, BotRow, ClientMsg, Incoming, Leaderboard, Online, Opponent } from '../types'
6
7export type Fetch = (url: string, init?: HttpInit) => Promise<HttpResponse>
8
9export type GiveUp = (signal: AbortSignal) => Promise<void>
10
11// The returned fetch rejects once giveUp resolves, and cancels giveUp once the request settles.
12export const withGiveUp =
13  (fetch: Fetch, giveUp: GiveUp): Fetch =>
14  async (url, init) => {
15    const stop = new AbortController()
16    const timedOut = giveUp(stop.signal).then(
17      () => Promise.reject(new Error('timed out')),
18      () => new Promise<never>(() => undefined),
19    )
20    try {
21      return await Promise.race([fetch(url, init), timedOut])
22    } finally {
23      stop.abort()
24    }
25  }
26
27export type Reply<T> = { ok: true; data: T } | { ok: false; status: number; error: string }
28
29// The server's cap on garbage in one log, so also the most a room can deliver.
30export const MAX_INCOMING = 10_000
31
32export const PROTOCOL_VERSION = 3
33const KEY = /^[A-Za-z0-9_-]{1,64}$/
34// 200,000 input pairs and 10,000 garbage pairs, the server's caps.
35const MAX_STREAM = 420_000
36
37export function serverUrlOk(u: string): boolean {
38  if (u.length > 200) return false
39  try {
40    const url = new URL(u)
41    return url.protocol === 'https:' || (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1'))
42  } catch {
43    return false
44  }
45}
46
47export function buildRequest(
48  base: string,
49  method: 'GET' | 'POST' | 'DELETE',
50  path: string,
51  token: string | null,
52  body?: unknown,
53): { url: string; init: HttpInit } {
54  const headers: Record<string, string> = {
55    ...(token === null ? {} : { Authorization: `Bearer ${token}` }),
56    Accept: 'application/json',
57    'X-Protocol-Version': String(PROTOCOL_VERSION),
58  }
59  if (body !== undefined) headers['Content-Type'] = 'application/json'
60
61  return {
62    url: base.replace(/\/+$/, '') + path,
63    init: { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
64  }
65}
66
67export async function call(
68  fetch: Fetch,
69  base: string,
70  method: 'GET' | 'POST' | 'DELETE',
71  path: string,
72  token: string | null,
73  body?: unknown,
74  giveUp: GiveUp = () => new Promise(() => undefined),
75): Promise<Reply<unknown>> {
76  const { url, init } = buildRequest(base, method, path, token, body)
77  try {
78    const res = await withGiveUp(fetch, giveUp)(url, init)
79    let data: unknown = null
80    try {
81      data = JSON.parse(res.text)
82    } catch {
83      // a non-JSON body (a proxy error page) is handled below by status
84    }
85    if (res.ok) return { ok: true, data }
86    const error = (data as { error?: unknown } | null)?.error
87
88    return { ok: false, status: res.status, error: typeof error === 'string' ? cleanText(error) : `HTTP ${res.status}` }
89  } catch {
90    return { ok: false, status: 0, error: 'unreachable' }
91  }
92}
93
94const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
95const LOGIN = /^[A-Za-z0-9-]{1,39}$/
96const SNAPSHOT = /^[.IOTSZJLG]{0,400}$/
97export const login = (v: unknown): string | null => (typeof v === 'string' && LOGIN.test(v) ? v : null)
98const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
99const count = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
100export const cleanText = (s: string) => s.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, '').slice(0, 120)
101
102const isLevel = (v: unknown): v is BotLevel => v === 'easy' || v === 'medium' || v === 'hard'
103
104const botRows = (v: unknown): BotRow[] =>
105  (Array.isArray(v) ? v : [])
106    .filter(isRecord)
107    .filter(r => login(r.login) && count(r.ms) && count(r.at))
108    .map(r => ({ login: r.login as string, ms: r.ms as number, at: r.at as number }))
109    .slice(0, 5)
110
111// A server older than 1.1.0 sends no `bot`; its boards then read as empty.
112export function parseLeaderboard(data: unknown): Leaderboard | null {
113  if (!isRecord(data) || !Array.isArray(data.marathon) || !Array.isArray(data.wins)) return null
114  const marathon = data.marathon
115    .filter(isRecord)
116    .filter(r => login(r.login) && count(r.score) && count(r.lines) && count(r.level) && count(r.at))
117    .map(r => ({ login: r.login as string, score: r.score as number, lines: r.lines as number, level: r.level as number, at: r.at as number }))
118  const wins = data.wins
119    .filter(isRecord)
120    .filter(r => login(r.login) && count(r.wins))
121    .map(r => ({ login: r.login as string, wins: r.wins as number }))
122  const bot = isRecord(data.bot) ? data.bot : {}
123
124  return { marathon: marathon.slice(0, 5), wins: wins.slice(0, 5), bot: { easy: botRows(bot.easy), medium: botRows(bot.medium), hard: botRows(bot.hard) } }
125}
126
127export type QueueReply = (
128  | { status: 'waiting' }
129  | { status: 'matched'; roomId: string; seed: number; opponent: string }
130) & { online: Online | null }
131
132const MAX_ONLINE = 100_000
133
134// Servers before 1.3.0 send no counts.
135function parseOnline(v: unknown): Online | null {
136  if (!isRecord(v) || !count(v.playing) || !count(v.looking)) return null
137
138  return { playing: Math.min(v.playing, MAX_ONLINE), looking: Math.min(v.looking, MAX_ONLINE) }
139}
140
141export function parseQueue(data: unknown): QueueReply | null {
142  if (!isRecord(data)) return null
143  const online = parseOnline(data.online)
144  if (data.status === 'waiting') return { status: 'waiting', online }
145  if (data.status === 'matched' && typeof data.roomId === 'string' && KEY.test(data.roomId) && count(data.seed)) {
146    const opp = isRecord(data.opponent) ? login(data.opponent.login) : null
147    if (opp) return { status: 'matched', roomId: data.roomId, seed: data.seed, opponent: opp, online }
148  }
149
150  return null
151}
152
153export function parseMarathonStart(data: unknown): { gameId: string; seed: number } | null {
154  if (!isRecord(data) || typeof data.gameId !== 'string' || !KEY.test(data.gameId)) return null
155  const { seed } = data
156
157  return count(seed) && seed < 2 ** 32 ? { gameId: data.gameId, seed } : null
158}
159
160export type SyncReply = { opponent: Opponent | null; incoming: Incoming[]; ended: boolean; winner: string | null }
161
162export function parseSync(data: unknown): SyncReply | null {
163  if (!isRecord(data)) return null
164  const o = isRecord(data.opponent) ? data.opponent : null
165  const snapshot = typeof o?.snapshot === 'string' ? o.snapshot : ''
166  const name = login(o?.login)
167  const opp = o && name && SNAPSHOT.test(snapshot) ? { login: name, snapshot, isOver: o.isOver === true } : null
168  const incoming = Array.isArray(data.incoming)
169    ? data.incoming
170        .slice(0, MAX_INCOMING)
171        .filter(isRecord)
172        .filter(a => count(a.id) && (a.id as number) >= 1 && Number.isInteger(a.lines) && (a.lines as number) >= 1 && (a.lines as number) <= 40)
173        .map(a => ({ id: a.id as number, lines: a.lines as number }))
174    : []
175  const ended = isRecord(data.result)
176  const winner = ended ? login((data.result as Record<string, unknown>).winner) : null
177
178  return { opponent: opp, incoming, ended, winner }
179}
180
181// The server may redeliver a batch when a poll is retried; the id is the identity, so keep the first copy of each.
182// Stops at MAX_INCOMING: past that the log could not hold them anyway.
183export function mergeIncoming(have: readonly Incoming[], got: readonly Incoming[]): Incoming[] {
184  const seen = new Set(have.map(a => a.id))
185  const fresh = got.filter(a => !seen.has(a.id) && seen.add(a.id))
186
187  return [...have, ...fresh.slice(0, MAX_INCOMING - have.length)]
188}
189
190// Attacks go out in a payload with a seq. A failed send is retried with the same seq and the
191// same attacks, which the server treats as idempotent; attacks made meanwhile wait in `queued`.
192export type Outbox = {
193  seq: number
194  inflight: { seq: number; attacks: number[] } | null
195  queued: number[]
196}
197
198export const emptyOutbox = (): Outbox => ({ seq: 0, inflight: null, queued: [] })
199
200export function nextPayload(box: Outbox): { seq: number; attacks: number[] } {
201  if (box.inflight) return box.inflight
202  const attacks = box.queued.splice(0, 20)
203  box.seq += 1
204  box.inflight = { seq: box.seq, attacks }
205
206  return box.inflight
207}
208
209export function queueAttacks(box: Outbox, lines: readonly number[]): void {
210  for (const n of lines) if (Number.isInteger(n) && n >= 1) box.queued.push(Math.min(40, n))
211}
212
213// A Client posts plain data the hooks side must treat as input, not fact.
214export function parseClientMsg(data: unknown): ClientMsg | null {
215  if (!isRecord(data)) return null
216  if (data.type === 'menu') {
217    const c = data.choice
218    if (c === 'battle') return typeof data.server === 'string' && serverUrlOk(data.server) ? { type: 'menu', choice: c, server: data.server } : null
219    if (c === 'bot') return isLevel(data.level) && count(data.nonce) ? { type: 'menu', choice: c, level: data.level, nonce: data.nonce } : null
220    if (c === 'marathon') return count(data.nonce) ? { type: 'menu', choice: c, nonce: data.nonce } : null
221
222    return c === 'leaderboard' || c === 'back' ? { type: 'menu', choice: c } : null
223  }
224  if (data.type === 'log') return parseLogMsg(data)
225  if (data.type === 'sync') {
226    const { snapshot, isOver, attacks } = data
227    if (typeof snapshot !== 'string' || snapshot.length > 400 || typeof isOver !== 'boolean') return null
228    if (!Array.isArray(attacks) || !attacks.every(a => Number.isInteger(a))) return null
229
230    return { type: 'sync', seq: num(data.seq), attacks: attacks as number[], snapshot, isOver }
231  }
232
233  return null
234}
235
236function parseLogMsg(d: Record<string, unknown>): LogMsg | null {
237  const { kind, key, steps, inputsLen, total, at, values } = d
238  if (kind !== 'marathon' && kind !== 'battle' && kind !== 'bot') return null
239  if (typeof key !== 'string' || !KEY.test(key)) return null
240  if (!count(steps) || steps < 1 || steps > MAX_STEPS) return null
241  if (!count(total) || total > MAX_STREAM || total % 2 !== 0) return null
242  if (!count(inputsLen) || inputsLen % 2 !== 0 || inputsLen > total) return null
243  if (!count(at) || at > total) return null
244  if (!Array.isArray(values) || values.length > CHUNK || at + values.length > total || !values.every(count)) return null
245
246  return { type: 'log', kind, key, steps, inputsLen, total, at, values: values as number[] }
247}
248
hooks/log.ts 122 lines
1import type { ClientMsg } from '../types'
2import type { Input } from './engine'
3
4export const STEP_MS = 16
5export const MAX_STEPS = 450_000
6const MAX_INPUTS = 200_000
7const MAX_GARBAGE = 10_000
8// Steps run in one tick at most; any further debt carries to later ticks.
9const MAX_CATCH_UP = 15
10export const CHUNK = 10_000
11
12// The server's list (claude-games-server src/protocol.ts). A logged code is an index here.
13export const INPUT_NAMES = [
14  'left', 'right', 'softDropOn', 'softDropOff', 'softDropStep', 'hardDrop', 'rotateCW', 'rotateCCW', 'rotate180', 'hold',
15] as const satisfies readonly Input[]
16
17export type GameLog = { steps: number; inputs: number[]; garbage?: number[] }
18
19// Kept in wire form as the game runs: flat [stepDelta, value] pairs, each delta from the previous pair.
20export type Recorder = { steps: number; inputs: number[]; garbage: number[]; lastInput: number; lastGarbage: number; isFull: boolean }
21
22export const newRecorder = (): Recorder => ({ steps: 0, inputs: [], garbage: [], lastInput: 0, lastGarbage: 0, isFull: false })
23
24// Called before the step it lands on runs, as the server applies it.
25export function recordGarbage(rec: Recorder, id: number): void {
26  if (rec.isFull) return
27  if (rec.garbage.length >= MAX_GARBAGE * 2) {
28    rec.isFull = true
29    return
30  }
31  rec.garbage.push(rec.steps - rec.lastGarbage, id)
32  rec.lastGarbage = rec.steps
33}
34
35// A full recorder stops counting, so what it holds is still a log the server can replay.
36export function recordStep(rec: Recorder, inputs: readonly Input[]): void {
37  if (rec.isFull) return
38  if (rec.inputs.length + inputs.length * 2 > MAX_INPUTS * 2) {
39    rec.isFull = true
40    return
41  }
42  for (const i of inputs) {
43    rec.inputs.push(rec.steps - rec.lastInput, INPUT_NAMES.indexOf(i))
44    rec.lastInput = rec.steps
45  }
46  rec.steps++
47  if (rec.steps >= MAX_STEPS) rec.isFull = true
48}
49
50// Steps due = base + max(ticks fired, real time / STEP_MS) - steps run. Ticks keep the test kit's frame
51// clock exact, real time catches up after a host stalls; neither runs ahead of real time on a real host.
52export type Pace = { base: number; ran: number; ticks: number; startAt: number }
53
54export const newPace = (at: number, ran = 0): Pace => ({ base: ran, ran, ticks: 0, startAt: at })
55
56export function stepsDue(p: Pace, at: number): number {
57  p.ticks++
58  const target = p.base + Math.max(p.ticks, Math.floor((at - p.startAt) / STEP_MS))
59  const n = Math.max(0, Math.min(MAX_CATCH_UP, target - p.ran))
60  p.ran += n
61
62  return n
63}
64
65export type Upload = { kind: 'marathon' | 'battle' | 'bot'; key: string; steps: number; inputsLen: number; stream: number[] }
66
67export function toUpload(kind: Upload['kind'], key: string, rec: Recorder): Upload | null {
68  if (rec.steps < 1) return null
69
70  return { kind, key, steps: rec.steps, inputsLen: rec.inputs.length, stream: [...rec.inputs, ...rec.garbage] }
71}
72
73const RESEND_MS = 250
74// About 10 s of re-sends with no progress: the hooks will never take this upload.
75const MAX_STALLS = 40
76
77export type Sender = { lastAck: number; waitMs: number; stalls: number }
78
79export const newSender = (): Sender => ({ lastAck: -1, waitMs: 0, stalls: 0 })
80
81// Called once per tick with the position the hooks acknowledged for the upload being sent.
82export function nextSend(s: Sender, have: number): 'wait' | 'send' | 'drop' {
83  s.waitMs += STEP_MS
84  if (have === s.lastAck && s.waitMs < RESEND_MS) return 'wait'
85  s.stalls = have === s.lastAck ? s.stalls + 1 : 0
86  if (s.stalls >= MAX_STALLS) return 'drop'
87  s.lastAck = have
88  s.waitMs = 0
89
90  return 'send'
91}
92
93export type LogMsg = Extract<ClientMsg, { type: 'log' }>
94
95export const chunkAt = (u: Upload, at: number): LogMsg => ({
96  type: 'log', kind: u.kind, key: u.key, steps: u.steps, inputsLen: u.inputsLen, total: u.stream.length, at, values: u.stream.slice(at, at + CHUNK),
97})
98
99export type Assembly = { kind: Upload['kind']; key: string; steps: number; inputsLen: number; total: number; values: number[] }
100
101const whole = (v: number) => Number.isSafeInteger(v) && v >= 0
102
103// Appends a chunk only at the position already held; anything else is answered with that position
104// so the Client re-sends from there.
105export function takeChunk(cur: Assembly | null, m: LogMsg): { asm: Assembly | null; have: number; log: GameLog | null } {
106  if (!m.values.every(whole)) return { asm: cur, have: cur?.key === m.key ? cur.values.length : 0, log: null }
107  let asm = cur
108  if (!asm || asm.key !== m.key || asm.kind !== m.kind) {
109    if (m.at !== 0) return { asm: cur, have: 0, log: null }
110    asm = { kind: m.kind, key: m.key, steps: m.steps, inputsLen: m.inputsLen, total: m.total, values: [] }
111  }
112  if (m.at === asm.values.length && asm.values.length + m.values.length <= asm.total) {
113    asm = { ...asm, values: [...asm.values, ...m.values] }
114  }
115  const have = asm.values.length
116  if (have < asm.total) return { asm, have, log: null }
117  const garbage = asm.values.slice(asm.inputsLen)
118  const log: GameLog = { steps: asm.steps, inputs: asm.values.slice(0, asm.inputsLen), ...(garbage.length > 0 ? { garbage } : {}) }
119
120  return { asm, have, log }
121}
122
hooks/game.tsx 1004 lines
1import type { ClientModule, ClientSurface, RenderChildren } from 'claude-code'
2
3import type { BotLevel, ClientMsg, GameView } from '../types'
4import { CLEAR_MS, COMPACT, FIELD_W, GARBAGE_MS, LABEL_MS, LEVEL_MS, OVER_FINAL_MS, TIERS, TRAIL_MS, bigWord, blankRows, emptyFx, fieldRows, formatMs, overStep, pickTier, previewRows, runs, snapshotRows, vsOverlay } from './draw'
5import type { Ch, Fx, Label, Overlay, Size } from './draw'
6import { newGame, receiveGarbage, snapshot, step } from './engine'
7import type { Game, GameEvent, Input, Mode } from './engine'
8import { STEP_MS, chunkAt, newPace, newRecorder, newSender, nextSend, recordGarbage, recordStep, stepsDue, toUpload } from './log'
9import type { LogMsg, Pace, Recorder, Sender, Upload } from './log'
10import { newMatch, stepMatch } from './match.ts'
11import type { Match } from './match.ts'
12import { serverUrlOk } from './net'
13
14// Rows per down press; tuned by feel (1 was too slow). A step stops at the floor, so more never locks.
15const SOFT_DROP_ROWS = 2
16const SYNC_MS = 200
17const START_WAIT_MS = 3_000
18
19type Screen = 'menu' | 'servers' | 'lobby' | 'play' | 'leaderboard' | 'starting' | 'levels'
20
21type Vs = { level: BotLevel; match: Match; gameId: string | null; snap: string }
22
23// Everything mutable lives here, so the 16 ms tick changes it in place and asks for a redraw only when
24// something visible moved. `rev` is what setState carries to make the engine call the module again.
25type Live = {
26  props: GameView
27  rev: number
28  screen: Screen
29  menu: number
30  mode: Mode
31  game: Game | null
32  inputs: Input[]
33  t: number
34  pace: Pace
35  rec: Recorder
36  gameId: string | null
37  nonce: number
38  waitMs: number
39  waitAt: number
40  uploads: Upload[]
41  sender: Sender
42  hasSentLog: boolean
43  isPaused: boolean
44  fx: Fx
45  overMs: number
46  // How many of the room's incoming attacks were taken; the list only grows.
47  applied: number
48  outbox: ClientMsg[]
49  attacks: number[]
50  syncMs: number
51  seq: number
52  roomId: string | null
53  hasPostedOver: boolean
54  result: 'win' | 'loss' | null
55  isDirty: boolean
56  hasKeyed: boolean
57  pick: number
58  // The address being typed on the server screen, or null while choosing from the list.
59  typing: string | null
60  typeError: string | null
61  vs: Vs | null
62  // Set while the Starting screen waits on a Vs Bot game rather than a Marathon one.
63  startingBot: BotLevel | null
64  // The highest props.vsbot nonce already adopted or closed.
65  botSeen: number
66  lbPage: 0 | 1
67}
68
69const MENU = [
70  { label: 'Marathon', hint: 'solo, endless levels' },
71  { label: 'Vs Bot', hint: 'solo, against a bot' },
72  { label: 'Battle', hint: 'online 1v1' },
73  { label: 'Leaderboard', hint: 'top 5' },
74] as const
75
76const BOT_LEVELS = [
77  { id: 'easy', name: 'Merge Conflict (easy)' },
78  { id: 'medium', name: 'Hotfix in Prod (medium)' },
79  { id: 'hard', name: 'Deploy on Friday (hard)' },
80] as const satisfies readonly { id: BotLevel; name: string }[]
81
82const emptyProps = (): GameView => ({
83  me: null,
84  leaderboard: null,
85  notice: null,
86  battle: { status: 'idle', roomId: null, seed: 0, opponent: null, incoming: [], result: null, online: null },
87  servers: { home: '', last: null, active: '' },
88  marathon: null,
89  vsbot: null,
90  uploaded: null,
91})
92
93const newLive = (props: GameView): Live => ({
94  props, rev: 0, screen: 'menu', menu: 0, mode: 'marathon', game: null, inputs: [], t: 0,
95  pace: newPace(0), rec: newRecorder(), gameId: null, nonce: 0, waitMs: 0, waitAt: 0, uploads: [], sender: newSender(), hasSentLog: false,
96  isPaused: false, fx: emptyFx(), overMs: 0, applied: 0, outbox: [], attacks: [], syncMs: 0, seq: 0, roomId: null,
97  hasPostedOver: false, result: null, isDirty: true, hasKeyed: false, pick: 0, typing: null, typeError: null,
98  vs: null, startingBot: null, botSeen: 0, lbPage: 0,
99})
100
101const CLEAR_NAMES: Record<string, string> = {
102  single: 'SINGLE', double: 'DOUBLE', triple: 'TRIPLE', quad: 'QUAD',
103  tspinMini: 'T-SPIN MINI', tspinMiniSingle: 'T-SPIN MINI SINGLE', tspinMiniDouble: 'T-SPIN MINI DOUBLE',
104  tspin: 'T-SPIN', tspinSingle: 'T-SPIN SINGLE', tspinDouble: 'T-SPIN DOUBLE', tspinTriple: 'T-SPIN TRIPLE',
105}
106
107function pushLabel(fx: Fx, text: string, color: string) {
108  fx.labels = [...fx.labels, { text, color, age: 0 }].slice(-3)
109}
110
111function applyEvents(live: Live, before: Game, events: readonly GameEvent[]) {
112  const { fx } = live
113  for (const ev of events) {
114    if (ev.type === 'lineClear') {
115      fx.clear = { rows: ev.rows, age: 0 }
116      if (ev.perfectClear) pushLabel(fx, 'PERFECT CLEAR', '#ffffff')
117      pushLabel(fx, CLEAR_NAMES[ev.kind] ?? String(ev.kind).toUpperCase(), ev.kind === 'quad' ? '#00e5ff' : ev.kind.startsWith('tspin') ? '#b44cff' : '#ffd400')
118      if (ev.b2b) pushLabel(fx, 'BACK-TO-BACK', '#ff9a1f')
119      if (ev.combo >= 1) pushLabel(fx, `COMBO x${ev.combo}`, '#38d64a')
120      if (live.mode === 'battle' && !live.vs && ev.attack > 0) live.attacks.push(ev.attack)
121    } else if (ev.type === 'hardDrop') {
122      const kind = before.active?.kind
123      if (kind && Array.isArray(ev.columns)) fx.trail = { cols: ev.columns, from: ev.from, to: ev.to, kind, age: 0 }
124    } else if (ev.type === 'garbageIn') {
125      fx.garbage = { lines: ev.lines, age: 0 }
126    } else if (ev.type === 'levelUp') {
127      fx.levelUp = 0
128      pushLabel(fx, `LEVEL ${ev.level}`, '#ffd400')
129    }
130  }
131}
132
133function ageFx(fx: Fx, dt: number): boolean {
134  let isLive = false
135  const grow = <T extends { age: number }>(x: T | null, limit: number): T | null => {
136    if (!x) return null
137    const next = { ...x, age: x.age + dt }
138    isLive = true
139
140    return next.age >= limit ? null : next
141  }
142  fx.clear = grow(fx.clear, CLEAR_MS)
143  fx.trail = grow(fx.trail, TRAIL_MS)
144  fx.garbage = grow(fx.garbage, GARBAGE_MS)
145  if (fx.levelUp !== null) {
146    isLive = true
147    fx.levelUp += dt
148    if (fx.levelUp >= LEVEL_MS) fx.levelUp = null
149  }
150  if (fx.labels.length > 0) {
151    isLive = true
152    fx.labels = fx.labels.map(l => ({ ...l, age: l.age + dt })).filter(l => l.age < LABEL_MS)
153  }
154
155  return isLive
156}
157
158function startGame(live: Live, mode: Mode, seed: number) {
159  live.screen = 'play'
160  live.mode = mode
161  live.vs = null
162  live.game = newGame(mode, seed)
163  live.inputs = []
164  live.isPaused = false
165  live.fx = emptyFx()
166  live.overMs = 0
167  live.attacks = []
168  live.syncMs = 0
169  live.hasPostedOver = false
170  live.result = null
171  live.pace = newPace(performance.now())
172  live.rec = newRecorder()
173  live.hasSentLog = false
174  live.isDirty = true
175}
176
177const randomSeed = () => Math.floor(Math.random() * 0x100000000)
178
179function askMarathon(live: Live) {
180  live.screen = 'starting'
181  live.startingBot = null
182  live.nonce++
183  live.waitMs = 0
184  live.waitAt = performance.now()
185  live.outbox.push({ type: 'menu', choice: 'marathon', nonce: live.nonce })
186  live.isDirty = true
187}
188
189function askBot(live: Live, level: BotLevel) {
190  live.screen = 'starting'
191  live.startingBot = level
192  live.nonce++
193  live.waitMs = 0
194  live.waitAt = performance.now()
195  live.outbox.push({ type: 'menu', choice: 'bot', level, nonce: live.nonce })
196  live.isDirty = true
197}
198
199function startVs(live: Live, level: BotLevel, gameId: string | null, seed: number) {
200  startGame(live, 'battle', seed)
201  live.startingBot = null
202  live.roomId = null
203  live.gameId = null
204  const match = newMatch(seed, level)
205  live.vs = { level, match, gameId, snap: '' }
206  live.game = match.me
207}
208
209// Closes a ranked game this Client will not play: the server counts it as a loss.
210function forfeit(live: Live, gameId: string) {
211  const rec = newRecorder()
212  recordStep(rec, [])
213  const u = toUpload('bot', gameId, rec)
214  if (u) live.uploads.push(u)
215}
216
217function queueUpload(live: Live, kind: Upload['kind']) {
218  const key = kind === 'marathon' ? live.gameId : kind === 'bot' ? (live.vs?.gameId ?? null) : live.roomId
219  if (key === null) return
220  const u = toUpload(kind, key, live.rec)
221  if (u) live.uploads.push(u)
222}
223
224// One chunk at a time: the next goes when the hooks' acknowledged position moves, or again after a
225// pause if it did not. Done once the hooks hold the whole stream for its key, or dropped if they never move.
226function nextChunk(live: Live): LogMsg | null {
227  const u = live.uploads[0]
228  if (!u) return null
229  const ack = live.props.uploaded
230  const isAcked = ack !== null && ack.key === u.key
231  const have = isAcked ? ack.have : 0
232  const next = isAcked && have >= u.stream.length ? 'drop' : nextSend(live.sender, have)
233  if (next === 'drop') {
234    live.uploads.shift()
235    live.sender = newSender()
236    return nextChunk(live)
237  }
238  return next === 'send' ? chunkAt(u, have) : null
239}
240
241function leave(live: Live) {
242  if (live.mode === 'marathon' && live.game && !live.game.isOver) queueUpload(live, 'marathon')
243  if (live.vs && !live.result) queueUpload(live, 'bot')
244  live.vs = null
245  live.screen = 'menu'
246  live.game = null
247  live.result = null
248  live.outbox.push({ type: 'menu', choice: 'back' })
249  live.isDirty = true
250}
251
252type ServerChoice = { label: string; url: string | null }
253
254function serverChoices(live: Live): ServerChoice[] {
255  const { home, last } = live.props.servers
256  const list: ServerChoice[] = [
257    { label: 'Official server', url: home },
258    { label: 'Enter a server address...', url: null },
259  ]
260  if (last && last !== home) list.push({ label: `${hostOf(last)}  (last used)`, url: last })
261
262  return list
263}
264
265const hostOf = (url: string) => {
266  try {
267    return new URL(url).host
268  } catch {
269    return url
270  }
271}
272
273function playOn(live: Live, server: string) {
274  live.screen = 'lobby'
275  live.typing = null
276  live.outbox.push({ type: 'menu', choice: 'battle', server: server.replace(/\/+$/, '') })
277}
278
279function onServerKey(live: Live, key: string) {
280  if (live.typing !== null) {
281    if (key === 'return') {
282      const url = live.typing.trim()
283      if (serverUrlOk(url)) playOn(live, url)
284      else live.typeError = 'The address must start with https://'
285    } else if (key === 'backspace' || key === 'delete') live.typing = live.typing.slice(0, -1)
286    else if (key === 'up' || key === 'down') live.typing = null
287    else if (key.length === 1 && key >= ' ' && live.typing.length < 200) live.typing += key
288    if (key !== 'return') live.typeError = null
289
290    return
291  }
292  const choices = serverChoices(live)
293  const k = key.length === 1 && key !== ' ' ? key.toLowerCase() : key
294  if (k === 'up' || k === 'k') live.pick = (live.pick + choices.length - 1) % choices.length
295  else if (k === 'down' || k === 'j') live.pick = (live.pick + 1) % choices.length
296  else if (k === 'q') live.screen = 'menu'
297  else if (k === 'return' || k === ' ') {
298    const c = choices[live.pick]!
299    if (c.url === null) live.typing = ''
300    else playOn(live, c.url)
301  }
302}
303
304function onKey(live: Live, key: string) {
305  live.hasKeyed = true
306  const k = key.length === 1 && key !== ' ' ? key.toLowerCase() : key
307  if (live.screen === 'menu') {
308    if (k === 'up' || k === 'k') live.menu = (live.menu + MENU.length - 1) % MENU.length
309    else if (k === 'down' || k === 'j') live.menu = (live.menu + 1) % MENU.length
310    else if (k === 'return' || k === ' ') {
311      if (live.menu === 0) askMarathon(live)
312      else if (live.menu === 1) {
313        live.screen = 'levels'
314        live.pick = 0
315      } else if (live.menu === 2) {
316        live.screen = 'servers'
317        live.pick = 0
318        live.typing = null
319        live.typeError = null
320      } else {
321        live.screen = 'leaderboard'
322        live.lbPage = 0
323        live.outbox.push({ type: 'menu', choice: 'leaderboard' })
324      }
325    }
326    live.isDirty = true
327
328    return
329  }
330  if (live.screen === 'servers') {
331    onServerKey(live, key)
332    live.isDirty = true
333
334    return
335  }
336  if (live.screen === 'levels') {
337    if (k === 'up' || k === 'k') live.pick = (live.pick + BOT_LEVELS.length - 1) % BOT_LEVELS.length
338    else if (k === 'down' || k === 'j') live.pick = (live.pick + 1) % BOT_LEVELS.length
339    else if (k === 'q') live.screen = 'menu'
340    else if (k === 'return' || k === ' ') askBot(live, BOT_LEVELS[live.pick]!.id)
341    live.isDirty = true
342
343    return
344  }
345  if (live.screen === 'lobby' || live.screen === 'leaderboard' || live.screen === 'starting') {
346    if (k === 'q' || (k === 'return' && live.screen !== 'starting')) leave(live)
347    else if (k === 'r' && live.screen === 'leaderboard') live.outbox.push({ type: 'menu', choice: 'leaderboard' })
348    else if ((k === 'left' || k === 'right') && live.screen === 'leaderboard') live.lbPage = live.lbPage === 0 ? 1 : 0
349    live.isDirty = true
350
351    return
352  }
353  if (k === 'q') return leave(live)
354  const game = live.game
355  if (!game) return
356  if (game.isOver || live.result) {
357    if (k === 'r' && live.mode === 'marathon') askMarathon(live)
358
359    return
360  }
361  if (k === 'p' && live.mode === 'marathon') {
362    live.isPaused = !live.isPaused
363    live.pace = newPace(performance.now(), live.pace.ran)
364    live.isDirty = true
365
366    return
367  }
368  if (live.isPaused) return
369  if (k === 'left') live.inputs.push('left')
370  else if (k === 'right') live.inputs.push('right')
371  // SOFT_DROP_ROWS per press: a terminal sends no key-up, so a held soft drop could not be told apart
372  // from a tap and would slam the piece down. Holding the key rides the keyboard's own repeat.
373  else if (k === 'down') for (let i = 0; i < SOFT_DROP_ROWS; i++) live.inputs.push('softDropStep')
374  else if (k === 'up' || k === 'x') live.inputs.push('rotateCW')
375  else if (k === 'z') live.inputs.push('rotateCCW')
376  else if (k === 'a') live.inputs.push('rotate180')
377  else if (k === ' ' || k === 'space') live.inputs.push('hardDrop')
378  else if (k === 'c') live.inputs.push('hold')
379}
380
381function tick(live: Live) {
382  const p = live.props
383  if (live.screen === 'lobby' && p.battle.status === 'matched' && p.battle.roomId !== null && p.battle.roomId !== live.roomId) {
384    live.roomId = p.battle.roomId
385    live.applied = 0
386    live.gameId = null
387    startGame(live, 'battle', p.battle.seed)
388  }
389  if (live.screen === 'starting') {
390    live.waitMs += STEP_MS
391    const isLate = Math.max(live.waitMs, performance.now() - live.waitAt) >= START_WAIT_MS
392    const level = live.startingBot
393    if (level !== null) {
394      const b = p.vsbot
395      if (b && b.nonce === live.nonce) {
396        live.botSeen = b.nonce
397        startVs(live, level, b.gameId, b.gameId === null ? randomSeed() : b.seed)
398      } else if (isLate) startVs(live, level, null, randomSeed())
399    } else {
400      const m = p.marathon
401      if (m && m.nonce === live.nonce) {
402        live.gameId = m.gameId
403        startGame(live, 'marathon', m.gameId === null ? randomSeed() : m.seed)
404      } else if (isLate) {
405        live.gameId = null
406        startGame(live, 'marathon', randomSeed())
407      }
408    }
409  }
410  const vb = p.vsbot
411  if (vb && vb.nonce > live.botSeen && !(live.screen === 'starting' && live.startingBot !== null && vb.nonce === live.nonce)) {
412    live.botSeen = vb.nonce
413    if (vb.gameId !== null) forfeit(live, vb.gameId)
414  }
415  if (live.screen === 'play' && live.mode === 'battle' && !live.vs && live.roomId !== null && p.battle.status === 'idle') {
416    // The server dropped the match under us: back to the lobby, where the notice is shown.
417    live.screen = 'lobby'
418    live.game = null
419    live.result = null
420    live.roomId = null
421    live.isDirty = true
422  }
423  const n = live.screen === 'play' && !live.isPaused ? stepsDue(live.pace, performance.now()) : 1
424  const dt = n * STEP_MS
425  live.t += dt
426  if (live.screen === 'starting' && live.t % 320 < STEP_MS) live.isDirty = true
427  let dirty = live.isDirty
428  const game = live.game
429  if (live.screen === 'play' && game) {
430    if (live.mode === 'battle' && !live.vs && live.roomId === p.battle.roomId) {
431      let g = game
432      if (!g.isOver && !live.result) {
433        const { incoming } = p.battle
434        for (; live.applied < incoming.length; live.applied++) {
435          const inc = incoming[live.applied]!
436          recordGarbage(live.rec, inc.id)
437          g = receiveGarbage(g, inc.lines)
438          dirty = true
439        }
440      }
441      live.game = g
442      if (p.battle.result && !live.result) {
443        live.result = p.battle.result
444        dirty = true
445      }
446      if (live.result && !live.hasSentLog) {
447        live.hasSentLog = true
448        queueUpload(live, 'battle')
449      }
450    }
451    if (n > 0 && !live.isPaused && !live.game!.isOver && !live.result) {
452      let inputs = live.inputs
453      live.inputs = []
454      let g = live.game!
455      const a = g.active
456      let moved = inputs.length > 0
457      // A full recorder freezes the board: nothing past it could be verified.
458      for (let i = 0; i < n && !g.isOver && !live.rec.isFull; i++) {
459        const counted = live.rec.steps
460        recordStep(live.rec, inputs)
461        if (live.rec.steps === counted) break
462        if (live.vs) {
463          const r = stepMatch(live.vs.match, inputs)
464          applyEvents(live, g, r.me)
465          if (r.me.length > 0) moved = true
466          g = live.vs.match.me
467          inputs = []
468          if (live.vs.match.winner) break
469          continue
470        }
471        const r = step(g, inputs, STEP_MS)
472        applyEvents(live, g, r.events)
473        if (r.events.length > 0) moved = true
474        g = r.game
475        inputs = []
476      }
477      live.game = g
478      const b = g.active
479      if (moved || a?.x !== b?.x || a?.y !== b?.y || a?.rotation !== b?.rotation || a?.kind !== b?.kind) dirty = true
480    }
481    if (live.vs) {
482      const snap = snapshot(live.vs.match.bot)
483      if (snap !== live.vs.snap) {
484        live.vs.snap = snap
485        dirty = true
486      }
487      const w = live.vs.match.winner
488      if (w && !live.result) {
489        live.result = w === 'me' ? 'win' : 'loss'
490        queueUpload(live, 'bot')
491        dirty = true
492      }
493    }
494    const now = live.game!
495    if (now.isOver || live.result) {
496      const next = overStep(live.overMs, dt)
497      if (next !== live.overMs) dirty = true
498      live.overMs = next
499    }
500    if (ageFx(live.fx, dt)) dirty = true
501
502    if (now.isOver && !live.hasPostedOver && live.mode === 'marathon') queueUpload(live, 'marathon')
503    // The top-out is posted again until a result comes: one lost post must not leave the hooks unaware.
504    if (live.mode === 'battle' && !live.vs && !live.result) {
505      live.syncMs += dt
506      if (live.syncMs >= SYNC_MS || (now.isOver && !live.hasPostedOver)) {
507        live.syncMs = 0
508        live.outbox.push({ type: 'sync', seq: ++live.seq, attacks: live.attacks.splice(0), snapshot: snapshot(now), isOver: now.isOver })
509      }
510    }
511    if (now.isOver) live.hasPostedOver = true
512  } else if (live.screen === 'lobby') {
513    if (live.t % 320 < dt) dirty = true
514  }
515
516  live.isDirty = false
517
518  return dirty
519}
520
521type Shell = { live: Live; rev: number }
522type Surf = ClientSurface<Shell>
523
524const FRAME = '#6a6a8c'
525const PANEL = '#4a4a66'
526const TITLE = '#9a9ab8'
527const BIG: Size = { cw: 4, ch: 2 }
528
529function gridEl(rows: Ch[][], surface: Surf) {
530  const { Box, Text } = surface.elements
531
532  return (
533    <Box flexDirection="column">
534      {rows.map(row => (
535        <Box flexDirection="row">
536          {runs(row).map(r => (
537            <Text color={r.fg} backgroundColor={r.bg} dimColor={r.dim} bold={r.bold} inverse={r.inverse}>
538              {r.text}
539            </Text>
540          ))}
541        </Box>
542      ))}
543    </Box>
544  )
545}
546
547// A rounded panel `width` columns wide (border included).
548function panel(surface: Surf, width: number, color: string, children: RenderChildren) {
549  const { Box } = surface.elements
550
551  return (
552    <Box flexDirection="column" width={width} borderStyle="round" borderColor={color}>
553      {children}
554    </Box>
555  )
556}
557
558function bar(surface: Surf, fraction: number, width: number) {
559  const { Box, Text } = surface.elements
560  const filled = Math.max(0, Math.min(width, Math.round(fraction * width)))
561
562  return (
563    <Box flexDirection="row">
564      <Text color="#00e5ff">{'█'.repeat(filled)}</Text>
565      <Text color="#2c2c40">{'█'.repeat(width - filled)}</Text>
566    </Box>
567  )
568}
569
570function kv(surface: Surf, width: number, label: string, value: string, color?: string) {
571  const { Box, Text } = surface.elements
572
573  return (
574    <Box width={width} justifyContent="space-between">
575      <Text dimColor>{label}</Text>
576      <Text bold color={color}>{value}</Text>
577    </Box>
578  )
579}
580
581const HELP_BIG = ['arrows  move', 'up/x    rotate', 'z ccw   a 180', 'space   hard drop', 'c hold  p pause', 'q menu']
582const HELP_MEDIUM = ['arrows move up/x turn', 'z ccw a 180 spc drop', 'c hold p pause q menu']
583
584// The full prompt where it fits; a short one on compact and mini layouts, which are narrower.
585const activate = (width: number) => (width >= 28 ? 'click here to activate panel' : 'click to activate')
586
587function drawTitle(live: Live, surface: Surf, width: number) {
588  const { Box, Text } = surface.elements
589
590  return (
591    <Box width={width} justifyContent="center">
592      {live.hasKeyed ? <Text bold color="cyan">BLOCK BATTLE</Text> : <Text bold color="yellow">{activate(width)}</Text>}
593    </Box>
594  )
595}
596
597function drawOpponent(live: Live, surface: Surf, framed: boolean) {
598  const { Box, Text } = surface.elements
599  const vs = live.vs
600  const opp = vs ? { login: `${vs.level} bot`, snapshot: vs.snap, isOver: vs.match.bot.isOver } : live.props.battle.opponent
601  const name = (opp ? opp.login : 'opponent').slice(0, 10)
602  const body = (
603    <Box flexDirection="column">
604      <Box justifyContent="center">
605        <Text bold color={opp?.isOver ? 'red' : undefined}>{name}</Text>
606      </Box>
607      {gridEl(snapshotRows(opp?.snapshot ?? ''), surface)}
608    </Box>
609  )
610
611  return framed ? panel(surface, 12, PANEL, body) : <Box flexDirection="column" width={10}>{body}</Box>
612}
613
614function drawSide(live: Live, surface: Surf, tier: 'big' | 'medium', pending: number) {
615  const { Box, Text } = surface.elements
616  const game = live.game!
617  const big = tier === 'big'
618  const width = TIERS[tier].side
619  const inner = width - 2
620  const si = inner - 2
621  const hold = game.hold ? previewRows(game.hold, big ? BIG : COMPACT, inner, !game.canHold) : blankRows(big ? 4 : 2, inner)
622  const nexts = game.next.slice(0, big ? 5 : 3)
623  const title = (t: string, dim = false) => <Text bold dimColor={dim} color={TITLE}>{' ' + t}</Text>
624  const gap = () => <Text> </Text>
625  const nextBody = nexts.map((kind, i) => (
626    <Box flexDirection="column">
627      {i > 0 && gap()}
628      {gridEl(previewRows(kind, big && i === 0 ? BIG : COMPACT, inner), surface)}
629    </Box>
630  ))
631  const level = game.lines % 10
632  const status = [
633    pending > 0 ? { t: `INCOMING ${pending}`, c: 'red' } : null,
634    game.combo > 0 ? { t: `COMBO x${game.combo}`, c: 'green' } : null,
635    game.b2b ? { t: big ? 'BACK-TO-BACK' : 'B2B', c: '#ff9a1f' } : null,
636  ].filter((s): s is { t: string; c: string } => s !== null)
637  let used = 0
638  const fitted = status.filter(s => {
639    if (used + s.t.length + (used > 0 ? 1 : 0) > si) return false
640    used += s.t.length + (used > 0 ? 1 : 0)
641
642    return true
643  })
644  const barLine = (
645    <Box width={si}>
646      {bar(surface, level / 10, si - 5)}
647      <Text dimColor>{` ${level}/10`.padStart(5)}</Text>
648    </Box>
649  )
650  const stats = big ? (
651    <Box flexDirection="column">
652      <Text dimColor>SCORE</Text>
653      <Box width={si} justifyContent="flex-end">
654        <Text bold color="yellow">{String(game.score)}</Text>
655      </Box>
656      {kv(surface, si, 'LINES', String(game.lines))}
657      {kv(surface, si, 'LEVEL', String(game.level), 'cyan')}
658      {barLine}
659      <Text color="green">{game.combo > 0 ? `COMBO x${game.combo}` : ' '}</Text>
660      <Text color="#ff9a1f">{game.b2b ? 'BACK-TO-BACK' : ' '}</Text>
661      <Text color="red">{pending > 0 ? `INCOMING ${pending}` : ' '}</Text>
662    </Box>
663  ) : (
664    <Box flexDirection="column">
665      {kv(surface, si, 'SCORE', String(game.score), 'yellow')}
666      <Box width={si} gap={2}>
667        {kv(surface, 8, 'LN', String(game.lines))}
668        {kv(surface, 10, 'LEVEL', String(game.level), 'cyan')}
669      </Box>
670      {barLine}
671      <Box width={si} gap={1}>
672        {fitted.length === 0 ? <Text> </Text> : fitted.map(s => <Text color={s.c}>{s.t}</Text>)}
673      </Box>
674    </Box>
675  )
676
677  return (
678    <Box flexDirection="column" width={width}>
679      {panel(surface, width, PANEL, <Box flexDirection="column">{title('HOLD', !game.canHold)}{gridEl(hold, surface)}</Box>)}
680      {panel(surface, width, PANEL, <Box flexDirection="column">{title('NEXT')}{nextBody}</Box>)}
681      {panel(surface, width, PANEL, <Box flexDirection="column" paddingX={1}>{stats}</Box>)}
682      <Box flexDirection="column">
683        {(big ? HELP_BIG : HELP_MEDIUM).map(l => <Text dimColor>{' ' + l}</Text>)}
684      </Box>
685    </Box>
686  )
687}
688
689function drawPlay(live: Live, surface: Surf) {
690  const { Box, Text } = surface.elements
691  const game = live.game!
692  const { fx } = live
693  const isBattle = live.mode === 'battle'
694  const tier = pickTier(surface.columns, surface.rows, isBattle)
695  if (tier === 'tiny') {
696    return (
697      <Box paddingX={1}>
698        <Text color="yellow">Too small for Block Battle: widen and heighten this pane (21 x 22 at least).</Text>
699      </Box>
700    )
701  }
702  const size = tier === 'big' ? BIG : COMPACT
703  const extra: Label[] = []
704  if (live.isPaused) extra.push({ text: 'PAUSED', color: '#ffffff', age: 200 })
705  const isDone = game.isOver || live.result !== null
706  let overlay: Overlay | null = null
707  if (isDone && live.overMs > OVER_FINAL_MS) {
708    if (live.vs) overlay = vsOverlay(live.result === 'win', live.vs.match.steps, live.vs.gameId !== null)
709    else {
710      const text = live.result ? (live.result === 'win' ? 'YOU WIN' : 'YOU LOSE') : 'GAME OVER'
711      overlay = { title: text, color: live.result === 'win' ? '#38d64a' : '#ff3b3b', lines: [`SCORE ${game.score}`, live.mode === 'marathon' && live.gameId === null ? 'unranked' : '', live.mode === 'marathon' ? 'r restart  q menu' : 'q menu'] }
712    }
713  }
714  const pending = game.pendingGarbage.reduce((n, g) => n + g.lines, 0)
715  const flash = fx.levelUp !== null && Math.floor(fx.levelUp / 100) % 2 === 0
716  const frame = flash ? 'yellow' : FRAME
717  const rows = fieldRows(game, fx, extra, live.overMs, size, overlay)
718  const warn = pending > 0 && Math.floor(live.t / 200) % 2 === 0
719  const barOn = (i: number) => rows.length - i <= pending * size.ch
720  const bars = rows.map((_, i) => <Text color={warn ? '#ff8080' : '#ff3b3b'}>{barOn(i) ? '█' : ' '}</Text>)
721  const width = FIELD_W * size.cw
722  const opp = isBattle ? drawOpponent(live, surface, tier !== 'mini') : null
723
724  if (tier === 'mini') {
725    const side = surface.columns >= TIERS.mini.columns
726    const hold = game.hold ? previewRows(game.hold, COMPACT, 10, !game.canHold) : blankRows(2, 10)
727    const sideEl = (
728      <Box flexDirection="column" width={10}>
729        <Text bold dimColor={!game.canHold} color={TITLE}>HOLD</Text>
730        {gridEl(hold, surface)}
731        <Text bold color={TITLE}>NEXT</Text>
732        {game.next.slice(0, 3).map((kind, i) => (
733          <Box flexDirection="column">
734            {i > 0 && <Text> </Text>}
735            {gridEl(previewRows(kind, COMPACT, 10), surface)}
736          </Box>
737        ))}
738      </Box>
739    )
740
741    return (
742      <Box flexDirection="column">
743        <Box flexDirection="row" gap={1}>
744          <Box flexDirection="row">
745            <Box flexDirection="column">{bars}</Box>
746            {gridEl(rows, surface)}
747          </Box>
748          {side && !isBattle ? sideEl : null}
749          {isBattle && surface.columns >= 21 + 1 + 10 ? opp : null}
750        </Box>
751        <Text color={live.hasKeyed ? undefined : 'yellow'}>{live.hasKeyed ? `${game.score}  L${game.lines}  Lv${game.level}` : activate(surface.columns)}</Text>
752      </Box>
753    )
754  }
755
756  return (
757    <Box flexDirection="row" gap={1}>
758      <Box flexDirection="column">
759        {drawTitle(live, surface, width + 3)}
760        <Box flexDirection="row">
761          <Box flexDirection="column" marginTop={1}>{bars}</Box>
762          <Box borderStyle="round" borderColor={frame}>
763            {gridEl(rows, surface)}
764          </Box>
765        </Box>
766      </Box>
767      {drawSide(live, surface, tier, pending)}
768      {opp ? <Box flexDirection="column" marginTop={1}>{opp}</Box> : null}
769    </Box>
770  )
771}
772
773function drawMenu(live: Live, surface: Surf) {
774  const { Box, Text } = surface.elements
775  const cols = surface.columns
776  const isBig = cols >= 37 && surface.rows >= 26
777  const top = live.props.leaderboard?.marathon[0]
778  const mw = cols >= 46 ? 44 : Math.max(20, cols - 2)
779  const inner = mw - 2
780  const GRAD = ['#00e5ff', '#2fb8ff', '#5a8cff', '#8a62ff', '#b44cff']
781  const word = (w: string, flip: boolean) =>
782    bigWord(w).map((line, i) => <Text bold color={GRAD[flip ? 4 - i : i]}>{line}</Text>)
783
784  return (
785    <Box flexDirection="column" alignItems="center" paddingX={1}>
786      {isBig ? (
787        <Box flexDirection="column">
788          {word('BLOCK', false)}
789          <Text> </Text>
790          {word('BATTLE', true)}
791        </Box>
792      ) : (
793        <Text bold color="cyan">BLOCK BATTLE</Text>
794      )}
795      <Text dimColor>Play while Claude works</Text>
796      <Box marginTop={1}>
797        {panel(
798          surface,
799          mw,
800          FRAME,
801          MENU.map((m, i) => {
802            const sel = i === live.menu
803            const text = (' ' + (sel ? '> ' : '  ') + m.label.padEnd(12) + (inner >= 38 ? m.hint : '')).padEnd(inner).slice(0, inner)
804
805            return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
806          }),
807        )}
808      </Box>
809      <Text dimColor>{top ? `Marathon best: ${top.login} ${top.score}` : ' '}</Text>
810      <Text dimColor>up/down choose  enter starts</Text>
811      <Text dimColor>Esc hands the keys back</Text>
812      {live.hasKeyed ? <Text dimColor>keys: arrows up/x space c q</Text> : <Text bold color="yellow">{activate(cols)}</Text>}
813    </Box>
814  )
815}
816
817function drawStarting(live: Live, surface: Surf) {
818  const { Box, Text } = surface.elements
819
820  return (
821    <Box flexDirection="column" alignItems="center" paddingX={1}>
822      <Text bold color="cyan">{live.startingBot ? 'VS BOT' : 'MARATHON'}</Text>
823      <Text>{`Starting${'.'.repeat(1 + (Math.floor(live.t / 320) % 3))}`}</Text>
824    </Box>
825  )
826}
827
828function drawServers(live: Live, surface: Surf) {
829  const { Box, Text } = surface.elements
830  const w = Math.min(46, Math.max(24, surface.columns - 2))
831  const inner = w - 2
832  const choices = serverChoices(live)
833  const typing = live.typing
834
835  return (
836    <Box flexDirection="column" alignItems="center" paddingX={1}>
837      <Text bold color="cyan">BATTLE</Text>
838      <Text dimColor>Where do you want to play?</Text>
839      {panel(
840        surface,
841        w,
842        FRAME,
843        typing === null ? (
844          choices.map((c, i) => {
845            const sel = i === live.pick
846            const text = (' ' + (sel ? '> ' : '  ') + c.label).padEnd(inner).slice(0, inner)
847
848            return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
849          })
850        ) : (
851          <Box flexDirection="column" paddingX={1}>
852            <Text>Server address:</Text>
853            <Text color="cyan">{(typing + '_').slice(-(inner - 2))}</Text>
854            {live.typeError ? <Text color="red">{live.typeError}</Text> : <Text dimColor>enter connects, up goes back</Text>}
855          </Box>
856        ),
857      )}
858      <Text dimColor>{typing === null ? 'enter picks, q goes back' : ' '}</Text>
859    </Box>
860  )
861}
862
863function drawLevels(live: Live, surface: Surf) {
864  const { Box, Text } = surface.elements
865  const w = Math.min(46, Math.max(24, surface.columns - 2))
866  const inner = w - 2
867
868  return (
869    <Box flexDirection="column" alignItems="center" paddingX={1}>
870      <Text bold color="cyan">VS BOT</Text>
871      <Text dimColor>Pick a bot to beat</Text>
872      {panel(
873        surface,
874        w,
875        FRAME,
876        BOT_LEVELS.map((l, i) => {
877          const sel = i === live.pick
878          const text = (' ' + (sel ? '> ' : '  ') + l.name).padEnd(inner).slice(0, inner)
879
880          return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
881        }),
882      )}
883      <Text dimColor>enter starts, q goes back</Text>
884    </Box>
885  )
886}
887
888function drawLobby(live: Live, surface: Surf) {
889  const { Box, Text } = surface.elements
890  const { notice, battle } = live.props
891  const dots = '.'.repeat(1 + (Math.floor(live.t / 320) % 3))
892  const w = Math.min(46, Math.max(24, surface.columns - 2))
893  const showNotice = notice && battle.status === 'idle'
894  const on = battle.status === 'queueing' && !showNotice ? battle.online : null
895  const total = on ? `${on.playing + on.looking} online` : ''
896  const full = on ? `${total}: ${on.playing} in a battle, ${on.looking} looking` : ''
897
898  return (
899    <Box flexDirection="column" alignItems="center" paddingX={1}>
900      <Text bold color="cyan">BATTLE</Text>
901      {panel(
902        surface,
903        w,
904        FRAME,
905        <Box paddingX={1} flexDirection="column">
906          {showNotice ? <Text color="red">{notice}</Text> : <Text>{`Looking for an opponent${dots}`}</Text>}
907          {on && <Text dimColor>{full.length <= w - 4 ? full : total}</Text>}
908        </Box>,
909      )}
910      <Text dimColor>q goes back</Text>
911    </Box>
912  )
913}
914
915type Entry = { login: string; value: string }
916
917function board(surface: Surf, title: string, empty: string, entries: Entry[], width: number, me: string | null, num = 7, framed = true) {
918  const { Box, Text } = surface.elements
919  const inner = width - 2
920  const body = (
921    <Box flexDirection="column">
922      <Text bold color={TITLE}>{(' ' + title).slice(0, inner)}</Text>
923      {entries.length === 0 && <Text dimColor>{' ' + empty}</Text>}
924      {entries.map((r, i) => {
925        const mine = r.login === me
926        const name = r.login.slice(0, Math.max(4, inner - num - 4))
927        const text = (` ${i + 1}  ${name}`.padEnd(inner - num) + r.value.padStart(num - 1) + ' ').slice(0, inner)
928
929        return <Text bold={mine} inverse={mine} color={mine ? 'yellow' : undefined}>{text}</Text>
930      })}
931    </Box>
932  )
933
934  return framed ? panel(surface, width, FRAME, body) : <Box flexDirection="column" width={width}>{body}</Box>
935}
936
937function drawLeaderboard(live: Live, surface: Surf) {
938  const { Box, Text } = surface.elements
939  const { leaderboard, notice, me } = live.props
940  const cols = surface.columns
941  const isBot = live.lbPage === 1
942  const wide = cols >= (isBot ? 82 : 62)
943  const w = wide ? (isBot ? 26 : 30) : Math.min(36, Math.max(24, cols - 2))
944  // Three stacked boards fit a short pane only without their frames.
945  const framed = !isBot || wide || surface.rows >= 30
946  const boards = !leaderboard
947    ? null
948    : isBot
949      ? BOT_LEVELS.map(l => board(surface, l.name, 'no wins yet', leaderboard.bot[l.id].map(r => ({ login: r.login, value: formatMs(r.ms) })), w, me, 10, framed))
950      : [
951          board(surface, 'Marathon top 5', 'no scores yet', leaderboard.marathon.map(r => ({ login: r.login, value: String(r.score) })), w, me),
952          board(surface, 'Battle wins top 5', 'no wins yet', leaderboard.wins.map(r => ({ login: r.login, value: String(r.wins) })), w, me),
953        ]
954
955  return (
956    <Box flexDirection="column" alignItems="center" paddingX={1}>
957      <Text bold color="cyan">LEADERBOARD</Text>
958      <Text dimColor>{hostOf(live.props.servers.active)}</Text>
959      {boards ? (
960        <Box flexDirection={wide ? 'row' : 'column'} gap={framed ? 1 : 0}>
961          {boards}
962        </Box>
963      ) : (
964        <Text color={notice ? 'red' : undefined}>{notice ?? 'Loading...'}</Text>
965      )}
966      <Text dimColor>arrows page  r refresh  q back</Text>
967    </Box>
968  )
969}
970
971const Game: ClientModule<GameView, Shell> = (props, surface) => {
972  let shell = surface.state
973  if (shell === undefined) {
974    const live = newLive(props)
975    shell = { live, rev: 0 }
976    const commit = () => surface.setState({ live, rev: ++live.rev })
977    surface.every(STEP_MS, () => {
978      const dirty = tick(live)
979      // One post per tick: a later post in the same frame would replace this one.
980      const msg = live.outbox.shift() ?? nextChunk(live)
981      if (msg) surface.post(msg)
982      if (dirty) commit()
983    })
984    surface.onKey(e => {
985      onKey(live, e.key)
986      commit()
987    })
988    surface.setState(shell)
989  }
990  const { live } = shell
991  live.props = props ?? emptyProps()
992
993  if (live.screen === 'play' && live.game) return drawPlay(live, surface)
994  if (live.screen === 'starting') return drawStarting(live, surface)
995  if (live.screen === 'servers') return drawServers(live, surface)
996  if (live.screen === 'levels') return drawLevels(live, surface)
997  if (live.screen === 'lobby') return drawLobby(live, surface)
998  if (live.screen === 'leaderboard') return drawLeaderboard(live, surface)
999
1000  return drawMenu(live, surface)
1001}
1002
1003export default Game
1004
hooks/engine.ts 484 lines
1export type Kind = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
2export type Cell = Kind | 'G' | null
3export type Mode = 'marathon' | 'battle'
4export type Input =
5  | 'left' | 'right' | 'softDropOn' | 'softDropOff' | 'softDropStep' | 'hardDrop'
6  | 'rotateCW' | 'rotateCCW' | 'rotate180' | 'hold'
7export type ClearKind = 'single' | 'double' | 'triple' | 'quad'
8  | 'tspinMini' | 'tspinMiniSingle' | 'tspinMiniDouble'
9  | 'tspin' | 'tspinSingle' | 'tspinDouble' | 'tspinTriple'
10export type GameEvent =
11  | { type: 'lineClear'; rows: number[]; kind: ClearKind; b2b: boolean; combo: number; perfectClear: boolean; attack: number }
12  | { type: 'lock' }
13  | { type: 'hardDrop'; from: number; to: number; columns: number[] }
14  | { type: 'hold' }
15  | { type: 'garbageIn'; lines: number }
16  | { type: 'levelUp'; level: number }
17  | { type: 'topOut' }
18export type Active = { kind: Kind; rotation: 0 | 1 | 2 | 3; x: number; y: number }
19export type Game = {
20  mode: Mode; seed: number; board: Cell[][]; active: Active | null; hold: Kind | null; canHold: boolean
21  next: Kind[]; score: number; lines: number; level: number; combo: number; b2b: boolean
22  pendingGarbage: { lines: number; hole: number }[]; isOver: boolean; elapsedMs: number
23  rng: number; bag: Kind[]; garbageCount: number
24  gravityMs: number; lockMs: number; resets: number; lowestY: number
25  softDrop: boolean; rotated: boolean; kickIdx: number
26}
27
28const W = 10
29const H = 40
30const VISIBLE_TOP = 20
31const LOCK_MS = 500
32const MAX_RESETS = 15
33const MAX_LEVEL = 15
34const KINDS: Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
35
36type Pt = readonly [number, number]
37const BASE: Record<Kind, Pt[]> = {
38  I: [[0, 1], [1, 1], [2, 1], [3, 1]],
39  O: [[1, 0], [2, 0], [1, 1], [2, 1]],
40  T: [[1, 0], [0, 1], [1, 1], [2, 1]],
41  S: [[1, 0], [2, 0], [0, 1], [1, 1]],
42  Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
43  J: [[0, 0], [0, 1], [1, 1], [2, 1]],
44  L: [[2, 0], [0, 1], [1, 1], [2, 1]],
45}
46const SHAPES = {} as Record<Kind, Pt[][]>
47for (const k of KINDS) {
48  const n = k === 'I' || k === 'O' ? 4 : 3
49  const rots: Pt[][] = [BASE[k]]
50  for (let r = 1; r < 4; r++) {
51    rots.push(k === 'O' ? BASE[k] : rots[r - 1]!.map(([x, y]) => [n - 1 - y, x] as Pt))
52  }
53  SHAPES[k] = rots
54}
55
56// Offsets are (dx, dy) with y pointing up, as published; applied as y - dy.
57const KICK_JLSTZ: Record<string, Pt[]> = {
58  '01': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
59  '10': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
60  '12': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
61  '21': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
62  '23': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
63  '32': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
64  '30': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
65  '03': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
66}
67const KICK_I: Record<string, Pt[]> = {
68  '01': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
69  '10': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
70  '12': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
71  '21': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
72  '23': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
73  '32': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
74  '30': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
75  '03': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
76}
77// Not guideline: the common SRS+ style 180 kicks, shared by all pieces.
78const KICK_180: Record<string, Pt[]> = {
79  '02': [[0, 0], [0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0]],
80  '20': [[0, 0], [0, -1], [-1, -1], [1, -1], [-1, 0], [1, 0]],
81  '13': [[0, 0], [1, 0], [1, 2], [1, 1], [0, 2], [0, 1]],
82  '31': [[0, 0], [-1, 0], [-1, 2], [-1, 1], [0, 2], [0, 1]],
83}
84
85const LINE_SCORE: Record<ClearKind, number> = {
86  single: 100, double: 300, triple: 500, quad: 800,
87  tspinMini: 100, tspinMiniSingle: 200, tspinMiniDouble: 400,
88  tspin: 400, tspinSingle: 800, tspinDouble: 1200, tspinTriple: 1600,
89}
90const LINE_ATTACK: Record<ClearKind, number> = {
91  single: 0, double: 1, triple: 2, quad: 4,
92  tspinMini: 0, tspinMiniSingle: 0, tspinMiniDouble: 1,
93  tspin: 0, tspinSingle: 2, tspinDouble: 4, tspinTriple: 6,
94}
95const COMBO_ATTACK = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5]
96const PC_SCORE = [0, 800, 1200, 1800, 2000]
97const PC_B2B_QUAD = 3200
98const PC_ATTACK = 10
99
100function mix(s: number): number {
101  let t = Math.imul(s ^ (s >>> 15), 1 | s)
102  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
103  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
104}
105
106function rand(g: Game): number {
107  g.rng = (g.rng + 0x6d2b79f5) | 0
108  return mix(g.rng)
109}
110
111function draw(g: Game): Kind {
112  if (g.bag.length === 0) {
113    const bag = KINDS.slice()
114    for (let i = bag.length - 1; i > 0; i--) {
115      const j = Math.floor(rand(g) * (i + 1))
116      const t = bag[i]!
117      bag[i] = bag[j]!
118      bag[j] = t
119    }
120    g.bag = bag
121  }
122  return g.bag.pop()!
123}
124
125function clone(g: Game): Game {
126  return {
127    ...g,
128    board: g.board.map(r => r.slice()),
129    active: g.active && { ...g.active },
130    next: g.next.slice(),
131    bag: g.bag.slice(),
132    pendingGarbage: g.pendingGarbage.map(p => ({ ...p })),
133  }
134}
135
136function emptyRow(): Cell[] {
137  return new Array<Cell>(W).fill(null)
138}
139
140function collides(board: Cell[][], kind: Kind, rotation: number, x: number, y: number): boolean {
141  for (const [cx, cy] of SHAPES[kind][rotation]!) {
142    const px = x + cx
143    const py = y + cy
144    if (px < 0 || px >= W || py < 0 || py >= H || board[py]![px] != null) return true
145  }
146  return false
147}
148
149function grounded(g: Game): boolean {
150  const a = g.active!
151  return collides(g.board, a.kind, a.rotation, a.x, a.y + 1)
152}
153
154export function cellsOf(active: Active): [number, number][] {
155  return SHAPES[active.kind][active.rotation]!.map(([cx, cy]) => [active.x + cx, active.y + cy])
156}
157
158export function ghostY(game: Game): number | null {
159  const a = game.active
160  if (!a) return null
161  let y = a.y
162  while (!collides(game.board, a.kind, a.rotation, a.x, y + 1)) y++
163  return y
164}
165
166export function newGame(mode: Mode, seed: number): Game {
167  const g: Game = {
168    mode, seed, board: Array.from({ length: H }, emptyRow), active: null, hold: null, canHold: true,
169    next: [], score: 0, lines: 0, level: 1, combo: -1, b2b: false,
170    pendingGarbage: [], isOver: false, elapsedMs: 0,
171    rng: seed | 0, bag: [], garbageCount: 0,
172    gravityMs: 0, lockMs: 0, resets: 0, lowestY: 0,
173    softDrop: false, rotated: false, kickIdx: 0,
174  }
175  const first = draw(g)
176  for (let i = 0; i < 5; i++) g.next.push(draw(g))
177  spawn(g, first, [])
178  return g
179}
180
181function topOut(g: Game, events: GameEvent[]) {
182  g.isOver = true
183  g.active = null
184  events.push({ type: 'topOut' })
185}
186
187function spawn(g: Game, kind: Kind, events: GameEvent[]) {
188  let y = 18
189  if (collides(g.board, kind, 0, 3, y)) {
190    g.active = null
191    topOut(g, events)
192    return
193  }
194  if (!collides(g.board, kind, 0, 3, y + 1)) y++
195  g.active = { kind, rotation: 0, x: 3, y }
196  g.rotated = false
197  g.kickIdx = 0
198  g.lockMs = 0
199  g.resets = 0
200  g.gravityMs = 0
201  g.lowestY = y
202}
203
204function nextPiece(g: Game, events: GameEvent[]) {
205  const kind = g.next.shift()!
206  g.next.push(draw(g))
207  spawn(g, kind, events)
208}
209
210function afterMove(g: Game, wasGrounded: boolean) {
211  const a = g.active!
212  if (a.y > g.lowestY) {
213    g.lowestY = a.y
214    g.resets = 0
215  }
216  if (!wasGrounded) return
217  if (!grounded(g)) {
218    g.lockMs = 0
219  } else if (g.resets < MAX_RESETS) {
220    g.lockMs = 0
221    g.resets++
222  }
223}
224
225function tryMove(g: Game, dx: number) {
226  const a = g.active!
227  if (collides(g.board, a.kind, a.rotation, a.x + dx, a.y)) return
228  const was = grounded(g)
229  a.x += dx
230  g.rotated = false
231  afterMove(g, was)
232}
233
234function rotate(g: Game, turn: 1 | 2 | 3) {
235  const a = g.active!
236  if (a.kind === 'O') return
237  const to = ((a.rotation + turn) & 3) as 0 | 1 | 2 | 3
238  const key = `${a.rotation}${to}`
239  const list = turn === 2 ? KICK_180[key]! : (a.kind === 'I' ? KICK_I : KICK_JLSTZ)[key]!
240  const was = grounded(g)
241  for (let i = 0; i < list.length; i++) {
242    const [dx, dy] = list[i]!
243    if (collides(g.board, a.kind, to, a.x + dx, a.y - dy)) continue
244    a.rotation = to
245    a.x += dx
246    a.y -= dy
247    g.rotated = true
248    g.kickIdx = turn === 2 ? 0 : i
249    afterMove(g, was)
250    return
251  }
252}
253
254function stepDown(g: Game) {
255  const a = g.active!
256  a.y++
257  g.rotated = false
258  if (a.y > g.lowestY) {
259    g.lowestY = a.y
260    g.resets = 0
261  }
262}
263
264function pushGarbage(g: Game, lines: number, hole: number): boolean {
265  const lost = g.board.slice(0, lines).some(r => r.some(c => c != null))
266  const rows: Cell[][] = []
267  for (let i = 0; i < lines; i++) rows.push(Array.from({ length: W }, (_, c): Cell => (c === hole ? null : 'G')))
268  g.board = g.board.slice(lines).concat(rows)
269  return lost
270}
271
272function lockPiece(g: Game, events: GameEvent[]) {
273  const a = g.active!
274  const cells = cellsOf(a)
275  let spin: 'none' | 'mini' | 'full' = 'none'
276  if (a.kind === 'T' && g.rotated) {
277    const filled = ([[0, 0], [2, 0], [0, 2], [2, 2]] as const).map(([cx, cy]) => {
278      const x = a.x + cx
279      const y = a.y + cy
280      return x < 0 || x >= W || y < 0 || y >= H || g.board[y]![x] != null
281    })
282    if (filled.filter(Boolean).length >= 3) {
283      const front = [[0, 1], [1, 3], [2, 3], [0, 2]][a.rotation]!
284      spin = filled[front[0]!] && filled[front[1]!] || g.kickIdx === 4 ? 'full' : 'mini'
285    }
286  }
287  for (const [x, y] of cells) g.board[y]![x] = a.kind
288  g.active = null
289  g.canHold = true
290  events.push({ type: 'lock' })
291
292  const rows: number[] = []
293  for (let y = 0; y < H; y++) if (g.board[y]!.every(c => c != null)) rows.push(y)
294  const n = rows.length
295
296  if (n === 0 && cells.every(([, y]) => y < VISIBLE_TOP)) {
297    topOut(g, events)
298    return
299  }
300
301  if (n > 0) {
302    g.board = g.board.filter((_, y) => !rows.includes(y))
303    while (g.board.length < H) g.board.unshift(emptyRow())
304  }
305
306  const level = g.level
307  let kind: ClearKind | null = null
308  if (spin === 'none') kind = n === 0 ? null : (['single', 'double', 'triple', 'quad'] as const)[n - 1]!
309  else if (spin === 'mini') kind = (['tspinMini', 'tspinMiniSingle', 'tspinMiniDouble', 'tspinTriple'] as const)[n]!
310  else kind = (['tspin', 'tspinSingle', 'tspinDouble', 'tspinTriple'] as const)[n]!
311
312  let attack = 0
313  if (kind) {
314    const difficult = n > 0 && (kind === 'quad' || spin !== 'none')
315    const b2bApplied = difficult && g.b2b
316    let points = Math.floor(LINE_SCORE[kind] * level * (b2bApplied ? 1.5 : 1))
317    let perfect = false
318    if (n > 0) {
319      g.combo++
320      points += 50 * g.combo * level
321      perfect = g.board.every(r => r.every(c => c == null))
322      if (perfect) points += (n === 4 && b2bApplied ? PC_B2B_QUAD : PC_SCORE[n]!) * level
323      g.b2b = difficult
324      if (g.mode === 'battle') {
325        attack = perfect
326          ? PC_ATTACK
327          : LINE_ATTACK[kind] + (b2bApplied ? 1 : 0) + COMBO_ATTACK[Math.min(g.combo, COMBO_ATTACK.length - 1)]!
328        while (attack > 0 && g.pendingGarbage.length > 0) {
329          const p = g.pendingGarbage[0]!
330          if (p.lines <= attack) {
331            attack -= p.lines
332            g.pendingGarbage.shift()
333          } else {
334            p.lines -= attack
335            attack = 0
336          }
337        }
338      }
339    } else {
340      g.combo = -1
341    }
342    g.score += points
343    events.push({ type: 'lineClear', rows, kind, b2b: b2bApplied, combo: g.combo, perfectClear: perfect, attack })
344  } else {
345    g.combo = -1
346  }
347
348  if (n > 0) {
349    g.lines += n
350    const lv = Math.min(MAX_LEVEL, 1 + Math.floor(g.lines / 10))
351    if (lv > g.level) {
352      g.level = lv
353      events.push({ type: 'levelUp', level: lv })
354    }
355  } else if (g.pendingGarbage.length > 0) {
356    let total = 0
357    let lost = false
358    for (const p of g.pendingGarbage) {
359      total += p.lines
360      if (pushGarbage(g, p.lines, p.hole)) lost = true
361    }
362    g.pendingGarbage = []
363    events.push({ type: 'garbageIn', lines: total })
364    if (lost) {
365      topOut(g, events)
366      return
367    }
368  }
369  nextPiece(g, events)
370}
371
372function gravityMs(level: number): number {
373  return 1000 * Math.pow(0.8 - (level - 1) * 0.007, level - 1)
374}
375
376function advance(g: Game, dt: number, events: GameEvent[]) {
377  let rem = dt
378  for (let guard = 0; rem > 0 && g.active && !g.isOver && guard < 1000; guard++) {
379    if (grounded(g)) {
380      g.gravityMs = 0
381      const need = LOCK_MS - g.lockMs
382      if (rem >= need) {
383        rem -= need
384        lockPiece(g, events)
385      } else {
386        g.lockMs += rem
387        rem = 0
388      }
389      continue
390    }
391    g.lockMs = 0
392    const iv = gravityMs(g.level) / (g.softDrop ? 20 : 1)
393    g.gravityMs += rem
394    rem = 0
395    while (g.gravityMs >= iv && !grounded(g)) {
396      stepDown(g)
397      g.gravityMs -= iv
398      if (g.softDrop) g.score++
399    }
400    if (grounded(g)) {
401      rem = g.gravityMs
402      g.gravityMs = 0
403    }
404  }
405}
406
407function apply(g: Game, input: Input, events: GameEvent[]) {
408  const a = g.active!
409  switch (input) {
410    case 'left': return tryMove(g, -1)
411    case 'right': return tryMove(g, 1)
412    case 'softDropOn': g.softDrop = true; return
413    case 'softDropOff': g.softDrop = false; return
414    case 'softDropStep':
415      if (!grounded(g)) {
416        stepDown(g)
417        g.score++
418        g.gravityMs = 0
419      }
420      return
421    case 'rotateCW': return rotate(g, 1)
422    case 'rotateCCW': return rotate(g, 3)
423    case 'rotate180': return rotate(g, 2)
424    case 'hardDrop': {
425      const from = a.y
426      while (!grounded(g)) {
427        a.y++
428        g.score += 2
429      }
430      if (a.y !== from) g.rotated = false
431      const columns = [...new Set(cellsOf(a).map(([x]) => x))].sort((p, q) => p - q)
432      events.push({ type: 'hardDrop', from, to: a.y, columns })
433      lockPiece(g, events)
434      return
435    }
436    case 'hold': {
437      if (!g.canHold) return
438      const cur = a.kind
439      g.canHold = false
440      events.push({ type: 'hold' })
441      if (g.hold === null) {
442        g.hold = cur
443        nextPiece(g, events)
444      } else {
445        const h = g.hold
446        g.hold = cur
447        spawn(g, h, events)
448      }
449      return
450    }
451  }
452}
453
454export function step(game: Game, inputs: Input[], dtMs: number): { game: Game; events: GameEvent[] } {
455  if (game.isOver) return { game, events: [] }
456  const g = clone(game)
457  const events: GameEvent[] = []
458  for (const input of inputs) {
459    if (g.isOver || !g.active) break
460    apply(g, input, events)
461  }
462  if (!g.isOver && dtMs > 0) advance(g, dtMs, events)
463  g.elapsedMs += dtMs
464  return { game: g, events }
465}
466
467export function receiveGarbage(game: Game, lines: number): Game {
468  const g = clone(game)
469  if (g.mode !== 'battle' || lines <= 0 || g.isOver) return g
470  // Holes come from their own stream so garbage never shifts the 7-bag both players share.
471  g.garbageCount++
472  g.pendingGarbage.push({ lines, hole: Math.floor(mix((g.seed + Math.imul(g.garbageCount, 0x9e3779b9)) | 0) * W) })
473  return g
474}
475
476// 20 visible rows of 10 chars, top row first, concatenated with no separator; the falling piece is included.
477export function snapshot(game: Game): string {
478  const rows = game.board.slice(VISIBLE_TOP).map(r => r.map(c => c ?? '.'))
479  if (game.active) {
480    for (const [x, y] of cellsOf(game.active)) if (y >= VISIBLE_TOP) rows[y - VISIBLE_TOP]![x] = game.active.kind
481  }
482  return rows.map(r => r.join('')).join('')
483}
484
hooks/match.ts 34 lines
1import { newGame, receiveGarbage, step } from './engine.ts'
2import type { Game, GameEvent, Input } from './engine.ts'
3import { botStep, newBot } from './bot.ts'
4import type { Bot, Level } from './bot.ts'
5
6const STEP_MS = 16
7
8export type Match = { me: Game; bot: Game; brain: Bot; steps: number; winner: 'me' | 'bot' | null }
9
10export const newMatch = (seed: number, level: Level): Match => ({
11  me: newGame('battle', seed),
12  bot: newGame('battle', seed),
13  brain: newBot(level, seed),
14  steps: 0,
15  winner: null,
16})
17
18export function stepMatch(m: Match, inputs: readonly Input[]): { me: GameEvent[]; bot: GameEvent[] } {
19  if (m.winner) return { me: [], bot: [] }
20  const a = step(m.me, [...inputs], STEP_MS)
21  const b = botStep(m.brain, m.bot, STEP_MS)
22  let me = a.game
23  let bot = b.game
24  for (const e of a.events) if (e.type === 'lineClear' && e.attack > 0) bot = receiveGarbage(bot, e.attack)
25  for (const e of b.events) if (e.type === 'lineClear' && e.attack > 0) me = receiveGarbage(me, e.attack)
26  m.me = me
27  m.bot = bot
28  m.steps++
29  if (me.isOver) m.winner = 'bot'
30  else if (bot.isOver) m.winner = 'me'
31
32  return { me: a.events, bot: b.events }
33}
34
hooks/bot.ts 297 lines
1// The .ts extensions let the server run a byte-identical copy under Node.
2import { cellsOf, step } from './engine.ts'
3import type { Game, GameEvent, Input, Kind } from './engine.ts'
4
5export type Level = 'easy' | 'medium' | 'hard'
6export const LEVELS: readonly Level[] = ['easy', 'medium', 'hard']
7
8type Tuning = { pace: number; hold: boolean; lookahead: boolean; slip: number; slipTo: 'top5' | 'second' | 'none'; top: number }
9
10// pace: steps between inputs. slip: pieces per thousand that take a worse placement.
11// top: the engine level whose gravity the bot's board never goes past.
12export const TUNING: Record<Level, Tuning> = {
13  easy: { pace: 12, hold: false, lookahead: false, slip: 300, slipTo: 'top5', top: 5 },
14  medium: { pace: 5, hold: true, lookahead: false, slip: 50, slipTo: 'second', top: 8 },
15  hard: { pace: 3, hold: false, lookahead: true, slip: 0, slipTo: 'none', top: 10 },
16}
17
18// Placements scored per step, so a long think spreads over steps instead of stalling one.
19export const SEARCH_PER_STEP = 64
20const LOOKAHEAD_KEEP = 5
21// From this level a piece is on the stack before it can shift, so plans must be reachable by sliding.
22const FAST_LEVEL = 14
23
24// Integer weights: integer arithmetic is exact on every JS engine, so the server agrees.
25const W_HEIGHT = -510
26const W_LINES = 761
27const W_HOLES = -357
28const W_BUMP = -184
29const DEAD = -1_000_000_000
30
31const H = 40
32const W = 10
33const FULL = (1 << W) - 1
34const SPAWN_X = 3
35const SPAWN_Y = 18
36const VISIBLE_TOP = 20
37const KINDS: readonly Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
38
39// m[dy] is the piece's row dy as a bitmask of board columns; low is its lowest dy.
40type Place = { x: number; m: number[]; low: number }
41const PLACES = {} as Record<Kind, Place[][]>
42for (const kind of KINDS) {
43  PLACES[kind] = ([0, 1, 2, 3] as const).map(rotation => {
44    const cells = cellsOf({ kind, rotation, x: 0, y: 0 })
45    const xs = cells.map(([x]) => x)
46    const low = Math.max(...cells.map(([, y]) => y))
47    const out: Place[] = []
48    for (let x = -Math.min(...xs); x + Math.max(...xs) < W; x++) {
49      const m = [0, 0, 0, 0]
50      for (const [cx, cy] of cells) m[cy] = m[cy]! | (1 << (x + cx))
51      out.push({ x, m, low })
52    }
53    return out
54  })
55}
56
57type Cand = { kind: Kind; hold: boolean; rot: number; place: Place }
58type Scored = { c: Cand; score: number; rows: number[]; lines: number; i: number }
59type Job = { parent: number; c: Cand }
60type Think = { base: number[]; fast: boolean; jobs: Job[]; at: number; first: Scored[]; deep: number[]; stage: 1 | 2 }
61type Plan = { hold: boolean; held: boolean; rot: number; x: number; rescued: boolean }
62
63export type Bot = {
64  level: Level
65  rng: number
66  wait: number
67  think: Think | null
68  plan: Plan | null
69  last: { input: Input; x: number; rot: number } | null
70}
71
72export const newBot = (level: Level, seed: number): Bot => ({ level, rng: (seed ^ 0x2545f491) | 0, wait: 0, think: null, plan: null, last: null })
73
74function rand32(b: Bot): number {
75  b.rng = (b.rng + 0x6d2b79f5) | 0
76  let t = Math.imul(b.rng ^ (b.rng >>> 15), 1 | b.rng)
77  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
78  return (t ^ (t >>> 14)) >>> 0
79}
80
81function toRows(board: Game['board']): number[] {
82  return board.map(r => {
83    let m = 0
84    for (let x = 0; x < W; x++) if (r[x] != null) m |= 1 << x
85    return m
86  })
87}
88
89function fits(rows: readonly number[], m: readonly number[], y: number): boolean {
90  for (let dy = 0; dy < 4; dy++) {
91    if (m[dy] === 0) continue
92    const r = y + dy
93    if (r >= H || (rows[r]! & m[dy]!) !== 0) return false
94  }
95  return true
96}
97
98// Straight down from the spawn row. dead: the engine would top out on this lock.
99function land(rows: readonly number[], p: Place, from = SPAWN_Y): { rows: number[]; lines: number; dead: boolean } | null {
100  let y = from
101  if (!fits(rows, p.m, y)) return null
102  while (fits(rows, p.m, y + 1)) y++
103  const placed = rows.slice()
104  for (let dy = 0; dy < 4; dy++) if (p.m[dy]) placed[y + dy] = placed[y + dy]! | p.m[dy]!
105  const kept = placed.filter(r => r !== FULL)
106  const lines = H - kept.length
107  while (kept.length < H) kept.unshift(0)
108  return { rows: kept, lines, dead: lines === 0 && y + p.low < VISIBLE_TOP }
109}
110
111// Turned at the spawn column, then moved one column at a time while resting on the stack.
112function slides(rows: readonly number[], c: Cand): boolean {
113  const list = PLACES[c.kind][c.rot]!
114  const to = c.place.x - list[0]!.x
115  let i = SPAWN_X - list[0]!.x
116  let y = SPAWN_Y
117  if (!fits(rows, list[i]!.m, y)) return false
118  while (i !== to) {
119    while (fits(rows, list[i]!.m, y + 1)) y++
120    const n = i < to ? i + 1 : i - 1
121    if (!fits(rows, list[n]!.m, y)) return false
122    i = n
123  }
124  return true
125}
126
127function evaluate(rows: readonly number[], lines: number): number {
128  let agg = 0
129  let holes = 0
130  let bump = 0
131  let prev = -1
132  for (let c = 0; c < W; c++) {
133    const bit = 1 << c
134    let top = H
135    for (let y = 0; y < H; y++) {
136      if (rows[y]! & bit) {
137        top = y
138        break
139      }
140    }
141    for (let y = top + 1; y < H; y++) if (!(rows[y]! & bit)) holes++
142    const h = H - top
143    agg += h
144    if (prev >= 0) bump += Math.abs(h - prev)
145    prev = h
146  }
147  return W_HEIGHT * agg + W_LINES * lines + W_HOLES * holes + W_BUMP * bump
148}
149
150function candidates(kind: Kind, hold: boolean): Cand[] {
151  const out: Cand[] = []
152  for (let rot = 0; rot < (kind === 'O' ? 1 : 4); rot++) for (const place of PLACES[kind][rot]!) out.push({ kind, hold, rot, place })
153  return out
154}
155
156function startThink(b: Bot, game: Game): Think {
157  const a = game.active!
158  const cands = candidates(a.kind, false)
159  if (TUNING[b.level].hold && game.canHold) {
160    const alt = game.hold ?? game.next[0]
161    if (alt && alt !== a.kind) cands.push(...candidates(alt, true))
162  }
163  return { base: toRows(game.board), fast: game.level >= FAST_LEVEL, jobs: cands.map(c => ({ parent: -1, c })), at: 0, first: [], deep: [], stage: 1 }
164}
165
166function runJob(th: Think, job: Job) {
167  if (job.parent < 0) {
168    if (th.fast && !slides(th.base, job.c)) return
169    const r = land(th.base, job.c.place)
170    if (!r) return
171    th.first.push({ c: job.c, score: r.dead ? DEAD : evaluate(r.rows, r.lines), rows: r.rows, lines: r.lines, i: th.first.length })
172    return
173  }
174  const p = th.first[job.parent]!
175  if (p.score === DEAD || (th.fast && !slides(p.rows, job.c))) return
176  const r = land(p.rows, job.c.place)
177  if (!r || r.dead) return
178  const s = evaluate(r.rows, p.lines + r.lines)
179  if (s > th.deep[job.parent]!) th.deep[job.parent] = s
180}
181
182function choose(b: Bot, th: Think, game: Game): Plan {
183  let order = th.first
184  if (th.stage === 2) {
185    const k = Math.min(LOOKAHEAD_KEEP, order.length)
186    const top = order
187      .slice(0, k)
188      .map((s, i) => ({ s, d: th.deep[i]!, i }))
189      .sort((p, q) => q.d - p.d || p.i - q.i)
190      .map(t => t.s)
191    order = [...top, ...order.slice(k)]
192  }
193  const t = TUNING[b.level]
194  const u1 = rand32(b)
195  const u2 = rand32(b)
196  let pick = 0
197  if (order.length > 1 && u1 % 1000 < t.slip) pick = t.slipTo === 'second' ? 1 : u2 % Math.min(5, order.length)
198  const c = order[pick]?.c
199  const a = game.active!
200  return c ? { hold: c.hold, held: false, rot: c.rot, x: c.place.x, rescued: false } : { hold: false, held: false, rot: a.rotation, x: a.x, rescued: true }
201}
202
203// True once a plan is ready.
204function thinkStep(b: Bot, game: Game): boolean {
205  const th = b.think ?? (b.think = startThink(b, game))
206  for (let n = 0; n < SEARCH_PER_STEP && th.at < th.jobs.length; n++) runJob(th, th.jobs[th.at++]!)
207  if (th.at < th.jobs.length) return false
208  if (th.stage === 1) {
209    th.first.sort((p, q) => q.score - p.score || p.i - q.i)
210    const next = game.next[0]
211    if (TUNING[b.level].lookahead && next) {
212      th.stage = 2
213      th.deep = th.first.map(() => DEAD)
214      const k = Math.min(LOOKAHEAD_KEEP, th.first.length)
215      for (let i = 0; i < k; i++) for (const c of candidates(next, false)) th.jobs.push({ parent: i, c })
216      if (th.at < th.jobs.length) return false
217    }
218  }
219  b.plan = choose(b, th, game)
220  b.think = null
221  return true
222}
223
224// Blocked on the way, usually by fast gravity: retarget to the best column the piece can still slide to.
225function rescue(game: Game): Plan {
226  const a = game.active!
227  const rows = toRows(game.board)
228  const list = PLACES[a.kind][a.rotation]!
229  const at = a.x - list[0]!.x
230  let best = at
231  let bestScore = DEAD
232  const consider = (i: number) => {
233    const r = land(rows, list[i]!, a.y)
234    const s = !r || r.dead ? DEAD : evaluate(r.rows, r.lines)
235    if (s > bestScore) {
236      best = i
237      bestScore = s
238    }
239  }
240  consider(at)
241  for (let i = at - 1; i >= 0 && fits(rows, list[i]!.m, a.y); i--) consider(i)
242  for (let i = at + 1; i < list.length && fits(rows, list[i]!.m, a.y); i++) consider(i)
243  return { hold: false, held: false, rot: a.rotation, x: list[best]!.x, rescued: true }
244}
245
246const TURN: Record<number, Input> = { 1: 'rotateCW', 2: 'rotate180', 3: 'rotateCCW' }
247
248// Steers the live piece toward the plan. A move or turn that changed nothing means it is blocked:
249// the first time it retargets, after that it drops.
250function nextInput(b: Bot, game: Game): Input {
251  const a = game.active!
252  const last = b.last
253  const isStuck = last !== null && last.input !== 'hold' && last.x === a.x && last.rot === a.rotation
254  const giveUp = isStuck && b.plan!.rescued
255  if (isStuck && !giveUp) b.plan = rescue(game)
256  const p = b.plan!
257  let input: Input
258  if (p.hold && !p.held && game.canHold) {
259    input = 'hold'
260    p.held = true
261  } else if (giveUp || (a.rotation === p.rot && a.x === p.x)) input = 'hardDrop'
262  else if (a.rotation !== p.rot) input = TURN[(p.rot - a.rotation) & 3]!
263  else input = a.x < p.x ? 'right' : 'left'
264  b.last = { input, x: a.x, rot: a.rotation }
265  return input
266}
267
268export function botInputs(b: Bot, game: Game): Input[] {
269  if (game.isOver || !game.active) return []
270  if (b.wait > 0) b.wait--
271  if (!b.plan && !thinkStep(b, game)) return []
272  if (b.wait > 0) return []
273  b.wait = TUNING[b.level].pace
274  return [nextInput(b, game)]
275}
276
277// Without a top speed the bot's own line clears raise its level until pieces outrun its pace.
278const capSpeed = (b: Bot, game: Game): Game => (game.level > TUNING[b.level].top ? { ...game, level: TUNING[b.level].top } : game)
279
280// One step of the bot's board. Inputs and gravity run as two engine steps so a clear that raises
281// the level is capped before the next piece falls. A lock by gravity alone can still spend the
282// rest of its step at the engine's level.
283export function botStep(b: Bot, game: Game, dtMs: number): { game: Game; events: GameEvent[] } {
284  const moved = step(game, botInputs(b, game), 0)
285  const fell = step(capSpeed(b, moved.game), [], dtMs)
286  const events = [...moved.events, ...fell.events]
287  botSaw(b, events)
288  return { game: capSpeed(b, fell.game), events }
289}
290
291export function botSaw(b: Bot, events: readonly GameEvent[]): void {
292  if (!events.some(e => e.type === 'lock' || e.type === 'topOut')) return
293  b.plan = null
294  b.think = null
295  b.last = null
296}
297
types/index.d.ts 58 lines
1export type ScoreRow = { login: string; score: number; lines: number; level: number; at: number }
2export type WinRow = { login: string; wins: number }
3export type BotLevel = 'easy' | 'medium' | 'hard'
4export type BotRow = { login: string; ms: number; at: number }
5export type Leaderboard = { marathon: ScoreRow[]; wins: WinRow[]; bot: Record<BotLevel, BotRow[]> }
6
7export type Incoming = { id: number; lines: number }
8export type Opponent = { login: string; snapshot: string; isOver: boolean }
9export type Online = { playing: number; looking: number }
10
11export type Battle = {
12  status: 'idle' | 'queueing' | 'matched' | 'ended'
13  roomId: string | null
14  seed: number
15  opponent: Opponent | null
16  // Every attack received this match, by server id; the Client applies each id once.
17  incoming: Incoming[]
18  result: 'win' | 'loss' | null
19  // Server-wide counts from the latest queue reply of this search; null until one carries them.
20  online: Online | null
21}
22
23export type GameView = {
24  me: string | null
25  leaderboard: Leaderboard | null
26  // Short human line shown instead of the leaderboard or lobby when the server is unreachable.
27  notice: string | null
28  battle: Battle
29  servers: Servers
30  marathon: { nonce: number; gameId: string | null; seed: number } | null
31  vsbot: { nonce: number; level: BotLevel; gameId: string | null; seed: number } | null
32  uploaded: { key: string; have: number } | null
33}
34
35export type Servers = {
36  // The plugin's configured server, the official one unless the player changed the setting.
37  home: string
38  last: string | null
39  // Where Battle and the leaderboard talk to right now.
40  active: string
41}
42
43// Everything a Client posts to the hooks side. Attacks ride inside `sync`: a post
44// replaces an undelivered one within a frame, so a separate message could be lost.
45export type ClientMsg =
46  | { type: 'menu'; choice: 'leaderboard' | 'back' }
47  | { type: 'menu'; choice: 'marathon'; nonce: number }
48  | { type: 'menu'; choice: 'bot'; level: BotLevel; nonce: number }
49  | { type: 'menu'; choice: 'battle'; server: string }
50  | { type: 'sync'; seq: number; attacks: number[]; snapshot: string; isOver: boolean }
51  | { type: 'log'; kind: 'marathon' | 'battle' | 'bot'; key: string; steps: number; inputsLen: number; total: number; at: number; values: number[] }
52
53declare module 'claude-code' {
54  interface PluginState {
55    'block-battle': { view: GameView }
56  }
57}
58