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

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.

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">
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.
m: every press counts, while quick clicks on one spot count as one./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.
/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.
afplay on macOS).m instead.MIT
hooks/register.tsx 652 lines1// 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}
652hooks/game.ts 1231 lines1// 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 lines1// 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}
848hooks/model.ts 456 lines1// 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 }
456hooks/pictures.ts 212 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
212hooks/scene.ts 1201 lines1// 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 lines1// 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}
745hooks/ui.tsx 415 lines1// 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}
415hooks/hud.ts 289 lines1// 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
289hooks/shopbutton.ts 49 lines1// 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
49hooks/vein.ts 80 lines1// 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
80hooks/stats.ts 183 lines1// 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