SLOPSHOPPER

server-farm

An idle server farm above the prompt: daemon bots collect data from your racks, and your Claude work boosts it

newpanebandguardcommandtoast
v0.1.3no licenseupdated 2026-10-02pierregoutheraud/claude-mods/plugins/server-farm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · server-farm
│ ┃ Server farm shop ✕ › fix the failing auth test and add an audit log call │ ┃ 0 B stored, +1.6 B/s │ ┃ 2 racks, daemons: Byte, overclock level 0 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 1: New rack (40 B) ⏺ Update(src/auth.ts) │ ┃ 2: Hire Nibble (120 B) ⎿ Added 2 lines, removed 1 line │ ┃ 3: Overclock to level 1 (80 B) ⏺ Bash(bun test) │ ┃ 4: Rack 1: Raspberry Pi to Tower server (250 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Traffic spike! Production x2 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Finished turns bring traffic spikes, passing ✻ Worked for 42s · done 4:20 PM │ ┃ tests pay a bonus, failing tools take a rack │ ┃ down. Lifetime 0 B, 1 incidents. › /farm │ ┃ ⎿ server-farm: Server farm shop opened. │ ┃ [ close ] │ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ 0 B +1.6 B/s traffic spike x2 60s Traffic spike! Production x2 [ shop ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀ 0 B +1.6 B/s traffic spike x2 60s Traffic spike! Production x2 [ shop ]
Pane · Server farm shop
0 B stored, +1.6 B/s 2 racks, daemons: Byte, overclock level 0 1: New rack (40 B) 2: Hire Nibble (120 B) 3: Overclock to level 1 (80 B) 4: Rack 1: Raspberry Pi to Tower server (250 B) Traffic spike! Production x2 Finished turns bring traffic spikes, passing tests pay a bonus, failing tools take a rack down. Lifetime 0 B, 1 incidents. [ close ]
README

claude-mods

Mods for Claude Code, in the terminal and in the desktop app's Code tab: little games and toys that live above the prompt, written as plugins of function hooks.

The server farm strip

The mods

server-farm

An idle game in the band above the prompt. Racks of servers fill up with data, daemon bots walk over to collect it, and you spend it on more racks, more daemons, overclocking and better hardware (Raspberry Pi, Tower, Blade, GPU rig).

Your real work is the weather:

  • a turn Claude finishes starts a traffic spike: production x2 for 60 seconds, stacking up to 5 minutes
  • passing tests (npm test, vitest, pytest and the like) pay 30 seconds of production
  • a failing tool takes a rack down until a daemon fixes it

The farm keeps earning while you are away, at half rate for up to 8 hours. With several sessions open, one runs the farm and the others mirror it and send it their events.

Commands: /farm opens the shop (keys 1 to 4 buy), /farm stats, /farm hide, /farm show.

pixel-pet

The pixel pet's stages and moods

A pixel slime that hatches from an egg and grows into a teen and then a slime king as you work. It glances around while Claude works, cheers when tests pass, frowns when a tool fails and falls asleep when you leave.

Commands: /pet, /pet pat, /pet name <name>, /pet hide, /pet show.

Both mods draw the band above the prompt, so install one at a time.

Install

/plugin marketplace add pierregoutheraud/claude-mods
/plugin install server-farm@pierre-mods

What the mods touch

They watch turns and tool calls (to see whether a call failed or ran tests, never to change it), add their slash command, draw the band above the prompt and keep their save in their own plugin store. No network, no file access, no processes. claude plugin validate plugins/<name> lists every hook and call a mod makes.

Developing

Load a mod straight from this folder; it hot-reloads when you save:

claude --plugin-dir plugins/server-farm

To load it in every session, the desktop app included, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of your Claude Code settings.json. Do not run a dev copy and an installed copy of the same mod at once.

Run the checks before committing (CI runs the same script):

./scripts/check.sh

It validates the marketplace and each plugin, runs each plugin's tests with claude plugin test, and type-checks a plugin once Claude Code has loaded it, which is when its types land in .claude-plugin/types/ (gitignored).

The function hooks API is early access and can change between Claude Code releases. These mods were built and tested on Claude Code 2.1.287, and CI runs against the latest release on every push.

Releasing

Bump version in the plugin's .claude-plugin/plugin.json, commit, tag and push. Users get it with /plugin marketplace update pierre-mods.

Layout

.claude-plugin/marketplace.json   the marketplace: its name and the plugins it lists
plugins/<name>/
  .claude-plugin/plugin.json      the plugin's manifest
  hooks/hooks.json                names the hooks module
  hooks/register.tsx              the hooks: events in, drawing out
  hooks/*.ts                      pure logic and drawing, tested directly
  types/index.d.ts                the plugin's $.state contract
  tests/                          claude plugin test runs these
scripts/check.sh                  validate, test, type-check
Source 4 files
hooks/register.tsx 405 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Farm, FarmEvent } from '../types'
5import { drawStrip, STRIP_ROWS, textStrip, visibleRacks } from './draw'
6import { applyEvent, catchUp, formatBytes, formatRate, isFarm, newFarm, ratePerSecond, shopItems, step } from './game'
7
8const SHOP = 'server-farm-shop'
9// The farm animates every tick by repainting its Raster in place (no redraw of
10// the band); the status line under it is redrawn once a second, or at once on an event.
11const TICK_MS = 250
12const PUBLISH_EVERY_MS = 1_000
13const SAVE_EVERY_MS = 5_000
14const MIRROR_EVERY_MS = 2_000
15const STALE_OWNER_MS = 20_000
16const NOTICE_MS = 5_000
17// Empty rows between the transcript and the farm.
18const BAND_SPACING = 1
19// A gap longer than this between ticks (a sleeping laptop) is paid as time away.
20const CATCH_UP_AFTER_MS = 5_000
21const TEST_COMMAND = /\b(vitest|jest|pytest|mocha|playwright test|go test|cargo test|(npm|pnpm|yarn|bun) (run )?test)\b/
22
23const farm = atom({ plugin: 'server-farm', key: 'farm' } as const, null)
24const isOwner = atom({ plugin: 'server-farm', key: 'isOwner' } as const, false)
25const notice = atom({ plugin: 'server-farm', key: 'notice' } as const, null)
26const isHidden = atom({ plugin: 'server-farm', key: 'isHidden' } as const, false)
27
28// Which session runs the farm: the others mirror its save and send it their events.
29type Owner = { id: string; beatAt: number }
30
31const isOwnerRecord = (value: unknown): value is Owner =>
32  typeof value === 'object' && value !== null && typeof (value as Owner).id === 'string'
33
34// The loop's own state; a hot reload starts it over, and session.start seeds it again.
35let sessionId = ''
36let lastTickAt = 0
37let lastSaveAt = 0
38let lastMirrorAt = 0
39let lastPublishAt = 0
40let noticeUntil = 0
41let frame = 0
42// The farm as this session runs it, ahead of what `farm` last published; null unless it runs the farm.
43let live = null as Farm | null
44// The Raster the band last drew on the terminal, which blits repaint; null when none is mounted.
45let band = null as { requestId: string; columns: number } | null
46
47async function say($: EngineInterface, text: string) {
48  noticeUntil = (await $.clock.now()) + NOTICE_MS
49  await update($, notice, () => ({ text, until: noticeUntil }))
50}
51
52async function publish($: EngineInterface, now: number) {
53  lastPublishAt = now
54  const current = live
55  if (current !== null) await update($, farm, () => current)
56  if (noticeUntil !== 0 && now >= noticeUntil) {
57    noticeUntil = 0
58    await update($, notice, () => null)
59  }
60}
61
62// Repaints the band's Raster with the next frame, without redrawing the band.
63async function paint($: EngineInterface) {
64  if (band === null || live === null) return
65  try {
66    const painted = await $.ui.blit({
67      requestId: band.requestId,
68      key: 'farm',
69      cells: drawStrip(live, band.columns, frame),
70      columns: band.columns,
71      rows: STRIP_ROWS,
72    })
73    // Not mounted any more, or another size: the next drawing of the band arms it again.
74    if (painted.deny !== undefined) band = null
75  } catch {
76    band = null
77  }
78}
79
80async function loadFarm($: EngineInterface, now: number): Promise<Farm> {
81  const stored = await $.store.get('farm')
82
83  return isFarm(stored) ? stored : newFarm(now)
84}
85
86async function save($: EngineInterface, now: number) {
87  const current = live
88  if (current === null) return
89
90  await $.store.set('farm', { ...current, savedAt: now })
91  await $.store.set('owner', { id: sessionId, beatAt: now })
92  lastSaveAt = now
93}
94
95// Takes the farm when nobody runs it, this session already did, or its runner went quiet.
96async function claim($: EngineInterface, now: number): Promise<boolean> {
97  const owner = await $.store.get('owner')
98  const isFree = !isOwnerRecord(owner) || owner.id === sessionId || now - owner.beatAt > STALE_OWNER_MS
99  if (isFree) await $.store.set('owner', { id: sessionId, beatAt: now })
100  await update($, isOwner, () => isFree)
101
102  return isFree
103}
104
105// Starts running the farm here, from `seed` (what this session last published, after a hot
106// reload) or else from the save.
107async function takeOver($: EngineInterface, now: number, seed: Farm | null) {
108  const loaded = seed ?? (await loadFarm($, now))
109  const { farm: caught, earned } = catchUp(loaded, now - loaded.savedAt)
110  live = caught
111  await update($, isOwner, () => true)
112  await publish($, now)
113  await save($, now)
114  if (earned > 0) $.ui.toast(`While you were away, your farm produced ${formatBytes(earned)}`)
115}
116
117async function apply($: EngineInterface, event: FarmEvent) {
118  if (live === null) return
119
120  const result = applyEvent(live, event)
121  live = result.farm
122  if (result.notice !== null) await say($, result.notice)
123  await publish($, await $.clock.now())
124}
125
126// Events from this session: applied here when it runs the farm, else queued for the session that does.
127async function send($: EngineInterface, event: FarmEvent) {
128  if (live !== null) {
129    await apply($, event)
130    if (event.kind === 'buy') await save($, await $.clock.now())
131
132    return
133  }
134
135  const inbox = await $.store.get('inbox')
136  await $.store.set('inbox', [...(Array.isArray(inbox) ? inbox : []), event].slice(-50))
137  if (event.kind === 'buy') await say($, 'Sent to the session running the farm')
138}
139
140async function drainInbox($: EngineInterface) {
141  const inbox = await $.store.get('inbox')
142  if (!Array.isArray(inbox) || inbox.length === 0) return
143
144  await $.store.set('inbox', [])
145  for (const event of inbox as FarmEvent[]) await apply($, event)
146}
147
148async function tick($: EngineInterface) {
149  const now = await $.clock.now()
150  const elapsed = now - lastTickAt
151  lastTickAt = now
152  frame += 1
153
154  if (live === null) {
155    if (now - lastMirrorAt < MIRROR_EVERY_MS) return
156    lastMirrorAt = now
157    if (noticeUntil !== 0 && now >= noticeUntil) await publish($, now)
158    if (await claim($, now)) {
159      await takeOver($, now, null)
160    } else {
161      const mirrored = await loadFarm($, now)
162      await update($, farm, () => mirrored)
163    }
164
165    return
166  }
167
168  if (elapsed <= CATCH_UP_AFTER_MS) {
169    live = step(live, elapsed)
170  } else {
171    const caught = catchUp(live, elapsed)
172    live = caught.farm
173    if (caught.earned > 0) $.ui.toast(`While you were away, your farm produced ${formatBytes(caught.earned)}`)
174  }
175
176  await paint($)
177  if (now - lastPublishAt >= PUBLISH_EVERY_MS) await publish($, now)
178
179  if (now - lastSaveAt >= SAVE_EVERY_MS) {
180    // Another session may have taken over while this one slept.
181    const owner = await $.store.get('owner')
182    if (isOwnerRecord(owner) && owner.id !== sessionId && now - owner.beatAt <= STALE_OWNER_MS) {
183      live = null
184      await update($, isOwner, () => false)
185
186      return
187    }
188    await drainInbox($)
189    await save($, now)
190  }
191}
192
193async function openShop($: EngineInterface) {
194  await $.ui.open({ id: SHOP, title: 'Server farm shop', focus: true, closeOnEscape: true })
195}
196
197async function closeShop($: EngineInterface) {
198  await $.ui.close({ id: SHOP })
199}
200
201async function setHidden($: EngineInterface, hidden: boolean) {
202  await update($, isHidden, () => hidden)
203  await $.store.set('isHidden', hidden)
204}
205
206export const register: Register = on => {
207  on('session.start', async ($, e, next) => {
208    const now = await $.clock.now()
209    sessionId = await $.session.id()
210    lastTickAt = now
211    lastSaveAt = now
212    lastMirrorAt = now
213    lastPublishAt = now
214    live = null
215    band = null
216
217    await update($, isHidden, () => false)
218    if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
219
220    await $.command.register({
221      name: 'farm',
222      description: 'Your server farm: /farm opens the shop, /farm stats, /farm hide, /farm show',
223    })
224
225    // A headless run never runs the farm: it only sends its events to the session that does.
226    if (e.isInteractive) {
227      // A hot reload of the session running the farm picks up where it left off.
228      const seed = (await read($, isOwner)) ? await read($, farm) : null
229      if (await claim($, now)) {
230        await takeOver($, now, seed)
231      } else {
232        const mirrored = await loadFarm($, now)
233        await update($, farm, () => mirrored)
234      }
235
236      $.clock.every(TICK_MS, () => {
237        void tick($).catch(() => undefined)
238      })
239    }
240
241    return next(e)
242  })
243
244  on('session.end', async ($, e, next) => {
245    if (e.reason !== 'clear' && live !== null) {
246      await save($, await $.clock.now())
247      await $.store.delete('owner')
248    }
249
250    return next(e)
251  })
252
253  on('turn.complete', async ($, e, next) => {
254    if (e.agentId === undefined && e.reason === 'answer') await send($, { kind: 'turn' })
255
256    return next(e)
257  })
258
259  on('tool.call', async ($, e, next) => {
260    const ran = await next(e)
261    if (ran.deny !== undefined) return ran
262
263    if (ran.isError === true) await send($, { kind: 'failure' })
264    else if (e.tool === 'Bash' && TEST_COMMAND.test(e.command)) await send($, { kind: 'tests' })
265
266    return ran
267  })
268
269  on('command.run', { command: 'farm' }, async ($, e) => {
270    const current = await read($, farm)
271    switch (e.args.trim()) {
272      case '':
273        await setHidden($, false)
274        await openShop($)
275
276        return { text: 'Server farm shop opened.' }
277      case 'stats': {
278        if (current === null) return { text: 'The farm is still booting.' }
279        const racks = current.racks.length
280        const daemons = current.daemons.map(daemon => daemon.name).join(', ')
281
282        return {
283          text: `${formatBytes(current.bytes)} stored, +${formatRate(ratePerSecond(current))}. ${racks} racks, daemons: ${daemons}. Lifetime ${formatBytes(current.lifetimeBytes)}, ${current.incidents} incidents.`,
284        }
285      }
286      case 'hide':
287        await setHidden($, true)
288
289        return { text: 'The farm keeps running out of sight. /farm show brings it back.' }
290      case 'show':
291        await setHidden($, false)
292
293        return { text: 'The farm is back above the prompt.' }
294      default:
295        return { text: 'Usage: /farm [stats | hide | show]' }
296    }
297  })
298
299  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
300    const current = await read($, farm)
301    if (e.props.hasSurvey || current === null || (await read($, isHidden))) {
302      band = null
303
304      return next(e)
305    }
306
307    const beat = frame
308    const shown = await read($, notice)
309    const owner = await read($, isOwner)
310    const columns = Math.min(e.props.bodyColumns, 512)
311    const rate = ratePerSecond(current)
312    const offScreen = current.racks.length - visibleRacks(current, columns)
313    const { Box, Button, Text } = $.ui.resolve(e)
314
315    if (columns < 40) {
316      band = null
317
318      return (
319        <Box marginTop={BAND_SPACING}>
320          <Text wrap="truncate">
321            {formatBytes(current.bytes)} +{formatRate(rate)}
322          </Text>
323        </Box>
324      )
325    }
326
327    const status = (
328      <Box flexDirection="row" gap={2}>
329        <Text bold color="#7ee787">
330          {formatBytes(current.bytes)}
331        </Text>
332        <Text dimColor>+{formatRate(rate)}</Text>
333        {current.spikeMs > 0 && <Text color="#ffd166">traffic spike x2 {Math.ceil(current.spikeMs / 1000)}s</Text>}
334        {offScreen > 0 && <Text dimColor>+{offScreen} racks off screen</Text>}
335        {!owner && <Text dimColor>mirroring the farm of another session</Text>}
336        {shown !== null && (
337          <Text italic wrap="truncate">
338            {shown.text}
339          </Text>
340        )}
341        <Button key="shop" label="shop" hotkey="s" onPress={() => openShop($)} />
342      </Box>
343    )
344
345    if (e.surface === 'terminal') {
346      const { Raster } = $.ui.resolve(e)
347      band = { requestId: e.requestId, columns }
348
349      return (
350        <Box flexDirection="column" marginTop={BAND_SPACING}>
351          <Raster key="farm" columns={columns} rows={STRIP_ROWS} cells={drawStrip(current, columns, beat)} />
352          {status}
353        </Box>
354      )
355    }
356
357    return (
358      <Box flexDirection="column" marginTop={BAND_SPACING}>
359        <Text>{textStrip(current, beat)}</Text>
360        {status}
361      </Box>
362    )
363  })
364
365  on('ui.render', { component: 'Pane', requestId: SHOP }, async ($, e) => {
366    const current = await read($, farm)
367    const { Box, Button, Text } = $.ui.resolve(e)
368    if (current === null) return <Text dimColor>The farm is still booting.</Text>
369
370    const shown = await read($, notice)
371    const daemons = current.daemons.map(daemon => daemon.name).join(', ')
372
373    return (
374      <Box flexDirection="column" gap={1}>
375        <Box flexDirection="column">
376          <Text bold color="#7ee787">
377            {formatBytes(current.bytes)} stored, +{formatRate(ratePerSecond(current))}
378          </Text>
379          <Text dimColor>
380            {current.racks.length} racks, daemons: {daemons}, overclock level {current.overclock}
381          </Text>
382        </Box>
383        <Box flexDirection="column">
384          {shopItems(current).map((item, index) => (
385            <Button
386              key={`buy-${item.id}`}
387              hotkey={String(index + 1)}
388              label={item.cost === null ? `${item.label} (sold out)` : `${item.label} (${formatBytes(item.cost)})`}
389              dimColor={item.cost === null || current.bytes < item.cost}
390              plain
391              onPress={() => send($, { kind: 'buy', item: item.id })}
392            />
393          ))}
394        </Box>
395        {shown !== null && <Text italic>{shown.text}</Text>}
396        <Text dimColor>
397          Finished turns bring traffic spikes, passing tests pay a bonus, failing tools take a rack down.
398          Lifetime {formatBytes(current.lifetimeBytes)}, {current.incidents} incidents.
399        </Text>
400        <Button key="close" label="close" role="dismiss" onPress={() => closeShop($)} />
401      </Box>
402    )
403  })
404}
405
hooks/draw.ts 170 lines
1import type { Daemon, Farm, Rack, ServerKind } from '../types'
2import { RACK_WIDTH, rackX } from './game'
3
4// The strip is 10 pixels tall, two per terminal row with half blocks: racks on
5// top, a lane below them where the daemons walk, and the floor.
6export const STRIP_ROWS = 5
7const HEIGHT = STRIP_ROWS * 2
8const FLOOR_Y = HEIGHT - 1
9const RACK_TOP = 0
10const RACK_HEIGHT = 6
11const LEDS = 4
12
13const COLORS = {
14  floor: '#30363d',
15  inside: '#161b22',
16  bay: '#30363d',
17  led: '#7ee787',
18  ledOff: '#21262d',
19  ready: '#ffd166',
20  down: '#ff6b6b',
21  activity: '#58a6ff',
22  visor: '#56d4dd',
23  packet: '#ffd166',
24}
25
26const FRAMES: Record<ServerKind, string> = {
27  pi: '#4b5563',
28  tower: '#9ca3af',
29  blade: '#58a6ff',
30  gpu: '#3fb950',
31}
32
33const DAEMON_COLORS = ['#e5c07b', '#c678dd', '#56b6c2', '#e06c75', '#98c379', '#d19a66']
34
35type Canvas = (string | undefined)[][]
36
37const paint = (canvas: Canvas, x: number, y: number, color: string) => {
38  const row = canvas[y]
39  if (row !== undefined && x >= 0 && x < row.length) row[x] = color
40}
41
42export const visibleRacks = (farm: Farm, columns: number): number =>
43  farm.racks.filter((_, index) => rackX(index) + RACK_WIDTH <= columns).length
44
45const drawRack = (canvas: Canvas, rack: Rack, index: number, frame: number, isSpiking: boolean) => {
46  const left = rackX(index)
47  const right = left + RACK_WIDTH - 1
48  const bottom = RACK_TOP + RACK_HEIGHT - 1
49
50  for (let y = RACK_TOP; y <= bottom; y++) {
51    for (let x = left; x <= right; x++) {
52      const isEdge = y === RACK_TOP || y === bottom || x === left || x === right
53      paint(canvas, x, y, isEdge ? FRAMES[rack.kind] : COLORS.inside)
54    }
55  }
56
57  // Drive bays for texture.
58  for (const y of [RACK_TOP + 2, RACK_TOP + 4]) {
59    paint(canvas, left + 3, y, COLORS.bay)
60    paint(canvas, left + 4, y, COLORS.bay)
61  }
62
63  // Two columns of LEDs fill from the bottom as the batch builds up.
64  const lit = Math.floor(rack.progress * LEDS)
65  for (let level = 0; level < LEDS; level++) {
66    const y = bottom - 1 - level
67    const color = rack.isDown
68      ? frame % 2 === 0
69        ? COLORS.down
70        : COLORS.ledOff
71      : rack.progress >= 1
72        ? frame % 4 < 2
73          ? COLORS.ready
74          : COLORS.ledOff
75        : level < lit
76          ? COLORS.led
77          : COLORS.ledOff
78    paint(canvas, left + 1, y, color)
79    paint(canvas, left + 2, y, color)
80  }
81
82  // The activity light flickers while the rack works, faster during a traffic spike.
83  const isWorking = !rack.isDown && rack.progress < 1
84  const isOn = isWorking && (frame + index) % (isSpiking ? 2 : 4) === 0
85  paint(canvas, left + 4, RACK_TOP + 1, isOn ? COLORS.activity : COLORS.inside)
86}
87
88// A daemon is a 4x4 robot with a visor, walking the lane below the racks. While it
89// works, a packet blinks between it and the rack.
90const drawDaemon = (canvas: Canvas, daemon: Daemon, index: number, frame: number) => {
91  const x = Math.round(daemon.x)
92  const body = DAEMON_COLORS[index % DAEMON_COLORS.length] ?? COLORS.visor
93  const isWorking = daemon.workMs > 0
94  const isWalking = daemon.target !== null && !isWorking
95  const top = FLOOR_Y - 3
96
97  if (isWorking && frame % 2 === 0) paint(canvas, x + 2, top - 1, COLORS.packet)
98  for (const dx of [1, 2]) paint(canvas, x + dx, top, body)
99  paint(canvas, x, top + 1, body)
100  paint(canvas, x + 1, top + 1, COLORS.visor)
101  paint(canvas, x + 2, top + 1, COLORS.visor)
102  paint(canvas, x + 3, top + 1, body)
103  for (let dx = 0; dx < 4; dx++) paint(canvas, x + dx, top + 2, body)
104
105  const legs = isWalking && frame % 2 === 1 ? [1, 2] : [0, 3]
106  for (const dx of legs) paint(canvas, x + dx, top + 3, body)
107}
108
109const DEFAULT_COLOR = 0x01000000
110const UPPER_HALF = 0x2580
111const LOWER_HALF = 0x2584
112const SPACE = 0x20
113
114const hex = (color: string | undefined): number | undefined =>
115  color === undefined ? undefined : parseInt(color.slice(1), 16)
116
117// Packs a canvas into RasterProps cells: [codePoint, fg, bg] u32 triplets, base64.
118const encode = (canvas: Canvas, columns: number): string => {
119  const words = new Uint32Array(columns * STRIP_ROWS * 3)
120  for (let row = 0; row < STRIP_ROWS; row++) {
121    for (let x = 0; x < columns; x++) {
122      const top = hex(canvas[2 * row]?.[x])
123      const bottom = hex(canvas[2 * row + 1]?.[x])
124      const cell =
125        top !== undefined
126          ? [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
127          : bottom !== undefined
128            ? [LOWER_HALF, bottom, DEFAULT_COLOR]
129            : [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
130      words.set(cell, (row * columns + x) * 3)
131    }
132  }
133
134  const bytes = new Uint8Array(words.buffer)
135  let binary = ''
136  for (const byte of bytes) binary += String.fromCharCode(byte)
137
138  return btoa(binary)
139}
140
141export const drawCanvas = (farm: Farm, columns: number, frame: number): Canvas => {
142  const canvas: Canvas = Array.from({ length: HEIGHT }, () => new Array<string | undefined>(columns).fill(undefined))
143  for (let x = 0; x < columns; x++) paint(canvas, x, FLOOR_Y, COLORS.floor)
144
145  const shown = visibleRacks(farm, columns)
146  farm.racks.slice(0, shown).forEach((rack, index) => drawRack(canvas, rack, index, frame, farm.spikeMs > 0))
147  farm.daemons.forEach((daemon, index) => drawDaemon(canvas, daemon, index, frame))
148
149  return canvas
150}
151
152export const drawStrip = (farm: Farm, columns: number, frame: number): string =>
153  encode(drawCanvas(farm, columns, frame), columns)
154
155const LEVELS = '▁▂▃▄▅▆▇█'
156
157// The farm as text, for surfaces without Raster (desktop, VS Code, mobile).
158export const textStrip = (farm: Farm, frame: number): string =>
159  farm.racks
160    .map(rack =>
161      rack.isDown
162        ? '[x]'
163        : rack.progress >= 1
164          ? frame % 4 < 2
165            ? '[*]'
166            : '[ ]'
167          : `[${LEVELS[Math.min(LEVELS.length - 1, Math.floor(rack.progress * LEVELS.length))]}]`,
168    )
169    .join('')
170
hooks/game.ts 297 lines
1import type { Daemon, Farm, FarmEvent, Rack, ServerKind, ShopItemId } from '../types'
2
3// Layout along the strip, in pixels: a rack is 6 wide, one every 8.
4export const RACK_WIDTH = 6
5export const RACK_STRIDE = 8
6export const STRIP_LEFT = 1
7
8export const MAX_RACKS = 12
9export const MAX_DAEMONS = 6
10export const MAX_OVERCLOCK = 10
11
12const DAEMON_SPEED = 12 // pixels per second
13const COLLECT_MS = 600
14const FIX_MS = 4_000
15const SPIKE_MS = 60_000
16const MAX_SPIKE_MS = 300_000
17const OFFLINE_CAP_MS = 8 * 60 * 60 * 1000
18const OFFLINE_EFFICIENCY = 0.5
19const STEP_MS = 250
20
21export const DAEMON_NAMES = ['Byte', 'Nibble', 'Bit', 'Word', 'Qword', 'Kilo']
22
23export const SERVERS: Record<ServerKind, { label: string; cycleMs: number; yield: number; cost: number }> = {
24  pi: { label: 'Raspberry Pi', cycleMs: 10_000, yield: 8, cost: 0 },
25  tower: { label: 'Tower server', cycleMs: 30_000, yield: 60, cost: 250 },
26  blade: { label: 'Blade server', cycleMs: 90_000, yield: 400, cost: 2_000 },
27  gpu: { label: 'GPU rig', cycleMs: 240_000, yield: 2_400, cost: 16_000 },
28}
29
30const TIERS: readonly ServerKind[] = ['pi', 'tower', 'blade', 'gpu']
31
32export const rackX = (index: number): number => STRIP_LEFT + index * RACK_STRIDE
33
34// Where a daemon stands to work on a rack.
35const standX = (index: number): number => rackX(index) + 1
36
37const newRack = (): Rack => ({ kind: 'pi', progress: 0, isDown: false })
38
39const newDaemon = (index: number): Daemon => ({
40  name: DAEMON_NAMES[index] ?? `Daemon ${index + 1}`,
41  x: standX(0),
42  target: null,
43  workMs: 0,
44})
45
46export const newFarm = (now: number): Farm => ({
47  version: 1,
48  bytes: 0,
49  lifetimeBytes: 0,
50  racks: [newRack(), newRack()],
51  daemons: [newDaemon(0)],
52  overclock: 0,
53  spikeMs: 0,
54  incidents: 0,
55  savedAt: now,
56})
57
58export const isFarm = (value: unknown): value is Farm => {
59  const farm = value as Farm
60
61  return (
62    typeof value === 'object' &&
63    value !== null &&
64    farm.version === 1 &&
65    typeof farm.bytes === 'number' &&
66    Array.isArray(farm.racks) &&
67    Array.isArray(farm.daemons)
68  )
69}
70
71const speedOf = (farm: Farm): number => (1 + 0.2 * farm.overclock) * (farm.spikeMs > 0 ? 2 : 1)
72
73export const ratePerSecond = (farm: Farm): number =>
74  farm.racks.reduce(
75    (sum, rack) => (rack.isDown ? sum : sum + (SERVERS[rack.kind].yield * 1000) / SERVERS[rack.kind].cycleMs),
76    0,
77  ) * speedOf(farm)
78
79// The nearest rack that is full or down and that no other daemon has claimed.
80const pickTarget = (racks: readonly Rack[], x: number, taken: ReadonlySet<number>): number | null => {
81  let best = null as number | null
82  racks.forEach((rack, index) => {
83    const needsWork = rack.isDown || rack.progress >= 1
84    if (!needsWork || taken.has(index)) return
85    if (best === null || Math.abs(standX(index) - x) < Math.abs(standX(best) - x)) best = index
86  })
87
88  return best
89}
90
91const jobMs = (rack: Rack | undefined): number => (rack?.isDown ? FIX_MS : COLLECT_MS)
92
93const tick = (farm: Farm, dt: number): Farm => {
94  const speed = speedOf(farm)
95  const racks = farm.racks.map(rack =>
96    rack.isDown || rack.progress >= 1
97      ? rack
98      : { ...rack, progress: Math.min(1, rack.progress + (dt * speed) / SERVERS[rack.kind].cycleMs) },
99  )
100  const taken = new Set(farm.daemons.flatMap(daemon => (daemon.target === null ? [] : [daemon.target])))
101  let bytes = farm.bytes
102  let lifetimeBytes = farm.lifetimeBytes
103
104  const daemons = farm.daemons.map(daemon => {
105    let { x, target, workMs } = daemon
106
107    if (target === null || target >= racks.length) {
108      target = pickTarget(racks, x, taken)
109      workMs = 0
110      if (target !== null) taken.add(target)
111    }
112    if (target === null) return { ...daemon, target, workMs }
113
114    const goal = standX(target)
115    if (x !== goal) {
116      const move = (DAEMON_SPEED * dt) / 1000
117      x = Math.abs(goal - x) <= move ? goal : x + Math.sign(goal - x) * move
118      if (x === goal) workMs = jobMs(racks[target])
119
120      return { ...daemon, x, target, workMs }
121    }
122
123    if (workMs === 0) workMs = jobMs(racks[target])
124    workMs -= dt
125    if (workMs > 0) return { ...daemon, x, target, workMs }
126
127    const rack = racks[target]
128    if (rack?.isDown) {
129      racks[target] = { ...rack, isDown: false }
130    } else if (rack !== undefined && rack.progress >= 1) {
131      bytes += SERVERS[rack.kind].yield
132      lifetimeBytes += SERVERS[rack.kind].yield
133      racks[target] = { ...rack, progress: 0 }
134    }
135    taken.delete(target)
136
137    return { ...daemon, x, target: null, workMs: 0 }
138  })
139
140  return { ...farm, racks, daemons, bytes, lifetimeBytes, spikeMs: Math.max(0, farm.spikeMs - dt) }
141}
142
143// Runs the farm forward, in steps short enough for daemons to walk smoothly.
144export const step = (farm: Farm, elapsedMs: number): Farm => {
145  let next = farm
146  for (let left = elapsedMs; left > 0; left -= STEP_MS) next = tick(next, Math.min(STEP_MS, left))
147
148  return next
149}
150
151// Time the farm was not watched: paid at half rate, up to 8 hours, racks left full.
152export const catchUp = (farm: Farm, elapsedMs: number): { farm: Farm; earned: number } => {
153  const ms = Math.min(Math.max(0, elapsedMs), OFFLINE_CAP_MS)
154  const speed = 1 + 0.2 * farm.overclock
155  const perMs = farm.racks.reduce(
156    (sum, rack) => (rack.isDown ? sum : sum + SERVERS[rack.kind].yield / SERVERS[rack.kind].cycleMs),
157    0,
158  )
159  const earned = Math.floor(perMs * speed * ms * OFFLINE_EFFICIENCY)
160  const racks = farm.racks.map(rack =>
161    rack.isDown ? rack : { ...rack, progress: Math.min(1, rack.progress + (ms * speed) / SERVERS[rack.kind].cycleMs) },
162  )
163
164  return {
165    farm: {
166      ...farm,
167      racks,
168      bytes: farm.bytes + earned,
169      lifetimeBytes: farm.lifetimeBytes + earned,
170      spikeMs: Math.max(0, farm.spikeMs - ms),
171    },
172    earned,
173  }
174}
175
176const upgradeTarget = (farm: Farm): { index: number; to: ServerKind } | null => {
177  // Spelled with `as` so TypeScript keeps the type through the forEach assignments.
178  let found = null as { index: number; tier: number } | null
179  farm.racks.forEach((rack, index) => {
180    const tier = TIERS.indexOf(rack.kind)
181    if (tier < TIERS.length - 1 && (found === null || tier < found.tier)) found = { index, tier }
182  })
183  if (found === null) return null
184
185  const { index, tier } = found
186  const to = TIERS[tier + 1]
187
188  return to === undefined ? null : { index, to }
189}
190
191export type ShopItem = { id: ShopItemId; label: string; cost: number | null }
192
193export const shopItems = (farm: Farm): ShopItem[] => {
194  const upgrade = upgradeTarget(farm)
195  const from = upgrade === null ? undefined : farm.racks[upgrade.index]
196
197  return [
198    {
199      id: 'rack',
200      label: 'New rack',
201      cost: farm.racks.length >= MAX_RACKS ? null : Math.round(40 * 1.6 ** (farm.racks.length - 2)),
202    },
203    {
204      id: 'daemon',
205      label: `Hire ${DAEMON_NAMES[farm.daemons.length] ?? 'a daemon'}`,
206      cost: farm.daemons.length >= MAX_DAEMONS ? null : Math.round(120 * 3 ** (farm.daemons.length - 1)),
207    },
208    {
209      id: 'overclock',
210      label: `Overclock to level ${farm.overclock + 1}`,
211      cost: farm.overclock >= MAX_OVERCLOCK ? null : Math.round(80 * 2.2 ** farm.overclock),
212    },
213    upgrade === null || from === undefined
214      ? { id: 'upgrade', label: 'Upgrade a rack', cost: null }
215      : {
216          id: 'upgrade',
217          label: `Rack ${upgrade.index + 1}: ${SERVERS[from.kind].label} to ${SERVERS[upgrade.to].label}`,
218          cost: SERVERS[upgrade.to].cost,
219        },
220  ]
221}
222
223export const buy = (farm: Farm, id: ShopItemId): { farm: Farm; notice: string } => {
224  const item = shopItems(farm).find(one => one.id === id)
225  if (item === undefined || item.cost === null) return { farm, notice: 'Sold out' }
226  if (farm.bytes < item.cost) return { farm, notice: `Not enough data for ${item.label}` }
227
228  const paid = { ...farm, bytes: farm.bytes - item.cost }
229  const upgrade = upgradeTarget(farm)
230  switch (id) {
231    case 'rack':
232      return { farm: { ...paid, racks: [...paid.racks, newRack()] }, notice: 'A new rack is online' }
233    case 'daemon':
234      return {
235        farm: { ...paid, daemons: [...paid.daemons, newDaemon(paid.daemons.length)] },
236        notice: `${DAEMON_NAMES[paid.daemons.length] ?? 'A daemon'} joined the farm`,
237      }
238    case 'overclock':
239      return { farm: { ...paid, overclock: paid.overclock + 1 }, notice: `Overclocked to level ${paid.overclock + 1}` }
240    case 'upgrade': {
241      if (upgrade === null) return { farm, notice: 'Sold out' }
242      const racks = paid.racks.map((rack, index) =>
243        index === upgrade.index ? { kind: upgrade.to, progress: 0, isDown: false } : rack,
244      )
245
246      return { farm: { ...paid, racks }, notice: `Rack ${upgrade.index + 1} is now a ${SERVERS[upgrade.to].label}` }
247    }
248  }
249}
250
251// What Claude Code activity does to the farm, and the line the band shows for it.
252export const applyEvent = (farm: Farm, event: FarmEvent): { farm: Farm; notice: string | null } => {
253  switch (event.kind) {
254    case 'turn':
255      return {
256        farm: { ...farm, spikeMs: Math.min(MAX_SPIKE_MS, farm.spikeMs + SPIKE_MS) },
257        notice: farm.spikeMs > 0 ? 'Traffic spike extended' : 'Traffic spike! Production x2',
258      }
259    case 'tests': {
260      const bonus = Math.max(16, Math.floor(ratePerSecond({ ...farm, spikeMs: 0 }) * 30))
261
262      return {
263        farm: { ...farm, bytes: farm.bytes + bonus, lifetimeBytes: farm.lifetimeBytes + bonus },
264        notice: `Green build! +${formatBytes(bonus)}`,
265      }
266    }
267    case 'failure': {
268      const up = farm.racks.flatMap((rack, index) => (rack.isDown ? [] : [index]))
269      const down = farm.racks.length - up.length
270      const index = up[farm.incidents % Math.max(1, up.length)]
271      if (index === undefined || down >= Math.ceil(farm.racks.length / 3)) return { farm, notice: null }
272
273      const racks = farm.racks.map((rack, i) => (i === index ? { ...rack, isDown: true } : rack))
274
275      return { farm: { ...farm, racks, incidents: farm.incidents + 1 }, notice: `Incident! Rack ${index + 1} went down` }
276    }
277    case 'buy':
278      return buy(farm, event.item)
279  }
280}
281
282const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
283
284export const formatBytes = (bytes: number): string => {
285  let value = Math.floor(bytes)
286  let unit = 0
287  while (value >= 1024 && unit < UNITS.length - 1) {
288    value /= 1024
289    unit += 1
290  }
291
292  return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${UNITS[unit]}`
293}
294
295export const formatRate = (perSecond: number): string =>
296  perSecond < 10 ? `${perSecond.toFixed(1)} B/s` : `${formatBytes(perSecond)}/s`
297
types/index.d.ts 48 lines
1export type ServerKind = 'pi' | 'tower' | 'blade' | 'gpu'
2
3export type Rack = {
4  kind: ServerKind
5  // 0 to 1; at 1 the rack holds a full batch of data waiting for a daemon.
6  progress: number
7  isDown: boolean
8}
9
10export type Daemon = {
11  name: string
12  // Position along the strip, in pixels.
13  x: number
14  // The rack it is heading to or working on.
15  target: number | null
16  // Time left on the job at the target; 0 while walking or idle.
17  workMs: number
18}
19
20export type Farm = {
21  version: 1
22  bytes: number
23  lifetimeBytes: number
24  racks: Rack[]
25  daemons: Daemon[]
26  overclock: number
27  spikeMs: number
28  incidents: number
29  savedAt: number
30}
31
32export type ShopItemId = 'rack' | 'daemon' | 'overclock' | 'upgrade'
33
34export type FarmEvent = { kind: 'turn' } | { kind: 'tests' } | { kind: 'failure' } | { kind: 'buy'; item: ShopItemId }
35
36export type FarmNotice = { text: string; until: number }
37
38declare module 'claude-code' {
39  interface PluginState {
40    'server-farm': {
41      farm: Farm | null
42      isOwner: boolean
43      notice: FarmNotice | null
44      isHidden: boolean
45    }
46  }
47}
48