SLOPSHOPPER

sparkmine

A pixel-art mining clicker above your prompt. Claude works, you click.

newpanebandspinnerguardcommand
★ 3v0.14.1MITupdated 2026-10-14tozmoz/sparkmine
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sparkmine
│ ┃ Sparkmine ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./hud.ts │ ┃ Click +1 · Read +1 · Edit +4 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Shop ] [ Shaft ] [ Badges 0/42 ] [ Stats ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Producers ×1 ×10 Max ⏺ Bash(bun test) │ ┃ Apprentice [ 15 ⎿ 3 pass, 1 fail │ ┃ 0.1/s each · Swings a tiny pick when │ ┃ nobody is looking. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ??? · 110 ✻ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Upgrades │ ┃ Nothing on sale yet. › /mine │ ┃ Next: Reading glasses, after 50 Read, Grep │ ┃ and Glob calls │ ▣ client module ./hud.ts [ Shop ] ▣ client module ./vein.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./hud.ts [ Shop ] ▣ client module ./vein.ts
Pane · Sparkmine
▣ client module ./hud.ts Click +1 · Read +1 · Edit +4 [ Shop ] [ Shaft ] [ Badges 0/42 ] [ Stats ] Producers ×1 ×10 Max Apprentice [ 1 0.1/s each · Swings a tiny pick when nobody is looking. ??? · 110 ✻ Upgrades Nothing on sale yet. Next: Reading glasses, after 50 Read, Grep and Glob calls
README

Sparkmine: Idle Clicker Game for Claude Code

A game you play while Claude works, built as a Claude Code mod. Claude works. You click.

Plays in the Code tab of the Claude Desktop app and in Claude Code in the terminal.

The band above the prompt: a pixel miner digs while Claude works

While Claude works, a pixel miner digs in the band above your prompt. Every tool call Claude makes mines sparks ✻, and each press of Mine mines more. Claude's own work pays too: a test run that passes, a commit, a long turn. Spend it all in the shop on a crew that keeps digging, and on upgrades.

<img src="docs/shop.png" alt="The shop in the Claude Desktop app: upgrades on sale" width="420">

Install

Needs Claude Code v2.1.293 or later. In a terminal, run:

claude plugin marketplace add tozmoz/sparkmine
claude plugin install sparkmine@sparkmine --scope user

The user scope puts it in every session, including the Code tab of the Claude Desktop app. New sessions load it; in open ones, run /reload-plugins. Inside Claude Code, /plugin install sparkmine --marketplace tozmoz/sparkmine does the same.

To update later:

claude plugin marketplace update sparkmine
claude plugin update sparkmine@sparkmine

Or let updates arrive on their own: in Claude Code in a terminal, run /plugin, open the Marketplaces tab, select sparkmine and choose Enable auto-update.

Play

  • Press Mine while Claude works. Clicks count only then. In the terminal, give the band the keyboard (click it, or press ctrl+x then Tab) and press m: every press counts, while quick clicks on one spot count as one.
  • Open the shop with Shop or /mine. Its tabs hold the producers and upgrades, the shaft and its relics, your badges and your stats. Producers mine every second Claude works; upgrades make clicks, tool calls, Claude's work and the crew stronger. Each producer also mines a small share of what the ones above it mine, so the first ones keep paying.
  • Claude's work pays, once a turn each: tests that pass, a bug squashed (tests that failed, then pass), a build that passes, a clean lint, a commit, a pull request opened or merged, and a turn longer than five minutes. Every third good turn in a row pays a streak.
  • Veins: now and then a gold vein shows beside the Shop sign for twenty seconds. Catch it for two minutes of income, or a ×5 frenzy for thirty.
  • Depth: the mine goes a hundred metres deeper with every tenfold of sparks, through layer after layer. Each layer you reach adds its perk for the rest of the run.

The layers down to the core, where each starts and what it adds

  • New shaft: from 500 m down, open a new shaft in the shop to start over for ◈ ancient stones. They add to everything for good, each a little less than the one before: 10 stones add 50%, 100 add 251%. A deeper run pays more of them.
  • Relics: spend stones on relics that last, such as a starter crew for every new shaft or luckier veins. Spending them never lowers what they add.
  • Achievements: 33 badges for records such as your first vein, 100 commits, a ten-turn streak or reaching the magma, and nine secrets. Each adds 2% to everything, and they stay through new shafts. In the desktop app, point at a badge to see what it asks.
  • Sounds: a few rare moments chime softly, such as a vein caught. /mine sound turns them off.

All your sessions share one save, so they mine into the same mine. The crew's idle pace is paid once, however many sessions are open.

| Command | What it does | | :- | :- | | /mine | Opens the shop | | /mine share | Puts a card of your mine in the conversation to paste anywhere (in the terminal, on the clipboard too) | | /mine sound | Turns the sounds off in every session, and on again | | /mine stop, /mine start | Stops the game in every session, and starts it again | | /mine compact | Keeps the band to one line, even while Claude works | | /mine hide, /mine show | Hides the band in this session, and brings it back | | /mine reset | Deletes your save, after asking |

To remove it, disable or uninstall it from /plugin. Your save stays until you reset it.

What it touches

  • It DOES NOT use tokens: it never calls a model and adds nothing to your prompts.
  • It DOES NOT make network requests or run commands. Nothing leaves your machine. Its few sounds play through the system's own player (afplay on macOS).
  • It DOES NOT keep or send your prompts or Claude's answers: it reads them on your machine only to notice a few words.
  • It counts tool calls, turns and permission prompts, and reads whether a test run passed or a commit landed, but changes none of them.
  • It keeps one small save on your machine.

Known limits

  • In the terminal, quick clicks on one spot count as one. Press m instead.
  • A mod cannot see a permission dialog, so the band keeps going while one is open. It rests only while Claude asks you a question or waits on a plan.
  • Each machine keeps its own save.

License

MIT

Source 15 files
hooks/register.tsx 652 lines
1// Sparkmine: a pixel mining clicker above the prompt. Claude works, you click.
2
3import { atom, read, update } from 'claude-code'
4import type { RenderElement, Register, Timer } from 'claude-code'
5
6import type { Band, HudProps, StatsProps } from '../types'
7import { newMine, normalizeMine, outcomeOf, producer } from './game.ts'
8import { iconSvg } from './icons.ts'
9import { EMPTY_HUD, EMPTY_SHOP, EMPTY_STATS, answerOf, hudProps, shareCard, statsProps, veinProps } from './model.ts'
10import { badgeWall, barSvg, strataSvg } from './pictures.ts'
11import { H, ROWS, W, momentAt, momentKey, sceneCells, sceneSvg, type SceneSpec } from './scene.ts'
12import * as state from './state.ts'
13import { INITIAL_BAND, INITIAL_VIEW, type Io } from './state.ts'
14import * as ui from './ui.tsx'
15
16// The session's values. The band draws from BAND and the shop from SHOP alone:
17// both change rarely, so a click or a tick never redraws the scene. The live
18// numbers reach the screen through the HUD Clients instead.
19const MINE = atom({ plugin: 'sparkmine', key: 'mine' } as const, newMine('unloaded'))
20const VIEW = atom({ plugin: 'sparkmine', key: 'view' } as const, INITIAL_VIEW)
21const BAND = atom({ plugin: 'sparkmine', key: 'band' } as const, INITIAL_BAND)
22const SHOP = atom({ plugin: 'sparkmine', key: 'shop' } as const, EMPTY_SHOP)
23const REWARD = atom({ plugin: 'sparkmine', key: 'reward' } as const, null)
24
25/** The shop pane's id. */
26const PANE = 'sparkmine'
27/** CSS pixels per scene pixel: the band's scene, and the smaller one atop the shop. */
28const SCALE = 3
29const HEADER_SCALE = 2
30/** CSS pixels of each producer in the band's crew rack. */
31const CREW_ICON = 20
32/** Columns the Mine button, the HUD, the Shop button and a vein need beside the terminal scene. */
33const HUD_COLUMNS = 64
34/**
35 * The HUD's fixed height in rows. Its width follows its text: a desktop draws
36 * a width in cells much wider than the text.
37 */
38const HUD_WORK = { height: 2 } as const
39const HUD_IDLE = { height: 1 } as const
40const HUD_SHOP = { height: 4 } as const
41
42/** Where the band draws, as the Clients' props say it. */
43const surfaceOf = (surface: string): HudProps['surface'] => (surface === 'terminal' || surface === 'desktop' ? surface : 'other')
44
45/**
46 * Tools whose whole call waits on the person, so the miner rests while they
47 * decide: a question, a plan to approve. A permission check's `ask` is not one:
48 * the mode settles most (auto mode's classifier) without asking anyone, and a
49 * dialog it does show leaves nothing a mod can read, nor does its answer, so
50 * resting on every ask stopped the miner for each command and while it ran.
51 */
52const ASKS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
53
54const flag = (on: boolean | undefined) => (on === true ? 1 : 0)
55const specKey = (spec: SceneSpec) =>
56  `${spec.mode}:${spec.carts}:${spec.goblins}:${spec.pick}:${spec.biome}:${spec.bug ?? ''}:` +
57  `${flag(spec.canary)}${flag(spec.gloves)}${flag(spec.warm)}${flag(spec.coffee)}${flag(spec.flare)}${flag(spec.confetti)}:${spec.pose ?? ''}:${spec.find ?? ''}:${spec.season ?? ''}`
58const svgs = new Map<string, string>()
59
60/** The last of each of the shop's pictures, by what it showed: the shop redraws often, its pictures rarely change. */
61const pictures = new Map<string, { key: string; value: unknown }>()
62function picture<T>(kind: string, key: string, build: () => T): T {
63  const hit = pictures.get(kind)
64  if (hit !== undefined && hit.key === key) return hit.value as T
65  const value = build()
66  pictures.set(kind, { key, value })
67  return value
68}
69
70function svgFor(spec: SceneSpec, scale: number, fluid = false): string {
71  const key = `${specKey(spec)}@${scale}${fluid ? ':fluid' : ''}`
72  let svg = svgs.get(key)
73  if (svg === undefined) {
74    // A long session sees many: the details multiply them.
75    if (svgs.size > 64) svgs.clear()
76    svg = sceneSvg(spec, scale, fluid)
77    svgs.set(key, svg)
78  }
79  return svg
80}
81
82/**
83 * The mine as a band says it is: the bug while tests fail, the upgrades there
84 * to see, and the miner facing you while Claude asks.
85 */
86function bandSpec(band: Band, mode: Band['mode'], pose?: SceneSpec['pose']): SceneSpec {
87  return {
88    mode,
89    carts: band.carts,
90    goblins: band.goblins,
91    pick: band.pick,
92    biome: band.biome,
93    ...(band.bug === null ? {} : { bug: band.bug }),
94    ...band.extras,
95    ...(band.flare ? { flare: true } : {}),
96    ...(band.find === null ? {} : { find: band.find }),
97    ...(band.season === null ? {} : { season: band.season }),
98    ...(band.confetti ? { confetti: true } : {}),
99    ...(pose === undefined ? {} : { pose }),
100  }
101}
102
103const cells = new Map<string, string>()
104
105function cellsFor(spec: SceneSpec, ms: number): { key: string; cells: string } {
106  const m = momentAt(spec.mode, ms)
107  const key = `${specKey(spec)}|${momentKey(m)}`
108  let hit = cells.get(key)
109  if (hit === undefined) {
110    if (cells.size > 256) cells.clear()
111    hit = sceneCells(spec, m)
112    cells.set(key, hit)
113  }
114  return { key, cells: hit }
115}
116
117/** The most presses one post is counted for, should a HUD send something odd. */
118const MAX_PRESSES_PER_POST = state.MAX_CLICKS_PER_SECOND
119/** HUD instances whose press totals are remembered: a band remounts now and then. */
120const MAX_HUDS = 16
121
122export const register: Register = on => {
123  let io: Io | null = null
124  let isRunning = false
125  /** The last numbers a HUD was handed, so a redraw of the band or the shop starts from them. */
126  let lastHud: HudProps = EMPTY_HUD
127  /**
128   * When a redraw of the band or the shop hands its HUD those numbers: drawn
129   * now, they are the newest, unless there are none yet. See HudProps `at`.
130   */
131  const handedAt = (now: number): number => (lastHud.at === 0 ? 0 : now)
132  /** The last answer each HUD had to its polls, by its key: see answerOf. */
133  const answered = new Map<string, HudProps>()
134  /** The presses the band's HUD was last told were counted: the shop's HUD has none. */
135  let lastAcked = 0
136  /** The stats the shop's Client was last handed, so a redraw of the shop starts from them. */
137  let lastStats: StatsProps = EMPTY_STATS
138  /** Presses each band HUD has posted so far, by instance. */
139  const totals = new Map<string, number>()
140  /** Presses of the terminal band's own Mine button the hooks have counted: see HudProps `taps`. */
141  let taps = 0
142  /** Presses of that button not yet counted, and the run counting them. */
143  let queued = 0
144  let counting: Promise<void> | null = null
145  /**
146   * A press of the terminal's Mine button. It returns at once: presses that
147   * come while one is still being counted were lost, so `m` held down or tapped
148   * fast mined once. They queue, and each round counts all that came.
149   */
150  const tap = () => {
151    queued += 1
152    counting ??= (async () => {
153      while (queued > 0 && io !== null) {
154        const n = queued
155        queued = 0
156        taps += (await state.clicks(io, n)).counted
157      }
158    })()
159      .catch(() => undefined)
160      .finally(() => {
161        counting = null
162      })
163  }
164  /** The shop's Button handles, by surface and then key: see ui.keepHandles. */
165  const shopHandles = new Map<string, Map<string, ui.Press>>()
166  const handlesOn = (surface: string): Map<string, ui.Press> => {
167    let held = shopHandles.get(surface)
168    if (held === undefined) {
169      held = new Map()
170      shopHandles.set(surface, held)
171    }
172    return held
173  }
174
175  /**
176   * Counts the presses a band HUD reports. Each post carries its running
177   * total, so presses whose posts merged in one frame all count. An instance
178   * heard for the first time (a new HUD, or this module reloaded under a
179   * running one) is counted from there on. Answers how many are counted.
180   */
181  const countPresses = async (engine: Io, data: unknown): Promise<number> => {
182    if (data === null || typeof data !== 'object') return 0
183    const { instance, total } = data as { instance?: unknown; total?: unknown }
184    if (typeof instance !== 'string' || typeof total !== 'number' || !Number.isFinite(total)) return 0
185    const seen = totals.get(instance)
186    totals.delete(instance)
187    totals.set(instance, Math.max(total, seen ?? 0))
188    if (totals.size > MAX_HUDS) totals.delete(totals.keys().next().value ?? instance)
189    if (seen !== undefined && total > seen) await state.clicks(engine, Math.min(total - seen, MAX_PRESSES_PER_POST))
190    return total
191  }
192  /** The terminal's scene, which a timer repaints moment by moment. */
193  let live: { requestId: string; spec: SceneSpec; shown: string } | null = null
194  let animation: Timer | null = null
195
196  on('session.start', async ($, e, next) => {
197    const engine: Io = {
198      now: () => $.clock.now(),
199      get: key => $.store.get(key),
200      put: (key, value) => $.store.set(key, value),
201      toast: text => $.ui.toast(text),
202      // A clip of the mod's own, played by the system (afplay on macOS); it is not waited for, and a failure is quiet.
203      sound: name => {
204        void (async () => {
205          if (state.fillBand(await read($, BAND)).isMuted) return
206          await $.audio.play({ asset: `sounds/${name}.wav` })
207        })().catch(() => undefined)
208      },
209      ask: (question, options) => $.ui.ask(question, options),
210      // Values kept in the session from before an update lack newer fields: every read fills them.
211      mine: change => update($, MINE, m => change(normalizeMine(m))),
212      view: change => update($, VIEW, v => change(state.fillView(v))),
213      band: change => update($, BAND, b => change(state.fillBand(b))),
214      shop: change => update($, SHOP, change),
215      reward: change => update($, REWARD, change),
216      readMine: async () => normalizeMine(await read($, MINE)),
217      readView: async () => state.fillView(await read($, VIEW)),
218      readBand: async () => state.fillBand(await read($, BAND)),
219      readShop: () => read($, SHOP),
220      readReward: () => read($, REWARD),
221    }
222    io = engine
223
224    await $.command.register({
225      name: 'mine',
226      description: 'Open the Sparkmine shop',
227      argumentHint: '[share|sound|stop|start|compact|hide|show|reset]',
228      immediate: true,
229    })
230    await state.ensureLoaded(engine)
231    // A HUD drawn before its first poll is answered starts from the mine, not from nothing.
232    if (lastHud.at === 0) {
233      lastHud = hudProps((await engine.readMine()).save, await engine.readView(), await engine.readBand(), 'band', await $.clock.now())
234    }
235
236    if (!isRunning) {
237      isRunning = true
238      const step = async () => {
239        const scene = live
240        if (scene === null) return
241        const frame = cellsFor(scene.spec, await $.clock.now())
242        if (frame.key === scene.shown) return
243        scene.shown = frame.key
244        const painted = await $.ui.blit({ requestId: scene.requestId, key: 'scene', cells: frame.cells, columns: W, rows: ROWS })
245        if ('deny' in painted && painted.deny !== undefined) live = null
246      }
247      $.clock.every(state.TICK_MS, () => {
248        void state
249          .tick(engine)
250          .then(() => {
251            const wanted = live !== null && live.spec.mode !== 'pause'
252            if (wanted && animation === null) animation = $.clock.every(100, () => void step().catch(() => (live = null)))
253            if (!wanted && animation !== null) {
254              animation.cancel()
255              animation = null
256            }
257          })
258          .catch(() => undefined)
259      })
260      const syncLater = () =>
261        $.clock.after(state.SYNC_MS + Math.floor(Math.random() * state.SYNC_JITTER_MS), () => {
262          void state
263            .sync(engine)
264            .catch(() => undefined)
265            .finally(syncLater)
266        })
267      syncLater()
268    }
269    return next(e)
270  })
271
272  // /clear, /resume and /branch reset $.state, so the mine is read again.
273  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
274    if (io !== null) await state.ensureLoaded(io)
275    return next(e)
276  }).catch(($, e, next) => next(e))
277
278  on('session.end', async ($, e, next) => {
279    if (io !== null) await state.sync(io).catch(() => undefined)
280    return next(e)
281  })
282
283  // Read only: a few words in the person's own prompt find a secret, and the prompt goes on as it was.
284  // The person types at the terminal, on a phone over Remote Control, or in the
285  // desktop app, which hosts its sessions as an SDK does; a task's notification,
286  // a schedule or another session is not them.
287  on('prompt.submit', async ($, e, next) => {
288    const kind = e.origin.kind
289    if (io !== null && (kind === 'composer' || kind === 'bridge' || kind === 'sdk')) await state.promptSeen(io, e.text)
290    return next(e)
291  }).catch(($, e, next) => next(e))
292
293  on('turn.start', async ($, e, next) => {
294    if (io !== null) await state.startTurn(io, e.turnId)
295    return next(e)
296  })
297
298  on('turn.complete', async ($, e, next) => {
299    const result = await next(e)
300    if (io !== null && e.agentId === undefined) await state.endTurn(io, e.durationMs, e.reason, e.answer)
301    return result
302  })
303
304  on('tool.call', async ($, e, next) => {
305    const tool = String(e.tool)
306    const isQuestion = ASKS_PERSON.has(tool)
307    // Claude's own question waits on the person; another plugin's is not Claude waiting (this one's own never comes here).
308    const waits = isQuestion && next.origin.plugin === 'engine'
309    if (io !== null && waits) await state.setWaiting(io, e.tool_use_id, true)
310    let result
311    try {
312      result = await next(e)
313    } finally {
314      if (io !== null && waits) await state.setWaiting(io, e.tool_use_id, false)
315    }
316    // Read only: the outcome says whether tests passed or a commit landed, and the call goes on unchanged.
317    if (io !== null && result.deny === undefined && !isQuestion) await state.minedTool(io, outcomeOf(tool, e, result))
318    return result
319  }).catch(($, e, next) => next(e))
320
321  // The desktop's wooden Shop sign is a Client of its own: a press posts, and the shop opens.
322  on('ui.message', { element: 'shopbtn' }, async ($, e) => {
323    const data = e.data
324    if (data === null || typeof data !== 'object' || (data as { open?: unknown }).open !== true) return {}
325    await $.ui.open({ id: PANE, title: 'Sparkmine', focus: true, closeOnEscape: true })
326    const pane = (await $.ui.panes()).find(p => p.id === PANE)
327    if (pane !== undefined && !pane.isPlaced) $.ui.toast('The shop opens in a wider window, or with /mine')
328    return {}
329  })
330
331  // The vein asks four times a second whether one is showing, and says when it is caught.
332  on('ui.message', { element: 'vein' }, async ($, e) => {
333    if (io === null) return {}
334    const data = e.data
335    if (data !== null && typeof data === 'object' && typeof (data as { catch?: unknown }).catch === 'number') {
336      await state.catchVein(io, (data as { catch: number }).catch)
337    }
338    return { props: { ...veinProps(await io.readView(), await io.now(), surfaceOf(e.surface)), isAnswer: true } }
339  })
340
341  // The shop's stats ask for fresh numbers once a second.
342  on('ui.message', { element: 'stats' }, async ($, e) => {
343    if (io === null) return {}
344    lastStats = statsProps((await io.readMine()).save, surfaceOf(e.surface))
345    return { props: { ...lastStats, isAnswer: true } }
346  })
347
348  // The HUD Clients ask for fresh numbers ten times a second, and the band's
349  // reports the presses of its Mine button.
350  on('ui.message', { element: ['hud', 'shophud'] }, async ($, e) => {
351    const isBand = e.element === 'hud'
352    const acked = isBand && io !== null ? await countPresses(io, e.data) : 0
353    const mine = normalizeMine(await read($, MINE))
354    const view = state.fillView(await read($, VIEW))
355    const band = state.fillBand(await read($, BAND))
356    const size = !isBand ? HUD_SHOP : band.mode === 'idle' || band.isCompact ? HUD_IDLE : HUD_WORK
357    const props: HudProps = answerOf(answered.get(e.element), {
358      ...hudProps(mine.save, view, band, isBand ? 'band' : 'shop', await $.clock.now()),
359      ...size,
360      surface: surfaceOf(e.surface),
361      acked,
362      taps: isBand ? taps : 0,
363    })
364    answered.set(e.element, props)
365    lastHud = props
366    if (isBand) lastAcked = acked
367    return { props: { ...props, isAnswer: true } }
368  })
369
370  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
371    const band = state.fillBand(await read($, BAND))
372    if (e.props.hasSurvey || !band.isLoaded || band.isHidden || band.isStopped) {
373      live = null
374      return next(e)
375    }
376    const isOneLine = band.mode === 'idle' || band.isCompact
377    const hudSize = isOneLine ? HUD_IDLE : HUD_WORK
378    const surface = surfaceOf(e.surface)
379    // The Mine button is the HUD's own, so a press answers without a round trip.
380    const props: HudProps = { ...lastHud, variant: 'band', mode: band.mode, surface, acked: lastAcked, taps, isCompact: band.isCompact, ...hudSize, at: handedAt(await $.clock.now()) }
381    // Thanked, the miner stops a moment to tip his hat; while Claude asks, he faces you.
382    const spec = band.tip ? bandSpec(band, 'pause', 'tip') : bandSpec(band, band.mode, band.mode === 'pause' ? 'facing' : undefined)
383    const openShop = () => $.ui.open({ id: PANE, title: 'Sparkmine', focus: true, closeOnEscape: true })
384    let ours: RenderElement
385
386    if (e.surface === 'desktop') {
387      live = null
388      const { Box, Client, Svg, Text } = $.ui.resolve(e)
389      const hud = <Client key="hud" module="./hud.ts" props={props} height={hudSize.height} />
390      const shop = <Client key="shopbtn" module="./shopbutton.ts" props={{}} />
391      // A vein to catch beside the Shop sign, or the frenzy one started; nothing the rest of the time.
392      const vein = <Client key="vein" module="./vein.ts" props={{ vein: null, frenzy: null, surface }} />
393      if (isOneLine) {
394        ours = (
395          <Box flexDirection="row" columnGap={2} alignItems="center">
396            {hud}
397            {shop}
398            {vein}
399          </Box>
400        )
401      } else {
402        // Each producer owned, with its count: the crew fills the band's right side.
403        const crew =
404          band.crew.length === 0 ? null : (
405            <Box flexDirection="row" columnGap={1} alignItems="flex-end">
406              {band.crew.map(member => (
407                <Box flexDirection="column" alignItems="center">
408                  <Svg source={iconSvg(member.id, CREW_ICON)} alt={producer(member.id).name} width={CREW_ICON} height={CREW_ICON} />
409                  <Text dimColor>{`×${member.owned}`}</Text>
410                </Box>
411              ))}
412            </Box>
413          )
414        // One row, in order, nothing stretched apart: where room runs out the crew is cut, never overlapped.
415        // The gold Mine sign leads the HUD, and the wooden Shop sign sits under it at its own
416        // width: a column stretches its children across by default, and a desktop does too.
417        ours = (
418          <Box flexDirection="row" columnGap={2} alignItems="center" borderStyle="round" borderColor="#5e3b1d" paddingX={1}>
419            <Svg
420              source={svgFor(spec, SCALE, true)}
421              alt="A pixel miner digs into a rock wall"
422              width={W * SCALE}
423              height={H * SCALE}
424              isInteractive
425            />
426            <Box flexDirection="column" alignItems="flex-start" flexShrink={0}>
427              {hud}
428              <Box flexDirection="row" columnGap={1} alignItems="center">
429                {shop}
430                {vein}
431              </Box>
432            </Box>
433            {crew === null ? null : (
434              <Box flexShrink={1} overflow="hidden" marginLeft={1}>
435                {crew}
436              </Box>
437            )}
438          </Box>
439        )
440      }
441    } else if (e.surface === 'terminal') {
442      const { Box, Button, Client, Raster } = $.ui.resolve(e)
443      // Mine is the band's own Button here, so its hotkey works: a press of `m`
444      // while the band has the keyboard, which a quick run of clicks on one cell is not.
445      const mine =
446        band.mode === 'idle' ? null : (
447          <Button
448            key="mine"
449            label="Mine"
450            hotkey="m"
451            dimColor={band.mode !== 'work'}
452            onPress={tap}
453          />
454        )
455      const hud = <Client key="hud" module="./hud.ts" props={props} height={hudSize.height} />
456      const shop = <Button key="shop" label="Shop" dimColor={band.mode === 'idle'} onPress={openShop} />
457      const vein = <Client key="vein" module="./vein.ts" props={{ vein: null, frenzy: null, surface }} />
458      const hasRoom = e.props.bodyColumns >= W + HUD_COLUMNS && e.props.maxRows >= ROWS + 2
459      if (isOneLine || !hasRoom) {
460        live = null
461        ours = (
462          <Box flexDirection="row" columnGap={2}>
463            {mine}
464            {hud}
465            {shop}
466            {vein}
467          </Box>
468        )
469      } else {
470        const frame = cellsFor(spec, await $.clock.now())
471        live = { requestId: e.requestId, spec, shown: frame.key }
472        // The terminal draws a Button at the top of its row, whatever the row
473        // aligns: each sits in a slot the counter's height, centered as the
474        // counter is, so Mine, Shop and a vein line up with its first line.
475        const slot = (key: string, child: RenderElement | null) => (
476          <Box key={key} height={hudSize.height}>
477            {child}
478          </Box>
479        )
480        ours = (
481          <Box flexDirection="row" columnGap={2} alignItems="center" borderStyle="round" borderColor="#5e3b1d">
482            <Raster key="scene" columns={W} rows={ROWS} cells={frame.cells} />
483            {slot('mine-slot', mine)}
484            {hud}
485            {slot('shop-slot', shop)}
486            {slot('vein-slot', vein)}
487          </Box>
488        )
489      }
490    } else {
491      live = null
492      return next(e)
493    }
494
495    // Other mods share the band: keep what they draw under ours.
496    const theirs = await next(e)
497    if (theirs.type === 'engine') return ours
498    const { Box } = $.ui.resolve(e)
499    return (
500      <Box flexDirection="column">
501        {ours}
502        {theirs}
503      </Box>
504    )
505  })
506
507  on('ui.render', { component: 'Pane', requestId: 'sparkmine' }, async ($, e) => {
508    const shop = state.fillShop(await read($, SHOP))
509    const band = state.fillBand(await read($, BAND))
510    const act: ui.ShopActions = {
511      buy: id => (io === null ? 0 : state.buyProducer(io, id)),
512      upgrade: id => (io === null ? false : state.buyUpgrade(io, id)),
513      amount: n => (io === null ? undefined : state.setAmount(io, n)),
514      // The question waits on the person, longer than a press may run: it goes on after the press.
515      shaft: () => {
516        if (io !== null) void state.newShaft(io).catch(() => undefined)
517      },
518      tab: tab => (io === null ? undefined : state.setTab(io, tab)),
519      relic: id => (io === null ? false : state.buyRelic(io, id)),
520    }
521    const props: HudProps = { ...lastHud, variant: 'shop', mode: band.mode, acked: 0, ...HUD_SHOP, at: handedAt(await $.clock.now()) }
522    const held = handlesOn(e.surface)
523    const { Text } = $.ui.resolve(e)
524    const notice = band.isStopped ? <Text color="warning">Sparkmine is stopped. /mine start sets it going again.</Text> : null
525    const stats = { ...lastStats, surface: surfaceOf(e.surface) }
526    if (e.surface === 'desktop') {
527      const { Client, Svg } = $.ui.resolve(e)
528      // A still picture of the mine; a while into Claude's idle, the miner has a coffee, then a nap.
529      const header = bandSpec(band, 'pause', band.nap ?? undefined)
530      const { strata } = shop.depth
531      const wall = picture('badges', JSON.stringify(shop.badges.groups), () => badgeWall(shop.badges.groups))
532      return ui.keepHandles(
533        ui.shopTree($.ui.resolve(e), shop, {
534          icon: (id, isDim, size = 24) => <Svg source={iconSvg(id, size, isDim)} alt={id} width={size} height={size} />,
535          header: <Svg source={svgFor(header, HEADER_SCALE)} alt="The mine" width={W * HEADER_SCALE} height={H * HEADER_SCALE} />,
536          live: <Client key="shophud" module="./hud.ts" props={props} height={HUD_SHOP.height} />,
537          stats: <Client key="stats" module="./stats.ts" props={stats} />,
538          notice,
539          pictures: {
540            strata: (
541              <Svg
542                source={picture('strata', JSON.stringify(strata), () => strataSvg(strata))}
543                alt={`The layers in sight: ${strata.map(s => s.name).join(', ')}`}
544              />
545            ),
546            // Drawn interactive, so a pointer over a badge rings it and its title names
547            // it. Its frame takes the size given it, else a browser's small default.
548            badgeWall: (
549              <Svg
550                source={wall.source}
551                alt={`${shop.badges.earned} of ${shop.badges.total} badges`}
552                width={wall.width}
553                height={wall.height}
554                isInteractive
555              />
556            ),
557            bar: (share, color) => <Svg source={barSvg(share, color)} alt={`${Math.round(share * 100)}%`} />,
558          },
559          isRoomy: true,
560        }, act),
561        held,
562      )
563    }
564    if (e.surface === 'terminal') {
565      const { Client } = $.ui.resolve(e)
566      return ui.keepHandles(
567        ui.shopTree($.ui.resolve(e), shop, {
568          icon: () => null,
569          header: null,
570          live: <Client key="shophud" module="./hud.ts" props={props} height={HUD_SHOP.height} />,
571          stats: <Client key="stats" module="./stats.ts" props={stats} />,
572          notice,
573          pictures: null,
574          isRoomy: false,
575        }, act),
576        held,
577      )
578    }
579    return ui.keepHandles(
580      ui.shopTree(
581        $.ui.resolve(e),
582        shop,
583        { icon: () => null, header: null, live: <Text bold>{`✻ ${Math.floor(lastHud.sparks)}`}</Text>, stats: null, notice, pictures: null, isRoomy: false },
584        act,
585      ),
586      held,
587    )
588  })
589
590  // The spinner says what the mine has unlocked. On the desktop its word names
591  // the step Claude is on, so only the plain "Working" changes there.
592  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
593    // Now and then a turn has a rarer word than the mine's own.
594    const band = state.fillBand(await read($, BAND))
595    const word = band.word ?? (await read($, REWARD))
596    if (word === null || band.isStopped) return next(e)
597    if (e.surface === 'desktop' && e.props.word !== 'Working') return next(e)
598    return next({ ...e, props: { ...e.props, word } })
599  })
600
601  on('command.run', { command: 'mine' }, async ($, e) => {
602    const arg = e.args.trim().toLowerCase()
603    if (arg === '' || arg === 'shop') {
604      await $.ui.open({ id: PANE, title: 'Sparkmine', focus: true, closeOnEscape: true })
605      return {}
606    }
607    if (io === null) return { text: 'Sparkmine is still starting.' }
608    switch (arg) {
609      case 'stop':
610      case 'pause':
611        await state.setStopped(io, true)
612        return { text: 'Sparkmine is stopped in every session: nothing is mined and the band is gone. /mine start sets it going again.' }
613      case 'start':
614      case 'resume':
615        await state.setStopped(io, false)
616        return { text: 'Sparkmine is mining again.' }
617      case 'sound':
618      case 'mute': {
619        const wasMuted = (await io.readBand()).isMuted
620        await state.setMuted(io, !wasMuted)
621        return { text: wasMuted ? 'Sounds are on.' : 'Sounds are off in every session. /mine sound turns them back on.' }
622      }
623      case 'compact': {
624        const wasCompact = (await io.readBand()).isCompact
625        await state.setCompact(io, !wasCompact)
626        return { text: wasCompact ? 'The full band is back.' : 'Compact band: one line, even while Claude works. /mine compact again for the full band.' }
627      }
628      case 'hide':
629        await state.setHidden(io, true)
630        return { text: 'Sparkmine is hidden. /mine show brings it back.' }
631      case 'show':
632        await state.setHidden(io, false)
633        return { text: (await io.readBand()).isStopped ? 'Sparkmine is stopped. /mine start sets it going again.' : 'Sparkmine is back above the prompt.' }
634      case 'share': {
635        // Every session's latest mining, then the card: in a code block, so its
636        // lines stay lines and the surface offers to copy it; on the clipboard
637        // where the surface has one (the terminal; a desktop has none yet).
638        await state.sync(io)
639        const card = shareCard((await io.readMine()).save)
640        const copy = await $.ui.copy({ text: card }).catch(() => ({ isCopied: false }))
641        if (copy.isCopied) $.ui.toast('Copied to the clipboard')
642        // The row puts the plugin's name before the text: the block starts on a line of its own.
643        return { text: ['Your mine, ready to paste:', '```', card, '```'].join('\n') }
644      }
645      case 'reset':
646        return { text: (await state.resetMine(io)) ? 'Sparkmine was reset.' : 'Nothing was reset.' }
647      default:
648        return { text: 'Usage: /mine [shop|share|sound|stop|start|compact|hide|show|reset]' }
649    }
650  })
651}
652
hooks/game.ts 1231 lines
1// The economy, as pure functions over plain data. No `$` in here, so the
2// rules can be tested on their own and shared by every drawing.
3
4import type { Counts, Delta, EventKind, FindId, IdleSpan, Mine, ProducerId, RelicId, Save, SecretId, Season, SourceKind, ToolKind, VeinKind } from '../types'
5import {
6  ACHIEVEMENT_BONUS,
7  ACHIEVEMENTS,
8  BIOMES,
9  EVENTS,
10  FIND_CHANCE,
11  FINDS,
12  RARE_WORD_CHANCE,
13  RARE_WORDS,
14  GEM_WORTH,
15  GROWTH,
16  PRODUCERS,
17  RELIC_IDS,
18  RELICS,
19  REWARDS,
20  SALE_GATE,
21  SECRET_IDS,
22  SHAFT_DEPTH,
23  STONE_BONUS,
24  STONE_POWER,
25  STONE_UNIT,
26  SUPPORT,
27  TOOL_BASE,
28  UPGRADES,
29  VEIN,
30  type Achievement,
31  type Goal,
32  type Producer,
33  type Relic,
34  type Unlock,
35  type Upgrade,
36} from './data.ts'
37
38export const TOOL_KINDS: readonly ToolKind[] = ['read', 'edit', 'bash', 'web', 'agent', 'mcp', 'other']
39export const SOURCE_KINDS: readonly SourceKind[] = ['crew', 'click', 'tool', 'turn', 'event', 'vein']
40export const EVENT_KINDS: readonly EventKind[] = ['tests', 'fix', 'build', 'lint', 'commit', 'pr', 'merge', 'longShift', 'streak']
41
42export function fresh(runId: string): Save {
43  return {
44    v: 1,
45    runId,
46    sparks: 0,
47    lifetime: 0,
48    allTime: 0,
49    stones: 0,
50    deepest: 0,
51    shafts: 0,
52    clicks: 0,
53    tools: 0,
54    turns: 0,
55    mined: {},
56    producers: {},
57    upgrades: [],
58    bySource: {},
59    bestTurn: 0,
60    bestStreak: 0,
61    gems: 0,
62    veins: 0,
63    events: {},
64    secrets: [],
65    relics: [],
66    idleAt: 0,
67    writes: {},
68    chain: '',
69  }
70}
71
72export function emptyDelta(runId: string): Delta {
73  return {
74    runId,
75    sparks: 0,
76    earned: 0,
77    clicks: 0,
78    tools: 0,
79    turns: 0,
80    mined: {},
81    producers: {},
82    upgrades: [],
83    bySource: {},
84    bestTurn: 0,
85    bestStreak: 0,
86    gems: 0,
87    veins: 0,
88    events: {},
89    secrets: [],
90    relics: [],
91    idle: null,
92  }
93}
94
95export function newMine(runId: string): Mine {
96  return { save: fresh(runId), pending: emptyDelta(runId), isLoaded: false, writer: '', seq: 0, chain: '', inflight: [] }
97}
98
99const num = (value: unknown): number =>
100  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
101
102function counts<K extends string>(value: unknown, keys: readonly K[]): Counts<K> {
103  const out: Counts<K> = {}
104  if (value === null || typeof value !== 'object') return out
105  for (const key of keys) {
106    const n = num((value as Record<string, unknown>)[key])
107    if (n > 0) out[key] = Math.floor(n)
108  }
109  return out
110}
111
112/** The all-time stats a save or a delta carries, read field by field. */
113function stats(r: Record<string, unknown>) {
114  return {
115    bySource: counts(r.bySource, SOURCE_KINDS),
116    bestTurn: num(r.bestTurn),
117    bestStreak: Math.floor(num(r.bestStreak)),
118    gems: Math.floor(num(r.gems)),
119    veins: Math.floor(num(r.veins)),
120    events: counts(r.events, EVENT_KINDS),
121  }
122}
123
124/** Sessions whose last write a save remembers: the newest this many. */
125const MAX_WRITERS = 32
126
127/** Each session's last write a save holds, read entry by entry, the newest kept. */
128function writes(value: unknown): Record<string, number> {
129  if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
130  const entries = Object.entries(value as Record<string, unknown>).filter(([, seq]) => num(seq) > 0)
131  return Object.fromEntries(entries.slice(-MAX_WRITERS).map(([id, seq]) => [id, Math.floor(num(seq))]))
132}
133
134const PRODUCER_IDS = PRODUCERS.map(p => p.id)
135const UPGRADE_IDS = new Set(UPGRADES.map(u => u.id))
136
137/** A save read back from the store, checked field by field; null when it is not one. */
138export function normalize(raw: unknown): Save | null {
139  if (raw === null || typeof raw !== 'object') return null
140  const r = raw as Record<string, unknown>
141  if (r.v !== 1 || typeof r.runId !== 'string') return null
142  return {
143    v: 1,
144    runId: r.runId,
145    sparks: num(r.sparks),
146    lifetime: num(r.lifetime),
147    allTime: num(r.allTime),
148    // Crystals were the stones of the collapse a new shaft replaced.
149    stones: Math.floor(num(r.stones ?? r.crystals)),
150    deepest: Math.round(num(r.deepest)),
151    shafts: Math.floor(num(r.shafts)),
152    clicks: Math.floor(num(r.clicks)),
153    tools: Math.floor(num(r.tools)),
154    turns: Math.floor(num(r.turns)),
155    mined: counts(r.mined, TOOL_KINDS),
156    producers: counts(r.producers, PRODUCER_IDS),
157    upgrades: Array.isArray(r.upgrades)
158      ? [...new Set(r.upgrades.filter((id): id is string => typeof id === 'string' && UPGRADE_IDS.has(id)))]
159      : [],
160    ...stats(r),
161    secrets: secretsOf(r.secrets),
162    relics: relicsOf(r.relics),
163    idleAt: num(r.idleAt),
164    writes: writes(r.writes),
165    chain: typeof r.chain === 'string' ? r.chain : '',
166  }
167}
168
169const isSecret = (id: unknown): id is SecretId => typeof id === 'string' && (SECRET_IDS as readonly string[]).includes(id)
170
171/** Secrets read back: the known ones, each once. */
172const secretsOf = (value: unknown): SecretId[] => (Array.isArray(value) ? [...new Set(value.filter(isSecret))] : [])
173
174const isRelic = (id: unknown): id is RelicId => typeof id === 'string' && (RELIC_IDS as readonly string[]).includes(id)
175
176/** Relics read back: the known ones, each once; a save from before relics has none. */
177const relicsOf = (value: unknown): RelicId[] => (Array.isArray(value) ? [...new Set(value.filter(isRelic))] : [])
178
179/** A delta's idle span read back; null where it has none, as one kept from before an update. */
180function idleSpan(value: unknown): IdleSpan | null {
181  if (value === null || typeof value !== 'object') return null
182  const r = value as Record<string, unknown>
183  const span = { amount: num(r.amount), from: num(r.from), to: num(r.to) }
184  return span.amount > 0 && span.to >= span.from ? span : null
185}
186
187/**
188 * The session's mine with every field a newer version reads: a mine kept in
189 * the session's state from before an update lacks the stats and the sync's fields.
190 */
191export function normalizeMine(mine: Mine): Mine {
192  const save = normalize(mine.save) ?? fresh('unloaded')
193  const delta = (d: Delta): Delta => ({
194    ...emptyDelta(save.runId),
195    ...d,
196    ...stats(d as unknown as Record<string, unknown>),
197    secrets: secretsOf(d.secrets),
198    relics: relicsOf(d.relics),
199    idle: idleSpan(d.idle),
200  })
201  return {
202    save,
203    pending: delta(mine.pending),
204    isLoaded: mine.isLoaded,
205    writer: typeof mine.writer === 'string' ? mine.writer : '',
206    seq: Math.floor(num(mine.seq)),
207    chain: typeof mine.chain === 'string' ? mine.chain : '',
208    inflight: Array.isArray(mine.inflight) ? mine.inflight.map(delta) : [],
209  }
210}
211
212export function classifyTool(name: string): ToolKind {
213  if (name.startsWith('mcp__')) return 'mcp'
214  switch (name) {
215    case 'Read':
216    case 'Grep':
217    case 'Glob':
218    case 'LS':
219    case 'NotebookRead':
220      return 'read'
221    case 'Edit':
222    case 'Write':
223    case 'MultiEdit':
224    case 'NotebookEdit':
225      return 'edit'
226    case 'Bash':
227    case 'BashOutput':
228    case 'PowerShell':
229      return 'bash'
230    case 'WebFetch':
231    case 'WebSearch':
232      return 'web'
233    case 'Agent':
234    case 'Task':
235      return 'agent'
236    default:
237      return 'other'
238  }
239}
240
241export const owned = (save: Save, id: ProducerId): number => save.producers[id] ?? 0
242
243export const crewSize = (save: Save): number =>
244  PRODUCERS.reduce((sum, p) => sum + owned(save, p.id), 0)
245
246/** The price of the next `n` of a producer when `have` are owned. */
247export function priceOf(producer: Producer, have: number, n = 1): number {
248  let total = 0
249  for (let i = 0; i < n; i += 1) total += Math.ceil(producer.cost * GROWTH ** (have + i))
250  return total
251}
252
253/** How many of a producer `sparks` buys, when `have` are owned: none when it is short of one. */
254export function affordable(p: Producer, have: number, sparks: number): number {
255  if (!(sparks >= priceOf(p, have))) return 0
256  // The sum of the prices without their rounding up says nearly how many; the rounding is checked one by one.
257  let n = Math.max(1, Math.floor(Math.log(1 + (sparks * (GROWTH - 1)) / (p.cost * GROWTH ** have)) / Math.log(GROWTH)))
258  while (n > 1 && priceOf(p, have, n) > sparks) n -= 1
259  while (priceOf(p, have, n + 1) <= sparks) n += 1
260  return n
261}
262
263/**
264 * What a press of Max buys: as many as `sparks` buys, rounded down to a step
265 * (every count to nine, then fives, twenty-fives and hundreds), so its label
266 * changes now and then rather than with every spark; one when none fits, so
267 * the button still says what one costs.
268 */
269export function maxCount(p: Producer, have: number, sparks: number): number {
270  const n = affordable(p, have, sparks)
271  if (n < 10) return Math.max(1, n)
272  if (n < 50) return Math.floor(n / 5) * 5
273  if (n < 300) return Math.floor(n / 25) * 25
274  return Math.floor(n / 100) * 100
275}
276
277export function producer(id: ProducerId): Producer {
278  const found = PRODUCERS.find(p => p.id === id)
279  if (found === undefined) throw new Error(`no producer ${id}`)
280  return found
281}
282
283export const upgrade = (id: string): Upgrade | undefined => UPGRADES.find(u => u.id === id)
284
285export type Multipliers = {
286  global: number
287  click: number
288  clickRate: number
289  /** Sparks a click adds for every producer owned. */
290  clickCrew: number
291  /** The share of the rate a click adds for every producer owned. */
292  clickCrewRate: number
293  /** What everything a click mines is multiplied by. */
294  clickAll: number
295  /** The chance a click strikes a gem, and how many clicks a gem is worth. */
296  gemChance: number
297  gemWorth: number
298  producer: Record<ProducerId, number>
299  tool: Record<ToolKind, number>
300  ore: number
301  idle: number
302  shift: number
303  /** From the layers: how much more often veins show, what the idle crew and work events add. */
304  veinSpeed: number
305  idleMult: number
306  events: number
307  /** From the upgrades: what each work event pays more, and how much longer a frenzy lasts. */
308  event: Record<EventKind, number>
309  frenzyTime: number
310  /** What a caught frenzy multiplies by. */
311  frenzyMult: number
312  /** What a new shaft pays, from the layers: the far side pays more stones. */
313  stones: number
314}
315
316/** What ancient stones add to everything, as a share: each next one adds a little less. */
317export const stoneBonus = (stones: number): number => STONE_BONUS * Math.max(0, stones) ** STONE_POWER
318
319export function multipliers(save: Save): Multipliers {
320  const m: Multipliers = {
321    global: (1 + stoneBonus(save.stones)) * (1 + ACHIEVEMENT_BONUS * achievements(save).length),
322    click: 1,
323    clickRate: 0,
324    clickCrew: 0,
325    clickCrewRate: 0,
326    clickAll: 1,
327    gemChance: 0,
328    gemWorth: GEM_WORTH,
329    producer: Object.fromEntries(PRODUCERS.map(p => [p.id, 1])) as Record<ProducerId, number>,
330    tool: Object.fromEntries(TOOL_KINDS.map(k => [k, 1])) as Record<ToolKind, number>,
331    ore: 0,
332    idle: 0,
333    shift: 0,
334    veinSpeed: 1,
335    idleMult: 1,
336    events: 1,
337    event: Object.fromEntries(EVENT_KINDS.map(k => [k, 1])) as Record<EventKind, number>,
338    frenzyTime: 1,
339    frenzyMult: VEIN.frenzyMult,
340    stones: 1,
341  }
342  // Every layer this run reached adds its perk.
343  for (const biome of BIOMES.slice(0, biomeAt(depth(save)) + 1)) {
344    const perk = biome.perk
345    switch (perk.kind) {
346      case 'none':
347        break
348      case 'click':
349        m.click *= perk.mult
350        break
351      case 'gem':
352        m.gemChance += perk.chance
353        break
354      case 'veins':
355        m.veinSpeed *= perk.speed
356        break
357      case 'idle':
358        m.idleMult *= perk.mult
359        break
360      case 'events':
361        m.events *= perk.mult
362        break
363      case 'global':
364        m.global *= perk.mult
365        break
366      case 'gemWorth':
367        m.gemWorth *= perk.mult
368        break
369      case 'tools':
370        for (const kind of TOOL_KINDS) m.tool[kind] *= perk.mult
371        break
372      case 'stones':
373        m.stones *= perk.mult
374        break
375    }
376  }
377  for (const id of save.upgrades) {
378    const effect = upgrade(id)?.effect
379    if (effect === undefined) continue
380    switch (effect.kind) {
381      case 'click':
382        m.click *= effect.mult
383        break
384      case 'clickRate':
385        m.clickRate += effect.pct / 100
386        break
387      case 'clickCrew':
388        m.clickCrew += effect.per
389        break
390      case 'clickCrewRate':
391        m.clickCrewRate += effect.pct / 100
392        break
393      case 'clickAll':
394        m.clickAll *= effect.mult
395        break
396      case 'gem':
397        m.gemChance += effect.chance
398        break
399      case 'gemWorth':
400        m.gemWorth *= effect.mult
401        break
402      case 'producer':
403        m.producer[effect.id] *= effect.mult
404        break
405      case 'tool':
406        m.tool[effect.tool] *= effect.mult
407        break
408      case 'ore':
409        m.ore += effect.seconds
410        break
411      case 'idle':
412        m.idle = Math.max(m.idle, effect.share)
413        break
414      case 'shift':
415        m.shift += effect.seconds
416        break
417      case 'global':
418        m.global *= effect.mult
419        break
420      case 'events':
421        for (const kind of effect.events) m.event[kind] *= effect.mult
422        break
423      case 'veinSpeed':
424        m.veinSpeed *= effect.mult
425        break
426      case 'frenzy':
427        m.frenzyTime *= effect.mult
428        break
429    }
430  }
431  // The relics owned; the ones that act on a new shaft act in openShaft.
432  for (const r of ownedRelics(save)) {
433    const effect = r.effect
434    switch (effect.kind) {
435      case 'veinSpeed':
436        m.veinSpeed *= effect.mult
437        break
438      case 'gemWorth':
439        m.gemWorth *= effect.mult
440        break
441      case 'idle':
442        m.idleMult *= effect.mult
443        break
444      case 'frenzy':
445        m.frenzyMult = Math.max(m.frenzyMult, effect.mult)
446        break
447      case 'stones':
448        m.stones *= effect.mult
449        break
450      case 'ledger':
451      case 'crew':
452      case 'survey':
453        break
454    }
455  }
456  return m
457}
458
459/** The relics a save owns, in the order the shop lists them. */
460export const ownedRelics = (save: Save): Relic[] => RELICS.filter(r => save.relics.includes(r.id))
461
462export const relic = (id: RelicId): Relic | undefined => RELICS.find(r => r.id === id)
463
464export const hasRelic = (save: Save, id: RelicId): boolean => save.relics.includes(id)
465
466/** Stones found and not yet spent on relics. Two sessions buying at once may overspend: then none are left. */
467export const stonesToSpend = (save: Save): number =>
468  Math.max(0, save.stones - ownedRelics(save).reduce((sum, r) => sum + r.cost, 0))
469
470/** The producers from the top of the ladder down. */
471const TOP_DOWN: readonly Producer[] = [...PRODUCERS].reverse()
472
473/** Sparks per second one of this producer mines on its own, before its share of the crew above. */
474export const ownRate = (p: Producer, m: Multipliers): number => p.rate * m.producer[p.id] * m.global
475
476/**
477 * Sparks per second one of each producer mines while Claude works: its own
478 * rate, and SUPPORT of what the producers above it mine on their own, both
479 * times its upgrades. One pass from the top down.
480 */
481export function unitRates(save: Save, m = multipliers(save)): Record<ProducerId, number> {
482  const out = { ...m.producer }
483  let above = 0
484  for (const p of TOP_DOWN) {
485    const own = ownRate(p, m)
486    out[p.id] = own + SUPPORT * m.producer[p.id] * above
487    above += owned(save, p.id) * own
488  }
489  return out
490}
491
492/** Sparks per second one of this producer mines while Claude works. */
493export function unitRate(save: Save, id: ProducerId, m = multipliers(save)): number {
494  return unitRates(save, m)[id]
495}
496
497/** Sparks per second the whole crew mines while Claude works: unitRates summed in the same pass, with nothing built. */
498export function rate(save: Save, m = multipliers(save)): number {
499  let sum = 0
500  let above = 0
501  for (const p of TOP_DOWN) {
502    const n = owned(save, p.id)
503    const own = ownRate(p, m)
504    sum += n * (own + SUPPORT * m.producer[p.id] * above)
505    above += n * own
506  }
507  return sum
508}
509
510/** What one click mines, gems aside. The picks double the click alone; Obsidian edge lifts the shares of the rate and the crew too. */
511export function clickValue(save: Save, m = multipliers(save)): number {
512  const crew = crewSize(save)
513  return ((m.click + m.clickCrew * crew) * m.global + rate(save, m) * (m.clickRate + m.clickCrewRate * crew)) * m.clickAll
514}
515
516function mix(h: number): number {
517  let x = h
518  x ^= x >>> 16
519  x = Math.imul(x, 0x85ebca6b)
520  x ^= x >>> 13
521  x = Math.imul(x, 0xc2b2ae35)
522  x ^= x >>> 16
523  return x >>> 0
524}
525
526/**
527 * Where the next click lands in [0, 1). The run and the click count decide it,
528 * so a save always strikes the same gems and nothing rerolls a miss.
529 */
530export function clickRoll(save: Save): number {
531  let seed = 2166136261
532  for (let i = 0; i < save.runId.length; i += 1) seed = Math.imul(seed ^ save.runId.charCodeAt(i), 16777619)
533  return mix(seed ^ Math.imul(save.clicks + 1, 0x9e3779b9)) / 2 ** 32
534}
535
536/** A roll in [0, 1) for the run and a label: the same save always rolls the same. */
537export function roll(runId: string, label: string): number {
538  let seed = 2166136261
539  for (const ch of `${runId}:${label}`) seed = Math.imul(seed ^ ch.charCodeAt(0), 16777619)
540  return mix(seed) / 2 ** 32
541}
542
543/** How deep the mine is, in metres: a hundred more for every tenfold of this run's sparks. */
544export const depth = (save: Save): number => Math.round(100 * Math.log10(1 + save.lifetime / 1_000))
545
546/** The layer a depth is in, an index of BIOMES: 0 topsoil, 1 stone, ... 9 the far side. */
547export function biomeAt(metres: number): number {
548  let biome = 0
549  BIOMES.forEach((b, i) => {
550    if (metres >= b.from) biome = i
551  })
552  return biome
553}
554
555/** What a work event pays: seconds of the crew's rate, and never less than its clicks. */
556export function eventValue(save: Save, kind: EventKind, m = multipliers(save)): number {
557  const e = EVENTS[kind]
558  return Math.max(rate(save, m) * e.seconds, clickValue(save, m) * e.clicks) * m.events * m.event[kind]
559}
560
561/** What a rich vein pays. */
562export function veinValue(save: Save, m = multipliers(save)): number {
563  return Math.max(rate(save, m) * VEIN.richSeconds, clickValue(save, m) * VEIN.richClicks)
564}
565
566/**
567 * Seconds of work before the `n`th vein of a session shows, and what kind it
568 * is; `speed` times as often in the magma and below.
569 */
570export function veinAfter(runId: string, n: number, speed = 1): { seconds: number; kind: VeinKind } {
571  const wait = n === 0 ? VEIN.first : VEIN.next
572  return {
573    seconds: Math.round((wait.min + Math.floor(wait.spread * roll(runId, `vein-${n}`))) / speed),
574    kind: roll(runId, `vein-kind-${n}`) < 0.5 ? 'rich' : 'frenzy',
575  }
576}
577
578/** The find the `n`th vein of a run is instead, in a layer that hides one not found yet; the run decides, as for veins. */
579export function findAt(save: Save, n: number): FindId | null {
580  const here = biomeAt(depth(save))
581  for (const [id, find] of Object.entries(FINDS) as [FindId, (typeof FINDS)[FindId]][]) {
582    if (find.biome === here && !save.secrets.includes(id) && roll(save.runId, `find-${n}`) < FIND_CHANCE) return id
583  }
584  return null
585}
586
587/** A test runner at the head of one step of a shell command. */
588const TEST_RUNNERS: readonly RegExp[] = [
589  /^(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/,
590  /^(npx|bunx|pnpm\s+(exec|dlx)|yarn\s+dlx)\s+(vitest|jest|mocha|ava|playwright\s+test)\b/,
591  /^(\.\/node_modules\/\.bin\/)?(vitest|jest|mocha|ava|pytest|rspec|phpunit|ctest)\b/,
592  /^(go|cargo|deno|swift|dotnet|mix|flutter)\s+test\b/,
593  /^cargo\s+nextest\b/,
594  /^make\s+(test|check)\b/,
595  /^python3?\s+-m\s+(pytest|unittest)\b/,
596  /^(\.\/)?(mvnw?|gradlew?)\b.*\btest\b/,
597  /^\S+\s+plugin\s+test\b/,
598]
599
600/** A build at the head of one step: a package's build script, a compiler, a bundler. */
601const BUILDERS: readonly RegExp[] = [
602  /^(npm|pnpm|yarn|bun)\s+(run\s+)?build\b/,
603  /^(npx\s+|bunx\s+|pnpm\s+exec\s+)?(vite|next|nuxt|astro|parcel|svelte-kit)\s+build\b/,
604  /^(npx\s+|bunx\s+|pnpm\s+exec\s+)?(webpack|rollup|esbuild|tsup)\b/,
605  /^(npx\s+|bunx\s+|pnpm\s+exec\s+)?tsc\b(?!.*--noEmit)/,
606  /^(cargo|go|swift|dotnet|zig)\s+build\b/,
607  /^make(\s+(build|all))?$/,
608  /^(\.\/)?(mvnw?|gradlew?)\b.*\b(compile|package|build|assemble)\b/,
609  /^(xcodebuild|bazel\s+build|cmake\s+--build|ninja)\b/,
610  /^python3?\s+-m\s+build\b/,
611]
612
613/** A linter or a type check at the head of one step. */
614const LINTERS: readonly RegExp[] = [
615  /^(npm|pnpm|yarn|bun)\s+(run\s+)?(lint|typecheck|type-check)\b/,
616  /^(npx\s+|bunx\s+|pnpm\s+exec\s+)?(eslint|oxlint|stylelint|biome\s+(check|lint)|prettier\s+(--check|-c))\b/,
617  /^(npx\s+|bunx\s+|pnpm\s+exec\s+)?tsc\b.*--noEmit\b/,
618  /^(python3?\s+-m\s+)?(ruff\s+check|flake8|pylint|mypy|pyright|black\s+--check)\b/,
619  /^(golangci-lint|staticcheck|go\s+vet)\b/,
620  /^cargo\s+(clippy|fmt\s+--check)\b/,
621  /^(rubocop|swiftlint|ktlint|shellcheck|hadolint)\b/,
622  /^\S+\s+plugin\s+validate\b/,
623]
624
625/** A pull request opened, and one merged; `--auto` only asks to merge it later. */
626const OPENS_PR: readonly RegExp[] = [/^gh\s+pr\s+create\b/]
627const MERGES_PR: readonly RegExp[] = [/^gh\s+pr\s+merge\b(?!.*--auto)/]
628
629/** A commit at the head of one step, after git's own options; an amend rewrites one, and a dry run makes none. */
630const COMMITS: readonly RegExp[] = [/^git(\s+(-[cC]\s+("[^"]*"|'[^']*'|\S+)|--[\w-]+(=\S+)?))*\s+commit\b(?!.*\s--(amend|dry-run)\b)/]
631
632/** What git says when a commit had nothing in it. */
633const NOTHING_COMMITTED = /nothing (added )?to commit|no changes added to commit/
634
635/** Each step of a shell command, with the variables set before it dropped. */
636const steps = (command: string): string[] =>
637  command.split(/&&|\|\||;|\||\n/).map(step => step.trim().replace(/^(\w+=\S*\s+)+/, ''))
638
639const runs = (command: string, heads: readonly RegExp[]): boolean => steps(command).some(step => heads.some(re => re.test(step)))
640
641/** Whether a shell command runs a test suite, in any step of it. */
642export const isTestCommand = (command: string): boolean => runs(command, TEST_RUNNERS)
643
644/**
645 * What a finished tool call tells about Claude's work, as plain data the hooks
646 * read off it. `output` is the end of what a shell command printed.
647 */
648export type ToolOutcome = { tool: string; command?: string; isError: boolean; interrupted: boolean; background: boolean; committed: boolean; output?: string }
649
650/** How much of a command's output is read: a test run's summary comes last. */
651const OUTPUT_TAIL = 8_000
652
653/** Reads a finished call's input and result as a ToolOutcome: nothing in either is changed. */
654export function outcomeOf(tool: string, input: unknown, answer: { isError?: unknown; result?: unknown }): ToolOutcome {
655  const args = (input ?? {}) as { command?: unknown; run_in_background?: unknown }
656  const record = (answer.result ?? {}) as { interrupted?: unknown; backgroundTaskId?: unknown; stdout?: unknown; stderr?: unknown; gitOperation?: { commit?: { kind?: unknown } } }
657  const tail = (text: unknown) => (typeof text === 'string' ? text.slice(-OUTPUT_TAIL) : '')
658  const output = `${tail(record.stdout)}\n${tail(record.stderr)}`.trim()
659  const command = typeof args.command === 'string' ? args.command : undefined
660  const isError = answer.isError === true
661  const interrupted = record.interrupted === true
662  const background = args.run_in_background === true || typeof record.backgroundTaskId === 'string'
663  // The engine reads a commit off the line git prints for it, which `git commit -q` keeps to itself: a commit that ran and finished counts too.
664  const ranCommit = command !== undefined && !isError && !interrupted && !background && runs(command, COMMITS) && !NOTHING_COMMITTED.test(output)
665  return {
666    tool,
667    ...(command === undefined ? {} : { command }),
668    isError,
669    interrupted,
670    background,
671    committed: record.gitOperation?.commit?.kind === 'committed' || ranCommit,
672    ...(output === '' ? {} : { output }),
673  }
674}
675
676/**
677 * What test runners print when tests fail. A command whose last step is not
678 * the runner (`npm test 2>&1 | tail`, `npm test; echo done`) succeeds even when
679 * they did, so the output says it: mocha's "2 failing", jest's and pytest's
680 * "1 failed", rspec's "3 failures", jest's and go's FAIL lines, cargo's
681 * "test result: FAILED", unittest's "FAILED (failures=1)", TAP's "not ok".
682 */
683const FAILED: readonly RegExp[] = [
684  /\b[1-9]\d* (failed|failing|failures?)\b/i,
685  /^\s*(FAIL|FAILED)\b/m,
686  /^--- FAIL\b/m,
687  /\btest result: FAILED\b/,
688  /\bFAILED \((failures|errors)=/,
689  /^not ok \d+/m,
690  /^# fail [1-9]/m,
691]
692
693/**
694 * The work events a finished Bash call is, when it succeeded: tests, a build
695 * and a lint that passed, a commit, a pull request opened or merged. One
696 * command can be several (`npm run build && npm test`).
697 */
698export function workEvents(o: ToolOutcome): EventKind[] {
699  if (o.tool !== 'Bash' || o.isError || o.interrupted || o.background) return []
700  const command = o.command ?? ''
701  const events: EventKind[] = []
702  if (testRun(o) === 'passed') events.push('tests')
703  if (runs(command, BUILDERS)) events.push('build')
704  if (runs(command, LINTERS)) events.push('lint')
705  if (o.committed) events.push('commit')
706  if (runs(command, OPENS_PR)) events.push('pr')
707  if (runs(command, MERGES_PR)) events.push('merge')
708  return events
709}
710
711/**
712 * How a finished Bash call ran the tests, if it did: failed when the call
713 * failed or its output says tests failed. A run cut short or sent to the
714 * background tells nothing.
715 */
716export function testRun(o: ToolOutcome): 'passed' | 'failed' | null {
717  if (o.tool !== 'Bash' || o.interrupted || o.background || o.command === undefined || !isTestCommand(o.command)) return null
718  return o.isError || FAILED.some(re => re.test(o.output ?? '')) ? 'failed' : 'passed'
719}
720
721/** What the next click mines, and whether it strikes a gem. */
722export function strike(save: Save, m = multipliers(save)): { value: number; isGem: boolean } {
723  const value = clickValue(save, m)
724  const isGem = m.gemChance > 0 && clickRoll(save) < m.gemChance
725  return { value: isGem ? value * m.gemWorth : value, isGem }
726}
727
728/** What one tool call mines: its base and its seconds of the rate, both times what its kind is multiplied by. */
729export function toolValue(save: Save, kind: ToolKind, m = multipliers(save)): number {
730  return (TOOL_BASE[kind] * m.global + rate(save, m) * m.ore) * m.tool[kind]
731}
732
733export function shiftValue(save: Save, m = multipliers(save)): number {
734  return rate(save, m) * m.shift
735}
736
737export function idleShare(save: Save, m = multipliers(save)): number {
738  return Math.min(1, m.idle * m.idleMult)
739}
740
741function have(save: Save, unlock: Unlock): number {
742  switch (unlock.kind) {
743    case 'clicks':
744      return save.clicks
745    case 'tools':
746      return save.tools
747    case 'turns':
748      return save.turns
749    case 'mined':
750      return save.mined[unlock.tool] ?? 0
751    case 'owned':
752      return owned(save, unlock.id)
753    case 'crew':
754      return crewSize(save)
755    case 'earned':
756      return save.allTime
757    case 'veins':
758      return save.veins
759    case 'events':
760      return unlock.events.reduce((sum, kind) => sum + (save.events[kind] ?? 0), 0)
761  }
762}
763
764/** The sparks this run must mine before an upgrade goes on sale: none for a producer's own. */
765export const saleGate = (u: Upgrade): number => (u.effect.kind === 'producer' ? 0 : SALE_GATE * u.cost)
766
767/**
768 * How far an upgrade is from going on sale: its unlock, or this run's sparks
769 * against its sale gate when that is further off (`isSparks` says so).
770 */
771export function progress(save: Save, u: Upgrade): { have: number; need: number; isSparks?: true } {
772  const gate = saleGate(u)
773  const unlock = { have: have(save, u.unlock), need: u.unlock.n }
774  return gate > 0 && save.lifetime / gate < unlock.have / unlock.need ? { have: save.lifetime, need: gate, isSparks: true } : unlock
775}
776
777/** Whether the upgrade's own unlock is reached, and this run has mined enough for its price. */
778const isReached = (save: Save, u: Upgrade): boolean => have(save, u.unlock) >= u.unlock.n && save.lifetime >= saleGate(u)
779
780/** Whether the cheaper upgrade that does the same is bought, when there is one. */
781const isInTurn = (save: Save, u: Upgrade): boolean => u.after === undefined || save.upgrades.includes(u.after)
782
783export const isUnlocked = (save: Save, u: Upgrade): boolean => isInTurn(save, u) && isReached(save, u)
784
785export const hasUpgrade = (save: Save, id: string): boolean => save.upgrades.includes(id)
786
787/** The pick upgrades, in the order the miner's pick gets better. */
788export const PICK_TIERS = ['pick-1', 'pick-2', 'pick-3', 'pick-6'] as const
789
790/** Which pick the miner holds: 0 for the worn iron one he starts with. */
791export function pickTier(save: Save): number {
792  let tier = 0
793  PICK_TIERS.forEach((id, i) => {
794    if (hasUpgrade(save, id)) tier = Math.max(tier, i + 1)
795  })
796  return tier
797}
798
799/** What the shop lists: the upgrades on sale, cheapest first, and the next one to unlock. */
800export function shopUpgrades(save: Save): { onSale: Upgrade[]; next: Upgrade | null } {
801  const left = UPGRADES.filter(u => !hasUpgrade(save, u.id))
802  const onSale = left.filter(u => isUnlocked(save, u))
803  // The next goal is short of its unlock, and next in its line or behind one on sale now.
804  const locked = left.filter(u => !isReached(save, u) && (isInTurn(save, u) || onSale.some(o => o.id === u.after)))
805  locked.sort((a, b) => {
806    const pa = progress(save, a)
807    const pb = progress(save, b)
808    return pb.have / pb.need - pa.have / pa.need || a.cost - b.cost
809  })
810  return { onSale, next: locked[0] ?? null }
811}
812
813/** The deepest any run reached, this one included, in metres. */
814export const deepestOf = (save: Save): number => Math.max(save.deepest, depth(save))
815
816/** How many ancient stones a new shaft would pay now: none until the run reaches SHAFT_DEPTH. */
817export function stoneGain(save: Save): number {
818  return depth(save) < SHAFT_DEPTH ? 0 : Math.floor(Math.cbrt(save.lifetime / STONE_UNIT) * multipliers(save).stones)
819}
820
821/** How far a save has come toward a goal: the record or count it measures. */
822function reached(save: Save, goal: Goal): number {
823  switch (goal.kind) {
824    case 'veins':
825      return save.veins
826    case 'gems':
827      return save.gems
828    case 'clicks':
829      return save.clicks
830    case 'tools':
831      return save.tools
832    case 'shafts':
833      return save.shafts
834    case 'stones':
835      return save.stones
836    case 'bestTurn':
837      return save.bestTurn
838    case 'bestStreak':
839      return save.bestStreak
840    case 'allTime':
841      return save.allTime
842    case 'deepest':
843      return deepestOf(save)
844    case 'event':
845      return save.events[goal.event] ?? 0
846    case 'secret':
847      return save.secrets.includes(goal.secret) ? 1 : 0
848  }
849}
850
851export const isEarned = (save: Save, a: Achievement): boolean => reached(save, a.goal) >= a.goal.n
852
853/** How far a save has come toward an achievement, up to its goal. */
854export const achievementProgress = (save: Save, a: Achievement): { have: number; need: number } => ({
855  have: Math.min(reached(save, a.goal), a.goal.n),
856  need: a.goal.n,
857})
858
859/**
860 * The achievements a save has earned. Each counts a record that only grows and
861 * survives a new shaft, so they are read off the save and never stored.
862 */
863export const achievements = (save: Save): Achievement[] => ACHIEVEMENTS.filter(a => isEarned(save, a))
864
865/** A badge that keeps its name to itself until earned: a secret, or a hidden layer's. */
866export const isSurprise = (a: Achievement): boolean => a.goal.kind === 'secret' || a.isHidden === true
867
868/** The unearned achievement closest to done, by share of its goal; never a surprise. */
869export function nextAchievement(save: Save): Achievement | null {
870  let best: { a: Achievement; share: number } | null = null
871  for (const a of ACHIEVEMENTS) {
872    if (isSurprise(a) || isEarned(save, a)) continue
873    const share = reached(save, a.goal) / a.goal.n
874    if (best === null || share > best.share) best = { a, share }
875  }
876  return best?.a ?? null
877}
878
879export function rewardWord(allTime: number): string | null {
880  let word: string | null = null
881  for (const reward of REWARDS) if (allTime >= reward.at) word = reward.word
882  return word
883}
884
885function add<K extends string>(into: Counts<K>, from: Counts<K>): Counts<K> {
886  const out: Counts<K> = { ...into }
887  for (const key of Object.keys(from) as K[]) out[key] = (out[key] ?? 0) + (from[key] ?? 0)
888  return out
889}
890
891export type Source =
892  | { kind: 'click'; isGem?: boolean }
893  | { kind: 'tool'; tool: ToolKind }
894  | { kind: 'turn' }
895  | { kind: 'crew' }
896  | { kind: 'event'; event: EventKind }
897  | { kind: 'vein' }
898
899/** Adds sparks to the mine, and counts what earned them. */
900export function earn(mine: Mine, amount: number, source: Source): Mine {
901  const gain = Number.isFinite(amount) && amount > 0 ? amount : 0
902  const s = mine.save
903  const d = mine.pending
904  const clicks = source.kind === 'click' ? 1 : 0
905  const tools = source.kind === 'tool' ? 1 : 0
906  const turns = source.kind === 'turn' ? 1 : 0
907  const gems = source.kind === 'click' && source.isGem === true ? 1 : 0
908  const veins = source.kind === 'vein' ? 1 : 0
909  const mined: Counts<ToolKind> = source.kind === 'tool' ? { [source.tool]: 1 } : {}
910  const bySource: Counts<SourceKind> = gain > 0 ? { [source.kind]: gain } : {}
911  const events: Counts<EventKind> = source.kind === 'event' ? { [source.event]: 1 } : {}
912  return {
913    ...mine,
914    save: {
915      ...s,
916      sparks: s.sparks + gain,
917      lifetime: s.lifetime + gain,
918      allTime: s.allTime + gain,
919      clicks: s.clicks + clicks,
920      tools: s.tools + tools,
921      turns: s.turns + turns,
922      mined: add(s.mined, mined),
923      bySource: add(s.bySource, bySource),
924      gems: s.gems + gems,
925      veins: s.veins + veins,
926      events: add(s.events, events),
927    },
928    pending: {
929      ...d,
930      sparks: d.sparks + gain,
931      earned: d.earned + gain,
932      clicks: d.clicks + clicks,
933      tools: d.tools + tools,
934      turns: d.turns + turns,
935      mined: add(d.mined, mined),
936      bySource: add(d.bySource, bySource),
937      gems: d.gems + gems,
938      veins: d.veins + veins,
939      events: add(d.events, events),
940    },
941  }
942}
943
944/**
945 * The crew's pay for the moments from `from` to `to`. Its idle pace is the
946 * mine's: every open session mines it, so each one's counter runs smoothly, and
947 * the moments it spans go with it, so the save takes it once. What a session
948 * adds on top is its own: Claude's work lifting the crew to its full rate, a
949 * frenzy multiplying it.
950 */
951export function payCrew(mine: Mine, idle: number, more: number, from: number, to: number): Mine {
952  const paid = earn(mine, idle + more, { kind: 'crew' })
953  if (!(idle > 0) || !(to >= from)) return paid
954  const span = paid.pending.idle
955  const next: IdleSpan =
956    span === null ? { amount: idle, from, to } : { amount: span.amount + idle, from: Math.min(span.from, from), to: Math.max(span.to, to) }
957  return { ...paid, pending: { ...paid.pending, idle: next } }
958}
959
960/** Records a secret found, in the save and in what the next sync carries; once. */
961export function findSecret(mine: Mine, id: SecretId): Mine {
962  if (mine.save.secrets.includes(id)) return mine
963  return {
964    ...mine,
965    save: { ...mine.save, secrets: [...mine.save.secrets, id] },
966    pending: { ...mine.pending, secrets: [...mine.pending.secrets, id] },
967  }
968}
969
970/** Thanks in the person's prompt, in a few languages. */
971const THANKS: readonly RegExp[] = [/\b(thanks|thank you|thx|ty)\b/i, /teşekkür|sağ ?ol/i, /\b(danke|merci|gracias|grazie|obrigad[oa])\b/i]
972
973/** The secrets a prompt finds: thanks, and asking Claude to ultrathink. */
974export function promptSecrets(text: string): SecretId[] {
975  return [...(THANKS.some(re => re.test(text)) ? (['good-manners'] as const) : []), ...(/\bultrathink\b/i.test(text) ? (['deep-thinker'] as const) : [])]
976}
977
978/** Claude's own catchphrase, in an answer. */
979export const isAbsolutelyRight = (answer: string): boolean => /\byou(?:'|’|\s+a)?re absolutely right\b/i.test(answer)
980
981/** A Friday after four, where the person is. */
982export function isFridayAfternoon(ms: number): boolean {
983  const day = new Date(ms)
984  return day.getDay() === 5 && day.getHours() >= 16
985}
986
987/** A turn that ended on another day than it began, where the person is. */
988export const crossesMidnight = (endMs: number, durationMs: number): boolean =>
989  durationMs > 0 && new Date(endMs - durationMs).toDateString() !== new Date(endMs).toDateString()
990
991/** The rarer word a turn's spinner says, if it is one of the few: the turn decides. */
992export function rareWord(turnId: string): string | null {
993  if (roll(turnId, 'spinner') >= RARE_WORD_CHANCE) return null
994  return RARE_WORDS[Math.floor(roll(turnId, 'spinner-word') * RARE_WORDS.length)] ?? null
995}
996
997/** The season on the person's calendar: the last week of October, and December. */
998export function seasonOf(ms: number): Season | null {
999  const day = new Date(ms)
1000  if (day.getMonth() === 9 && day.getDate() >= 24) return 'halloween'
1001  if (day.getMonth() === 11) return 'winter'
1002  return null
1003}
1004
1005/** A commit that landed with "initial commit" in its command. */
1006export const isInitialCommit = (o: ToolOutcome): boolean => o.committed && /initial commit/i.test(o.command ?? '')
1007
1008/** Keeps the best turn yet, in the save and in what the next sync carries. */
1009export function recordTurn(mine: Mine, earned: number): Mine {
1010  if (!(earned > mine.save.bestTurn)) return mine
1011  return { ...mine, save: { ...mine.save, bestTurn: earned }, pending: { ...mine.pending, bestTurn: Math.max(mine.pending.bestTurn, earned) } }
1012}
1013
1014/** Keeps the longest streak of good turns yet, in the save and in what the next sync carries. */
1015export function recordStreak(mine: Mine, streak: number): Mine {
1016  if (!(streak > mine.save.bestStreak)) return mine
1017  return { ...mine, save: { ...mine.save, bestStreak: streak }, pending: { ...mine.pending, bestStreak: Math.max(mine.pending.bestStreak, streak) } }
1018}
1019
1020export function buyProducer(mine: Mine, id: ProducerId, n: number): Mine | null {
1021  const count = Math.max(1, Math.floor(n))
1022  const price = priceOf(producer(id), owned(mine.save, id), count)
1023  if (mine.save.sparks < price) return null
1024  return {
1025    ...mine,
1026    save: {
1027      ...mine.save,
1028      sparks: mine.save.sparks - price,
1029      producers: add(mine.save.producers, { [id]: count }),
1030    },
1031    pending: {
1032      ...mine.pending,
1033      sparks: mine.pending.sparks - price,
1034      producers: add(mine.pending.producers, { [id]: count }),
1035    },
1036  }
1037}
1038
1039export function buyUpgrade(mine: Mine, id: string): Mine | null {
1040  const u = upgrade(id)
1041  if (u === undefined || hasUpgrade(mine.save, id) || !isUnlocked(mine.save, u)) return null
1042  if (mine.save.sparks < u.cost) return null
1043  return {
1044    ...mine,
1045    save: { ...mine.save, sparks: mine.save.sparks - u.cost, upgrades: [...mine.save.upgrades, id] },
1046    pending: {
1047      ...mine.pending,
1048      sparks: mine.pending.sparks - u.cost,
1049      upgrades: [...mine.pending.upgrades, id],
1050    },
1051  }
1052}
1053
1054/** Buys a relic with stones not yet spent; null when it is owned or they are too few. */
1055export function buyRelic(mine: Mine, id: RelicId): Mine | null {
1056  const r = relic(id)
1057  if (r === undefined || hasRelic(mine.save, id) || stonesToSpend(mine.save) < r.cost) return null
1058  return {
1059    ...mine,
1060    save: { ...mine.save, relics: [...mine.save.relics, id] },
1061    pending: { ...mine.pending, relics: [...mine.pending.relics, id] },
1062  }
1063}
1064
1065/** Lifetime sparks at a depth: the inverse of `depth`. */
1066const lifetimeAt = (metres: number): number => Math.ceil(1_000 * (10 ** (metres / 100) - 1))
1067
1068/**
1069 * Prestige: a new shaft in fresh rock. Sparks, producers, upgrades and depth
1070 * go; ancient stones, relics, all-time sparks, the records and the counters
1071 * stay. The relics give the new shaft its head start. Null until the run is
1072 * deep enough to pay a stone.
1073 */
1074export function openShaft(save: Save, runId: string): Save | null {
1075  const gain = stoneGain(save)
1076  if (gain < 1) return null
1077  const start = { producers: {} as Counts<ProducerId>, upgrades: [] as string[], lifetime: 0 }
1078  for (const r of ownedRelics(save)) {
1079    const effect = r.effect
1080    if (effect.kind === 'ledger') start.upgrades = save.upgrades.slice(0, effect.n)
1081    if (effect.kind === 'crew') start.producers = add(start.producers, effect.producers)
1082    if (effect.kind === 'survey') start.lifetime = Math.max(start.lifetime, lifetimeAt(effect.metres))
1083  }
1084  return {
1085    ...fresh(runId),
1086    ...start,
1087    allTime: save.allTime,
1088    stones: save.stones + gain,
1089    deepest: deepestOf(save),
1090    shafts: save.shafts + 1,
1091    clicks: save.clicks,
1092    tools: save.tools,
1093    turns: save.turns,
1094    mined: save.mined,
1095    bySource: save.bySource,
1096    bestTurn: save.bestTurn,
1097    bestStreak: save.bestStreak,
1098    gems: save.gems,
1099    veins: save.veins,
1100    events: save.events,
1101    secrets: save.secrets,
1102    relics: save.relics,
1103    idleAt: save.idleAt,
1104  }
1105}
1106
1107/**
1108 * The part of a delta's idle pace the save holds already: another session
1109 * mined the same moments and gave them first.
1110 */
1111function idlePaid(d: Delta, idleAt: number): number {
1112  const span = d.idle
1113  if (span === null || !(span.amount > 0) || idleAt <= span.from) return 0
1114  if (idleAt >= span.to) return span.amount
1115  return span.amount * ((idleAt - span.from) / (span.to - span.from))
1116}
1117
1118/**
1119 * Lays one session's changes over the save in the store. Several sessions
1120 * can mine at once, so each keeps what it changed and adds it on sync, rather
1121 * than writing its own copy over theirs.
1122 */
1123export function applyDelta(stored: Save, d: Delta): Save {
1124  const sameRun = stored.runId === d.runId
1125  // The idle pace of moments the save holds already is not paid again.
1126  const paid = idlePaid(d, stored.idleAt)
1127  const earned = Math.max(0, d.earned - paid)
1128  const bySource = paid > 0 ? { ...d.bySource, crew: Math.max(0, (d.bySource.crew ?? 0) - paid) } : d.bySource
1129  return {
1130    ...stored,
1131    // What an older run mined went with it: a new shaft starts from nothing, and only the all-time records keep it.
1132    sparks: sameRun ? Math.max(0, stored.sparks + d.sparks - paid) : stored.sparks,
1133    lifetime: sameRun ? stored.lifetime + earned : stored.lifetime,
1134    allTime: stored.allTime + earned,
1135    clicks: stored.clicks + d.clicks,
1136    tools: stored.tools + d.tools,
1137    turns: stored.turns + d.turns,
1138    mined: add(stored.mined, d.mined),
1139    producers: sameRun ? add(stored.producers, d.producers) : stored.producers,
1140    upgrades: sameRun ? [...new Set([...stored.upgrades, ...d.upgrades])] : stored.upgrades,
1141    bySource: add(stored.bySource, bySource),
1142    bestTurn: Math.max(stored.bestTurn, d.bestTurn),
1143    bestStreak: Math.max(stored.bestStreak, d.bestStreak),
1144    gems: stored.gems + d.gems,
1145    veins: stored.veins + d.veins,
1146    events: add(stored.events, d.events),
1147    secrets: [...new Set([...stored.secrets, ...d.secrets])],
1148    // Relics are bought with stones, which outlast any run: one bought in an older run still counts.
1149    relics: [...new Set([...stored.relics, ...d.relics])],
1150    idleAt: Math.max(stored.idleAt, d.idle?.to ?? 0),
1151  }
1152}
1153
1154/** Notes a session's write in the save's `writes`, as its newest. */
1155function wrote(writes: Record<string, number>, writer: string, seq: number): Record<string, number> {
1156  const others = Object.entries(writes).filter(([id]) => id !== writer)
1157  return Object.fromEntries([...others.slice(-(MAX_WRITERS - 1)), [writer, seq]])
1158}
1159
1160/**
1161 * The chain a save with none yet starts: named after the save itself, so
1162 * sessions that read it at once start the same chain, and its record shows
1163 * whose write went missing. A save that changed in between, as an older
1164 * version's write changes it, starts a chain of its own.
1165 */
1166function chainOf(save: Save): string {
1167  const text = JSON.stringify(save)
1168  let a = 2166136261
1169  let b = 0x9e3779b9
1170  for (let i = 0; i < text.length; i += 1) {
1171    const c = text.charCodeAt(i)
1172    a = Math.imul(a ^ c, 16777619)
1173    b = Math.imul(b ^ c, 0x5bd1e995)
1174  }
1175  return `${mix(a).toString(36).padStart(7, '0')}${mix(b).toString(36).padStart(7, '0')}`
1176}
1177
1178/**
1179 * Brings a session's mine in line with the save in the store, and says what
1180 * to write back. Every session writes the one save: it reads it, lays its own
1181 * changes over and writes it back. Two that read it at once both write, and the
1182 * later write drops the earlier one's changes, so a purchase would come undone.
1183 * So the save keeps the number of each session's last write, and a session
1184 * whose write is missing lays those changes over again.
1185 *
1186 * That record holds only along one chain of writes. A save starts one at its
1187 * first write: a new save, a new shaft and a reset have none. Off the chain (an
1188 * older version of the mod wrote the save in between) the write may be in or
1189 * not, and it is left as it is: a change lost now and then, never one counted twice.
1190 *
1191 * @param newId a fresh id: the session's name in `writes` when it has none yet
1192 */
1193export function syncMine(stored: Save | null, mine: Mine, newId: string): { mine: Mine; save: Save; isWrite: boolean } {
1194  const writer = mine.writer === '' ? newId : mine.writer
1195  const base = stored ?? mine.save
1196  const isLost = base.chain !== '' && base.chain === mine.chain && (base.writes[writer] ?? 0) < mine.seq
1197  // The changes of an older run went with it on purpose: a new shaft opened, or the mine was reset.
1198  const lost = isLost ? mine.inflight.filter(d => d.runId === base.runId) : []
1199  // A reset elsewhere took even the all-time records back to nothing, what this session mined since it last synced too.
1200  // Only a reset makes them fall: a new shaft never halves them.
hooks/icons.ts 848 lines
1// Pixel icons for the shop, 12 x 12, drawn as SVG images on the desktop.
2
3import type { IconId } from '../types'
4
5const PALETTE: Readonly<Record<string, number>> = {
6  K: 0x1d1512,
7  Y: 0xf2b632,
8  y: 0xc7861d,
9  L: 0xfff4b8,
10  S: 0xf0c08a,
11  E: 0x1d1512,
12  B: 0x7a4a26,
13  R: 0xc2412d,
14  O: 0x3a6ea5,
15  G: 0x4b2f1c,
16  M: 0xaab4bf,
17  m: 0x6c7682,
18  W: 0xe6edf3,
19  H: 0x9c6b3c,
20  g: 0x6fa84a,
21  A: 0xf2b632,
22  a: 0xffe08a,
23  T: 0x5dcaa5,
24  t: 0xb5f0dc,
25  F: 0xff7a2f,
26  f: 0xffd36e,
27  P: 0x7f77dd,
28  p: 0xcecbf6,
29  C: 0xfff4b8,
30  N: 0x26215c,
31  Z: 0x5f5e5a,
32  z: 0xb4b2a9,
33  D: 0x5fd4e8,
34  d: 0xd8fbff,
35  X: 0x3a2f5a,
36  x: 0xb6a6ff,
37  Q: 0xff4fa3,
38  q: 0xffb3d6,
39}
40
41const ICONS: Readonly<Record<IconId, readonly string[]>> = {
42  apprentice: [
43    '....KKKK....',
44    '...KYYYYK...',
45    '..KYYYYYYKL.',
46    '..KyyyyyyyK.',
47    '..KSSSSSSK..',
48    '..KSESSESK..',
49    '..KSSSSSSK..',
50    '...KBBBBK...',
51    '..KRRBBRRK..',
52    '.KRRRRRRRRK.',
53    '.KRROOOORRK.',
54    '.KKKKKKKKKK.',
55  ],
56  cart: [
57    '............',
58    '....aAaA....',
59    '..AaAAaAaA..',
60    '.WWWWWWWWWW.',
61    '.KMMMMMMMMK.',
62    '.KMmMMmMMmK.',
63    '.KMMMMMMMMK.',
64    '..KKKKKKKK..',
65    '..KmK..KmK..',
66    '..KKK..KKK..',
67    'zzzzzzzzzzzz',
68    'H..H..H..H..',
69  ],
70  goblin: [
71    'g..........g',
72    'gg..gggg..gg',
73    '.gggggggggg.',
74    '..gggggggg..',
75    '..gaggggag..',
76    '..gggggggg..',
77    '...gKKKKg...',
78    '....gggg....',
79    '...BBBBBB...',
80    '..BBBBBBBB..',
81    '..BB.BB.BB..',
82    '...K....K...',
83  ],
84  drill: [
85    '.zz.........',
86    'zzzz........',
87    '.zz.........',
88    '..KK........',
89    '.KMMMMK.....',
90    '.KMRRMKmm...',
91    '.KMRRMKmmWW.',
92    '.KMMMMKmmWWW',
93    '.KMMMMKmm...',
94    '.KKKKKK.....',
95    '.KmK.KmK....',
96    '..K...K.....',
97  ],
98  crew: [
99    '............',
100    '.Y...Y...Y..',
101    'YYY.YYY.YYY.',
102    'yyy.yyy.yyy.',
103    'SSS.SSS.SSS.',
104    'ESE.ESE.ESE.',
105    'SSS.SSS.SSS.',
106    'RRR.RRR.RRR.',
107    'RRR.RRR.RRR.',
108    'OOO.OOO.OOO.',
109    'O.O.O.O.O.O.',
110    'G.G.G.G.G.G.',
111  ],
112  cache: [
113    '............',
114    '.....t......',
115    '....tTt.....',
116    '...tTTTt.t..',
117    '...TTTTT.Tt.',
118    '..t.TTT.TTT.',
119    '.tTt.T.TTTT.',
120    '.TTT...TTT..',
121    'TTTTT.ZZZZ..',
122    'ZZZZZZZZZZZZ',
123    'ZzZZzZZZzZZZ',
124    'ZZZZZZZZZZZZ',
125  ],
126  refinery: [
127    '...zz.......',
128    '..zzzz......',
129    '...zz.......',
130    '....KK......',
131    '..KKKKKKKK..',
132    '..KmmmmmmK..',
133    '..KmKKKKmK..',
134    '..KmKfFKmK..',
135    '..KmKFfKmK..',
136    '..KmKKKKmK..',
137    '..KmmmmmmK..',
138    '.KKKKKKKKKK.',
139  ],
140  furnace: [
141    '..f..F..f...',
142    '.fF.fFf.Ff..',
143    '.KKKKKKKKKK.',
144    'MKNNNNNNNNKM',
145    '.KNDDDDDDNK.',
146    'MKNDFffFDNKM',
147    '.KNDfFFfDNK.',
148    'MKNDDDDDDNKM',
149    '.KNNNNNNNNK.',
150    'MKKKKKKKKKKM',
151    '..M.M..M.M..',
152    '............',
153  ],
154  datacenter: [
155    '.KKKKKKKKKK.',
156    '.KZZZZZZZZK.',
157    '.KZmmmmgaZK.',
158    '.KZZZZZZZZK.',
159    '.KZmmmmTgZK.',
160    '.KZZZZZZZZK.',
161    '.KZmmmmgTZK.',
162    '.KZZZZZZZZK.',
163    '.KZmmmmagZK.',
164    '.KZZZZZZZZK.',
165    '.KKKKKKKKKK.',
166    '..KK....KK..',
167  ],
168  core: [
169    '....PPPP....',
170    '..PPppppPP..',
171    '.PppCCCCppP.',
172    '.PpCCaaCCpP.',
173    'PpCCaaaaCCpP',
174    'PpCaaaaaaCpP',
175    'PpCaaaaaaCpP',
176    'PpCCaaaaCCpP',
177    '.PpCCaaCCpP.',
178    '.PppCCCCppP.',
179    '..PPppppPP..',
180    '....PPPP....',
181  ],
182  pick: [
183    '...WMMMMW...',
184    '..W.MMMM.W..',
185    '.W...HH...W.',
186    '.....HH.....',
187    '.....HH.....',
188    '....HH......',
189    '....HH......',
190    '...HH.......',
191    '...HH.......',
192    '..HH........',
193    '..HH........',
194    '............',
195  ],
196  pickDiamond: [
197    '...dDDDDd...',
198    '..d.DDDD.d..',
199    '.d...HH...d.',
200    '.....HH.....',
201    '.....AA.....',
202    '....HH......',
203    '....HH......',
204    '...HH.......',
205    '...HH.......',
206    '..HH........',
207    '..HH........',
208    '............',
209  ],
210  pickObsidian: [
211    '...xXXXXx...',
212    '..x.XXXX.x..',
213    '.x...KK...x.',
214    '.....KK.....',
215    '.....PP.....',
216    '....KK......',
217    '....KK......',
218    '...KK.......',
219    '...KK.......',
220    '..KK........',
221    '..KK........',
222    '............',
223  ],
224  glasses: [
225    '............',
226    '............',
227    '............',
228    '.KKKK..KKKK.',
229    'KTtTK..KTtTK',
230    'KTTTKKKKTTTK',
231    'KTTTK..KTTTK',
232    '.KKK....KKK.',
233    '............',
234    '............',
235    '............',
236    '............',
237  ],
238  dynamite: [
239    '.........a..',
240    '........fa..',
241    '.......H....',
242    '......H.....',
243    '..KKKKKK....',
244    '.KRRRRRRK...',
245    '.KRRRRRRK...',
246    '.KCCCCCCK...',
247    '.KRRRRRRK...',
248    '.KRRRRRRK...',
249    '..KKKKKK....',
250    '............',
251  ],
252  terminal: [
253    '............',
254    'KKKKKKKKKKKK',
255    'KZZZZZZZZZZK',
256    'KNNNNNNNNNNK',
257    'KNaNNNNNNNNK',
258    'KNNaNNNNNNNK',
259    'KNaNNNNNNNNK',
260    'KNNNNaaaNNNK',
261    'KNNNNNNNNNNK',
262    'KKKKKKKKKKKK',
263    '............',
264    '............',
265  ],
266  globe: [
267    '....OOOO....',
268    '..OOggOOOO..',
269    '.OgggOOOggO.',
270    '.OggOOOOggO.',
271    'OOgOOOOOOgOO',
272    'OOOOOggOOOOO',
273    'OOOOgggOOOOO',
274    'OgOOOggOOOgO',
275    '.OgOOOOOOgO.',
276    '.OOOOOOOOOO.',
277    '..OOOOOOOO..',
278    '....OOOO....',
279  ],
280  plug: [
281    '...M....M...',
282    '...M....M...',
283    '..KKKKKKKK..',
284    '..KmmmmmmK..',
285    '..KmmmmmmK..',
286    '...KmmmmK...',
287    '....KmmK....',
288    '.....HH.....',
289    '.....HH.....',
290    '......HH....',
291    '.......HH...',
292    '........HH..',
293  ],
294  nugget: [
295    '............',
296    '............',
297    '....aaa.....',
298    '..aAAAAa....',
299    '.aAAaAAAA...',
300    '.AAAAAAyAa..',
301    'AAyAAAAAAAA.',
302    '.AAAAyAAAAy.',
303    '..yAAAAAyy..',
304    '...yyyyy....',
305    '............',
306    '............',
307  ],
308  moon: [
309    '....CCCC....',
310    '..CCCC......',
311    '.CCCC.......',
312    '.CCC........',
313    'CCCC........',
314    'CCCC.....a..',
315    'CCCC........',
316    'CCCC..a.....',
317    '.CCC........',
318    '.CCCC.....C.',
319    '..CCCCCCCC..',
320    '....CCCC....',
321  ],
322  clock: [
323    '....KKKK....',
324    '..KKCCCCKK..',
325    '.KCCCKCCCCK.',
326    '.KCCCKCCCCK.',
327    'KCCCCKCCCCCK',
328    'KCCCCKKKKCCK',
329    'KCCCCCCCCCCK',
330    'KCCCCCCCCCCK',
331    '.KCCCCCCCCK.',
332    '.KCCCCCCCCK.',
333    '..KKCCCCKK..',
334    '....KKKK....',
335  ],
336  lantern: [
337    '.....KK.....',
338    '....K..K....',
339    '...KKKKKK...',
340    '...KaaaaK...',
341    '..KaaCCaaK..',
342    '..KaCCCCaK..',
343    '..KaCCCCaK..',
344    '..KaaCCaaK..',
345    '...KaaaaK...',
346    '...KKKKKK...',
347    '....KKKK....',
348    '............',
349  ],
350  // An ancient stone: weathered grey, a rune glowing in it.
351  stone: [
352    '............',
353    '....KKKK....',
354    '...KzzzzK...',
355    '..KzzTzzzK..',
356    '..KzzTzTzK..',
357    '..KZzTTzzK..',
358    '..KzzTzzZK..',
359    '..KZzTzzzK..',
360    '..KzzzzZzK..',
361    '..KZZzzZZK..',
362    '...KKKKKK...',
363    '............',
364  ],
365  // A badge: a gold medal on a red ribbon.
366  badge: [
367    '..RR....RR..',
368    '...RR..RR...',
369    '....RRRR....',
370    '.....RR.....',
371    '....KKKK....',
372    '...KAaaAK...',
373    '..KAayyaAK..',
374    '..KAyAAyAK..',
375    '..KAayyaAK..',
376    '...KAAAAK...',
377    '....KKKK....',
378    '............',
379  ],
380  locked: [
381    '............',
382    '....KKKK....',
383    '...K....K...',
384    '...K....K...',
385    '..ZZZZZZZZ..',
386    '..ZzzzzzzZ..',
387    '..ZzzKKzzZ..',
388    '..ZzzKKzzZ..',
389    '..ZzzzKzzZ..',
390    '..ZzzzzzzZ..',
391    '..ZZZZZZZZ..',
392    '............',
393  ],
394  glove: [
395    '............',
396    '..KKKKKKK...',
397    '..KHKHKHK...',
398    '..KHKHKHK...',
399    '..KHHHHHKKK.',
400    '..KHHHHHKHK.',
401    '..KHHHHHHHK.',
402    '..KHHHHHHK..',
403    '..KHHHHHHK..',
404    '..KYYYYYYK..',
405    '..KyyyyyyK..',
406    '..KKKKKKKK..',
407  ],
408  whistle: [
409    '............',
410    '.RR.........',
411    '...R........',
412    '....RKKKKK..',
413    '....KMMMMMK.',
414    'KKKKMMWWMMMK',
415    'KMMMMMMWMMMK',
416    'KKKKMMMMMMMK',
417    '....KMMMMMK.',
418    '.....KmmmK..',
419    '......KKK...',
420    '............',
421  ],
422  check: [
423    '............',
424    '..........K.',
425    '.........KgK',
426    '........KgK.',
427    '.......KgK..',
428    '.K....KgK...',
429    'KgK..KgK....',
430    '.KgKKgK.....',
431    '..KggK......',
432    '...KK.......',
433    '............',
434    '............',
435  ],
436  rod: [
437    '.K.......K..',
438    '.HK.....KH..',
439    '..HK...KH...',
440    '...HK.KH....',
441    '....HKH.....',
442    '....KHK.....',
443    '....KHK.....',
444    '....KHK.....',
445    '....KHK.....',
446    '.....K......',
447    '....aAa.....',
448    '.....a......',
449  ],
450  bolt: [
451    '......KKKK..',
452    '.....KffK...',
453    '....KffK....',
454    '...KffK.....',
455    '..KffKKKKK..',
456    '.KffffffK...',
457    '.KKKKffK....',
458    '....KfK.....',
459    '...KfK......',
460    '..KfK.......',
461    '..KK........',
462    '............',
463  ],
464  gem: [
465    '............',
466    '............',
467    '...KKKKKK...',
468    '..KTtTTtTK..',
469    '.KTTTTTTTTK.',
470    'KKKKKKKKKKKK',
471    '.KTTTtTTTTK.',
472    '..KTTtTTTK..',
473    '...KTTTTK...',
474    '....KTTK....',
475    '.....KK.....',
476    '............',
477  ],
478  // The foreman's ledger: a brown book with a gold mark and a red ribbon.
479  ledger: [
480    '............',
481    '..KKKKKKKK..',
482    '..KGBBBBBK..',
483    '..KGBYYBBK..',
484    '..KGBBYBBK..',
485    '..KGBYYBBK..',
486    '..KGBBBBBK..',
487    '..KGBBBBBK..',
488    '..KGWWWWWK..',
489    '..KKKKKKKK..',
490    '.......R....',
491    '.......R....',
492  ],
493  // A survey map: a red cross and a dotted trail.
494  map: [
495    '............',
496    '.KKKKKKKKKK.',
497    '.KLLLLLLLLK.',
498    '.KLRLLLLBLK.',
499    '.KLLRLLBLLK.',
500    '.KLRLRLLLLK.',
501    '.KLLLLLBLLK.',
502    '.KLBLLLLLLK.',
503    '.KLLBBLBLLK.',
504    '.KLLLLLLLLK.',
505    '.KKKKKKKKKK.',
506    '............',
507  ],
508  // The bug the tests left on the rock, as the scene draws it.
509  bug: [
510    '............',
511    '....K..K....',
512    '.....KK.....',
513    '...KQQQQK...',
514    '..KQqQQqQK..',
515    '.KKQQQQQQKK.',
516    '...KQQQQK...',
517    '..KKQQQQKK..',
518    '...KQQQQK...',
519    '..K.KKKK.K..',
520    '............',
521    '............',
522  ],
523  // A rubber duck.
524  duck: [
525    '............',
526    '....KKK.....',
527    '...KYYYK....',
528    '..KYKYYYK...',
529    '.KFFYYYYK...',
530    '..KKYYYYKKK.',
531    '...KYYYYYYYK',
532    '..KYYaaaaYYK',
533    '..KYYYaaYYK.',
534    '...KYYYYYK..',
535    '....KKKKK...',
536    '............',
537  ],
538  // Vein hunter: rock with a gold vein through it.
539  ore: [
540    '............',
541    '...KKKKKK...',
542    '..KzzzzZzK..',
543    '.KzYYzzzzZK.',
544    '.KzzaYzzZzK.',
545    '.KZzzYYzzzK.',
546    '.KzzzzYYzzK.',
547    '.KzZzzzaYzK.',
548    '.KzzzZzzYzK.',
549    '..KzzzzzzK..',
550    '...KKKKKK...',
551    '............',
552  ],
553  // Gold rush: a stack of gold bars.
554  bars: [
555    '............',
556    '............',
557    '...KKKKKK...',
558    '...KaYYyK...',
559    '...KYYYyK...',
560    'KKKKKKKKKKKK',
561    'KaYYyKKaYYyK',
562    'KYYYyKKYYYyK',
563    'KyyyyKKyyyyK',
564    'KKKKKKKKKKKK',
565    '............',
566    '............',
567  ],
568  // Crown jewels: a gold crown set with stones.
569  crown: [
570    '............',
571    '............',
572    '.K...KK...K.',
573    'KYK.KaYK.KYK',
574    'KYYKYYYYKYYK',
575    'KaYYYYYYYYyK',
576    'KYRYYDYYRYyK',
577    'KYYYYYYYYYyK',
578    'KyyyyyyyyyyK',
579    'KKKKKKKKKKKK',
580    '............',
581    '............',
582  ],
583  // Billionaire: stacks of gold coins.
584  coins: [
585    '............',
586    '............',
587    '.KKKK.......',
588    'KaYYyK......',
589    'KKKKKK.KKKK.',
590    'KaYYyKKaYYyK',
591    'KKKKKKKKKKKK',
592    'KaYYyKKaYYyK',
593    'KKKKKKKKKKKK',
594    'KaYYyKKaYYyK',
595    'KKKKKKKKKKKK',
596    '............',
597  ],
598  // Trillionaire: a treasure chest.
599  chest: [
600    '............',
601    '..KKKKKKKK..',
602    '.KHHHHHHHHK.',
603    '.KHHHHHHHHK.',
604    'KKKKKYYKKKKK',
605    'KHHHHaYHHHHK',
606    'KHHHHKKHHHHK',
607    'KHHHHHHHHHHK',
608    'KGGGGGGGGGGK',
609    'KHHHHHHHHHHK',
610    'KKKKKKKKKKKK',
611    '............',
612  ],
613  // Ship it: a sailing ship on the water.
614  ship: [
615    '.....K......',
616    '.....KW.....',
617    '.....KWW....',
618    '.....KWWW...',
619    '.....KWWWW..',
620    '.....KWWWWW.',
621    '.....K......',
622    'KKKKKKKKKKKK',
623    'KHHHHHHHHHHK',
624    '.KHHHHHHHHK.',
625    '..KKKKKKKK..',
626    'OOdOOOOOdOOO',
627  ],
628  // Night owl.
629  owl: [
630    '............',
631    '..K......K..',
632    '..KK....KK..',
633    '..KBBBBBBK..',
634    '.KBWWBBWWBK.',
635    '.KBWKBBWKBK.',
636    '.KBBBYYBBBK.',
637    '.KBHHBBHHBK.',
638    '.KBHHHHHHBK.',
639    '..KBBBBBBK..',
640    '...KY..YK...',
641    '............',
642  ],
643  // Release train: a locomotive, smoking.
644  train: [
645    '..zz........',
646    '.zzz........',
647    '..KK....KKKK',
648    '..KZK...KWWK',
649    'KKKKKKKKKWWK',
650    'KRRRRRRRKRRK',
651    'KYRRRRRRKRRK',
652    'KRRRRRRRRRRK',
653    'KKKKKKKKKKKK',
654    '.KZK.KZK.KZK',
655    '..K...K...K.',
656    '............',
657  ],
658  // On fire.
659  flame: [
660    '.....KK.....',
661    '....KFFK....',
662    '....KFFK.K..',
663    '...KFFFKKFK.',
664    '..KFFfFFFFK.',
665    '.KFFffFFFFK.',
666    '.KFfffFfFFK.',
667    'KFFfaafffFFK',
668    'KFfaaaaffFFK',
669    'KFfaaaafffFK',
670    '.KFffffffFK.',
671    '..KKKKKKKK..',
672  ],
673  // Legendary turn: a gold cup.
674  trophy: [
675    '............',
676    '..KKKKKKKK..',
677    '.KKaYYYYyKK.',
678    'KYKaYYYYyKYK',
679    'KYKYYYYYyKYK',
680    '.KKYYYYYyKK.',
681    '...KYYYyK...',
682    '....KYyK....',
683    '....KYyK....',
684    '...KKKKKK...',
685    '..KyyyyyyK..',
686    '..KKKKKKKK..',
687  ],
688  // Stone circle: standing stones on grass.
689  henge: [
690    '............',
691    '............',
692    '..KKKKKKKK..',
693    '..KzzzzzzK..',
694    '..KKKKKKKK..',
695    'KK.KzKKzK.KK',
696    'zK.KzKKzK.Kz',
697    'zK.KzKKzK.Kz',
698    'zK.KZKKZK.Kz',
699    'ZK.KZKKZK.KZ',
700    'gggggggggggg',
701    '............',
702  ],
703  // Pyramid builder.
704  pyramid: [
705    '............',
706    '............',
707    '............',
708    '.....KK.....',
709    '....KayK....',
710    '...KaayyK...',
711    '..KaaayyyK..',
712    '.KaaaayyyyK.',
713    'KaaaaayyyyyK',
714    'KKKKKKKKKKKK',
715    '............',
716    '............',
717  ],
718  // Absolutely right: a speech bubble with a bang.
719  speech: [
720    '............',
721    '.KKKKKKKKKK.',
722    'KWWWWWWWWWWK',
723    'KWWWWKKWWWWK',
724    'KWWWWKKWWWWK',
725    'KWWWWKKWWWWK',
726    'KWWWWWWWWWWK',
727    'KWWWWKKWWWWK',
728    '.KKKWWKKKKK.',
729    '...KWK......',
730    '...KK.......',
731    '............',
732  ],
733  // Groundbreaking: the first spade.
734  shovel: [
735    '.........KK.',
736    '........KHK.',
737    '.......KHK..',
738    '......KHK...',
739    '.....KHK....',
740    '..KKKHK.....',
741    '.KMMKK......',
742    'KMMMMK......',
743    'KMMMK.......',
744    'KMMK........',
745    '.KK.........',
746    '............',
747  ],
748  // Friday deploy: a rocket lifting off.
749  rocket: [
750    '.....KK.....',
751    '....KWWK....',
752    '....KWWK....',
753    '...KWWWWK...',
754    '...KWOOWK...',
755    '...KWOOWK...',
756    '...KWWWWK...',
757    '..KRKWWKRK..',
758    '.KRRKWWKRRK.',
759    '.KKKKFFKKKK.',
760    '.....fF.....',
761    '.....F......',
762  ],
763  // Paleontologist: an old bone.
764  fossil: [
765    '............',
766    '............',
767    '.KK......KK.',
768    'KLLK....KLLK',
769    'KLLLKKKKLLLK',
770    '.KLLLLLLLLK.',
771    'KLLLKKKKLLLK',
772    'KLLK....KLLK',
773    '.KK......KK.',
774    '............',
775    '............',
776    '............',
777  ],
778  // Pharaoh's mask: gold, striped in lapis blue.
779  mask: [
780    '...KKKKKK...',
781    '..KOYOYOYK..',
782    '.KYOKKKKOYK.',
783    'KOYKaaaaKYOK',
784    'KYOKKaaKKOYK',
785    'KOYKaaaaKYOK',
786    'KYOKayyaKOYK',
787    'KOYKaaaaKYOK',
788    '.KYOKaaKOYK.',
789    '..KOKyyKOK..',
790    '...KKyyKK...',
791    '.....KK.....',
792  ],
793}
794
795export const ICON_IDS = Object.keys(ICONS) as IconId[]
796
797const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
798
799/** The sprite's rows, checked: every row 12 wide, every letter in the palette. */
800export function iconRows(id: IconId): readonly string[] {
801  return ICONS[id]
802}
803
804export const PALETTE_KEYS = new Set(Object.keys(PALETTE))
805
806const cache = new Map<string, string>()
807
808/** An icon's pixels as SVG paths in a 12 x 12 box: a picture that draws many keeps each once. */
809export function iconBody(id: IconId): string {
810  const key = `body:${id}`
811  const hit = cache.get(key)
812  if (hit !== undefined) return hit
813  const runs = new Map<number, string[]>()
814  ICONS[id].forEach((row, y) => {
815    let x = 0
816    while (x < row.length) {
817      const color = PALETTE[row[x] ?? '.']
818      if (color === undefined) {
819        x += 1
820        continue
821      }
822      let len = 1
823      while (x + len < row.length && PALETTE[row[x + len] ?? '.'] === color) len += 1
824      const list = runs.get(color) ?? []
825      list.push(`M${x} ${y}h${len}v1h-${len}z`)
826      runs.set(color, list)
827      x += len
828    }
829  })
830  const body = [...runs].map(([color, d]) => `<path fill="${hex(color)}" d="${d.join('')}"/>`).join('')
831  cache.set(key, body)
832  return body
833}
834
835/** An icon as an SVG document `size` CSS pixels square; `dim` greys it out. */
836export function iconSvg(id: IconId, size = 24, dim = false): string {
837  const key = `${id}:${size}:${dim ? 1 : 0}`
838  const hit = cache.get(key)
839  if (hit !== undefined) return hit
840  const body = iconBody(id)
841  const svg =
842    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="${size}" height="${size}" shape-rendering="crispEdges">` +
843    (dim ? `<g opacity=".3">${body}</g>` : body) +
844    '</svg>'
845  cache.set(key, svg)
846  return svg
847}
848
hooks/model.ts 456 lines
1// What the drawings show, computed from the save as plain data. The shop
2// draws its model and the HUD its props, so neither reads the mine itself and
3// neither is redrawn when nothing it shows has changed.
4
5import type { Amount, Badge, BadgeArt, Band, EventKind, HudProps, IconId, Save, ShopModel, ShopTab, StatsProps, ToolKind, VeinProps, View } from '../types'
6import { ACHIEVEMENT_BONUS, ACHIEVEMENTS, BIOMES, FINDS, PRODUCERS, RELICS, SHAFT_DEPTH, TOOL_NAMES, type Achievement, type Goal, type Upgrade } from './data.ts'
7import { commas, fmt, fmtRate, plural } from './format.ts'
8import * as game from './game.ts'
9
10export const SHOP_LIMIT = 8
11/** The topsoil's color, where a layer is missing. */
12const TOPSOIL = '#b8804f'
13
14const TOOL_ICONS: Readonly<Record<ToolKind, IconId>> = {
15  read: 'glasses',
16  edit: 'dynamite',
17  bash: 'terminal',
18  web: 'globe',
19  mcp: 'plug',
20  agent: 'crew',
21  other: 'pick',
22}
23
24/** The icon of an upgrade that makes work events pay more, by the first event it names. */
25const EVENT_ICONS: Readonly<Record<EventKind, IconId>> = {
26  tests: 'check',
27  fix: 'check',
28  build: 'terminal',
29  lint: 'terminal',
30  commit: 'clock',
31  pr: 'glasses',
32  merge: 'glasses',
33  longShift: 'clock',
34  streak: 'clock',
35}
36
37export function upgradeIcon(u: Upgrade): IconId {
38  if (u.id === 'pick-3') return 'pickDiamond'
39  if (u.id === 'pick-6') return 'pickObsidian'
40  if (u.id === 'grip-1') return 'glove'
41  switch (u.effect.kind) {
42    case 'click':
43    case 'clickAll':
44      return 'pick'
45    case 'clickRate':
46      return 'bolt'
47    case 'clickCrew':
48    case 'clickCrewRate':
49      return 'whistle'
50    case 'gem':
51    case 'gemWorth':
52      return 'gem'
53    case 'producer':
54      return u.effect.id
55    case 'tool':
56      return TOOL_ICONS[u.effect.tool]
57    case 'ore':
58      return 'nugget'
59    case 'idle':
60      return 'moon'
61    case 'shift':
62      return 'clock'
63    case 'global':
64      return 'lantern'
65    case 'events':
66      return EVENT_ICONS[u.effect.events[0] ?? 'tests']
67    case 'veinSpeed':
68      return 'rod'
69    case 'frenzy':
70      return 'nugget'
71  }
72}
73
74export function unlockText(u: Upgrade): string {
75  const n = commas(u.unlock.n)
76  switch (u.unlock.kind) {
77    case 'clicks':
78      return `after ${n} clicks`
79    case 'tools':
80      return `after ${n} tool calls`
81    case 'turns':
82      return `after ${n} turns`
83    case 'mined':
84      return `after ${n} ${TOOL_NAMES[u.unlock.tool]} calls`
85    case 'owned': {
86      const p = game.producer(u.unlock.id)
87      return `with ${n} ${u.unlock.n === 1 ? p.name : p.plural}`
88    }
89    case 'crew':
90      return `with ${n} producers`
91    case 'earned':
92      return `after ${fmt(u.unlock.n)} sparks in all`
93    case 'veins':
94      return `after ${n} veins caught`
95    case 'events':
96      return `after ${n} ${u.unlock.label}`
97  }
98}
99
100/** When the next upgrade goes on sale: at its unlock, or once this run has mined enough for its price. */
101function nextText(save: Save, u: Upgrade): string {
102  const p = game.progress(save, u)
103  return p.isSparks === true ? `once this shaft has mined ${fmt(p.need)} ✻` : unlockText(u)
104}
105
106export const EMPTY_SHOP: ShopModel = {
107  isLoaded: false,
108  tab: 'shop',
109  amount: 1,
110  producers: [],
111  teaser: null,
112  upgrades: [],
113  more: 0,
114  next: null,
115  values: { click: 1, read: 1, edit: 4 },
116  bonus: { stones: 0, stonesPct: 0, achievements: 0, achievementsPct: 0 },
117  depth: { metres: 0, deepest: 0, strata: [], here: { name: 'Topsoil', color: TOPSOIL }, next: null, perks: [] },
118  shaft: { gain: 0, at: SHAFT_DEPTH, pct: 0 },
119  relics: { found: 0, toSpend: 0, owned: [], forSale: [] },
120  badges: { earned: 0, total: ACHIEVEMENTS.length, pct: 0, next: null, groups: [] },
121}
122
123/** The shaft tab's top: the layers in sight, the one the mine is in, the next, and what the reached ones add. */
124function depthOf(save: Save): ShopModel['depth'] {
125  const metres = game.depth(save)
126  const here = game.biomeAt(metres)
127  const stateOf = (i: number): ShopModel['depth']['strata'][number]['state'] =>
128    i < here ? 'reached' : i === here ? 'here' : i === here + 1 ? 'next' : 'ahead'
129  // A hidden layer comes in sight once the mine is a layer above it.
130  const strata = BIOMES.flatMap((b, i) => (b.isHidden === true && i > here + 1 ? [] : [{ name: b.name, color: b.color, state: stateOf(i) }]))
131  const at = BIOMES[here] ?? BIOMES[0]
132  const below = BIOMES[here + 1]
133  return {
134    metres,
135    deepest: game.deepestOf(save),
136    strata,
137    here: { name: at?.name ?? 'Topsoil', color: at?.color ?? TOPSOIL },
138    next: below === undefined ? null : { name: below.name, color: below.color, from: below.from, text: below.text },
139    perks: BIOMES.slice(0, here + 1).flatMap(b => (b.short === '' ? [] : [b.short])),
140  }
141}
142
143/** Stones found and still to spend, and the relics: owned with what each does, and for sale at their price in stones. */
144function relicsOf(save: Save): ShopModel['relics'] {
145  const toSpend = game.stonesToSpend(save)
146  return {
147    found: save.stones,
148    toSpend,
149    owned: game.ownedRelics(save).map(r => ({ id: r.id, name: r.name, icon: r.icon, text: r.blurb })),
150    forSale: RELICS.filter(r => !game.hasRelic(save, r.id)).map(r => ({
151      id: r.id,
152      icon: r.icon,
153      name: r.name,
154      detail: r.blurb,
155      owned: 0,
156      count: 1,
157      price: r.cost,
158      canBuy: toSpend >= r.cost,
159    })),
160  }
161}
162
163/** The badge wall's groups, by what each badge counts, in the order they show. */
164const BADGE_GROUPS: readonly { title: string; kinds: readonly Goal['kind'][] }[] = [
165  { title: 'Depth', kinds: ['deepest'] },
166  { title: 'Veins and gems', kinds: ['veins', 'gems'] },
167  { title: 'Records', kinds: ['clicks', 'tools', 'allTime'] },
168  { title: "Claude's work", kinds: ['event', 'bestStreak', 'bestTurn'] },
169  { title: 'Shafts and stones', kinds: ['shafts', 'stones'] },
170  { title: 'Secrets', kinds: ['secret'] },
171]
172
173/** Each badge's own picture, none like another's; a depth badge is a lump of its layer's rock instead. */
174const BADGE_ICONS: Readonly<Record<string, IconId>> = {
175  'first-vein': 'nugget',
176  'vein-hunter': 'ore',
177  'veins-100': 'bars',
178  'gems-100': 'gem',
179  'gems-1k': 'crown',
180  'clicks-10k': 'glove',
181  'tools-1k': 'terminal',
182  billion: 'coins',
183  trillion: 'chest',
184  'tests-100': 'check',
185  'commits-100': 'ship',
186  'long-shifts-10': 'owl',
187  'fixes-25': 'bug',
188  'merges-10': 'train',
189  'streak-10': 'flame',
190  'streak-25': 'bolt',
191  'turn-1m': 'clock',
192  'turn-1b': 'trophy',
193  shaft: 'pick',
194  'shafts-5': 'pickDiamond',
195  'shafts-10': 'pickObsidian',
196  'stones-25': 'stone',
197  'stones-100': 'henge',
198  'stones-500': 'pyramid',
199  'absolutely-right': 'speech',
200  'good-manners': 'apprentice',
201  'deep-thinker': 'lantern',
202  groundbreaking: 'shovel',
203  'friday-deploy': 'rocket',
204  'midnight-oil': 'moon',
205  fossil: 'fossil',
206  'rubber-duck': 'duck',
207  pharaoh: 'mask',
208}
209
210/** A badge's picture: a lump of its layer's rock for a depth, else its own icon. */
211export function badgeArt(a: Achievement): BadgeArt {
212  if (a.goal.kind === 'deepest') return { kind: 'rock', color: BIOMES[game.biomeAt(a.goal.n)]?.color ?? TOPSOIL }
213  return { kind: 'icon', id: BADGE_ICONS[a.id] ?? 'badge' }
214}
215
216/** Every badge by group, how many are earned, and the closest unearned one. A surprise shows nothing of itself until earned. */
217function badgesOf(save: Save, won: ReadonlySet<string>): ShopModel['badges'] {
218  const badge = (a: Achievement): Badge => {
219    const isEarned = won.has(a.id)
220    const isSurprise = !isEarned && game.isSurprise(a)
221    return {
222      id: a.id,
223      name: isSurprise ? '???' : a.name,
224      text: isSurprise ? '???' : a.text,
225      art: isSurprise ? { kind: 'icon', id: 'locked' } : badgeArt(a),
226      isEarned,
227      isSurprise,
228    }
229  }
230  const goal = game.nextAchievement(save)
231  const art = goal === null ? null : badgeArt(goal)
232  return {
233    earned: won.size,
234    total: ACHIEVEMENTS.length,
235    pct: Math.round(won.size * ACHIEVEMENT_BONUS * 100),
236    next:
237      goal === null || art === null
238        ? null
239        : { name: goal.name, text: goal.text, icon: art.kind === 'icon' ? art.id : 'stone', ...game.achievementProgress(save, goal) },
240    groups: BADGE_GROUPS.map(g => ({ title: g.title, items: ACHIEVEMENTS.filter(a => g.kinds.includes(a.goal.kind)).map(badge) })),
241  }
242}
243
244export function shopModel(save: Save, amount: Amount, tab: ShopTab, isLoaded: boolean): ShopModel {
245  const m = game.multipliers(save)
246  const each = game.unitRates(save, m)
247  const top = PRODUCERS.reduce((high, p, i) => (game.owned(save, p.id) > 0 ? i : high), -1)
248  const producers = PRODUCERS.slice(0, top + 2).map(p => {
249    const owned = game.owned(save, p.id)
250    const count = amount === 'max' ? game.maxCount(p, owned, save.sparks) : amount
251    const price = game.priceOf(p, owned, count)
252    // Once its share of the producers above outgrows its own rate, the card says where the rest comes from.
253    const from = each[p.id] >= 2 * game.ownRate(p, m) ? ', mostly from the producers above' : ''
254    return {
255      id: p.id,
256      icon: p.id,
257      name: p.name,
258      detail: `${fmtRate(each[p.id])}/s each${from} · ${p.blurb}`,
259      owned,
260      count,
261      price,
262      // Lit only when the whole count is affordable: ×10 buys ten or none.
263      canBuy: save.sparks >= price,
264    }
265  })
266  const { onSale, next } = game.shopUpgrades(save)
267  const upgrades = onSale.slice(0, SHOP_LIMIT).map(u => ({
268    id: u.id,
269    icon: upgradeIcon(u),
270    name: u.name,
271    detail: u.blurb,
272    owned: 0,
273    count: 1,
274    price: u.cost,
275    canBuy: save.sparks >= u.cost,
276  }))
277  const nextUp: ShopModel['next'] = next === null ? null : { name: next.name, icon: upgradeIcon(next), text: nextText(save, next) }
278  const won = new Set(game.achievements(save).map(a => a.id))
279  const gain = game.stoneGain(save)
280  return {
281    isLoaded,
282    tab,
283    amount,
284    producers,
285    teaser: PRODUCERS[top + 2]?.cost ?? null,
286    upgrades,
287    more: Math.max(0, onSale.length - SHOP_LIMIT),
288    next: nextUp,
289    values: {
290      click: game.clickValue(save, m),
291      read: game.toolValue(save, 'read', m),
292      edit: game.toolValue(save, 'edit', m),
293    },
294    bonus: {
295      stones: save.stones,
296      stonesPct: Math.round(game.stoneBonus(save.stones) * 100),
297      achievements: won.size,
298      achievementsPct: Math.round(won.size * ACHIEVEMENT_BONUS * 100),
299    },
300    depth: depthOf(save),
301    shaft: { gain, at: SHAFT_DEPTH, pct: Math.round(game.stoneBonus(save.stones + gain) * 100) },
302    relics: relicsOf(save),
303    badges: badgesOf(save, won),
304  }
305}
306
307/** Where a shared card sends people. */
308const HOME = 'github.com/tozmoz/sparkmine'
309
310/**
311 * A card of the mine to paste anywhere, Wordle-style: how deep this shaft is
312 * and where, the layers reached in squares of their colors, what was mined
313 * and earned, and where the game is. A record not made yet is left out, and a
314 * hidden layer not reached yet has no square, so a card spoils no one's surprise.
315 */
316export function shareCard(save: Save): string {
317  const metres = game.depth(save)
318  const deepest = game.deepestOf(save)
319  const here = BIOMES[game.biomeAt(metres)] ?? BIOMES[0]
320  const name = here?.name ?? 'Topsoil'
321  const place = here?.place ?? (name.startsWith('The ') ? `at ${name.toLowerCase()}` : `in the ${name}`)
322  const reached = game.biomeAt(deepest)
323  const squares = BIOMES.flatMap((b, i) => (i <= reached ? [b.square] : b.isHidden === true ? [] : ['⬛'])).join('')
324  const stones = save.stones > 0 ? [`◈ ${plural(save.stones, 'stone', 'stones')}`] : []
325  const records = [
326    ...(save.bestTurn > 0 ? [`Best turn +${fmt(save.bestTurn)}`] : []),
327    ...(save.gems > 0 ? [plural(save.gems, 'gem', 'gems')] : []),
328    ...(save.veins > 0 ? [plural(save.veins, 'vein', 'veins')] : []),
329  ]
330  return [
331    `⛏️ Sparkmine · ${commas(metres)} m ${place}`,
332    deepest > metres ? `${squares} deepest ${commas(deepest)} m` : squares,
333    [`✻ ${fmt(save.allTime)} mined`, ...stones, `★ ${game.achievements(save).length}/${ACHIEVEMENTS.length}`].join(' · '),
334    ...(records.length > 0 ? [records.join(' · ')] : []),
335    HOME,
336  ].join('\n')
337}
338
339/** The layer a depth is in, by name and color, for the drawings. */
340export function layerAt(metres: number): { name: string; color: string } {
341  const b = BIOMES[game.biomeAt(metres)] ?? BIOMES[0]
342  return { name: b?.name ?? 'Topsoil', color: b?.color ?? TOPSOIL }
343}
344
345/** How long the HUD shows a reward in place of the last tool call. */
346export const EVENT_SHOW_MS = 5_000
347
348export const EMPTY_HUD: HudProps = {
349  variant: 'band',
350  mode: 'idle',
351  surface: 'other',
352  sparks: 0,
353  rate: 0,
354  click: 1,
355  clicks: 0,
356  acked: 0,
357  taps: 0,
358  gem: null,
359  depth: 0,
360  layer: { name: 'Topsoil', color: TOPSOIL },
361  event: null,
362  frenzy: null,
363  isCompact: false,
364  idle: 0,
365  turn: 0,
366  lastTurn: 0,
367  last: null,
368  goal: null,
369  tools: 0,
370  turns: 0,
371  nap: null,
372  height: 3,
373  at: 0,
374}
375
376export function hudProps(save: Save, view: View, band: Band, variant: HudProps['variant'], now: number): HudProps {
377  const m = game.multipliers(save)
378  const { next } = game.shopUpgrades(save)
379  const progress = next === null ? null : game.progress(save, next)
380  const metres = game.depth(save)
381  const boost = view.frenzy !== null && now < view.frenzy.until ? view.frenzy : null
382  return {
383    variant,
384    mode: band.mode,
385    surface: 'other',
386    sparks: save.sparks,
387    rate: game.rate(save, m),
388    click: game.clickValue(save, m) * (boost?.mult ?? 1),
389    clicks: save.clicks,
390    acked: 0,
391    taps: 0,
392    gem: view.gem ?? null,
393    depth: metres,
394    layer: layerAt(metres),
395    event: view.event !== null && now - view.event.at < EVENT_SHOW_MS ? { label: view.event.label, value: view.event.value } : null,
396    frenzy: boost === null ? null : { mult: boost.mult, left: Math.ceil((boost.until - now) / 1000) },
397    isCompact: band.isCompact,
398    idle: m.idle,
399    turn: Math.max(0, save.lifetime - view.turnStart),
400    lastTurn: view.lastTurn,
401    last: view.last === null ? null : `${view.last.label} +${fmt(view.last.value)} ✻`,
402    goal:
403      next === null || progress === null
404        ? null
405        : { name: next.name, have: Math.min(progress.have, progress.need), need: progress.need, ...(progress.isSparks === true ? { isSparks: true as const } : {}) },
406    tools: save.tools,
407    turns: save.turns,
408    nap: band.nap,
409    height: EMPTY_HUD.height,
410    at: now,
411  }
412}
413
414/** What the vein Client draws: the vein showing now, or the frenzy it started. */
415export function veinProps(view: View, now: number, surface: VeinProps['surface']): VeinProps {
416  const vein = view.vein !== null && now <= view.vein.until ? view.vein : null
417  const frenzy = view.frenzy !== null && now < view.frenzy.until ? view.frenzy : null
418  return {
419    vein:
420      vein === null
421        ? null
422        : { id: vein.id, kind: vein.kind, left: Math.ceil((vein.until - now) / 1000), label: vein.find === undefined ? 'Vein!' : FINDS[vein.find].label },
423    frenzy: frenzy === null ? null : { mult: frenzy.mult, left: Math.ceil((frenzy.until - now) / 1000) },
424    surface,
425  }
426}
427
428/**
429 * An answer to a HUD's poll, given its last: one that says what the last said
430 * keeps the last one's time, so the surface finds the props unchanged and
431 * draws nothing, and an idle band costs no redraws.
432 */
433export function answerOf<P extends { at: number }>(last: P | undefined, props: P): P {
434  return last !== undefined && JSON.stringify({ ...last, at: 0 }) === JSON.stringify({ ...props, at: 0 }) ? { ...props, at: last.at } : props
435}
436
437/** What the shop's stats Client draws, all time. */
438export function statsProps(save: Save, surface: StatsProps['surface']): StatsProps {
439  return {
440    surface,
441    bySource: save.bySource,
442    bestTurn: save.bestTurn,
443    deepest: game.deepestOf(save),
444    shafts: save.shafts,
445    bestStreak: save.bestStreak,
446    gems: save.gems,
447    veins: save.veins,
448    events: save.events,
449    clicks: save.clicks,
450    tools: save.tools,
451    turns: save.turns,
452  }
453}
454
455export const EMPTY_STATS: StatsProps = { surface: 'other', bySource: {}, bestTurn: 0, deepest: 0, shafts: 0, bestStreak: 0, gems: 0, veins: 0, events: {}, clicks: 0, tools: 0, turns: 0 }
456
hooks/pictures.ts 212 lines
1// The shop's pictures on the desktop: the layers in sight, the badge wall and
2// progress bars, each an SVG document an Svg element draws. The terminal
3// draws the same in text. Their greys are see-through, so they read on a light
4// theme as on a dark one, and each is built only when what it shows changes.
5
6import type { Badge, BadgeArt, ShopModel } from '../types'
7import { iconBody } from './icons.ts'
8
9/** The width the shop's pictures are drawn at; a narrower pane scales them down. */
10export const PICTURE_WIDTH = 484
11
12const GREY_FILL = 'rgba(128,128,128,.12)'
13const GREY_LINE = 'rgba(128,128,128,.38)'
14const GOLD = '#f2b632'
15/** The color a pointer over a badge rings it in: the Claude orange. */
16const HOVER = '#d97757'
17
18const esc = (text: string): string => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
19
20/** An SVG document; the color scheme lets a frame that draws it stay see-through on either theme. */
21const svg = (width: number, height: number, body: string): string =>
22  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="color-scheme:light dark">${body}</svg>`
23
24/** A bar `share` full in `color`, rounded at both ends. */
25export function barSvg(share: number, color: string, width = PICTURE_WIDTH, height = 10): string {
26  const filled = Math.round(Math.max(0, Math.min(1, share)) * width)
27  const r = height / 2
28  return svg(
29    width,
30    height,
31    `<clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>` +
32      `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="${GREY_FILL}"/>` +
33      (filled > 0 ? `<rect width="${filled}" height="${height}" fill="${color}"/>` : '') +
34      '</g>',
35  )
36}
37
38/**
39 * The layers in sight as a row of blocks: the reached ones in their colors,
40 * the one the mine is in ringed, the next one dashed, the ones below empty.
41 */
42export function strataSvg(strata: ShopModel['depth']['strata'], width = PICTURE_WIDTH): string {
43  const pad = 4
44  const height = 22
45  const gap = 4
46  const n = Math.max(1, strata.length)
47  const w = (width - 2 * pad - gap * (n - 1)) / n
48  const blocks = strata.map((s, i) => {
49    const x = pad + i * (w + gap)
50    const rect = (extra: string) => `<rect x="${x.toFixed(1)}" y="${pad}" width="${w.toFixed(1)}" height="${height}" rx="5" ${extra}/>`
51    switch (s.state) {
52      case 'reached':
53        return rect(`fill="${s.color}"`)
54      case 'here':
55        return (
56          rect(`fill="${s.color}"`) +
57          `<rect x="${(x - 3).toFixed(1)}" y="${pad - 3}" width="${(w + 6).toFixed(1)}" height="${height + 6}" rx="7.5" fill="none" stroke="${s.color}" stroke-width="1.5"/>`
58        )
59      case 'next':
60        return rect(`fill="${s.color}" fill-opacity=".12" stroke="${s.color}" stroke-width="1.5" stroke-dasharray="4 3"`)
61      case 'ahead':
62        return rect(`fill="none" stroke="${GREY_LINE}" stroke-width="1"`)
63    }
64  })
65  return svg(width, height + 2 * pad, blocks.join(''))
66}
67
68const TILE = 40
69const GAP = 7
70/** Badges to a row: nine, as many as the depth, Claude's work and the secrets each have. */
71const PER_ROW = 9
72/** The wall's width: nine badges to a row, narrower than the pane so it never overflows it. */
73export const WALL_WIDTH = PER_ROW * (TILE + GAP) - GAP
74
75/** A lump of a layer's rock, 12 x 12: the layer's color with darker flecks. */
76const ROCK = [
77  '............',
78  '....KKKK....',
79  '..KKccccKK..',
80  '.KccdcccccK.',
81  '.KcccccdccK.',
82  '.KcdccccccK.',
83  '.KccccdcccK.',
84  '.KcccccccdK.',
85  '..KKccccKK..',
86  '....KKKK....',
87  '............',
88  '............',
89]
90
91/** A color `k` of the way to black. */
92function darker(hex: string, k: number): string {
93  const n = parseInt(hex.slice(1), 16)
94  const ch = [n >> 16, (n >> 8) & 255, n & 255].map(v => Math.round(v * (1 - k)))
95  return `#${ch.map(v => v.toString(16).padStart(2, '0')).join('')}`
96}
97
98function rockBody(color: string): string {
99  const fill: Record<string, string> = { K: '#1d1512', c: color, d: darker(color, 0.35) }
100  let body = ''
101  ROCK.forEach((row, y) => {
102    for (let x = 0; x < row.length; x += 1) {
103      const c = fill[row[x] ?? '.']
104      if (c !== undefined) body += `<rect x="${x}" y="${y}" width="1" height="1" fill="${c}"/>`
105    }
106  })
107  return body
108}
109
110/** The symbol a badge's picture is drawn from, added to `used` the first time. */
111function symbolOf(art: BadgeArt, used: Map<string, string>): string {
112  const id = art.kind === 'icon' ? `i-${art.id}` : `r-${art.color.slice(1)}`
113  if (!used.has(id)) used.set(id, art.kind === 'icon' ? iconBody(art.id) : rockBody(art.color))
114  return id
115}
116
117function tile(b: Badge, i: number, x: number, y: number, used: Map<string, string>): string {
118  const kind = b.isEarned ? 'b won' : b.isSurprise ? 'b q' : 'b'
119  const frame = `<rect class="f" x="${x + 0.75}" y="${y + 0.75}" width="${TILE - 1.5}" height="${TILE - 1.5}" rx="8"/>`
120  const art = b.isSurprise
121    ? `<text class="qm" x="${x + TILE / 2}" y="${y + TILE / 2 + 6}">?</text>`
122    : `<use href="#${symbolOf(b.art, used)}" x="${x + 8}" y="${y + 8}" width="24" height="24" shape-rendering="crispEdges"${b.isEarned ? '' : ' opacity=".28"'}/>`
123  return `<g class="${kind}" id="b${i}" aria-label="${esc(b.name)}">${frame}${art}</g>`
124}
125
126/** The hover note: a box with the badge's name, what it asks, and whether it is earned. */
127const TIP_WIDTH = 216
128const TIP_CHARS = 32
129const TIP_PAD = 9
130const TIP_LINE = 16
131
132/** Words in lines of at most `chars` characters. */
133function wrap(text: string, chars: number): string[] {
134  const lines: string[] = []
135  for (const word of text.split(' ')) {
136    const last = lines.at(-1)
137    if (last !== undefined && last.length + 1 + word.length <= chars) lines[lines.length - 1] = `${last} ${word}`
138    else lines.push(word)
139  }
140  return lines
141}
142
143/**
144 * A badge's note, there at once when the pointer is over it, where a title
145 * would wait a second or more. Under the badge, or over it at the foot of the
146 * wall, and kept inside it.
147 */
148function tip(b: Badge, i: number, x: number, y: number, width: number, height: number): string {
149  const says = b.isSurprise ? ['Still a secret.'] : wrap(b.text, TIP_CHARS)
150  const lines = [
151    { text: b.name, cls: 'tn' },
152    ...says.map(text => ({ text, cls: 'tt' })),
153    ...(b.isEarned ? [{ text: 'Earned', cls: 'te' }] : []),
154  ]
155  const h = TIP_PAD * 2 + lines.length * TIP_LINE - 2
156  const left = Math.max(0, Math.min(width - TIP_WIDTH, x + TILE / 2 - TIP_WIDTH / 2))
157  const below = y + TILE + 6
158  const top = below + h <= height ? below : Math.max(0, y - 6 - h)
159  const text = lines
160    .map((l, n) => `<tspan class="${l.cls}" x="${left + TIP_PAD + 1}" y="${top + TIP_PAD + 11 + n * TIP_LINE}">${esc(l.text)}</tspan>`)
161    .join('')
162  return `<g class="tip" id="t${i}"><rect x="${left + 0.5}" y="${top + 0.5}" width="${TIP_WIDTH - 1}" height="${h - 1}" rx="7"/><text>${text}</text></g>`
163}
164
165/**
166 * Every badge, by group, in one picture: earned ones framed in gold, the rest
167 * greyed, a surprise a dashed "?". Drawn interactive, so a pointer over a
168 * badge rings it and opens its note at once. An interactive picture is drawn
169 * in a frame, which takes the size it is given and no other: so the wall says
170 * its own.
171 */
172export function badgeWall(groups: ShopModel['badges']['groups']): { source: string; width: number; height: number } {
173  const width = WALL_WIDTH
174  const cols = PER_ROW
175  const used = new Map<string, string>()
176  const labels: string[] = []
177  const placed: { b: Badge; x: number; y: number }[] = []
178  let y = 0
179  for (const g of groups) {
180    const won = g.items.filter(b => b.isEarned).length
181    labels.push(`<text class="t" x="0" y="${y + 13}">${esc(g.title)} <tspan class="n">${won}/${g.items.length}</tspan></text>`)
182    y += 20
183    g.items.forEach((b, i) => placed.push({ b, x: (i % cols) * (TILE + GAP), y: y + Math.floor(i / cols) * (TILE + GAP) }))
184    y += Math.ceil(g.items.length / cols) * (TILE + GAP) + 8
185  }
186  const height = Math.max(1, y - 8)
187  const tiles = placed.map((p, i) => tile(p.b, i, p.x, p.y, used))
188  // The notes come after every badge, so one is drawn over the badges below it,
189  // and each opens from its own badge, a sibling before it.
190  const tips = placed.map((p, i) => tip(p.b, i, p.x, p.y, width, height))
191  const style =
192    '<style>' +
193    // A frame that sets the picture in a page of its own gives that page a margin: none here.
194    'html,body{margin:0;padding:0;overflow:hidden}svg{display:block}' +
195    `.t{font:12.5px system-ui,-apple-system,sans-serif;fill:#8a8880}.n{opacity:.75}` +
196    `.f{fill:${GREY_FILL};stroke:${GREY_LINE};stroke-width:1}` +
197    `.won .f{fill:rgba(242,182,50,.16);stroke:${GOLD};stroke-width:1.5}` +
198    `.q .f{stroke-dasharray:3 3}` +
199    `.qm{font:700 17px system-ui,-apple-system,sans-serif;fill:rgba(128,128,128,.85);text-anchor:middle}` +
200    `.b:hover .f{stroke:${HOVER};stroke-width:1.5}` +
201    // The notes: dark on either theme, as a system's own are.
202    '.tip{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .08s}' +
203    '.tip rect{fill:#2b2a27;stroke:#4a4945}' +
204    '.tip text{font:12px system-ui,-apple-system,sans-serif}' +
205    '.tn{font-weight:700;fill:#f5f4ef}.tt{fill:#c9c6bb}.te{fill:#f2b632;font-weight:600}' +
206    placed.map((_, i) => `#b${i}:hover~#t${i}`).join(',') +
207    '{opacity:1;visibility:visible}' +
208    '</style>'
209  const defs = [...used].map(([id, body]) => `<symbol id="${id}" viewBox="0 0 12 12">${body}</symbol>`).join('')
210  return { source: svg(width, height, `${style}<defs>${defs}</defs>${labels.join('')}${tiles.join('')}${tips.join('')}`), width, height }
211}
212
hooks/scene.ts 1201 lines
1// The mine above the prompt, drawn pixel by pixel. One picture, two outputs:
2// an SVG whose own CSS animates it (desktop), and Raster cells, two pixels to
3// a cell, that a timer repaints (terminal).
4//
5// While Claude works the miner digs an endless tunnel. He stays where he is
6// and the camera follows him: each swing the world slides CUT pixels left,
7// fresh rock comes in from the right, and what he dug falls behind him with
8// its timbers, lanterns and spoil heaps. The world repeats every PERIOD
9// pixels, so when the slide wraps around the picture is the same and the loop
10// never shows a seam.
11
12import type { FindId, Mode, Season } from '../types'
13
14export const W = 64
15export const H = 24
16/** Terminal rows: each cell is an upper half block, so two pixels tall. */
17export const ROWS = H / 2
18
19const CLEAR = -1
20
21/** `pick` is the miner's pick, 0 worn iron to 4 obsidian, as the pick upgrades make it. */
22export type SceneSpec = {
23  mode: Mode
24  carts: number
25  goblins: number
26  pick: number
27  biome: number
28  /** A bug on the rock while tests fail, or the splat where the pick got it. */
29  bug?: 'crawl' | 'splat'
30  /** What upgrades bought, there to see: a caged canary, work gloves, warmer lanterns, a coffee mug. */
31  canary?: boolean
32  gloves?: boolean
33  warm?: boolean
34  coffee?: boolean
35  /** Claude asked to ultrathink: the lanterns flare. */
36  flare?: boolean
37  /** A find on the rock, there to catch. */
38  find?: FindId
39  /** A pumpkin on the cart the last week of October; a Santa hat, and snow on the topsoil, in December. */
40  season?: Season
41  /** A pull request merged: confetti falls for a moment. */
42  confetti?: boolean
43  /**
44   * How the miner stands still: facing you while Claude asks, tipping his
45   * helmet when thanked, with a coffee a while into Claude's idle, asleep after a long one.
46   */
47  pose?: 'facing' | 'tip' | 'coffee' | 'sleep'
48}
49
50/** One pose of the miner: `t` runs from the strike (0) to the raised pick (1); null rests. */
51export type Frame = { at: number; t: number | null; bob: number; chips: boolean }
52
53export const SWING_MS = 900
54export const IMPACT_AT = 0.64
55export const SWING: readonly Frame[] = [
56  { at: 0, t: 0.3, bob: 0, chips: false },
57  { at: 0.1, t: 0.6, bob: 0, chips: false },
58  { at: 0.2, t: 0.85, bob: 0, chips: false },
59  { at: 0.3, t: 1, bob: 0, chips: false },
60  { at: 0.56, t: 0.5, bob: 0, chips: false },
61  { at: IMPACT_AT, t: 0, bob: 1, chips: true },
62  { at: 0.76, t: 0, bob: 0, chips: false },
63]
64
65export const REST_MS = 1600
66export const REST: readonly Frame[] = [
67  { at: 0, t: null, bob: 0, chips: false },
68  { at: 0.5, t: null, bob: 1, chips: false },
69]
70
71/** Pixels the world slides per swing, and the width after which it repeats. */
72export const CUT = 2
73export const PERIOD = 32
74/** Swings in one loop of the slide. */
75export const CUTS = PERIOD / CUT
76export const LOOP_MS = SWING_MS * CUTS
77/** Every this many swings the pick strikes ore and sparkles. */
78const SPARK_EVERY = 4
79const SPARK_FOR = 0.25
80const WHEEL_MS = 450
81
82const cutAt = (k: number) => (k + IMPACT_AT) / CUTS
83
84export function framesFor(mode: Mode): { frames: readonly Frame[]; ms: number } {
85  if (mode === 'work') return { frames: SWING, ms: SWING_MS }
86  if (mode === 'pause') return { frames: REST.slice(0, 1), ms: REST_MS }
87  return { frames: REST, ms: REST_MS }
88}
89
90const mod = (n: number, m: number) => ((n % m) + m) % m
91
92/** Which of `frames` shows at `share` (0 to 1) of its loop. */
93export function frameIndex(frames: readonly Frame[], share: number): number {
94  let index = 0
95  frames.forEach((frame, i) => {
96    if (frame.at <= share) index = i
97  })
98  return index
99}
100
101/** Everything that moves, `ms` into the animation. */
102export type Moment = { frame: number; scroll: number; spark: boolean; wheel: number }
103
104export function momentAt(mode: Mode, ms: number): Moment {
105  if (mode !== 'work') {
106    const { frames, ms: loop } = framesFor(mode)
107    return { frame: frameIndex(frames, mod(ms, loop) / loop), scroll: 0, spark: false, wheel: 0 }
108  }
109  const at = mod(ms, LOOP_MS)
110  const swing = Math.floor(at / SWING_MS)
111  const within = (at - swing * SWING_MS) / SWING_MS
112  const cuts = swing + (within >= IMPACT_AT ? 1 : 0)
113  return {
114    frame: frameIndex(SWING, within),
115    scroll: mod(cuts * CUT, PERIOD),
116    spark: swing % SPARK_EVERY === SPARK_EVERY - 1 && within >= IMPACT_AT && within < IMPACT_AT + SPARK_FOR,
117    wheel: Math.floor(mod(ms, WHEEL_MS * 2) / WHEEL_MS),
118  }
119}
120
121export const momentKey = (m: Moment) => `${m.frame}:${m.scroll}:${m.spark ? 1 : 0}:${m.wheel}`
122
123class Pixels {
124  readonly w: number
125  readonly h: number
126  data: Int32Array
127
128  constructor(w = W, h = H, from?: Pixels) {
129    this.w = w
130    this.h = h
131    this.data = from ? from.data.slice() : new Int32Array(w * h).fill(CLEAR)
132  }
133
134  copy(): Pixels {
135    return new Pixels(this.w, this.h, this)
136  }
137
138  set(x: number, y: number, color: number): void {
139    const px = Math.round(x)
140    const py = Math.round(y)
141    if (px >= 0 && px < this.w && py >= 0 && py < this.h) this.data[py * this.w + px] = color
142  }
143
144  get(x: number, y: number): number {
145    if (x < 0 || x >= this.w || y < 0 || y >= this.h) return CLEAR
146    return this.data[y * this.w + x] ?? CLEAR
147  }
148
149  /** Draws `over` on top, its clear pixels letting this one show. */
150  stamp(over: Pixels): void {
151    for (let y = 0; y < over.h; y += 1) {
152      for (let x = 0; x < over.w; x += 1) {
153        const color = over.get(x, y)
154        if (color !== CLEAR) this.set(x, y, color)
155      }
156    }
157  }
158
159  line(x0: number, y0: number, x1: number, y1: number, color: number): void {
160    let x = Math.round(x0)
161    let y = Math.round(y0)
162    const tx = Math.round(x1)
163    const ty = Math.round(y1)
164    const dx = Math.abs(tx - x)
165    const dy = -Math.abs(ty - y)
166    const sx = x < tx ? 1 : -1
167    const sy = y < ty ? 1 : -1
168    let err = dx + dy
169    for (let i = 0; i < 256; i += 1) {
170      this.set(x, y, color)
171      if (x === tx && y === ty) break
172      const e2 = 2 * err
173      if (e2 >= dy) {
174        err += dy
175        x += sx
176      }
177      if (e2 <= dx) {
178        err += dx
179        y += sy
180      }
181    }
182  }
183
184  sprite(rows: readonly string[], palette: Readonly<Record<string, number>>, x: number, y: number): void {
185    rows.forEach((row, dy) => {
186      for (let dx = 0; dx < row.length; dx += 1) {
187        const color = palette[row[dx] ?? '.']
188        if (color !== undefined) this.set(x + dx, y + dy, color)
189      }
190    })
191  }
192}
193
194function rng(seed: number): () => number {
195  let s = seed | 0
196  return () => {
197    s = (s + 0x6d2b79f5) | 0
198    let t = Math.imul(s ^ (s >>> 15), 1 | s)
199    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
200    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
201  }
202}
203
204const OUTLINE = 0x1d1512
205const RAIL = 0x9aa3ad
206const TIE = 0x5e3b1d
207const WOOD = [0xa8743f, 0x8a5a2e, 0x5e3b1d] as const
208const HANDLE = 0x9c6b3c
209const STEEL = 0xaab4bf
210const STEEL_LIGHT = 0xe6edf3
211const STEEL_DARK = 0x6c7682
212const LAMP = 0xfff4b8
213const GLOW = 0xffc86e
214const GOLD = [0xf2b632, 0xffe08a, 0xc7861d] as const
215const TEAL = [0x5dcaa5, 0xb5f0dc, 0x1d9e75] as const
216const SHIRT = 0xc2412d
217const SKIN = 0xf0c08a
218
219/** An ore's colors: its body, its glint and its shadow. */
220type Ore = readonly [number, number, number]
221
222/** The colors of one layer the mine digs through. */
223type Strata = {
224  cave: number
225  caveDark: number
226  caveLight: number
227  rock: readonly [number, number, number]
228  ceiling: readonly [number, number, number]
229  dirt: number
230  dirtSpeck: number
231  pebble: number
232  pile: number
233  pileTop: number
234  /** The two ores in the rock: the first is the one the pick throws sparks of. */
235  ores: readonly [Ore, Ore]
236  /** Daylight where the tunnel's back wall would be: the far side, out through the planet. */
237  sky?: Sky
238}
239
240/**
241 * The far side's sky, seen upside down: its bands from the top row down, palest
242 * by the turf overhead, the clouds' color, the sun low in the frame, and the turf.
243 */
244type Sky = {
245  bands: readonly (readonly [number, number])[]
246  cloud: number
247  sun: readonly [number, number]
248  turf: readonly [number, number, number]
249}
250
251/**
252 * Deeper is stranger: the mine passes from topsoil through stone, ancient ruins
253 * and crystal caves into magma, then groundwater and the core, and on through
254 * the inner core and a hollow earth out into daylight on the far side. One per
255 * BIOMES entry, in its order.
256 */
257const STRATA: readonly Strata[] = [
258  // Topsoil: warm brown earth with gold and teal.
259  {
260    cave: 0x211915, caveDark: 0x1a1310, caveLight: 0x2a201b,
261    rock: [0x2b221e, 0x4a3c34, 0x57473d], ceiling: [0x2b221e, 0x3d322b, 0x4a3c34],
262    dirt: 0x2a201b, dirtSpeck: 0x3a2c25, pebble: 0x4a3c34, pile: 0x6b4a2f, pileTop: 0x8a6240,
263    ores: [GOLD, TEAL],
264  },
265  // Stone: cold grey granite with gold and silver.
266  {
267    cave: 0x1b1c20, caveDark: 0x141518, caveLight: 0x24262b,
268    rock: [0x2a2c31, 0x4a4d55, 0x5f636c], ceiling: [0x2a2c31, 0x3a3d44, 0x4a4d55],
269    dirt: 0x24262b, dirtSpeck: 0x34373e, pebble: 0x4a4d55, pile: 0x575b63, pileTop: 0x7a7f88,
270    ores: [GOLD, [0xd0d6de, 0xffffff, 0x8a919b]],
271  },
272  // Ancient ruins: carved sandstone, set with old gold and jade.
273  {
274    cave: 0x221a10, caveDark: 0x18120a, caveLight: 0x2e2416,
275    rock: [0x2e2416, 0x5a4626, 0x7a6236], ceiling: [0x2e2416, 0x4a3a20, 0x5a4626],
276    dirt: 0x2a2014, dirtSpeck: 0x4a3a20, pebble: 0x5a4626, pile: 0x7a6236, pileTop: 0xd9b45f,
277    ores: [[0xd9b45f, 0xfff0b8, 0xa8862e], [0x5dcaa5, 0xb5f0dc, 0x2f8a6c]],
278  },
279  // Crystal caves: indigo rock grown through with amethyst and diamond.
280  {
281    cave: 0x17142a, caveDark: 0x100e1e, caveLight: 0x221e3a,
282    rock: [0x221d3a, 0x3a3263, 0x4b4280], ceiling: [0x221d3a, 0x2e2850, 0x3a3263],
283    dirt: 0x1d1930, dirtSpeck: 0x2e2850, pebble: 0x3a3263, pile: 0x4b4280, pileTop: 0x7f77dd,
284    ores: [[0xb6a6ff, 0xe0d8ff, 0x7f77dd], [0x5fd4e8, 0xd8fbff, 0x2a8fa8]],
285  },
286  // Magma: black basalt, its seams running with lava and ruby.
287  {
288    cave: 0x241210, caveDark: 0x180b09, caveLight: 0x341a15,
289    rock: [0x1a1414, 0x2e2522, 0x4a271d], ceiling: [0x1a1414, 0x2e2522, 0x3d302b],
290    dirt: 0x2a1410, dirtSpeck: 0x5a1f10, pebble: 0x3d302b, pile: 0x4a2a20, pileTop: 0x6a3a28,
291    ores: [[0xff7a2f, 0xffd36e, 0xc2412d], [0xe24b4a, 0xf7c1c1, 0x8f2e20]],
292  },
293  // Groundwater: dark wet slate, seeping with aquamarine and pearl.
294  {
295    cave: 0x0f1d24, caveDark: 0x0a151a, caveLight: 0x15283a,
296    rock: [0x13242c, 0x1f3d4a, 0x2b5566], ceiling: [0x13242c, 0x1a3340, 0x1f3d4a],
297    dirt: 0x10202a, dirtSpeck: 0x1f3d4a, pebble: 0x2b5566, pile: 0x2b5566, pileTop: 0x4fb3d9,
298    ores: [[0x4fb3d9, 0xd8f4ff, 0x2a7fa8], [0xe6edf3, 0xffffff, 0x9aa8b4]],
299  },
300  // The core: rock glowing red from within, veined with white-hot gold.
301  {
302    cave: 0x2a0c06, caveDark: 0x1e0804, caveLight: 0x3a120a,
303    rock: [0x5a1608, 0x9a2e10, 0xd9531c], ceiling: [0x4a1208, 0x6e200c, 0x9a2e10],
304    dirt: 0x3a120a, dirtSpeck: 0x6e200c, pebble: 0x9a2e10, pile: 0xc2411a, pileTop: 0xffd36e,
305    ores: [[0xffe9a8, 0xffffff, 0xf2b632], [0xffd36e, 0xfff4b8, 0xff7a2f]],
306  },
307  // Inner core: white-hot gold under such pressure that it grows diamonds.
308  {
309    cave: 0x2b1d08, caveDark: 0x1f1405, caveLight: 0x3b2a0e,
310    rock: [0x7a4e10, 0xc48a26, 0xf2c45a], ceiling: [0x5e3c0c, 0x7a4e10, 0xa86e1a],
311    dirt: 0x3b2a0e, dirtSpeck: 0x7a4e10, pebble: 0xa86e1a, pile: 0xc48a26, pileTop: 0xfff0b8,
312    ores: [[0xe8fbff, 0xffffff, 0x8fd8e8], [0xfff4b8, 0xffffff, 0xf2b632]],
313  },
314  // Hollow earth: a lost green world inside the planet, its rock lit by emerald and glowing spores.
315  {
316    cave: 0x0d1f15, caveDark: 0x08150d, caveLight: 0x15301f,
317    rock: [0x14281a, 0x24472c, 0x3a6a3e], ceiling: [0x14281a, 0x1c3a24, 0x24472c],
318    dirt: 0x112418, dirtSpeck: 0x24472c, pebble: 0x3a6a3e, pile: 0x3a6a3e, pileTop: 0x7bd88f,
319    ores: [[0x3ecf6e, 0xc8f7d4, 0x1d8a45], [0xd08cff, 0xf3e0ff, 0x8a4fc0]],
320  },
321  // The far side: through the planet and out. Daylight behind the timbers, the
322  // other side's turf overhead, and its last crust ahead, set with sapphire.
323  {
324    cave: 0x7cb8ec, caveDark: 0x5598dc, caveLight: 0xa8d4f7,
325    rock: [0x3b2a1c, 0x6b4a2f, 0x8a6240], ceiling: [0x5e3b1d, 0x5e3b1d, 0x6b4a2f],
326    dirt: 0x3b2a1c, dirtSpeck: 0x5e3b1d, pebble: 0x6b4a2f, pile: 0x6b4a2f, pileTop: 0x5fb84a,
327    ores: [[0x7fd3ff, 0xe0f6ff, 0x3a8fd0], GOLD],
328    sky: {
329      bands: [[0, 0xd6ecff], [7, 0xa8d4f7], [11, 0x7cb8ec], [16, 0x5598dc]],
330      cloud: 0xffffff,
331      sun: [25, 17],
332      turf: [0x2f6e2c, 0x3f8f3a, 0x5fb84a],
333    },
334  },
335]
336
337const strataOf = (biome: number): Strata => STRATA[Math.max(0, Math.min(STRATA.length - 1, biome))] ?? (STRATA[0] as Strata)
338
339/** What the rock throws when an iron or steel pick strikes it: its ore and its own stone. */
340const rockSparks = (st: Strata): readonly number[] => [st.ores[0][1], st.rock[2], st.rock[1], st.rock[1], st.ores[0][0], st.rock[2]]
341
342const MINER_PALETTE: Readonly<Record<string, number>> = {
343  K: OUTLINE,
344  Y: 0xf2b632,
345  y: 0xc7861d,
346  L: LAMP,
347  S: SKIN,
348  E: OUTLINE,
349  B: 0x7a4a26,
350  R: SHIRT,
351  r: 0x8f2e20,
352  O: 0x3a6ea5,
353  o: 0x2a5180,
354  G: 0x4b2f1c,
355}
356
357// 14 x 20, facing the wall on the right.
358const MINER = [
359  '....KKKKK.....',
360  '...KYYYYYK....',
361  '..KYYYYYYYK...',
362  '..KYYYYYYYKLL.',
363  '.KyyyyyyyyyKK.',
364  '..KSSSSSSSK...',
365  '..KSSSSSESK...',
366  '..KSSSSSSSSK..',
367  '..KBBSSSSSK...',
368  '..KBBBBBBBK...',
369  '..KRBBBBBRK...',
370  '.KRRRBBBRRRK..',
371  '.KRROOOOORRK..',
372  '.KrrOOOOOrrK..',
373  '.KKKGGGGGKKK..',
374  '..KOOOOOOOK...',
375  '..KOOoKOOoK...',
376  '..KOOK.KOOK...',
377  '.KGGGK.KGGGK..',
378  '.KKKKK.KKKKK..',
379] as const
380
381const GLOVE = 0xc8862a
382const MUG_RED = 0xe24b4a
383
384// Facing you, the lamp in the middle of his helmet and a hand at each side.
385const MINER_FRONT = [
386  '....KKKKK.....',
387  '...KYYYYYK....',
388  '..KYYLLLYYK...',
389  '..KYYYYYYYK...',
390  '.KyyyyyyyyyK..',
391  '..KSSSSSSSK...',
392  '..KSESSSESK...',
393  '..KSSSSSSSK...',
394  '..KBSSSSSBK...',
395  '..KBBBBBBBK...',
396  '..KRBBBBBRK...',
397  '.KRRRBBBRRRK..',
398  '.KRROOOOORRK..',
399  '.KHrOOOOOrHK..',
400  '.KKKGGGGGKKK..',
401  '..KOOOOOOOK...',
402  '..KOOoKoOOK...',
403  '..KOOK.KOOK...',
404  '.KGGGK.KGGGK..',
405  '.KKKKK.KKKKK..',
406] as const
407
408// Thanked: facing you, bald as an egg, his helmet raised in his right hand.
409const MINER_TIP = [
410  '.........KKKK.',
411  '........KYYYYK',
412  '........KyLyyK',
413  '....KKKKK.KHK.',
414  '...KSSSSSK.HK.',
415  '..KSSSSSSSKHK.',
416  '..KSESSSESKRK.',
417  '..KSSSSSSSKRK.',
418  '..KBSSSSSBRRK.',
419  '..KBBBBBBBRK..',
420  '..KRBBBBBRK...',
421  '.KRRRBBBRRK...',
422  '.KRROOOOORK...',
423  '.KHrOOOOOrK...',
424  '.KKKGGGGGKK...',
425  '..KOOOOOOOK...',
426  '..KOOoKoOOK...',
427  '..KOOK.KOOK...',
428  '.KGGGK.KGGGK..',
429  '.KKKKK.KKKKK..',
430] as const
431
432// Sitting with a mug, legs out toward the wall.
433const MINER_SIT = [
434  '..............',
435  '..............',
436  '..............',
437  '..............',
438  '....KKKKK.....',
439  '...KYYYYYK....',
440  '..KYYYYYYYKLL.',
441  '.KyyyyyyyyyKK.',
442  '..KSSSSSSSK...',
443  '..KSSSSSESK...',
444  '..KSSSSSSSSK..',
445  '..KBBSSSSSK...',
446  '..KBBBBBBBK...',
447  '..KRBBBBBRKWWK',
448  '.KRRRBBBRHWWK.',
449  '.KRROOOOORRK..',
450  '.KKKGGGGGKKK..',
451  '..KOOOOOOOOOK.',
452  '..KOOOOOOOGGK.',
453  '..KKKKKKKKKKK.',
454] as const
455
456// Asleep where he sat, his lamp out and his eye shut.
457const MINER_SLEEP = [
458  '..............',
459  '..............',
460  '..............',
461  '..............',
462  '..............',
463  '....KKKKK.....',
464  '...KYYYYYK....',
465  '..KYYYYYYYKll.',
466  '.KyyyyyyyyyKK.',
467  '..KSSSSSSSK...',
468  '..KSSSSSeSK...',
469  '..KSSSSSSSSK..',
470  '..KBBSSSSSK...',
471  '..KBBBBBBBK...',
472  '.KRRRBBBRRRK..',
473  '.KRROOOOORRK..',
474  '.KKKGGGGGKKK..',
475  '..KOOOOOOOOOK.',
476  '..KOOOOOOOGGK.',
477  '..KKKKKKKKKKK.',
478] as const
479
480// A beetle seen from above, in a pink no layer's rock or ore has, so it shows on every one.
481const BUG = ['.K..K.', '..KK..', 'KPPPPK', '.PpPP.', 'KPPPPK', '.K..K.'] as const
482const SPLAT = ['g..G.g', '.GgGG.', 'GgGGgG', '.G.gG.'] as const
483// Steel bars, so the yellow bird inside stands out.
484const CAGE = ['...K...', '..MMM..', '.M...M.', 'M..YY.M', 'M.YYKOM', 'MYYYY.M', 'M.WWW.M', 'MMMMMMM'] as const
485const NOTE = ['.LL', '.L.', 'LL.', 'LL.'] as const
486const MUG = ['LLL.', 'RRRK', 'RRR.'] as const
487const MUG_STEAMING = ['..z.', '.z..', 'LLL.', 'RRRK', 'RRR.'] as const
488const ZBIG = ['LLLL', '..L.', '.L..', 'LLLL'] as const
489// What three layers hide in the rock: an ammonite, a rubber duck, a pharaoh's gold mask.
490const FIND_SPRITES: Readonly<Record<FindId, readonly string[]>> = {
491  fossil: ['..bbb.', '.b...b', 'b.dd.b', 'b.d..b', '.b..b.', '..bb..'],
492  'rubber-duck': ['..YYY.', '..YKYO', 'Y.YYY.', 'YYYYYY', '.YYYY.'],
493  pharaoh: ['.ABBA.', 'ABAABA', 'BAKAKB', 'BAAAAB', '.AAAA.', '..AA..'],
494}
495const FIND_AT = [55, 9] as const
496// A jack-o'-lantern, its face lit from within.
497const PUMPKIN = ['..vv..', '.OOOO.', 'OfOOfO', 'OOOOOO', 'OffffO', '.OOOO.'] as const
498const PUMPKIN_AT = [13, 5] as const
499const SANTA_RED = 0xd03a2f
500const SNOW = 0xf4f8fb
501
502/** Confetti for a merged pull request: where each bit starts, and its color. */
503const CONFETTI_COLORS = [0xff6b6b, 0xffd93d, 0x6bcbff, 0xc77dff, 0x7dff9a] as const
504const CONFETTI: readonly (readonly [number, number])[] = [
505  [3, 3], [9, 6], [15, 2], [21, 8], [27, 4], [12, 11], [24, 13], [6, 15], [18, 16], [30, 10], [36, 2], [42, 7], [45, 13], [39, 15], [33, 5], [48, 3],
506]
507/** How far the confetti falls each step, and in how many steps it loops. */
508const CONFETTI_FALL = 2
509const CONFETTI_STEPS = 4
510
511function confetti(px: Pixels, step: number): void {
512  CONFETTI.forEach(([x, y], i) => px.set(x, y + step * CONFETTI_FALL, CONFETTI_COLORS[i % CONFETTI_COLORS.length] ?? GOLD[0]))
513}
514const ZSMALL = ['LLL', '.L.', 'LLL'] as const
515
516const DETAIL_PALETTE: Readonly<Record<string, number>> = {
517  K: OUTLINE,
518  R: MUG_RED,
519  P: 0xff4fa3,
520  p: 0xb0306f,
521  g: 0xb5e08a,
522  G: 0x6fa84a,
523  M: 0x9aa3ad,
524  Y: 0xffd84a,
525  O: 0xff7a2f,
526  W: WOOD[1],
527  L: LAMP,
528  z: 0xb4b2a9,
529  b: 0xeadfc4,
530  d: 0xb8a888,
531  f: 0xffd36e,
532  v: 0x4f8a2a,
533  A: GOLD[0],
534  B: 0x3a6ea5,
535}
536
537/** Where the bug crawls on the rock, a quarter of the loop at each. */
538const BUG_AT: readonly (readonly [number, number])[] = [[56, 2], [57, 7], [54, 12], [56, 16]]
539const SPLAT_AT = [51, 9] as const
540/** The canary's pole stands at the cart's front, its cage hung between the cart and the miner. */
541const CAGE_AT = [22, 4] as const
542const MUG_AT = [23, 16] as const
543
544/** The details that stay put on screen: the canary on its pole, the mug on the ground. */
545function fixedDetails(px: Pixels, spec: SceneSpec): void {
546  if (spec.canary === true) {
547    for (let y = 3; y < 13; y += 1) px.set(21, y, WOOD[1])
548    for (let x = 21; x <= 25; x += 1) px.set(x, 3, WOOD[0])
549    px.sprite(CAGE, DETAIL_PALETTE, CAGE_AT[0], CAGE_AT[1])
550  }
551  if (spec.coffee === true && spec.pose === undefined) px.sprite(MUG_STEAMING, DETAIL_PALETTE, MUG_AT[0], MUG_AT[1])
552  if (spec.bug === 'splat') px.sprite(SPLAT, DETAIL_PALETTE, SPLAT_AT[0], SPLAT_AT[1])
553  if (spec.find !== undefined) px.sprite(FIND_SPRITES[spec.find], DETAIL_PALETTE, FIND_AT[0], FIND_AT[1])
554  if (spec.season === 'halloween' && spec.carts > 0) px.sprite(PUMPKIN, DETAIL_PALETTE, PUMPKIN_AT[0], PUMPKIN_AT[1])
555}
556
557const GOBLIN_PALETTE: Readonly<Record<string, number>> = {
558  g: 0x6fa84a,
559  E: 0xffe08a,
560  b: 0x7a4a26,
561  K: OUTLINE,
562}
563
564const GOBLIN = ['g....g', 'gggggg', 'gEggEg', '.gggg.', '.bbbb.', '.bbbb.', '.K..K.'] as const
565
566const HEAP: readonly (readonly [number, number])[] = [
567  [14, 12], [15, 12], [13, 12], [16, 12], [12, 12], [17, 12], [11, 12], [18, 12], [10, 12], [19, 12],
568  [14, 11], [15, 11], [13, 11], [16, 11], [12, 11], [17, 11], [11, 11], [18, 11],
569  [14, 10], [15, 10], [13, 10], [16, 10],
570]
571
572/** Where the miner stands. */
573const SX = 32
574const SY = 2
575
576/** The rock face, row by row: everything from this column right is still rock. */
577const FACE = [51, 51, 50, 50, 51, 52, 52, 51, 50, 50, 50, 51, 51, 52, 51, 50, 50, 51, 51, 50, 51, 52, 52, 51] as const
578const faceAt = (y: number) => FACE[y] ?? 50
579
580/** In the world's tile: a timber post, a lantern and a spoil heap every PERIOD pixels. */
581const POST_X = 4
582const LANTERN_X = 21
583const LANTERN_Y = 6
584const SPOIL = { x: 12, heights: [1, 2, 3, 3, 2, 1] } as const
585const ORES: readonly (readonly [number, number, 0 | 1])[] = [
586  [6, 5, 0],
587  [19, 9, 1],
588  [10, 15, 0],
589  [26, 18, 1],
590  [28, 3, 0],
591]
592
593/**
594 * How each pick looks: its handle, the wrap where he holds it, its head, and
595 * what flies when it strikes; null for the rock's own chips.
596 */
597type PickLook = { handle: number; grip: number | null; head: number; edge: number; shade: number; sparks: readonly number[] | null }
598
599const PICKS: readonly PickLook[] = [
600  // Worn iron, the pick he starts with.
601  { handle: HANDLE, grip: null, head: 0x8a8f96, edge: 0xb7bcc2, shade: 0x5c6168, sparks: null },
602  // Sharpened: the same iron, its points bright.
603  { handle: HANDLE, grip: null, head: 0x9aa1a9, edge: 0xffffff, shade: 0x5c6168, sparks: null },
604  // Steel, with a red wrap on the handle.
605  { handle: 0x7a4a26, grip: SHIRT, head: STEEL, edge: STEEL_LIGHT, shade: STEEL_DARK, sparks: null },
606  // Diamond, on a dark handle with a gold wrap: it throws diamond glints.
607  { handle: 0x5e3b1d, grip: GOLD[0], head: 0x5fd4e8, edge: 0xd8fbff, shade: 0x2a8fa8, sparks: [0xd8fbff, 0x5fd4e8, 0xffffff, 0x2a8fa8, 0xd8fbff, 0x5fd4e8] },
608  // Obsidian, black and violet.
609  { handle: 0x2b2440, grip: 0x7f77dd, head: 0x3a2f5a, edge: 0xb6a6ff, shade: 0x151021, sparks: [0xb6a6ff, 0x7f77dd, 0xe0d8ff, 0x3a2f5a, 0xb6a6ff, 0x7f77dd] },
610]
611
612const lookOf = (pick: number): PickLook => PICKS[Math.max(0, Math.min(PICKS.length - 1, pick))] ?? (PICKS[0] as PickLook)
613
614const CHIPS = [
615  { x: 51, y: 11, dx1: -3, dy1: -4, dx2: -6, dy2: 1 },
616  { x: 51, y: 13, dx1: -4, dy1: -2, dx2: -8, dy2: 3 },
617  { x: 50, y: 12, dx1: -2, dy1: -5, dx2: -4, dy2: -1 },
618  { x: 51, y: 15, dx1: -3, dy1: -1, dx2: -5, dy2: 5 },
619  { x: 50, y: 10, dx1: -1, dy1: -4, dx2: -2, dy2: 0 },
620  { x: 51, y: 14, dx1: -5, dy1: -3, dx2: -9, dy2: 2 },
621] as const
622
623/** The colors the chips fly in: the pick's own, or the rock's. */
624const sparksOf = (look: PickLook, st: Strata): readonly number[] => look.sparks ?? rockSparks(st)
625
626function mix(a: number, b: number, share: number): number {
627  const ch = (shift: number) => {
628    const x = (a >> shift) & 0xff
629    const y = (b >> shift) & 0xff
630    return Math.round(x + (y - x) * share) << shift
631  }
632  return ch(16) | ch(8) | ch(0)
633}
634
635/** The far side's sky behind the timbers: its bands, clouds and sun, and its turf hanging overhead. */
636function skyTile(t: Pixels, sky: Sky, r: () => number): void {
637  for (let y = 0; y < H; y += 1) {
638    let color = sky.bands[0]?.[1] ?? 0
639    for (const [from, band] of sky.bands) if (y >= from) color = band
640    for (let x = 0; x < PERIOD; x += 1) t.set(x, y, color)
641  }
642  for (const y of [5, 9, 13]) {
643    let x = Math.floor(r() * 8)
644    while (x < PERIOD) {
645      const len = 3 + Math.floor(r() * 4)
646      for (let i = 0; i < len; i += 1) t.set(x + i, y, sky.cloud)
647      for (let i = 1; i < len - 1; i += 1) t.set(x + i, y - 1, sky.cloud)
648      x += len + 6 + Math.floor(r() * 8)
649    }
650  }
651  const [sx, sy] = sky.sun
652  for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] as const) t.set(sx + dx, sy + dy, GOLD[0])
653  t.set(sx, sy, LAMP)
654  const [deep, mid, light] = sky.turf
655  for (let x = 0; x < PERIOD; x += 1) {
656    t.set(x, 1, deep)
657    t.set(x, 2, mid)
658    if (x % 2 === 0) t.set(x, 3, light)
659    if (x % 5 === 1) t.set(x, 4, light)
660  }
661}
662
663/** One PERIOD-wide tile of the dug tunnel: back wall, timbers, lantern, rails, spoil. */
664function tunnelTile(st: Strata): Pixels {
665  const r = rng(11)
666  const pick = <T>(list: readonly T[]): T => list[Math.floor(r() * list.length)] as T
667  const t = new Pixels(PERIOD, H)
668  if (st.sky !== undefined) return skyTunnel(t, st, st.sky, r, pick)
669  t.data.fill(st.cave)
670  for (const y of [5, 9, 13, 17]) {
671    let x = Math.floor(r() * 4)
672    while (x < PERIOD) {
673      const len = 3 + Math.floor(r() * 5)
674      for (let i = 0; i < len; i += 1) t.set(x + i, y, st.caveDark)
675      t.set(x + len, y + 1, st.caveDark)
676      x += len + 2 + Math.floor(r() * 3)
677    }
678  }
679  for (let i = 0; i < 10; i += 1) t.set(Math.floor(r() * PERIOD), 3 + Math.floor(r() * 16), r() < 0.5 ? st.caveLight : st.caveDark)
680  for (let x = 0; x < PERIOD; x += 1) {
681    t.set(x, 0, pick(st.ceiling))
682    t.set(x, 1, WOOD[0])
683    t.set(x, 2, WOOD[2])
684  }
685  for (let y = 3; y < 21; y += 1) {
686    t.set(POST_X, y, WOOD[0])
687    t.set(POST_X + 1, y, WOOD[1])
688    t.set(POST_X + 2, y, WOOD[2])
689  }
690  SPOIL.heights.forEach((height, i) => {
691    for (let k = 0; k < height; k += 1) t.set(SPOIL.x + i, 20 - k, k === height - 1 ? st.pileTop : st.pile)
692  })
693  for (let y = 22; y < H; y += 1) for (let x = 0; x < PERIOD; x += 1) t.set(x, y, r() < 0.12 ? st.dirtSpeck : st.dirt)
694  for (let i = 0; i < 3; i += 1) t.set(Math.floor(r() * PERIOD), 22, st.pebble)
695  for (let x = 0; x < PERIOD; x += 1) {
696    t.set(x, 21, RAIL)
697    if (x % 4 < 2) t.set(x, 22, TIE)
698  }
699  t.set(LANTERN_X, 3, STEEL_DARK)
700  for (let x = LANTERN_X - 1; x <= LANTERN_X + 1; x += 1) {
701    t.set(x, 4, OUTLINE)
702    t.set(x, 7, OUTLINE)
703  }
704  for (let y = 5; y < 7; y += 1) {
705    t.set(LANTERN_X - 1, y, OUTLINE)
706    t.set(LANTERN_X, y, LAMP)
707    t.set(LANTERN_X + 1, y, OUTLINE)
708  }
709  return t
710}
711
712/** The far side's tunnel: open to the sky, its timbers and rails still there, no lantern needed. */
713function skyTunnel(t: Pixels, st: Strata, sky: Sky, r: () => number, pick: <T>(list: readonly T[]) => T): Pixels {
714  skyTile(t, sky, r)
715  for (let x = 0; x < PERIOD; x += 1) t.set(x, 0, pick(st.ceiling))
716  for (let y = 3; y < 21; y += 1) {
717    t.set(POST_X, y, WOOD[0])
718    t.set(POST_X + 1, y, WOOD[1])
719    t.set(POST_X + 2, y, WOOD[2])
720  }
721  SPOIL.heights.forEach((height, i) => {
722    for (let k = 0; k < height; k += 1) t.set(SPOIL.x + i, 20 - k, k === height - 1 ? st.pileTop : st.pile)
723  })
724  for (let y = 22; y < H; y += 1) for (let x = 0; x < PERIOD; x += 1) t.set(x, y, r() < 0.12 ? st.dirtSpeck : st.dirt)
725  for (let i = 0; i < 3; i += 1) t.set(Math.floor(r() * PERIOD), 22, st.pebble)
726  for (let x = 0; x < PERIOD; x += 1) {
727    t.set(x, 21, RAIL)
728    if (x % 4 < 2) t.set(x, 22, TIE)
729  }
730  return t
731}
732
733/** One PERIOD-wide tile of the rock still ahead, with its ore. */
734function rockTile(st: Strata): Pixels {
735  const r = rng(29)
736  const t = new Pixels(PERIOD, H)
737  for (let y = 0; y < H; y += 1) {
738    for (let x = 0; x < PERIOD; x += 1) {
739      const shade = r()
740      let color: number = shade < 0.15 ? st.rock[0] : shade < 0.7 ? st.rock[1] : st.rock[2]
741      if (y % 7 === 0 && r() < 0.6) color = st.rock[0]
742      t.set(x, y, color)
743    }
744  }
745  for (const [ox, oy, kind] of ORES) {
746    const [main, light, dark] = st.ores[kind]
747    for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] as const) t.set(ox + dx, oy + dy, main)
748    t.set(ox, oy, light)
749    t.set(ox - 1, oy - 1, light)
750    t.set(ox + 1, oy + 1, dark)
751  }
752  return t
753}
754
755/** A tile repeated across `width`, so a window of W can slide a whole PERIOD. */
756function strip(tile: Pixels, width: number): Pixels {
757  const s = new Pixels(width, H)
758  for (let y = 0; y < H; y += 1) for (let x = 0; x < width; x += 1) s.set(x, y, tile.get(x % PERIOD, y))
759  return s
760}
761
762const STRIP = W + PERIOD
763const lanterns = Array.from({ length: Math.ceil(STRIP / PERIOD) }, (_, k) => LANTERN_X + k * PERIOD)
764
765const worlds = new Map<string, { tunnel: Pixels; rock: Pixels }>()
766
767/** How the lanterns burn: as dug, warmer once Brighter lanterns are bought, flaring while Claude ultrathinks. */
768type Glow = 'plain' | 'warm' | 'flare'
769
770/**
771 * Each glow's color; how much of it the terminal's tunnel takes near the
772 * lantern and further out, and how far; the SVG's two rings and their opacity.
773 */
774const GLOWS: Readonly<Record<Glow, { color: number; near: number; far: number; inner: number; reach: number; rings: readonly [number, number]; opacity: string }>> = {
775  plain: { color: GLOW, near: 0.12, far: 0.06, inner: 6, reach: 10, rings: [10, 6], opacity: '.07' },
776  warm: { color: 0xffa63d, near: 0.22, far: 0.11, inner: 6, reach: 10, rings: [12, 7], opacity: '.13' },
777  flare: { color: 0xfff2c4, near: 0.3, far: 0.15, inner: 8, reach: 13, rings: [14, 8], opacity: '.15' },
778}
779
780const glowOf = (spec: SceneSpec): Glow => (spec.flare === true ? 'flare' : spec.warm === true ? 'warm' : 'plain')
781
782/** In December snow lies on the topsoil: on its ceiling, its spoil heaps and the rock ahead. */
783const isSnowy = (spec: SceneSpec): boolean => spec.season === 'winter' && spec.biome === 0
784
785/** The strips the window slides over, with the lanterns' glow mixed in when asked. Built once a layer, glow and snow. */
786function world(withGlow: boolean, biome: number, glow: Glow = 'plain', snowy = false): { tunnel: Pixels; rock: Pixels } {
787  const key = `${biome}:${withGlow}:${glow}:${snowy}`
788  const hit = worlds.get(key)
789  if (hit !== undefined) return hit
790  const built = buildWorld(withGlow, strataOf(biome), GLOWS[glow], snowy)
791  worlds.set(key, built)
792  return built
793}
794
795function buildWorld(withGlow: boolean, st: Strata, glow: (typeof GLOWS)[Glow], snowy: boolean): { tunnel: Pixels; rock: Pixels } {
796  const tunnel = strip(tunnelTile(st), STRIP)
797  const rock = strip(rockTile(st), STRIP)
798  if (snowy) {
799    for (let x = 0; x < STRIP; x += 1) {
800      if (x % 3 !== 1) tunnel.set(x, 0, SNOW)
801      if (x % 2 === 0) rock.set(x, 0, SNOW)
802      for (let y = 0; y < H; y += 1) if (tunnel.get(x, y) === st.pileTop) tunnel.set(x, y, SNOW)
803    }
804  }
805  if (withGlow && st.sky === undefined) {
806    for (const lx of lanterns) {
807      for (let y = 0; y < H; y += 1) {
808        for (let x = lx - glow.reach; x <= lx + glow.reach; x += 1) {
809          const d = Math.hypot(x - lx, y - LANTERN_Y)
810          const color = tunnel.get(x, y)
811          if (d <= glow.reach && color !== CLEAR && color !== LAMP) tunnel.set(x, y, mix(color, glow.color, d <= glow.inner ? glow.near : glow.far))
812        }
813      }
814    }
815  }
816  return { tunnel, rock }
817}
818
819/** The face's dark edge: the line between the tunnel and the rock. */
820function faceEdge(st: Strata): Pixels {
821  const edge = new Pixels()
822  for (let y = 0; y < H; y += 1) edge.set(faceAt(y), y, st.rock[0])
823  return edge
824}
825
826function cart(px: Pixels, carts: number, wheel: number): void {
827  for (let x = 8; x < 22; x += 1) {
828    px.set(x, 13, STEEL)
829    px.set(x, 18, OUTLINE)
830  }
831  for (let y = 14; y < 18; y += 1) {
832    px.set(8, y, OUTLINE)
833    px.set(21, y, OUTLINE)
834    for (let x = 9; x < 21; x += 1) px.set(x, y, y === 15 && x % 3 === 0 ? STEEL : STEEL_DARK)
835  }
836  for (const cx of [11, 18]) {
837    for (let dx = -1; dx <= 1; dx += 1) for (let dy = 18; dy <= 20; dy += 1) px.set(cx + dx, dy, OUTLINE)
838    px.set(cx, 19, STEEL_DARK)
839    px.set(cx + (wheel === 0 ? -1 : 1), 19, STEEL)
840  }
841  const heap = Math.min(HEAP.length, 4 + carts * 3)
842  HEAP.slice(0, heap).forEach(([x, y], i) => px.set(x, y, i % 4 === 1 ? TEAL[0] : i % 3 === 0 ? GOLD[1] : GOLD[0]))
843}
844
845function goblin(px: Pixels, bob: number): void {
846  px.sprite(GOBLIN, GOBLIN_PALETTE, 1, 15 - bob)
847}
848
849type Pose = { hx: number; hy: number; angle: number }
850
851const STRIKE: Pose = { hx: 12, hy: 10, angle: 10 }
852const RAISED: Pose = { hx: 8, hy: 6, angle: -100 }
853const RESTING: Pose = { hx: 10, hy: 10, angle: 72 }
854
855const lerp = (a: number, b: number, t: number) => a + (b - a) * t
856
857function pose(frame: Frame): Pose {
858  if (frame.t === null) return RESTING
859  return {
860    hx: lerp(STRIKE.hx, RAISED.hx, frame.t),
861    hy: lerp(STRIKE.hy, RAISED.hy, frame.t),
862    angle: lerp(STRIKE.angle, RAISED.angle, frame.t),
863  }
864}
865
866function pickaxe(px: Pixels, hx: number, hy: number, angle: number, look: PickLook): void {
867  const a = (angle * Math.PI) / 180
868  const dx = Math.cos(a)
869  const dy = Math.sin(a)
870  px.line(hx - dx, hy - dy, hx + dx * 8, hy + dy * 8, look.handle)
871  if (look.grip !== null) {
872    px.set(hx + dx * 2, hy + dy * 2, look.grip)
873    px.set(hx + dx * 3, hy + dy * 3, look.grip)
874  }
875  const ex = hx + dx * 8
876  const ey = hy + dy * 8
877  const nx = -dy
878  const ny = dx
879  let prev: [number, number] | null = null
880  for (let t = -4; t <= 4; t += 1) {
881    const bend = 0.08 * t * t
882    const x = ex + nx * t - dx * bend
883    const y = ey + ny * t - dy * bend
884    const color = Math.abs(t) >= 3 ? look.edge : look.head
885    if (prev) px.line(prev[0], prev[1], x, y, color)
886    else px.set(x, y, color)
887    if (Math.abs(t) <= 2) px.set(x - dx, y - dy, look.shade)
888    prev = [x, y]
889  }
890}
891
892function miner(px: Pixels, frame: Frame, look: PickLook, spec?: SceneSpec): void {
893  const hand = spec?.gloves === true ? GLOVE : SKIN
894  // His hands, a shut eye, an unlit lamp, and the mug he holds: the one by the cart. In December his helmet is a Santa hat.
895  const hat: Readonly<Record<string, number>> = spec?.season === 'winter' ? { Y: SANTA_RED, y: SNOW } : {}
896  const palette = { ...MINER_PALETTE, H: hand, e: 0xc89a6a, l: 0x8a8460, W: MUG_RED, ...hat }
897  if (spec?.pose === 'facing' || spec?.pose === 'tip') {
898    // The pick stands head down beside him while he looks out at you.
899    pickaxe(px, SX + 13, SY + 10, 90, look)
900    px.sprite(spec.pose === 'tip' ? MINER_TIP : MINER_FRONT, palette, SX, SY)
901    return
902  }
903  if (spec?.pose === 'coffee' || spec?.pose === 'sleep') {
904    // The pick leans on the rock, its head on the ground.
905    pickaxe(px, 48, 9, 80, look)
906    px.sprite(spec.pose === 'coffee' ? MINER_SIT : MINER_SLEEP, palette, SX, SY)
907    if (spec.pose === 'coffee') {
908      px.set(SX + 11, SY + 11, 0xb4b2a9)
909      px.set(SX + 12, SY + 10, 0xb4b2a9)
910    } else {
911      px.sprite(MUG, DETAIL_PALETTE, SX + 14, SY + 16)
912      px.sprite(ZBIG, DETAIL_PALETTE, SX + 11, SY + 1)
913      px.sprite(ZSMALL, DETAIL_PALETTE, SX + 15, SY - 1)
914    }
915    return
916  }
917  const p = pose(frame)
918  const hx = SX + p.hx
919  const hy = SY + p.hy + frame.bob
920  pickaxe(px, hx, hy, p.angle, look)
921  px.sprite(MINER, palette, SX, SY + frame.bob)
922  // The hat's white bobble hangs at its back.
923  if (spec?.season === 'winter') px.set(SX + 3, SY + 1 + frame.bob, SNOW)
924  px.line(SX + 9, SY + 11 + frame.bob, hx, hy, SHIRT)
925  px.set(hx, hy, hand)
926  if (spec?.gloves === true) {
927    px.set(hx + 1, hy, hand)
928    px.set(hx, hy + 1, hand)
929  }
930}
931
932function chips(px: Pixels, look: PickLook, st: Strata): void {
933  const colors = sparksOf(look, st)
934  CHIPS.forEach((chip, i) => px.set(chip.x + chip.dx1, chip.y + chip.dy1, colors[i] ?? st.rock[2]))
935}
936
937function sparkle(px: Pixels, look: PickLook, st: Strata): void {
938  const ray = look.sparks === null ? st.ores[0][0] : (look.sparks[1] ?? GOLD[0])
939  for (const [dx, dy] of [[0, -2], [0, -1], [0, 1], [0, 2], [-2, 0], [-1, 0], [1, 0], [2, 0]] as const) px.set(53 + dx, 11 + dy, ray)
940  px.set(53, 11, LAMP)
941}
942
943const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
944const pct = (share: number) => `${(Math.min(1, Math.max(0, share)) * 100).toFixed(3)}%`
945
946/** One <path> per color, each a run of same-colored pixels in a row. */
947function paths(px: Pixels): string {
948  const runs = new Map<number, string[]>()
949  for (let y = 0; y < px.h; y += 1) {
950    let x = 0
951    while (x < px.w) {
952      const color = px.get(x, y)
953      if (color === CLEAR) {
954        x += 1
955        continue
956      }
957      let len = 1
958      while (x + len < px.w && px.get(x + len, y) === color) len += 1
959      const list = runs.get(color) ?? []
960      list.push(`M${x} ${y}h${len}v1h-${len}z`)
961      runs.set(color, list)
962      x += len
963    }
964  }
965  return [...runs].map(([color, d]) => `<path fill="${hex(color)}" d="${d.join('')}"/>`).join('')
966}
967
968function glowPath(cx: number, radius: number, glow: (typeof GLOWS)[Glow]): string {
969  const d: string[] = []
970  for (let dy = -radius; dy <= radius; dy += 1) {
971    const w = Math.floor(Math.sqrt(radius * radius - dy * dy))
972    const y = LANTERN_Y + dy
973    if (y < 0 || y >= H) continue
974    d.push(`M${cx - w} ${y}h${2 * w + 1}v1h-${2 * w + 1}z`)
975  }
976  return `<path fill="${hex(glow.color)}" fill-opacity="${glow.opacity}" d="${d.join('')}"/>`
977}
978
979/** Keyframes that switch a property at the given shares of the loop and hold it between. */
980function stepKeyframes(name: string, prop: string, start: string, steps: readonly (readonly [number, string])[]): string {
981  const body = [`0%{${prop}:${start}}`, ...steps.map(([at, value]) => `${pct(at)}{${prop}:${value}}`)]
982  return `@keyframes ${name}{${body.join('')}}`
983}
984
985/** Shows a frame for its slice of the loop and hides it otherwise. */
986function windowKeyframes(name: string, from: number, to: number): string {
987  if (from === 0) return stepKeyframes(name, 'opacity', '1', to < 1 ? [[to, '0']] : [])
988  return stepKeyframes(name, 'opacity', '0', to < 1 ? [[from, '1'], [to, '0']] : [[from, '1']])
989}
990
991/**
992 * The scene as an SVG document whose own CSS animates it. `fluid` makes it fill
993 * whatever box draws it, cropping rather than leaving a margin, for a surface
994 * that may draw the box a little taller than asked.
995 */
996export function sceneSvg(spec: SceneSpec, scale: number, fluid = false): string {
997  const st = strataOf(spec.biome)
998  const { tunnel, rock } = world(false, spec.biome, glowOf(spec), isSnowy(spec))
999  const look = lookOf(spec.pick)
1000  const { frames, ms } = framesFor(spec.mode)
1001  const isDigging = spec.mode === 'work'
1002  const css: string[] = [
1003    '.glow{animation:flicker 1.7s step-end infinite}',
1004    '@keyframes flicker{0%{opacity:.9}22%{opacity:.65}31%{opacity:1}64%{opacity:.8}83%{opacity:1}}',
1005  ]
1006  const clip = FACE.map((x, y) => `M${x} ${y}h${W - x}v1h-${W - x}z`).join('')
1007  const body: string[] = [`<defs><clipPath id="rock"><path d="${clip}"/></clipPath></defs>`]
1008
1009  // The world: the tunnel behind, the rock ahead clipped to the face. While
1010  // Claude works both slide left a CUT at every strike, and wrap after PERIOD.
1011  if (isDigging) {
1012    css.push(
1013      `.slide{animation:slide ${LOOP_MS}ms step-end infinite}`,
1014      stepKeyframes(
1015        'slide',
1016        'transform',
1017        'translate(0px,0px)',
1018        Array.from({ length: CUTS }, (_, k) => [cutAt(k), `translate(${-mod((k + 1) * CUT, PERIOD)}px,0px)`] as const),
1019      ),
1020    )
1021  }
1022  const slide = isDigging ? ' class="slide"' : ''
1023  // Under the sky no lantern burns, so nothing flickers.
1024  const glow = GLOWS[glowOf(spec)]
1025  const glows = st.sky === undefined ? `<g class="glow">${lanterns.map(lx => `${glowPath(lx, glow.rings[0], glow)}${glowPath(lx, glow.rings[1], glow)}`).join('')}</g>` : ''
1026  body.push(`<g${slide}>${paths(tunnel)}${glows}</g>`)
1027  body.push(`<g clip-path="url(#rock)"><g${slide}>${paths(rock)}</g></g>`)
1028  body.push(paths(faceEdge(st)))
1029
1030  // The crew that follows him stays put on screen while the world slides by.
1031  if (spec.carts > 0) {
1032    if (isDigging) {
1033      css.push('.wh{opacity:0;animation:900ms step-end infinite}')
1034      ;[0, 1].forEach(wheel => {
1035        const layer = new Pixels()
1036        cart(layer, spec.carts, wheel)
1037        css.push(`.wh${wheel}{animation-name:wh${wheel}}`, windowKeyframes(`wh${wheel}`, wheel * 0.5, wheel === 0 ? 0.5 : 1))
1038        body.push(`<g class="wh wh${wheel}">${paths(layer)}</g>`)
1039      })
1040    } else {
1041      const layer = new Pixels()
1042      cart(layer, spec.carts, 0)
1043      body.push(paths(layer))
1044    }
1045  }
1046  if (spec.goblins > 0) {
1047    css.push('.gb{opacity:0;animation:1.2s step-end infinite}')
1048    ;[0, 1].forEach(bob => {
1049      const layer = new Pixels()
1050      goblin(layer, bob)
1051      css.push(`.gb${bob}{animation-name:gb${bob}}`, windowKeyframes(`gb${bob}`, bob * 0.5, bob === 0 ? 0.5 : 1))
1052      body.push(`<g class="gb gb${bob}">${paths(layer)}</g>`)
1053    })
1054  }
1055
1056  const fixed = new Pixels()
1057  fixedDetails(fixed, spec)
1058  body.push(paths(fixed))
1059  if (spec.bug === 'crawl') {
1060    const bug = new Pixels()
1061    const [bx, by] = BUG_AT[0] ?? [56, 2]
1062    bug.sprite(BUG, DETAIL_PALETTE, bx, by)
1063    if (isDigging) {
1064      const steps = BUG_AT.slice(1).map(([x, y], i) => [(i + 1) / BUG_AT.length, `translate(${x - bx}px,${y - by}px)`] as const)
1065      css.push(`.bug{animation:bug ${LOOP_MS}ms step-end infinite}`, stepKeyframes('bug', 'transform', 'translate(0px,0px)', steps))
1066      body.push(`<g class="bug">${paths(bug)}</g>`)
1067    } else {
1068      body.push(paths(bug))
1069    }
1070  }
1071  if (spec.confetti === true) {
1072    const bits = new Pixels()
1073    confetti(bits, 0)
1074    if (isDigging) {
1075      const steps = Array.from({ length: CONFETTI_STEPS - 1 }, (_, k) => [(k + 1) / CONFETTI_STEPS, `translate(0px,${(k + 1) * CONFETTI_FALL}px)`] as const)
1076      css.push(`.cf{animation:cf ${SWING_MS}ms step-end infinite}`, stepKeyframes('cf', 'transform', 'translate(0px,0px)', steps))
1077      body.push(`<g class="cf">${paths(bits)}</g>`)
1078    } else {
1079      body.push(paths(bits))
1080    }
1081  }
1082  if (spec.canary === true && isDigging) {
1083    const note = new Pixels()
1084    note.sprite(NOTE, DETAIL_PALETTE, 29, 6)
1085    css.push(`.nt{opacity:0;animation:nt ${LOOP_MS}ms step-end infinite}`, stepKeyframes('nt', 'opacity', '0', [[0.19, '1'], [0.31, '0'], [0.69, '1'], [0.81, '0']]))
1086    body.push(`<g class="nt">${paths(note)}</g>`)
1087  }
1088
1089  if (frames.length === 1) {
1090    const layer = new Pixels()
1091    miner(layer, frames[0] as Frame, look, spec)
1092    body.push(paths(layer))
1093  } else {
1094    css.push(`.m{opacity:0;animation-duration:${ms}ms;animation-timing-function:step-end;animation-iteration-count:infinite}`)
1095    frames.forEach((frame, i) => {
1096      const layer = new Pixels()
1097      miner(layer, frame, look, spec)
1098      const to = frames[i + 1]?.at ?? 1
1099      css.push(`.m${i}{animation-name:m${i}}`, windowKeyframes(`m${i}`, frame.at, to))
1100      body.push(`<g class="m m${i}">${paths(layer)}</g>`)
1101    })
1102  }
1103
1104  if (isDigging) {
1105    css.push(`.c{opacity:0;animation:${SWING_MS}ms linear infinite}`)
1106    CHIPS.forEach((chip, i) => {
1107      const a = IMPACT_AT
1108      css.push(
1109        `.c${i}{animation-name:c${i}}`,
1110        `@keyframes c${i}{0%,${pct(a - 0.001)}{opacity:0;transform:translate(0px,0px)}` +
1111          `${pct(a)}{opacity:1;transform:translate(0px,0px)}` +
1112          `${pct(a + 0.12)}{opacity:1;transform:translate(${chip.dx1}px,${chip.dy1}px)}` +
1113          `${pct(a + 0.3)}{opacity:0;transform:translate(${chip.dx2}px,${chip.dy2}px)}100%{opacity:0}}`,
1114      )
1115      body.push(`<rect class="c c${i}" x="${chip.x}" y="${chip.y}" width="1" height="1" fill="${hex(sparksOf(look, st)[i] ?? st.rock[2])}"/>`)
1116    })
1117    // Every few strikes the pick finds ore.
1118    const sparks: (readonly [number, string])[] = []
1119    for (let k = SPARK_EVERY - 1; k < CUTS; k += SPARK_EVERY) sparks.push([cutAt(k), '1'], [(k + IMPACT_AT + SPARK_FOR) / CUTS, '0'])
1120    const layer = new Pixels()
1121    sparkle(layer, look, st)
1122    css.push(`.sp{opacity:0;animation:sp ${LOOP_MS}ms step-end infinite}`, stepKeyframes('sp', 'opacity', '0', sparks))
1123    body.push(`<g class="sp">${paths(layer)}</g>`)
1124  }
1125
1126  const size = fluid
1127    ? 'width="100%" height="100%" preserveAspectRatio="xMidYMid slice"'
1128    : `width="${W * scale}" height="${H * scale}"`
1129  return (
1130    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" ${size} shape-rendering="crispEdges" style="display:block;background:${hex(st.cave)}">` +
1131    `<style>${css.join('')}</style>${body.join('')}</svg>`
1132  )
1133}
1134
1135/** The scene at one moment, as pixels: what the terminal paints and the tests read. */
1136export function compose(spec: SceneSpec, m: Moment): Pixels {
1137  const st = strataOf(spec.biome)
1138  const { tunnel, rock } = world(true, spec.biome, glowOf(spec), isSnowy(spec))
1139  const px = new Pixels()
1140  for (let y = 0; y < H; y += 1) {
1141    for (let x = 0; x < W; x += 1) px.set(x, y, (x >= faceAt(y) ? rock : tunnel).get(x + m.scroll, y))
1142  }
1143  px.stamp(faceEdge(st))
1144  if (spec.carts > 0) cart(px, spec.carts, m.wheel)
1145  if (spec.goblins > 0) goblin(px, 0)
1146  fixedDetails(px, spec)
1147  if (spec.bug === 'crawl') {
1148    const [bx, by] = BUG_AT[spec.mode === 'work' ? Math.floor(m.scroll / (PERIOD / BUG_AT.length)) % BUG_AT.length : 0] ?? [56, 2]
1149    px.sprite(BUG, DETAIL_PALETTE, bx, by)
1150  }
1151  if (spec.canary === true && spec.mode === 'work' && (m.scroll === 6 || m.scroll === 22)) px.sprite(NOTE, DETAIL_PALETTE, 29, 6)
1152  if (spec.confetti === true) confetti(px, spec.mode === 'work' ? (m.scroll / CUT) % CONFETTI_STEPS : 0)
1153  const look = lookOf(spec.pick)
1154  const { frames } = framesFor(spec.mode)
1155  const frame = frames[m.frame] ?? frames[0]
1156  if (frame !== undefined) {
1157    miner(px, frame, look, spec)
1158    if (frame.chips) chips(px, look, st)
1159  }
1160  if (m.spark) sparkle(px, look, st)
1161  return px
1162}
1163
1164const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
1165
1166export function base64(bytes: Uint8Array): string {
1167  let out = ''
1168  let i = 0
1169  for (; i + 2 < bytes.length; i += 3) {
1170    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
1171    out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '') + (B64[(n >> 6) & 63] ?? '') + (B64[n & 63] ?? '')
1172  }
1173  const left = bytes.length - i
1174  if (left > 0) {
1175    const n = ((bytes[i] ?? 0) << 16) | (left === 2 ? (bytes[i + 1] ?? 0) << 8 : 0)
1176    out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '')
1177    out += left === 2 ? `${B64[(n >> 6) & 63] ?? ''}=` : '=='
1178  }
1179  return out
1180}
1181
1182const UPPER_HALF = 0x2580
1183
1184/** Packs pixels into Raster cells: the upper half block, top pixel as ink, bottom as paper. */
1185export function cellsOf(px: Pixels): string {
1186  const view = new DataView(new ArrayBuffer(W * ROWS * 12))
1187  let offset = 0
1188  for (let row = 0; row < ROWS; row += 1) {
1189    for (let x = 0; x < W; x += 1) {
1190      view.setUint32(offset, UPPER_HALF, true)
1191      view.setUint32(offset + 4, px.get(x, row * 2) & 0xffffff, true)
1192      view.setUint32(offset + 8, px.get(x, row * 2 + 1) & 0xffffff, true)
1193      offset += 12
1194    }
1195  }
1196  return base64(new Uint8Array(view.buffer))
1197}
1198
1199/** The terminal's cells for one moment of the scene. */
1200export const sceneCells = (spec: SceneSpec, m: Moment): string => cellsOf(compose(spec, m))
hooks/state.ts 745 lines
1// The session's state and everything that changes it. The economy's rules
2// live in game.ts and what the drawings show in model.ts; this file holds the
3// store sync and the actions the hooks and buttons call.
4
5import type { Amount, Band, EventKind, Extras, FindId, Mine, Mode, Nap, ProducerId, RelicId, Save, SecretId, ShopModel, ShopTab, VeinKind, View } from '../types'
6import { ACHIEVEMENT_BONUS, CONFETTI_MS, EVENTS, FINDS, LONG_SHIFT_MS, PRODUCERS, SHAFT_DEPTH, STREAK_TURNS, TIP_MS, VEIN, type Achievement } from './data.ts'
7import { fmt } from './format.ts'
8import * as game from './game.ts'
9import * as model from './model.ts'
10
11/**
12 * What the actions need from the engine. The hooks module builds it from `$`
13 * once a session starts, since `$` itself is only ever spelled at a call site.
14 */
15/** The few moments that make a sound, each a file in `sounds/`. */
16export type Sound = 'vein' | 'find' | 'secret' | 'squash' | 'party'
17
18export type Io = {
19  now: () => Promise<number>
20  get: (key: string) => Promise<unknown>
21  put: (key: string, value: unknown) => Promise<void>
22  toast: (text: string) => void
23  /** Plays a sound unless they are off; it never waits for it to end. */
24  sound: (name: Sound) => void
25  ask: (question: string, options: readonly string[]) => Promise<string>
26  mine: (change: (mine: Mine) => Mine) => Promise<Mine>
27  view: (change: (view: View) => View) => Promise<View>
28  band: (change: (band: Band) => Band) => Promise<Band>
29  shop: (change: (shop: ShopModel) => ShopModel) => Promise<ShopModel>
30  reward: (change: (word: string | null) => string | null) => Promise<string | null>
31  readMine: () => Promise<Mine>
32  readView: () => Promise<View>
33  readBand: () => Promise<Band>
34  readShop: () => Promise<ShopModel>
35  readReward: () => Promise<string | null>
36}
37
38export const SAVE_KEY = 'save'
39/** The person's settings, kept beside the save so every session follows them. */
40export const COMPACT_KEY = 'compact'
41export const STOPPED_KEY = 'stopped'
42export const MUTED_KEY = 'muted'
43export const TICK_MS = 100
44export const SYNC_MS = 5_000
45/**
46 * Each sync waits up to this much longer, at random. Sessions an update
47 * reloaded together would otherwise sync together for good, and two syncs at
48 * once write over each other.
49 */
50export const SYNC_JITTER_MS = 1_000
51/** A tool call this recent keeps the miner busy, so background agents count as work. */
52export const RECENT_MS = 4_000
53export const MAX_CLICKS_PER_SECOND = 20
54/**
55 * A purchase redraws the shop at once, right after the press, when the next
56 * press is furthest away. Then the band and the shop's other changes wait
57 * this long after the last purchase: a redraw hands every button a new handle,
58 * and a redraw landing mid-run would lose the press that crossed it.
59 */
60export const HOLD_MS = 600
61/** How long the splat stays where the pick squashed the bug. */
62export const SPLAT_MS = 3_000
63/** Idle this long, the miner has a coffee; this long, he falls asleep. */
64export const COFFEE_MS = 5 * 60_000
65export const SLEEP_MS = 30 * 60_000
66
67let heldUntil = 0
68
69export const INITIAL_VIEW: View = {
70  isTurn: false,
71  lastToolAt: 0,
72  waiting: [],
73  last: null,
74  turnStart: 0,
75  lastTurn: 0,
76  amount: 1,
77  tab: 'shop',
78  gem: null,
79  vein: null,
80  veinWork: 0,
81  veinCount: 0,
82  frenzy: null,
83  event: null,
84  turnEvents: [],
85  turnTools: 0,
86  streak: 0,
87  testsFailing: false,
88  squashedAt: 0,
89  restSince: 0,
90  tipUntil: 0,
91  flare: false,
92  confettiAt: 0,
93  word: null,
94}
95
96export const INITIAL_BAND: Band = {
97  isLoaded: false,
98  mode: 'idle',
99  isHidden: false,
100  carts: 0,
101  goblins: 0,
102  pick: 0,
103  crew: [],
104  biome: 0,
105  isCompact: false,
106  isStopped: false,
107  isMuted: false,
108  bug: null,
109  extras: { canary: false, gloves: false, warm: false, coffee: false },
110  nap: null,
111  tip: false,
112  flare: false,
113  find: null,
114  season: null,
115  confetti: false,
116  word: null,
117}
118
119/** A shop amount as this version reads it: ×100 is gone, and Max buys a lot in its place. */
120const amountOf = (value: unknown): Amount => (value === 10 ? 10 : value === 'max' || value === 100 ? 'max' : 1)
121
122const TABS: readonly ShopTab[] = ['shop', 'shaft', 'badges', 'stats']
123const tabOf = (value: unknown): ShopTab => (TABS as readonly unknown[]).includes(value) ? (value as ShopTab) : 'shop'
124
125/** A view or a band as this version reads it: one kept from before an update lacks the newer fields. */
126export const fillView = (view: View): View => ({ ...INITIAL_VIEW, ...view, amount: amountOf(view.amount), tab: tabOf(view.tab) })
127export const fillBand = (band: Band): Band => ({ ...INITIAL_BAND, ...band })
128/** A shop model drawn before this version's first refresh lacks the newer sections. */
129export const fillShop = (shop: ShopModel): ShopModel => ({ ...model.EMPTY_SHOP, ...shop, tab: tabOf(shop.tab) })
130
131/** The multiplier a caught frenzy puts on everything until it runs out. */
132export const boostAt = (view: View, now: number): number => (view.frenzy !== null && now < view.frenzy.until ? view.frenzy.mult : 1)
133
134export const newRunId = (): string =>
135  `${Date.now().toString(36)}-${Math.floor(Math.random() * 36 ** 6).toString(36)}`
136
137/** The upgrades that show in the mine. */
138export const extrasOf = (save: Save): Extras => ({
139  canary: game.hasUpgrade(save, 'lamp-2'),
140  gloves: game.hasUpgrade(save, 'grip-1'),
141  warm: game.hasUpgrade(save, 'lamp-1'),
142  coffee: game.hasUpgrade(save, 'apprentice-2'),
143})
144
145/** What an idle stretch this long shows on the miner, if anything yet. */
146export const napOf = (idleMs: number): Nap | null => (idleMs >= SLEEP_MS ? 'sleep' : idleMs >= COFFEE_MS ? 'coffee' : null)
147
148export function modeOf(view: View, now: number): Mode {
149  if (view.waiting.length > 0) return 'pause'
150  if (view.isTurn || (view.lastToolAt > 0 && now - view.lastToolAt < RECENT_MS)) return 'work'
151  return 'idle'
152}
153
154/** Writes a value only when it changed, so nothing that reads it redraws for nothing. */
155async function patch<T>(read: () => Promise<T>, write: (change: (value: T) => T) => Promise<T>, change: (value: T) => T): Promise<T> {
156  const before = await read()
157  const after = change(before)
158  if (JSON.stringify(after) === JSON.stringify(before)) return before
159  return write(change)
160}
161
162export const patchView = (io: Io, change: (view: View) => View): Promise<View> => patch(io.readView, io.view, change)
163
164/**
165 * Brings the band and the shop in line with the mine. Each is written only
166 * when it changed, and right after a purchase only the band's mode is.
167 */
168export async function refresh(io: Io, now?: number, force = false): Promise<Band> {
169  const at = now ?? (await io.now())
170  const isHeld = !force && at < heldUntil
171  const mine = await io.readMine()
172  const view = await io.readView()
173  const bug: Band['bug'] = view.testsFailing ? 'crawl' : at - view.squashedAt < SPLAT_MS ? 'splat' : null
174  const band = await patch(io.readBand, io.band, current => {
175    const mode = { ...current, isLoaded: mine.isLoaded, mode: modeOf(view, at) }
176    if (isHeld) return mode
177    return {
178      ...mode,
179      bug,
180      extras: extrasOf(mine.save),
181      nap: mode.mode === 'idle' && view.restSince > 0 ? napOf(at - view.restSince) : null,
182      tip: at < view.tipUntil,
183      flare: view.flare && view.isTurn,
184      find: view.vein?.kind === 'find' && at <= view.vein.until ? (view.vein.find ?? null) : null,
185      season: game.seasonOf(at),
186      confetti: view.confettiAt > 0 && at - view.confettiAt < CONFETTI_MS,
187      word: view.word,
188      carts: Math.min(6, game.owned(mine.save, 'cart')),
189      goblins: Math.min(1, game.owned(mine.save, 'goblin')),
190      pick: game.pickTier(mine.save),
191      crew: PRODUCERS.filter(p => game.owned(mine.save, p.id) > 0).map(p => ({ id: p.id, owned: game.owned(mine.save, p.id) })),
192      biome: game.biomeAt(game.depth(mine.save)),
193    }
194  })
195  if (!isHeld) await patch(io.readShop, io.shop, () => model.shopModel(mine.save, view.amount, view.tab, mine.isLoaded))
196  return band
197}
198
199/** Shows a purchase in the shop now, and holds everything else still: see HOLD_MS. */
200async function showPurchase(io: Io): Promise<void> {
201  heldUntil = (await io.now()) + HOLD_MS
202  const mine = await io.readMine()
203  const view = await io.readView()
204  await patch(io.readShop, io.shop, () => model.shopModel(mine.save, view.amount, view.tab, mine.isLoaded))
205}
206
207let loading: Promise<void> | null = null
208/** The achievements this session has seen earned; null until it first looks. */
209let earned: Set<string> | null = null
210
211/** Reads the save from the store the first time anything needs it. */
212export async function ensureLoaded(io: Io): Promise<void> {
213  if ((await io.readMine()).isLoaded) return
214  loading ??= (async () => {
215    const stored = game.normalize(await io.get(SAVE_KEY))
216    const save = stored ?? game.fresh(newRunId())
217    await io.mine(mine => (mine.isLoaded ? mine : { ...game.newMine(save.runId), save, isLoaded: true, writer: newRunId() }))
218    // What the save has earned already is not news.
219    earned ??= new Set(game.achievements(save).map(a => a.id))
220    if (stored === null) await io.put(SAVE_KEY, save)
221    const now = await io.now()
222    await patchView(io, view => (view.restSince > 0 ? view : { ...view, restSince: now }))
223    await readSettings(io)
224    await refreshReward(io, false)
225    await refresh(io)
226  })().finally(() => {
227    loading = null
228  })
229  await loading
230}
231
232let syncing: Promise<void> | null = null
233
234/**
235 * Merges this session's changes into the store, and takes in what other
236 * sessions mined. Changes another session wrote over go in again: see game.syncMine.
237 */
238export async function sync(io: Io): Promise<void> {
239  syncing ??= (async () => {
240    if (!(await io.readMine()).isLoaded) return
241    const stored = game.normalize(await io.get(SAVE_KEY))
242    const id = newRunId()
243    const out: { write: Save | null } = { write: null }
244    await io.mine(mine => {
245      const next = game.syncMine(stored, mine, id)
246      out.write = next.isWrite ? next.save : null
247      return next.mine
248    })
249    if (out.write !== null) await io.put(SAVE_KEY, out.write)
250    await readSettings(io)
251    await refreshReward(io, true)
252    await refresh(io)
253  })().finally(() => {
254    syncing = null
255  })
256  await syncing
257}
258
259/** Keeps the spinner word in step with all-time sparks. */
260export async function refreshReward(io: Io, announce: boolean): Promise<void> {
261  const mine = await io.readMine()
262  const word = game.rewardWord(mine.save.allTime)
263  const current = await io.readReward()
264  if (word === current) return
265  await io.reward(() => word)
266  if (announce && word !== null) io.toast(`New spinner word unlocked: ${word}`)
267}
268
269let lastTick = 0
270
271/**
272 * The achievements a save earned since this session last looked. The ones a
273 * save already has when the session first looks are not news.
274 */
275function newlyEarned(save: Save): Achievement[] {
276  const now = game.achievements(save)
277  const before = earned
278  earned = new Set(now.map(a => a.id))
279  return before === null ? [] : now.filter(a => !before.has(a.id))
280}
281
282/**
283 * One line for every achievement earned at once: a toast drawn over another
284 * before it showed is lost, and a deep save from another session earns several.
285 */
286function achievementText(list: readonly Achievement[]): string {
287  const names = list.map(a => a.name)
288  const last = names.pop()
289  const named = names.length === 0 ? last : `${names.join(', ')} and ${last}`
290  return `${list.length === 1 ? 'Achievement' : 'Achievements'}: ${named}. Everything mines ${Math.round(ACHIEVEMENT_BONUS * 100 * list.length)}% more.`
291}
292
293/** Runs every TICK_MS: the crew's income, the switch between work and idle, and the veins. */
294export async function tick(io: Io): Promise<void> {
295  const now = await io.now()
296  const seconds = lastTick === 0 ? 0 : Math.min(2, Math.max(0, (now - lastTick) / 1000))
297  lastTick = now
298  const mine = await io.readMine()
299  if (!mine.isLoaded) return
300  const won = newlyEarned(mine.save)
301  if (won.length > 0) io.toast(achievementText(won))
302  const band = await refresh(io, now)
303  if (band.isStopped) return
304  const view = await io.readView()
305  const rate = game.rate(mine.save)
306  // The idle pace runs whatever Claude does, and it is the mine's: the save
307  // takes it once, however many sessions are open. Claude's work lifts this
308  // session's crew to the full rate, and a frenzy multiplies it: those are its own.
309  const idle = rate * game.idleShare(mine.save) * seconds
310  const amount = (band.mode === 'idle' ? idle : rate * seconds) * boostAt(view, now)
311  if (amount > 0) await io.mine(m => game.payCrew(m, idle, amount - idle, now - seconds * 1000, now))
312  await growVeins(io, band.mode, seconds, now, mine.save)
313}
314
315/**
316 * Veins show while Claude works: after so many seconds of work one shows for
317 * a few seconds, and goes when it is not caught. A frenzy runs out on its own.
318 */
319async function growVeins(io: Io, mode: Mode, seconds: number, now: number, save: Save): Promise<void> {
320  const speed = game.multipliers(save).veinSpeed
321  await patchView(io, view => {
322    let next = view
323    if (next.vein !== null && now > next.vein.until) next = { ...next, vein: null }
324    if (next.frenzy !== null && now >= next.frenzy.until) next = { ...next, frenzy: null }
325    if (mode !== 'work' || next.vein !== null) return next
326    const work = next.veinWork + seconds
327    const due = game.veinAfter(save.runId, next.veinCount, speed)
328    if (work < due.seconds) return { ...next, veinWork: work }
329    const id = next.veinCount + 1
330    // In a layer with a find not yet made, the vein may be that instead.
331    const find = game.findAt(save, id)
332    const vein = find === null ? { id, kind: due.kind, until: now + VEIN.showMs } : { id, kind: 'find' as const, find, until: now + VEIN.showMs }
333    return { ...next, veinWork: 0, veinCount: id, vein }
334  })
335}
336
337/** A press on the vein while it shows: a rich one pays at once, a frenzy multiplies everything for a while. */
338export async function catchVein(io: Io, id: number): Promise<VeinKind | null> {
339  const now = await io.now()
340  if ((await io.readBand()).isStopped) return null
341  const m = game.multipliers((await io.readMine()).save)
342  const frenzyMs = VEIN.frenzyMs * m.frenzyTime
343  const hit: { kind: VeinKind | null; find?: FindId } = { kind: null }
344  await patchView(io, view => {
345    hit.kind = null
346    if (view.vein === null || view.vein.id !== id || now > view.vein.until) return view
347    hit.kind = view.vein.kind
348    if (view.vein.find !== undefined) hit.find = view.vein.find
349    const frenzy = view.vein.kind === 'frenzy' ? { mult: m.frenzyMult, until: now + frenzyMs } : view.frenzy
350    return { ...view, vein: null, frenzy }
351  })
352  const kind = hit.kind
353  if (kind === null) return null
354  // A find pays as a rich vein does, and is a secret for good.
355  let value = 0
356  await io.mine(m => {
357    value = kind === 'frenzy' ? 0 : game.veinValue(m.save)
358    return game.earn(m, value, { kind: 'vein' })
359  })
360  if (hit.find !== undefined) await findSecrets(io, [hit.find])
361  io.sound(hit.find !== undefined ? 'find' : 'vein')
362  const label = hit.find !== undefined ? FINDS[hit.find].name : kind === 'rich' ? 'Rich vein' : `Frenzy ×${m.frenzyMult}`
363  await io.view(view => ({ ...view, event: { label, value, at: now } }))
364  await refresh(io, now)
365  return kind
366}
367
368let pressed: number[] = []
369
370/**
371 * Presses of the Mine button, counted while Claude works and at most
372 * MAX_CLICKS_PER_SECOND. Each may strike a gem; the view keeps the latest,
373 * so the HUD can pop it. A frenzy multiplies what they mine.
374 */
375export async function clicks(io: Io, n: number): Promise<{ counted: number; gems: number }> {
376  const now = await io.now()
377  pressed = pressed.filter(at => now - at < 1000)
378  const band = await io.readBand()
379  const mine = await io.readMine()
380  const counted = Math.max(0, Math.min(Math.floor(n), MAX_CLICKS_PER_SECOND - pressed.length))
381  if (!mine.isLoaded || band.mode !== 'work' || band.isStopped || counted === 0) return { counted: 0, gems: 0 }
382  for (let i = 0; i < counted; i += 1) pressed.push(now)
383  const boost = boostAt(await io.readView(), now)
384  let gems = 0
385  let bonus = 0
386  await io.mine(m => {
387    gems = 0
388    bonus = 0
389    const mult = game.multipliers(m.save)
390    let next = m
391    for (let i = 0; i < counted; i += 1) {
392      const hit = game.strike(next.save, mult)
393      if (hit.isGem) {
394        gems += 1
395        bonus += (hit.value - game.clickValue(next.save, mult)) * boost
396      }
397      next = game.earn(next, hit.value * boost, { kind: 'click', isGem: hit.isGem })
398    }
399    return next
400  })
401  if (gems > 0) await io.view(view => ({ ...view, gem: { seq: (view.gem?.seq ?? 0) + gems, bonus } }))
402  await refresh(io, now)
403  return { counted, gems }
404}
405
406/**
407 * Pays work events, once a turn for each kind: green tests, a build, a commit,
408 * a pull request, a long shift. The view keeps them a moment, so the HUD can
409 * say what paid; several at once (a build and the tests in one command) share
410 * one line and add up.
411 */
412async function reward(io: Io, kinds: readonly EventKind[], now: number, labels: Partial<Record<EventKind, string>> = {}): Promise<number> {
413  const fresh: { kinds: EventKind[] } = { kinds: [] }
414  await patchView(io, view => {
415    fresh.kinds = [...new Set(kinds)].filter(kind => !view.turnEvents.includes(kind))
416    return fresh.kinds.length === 0 ? view : { ...view, turnEvents: [...view.turnEvents, ...fresh.kinds] }
417  })
418  if (fresh.kinds.length === 0) return 0
419  let total = 0
420  await io.mine(m => {
421    total = 0
422    let next = m
423    for (const kind of fresh.kinds) {
424      const value = game.eventValue(next.save, kind)
425      total += value
426      next = game.earn(next, value, { kind: 'event', event: kind })
427    }
428    return next
429  })
430  const label = fresh.kinds.map(kind => labels[kind] ?? EVENTS[kind].label).join(' · ')
431  await io.view(view => ({ ...view, event: { label, value: total, at: now } }))
432  return total
433}
434
435/**
436 * A tool call Claude finished: the miner mines it, and the work it did pays a
437 * little more. Tests that pass after a run of them failed squash a bug.
438 */
439export async function minedTool(io: Io, outcome: game.ToolOutcome): Promise<number> {
440  await ensureLoaded(io)
441  if ((await io.readBand()).isStopped) return 0
442  const kind = game.classifyTool(outcome.tool)
443  let value = 0
444  await io.mine(m => {
445    value = game.toolValue(m.save, kind)
446    return game.earn(m, value, { kind: 'tool', tool: kind })
447  })
448  const now = await io.now()
449  const label = kind === 'mcp' ? 'MCP' : outcome.tool
450  const run = game.testRun(outcome)
451  const squashed = { yes: false }
452  await io.view(view => {
453    squashed.yes = run === 'passed' && view.testsFailing
454    const testsFailing = run === null ? view.testsFailing : run === 'failed'
455    const squashedAt = squashed.yes ? now : view.squashedAt
456    return { ...view, last: { label, value }, lastToolAt: now, restSince: now, turnTools: view.turnTools + 1, testsFailing, squashedAt }
457  })
458  const events = game.workEvents(outcome)
459  if (squashed.yes) events.push('fix')
460  if (events.length > 0) await reward(io, events, now)
461  if (events.includes('merge')) await io.view(view => ({ ...view, confettiAt: now }))
462  const found = await findSecrets(io, [
463    ...(game.isInitialCommit(outcome) ? (['groundbreaking'] as const) : []),
464    ...(events.includes('merge') && game.isFridayAfternoon(now) ? (['friday-deploy'] as const) : []),
465  ])
466  // One sound for the call's news, the rarest: a secret, a merge, a bug squashed.
467  if (found.length > 0) io.sound('secret')
468  else if (events.includes('merge')) io.sound('party')
469  else if (squashed.yes) io.sound('squash')
470  await refresh(io, now)
471  return value
472}
473
474/** Records the secrets found that were not yet: each is an achievement, which the tick toasts. */
475async function findSecrets(io: Io, ids: readonly SecretId[]): Promise<SecretId[]> {
476  const found: { ids: SecretId[] } = { ids: [] }
477  if (ids.length === 0) return found.ids
478  await io.mine(mine => {
479    found.ids = ids.filter(id => !mine.save.secrets.includes(id))
480    return found.ids.reduce((next, id) => game.findSecret(next, id), mine)
481  })
482  return found.ids
483}
484
485/**
486 * The person's prompt, read for a few words and left as it is: thanks make
487 * the miner tip his hat, and ultrathink flares his lantern for the turn.
488 * Both are secrets the first time.
489 */
490export async function promptSeen(io: Io, text: string): Promise<void> {
491  const secrets = game.promptSecrets(text)
492  if (secrets.length === 0) return
493  await ensureLoaded(io)
494  const now = await io.now()
495  await patchView(io, view => ({
496    ...view,
497    tipUntil: secrets.includes('good-manners') ? now + TIP_MS : view.tipUntil,
498    flare: view.flare || secrets.includes('deep-thinker'),
499  }))
500  if ((await findSecrets(io, secrets)).length > 0) io.sound('secret')
501  await refresh(io, now)
502}
503
504/** Marks a tool call that waits on the person (a permission prompt, a question), or clears it. */
505export async function setWaiting(io: Io, id: string, isWaiting: boolean): Promise<void> {
506  const view = await patchView(io, current => {
507    if (current.waiting.includes(id) === isWaiting) return current
508    const waiting = isWaiting ? [...current.waiting, id] : current.waiting.filter(other => other !== id)
509    return { ...current, waiting }
510  })
511  if (view.waiting.includes(id) === isWaiting) await refresh(io)
512}
513
514export async function startTurn(io: Io, turnId = ''): Promise<void> {
515  await ensureLoaded(io)
516  const mine = await io.readMine()
517  const word = game.rareWord(turnId)
518  await io.view(view => ({ ...view, isTurn: true, turnStart: mine.save.lifetime, turnEvents: [], turnTools: 0, word }))
519  await refresh(io)
520}
521
522/** Why a turn ended, as `turn.complete` says: only an answer keeps a streak going. */
523export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
524
525/**
526 * A finished turn: shift pay, the long-shift reward for a long one, the
527 * streak, and the best turn yet. A good turn ends in an answer after some
528 * work; every STREAK_TURNS of them in a row pay. One without work leaves the
529 * streak as it was, and one cut short or failed ends it.
530 */
531export async function endTurn(io: Io, durationMs = 0, reason: TurnEnd = 'answer', answer = ''): Promise<number> {
532  await ensureLoaded(io)
533  const at = await io.now()
534  const found = await findSecrets(io, [
535    ...(game.isAbsolutelyRight(answer) ? (['absolutely-right'] as const) : []),
536    ...(game.crossesMidnight(at, durationMs) ? (['midnight-oil'] as const) : []),
537  ])
538  if (found.length > 0) io.sound('secret')
539  const isStopped = (await io.readBand()).isStopped
540  let bonus = 0
541  if (!isStopped) {
542    await io.mine(m => {
543      bonus = game.shiftValue(m.save)
544      return game.earn(m, bonus, { kind: 'turn' })
545    })
546    const run = { streak: 0, isLonger: false }
547    await patchView(io, view => {
548      const streak = reason !== 'answer' ? 0 : view.turnTools > 0 ? view.streak + 1 : view.streak
549      run.streak = streak
550      run.isLonger = streak > view.streak
551      return streak === view.streak ? view : { ...view, streak }
552    })
553    const kinds: EventKind[] = []
554    if (durationMs >= LONG_SHIFT_MS) kinds.push('longShift')
555    if (run.isLonger && run.streak % STREAK_TURNS === 0) kinds.push('streak')
556    if (kinds.length > 0) await reward(io, kinds, await io.now(), { streak: `Streak ×${run.streak}` })
557    await io.mine(m => game.recordStreak(m, run.streak))
558  }
559  const mine = await io.readMine()
560  const { turnStart } = await io.readView()
561  const earned = Math.max(0, mine.save.lifetime - turnStart)
562  if (!isStopped) await io.mine(m => game.recordTurn(m, earned))
563  const endedAt = await io.now()
564  await io.view(view => ({
565    ...view,
566    isTurn: false,
567    waiting: [],
568    lastToolAt: 0,
569    lastTurn: earned,
570    restSince: endedAt,
571    flare: false,
572    word: null,
573  }))
574  await refresh(io)
575  void sync(io).catch(() => undefined)
576  return bonus
577}
578
579/**
580 * Buys the amount the shop is set to, all of it or none, or for Max as many as
581 * the purse allows: a short purse says how short.
582 */
583export async function buyProducer(io: Io, id: ProducerId): Promise<number> {
584  const { amount } = await io.readView()
585  const p = game.producer(id)
586  const countFor = (save: Save) => (amount === 'max' ? game.maxCount(p, game.owned(save, id), save.sparks) : amount)
587  let bought = 0
588  await io.mine(mine => {
589    bought = 0
590    const count = countFor(mine.save)
591    const next = game.buyProducer(mine, id, count)
592    if (next === null) return mine
593    bought = count
594    return next
595  })
596  if (bought === 0) {
597    const { save } = await io.readMine()
598    const short = game.priceOf(p, game.owned(save, id), countFor(save)) - save.sparks
599    io.toast(`Need ${fmt(Math.max(1, short))} more ✻`)
600  } else {
601    await showPurchase(io)
602  }
603  await refresh(io)
604  return bought
605}
606
607export async function buyUpgrade(io: Io, id: string): Promise<boolean> {
608  let isBought = false
609  await io.mine(mine => {
610    const next = game.buyUpgrade(mine, id)
611    isBought = next !== null
612    return next ?? mine
613  })
614  if (!isBought) {
615    const { save } = await io.readMine()
616    const u = game.upgrade(id)
617    if (u !== undefined) io.toast(`Need ${fmt(Math.max(1, u.cost - save.sparks))} more ✻`)
618  } else {
619    await showPurchase(io)
620  }
621  await refresh(io)
622  return isBought
623}
624
625export async function setAmount(io: Io, amount: Amount): Promise<void> {
626  await patchView(io, view => (view.amount === amount ? view : { ...view, amount }))
627  await refresh(io, undefined, true)
628}
629
630/** Shows one of the shop's tabs, in this session. */
631export async function setTab(io: Io, tab: ShopTab): Promise<void> {
632  await patchView(io, view => (view.tab === tab ? view : { ...view, tab }))
633  await refresh(io, undefined, true)
634}
635
636/**
637 * Buys a relic with stones not spent yet. It syncs first, so a relic another
638 * session just bought is counted, and after, so the next session to sync sees it.
639 */
640export async function buyRelic(io: Io, id: RelicId): Promise<boolean> {
641  await sync(io)
642  let isBought = false
643  await io.mine(mine => {
644    const next = game.buyRelic(mine, id)
645    isBought = next !== null
646    return next ?? mine
647  })
648  const { save } = await io.readMine()
649  const r = game.relic(id)
650  if (!isBought) {
651    if (r !== undefined && !game.hasRelic(save, id)) io.toast(`Need ${Math.max(1, r.cost - game.stonesToSpend(save))} more ◈`)
652  } else {
653    await showPurchase(io)
654    if (r !== undefined) io.toast(`${r.name}: ${r.blurb}`)
655  }
656  await refresh(io)
657  if (isBought) void sync(io).catch(() => undefined)
658  return isBought
659}
660
661export async function setHidden(io: Io, isHidden: boolean): Promise<void> {
662  await patch(io.readBand, io.band, band => (band.isHidden === isHidden ? band : { ...band, isHidden }))
663}
664
665/** One line even while Claude works, in every session. */
666export async function setCompact(io: Io, isCompact: boolean): Promise<void> {
667  await io.put(COMPACT_KEY, isCompact)
668  await patch(io.readBand, io.band, band => (band.isCompact === isCompact ? band : { ...band, isCompact }))
669}
670
671/** Stops the game in every session: nothing mines and the band goes, until it starts again. */
672export async function setStopped(io: Io, isStopped: boolean): Promise<void> {
673  await io.put(STOPPED_KEY, isStopped)
674  await patch(io.readBand, io.band, band => (band.isStopped === isStopped ? band : { ...band, isStopped }))
675  if (isStopped) await patchView(io, view => (view.vein === null && view.frenzy === null ? view : { ...view, vein: null, frenzy: null }))
676}
677
678/** Takes in the settings another session may have changed. */
679async function readSettings(io: Io): Promise<void> {
680  const isCompact = (await io.get(COMPACT_KEY)) === true
681  const isStopped = (await io.get(STOPPED_KEY)) === true
682  const isMuted = (await io.get(MUTED_KEY)) === true
683  await patch(io.readBand, io.band, band =>
684    band.isCompact === isCompact && band.isStopped === isStopped && band.isMuted === isMuted ? band : { ...band, isCompact, isStopped, isMuted },
685  )
686}
687
688/** Sounds off or on, in every session. */
689export async function setMuted(io: Io, isMuted: boolean): Promise<void> {
690  await io.put(MUTED_KEY, isMuted)
691  await patch(io.readBand, io.band, band => (band.isMuted === isMuted ? band : { ...band, isMuted }))
692}
693
694async function confirm(io: Io, question: string, yes: string, no: string): Promise<boolean> {
695  try {
696    return (await io.ask(question, [no, yes])) === yes
697  } catch {
698    return false
699  }
700}
701
702/** Prestige: trade this run for ancient stones in a new shaft, after the person confirms. */
703export async function newShaft(io: Io): Promise<boolean> {
704  await sync(io)
705  const { save } = await io.readMine()
706  const gain = game.stoneGain(save)
707  if (gain < 1) {
708    io.toast(`A new shaft opens once this one reaches ${SHAFT_DEPTH} m`)
709    return false
710  }
711  const pct = Math.round(game.stoneBonus(save.stones + gain) * 100)
712  const headStart = game.ownedRelics(save).some(r => r.effect.kind === 'ledger' || r.effect.kind === 'crew' || r.effect.kind === 'survey')
713  const isSure = await confirm(
714    io,
715    `Open a new shaft and find ${gain} ◈? Sparks, producers, upgrades and depth start over${headStart ? ', with the head start your relics give' : ''}. Ancient stones, relics, achievements and stats stay: you will have ${game.stonesToSpend(save) + gain} ◈ to spend on relics, and your stones will add ${pct}% to everything.`,
716    'Open it',
717    'Keep digging',
718  )
719  if (!isSure) return false
720  await sync(io)
721  const current = (await io.readMine()).save
722  const next = game.openShaft(current, newRunId())
723  if (next === null) return false
724  await io.put(SAVE_KEY, next)
725  await io.mine(mine => ({ ...mine, save: next, pending: game.emptyDelta(next.runId), inflight: [], isLoaded: true }))
726  // A turn running now counts from the new shaft's start, which a survey map puts a hundred metres down.
727  await io.view(view => ({ ...view, turnStart: next.lifetime }))
728  await refresh(io, undefined, true)
729  // Fresh rock is earned now too: one toast says both, so neither hides the other.
730  const won = newlyEarned(next)
731  io.toast([`A new shaft is open: ${next.stones - current.stones} ◈ found, ${game.stonesToSpend(next)} to spend on relics.`, ...(won.length > 0 ? [achievementText(won)] : [])].join(' '))
732  return true
733}
734
735export async function resetMine(io: Io): Promise<boolean> {
736  const isSure = await confirm(io, 'Reset Sparkmine? This deletes every spark, producer, upgrade, ancient stone and achievement.', 'Reset', 'Keep it')
737  if (!isSure) return false
738  const save = game.fresh(newRunId())
739  await io.put(SAVE_KEY, save)
740  await io.mine(mine => ({ ...mine, save, pending: game.emptyDelta(save.runId), inflight: [], isLoaded: true }))
741  await io.reward(() => null)
742  await refresh(io, undefined, true)
743  return true
744}
745
hooks/ui.tsx 415 lines
1// The shop pane, as a plain tree built from its model. The render hook reads
2// the model, picks the surface's elements and hands in what only it can make:
3// the pictures (desktop only) and the live Clients. The pane shows one tab at
4// a time, so however much the shop holds, it stays short.
5
6import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
7
8import type { Amount, IconId, ProducerId, RelicId, ShopItem, ShopModel, ShopTab } from '../types'
9import { ACHIEVEMENT_BONUS, RELICS } from './data.ts'
10import { commas, fmt, plural } from './format.ts'
11
12type Elements = ElementTable<RenderSurface>
13
14export type ShopActions = {
15  buy: (id: ProducerId) => unknown
16  upgrade: (id: string) => unknown
17  amount: (amount: Amount) => unknown
18  shaft: () => unknown
19  tab: (tab: ShopTab) => unknown
20  relic: (id: RelicId) => unknown
21}
22
23export type ShopParts = {
24  /** A picture of the icon, `size` CSS pixels square, or null where the surface draws none. */
25  icon: (id: IconId, isDim: boolean, size?: number) => RenderElement | null
26  /** The mine itself, at the top of the pane. */
27  header: RenderElement | null
28  /** The Client that keeps the sparks counter live. */
29  live: RenderElement
30  /** The Client that keeps the stats live, where the surface draws one. */
31  stats: RenderElement | null
32  /** A line above everything, such as that the game is stopped. */
33  notice: RenderElement | null
34  /** The desktop's pictures: the layers in sight, the badge wall, a bar. Null where the surface draws them in text. */
35  pictures: {
36    strata: RenderElement
37    badgeWall: RenderElement
38    bar: (share: number, color: string) => RenderElement
39  } | null
40  /** Cards get a border where there is room for one. */
41  isRoomy: boolean
42}
43
44/** Where a Button's closure is held: what a surface names when it presses the Button. */
45export type Press = { plugin: string; handle: number }
46
47/**
48 * Keeps each Button's handle from one drawing of the shop to the next. Every
49 * Button built gets a new handle and a redraw lets the old ones go, so a press
50 * the desktop sent under one just let go reached nothing: a purchase made as
51 * the shop redrew was lost. A Button whose key was drawn before keeps that
52 * handle, with this drawing's label and closure.
53 *
54 * @param held the handles drawn so far, by key, kept from drawing to drawing
55 */
56export function keepHandles(tree: RenderElement, held: Map<string, Press>): RenderElement {
57  const keep = (node: unknown): unknown => {
58    if (Array.isArray(node)) return node.map(keep)
59    if (node === null || typeof node !== 'object') return node
60    const el = node as { type?: unknown; props?: { key?: unknown }; press?: unknown; children?: unknown }
61    if (el.type === 'Button' && typeof el.props?.key === 'string' && typeof el.press === 'object' && el.press !== null) {
62      const before = held.get(el.props.key)
63      if (before !== undefined) return { ...el, press: before }
64      held.set(el.props.key, el.press as Press)
65      return node
66    }
67    return el.children === undefined ? node : { ...el, children: keep(el.children) }
68  }
69  return keep(tree) as RenderElement
70}
71
72/** What a press of a producer's button can buy: one, ten, or as many as the purse allows. */
73const AMOUNTS: readonly Amount[] = [1, 10, 'max']
74
75/** The tabs, in order, and the glyph the desktop shows before each name. */
76const TABS: readonly { tab: ShopTab; glyph: string; name: string }[] = [
77  { tab: 'shop', glyph: '⛏', name: 'Shop' },
78  { tab: 'shaft', glyph: '◈', name: 'Shaft' },
79  { tab: 'badges', glyph: '★', name: 'Badges' },
80  { tab: 'stats', glyph: '▤', name: 'Stats' },
81]
82
83/** On the desktop the owned relics' names take a column as wide as the longest, so what each does lines up. */
84const RELIC_NAME_WIDTH = Math.max(...RELICS.map(r => r.name.length))
85
86/** The gold of an earned badge and a bar of them: the theme's, so a light theme darkens it. */
87const GOLD = 'warning'
88/** The same gold for the desktop's pictures, which draw no theme colors. */
89const PICTURE_GOLD = '#f2b632'
90
91/** A bar in text: `have` of `need` filled. */
92function textBar(have: number, need: number, width: number): string {
93  const filled = need <= 0 ? width : Math.max(0, Math.min(width, Math.floor((have / need) * width)))
94  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
95}
96
97/** A sentence that goes on after another: its first letter small, unless it names Claude. */
98const lower = (text: string): string => (text.startsWith('Claude') ? text : `${text.charAt(0).toLowerCase()}${text.slice(1)}`)
99
100/** What the stones and achievements add to everything, once there are any. */
101function bonusLine(el: Elements, model: ShopModel): RenderElement | null {
102  const { Text } = el
103  const b = model.bonus
104  if (b.stones === 0 && b.achievements === 0) return null
105  const parts = [
106    ...(b.stones > 0 ? [`◈ ${plural(b.stones, 'stone', 'stones')} +${b.stonesPct}%`] : []),
107    ...(b.achievements > 0 ? [`★ ${plural(b.achievements, 'achievement', 'achievements')} +${b.achievementsPct}%`] : []),
108  ]
109  return <Text color="permission">{parts.join(' · ')}</Text>
110}
111
112/** A section's name in bold, and a note on it. */
113function heading(el: Elements, name: string, note?: string): RenderElement {
114  const { Box, Text } = el
115  return (
116    <Box flexDirection="row" columnGap={1}>
117      <Text bold>{name}</Text>
118      {note === undefined ? null : <Text dimColor>{note}</Text>}
119    </Box>
120  )
121}
122
123function card(el: Elements, parts: ShopParts, item: ShopItem, button: RenderElement): RenderElement {
124  const { Box, Text } = el
125  const name = item.owned > 0 ? `${item.name} ×${item.owned}` : item.name
126  return (
127    <Box
128      flexDirection="row"
129      columnGap={1}
130      alignItems="center"
131      paddingX={1}
132      {...(parts.isRoomy ? { borderStyle: 'round', borderColor: item.canBuy ? 'claude' : 'inactive' } : {})}
133    >
134      {parts.icon(item.icon, !item.canBuy && item.owned === 0)}
135      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
136        <Text bold>{name}</Text>
137        <Text dimColor>{item.detail}</Text>
138      </Box>
139      {button}
140    </Box>
141  )
142}
143
144function priceButton(el: Elements, key: string, label: string, canBuy: boolean, onPress: () => unknown): RenderElement {
145  const { Button } = el
146  return canBuy ? (
147    <Button key={key} label={label} variant="primary" onPress={() => onPress()} />
148  ) : (
149    <Button key={key} label={label} dimColor onPress={() => onPress()} />
150  )
151}
152
153/** Bordered cards stand apart by a row, so their edges never touch. */
154function cards(el: Elements, parts: ShopParts, list: RenderElement[]): RenderElement | RenderElement[] {
155  const { Box } = el
156  return parts.isRoomy ? <Box flexDirection="column" rowGap={1}>{list}</Box> : list
157}
158
159/** The mine's picture, the live counter, what a click and a tool call mine, and the bonuses. */
160function header(el: Elements, model: ShopModel, parts: ShopParts): RenderElement {
161  const { Box, Text } = el
162  const v = model.values
163  return (
164    <Box flexDirection="row" columnGap={2} alignItems="center">
165      {parts.header}
166      <Box flexDirection="column">
167        {parts.live}
168        <Text dimColor>{`Click +${fmt(v.click)} · Read +${fmt(v.read)} · Edit +${fmt(v.edit)}`}</Text>
169        {bonusLine(el, model)}
170      </Box>
171    </Box>
172  )
173}
174
175/** The tabs: the one showing lit, the rest dim. The terminal draws no glyph, which an emoji font could widen. */
176function tabRow(el: Elements, model: ShopModel, parts: ShopParts, act: ShopActions): RenderElement {
177  const { Box, Button } = el
178  return (
179    <Box flexDirection="row" columnGap={1} flexWrap="wrap">
180      {TABS.map(t => {
181        const count = t.tab === 'badges' ? ` ${model.badges.earned}/${model.badges.total}` : ''
182        const label = `${parts.isRoomy ? `${t.glyph} ` : ''}${t.name}${count}`
183        return t.tab === model.tab ? (
184          <Button key={`tab-${t.tab}`} label={label} variant="primary" onPress={() => act.tab(t.tab)} />
185        ) : (
186          <Button key={`tab-${t.tab}`} label={label} dimColor onPress={() => act.tab(t.tab)} />
187        )
188      })}
189    </Box>
190  )
191}
192
193/** Producers and upgrades. */
194function shopTab(el: Elements, model: ShopModel, parts: ShopParts, act: ShopActions): RenderElement[] {
195  const { Box, Text, Button } = el
196  const amount = model.amount
197  const producers = model.producers.map(item =>
198    card(
199      el,
200      parts,
201      item,
202      priceButton(el, `buy-${item.id}`, `${amount === 1 ? '' : `×${item.count} `}${fmt(item.price)} ✻`, item.canBuy, () =>
203        act.buy(item.id as ProducerId),
204      ),
205    ),
206  )
207  const teaser =
208    model.teaser === null ? null : (
209      <Box flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
210        {parts.icon('locked', true)}
211        <Text dimColor>{`??? · ${fmt(model.teaser)} ✻`}</Text>
212      </Box>
213    )
214  const upgrades = model.upgrades.map(item =>
215    card(el, parts, item, priceButton(el, `up-${item.id}`, `${fmt(item.price)} ✻`, item.canBuy, () => act.upgrade(item.id))),
216  )
217  const notes = [...(model.more > 0 ? [`+${model.more} more on sale`] : []), ...(model.next === null ? [] : [`Next: ${model.next.name}, ${model.next.text}`])]
218  return [
219    <Box flexDirection="column">
220      <Box flexDirection="row" columnGap={2}>
221        <Text bold>Producers</Text>
222        {AMOUNTS.map(n => (
223          <Button key={`amount-${n}`} label={n === 'max' ? 'Max' : `×${n}`} plain dimColor={amount !== n} onPress={() => act.amount(n)} />
224        ))}
225      </Box>
226      {cards(el, parts, producers)}
227      {teaser}
228    </Box>,
229    <Box flexDirection="column">
230      <Text bold>Upgrades</Text>
231      {upgrades.length > 0 ? cards(el, parts, upgrades) : <Text dimColor>Nothing on sale yet.</Text>}
232      {notes.length > 0 ? <Text dimColor>{notes.join(' · ')}</Text> : null}
233    </Box>,
234  ]
235}
236
237/** The layers in sight in text: reached ones solid in their colors, the next shaded, the ones below dotted. */
238function textStrata(el: Elements, model: ShopModel): RenderElement {
239  const { Text } = el
240  return (
241    <Text>
242      {model.depth.strata.map(s =>
243        s.state === 'ahead' ? <Text dimColor>{'····· '}</Text> : <Text color={s.color}>{s.state === 'next' ? '░░░░░ ' : '█████ '}</Text>,
244      )}
245    </Text>
246  )
247}
248
249/** How deep this run is and what its layers add, the new shaft, and the relics stones buy. */
250function shaftTab(el: Elements, model: ShopModel, parts: ShopParts, act: ShopActions): RenderElement[] {
251  const { Box, Text } = el
252  const d = model.depth
253  const s = model.shaft
254  const r = model.relics
255  const depth = (
256    <Box flexDirection="column">
257      {heading(el, 'Depth', d.deepest > d.metres ? `${commas(d.metres)} m · deepest ${commas(d.deepest)} m` : `${commas(d.metres)} m`)}
258      {parts.pictures?.strata ?? textStrata(el, model)}
259      <Text dimColor>
260        <Text color={d.here.color} bold>
261          {d.here.name}
262        </Text>
263        {d.next === null ? ' now: the deepest layer there is.' : ' now · '}
264        {d.next === null ? null : (
265          <Text color={d.next.color} bold>
266            {d.next.name}
267          </Text>
268        )}
269        {d.next === null ? null : ` at ${commas(d.next.from)} m: ${lower(d.next.text)}`}
270      </Text>
271      <Text dimColor>{d.perks.length > 0 ? `Your layers add: ${d.perks.join(' · ')}.` : 'Each layer you reach adds its perk for the rest of the run.'}</Text>
272    </Box>
273  )
274  // The shaft pays stones, it costs none: so the card says what it finds, and
275  // what there is to spend then, in the words the relics use.
276  const canOpen = s.gain > 0
277  const total = r.found + s.gain
278  const shaft = card(
279    el,
280    parts,
281    {
282      id: 'shaft',
283      icon: 'stone',
284      name: canOpen ? `Open it now to find ◈ ${plural(s.gain, 'stone', 'stones')}` : `Opens at ${s.at} m`,
285      detail: canOpen
286        ? `Start over in fresh rock with ◈ ${commas(r.toSpend + s.gain)} to spend on relics. ${total === 1 ? 'The stone then adds' : `All ${commas(total)} stones then add`} ${s.pct}% to everything${r.found > 0 ? `, up from ${model.bonus.stonesPct}%` : ', for good'}. Badges, stats and relics stay.`
287        : `A new shaft opens once this one reaches ${s.at} m, and finds more ◈ stones the deeper it gets.`,
288      owned: 0,
289      count: 1,
290      price: 0,
291      canBuy: canOpen,
292    },
293    priceButton(el, 'shaft', 'Open', canOpen, () => act.shaft()),
294  )
295  const spent = r.found - r.toSpend
296  const about =
297    r.found === 0
298      ? 'New shafts find ◈ ancient stones to spend here. They add to everything for good, and spending them never lowers that.'
299      : spent === 0
300        ? `You found ${plural(r.found, 'stone', 'stones')}. ${r.found === 1 ? 'It adds' : 'They add'} ${model.bonus.stonesPct}% to everything, and spending ${r.found === 1 ? 'it' : 'them'} never lowers that.`
301        : `You found ${plural(r.found, 'stone', 'stones')} and spent ${commas(spent)} on relics. All ${commas(r.found)} still add ${model.bonus.stonesPct}% to everything: spending never lowers it.`
302  // The relics owned and what each does: where there is room, beside the
303  // names in a column of their own; elsewhere, under each name.
304  const owned =
305    r.owned.length === 0 ? null : (
306      <Box flexDirection="column">
307        {r.owned.map(o => (
308          <Box flexDirection="row" columnGap={1} alignItems="flex-start">
309            <Text color="success">✓</Text>
310            {parts.icon(o.icon, false, 18)}
311            <Box flexDirection={parts.isRoomy ? 'row' : 'column'} columnGap={2} flexGrow={1} flexShrink={1}>
312              <Box {...(parts.isRoomy ? { width: RELIC_NAME_WIDTH } : {})} flexShrink={0}>
313                <Text bold>{o.name}</Text>
314              </Box>
315              <Box flexGrow={1} flexShrink={1}>
316                <Text dimColor>{o.text}</Text>
317              </Box>
318            </Box>
319          </Box>
320        ))}
321      </Box>
322    )
323  const forSale = r.forSale.map(item => card(el, parts, item, priceButton(el, `relic-${item.id}`, `◈ ${commas(item.price)}`, item.canBuy, () => act.relic(item.id as RelicId))))
324  return [
325    depth,
326    <Box flexDirection="column">
327      <Text bold>New shaft</Text>
328      {shaft}
329    </Box>,
330    <Box flexDirection="column" rowGap={parts.isRoomy ? 1 : 0}>
331      <Box flexDirection="column">
332        <Box flexDirection="row" columnGap={1}>
333          <Text bold>Relics</Text>
334          <Text color="permission" bold>{`◈ ${commas(r.toSpend)} to spend`}</Text>
335        </Box>
336        <Text dimColor>{about}</Text>
337      </Box>
338      {owned}
339      {forSale.length > 0 ? cards(el, parts, forSale) : <Text dimColor>Every relic is yours.</Text>}
340    </Box>,
341  ]
342}
343
344/** The badges in text: a line of each group, earned ones in gold. */
345function textWall(el: Elements, model: ShopModel): RenderElement {
346  const { Box, Text } = el
347  return (
348    <Box flexDirection="column">
349      {model.badges.groups.map(g => (
350        <Box flexDirection="row" columnGap={1}>
351          <Box width={22} flexShrink={0}>
352            <Text dimColor>{`${g.title} ${g.items.filter(b => b.isEarned).length}/${g.items.length}`}</Text>
353          </Box>
354          <Box flexDirection="row" flexWrap="wrap" columnGap={2} flexShrink={1}>
355            {g.items.map(b => (b.isEarned ? <Text color={GOLD}>{`★ ${b.name}`}</Text> : <Text dimColor>{`☆ ${b.name}`}</Text>))}
356          </Box>
357        </Box>
358      ))}
359    </Box>
360  )
361}
362
363/** How many badges are earned, the closest one to go, and every badge. */
364function badgesTab(el: Elements, model: ShopModel, parts: ShopParts): (RenderElement | null)[] {
365  const { Box, Text } = el
366  const b = model.badges
367  const next = b.next
368  return [
369    <Box flexDirection="column">
370      {heading(el, `${b.earned} of ${b.total}`, `· each adds ${Math.round(ACHIEVEMENT_BONUS * 100)}% to everything, for good`)}
371      {parts.pictures?.bar(b.earned / Math.max(1, b.total), PICTURE_GOLD) ?? <Text color={GOLD}>{textBar(b.earned, b.total, 21)}</Text>}
372    </Box>,
373    next === null ? null : (
374      <Box
375        flexDirection="row"
376        columnGap={1}
377        alignItems="center"
378        paddingX={1}
379        {...(parts.isRoomy ? { borderStyle: 'round', borderColor: 'claude' } : {})}
380      >
381        {parts.icon(next.icon, false)}
382        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
383          <Text bold>{`Next: ${next.name}`}</Text>
384          <Text dimColor>
385            {`${next.text} · `}
386            <Text color="claude">{`${textBar(next.have, next.need, 10)} ${fmt(next.have)}/${fmt(next.need)}`}</Text>
387          </Text>
388        </Box>
389      </Box>
390    ),
391    parts.pictures?.badgeWall ?? textWall(el, model),
392  ]
393}
394
395export function shopTree(el: Elements, model: ShopModel, parts: ShopParts, act: ShopActions): RenderElement {
396  const { Box, Text } = el
397  if (!model.isLoaded) return <Text dimColor>Opening the mine…</Text>
398  const content =
399    model.tab === 'shaft'
400      ? shaftTab(el, model, parts, act)
401      : model.tab === 'badges'
402        ? badgesTab(el, model, parts)
403        : model.tab === 'stats'
404          ? [parts.stats ?? <Text dimColor>The stats show in the desktop app and the terminal.</Text>]
405          : shopTab(el, model, parts, act)
406  return (
407    <Box flexDirection="column" rowGap={1}>
408      {parts.notice}
409      {header(el, model, parts)}
410      {tabRow(el, model, parts, act)}
411      {content}
412    </Box>
413  )
414}
415
hooks/hud.ts 289 lines
1// The live numbers in the band and the shop, and on the desktop the band's
2// Mine button. A Client draws them, so they change without the band being
3// redrawn and the scene never flickers.
4//
5// On the desktop the button is a gold sign the HUD draws itself, since a
6// desktop draws every Button as its own native one, and it lives here so a
7// press answers at once: the HUD counts it, adds it to the counter and pops
8// what it mined before the hooks have heard of it. A press reaches the HUD the
9// moment the pointer goes down over its region, whatever was redrawn since.
10// Each post carries the running total of presses, so the hooks count every one
11// even when a frame's posts merge into one, and the props they answer say how
12// many they have counted.
13//
14// On the terminal Mine is the band's own Button: a Client's Buttons take no
15// hotkey there, and its `m` is how a terminal mines fast. The hooks count its
16// presses and say how many, and the HUD pops what they mined.
17//
18// Its height is fixed and its width follows its text, which changes only when
19// new numbers arrive: a surface unmounts a Client whose size changes frame
20// after frame on its own. The pop sits in a slot of its own fixed width.
21//
22// The numbers come two ways: in the band's or the shop's tree, and in the
23// answers to its polls. A surface may hand in a tree drawn a while ago again,
24// with the numbers it had then: the desktop does, each time something else on
25// screen changes. So the HUD keeps the newest numbers it has had and draws
26// those. A new answer always is; a tree only when drawn after the last answer
27// was read, for its mode and size. Older ones never turn the counter back.
28//
29// It keeps them in a box of its own, set in place as it draws: a setState
30// while drawing draws the HUD once more, and on the desktop each such draw
31// made the band's scene and the badge wall blink, ten times a second.
32//
33// A surface module has no `$` and takes nothing from the plugin's other files,
34// so it carries its own number formatting.
35
36import type { ClientModule } from 'claude-code'
37
38import type { HudProps } from '../types'
39
40/** What a run of presses has mined, shown beside the counter until it fades. */
41type Pop = { value: number; age: number; isGem: boolean }
42type State = {
43  instance: string
44  /** Presses counted, in all: what each post reports. */
45  total: number
46  /** When the last second's presses came, to keep under MAX_PER_SECOND. */
47  recent: number[]
48  /** The band's own Mine presses the hooks had counted when the HUD last drew. */
49  taps: number
50  gemSeq: number
51  pop: Pop | null
52  /** Frames the gold sign stays pressed in after a press. */
53  pressed: number
54  /** The counter's thousands (K, M, B ...), and frames of gold left since it passed into new ones. */
55  tier?: number
56  shimmer?: number
57  /** The newest numbers handed in, the ones drawn, in a box a draw sets in place. */
58  newest?: { props: HudProps }
59}
60
61const POLL_MS = 100
62const FRAME_MS = 60
63/** Frames a pop stays up, and stays bright before it dims. A gem stays longer. */
64const POP_FRAMES = 10
65const POP_BRIGHT = 4
66const GEM_FRAMES = 20
67const GEM_BRIGHT = 12
68/** Cells the pop's slot takes, whether or not a pop is up. */
69const POP_WIDTH = 10
70/** The hooks count no more presses than this a second, so the HUD shows no more. */
71const MAX_PER_SECOND = 20
72// The text's colors are the theme's own, so they read on a light theme as on a dark one.
73/** A gem pops in green. */
74const GEM_COLOR = 'success'
75/** A reward or a frenzy reads in gold. */
76const REWARD_COLOR = 'warning'
77const BAR = 10
78const PRESS_FRAMES = 2
79/** Frames the counter shines when it passes 1K, 1M, 1B and on, between gold and the text's own color. */
80const SHIMMER_FRAMES = 10
81const SHIMMER = ['warning', 'text'] as const
82
83/** Which thousands a count is in: 0 under a thousand, 1 for K, 2 for M, 3 for B. */
84const tierOf = (n: number): number => (n >= 1000 ? Math.floor(Math.log10(n) / 3) : 0)
85
86/** The desktop's Mine sign: gold ore while Claude works, dull while it waits. */
87const GOLD = { face: '#f2b632', lit: '#ffcd52', pressed: '#d9951f', edge: '#a8691a', ink: '#3a2410' }
88const DULL = { face: '#5c4d2e', edge: '#463a22', ink: '#a8987a' }
89/** The topsoil's color, for props from before the HUD knew the layer. */
90const TOPSOIL = '#b8804f'
91
92function commas(n: number): string {
93  const whole = Math.floor(Math.abs(n)).toString()
94  return whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
95}
96
97const SUFFIXES = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc']
98
99function short(n: number): string {
100  if (!Number.isFinite(n)) return '∞'
101  if (Math.abs(n) < 100_000) return commas(n)
102  let tier = Math.floor(Math.log10(Math.abs(n)) / 3)
103  let scaled = n / 10 ** (tier * 3)
104  let digits = scaled >= 100 ? 1 : 2
105  if (Number(scaled.toFixed(digits)) >= 1000) {
106    tier += 1
107    scaled /= 1000
108    digits = 2
109  }
110  const suffix = SUFFIXES[tier]
111  return suffix === undefined ? n.toExponential(2) : `${scaled.toFixed(digits)}${suffix}`
112}
113
114function perSecond(n: number): string {
115  const tenths = Math.round(n * 10) / 10
116  return tenths < 10 ? tenths.toFixed(1) : short(Math.round(n))
117}
118
119function bar(have: number, need: number): string {
120  const filled = need <= 0 ? BAR : Math.max(0, Math.min(BAR, Math.floor((have / need) * BAR)))
121  return '▰'.repeat(filled) + '▱'.repeat(BAR - filled)
122}
123
124const Hud: ClientModule<HudProps, State> = (handed, surface) => {
125  const { Box, Text } = surface.elements
126
127  // A HUD an older version drew keeps other state: this one starts afresh.
128  const old = surface.state
129  let s = old !== undefined && typeof old.taps === 'number' && typeof old.pressed === 'number' ? old : undefined
130  // The newest numbers it has had: a new answer, or a tree drawn after the last one was read.
131  const kept = s?.newest?.props
132  const isNewer = kept === undefined || (handed.isAnswer === true ? JSON.stringify(handed) !== JSON.stringify(kept) : (handed.at ?? 0) > kept.at)
133  const props = isNewer || kept === undefined ? handed : kept
134  const gem = props.gem ?? null
135  const acked = props.acked ?? 0
136  const taps = props.taps ?? 0
137
138  if (s === undefined) {
139    const instance = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}`
140    s = { instance, total: 0, recent: [], taps, gemSeq: gem?.seq ?? 0, pop: null, pressed: 0, tier: tierOf(props.sparks), shimmer: 0, newest: { props: handed } }
141    surface.setState(s)
142    surface.every(POLL_MS, () => surface.post({ instance, total: surface.state?.total ?? 0 }))
143    // Ages the pop, lets the sign back up and dims the shine; does nothing at all while none is showing.
144    surface.every(FRAME_MS, () => {
145      const now = surface.state
146      if (now === undefined || (now.pop === null && now.pressed === 0 && (now.shimmer ?? 0) === 0)) return
147      let pop = now.pop
148      if (pop !== null) {
149        const age = pop.age + 1
150        pop = age >= (pop.isGem ? GEM_FRAMES : POP_FRAMES) ? null : { ...pop, age }
151      }
152      surface.setState({ ...now, pop, pressed: Math.max(0, now.pressed - 1), shimmer: Math.max(0, (now.shimmer ?? 0) - 1) })
153    })
154    surface.post({ instance, total: 0 })
155  } else {
156    let next = s
157    if (s.newest === undefined) next = { ...s, newest: { props: handed } }
158    else if (isNewer) s.newest.props = handed
159    const isGem = gem !== null && gem.seq > next.gemSeq
160    if (isGem || taps !== next.taps) {
161      // Presses of the band's own Mine button, and gems among the presses the
162      // hooks counted: the pop takes what they mined. Fewer taps than before is
163      // the hooks starting over (an update), and pops nothing.
164      const mined = Math.max(0, taps - next.taps) * props.click + (isGem ? gem.bonus : 0)
165      const pop = props.variant === 'band' && mined > 0 ? { value: (next.pop?.value ?? 0) + mined, age: 0, isGem: isGem || next.pop?.isGem === true } : next.pop
166      next = { ...next, taps, gemSeq: isGem ? gem.seq : next.gemSeq, pop }
167    }
168    // Passing into new thousands, the counter shines a moment; a new shaft's fall says nothing.
169    const tier = tierOf(props.sparks)
170    if (tier !== (next.tier ?? tier)) next = { ...next, tier, shimmer: tier > (next.tier ?? tier) ? SHIMMER_FRAMES : (next.shimmer ?? 0) }
171    else if (next.tier === undefined) next = { ...next, tier }
172    if (next !== s) {
173      s = next
174      surface.setState(s)
175    }
176  }
177
178  const isWorking = props.variant === 'band' && props.mode === 'work'
179  /** A press of the desktop's gold sign: counted here at once, and posted to the hooks. */
180  const press = () => {
181    const now = surface.state ?? s
182    const at = Date.now()
183    const recent = now.recent.filter(t => at - t < 1000)
184    if (!isWorking || recent.length >= MAX_PER_SECOND) return
185    const total = now.total + 1
186    const keep = now.pop !== null && now.pop.age < (now.pop.isGem ? GEM_FRAMES : POP_FRAMES)
187    const pop = { value: (keep ? (now.pop?.value ?? 0) : 0) + props.click, age: 0, isGem: keep && now.pop?.isGem === true }
188    surface.setState({ ...now, total, recent: [...recent, at], pop, pressed: PRESS_FRAMES })
189    surface.post({ instance: now.instance, total })
190  }
191  // Set on every draw, so it presses with the latest numbers. On the terminal the band's Button mines.
192  if (props.variant === 'band' && props.surface !== 'terminal') {
193    surface.onPointer(event => {
194      if (event.type === 'down' && (event.button === undefined || event.button === 'left')) press()
195    })
196  }
197
198  // Presses the hooks have not counted yet are already on the counter.
199  const waiting = Math.max(0, s.total - acked)
200  const counter = `✻ ${short(props.sparks + waiting * props.click)}`
201
202  const box = (children: unknown[]) => Box({ height: props.height, flexDirection: 'column', children: children as never[] })
203  const line = (children: unknown[]) => Box({ flexDirection: 'row', columnGap: 1, children: children as never[] })
204  const dim = (text: string) => Text({ dimColor: true, wrap: 'truncate-end', children: [text] })
205  const shine = s.shimmer ?? 0
206  const count = Text({ color: shine > 0 ? SHIMMER[shine % 2] : 'claude', bold: true, children: [counter] })
207  const goal = props.goal === null ? null : `Next: ${props.goal.name}`
208
209  // How deep the mine is, in the color of its layer; a reward for a moment; a frenzy on the rate.
210  const deep = Text({ color: props.layer?.color ?? TOPSOIL, children: [`${commas(props.depth ?? 0)} m`] })
211  const event = props.event ?? null
212  const reward = event === null ? null : Text({ color: REWARD_COLOR, bold: true, wrap: 'truncate-end', children: [`★ ${event.label} +${short(event.value)} ✻`] })
213  const frenzy = props.frenzy ?? null
214  const rate =
215    frenzy === null
216      ? dim(`${perSecond(props.rate)}/s`)
217      : Text({ color: REWARD_COLOR, bold: true, children: [`${perSecond(props.rate * frenzy.mult)}/s ×${frenzy.mult}`] })
218
219  if (props.variant === 'shop') {
220    const idle = props.idle > 0 ? ` · ${Math.round(props.idle * 100)}% while idle` : ''
221    const rows: unknown[] = [
222      line([count]),
223      dim(`${perSecond(props.rate)}/s while Claude works${idle}`),
224      line([Text({ color: props.layer?.color ?? TOPSOIL, children: [props.layer?.name ?? 'Topsoil'] }), dim(`· ${commas(props.depth ?? 0)} m deep`)]),
225    ]
226    if (props.goal !== null) {
227      const { have, need } = props.goal
228      const count = props.goal.isSparks === true ? `${short(have)}/${short(need)} ✻` : `${commas(have)}/${commas(need)}`
229      rows.push(line([dim(goal ?? ''), Text({ color: 'claude', children: [`${bar(have, need)} ${count}`] })]))
230    }
231    return box(rows)
232  }
233
234  if (props.mode === 'idle') {
235    const notes = [props.lastTurn > 0 ? `last turn +${short(props.lastTurn)}` : 'mines while Claude works']
236    if (props.idle > 0) notes.push(`crew at ${Math.round(props.idle * 100)}%`)
237    // The miner's own break, a while into Claude's: a cup, then a snooze.
238    if (props.nap === 'coffee') notes.push('☕')
239    if (props.nap === 'sleep') notes.push('💤')
240    return box([line([count, dim('·'), deep, reward === null ? dim(`· ${notes.join(' · ')}`) : line([dim('·'), reward])])])
241  }
242
243  // The band leaves the next goal to the shop, where its numbers explain it.
244  const news =
245    props.mode === 'pause'
246      ? Text({ color: 'warning', wrap: 'truncate-end', children: ['Claude is waiting for you'] })
247      : (reward ?? dim(props.last ?? `this turn +${short(props.turn)}`))
248  const second = line([deep, dim('·'), news])
249  const pop = s.pop
250  const isBright = pop !== null && pop.age < (pop.isGem ? GEM_BRIGHT : POP_BRIGHT)
251  const popText =
252    pop === null
253      ? []
254      : [
255          Text({
256            color: pop.isGem ? GEM_COLOR : 'claude',
257            bold: isBright,
258            dimColor: !isBright,
259            wrap: 'truncate-end',
260            children: [`${pop.isGem ? '◆ ' : ''}+${short(pop.value)}`],
261          }),
262        ]
263  const slot = Box({ key: 'pop', width: POP_WIDTH, flexShrink: 0, children: popText as never[] })
264  const mine =
265    props.surface === 'terminal'
266      ? null
267      : Box({
268          key: 'mine',
269          borderStyle: 'round',
270          borderColor: isWorking ? GOLD.edge : DULL.edge,
271          backgroundColor: !isWorking ? DULL.face : s.pressed > 0 ? GOLD.pressed : GOLD.face,
272          ...(isWorking ? { hover: { backgroundColor: GOLD.lit } } : {}),
273          paddingX: 1,
274          flexShrink: 0,
275          children: [Text({ bold: true, color: isWorking ? GOLD.ink : DULL.ink, children: ['⛏ Mine'] })],
276        })
277  // Compact: everything on one line beside the sign.
278  const text = props.isCompact === true ? box([line([count, rate, slot, dim('·'), deep, dim('·'), news])]) : box([line([count, rate, slot]), second])
279  return Box({
280    height: props.height,
281    flexDirection: 'row',
282    columnGap: 2,
283    alignItems: 'center',
284    children: mine === null ? [text] : [mine, text],
285  })
286}
287
288export default Hud
289
hooks/shopbutton.ts 49 lines
1// The band's Shop button on the desktop: a wooden sign. A desktop draws every
2// Button as its own native one, so the sign is a Client the mod draws itself,
3// and the whole region is the sign: a pointer going down anywhere on it is a
4// press. The hooks hear it as a post and open the shop.
5
6import type { ClientModule } from 'claude-code'
7
8type State = { pressed: number }
9
10const FRAME_MS = 60
11/** Frames the sign stays pressed in after a press. */
12const PRESS_FRAMES = 2
13
14const WOOD = '#4b2f1c'
15const WOOD_LIT = '#5e3b22'
16const WOOD_PRESSED = '#3a2410'
17const GRAIN = '#8a5a2b'
18const GRAIN_LIT = '#a8703a'
19const CREAM = '#f2d39a'
20
21const ShopButton: ClientModule<Record<string, never>, State> = (_props, surface) => {
22  const { Box, Text } = surface.elements
23  let s = surface.state
24  if (s === undefined) {
25    s = { pressed: 0 }
26    surface.setState(s)
27    surface.every(FRAME_MS, () => {
28      const now = surface.state
29      if (now !== undefined && now.pressed > 0) surface.setState({ pressed: now.pressed - 1 })
30    })
31  }
32  surface.onPointer(event => {
33    if (event.type !== 'down' || (event.button !== undefined && event.button !== 'left')) return
34    surface.setState({ pressed: PRESS_FRAMES })
35    surface.post({ open: true })
36  })
37  return Box({
38    key: 'shop',
39    borderStyle: 'round',
40    borderColor: GRAIN,
41    backgroundColor: s.pressed > 0 ? WOOD_PRESSED : WOOD,
42    hover: { backgroundColor: WOOD_LIT, borderColor: GRAIN_LIT },
43    paddingX: 1,
44    children: [Text({ bold: true, color: CREAM, children: ['✻ Shop'] })],
45  })
46}
47
48export default ShopButton
49
hooks/vein.ts 80 lines
1// The vein: now and then, while Claude works, a gold vein shows beside the Shop
2// sign for a few seconds, and a press catches it. A Client of its own, so it
3// shows and goes without the band being redrawn, and the whole region is the
4// vein: the pointer going down anywhere on it catches it. While a frenzy runs
5// it says how long is left.
6//
7// The band's tree holds no vein; the answers to its polls do. A surface may
8// hand that tree in again (the desktop does, each time something else on
9// screen changes), so the vein keeps the newest answer it has had, and the
10// tree never hides a vein that shows. It keeps it in a box set in place as it
11// draws: a setState while drawing would draw it once more (see hud.ts).
12
13import type { ClientModule } from 'claude-code'
14
15import type { VeinProps } from '../types'
16
17type State = { pulse: number; caught: number; isShowing: boolean; newest?: { props: VeinProps } }
18
19const POLL_MS = 250
20const PULSE_MS = 300
21
22const ORE = { face: '#3a2410', lit: '#5e3b1d', edge: '#f2b632', ink: '#ffe08a' }
23/** The theme's gold, so a light theme darkens it. */
24const FRENZY = 'warning'
25
26const Vein: ClientModule<VeinProps, State> = (handed, surface) => {
27  const { Box, Text } = surface.elements
28
29  let s = surface.state
30  if (s === undefined) {
31    s = { pulse: 0, caught: 0, isShowing: false, newest: { props: handed } }
32    surface.setState(s)
33    surface.every(POLL_MS, () => surface.post({ poll: true }))
34    // The vein glints while it shows; nothing runs while it does not.
35    surface.every(PULSE_MS, () => {
36      const now = surface.state
37      if (now !== undefined && now.isShowing) surface.setState({ ...now, pulse: now.pulse + 1 })
38    })
39    surface.post({ poll: true })
40  }
41  // The newest answer it has had: the band's tree, handed in again, holds none.
42  const kept = s.newest?.props
43  const isNewer = kept === undefined || (handed.isAnswer === true && JSON.stringify(handed) !== JSON.stringify(kept))
44  const props = isNewer || kept === undefined ? handed : kept
45  const vein = props.vein ?? null
46  const frenzy = props.frenzy ?? null
47  let next = s
48  if (s.newest === undefined) next = { ...s, newest: { props: handed } }
49  else if (isNewer) s.newest.props = handed
50  const isShowing = vein !== null && vein.id !== next.caught
51  if (isShowing !== next.isShowing) next = { ...next, isShowing }
52  if (next !== s) {
53    s = next
54    surface.setState(s)
55  }
56
57  surface.onPointer(event => {
58    if (event.type !== 'down' || (event.button !== undefined && event.button !== 'left')) return
59    const now = surface.state ?? s
60    if (vein === null || vein.id === now.caught) return
61    surface.setState({ ...now, caught: vein.id, isShowing: false })
62    surface.post({ catch: vein.id })
63  })
64
65  if (vein !== null && isShowing) {
66    const isLit = s.pulse % 2 === 1
67    return Box({
68      key: 'vein',
69      ...(props.surface === 'terminal' ? {} : { borderStyle: 'round', borderColor: ORE.edge }),
70      backgroundColor: isLit ? ORE.lit : ORE.face,
71      paddingX: 1,
72      children: [Text({ bold: true, color: ORE.ink, children: [`✦ ${vein.label ?? 'Vein!'} ${vein.left}s`] })],
73    })
74  }
75  if (frenzy !== null) return Text({ bold: true, color: FRENZY, children: [`Frenzy ×${frenzy.mult} · ${frenzy.left}s`] })
76  return Box({ key: 'vein' })
77}
78
79export default Vein
80
hooks/stats.ts 183 lines
1// The shop's stats, all time: where the sparks came from, the records, and
2// what Claude's work paid. A Client, so the numbers keep up without the shop
3// being redrawn. The desktop draws a bar and tiles; the terminal, lines.
4//
5// A surface may hand the shop's tree in again, with the numbers it had when
6// drawn (the desktop does, each time something else on screen changes): the
7// stats keep the newest numbers they have had, so none ever goes back. They
8// keep them in a box set in place as they draw: a setState while drawing would
9// draw them once more (see hud.ts).
10//
11// A surface module has no `$` and takes nothing from the plugin's other files,
12// so it carries its own number formatting.
13
14import type { ClientModule } from 'claude-code'
15
16import type { StatsProps } from '../types'
17
18const POLL_MS = 1_000
19/** Cells the terminal's bar takes. */
20const TEXT_BAR = 40
21/** The desktop's tiles: a see-through grey, so they read on a light theme as on a dark one. */
22const TILE_FILL = 'rgba(128,128,128,0.10)'
23const TILE_LINE = 'rgba(128,128,128,0.28)'
24
25function commas(n: number): string {
26  const whole = Math.floor(Math.abs(n)).toString()
27  return whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
28}
29
30const SUFFIXES = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc']
31
32function short(n: number): string {
33  if (!Number.isFinite(n)) return '∞'
34  if (Math.abs(n) < 100_000) return commas(n)
35  let tier = Math.floor(Math.log10(Math.abs(n)) / 3)
36  let scaled = n / 10 ** (tier * 3)
37  let digits = scaled >= 100 ? 1 : 2
38  if (Number(scaled.toFixed(digits)) >= 1000) {
39    tier += 1
40    scaled /= 1000
41    digits = 2
42  }
43  const suffix = SUFFIXES[tier]
44  return suffix === undefined ? n.toExponential(2) : `${scaled.toFixed(digits)}${suffix}`
45}
46
47/** Where the sparks came from, each in a color of the theme: the crew in Claude's own. */
48const SOURCES = [
49  { name: 'Crew', color: 'claude', of: (by: StatsProps['bySource']) => by.crew ?? 0 },
50  { name: 'Tools', color: 'suggestion', of: (by: StatsProps['bySource']) => by.tool ?? 0 },
51  { name: 'Bonuses', color: 'warning', of: (by: StatsProps['bySource']) => (by.turn ?? 0) + (by.event ?? 0) + (by.vein ?? 0) },
52  { name: 'Clicks', color: 'success', of: (by: StatsProps['bySource']) => by.click ?? 0 },
53] as const
54
55const Stats: ClientModule<StatsProps, { isPolling: true; newest?: { props: StatsProps } }> = (handed, surface) => {
56  const { Box, Text } = surface.elements
57  let s = surface.state
58  if (s === undefined) {
59    s = { isPolling: true, newest: { props: handed } }
60    surface.setState(s)
61    surface.every(POLL_MS, () => surface.post({ poll: true }))
62    surface.post({ poll: true })
63  }
64  // The newest numbers they have had: a new answer; the shop's tree, handed in again, holds older ones.
65  const kept = s.newest?.props
66  const isNewer = kept === undefined || (handed.isAnswer === true && JSON.stringify(handed) !== JSON.stringify(kept))
67  const props = isNewer || kept === undefined ? handed : kept
68  if (s.newest === undefined) surface.setState({ ...s, newest: { props: handed } })
69  else if (isNewer) s.newest.props = handed
70  const by = props.bySource ?? {}
71  const events = props.events ?? {}
72  const sources = SOURCES.map(s => ({ name: s.name, color: s.color, value: s.of(by) }))
73  const total = sources.reduce((sum, s) => sum + s.value, 0)
74  const pct = (value: number) => Math.round((value / Math.max(1, total)) * 100)
75  const column = (children: unknown[], gap = 0) => Box({ flexDirection: 'column', rowGap: gap, children: children as never[] })
76  const row = (children: unknown[], gap = 1) => Box({ flexDirection: 'row', columnGap: gap, children: children as never[] })
77  const dim = (text: string) => Text({ dimColor: true, wrap: 'truncate-end', children: [text] })
78  const bold = (text: string) => Text({ bold: true, children: [text] })
79
80  const records = [
81    [`+${short(props.bestTurn ?? 0)}`, 'Best turn'],
82    [commas(props.bestStreak ?? 0), 'Best streak'],
83    [`${commas(props.deepest ?? 0)} m`, 'Deepest'],
84    [commas(props.shafts ?? 0), 'Shafts'],
85    [commas(props.gems ?? 0), 'Gems'],
86    [commas(props.veins ?? 0), 'Veins'],
87  ] as const
88  const work = [
89    [commas(events.tests ?? 0), 'Tests passed'],
90    [commas(events.fix ?? 0), 'Bugs squashed'],
91    [commas(events.build ?? 0), 'Builds'],
92    [commas(events.lint ?? 0), 'Clean lints'],
93    [commas(events.commit ?? 0), 'Commits'],
94    [commas(events.pr ?? 0), 'PRs opened'],
95    [commas(events.merge ?? 0), 'PRs merged'],
96    [commas(events.longShift ?? 0), 'Long shifts'],
97  ] as const
98  const totals = dim(`Clicks ${commas(props.clicks ?? 0)} · Tool calls ${commas(props.tools ?? 0)} · Turns ${commas(props.turns ?? 0)}`)
99  const heading = row([bold('Where your sparks came from'), dim(total > 0 ? `${short(total)} in all` : 'nothing mined yet')])
100
101  if (props.surface === 'desktop') {
102    // The bar: each source's share of one row, in its color.
103    const bar = Box({
104      flexDirection: 'row',
105      height: 1,
106      children: (total > 0 ? sources.filter(s => s.value > 0) : [{ name: 'None', color: TILE_LINE, value: 1 }]).map(s =>
107        Box({ flexGrow: Math.max(1, Math.round((s.value / Math.max(1, total)) * 1000)), height: 1, backgroundColor: s.color, children: [] }),
108      ) as never[],
109    })
110    const legendItem = (s: (typeof sources)[number]) =>
111      row([Text({ color: s.color, children: ['■'] }), Text({ children: [s.name] }), dim(`${pct(s.value)}% · ✻ ${short(s.value)}`)])
112    // Two columns of the legend, the first two sources over the other two.
113    const legend = row(
114      [0, 1].map(c => Box({ flexDirection: 'column', flexGrow: 1, width: 0, children: sources.filter((_, i) => i % 2 === c).map(legendItem) as never[] })),
115      2,
116    )
117    const tile = ([value, name]: readonly [string, string]) =>
118      Box({
119        flexDirection: 'column',
120        flexGrow: 1,
121        width: 0,
122        paddingX: 1,
123        borderStyle: 'round',
124        borderColor: TILE_LINE,
125        backgroundColor: TILE_FILL,
126        // A long name wraps rather than losing its end: the row's tiles grow alike.
127        children: [Text({ bold: true, children: [value] }), Text({ dimColor: true, wrap: 'wrap', children: [name] })] as never[],
128      })
129    const grid = (list: readonly (readonly [string, string])[], perRow: number) =>
130      column(
131        Array.from({ length: Math.ceil(list.length / perRow) }, (_, r) => row(list.slice(r * perRow, (r + 1) * perRow).map(tile))),
132        1,
133      )
134    return column(
135      [
136        column([heading, bar, legend], 1),
137        column([bold('Records'), grid(records, 3)]),
138        column([bold("Claude's work"), grid(work, 4)]),
139        totals,
140      ],
141      1,
142    )
143  }
144
145  // The terminal's bar: a run of blocks in each source's color.
146  let left = TEXT_BAR
147  const runs = sources
148    .filter(s => s.value > 0)
149    .map((s, i, all) => {
150      const width = i === all.length - 1 ? left : Math.min(left, Math.round((s.value / Math.max(1, total)) * TEXT_BAR))
151      left -= width
152      return Text({ color: s.color, children: ['█'.repeat(Math.max(0, width))] })
153    })
154  // Two to a line, the second in line with the one above it.
155  const line = (pair: readonly (typeof sources)[number][]) =>
156    row(
157      pair.map((s, i) =>
158        Box({
159          ...(i === 0 ? { width: 30, flexShrink: 0 } : {}),
160          children: [row([Text({ color: s.color, children: ['■'] }), Text({ children: [s.name] }), dim(`${pct(s.value)}% · ✻ ${short(s.value)}`)])] as never[],
161        }),
162      ),
163      1,
164    )
165  const named = (title: string, list: readonly (readonly [string, string])[]) =>
166    row([Box({ width: 14, flexShrink: 0, children: [bold(title)] }), Text({ wrap: 'wrap', children: [list.map(([value, name]) => `${name} ${value}`).join(' · ')] })])
167  return column(
168    [
169      heading,
170      total > 0 ? Text({ children: runs as never[] }) : dim('▱'.repeat(TEXT_BAR)),
171      line(sources.slice(0, 2)),
172      line(sources.slice(2)),
173      Text({ children: [' '] }),
174      named('Records', records),
175      named("Claude's work", work),
176      totals,
177    ],
178    0,
179  )
180}
181
182export default Stats
183