Every file that changes becomes a crop: Claude's edits and yours grow a farm in its own pane, tests bring sun or storms, and passing turns fill the barn.

Your files are the field. Every file that changes in a session becomes a plot on a small pixel farm, the seasons turn every five minutes, and the farmer never stands still.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install little-harvest@claude-code-mods
Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.
The farm is hidden until you ask for it.
/farm shows the farm. Run it again to hide it./farm on and /farm off set it directly./farm stats lists every plot with its crop, stage, and lines changed, and leaves the farm as it is.When you turn the farm on, it comes back in every new session until you turn it off. One game shows at a time: turning on another game from this marketplace (for example /dojo) hides the farm, and the farm stays off until you run /farm again. Closing the pane with its close mark also turns it off.
In the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1) the farm docks in a side column beside the transcript, with a log of what the farmer is doing under the scene. On the main screen it sits above the prompt. A pane that opens by itself at the start of a session needs a wide terminal (144 columns); if yours is narrower, run /farm to show it. The desktop app shows a one-line summary.
The farm is for watching. Nothing in it needs a key.
| File | Crop |
|---|---|
| Tests | Pumpkins |
| Source code | Corn |
| Docs | Sunflowers |
| Styles | Tulips |
| Config | Wheat |
| Anything else | Carrots |
Real work moves the farm:
git diff every 20 seconds), becomes a plot. Crops grow with the lines changed: seed, sprout, growing, then ripe. The farmer drops what they are doing and hoes that plot.Between work, the farm keeps busy on its own. The farmer waters plots, hoes new rows, pulls weeds, carries hay from the barn, feeds the chickens, shoos crows off the field, fixes the scarecrow, and rests under the cherry tree. Chickens peck and wander, a crow lands now and then, and a cat naps on the fence. This is only for show: chores never grow crops or add bushels.
The seasons turn every five minutes, in step with the Samurai Dojo: winter (snow on the fence, the barn, and the crops), cherry blossom (falling petals), summer (fireflies), and autumn (falling leaves). The sky also follows your local time of day.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.tsx 119 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { noop } from './shared/noop'
4import { configureStage, installStage } from './shared/pixel/stage'
5import { plural } from './shared/text/meter'
6import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
7import { CROP_LABELS, stageForLines } from './sim/crops'
8import { farm, parseNumstat, projectRoot, setProjectRoot, userChanges } from './sim/ledger'
9import { installWork } from './work'
10
11const LIFETIME_KEY = 'lifetimeBushels'
12const GIT_POLL_MS = 20000
13const HOUR_POLL_MS = 60000
14
15function seasonLine(): string {
16 const next = farm.calendar.next()
17 const minutes = Math.max(1, Math.ceil(next.remainingMs / 60000))
18 return `${farm.calendar.season().label}, ${next.label.toLowerCase()} in ${minutes} min`
19}
20
21function summary(): string {
22 const { plots, ripe, lifetimeBushels } = farm.summary()
23 return `${plural({ count: plots, word: 'plot' })} ${ripe} ripe ${plural({ count: lifetimeBushels, word: 'bushel' })} ${seasonLine()}`
24}
25
26function plotLines(): string[] {
27 const plots = farm.plots()
28 if (plots.length === 0) {
29 return ['No plots yet. Every file that changes in this session becomes a plot.']
30 }
31 return [
32 'Plots:',
33 ...plots.map(plot => {
34 const stage = stageForLines(plot.lines) + (plot.isWilted ? ', wilted' : '')
35 return ` ${CROP_LABELS[plot.crop].padEnd(9)} ${stage.padEnd(16)} ${plot.path} (${plural({ count: plot.lines, word: 'line' })})`
36 }),
37 ]
38}
39
40function report(): string[] {
41 const { sessionBushels, lifetimeBushels } = farm.summary()
42 return [
43 `Barn: ${plural({ count: sessionBushels, word: 'bushel' })} this session, ${lifetimeBushels} in all.`,
44 `Season: ${seasonLine()}.`,
45 ...plotLines(),
46 ]
47}
48
49async function syncHour($: EngineInterface): Promise<void> {
50 const date = new Date(await $.clock.now())
51 farm.setHour(date.getHours() + date.getMinutes() / 60)
52}
53
54async function pollGit($: EngineInterface): Promise<void> {
55 const result = await $.process
56 .run(['git', 'diff', '--numstat', 'HEAD'], { cwd: projectRoot(), timeoutMs: 3000 })
57 .catch(() => undefined)
58 if (!result || result.exitCode !== 0) {
59 return
60 }
61 userChanges(parseNumstat(result.stdout)).forEach(change => farm.tend({ ...change, by: 'you' }))
62}
63
64export const register: Register = on => {
65 configureStage({
66 game: 'little-harvest',
67 title: 'Little Harvest',
68 command: {
69 name: 'farm',
70 description: 'Show or hide Little Harvest (on, off, stats lists every plot)',
71 report,
72 },
73 rows: STAGE_ROWS,
74 minColumns: MIN_COLUMNS,
75 maxColumns: MAX_COLUMNS,
76 frameMs: FRAME_MS,
77 scene: {
78 begin: epochMs => farm.begin(epochMs),
79 resize: columns => farm.resize(columns),
80 tick: dtMs => farm.tick(dtMs),
81 frame: () => farm.frame(),
82 summary,
83 log: () => farm.log(),
84 },
85 })
86 installStage(on)
87 installWork(on)
88
89 on('session.start', async ($, e, next) => {
90 setProjectRoot(e.cwd)
91 farm.restore({ lifetimeBushels: Number((await $.store.get(LIFETIME_KEY)) ?? 0) })
92 await syncHour($)
93 await pollGit($)
94 $.clock.every(HOUR_POLL_MS, () => {
95 syncHour($).catch(noop)
96 })
97 $.clock.every(GIT_POLL_MS, () => {
98 pollGit($).catch(noop)
99 })
100 return next(e)
101 })
102
103 on('turn.start', ($, e, next) => {
104 farm.beginTurn()
105 return next(e)
106 })
107
108 on('turn.complete', async ($, e, next) => {
109 if (e.agentId === undefined) {
110 const harvested = farm.endTurn()
111 if (harvested > 0) {
112 await $.store.set(LIFETIME_KEY, farm.summary().lifetimeBushels)
113 $.ui.toast(`Harvested ${harvested} ripe ${harvested === 1 ? 'crop' : 'crops'} into the barn`)
114 }
115 }
116 return next(e)
117 })
118}
119hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/shared/pixel/stage.tsx 199 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { noop } from '../noop'
4import type { Bitmap } from './bitmap'
5import { encodeCells } from './cells'
6
7export type StageScene = {
8 begin: (epochMs: number) => void
9 resize: (columns: number) => void
10 tick: (dtMs: number) => void
11 frame: () => Bitmap
12 summary: () => string
13 log: () => readonly string[]
14}
15
16export type StageOptions = {
17 game: string
18 title: string
19 command: { name: string; description: string; report: () => readonly string[] }
20 rows: number
21 minColumns: number
22 maxColumns: number
23 frameMs: number
24 scene: StageScene
25}
26
27const POLL_MS = 1000
28const PANE_COLUMNS = 62
29const ACTIVE_FILE = '.claude/mods/active-game'
30
31let stage: StageOptions | undefined
32let activePath = ''
33let isClockRunning = false
34let isPaneOpen = false
35let isRasterMounted = false
36let lastCells = ''
37let lastSummary = ''
38let sincePollMs = 0
39
40export function configureStage(options: StageOptions): void {
41 stage = options
42}
43
44export function isStageShown(): boolean {
45 return isPaneOpen
46}
47
48function wantsShow(options: { args: string; isActive: boolean }): boolean {
49 if (options.args === 'on') {
50 return true
51 }
52 return options.args === 'off' ? false : !options.isActive
53}
54
55async function readActive($: EngineInterface): Promise<string> {
56 if (activePath === '') {
57 return ''
58 }
59 return (await $.fs.read(activePath).catch(() => '')).trim()
60}
61
62export function installStage(on: On): void {
63 on('session.start', { isInteractive: true }, async ($, e, next) => {
64 const options = stage
65 if (!options) {
66 return next(e)
67 }
68 await $.command.register({ name: options.command.name, description: options.command.description, immediate: true })
69 activePath = `${(await $.env.get('HOME')) ?? '.'}/${ACTIVE_FILE}`
70 options.scene.begin(await $.clock.now())
71 isPaneOpen = (await $.ui.panes()).some(pane => pane.id === options.game)
72 const active = await readActive($)
73 if (active === options.game && !isPaneOpen) {
74 const opened = await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
75 isPaneOpen = true
76 if (!opened.isPlaced) {
77 $.ui.toast(`${options.title} waits for a wider terminal. Run /${options.command.name} to show it now.`)
78 }
79 }
80 if (isClockRunning) {
81 return next(e)
82 }
83 isClockRunning = true
84 $.clock.every(options.frameMs, () => {
85 options.scene.tick(options.frameMs)
86 sincePollMs += options.frameMs
87 if (sincePollMs >= POLL_MS) {
88 sincePollMs = 0
89 if (isPaneOpen && options.scene.summary() !== lastSummary) {
90 $.ui.invalidate('ui.render')
91 }
92 readActive($)
93 .then(current => (isPaneOpen && current !== options.game ? $.ui.close({ id: options.game }) : undefined))
94 .catch(noop)
95 }
96 if (!isPaneOpen || !isRasterMounted) {
97 return
98 }
99 const cells = encodeCells(options.scene.frame())
100 if (cells === lastCells) {
101 return
102 }
103 lastCells = cells
104 $.ui
105 .blit({ requestId: options.game, key: `${options.game}:stage`, cells })
106 .then(result => {
107 if (result.deny !== undefined) {
108 isRasterMounted = false
109 }
110 })
111 .catch(noop)
112 })
113 return next(e)
114 })
115
116 on('command.run', { command: stage?.command.name ?? '' }, async ($, e, next) => {
117 const options = stage
118 if (!options || e.command !== options.command.name) {
119 return next(e)
120 }
121 const args = e.args.trim().toLowerCase()
122 if (args === 'stats') {
123 return { text: options.command.report().join('\n') }
124 }
125 if (args !== '' && args !== 'on' && args !== 'off') {
126 return { text: [`Usage: /${options.command.name} [on|off|stats]`, ...options.command.report()].join('\n') }
127 }
128 const pane = (await $.ui.panes()).find(candidate => candidate.id === options.game)
129 const isActive = (await readActive($)) === options.game && pane?.isPlaced === true
130 if (wantsShow({ args, isActive })) {
131 await $.fs.write(activePath, options.game)
132 await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
133 isPaneOpen = true
134 return {
135 text: [`${options.title} is on. It comes back each session until you run /${options.command.name} again.`, ...options.command.report()].join('\n'),
136 }
137 }
138 await $.fs.write(activePath, '')
139 await $.ui.close({ id: options.game })
140 isPaneOpen = false
141 return { text: `${options.title} is off. Run /${options.command.name} to bring it back.` }
142 })
143
144 on('ui.close', async ($, e, next) => {
145 const options = stage
146 if (!options || e.id !== options.game) {
147 return next(e)
148 }
149 const closed = await next(e)
150 isPaneOpen = false
151 isRasterMounted = false
152 const active = await readActive($)
153 if (e.origin.kind === 'person' && active === options.game) {
154 await $.fs.write(activePath, '')
155 }
156 return closed
157 })
158
159 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
160 const options = stage
161 if (!options || e.requestId !== options.game) {
162 return next(e)
163 }
164 lastSummary = options.scene.summary()
165 const logRows = e.props.placement === 'dock' ? Math.max(0, e.props.scroll.bodyRows - options.rows - 3) : 0
166 const lines = options.scene.log().slice(0, logRows)
167 if (e.surface !== 'terminal' || e.props.bodyColumns < options.minColumns) {
168 const { Box, Text } = $.ui.resolve(e)
169 isRasterMounted = false
170 return (
171 <Box flexDirection="column">
172 <Text>{lastSummary}</Text>
173 {e.surface === 'terminal' ? <Text dimColor>Widen this pane to see the scene.</Text> : null}
174 </Box>
175 )
176 }
177 const { Box, Raster, Text } = $.ui.resolve(e)
178 const columns = Math.min(e.props.bodyColumns, options.maxColumns)
179 options.scene.resize(columns)
180 isRasterMounted = true
181 lastCells = encodeCells(options.scene.frame())
182 return (
183 <Box flexDirection="column">
184 <Raster key={`${options.game}:stage`} columns={columns} rows={options.rows} cells={lastCells} />
185 <Text dimColor wrap="truncate">{lastSummary}</Text>
186 {lines.length > 0 ? (
187 <Box flexDirection="column" marginTop={1}>
188 {lines.map((line, index) => (
189 <Text key={`log-${index}`} dimColor={index > 0} wrap="truncate">
190 {line}
191 </Text>
192 ))}
193 </Box>
194 ) : null}
195 </Box>
196 )
197 })
198}
199hooks/shared/text/meter.ts 34 lines1const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
2const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
3
4export function meter(options: { value: number; max: number; width: number }): string {
5 const ratio = options.max <= 0 ? 0 : Math.min(1, Math.max(0, options.value / options.max))
6 const eighths = Math.round(ratio * options.width * 8)
7 const full = Math.floor(eighths / 8)
8 const partial = EIGHTHS[eighths % 8] ?? ''
9 const filled = '█'.repeat(full) + partial
10 return filled + ' '.repeat(Math.max(0, options.width - full - (partial ? 1 : 0)))
11}
12
13export function sparkline(values: readonly number[]): string {
14 const peak = Math.max(0, ...values)
15 if (peak === 0) {
16 return SPARKS[0]?.repeat(values.length) ?? ''
17 }
18 return values
19 .map(value => SPARKS[Math.min(SPARKS.length - 1, Math.floor((value / peak) * (SPARKS.length - 1)))])
20 .join('')
21}
22
23export function truncate(options: { text: string; width: number }): string {
24 const characters = Array.from(options.text.replace(/\s+/g, ' ').trim())
25 if (characters.length <= options.width) {
26 return characters.join('')
27 }
28 return characters.slice(0, Math.max(0, options.width - 1)).join('') + '…'
29}
30
31export function plural(options: { count: number; word: string }): string {
32 return `${options.count} ${options.word}${options.count === 1 ? '' : 's'}`
33}
34hooks/sim/constants.ts 37 lines1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const MIN_COLUMNS = 44
5export const MAX_COLUMNS = 72
6
7export const HORIZON_Y = 11
8export const MOUND_Y = 12
9export const CROP_BASE_Y = 12
10export const FEET_Y = 15
11export const FENCE_RAIL_Y = 8
12
13export const TREE_X = 6
14export const TREE_GROUND_Y = 15
15export const TREE_CROWN = { x: TREE_X - 7, y: TREE_GROUND_Y - 13, width: 15, height: 8 }
16export const FIELD_LEFT = 15
17export const PLOT_WIDTH = 7
18export const BARN_WIDTH = 17
19export const BARN_MARGIN = 2
20
21export const FARMER_SPEED_PIXELS_PER_SECOND = 18
22export const HOE_MS = 2400
23export const SWING_MS = 400
24export const WALK_FRAME_MS = 220
25export const CHEER_MS = 1600
26
27export const STORM_MS = 7000
28export const SUN_RAYS_MS = 3000
29export const HARVEST_MS = 2400
30export const POP_MS = 900
31
32export const SPROUT_LINES = 1
33export const GROWING_LINES = 10
34export const RIPE_LINES = 30
35
36export const LOG_LIMIT = 8
37hooks/sim/crops.ts 51 lines1import { GROWING_LINES, RIPE_LINES, SPROUT_LINES } from './constants'
2import type { CropKind, GrowthStage } from './types'
3
4const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[a-z0-9]+$/i
5const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$|(^|\/)(docs?|LICENSE|README)[^/]*$/i
6const STYLE_PATH = /\.(css|scss|sass|less|styl|pcss)$/i
7const CONFIG_PATH = /\.(json|jsonc|ya?ml|toml|ini|env|lock|config\.[cm]?[jt]s)$|(^|\/)\.[a-z]+rc$|(^|\/)(Dockerfile|Makefile)$/i
8const SOURCE_PATH = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|scala|ex|exs|lua|dart|vue|svelte|sh|sql|zig)$/i
9
10export const CROP_LABELS: Record<CropKind, string> = {
11 pumpkin: 'pumpkin',
12 corn: 'corn',
13 sunflower: 'sunflower',
14 tulip: 'tulip',
15 wheat: 'wheat',
16 carrot: 'carrot',
17}
18
19export function cropForPath(path: string): CropKind {
20 if (TEST_PATH.test(path)) {
21 return 'pumpkin'
22 }
23 if (DOC_PATH.test(path)) {
24 return 'sunflower'
25 }
26 if (STYLE_PATH.test(path)) {
27 return 'tulip'
28 }
29 if (CONFIG_PATH.test(path)) {
30 return 'wheat'
31 }
32 return SOURCE_PATH.test(path) ? 'corn' : 'carrot'
33}
34
35export function stageForLines(lines: number): GrowthStage {
36 if (lines >= RIPE_LINES) {
37 return 'ripe'
38 }
39 if (lines >= GROWING_LINES) {
40 return 'growing'
41 }
42 return lines >= SPROUT_LINES ? 'sprout' : 'seed'
43}
44
45const TEST_COMMAND =
46 /(^|[\s;&|(])(npm|pnpm|yarn|bun)\s+(run\s+)?(test|check|vitest|jest)\b|(^|[\s;&|(])(npx\s+)?(vitest|jest|mocha|ava|playwright\s+test|pytest|tox|rspec|phpunit)\b|(^|[\s;&|(])(go|cargo|deno|swift|dotnet|mix|gradle|mvn)\s+test\b|(^|[\s;&|(])node\s+(--test\b|\S*\.test\.[cm]?[jt]s)|(^|[\s;&|(])claude\s+plugin\s+test\b|(^|[\s;&|(])make\s+(test|check)\b|python3?\s+-m\s+(pytest|unittest)\b/
47
48export function isTestCommand(command: string): boolean {
49 return TEST_COMMAND.test(command)
50}
51hooks/sim/ledger.ts 56 lines1import { createFarm } from './farm'
2
3export const farm = createFarm()
4
5let root = ''
6let baseline: Map<string, number> | undefined
7let claudeLines = new Map<string, number>()
8
9export function setProjectRoot(path: string): void {
10 root = path.replace(/\/$/, '')
11}
12
13export function projectRoot(): string {
14 return root
15}
16
17export function relativePath(path: string): string {
18 return root !== '' && path.startsWith(root + '/') ? path.slice(root.length + 1) : path
19}
20
21export function countPatchLines(hunks: readonly { lines: readonly string[] }[]): number {
22 return hunks.reduce((total, hunk) => total + hunk.lines.filter(line => line.startsWith('+') || line.startsWith('-')).length, 0)
23}
24
25export function noteClaudeChange(options: { path: string; lines: number }): void {
26 claudeLines.set(options.path, (claudeLines.get(options.path) ?? 0) + options.lines)
27 farm.tend({ ...options, by: 'claude' })
28}
29
30export function parseNumstat(text: string): Map<string, number> {
31 const counts = new Map<string, number>()
32 text
33 .split('\n')
34 .map(line => line.split('\t'))
35 .forEach(([added, removed, ...rest]) => {
36 const path = rest.join('\t')
37 if (path !== '' && added !== undefined && removed !== undefined) {
38 counts.set(path, (Number(added) || 0) + (Number(removed) || 0))
39 }
40 })
41 return counts
42}
43
44export function userChanges(counts: Map<string, number>): { path: string; lines: number }[] {
45 const previous = baseline
46 const claude = claudeLines
47 baseline = counts
48 claudeLines = new Map()
49 if (!previous) {
50 return []
51 }
52 return [...counts.entries()]
53 .map(([path, total]) => ({ path, lines: total - (previous.get(path) ?? 0) - (claude.get(path) ?? 0) }))
54 .filter(change => change.lines > 0)
55}
56hooks/work.ts 37 lines1import type { On } from 'claude-code'
2
3import { isTestCommand } from './sim/crops'
4import { countPatchLines, farm, noteClaudeChange, relativePath } from './sim/ledger'
5
6function lineCount(text: string): number {
7 return text === '' ? 0 : text.replace(/\n$/, '').split('\n').length
8}
9
10export function installWork(on: On): void {
11 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
12 const ran = await next(e)
13 if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
14 noteClaudeChange({ path: relativePath(e.file_path), lines: Math.max(1, countPatchLines(ran.result.structuredPatch)) })
15 }
16 return ran
17 })
18
19 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
20 const ran = await next(e)
21 if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
22 const patched = countPatchLines(ran.result.structuredPatch)
23 const lines = patched > 0 ? patched : lineCount(e.content)
24 noteClaudeChange({ path: relativePath(e.file_path), lines: Math.max(1, lines) })
25 }
26 return ran
27 })
28
29 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
30 const ran = await next(e)
31 if (ran.deny === undefined && isTestCommand(e.command)) {
32 farm.testRan({ isPassing: ran.isError !== true && !ran.result.interrupted })
33 }
34 return ran
35 })
36}
37hooks/shared/pixel/bitmap.ts 83 lines1export type Color = number
2
3export type Bitmap = {
4 width: number
5 height: number
6 pixels: (Color | null)[]
7}
8
9export type Tint = (color: Color) => Color
10
11export function createBitmap(options: { width: number; height: number }): Bitmap {
12 const { width, height } = options
13 return { width, height, pixels: Array.from({ length: width * height }, () => null) }
14}
15
16export function setPixel(options: {
17 bitmap: Bitmap
18 x: number
19 y: number
20 color: Color
21}): void {
22 const { bitmap, color } = options
23 const x = Math.round(options.x)
24 const y = Math.round(options.y)
25 if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
26 return
27 }
28 bitmap.pixels[y * bitmap.width + x] = color
29}
30
31export function getPixel(options: { bitmap: Bitmap; x: number; y: number }): Color | null {
32 const { bitmap, x, y } = options
33 if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
34 return null
35 }
36 return bitmap.pixels[y * bitmap.width + x] ?? null
37}
38
39export function fillRect(options: {
40 bitmap: Bitmap
41 x: number
42 y: number
43 width: number
44 height: number
45 color: Color
46}): void {
47 const { bitmap, color } = options
48 for (let row = 0; row < options.height; row += 1) {
49 for (let column = 0; column < options.width; column += 1) {
50 setPixel({ bitmap, x: options.x + column, y: options.y + row, color })
51 }
52 }
53}
54
55export function stamp(options: {
56 target: Bitmap
57 source: Bitmap
58 x: number
59 y: number
60 isFlipped?: boolean
61 tint?: Tint
62}): void {
63 const { target, source, tint } = options
64 source.pixels.forEach((color, index) => {
65 if (color === null) {
66 return
67 }
68 const column = index % source.width
69 const row = Math.floor(index / source.width)
70 const offset = options.isFlipped ? source.width - 1 - column : column
71 setPixel({
72 bitmap: target,
73 x: options.x + offset,
74 y: options.y + row,
75 color: tint ? tint(color) : color,
76 })
77 })
78}
79
80export function clearBitmap(bitmap: Bitmap): void {
81 bitmap.pixels.fill(null)
82}
83hooks/shared/pixel/cells.ts 44 lines1import { getPixel } from './bitmap'
2import type { Bitmap } from './bitmap'
3
4const TERMINAL_DEFAULT = 0x01000000
5const SPACE = 0x20
6const UPPER_HALF_BLOCK = 0x2580
7const LOWER_HALF_BLOCK = 0x2584
8
9export function cellRowsOf(bitmap: Bitmap): number {
10 return Math.floor(bitmap.height / 2)
11}
12
13export function toCellWords(bitmap: Bitmap): Uint32Array {
14 const rows = cellRowsOf(bitmap)
15 const words = new Uint32Array(bitmap.width * rows * 3)
16 for (let row = 0; row < rows; row += 1) {
17 for (let column = 0; column < bitmap.width; column += 1) {
18 const upper = getPixel({ bitmap, x: column, y: row * 2 })
19 const lower = getPixel({ bitmap, x: column, y: row * 2 + 1 })
20 const offset = (row * bitmap.width + column) * 3
21 if (upper === null && lower === null) {
22 words.set([SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT], offset)
23 } else if (upper === null && lower !== null) {
24 words.set([LOWER_HALF_BLOCK, lower, TERMINAL_DEFAULT], offset)
25 } else if (upper !== null) {
26 words.set([UPPER_HALF_BLOCK, upper, lower ?? TERMINAL_DEFAULT], offset)
27 }
28 }
29 }
30 return words
31}
32
33function toBinaryString(bytes: Uint8Array): string {
34 let binary = ''
35 bytes.forEach(byte => {
36 binary += String.fromCharCode(byte)
37 })
38 return binary
39}
40
41export function encodeCells(bitmap: Bitmap): string {
42 return btoa(toBinaryString(new Uint8Array(toCellWords(bitmap).buffer)))
43}
44hooks/sim/types.ts 95 lines1export type CropKind = 'pumpkin' | 'corn' | 'sunflower' | 'tulip' | 'wheat' | 'carrot'
2
3export type GrowthStage = 'seed' | 'sprout' | 'growing' | 'ripe'
4
5export type Plot = {
6 path: string
7 crop: CropKind
8 lines: number
9 isWilted: boolean
10 plantedOrder: number
11 touchedOrder: number
12}
13
14export type FarmerTask =
15 | 'idle'
16 | 'hoe'
17 | 'water'
18 | 'weed'
19 | 'lift'
20 | 'drop'
21 | 'feed'
22 | 'shoo'
23 | 'rest'
24 | 'inspect'
25 | 'cheer'
26
27export type FarmerMode = 'walk' | FarmerTask
28
29export type FarmerStep = {
30 task: FarmerTask
31 x: number
32 durationMs: number
33 isCarrying: boolean
34 isWork: boolean
35 isFacingLeft?: boolean
36}
37
38export type Farmer = {
39 x: number
40 mode: FarmerMode
41 modeMs: number
42 clockMs: number
43 isFacingLeft: boolean
44 isCarrying: boolean
45 step: FarmerStep | undefined
46}
47
48export type ChickenMode = 'walk' | 'peck' | 'stand'
49
50export type Chicken = {
51 x: number
52 targetX: number
53 mode: ChickenMode
54 modeMs: number
55 limitMs: number
56 isFacingLeft: boolean
57 isBrown: boolean
58}
59
60export type CrowMode = 'away' | 'arrive' | 'perch' | 'leave'
61
62export type Crow = {
63 mode: CrowMode
64 modeMs: number
65 limitMs: number
66 fromX: number
67 fromY: number
68 x: number
69 y: number
70 perchX: number
71}
72
73export type CatMode = 'nap' | 'stretch' | 'walk'
74
75export type Cat = { x: number; targetX: number; mode: CatMode; modeMs: number; limitMs: number; isFacingLeft: boolean }
76
77export type WeatherKind = 'clear' | 'storm' | 'sunny'
78
79export type TestWeather = { kind: WeatherKind; ms: number }
80
81export type Pop = { fromX: number; toX: number; ageMs: number; color: number }
82
83export type Harvest = { count: number; ageMs: number }
84
85export type Cloud = { x: number; y: number; width: number; speed: number }
86
87export type PlotView = { plot: Plot; x: number }
88
89export type FarmSummary = {
90 plots: number
91 ripe: number
92 sessionBushels: number
93 lifetimeBushels: number
94}
95hooks/sim/farm.ts 200 lines1import { BARN_DOOR_X } from '../art/barn'
2import { PRODUCE_COLORS } from '../art/crops'
3import { drawScene } from '../draw/scene'
4import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
5import type { Bitmap } from '../shared/pixel/bitmap'
6import { createWeather } from '../shared/pixel/weather'
7import {
8 BARN_MARGIN,
9 BARN_WIDTH,
10 CHEER_MS,
11 FIELD_LEFT,
12 HOE_MS,
13 LOG_LIMIT,
14 MAX_COLUMNS,
15 PLOT_WIDTH,
16 STAGE_HEIGHT,
17 TREE_CROWN,
18 TREE_X,
19} from './constants'
20import { createCalendar } from './calendar'
21import { createCritters } from './critters'
22import { CROP_LABELS } from './crops'
23import { createDirector } from './director'
24import { createEffects } from './effects'
25import { advanceFarmer, assignStep, createFarmer, isFree } from './farmer'
26import { createField } from './field'
27import type { FarmerTask, Plot } from './types'
28
29export function createFarm(options: { seed?: number } = {}) {
30 const seed = options.seed ?? 7
31 const field = createField()
32 const farmer = createFarmer()
33 const director = createDirector(seed)
34 const critters = createCritters(seed + 1)
35 const weather = createWeather({ seed: seed + 2 })
36 const effects = createEffects()
37 let hour = 12
38 let columns = MAX_COLUMNS
39 let bitmap: Bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
40 const calendar = createCalendar()
41 let lines: string[] = []
42
43 function note(line: string): void {
44 lines = [line, ...lines].slice(0, LOG_LIMIT)
45 }
46
47 function barnX(): number {
48 return columns - BARN_WIDTH
49 }
50
51 function work(step: { task: FarmerTask; x: number; durationMs: number }): void {
52 director.clear()
53 assignStep({ farmer, step: { ...step, isCarrying: false, isWork: true, isFacingLeft: true } })
54 }
55
56 function popFor(popOptions: { plot: Plot; x: number | undefined; isToBarn: boolean; delayMs: number }): void {
57 if (popOptions.x === undefined) {
58 return
59 }
60 const fromX = popOptions.x + 2
61 const toX = popOptions.isToBarn ? barnX() + BARN_DOOR_X : fromX
62 effects.pop({ fromX, toX, ageMs: -popOptions.delayMs, color: PRODUCE_COLORS[popOptions.plot.crop] })
63 }
64
65 function planAmbient(): void {
66 const plan = director.next({
67 plots: field.visible(columns).map(view => ({ x: view.x, label: CROP_LABELS[view.plot.crop] })),
68 fieldLeft: FIELD_LEFT,
69 fieldRight: barnX() - BARN_MARGIN,
70 barnX: barnX(),
71 treeX: TREE_X,
72 chickensX: critters.chickensX(),
73 crowX: critters.perchedCrowX(),
74 scarecrowX: field.scarecrowX(columns),
75 })
76 assignStep({ farmer, step: plan.step })
77 if (plan.line) {
78 note(plan.line)
79 }
80 if (plan.step.task === 'feed') {
81 critters.scatterFeed({ x: plan.step.x, durationMs: 6000 })
82 }
83 }
84
85 return {
86 resize(nextColumns: number): void {
87 if (nextColumns !== columns) {
88 columns = nextColumns
89 bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
90 }
91 },
92
93 begin(startMs: number): void {
94 calendar.begin(startMs)
95 },
96
97 setHour(nextHour: number): void {
98 hour = ((nextHour % 24) + 24) % 24
99 },
100
101 restore(saved: { lifetimeBushels: number }): void {
102 field.restore(saved)
103 },
104
105 tend(change: { path: string; lines: number; by?: 'claude' | 'you' }): void {
106 const { plot, x, didRipen } = field.tend({ ...change, columns })
107 if (x !== undefined) {
108 work({ task: 'hoe', x: x + PLOT_WIDTH - 1, durationMs: HOE_MS })
109 }
110 if (didRipen) {
111 popFor({ plot, x, isToBarn: false, delayMs: 0 })
112 }
113 note(`${change.by === 'you' ? 'You changed' : 'Claude tended'} ${change.path}, ${change.lines} ${change.lines === 1 ? 'line' : 'lines'}`)
114 },
115
116 testRan(result: { isPassing: boolean }): void {
117 effects.testRan(result)
118 field.testRan(result)
119 note(result.isPassing ? 'Tests passed: sun on the field' : 'Tests failed: a storm wilts the pumpkins')
120 },
121
122 beginTurn(): void {
123 field.beginTurn()
124 },
125
126 endTurn(): number {
127 const ripe = field.harvest(columns)
128 if (ripe.length === 0) {
129 return 0
130 }
131 ripe.forEach((entry, index) => popFor({ ...entry, isToBarn: true, delayMs: index * 120 }))
132 effects.harvested(ripe.length)
133 work({ task: 'cheer', x: farmer.x, durationMs: CHEER_MS })
134 note(`Harvested ${ripe.length} ${ripe.length === 1 ? 'crop' : 'crops'} into the barn`)
135 return ripe.length
136 },
137
138 tick(dtMs: number): void {
139 const newSeason = calendar.advance(dtMs)
140 if (newSeason) {
141 note(`${newSeason.label} comes to the farm`)
142 }
143 const season = calendar.season()
144 advanceFarmer({ farmer, dtMs })
145 if (isFree(farmer)) {
146 planAmbient()
147 }
148 if (farmer.mode === 'shoo' && farmer.modeMs > 300) {
149 critters.shooCrow()
150 }
151 critters.advance({
152 dtMs,
153 columns,
154 barnX: barnX(),
155 fieldLeft: FIELD_LEFT,
156 perchSpots: field.visible(columns).map(view => view.x + 1),
157 })
158 weather.advance({ dtMs, season, width: columns, groundY: STAGE_HEIGHT, sources: [TREE_CROWN] })
159 effects.advance({ dtMs, columns })
160 },
161
162 plots(): Plot[] {
163 return field.plots()
164 },
165
166 summary() {
167 return field.summary()
168 },
169
170 calendar,
171
172 activity(): { task: FarmerTask; isWork: boolean } {
173 return { task: farmer.step?.task ?? 'idle', isWork: farmer.step?.isWork === true }
174 },
175
176 log(): readonly string[] {
177 return lines
178 },
179
180 frame(): Bitmap {
181 clearBitmap(bitmap)
182 drawScene({
183 bitmap,
184 hour,
185 season: calendar.season(),
186 effects: effects.view(),
187 views: field.visible(columns),
188 scarecrowX: field.scarecrowX(columns),
189 farmer,
190 critters,
191 weather,
192 bushels: field.summary().lifetimeBushels,
193 })
194 return bitmap
195 },
196 }
197}
198
199export type Farm = ReturnType<typeof createFarm>
200