A cozy Stardew/Animal-Crossing-style village pane where Claude lives, farms, fishes and chats in speech bubbles - prompt it from the pane's input field.

A cozy Stardew Valley / Animal Crossing style village that lives in a pane inside Claude Code. An AI villager named Claude farms, fishes, chats with its neighbours and takes part in village life. You steer it by typing wishes like "buy pumpkin seeds and plant them" or "bring Rosa a flower and ask about her day".
In the terminal

In the browser (from the desktop app)

The office: an apprentice for every session

pedal) keep its battery up, a charged one is kind and computes Bells and apples, and one that runs flat turns evil and blows up the bomb factory.The desktop app cannot animate a picture inside a pane without it flickering, so there the village runs in your browser instead, as a pixel-art canvas game:
In the desktop app, /village shows the village's link with Open in my browser and Copy link. To keep it inside the app, open the link in the desktop app's Browser pane (Ctrl+Shift+B on Windows, Cmd+Shift+B on macOS). In any session, /village web opens it in your browser. The page is served by the village's local server (web/server.mjs), the same one every session connects to. It listens on this computer's loopback address only, and the link carries a random key for that server.
Claude Code with function-hook plugins (mods), and Node.js 22.18 or newer on the computer: the village runs in a small local Node server that runs the game's TypeScript directly. In the terminal the village is drawn in character cells and looks best in the fullscreen layout.
In a Claude Code terminal session, run:
/plugin install cozy-village --marketplace NicoReber/cozy-village
Answer y to add its marketplace, then pick a scope (user scope makes the village available in every session). The plugin is active right away; type /village to open the pane.
To install from a fork or a local clone, use its owner/repo, URL or path in place of NicoReber/cozy-village.
While working on the plugin, load it straight from a clone instead of installing it:
claude --plugin-dir <path-to-your-clone>
To load it in every session without installing, set CLAUDE_CODE_PLUGIN_DIRS to the clone's path in the env block of your user settings (~/.claude/settings.json). Claude Code watches folders loaded this way and reloads the plugin when its files change.
| Where | What |
|---|---|
| Input field under the map | Type a wish; Claude plans it and carries it out |
| Tabs: Chat · Memories · Village | The chat log, what everyone remembers, and projects / museum / requests |
| Quick bar | One-click chores: fish, chop wood, water, harvest, sell, read mail (no model call) |
| Postcard | Copies the scene to the clipboard |
| Office / Leave office | Sends Claude into the office to visit the apprentices, or back out |
| Museum tour | Walk through the museum once it is built |
| Village info (browser) | Requests, project progress, museum collection and memories |
| Open in my browser | Desktop app: opens the browser view (/village web anywhere) |
/village stop | Stops the village server for every session; it keeps running until you do (or until no session has been open for five minutes), even if you close the pane |
/village memory | Prints every character's memory |
/village forget | Wipes all memories and friendships |
Buttons are clicked with the mouse; there are no keyboard shortcuts.
Open /config and look for cozy-village:
| Setting | Default | What it does |
|---|---|---|
| Open the village at session start | off | Opens the pane by itself; otherwise use /village |
| Village ticker in the status line | off | Season, Bells, events and requests under the prompt |
| Model for the villagers | haiku | haiku, sonnet or opus |
| Velvet Club (adults only) | off | Adds an adults-only club as a community project. Content stays non-explicit, and Pip (a child) is always kept out of it |
The village calls the chosen model:
The village server cannot call a model itself, so one of the open sessions makes each call, with that session's model setting; a wish is answered by the session it was typed in. Nothing else talks to a model.
The village server saves the world to cozy-village/world.json in your Claude config folder (~/.claude unless CLAUDE_CONFIG_DIR says otherwise) every in-game hour and when it stops, and the save carries over between sessions. A save from an earlier version, kept in Claude Code's plugin store, moves there the first time the server starts. Next to it, server.json tells the sessions where the running server is.
hooks/
register.tsx pane UI, slash command, session hooks, model calls
game.ts simulation: map, villagers, memory, events, economy, seasons, drawing
catalog.ts data tables: crops, shop, recipes, projects, bugs, fish, museum
museum.ts the museum interior and the curator's lines
web.ts what the browser view is sent: the map and the moving parts
host.ts the shared village the server runs: sessions, panes and the model jobs it hands out
office.ts the office's interior and its apprentices, one per session
types/index.d.ts the plugin's state contract
web/ server.mjs (the village server every session connects to), launch.mjs (starts it detached),
index.html and village.js (the browser view's canvas renderer)
tests/ claude plugin test suite
tools/ scene scripts and renderers for docs/village.gif, docs/browser.gif and docs/office.gif
claude plugin validate .
claude plugin test .
The animation at the top is drawn from the game's own frames. To regenerate it after changing the scenes in tools/scene.ts or the drawing code, run python tools/render_gif.py (Python with Pillow on Windows, using the Cascadia Mono and Segoe UI Symbol fonts); it writes docs/village.gif.
The browser animation is filmed from the real browser view replaying the scenes in tools/browser_scene.ts. Run node tools/render_browser_gif.mjs (Node 22+, Chrome, and Python with Pillow); it writes docs/browser.gif. node tools/render_browser_gif.mjs office films the office scene in tools/office_scene.ts and writes docs/office.gif.
.claude-plugin/types/ is generated by Claude Code when the plugin loads and is not committed.
hooks/register.tsx 533 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpResponse, Register } from 'claude-code'
3
4import type { Info, LogLine, Tab } from '../types'
5import { encode, MAX_VIEW, ROWS, VILLAGERS } from './game'
6import type { Job } from './host'
7import { CHORES } from './web'
8
9// The village itself runs in one local server that every session shares (web/server.mjs, hooks/host.ts).
10// This module connects to it: it says hello, reports what the session's real work is doing, draws the pane
11// from the server's picture, and runs the model calls the village hands out, since only a session can.
12
13const PANE = 'cozy-village'
14/** The model behind the villagers; the `model` setting replaces it when the module loads. */
15let MODEL = 'haiku'
16/** Where the save lived before the shared server; moved into the server's save folder once. */
17const STORE_KEY = 'world'
18const log = atom({ plugin: 'cozy-village', key: 'log' } as const, [] as LogLine[])
19const thinking = atom({ plugin: 'cozy-village', key: 'thinking' } as const, false)
20const tab = atom({ plugin: 'cozy-village', key: 'tab' } as const, 'chat' as Tab)
21/** The browser view's address once the server is there, empty before; the pane shows it. */
22const webLink = atom({ plugin: 'cozy-village', key: 'web' } as const, '')
23/** The Memories and Village tabs, as the server last sent them. */
24const info = atom({ plugin: 'cozy-village', key: 'info' } as const, null as Info | null)
25/** Whether the museum is built and Claude is inside it, for the tab bar's button. */
26const museum = atom({ plugin: 'cozy-village', key: 'museum' } as const, { open: false, inside: false })
27/** How many apprentices work at the office and whether Claude is inside it, for the tab bar's button. */
28const office = atom({ plugin: 'cozy-village', key: 'office' } as const, { staff: 0, inside: false })
29
30const ACCENT = '#e6c34a'
31const INK = '#3a2e2a'
32const COLORS: Record<string, string> = {
33 you: '#7ec8e3', claude: ACCENT, mochi: '#c8b8b8', kiki: '#ffd54f', hoot: '#bcaaa4', event: '#8bc34a',
34 ...Object.fromEntries(VILLAGERS.map(v => [v.id, `#${v.color.toString(16).padStart(6, '0')}`])),
35}
36const NAMES: Record<string, string> = {
37 you: 'You', claude: 'Claude', mochi: 'Mochi', kiki: 'Kiki', hoot: 'Hoot', ...Object.fromEntries(VILLAGERS.map(v => [v.id, v.name])),
38}
39const TABS: { id: Tab; label: string }[] = [
40 { id: 'chat', label: 'Chat' },
41 { id: 'memories', label: 'Memories' },
42 { id: 'village', label: 'Village' },
43]
44const NAME_WIDTH = 7
45/** How an apprentice's desk reads in the Village tab. */
46const DESK_STATUS: Record<Info['office'][number]['status'], [string, string]> = {
47 working: ['busy', '#69f0ae'], idle: ['idle', '#90a4ae'], done: ['done', '#ffd54f'], error: ['trouble', '#ff5252'],
48}
49const bar = (have: number, total: number, width = 12) => {
50 const full = Math.round((Math.min(have, total) / Math.max(1, total)) * width)
51 return '█'.repeat(full) + '░'.repeat(width - full)
52}
53const TESTS = /\b(test|tests|vitest|jest|pytest|dotnet\s+test|cargo\s+test|npm\s+run\s+build|dotnet\s+build|tsc)\b/i
54/** How often a session that only reports its work checks in, takes model jobs and looks for a server. */
55const LINK_MS = 3000
56
57/** The link to the shared server; `port` is 0 while there is none. */
58const server = { port: 0, token: '', session: `pane-${Math.random().toString(36).slice(2)}`, repo: '', branch: '', launching: false }
59/** What the pane draws, as the server last sent it. */
60const pane = { cells: '', cols: 0, chat: '', since: 0, polls: 0, polling: false, ticker: '' }
61let viewCols = MAX_VIEW
62let surface = 'terminal'
63/** The pane's clock; null while the village is stopped in this session. */
64let tick: { cancel: () => void } | null = null
65let busyWithJob = false
66/** The `ticker` setting; set when the module registers. */
67let showTicker = false
68let adults = false
69
70/** An empty picture `cols` wide: spaces on the night sky's ink. */
71function blank(cols: number): string {
72 const cells = new Uint32Array(cols * ROWS * 3)
73 for (let i = 0; i < cells.length; i += 3) { cells[i] = 0x20; cells[i + 1] = 0x808080; cells[i + 2] = 0x101820 }
74 return encode(cells)
75}
76
77function open($: EngineInterface, focus?: true) {
78 return $.ui.open({ id: PANE, title: 'Village', rows: ROWS + 12, columns: MAX_VIEW + 2, focus })
79}
80
81/** The folder the server keeps its save and server.json in (`VILLAGE_HOME` for a test village); the server is told the same. */
82async function villageHome($: EngineInterface): Promise<string> {
83 const own = await $.env.get('VILLAGE_HOME')
84 if (own) return own.replaceAll('\\', '/')
85 const config = await $.env.get('CLAUDE_CONFIG_DIR')
86 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '.'
87 return `${(config || `${home}/.claude`).replaceAll('\\', '/')}/cozy-village`
88}
89
90/** Calls the shared server; null when it is not there (then the link is dropped, to be found again). */
91async function api($: EngineInterface, method: 'GET' | 'POST', path: string, body?: unknown): Promise<HttpResponse | null> {
92 if (!server.port) return null
93 try {
94 const res = await $.http.fetch(`http://127.0.0.1:${server.port}${path}`, {
95 method,
96 headers: { 'content-type': 'application/json', 'x-village-token': server.token },
97 body: body === undefined ? undefined : JSON.stringify(body),
98 })
99 // a new server took the port since, with a token of its own
100 if (res.status === 403) throw new Error('not our server')
101 return res
102 } catch {
103 server.port = 0
104 await update($, webLink, () => '')
105 return null
106 }
107}
108
109const q = (params: Record<string, string | number>) => Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
110
111/**
112 * Finds the shared server, or with `start` starts it when none runs. A server stopped on purpose
113 * (`/village stop` in any session) is only started again by a `start`; without one this session's pane stops too.
114 */
115async function connect($: EngineInterface, start: boolean): Promise<boolean> {
116 if (server.port) return true
117 if (server.launching) return false
118 server.launching = true
119 try {
120 const home = await villageHome($)
121 let found: { port?: unknown; token?: unknown; stopped?: unknown } | null = null
122 try { found = JSON.parse(await $.fs.read(`${home}/server.json`) as string) } catch { /* no server yet */ }
123 if (found?.stopped && !start) {
124 stopClock($)
125 return false
126 }
127 if (found && typeof found.port === 'number' && typeof found.token === 'string' && !found.stopped) {
128 server.port = found.port
129 server.token = found.token
130 if (!(await api($, 'GET', '/api/ping'))?.ok) server.port = 0
131 }
132 if (!server.port && start) {
133 // the save from before the shared server moves into its folder once, so nobody loses their village
134 if (!(await $.fs.exists(`${home}/world.json`))) {
135 const old = await $.store.get(STORE_KEY)
136 if (old) await $.fs.write(`${home}/world.json`, JSON.stringify(old))
137 }
138 const done = await $.process.run(['node', `${$.plugin.root}/web/launch.mjs`], {
139 env: { VILLAGE_HOME: home, VILLAGE_ADULTS: adults ? '1' : '0' }, timeoutMs: 30000,
140 })
141 const line = done.stdout.trim().split('\n').pop() ?? ''
142 if (done.exitCode !== 0 || !line.startsWith('{')) {
143 $.ui.log(`cozy-village: the village server did not start (${done.stderr.trim() || done.exitCode}); is Node.js 22.18+ installed?`, { to: 'debug' })
144 return false
145 }
146 const launched = JSON.parse(line) as { port: number; token: string }
147 server.port = launched.port
148 server.token = launched.token
149 }
150 if (!server.port) return false
151 await api($, 'POST', '/api/hello', { session: server.session, repo: server.repo, branch: server.branch })
152 await update($, webLink, () => `http://localhost:${server.port}/?k=${server.token}`)
153 return true
154 } catch (err) {
155 $.ui.log(`cozy-village: cannot reach the village server (${String(err)})`, { to: 'debug' })
156 server.port = 0
157 return false
158 } finally {
159 server.launching = false
160 }
161}
162
163/** Takes one model job from the village, runs it with this session's model and answers it. */
164async function runJob($: EngineInterface) {
165 if (busyWithJob || !server.port) return
166 busyWithJob = true
167 try {
168 const res = await api($, 'GET', `/api/jobs?${q({ session: server.session })}`)
169 if (res?.status !== 200) return
170 const job = JSON.parse(res.text) as Job
171 const reply = await $.model.complete({ model: MODEL, system: job.system, prompt: job.prompt, maxTokens: job.maxTokens, timeoutMs: 60000 })
172 await api($, 'POST', `/api/jobs/${job.id}`, reply.isAnswered ? { text: reply.text } : { text: null, reason: reply.reason })
173 } catch (err) {
174 $.ui.log(`cozy-village: a village job failed (${String(err)})`, { to: 'debug' })
175 } finally {
176 busyWithJob = false
177 }
178}
179
180/** Reports what the session's real work is doing; nothing when no server runs. */
181function report($: EngineInterface, event: Record<string, unknown>) {
182 if (server.port) void api($, 'POST', '/api/event', { session: server.session, ...event })
183}
184
185/** Fetches what the pane shows and draws it: the picture, the chat, the headlines, the tab panels. */
186async function pollPane($: EngineInterface) {
187 pane.polling = true
188 try {
189 const current = await read($, tab)
190 const wantInfo = current !== 'chat' && pane.polls++ % 10 === 0
191 const res = await api($, 'GET', `/api/pane?${q({ session: server.session, cols: viewCols, since: pane.since, info: wantInfo ? 1 : 0, cells: surface === 'terminal' ? 1 : 0 })}`)
192 if (!res?.ok) return
193 const p = JSON.parse(res.text) as {
194 cols: number; cells: string; chat: LogLine[]; thinking: boolean; scene: string; museumOpen: boolean; staff: number; ticker: string
195 announce: string[]; seq: number; info?: Info
196 }
197 pane.since = p.seq
198 for (const headline of p.announce) $.ui.toast(`★ ${headline}`)
199 const chat = JSON.stringify(p.chat)
200 if (chat !== pane.chat) { pane.chat = chat; await update($, log, () => p.chat) }
201 if (p.thinking !== await read($, thinking)) await update($, thinking, () => p.thinking)
202 const place = await read($, museum)
203 if (place.open !== p.museumOpen || place.inside !== (p.scene === 'museum')) await update($, museum, () => ({ open: p.museumOpen, inside: p.scene === 'museum' }))
204 const desks = await read($, office)
205 if (desks.staff !== p.staff || desks.inside !== (p.scene === 'office')) await update($, office, () => ({ staff: p.staff, inside: p.scene === 'office' }))
206 if (p.info) await update($, info, () => p.info!)
207 if (p.cells) {
208 pane.cells = p.cells
209 pane.cols = p.cols
210 void $.ui.blit({ requestId: PANE, key: 'world', columns: p.cols, rows: ROWS, cells: p.cells })
211 }
212 if (showTicker && p.ticker !== pane.ticker) { pane.ticker = p.ticker; $.ui.status(p.ticker) }
213 } catch {
214 // the next poll tries again
215 } finally {
216 pane.polling = false
217 }
218}
219
220/** The pane runs between a start (`/village`, auto-open, the ticker) and a stop (`/village stop`). */
221function startClock($: EngineInterface) {
222 if (tick) return
223 tick = $.clock.every(150, async () => {
224 if (!server.port) { await connect($, false); return }
225 if (!pane.polling) await pollPane($)
226 })
227}
228
229function stopClock($: EngineInterface) {
230 tick?.cancel()
231 tick = null
232 if (showTicker) $.ui.status(undefined)
233 pane.ticker = ''
234}
235
236/** Sends a wish, a chore or the museum button; a wish's model call starts here right away. */
237async function act($: EngineInterface, action: Record<string, unknown>) {
238 if (!(await connect($, true))) { $.ui.toast('The village server is not running (it needs Node.js 22.18+).'); return }
239 await api($, 'POST', '/api/do', { ...action, session: server.session })
240 if (action.kind === 'wish') await runJob($)
241}
242
243/** Opens a link in the person's browser, the way their system does it. */
244async function openInBrowser($: EngineInterface, url: string): Promise<boolean> {
245 const windows = (await $.env.get('OS')) === 'Windows_NT'
246 const tries = windows ? [['rundll32', 'url.dll,FileProtocolHandler', url]] : [['open', url], ['xdg-open', url]]
247 for (const argv of tries) {
248 try {
249 const done = await $.process.run(argv, { timeoutMs: 10000 })
250 if (done.exitCode === 0) return true
251 } catch { /* try the next opener */ }
252 }
253 return false
254}
255
256export const register: Register = (on, options) => {
257 const autoOpen = options.autoOpen === true
258 showTicker = options.ticker === true
259 MODEL = typeof options.model === 'string' && options.model ? options.model : 'haiku'
260 adults = options.nightclub === true
261
262 on('session.start', async ($, e, next) => {
263 await $.command.register({
264 name: 'village',
265 description: 'Open the cozy village; "/village web" opens it in your browser, "/village stop" stops it for good, "/village memory" shows what everyone remembers, "/village forget" wipes it',
266 argumentHint: '[web|stop|memory|forget]',
267 })
268 server.session = await $.session.id()
269 const repo = await $.session.repo().catch(() => null)
270 const cwd = (await $.session.cwd()).replaceAll('\\', '/')
271 server.repo = (repo?.root ?? cwd).replaceAll('\\', '/').split('/').filter(Boolean).pop() ?? ''
272 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 }).catch(() => null)
273 server.branch = branch?.exitCode === 0 ? branch.stdout.trim() : ''
274
275 // every session joins a running village, pane or not: it reports its work and helps with model calls
276 $.clock.every(LINK_MS, async () => {
277 if (!server.port) await connect($, false)
278 else await runJob($)
279 })
280 if (autoOpen || showTicker) startClock($)
281 void connect($, autoOpen || showTicker)
282 if (autoOpen) void open($)
283 if (!showTicker) $.ui.status(undefined)
284 return next(e)
285 })
286
287 on('session.end', async ($, e, next) => {
288 if (server.port) await api($, 'POST', '/api/bye', { session: server.session })
289 return next(e)
290 }).catch(($, e, next) => next(e))
291
292 // the village mirrors the real session: tools become chores, finished turns get a cheer
293 on('tool.call', async ($, e, next) => {
294 if (e.agentId) return next(e)
295 report($, { type: 'tool', tool: String(e.tool) })
296 const result = await next(e)
297 const command = String((e as { input?: { command?: unknown } }).input?.command ?? '')
298 if (/bash|powershell/i.test(String(e.tool)) && TESTS.test(command)) {
299 report($, { type: 'tests', passed: !(result as { isError?: boolean }).isError })
300 }
301 return result
302 }).catch(($, e, next) => next(e)) // the village only watches: whatever goes wrong here, the tool call runs untouched
303
304 on('turn.complete', async ($, e, next) => {
305 if (!e.agentId) report($, { type: 'turn', seconds: e.durationMs / 1000, outcome: e.reason === 'answer' ? 'answer' : e.reason === 'aborted' ? 'aborted' : 'error' })
306 return next(e)
307 })
308
309 on('command.run', { command: 'village' }, async ($, e) => {
310 const arg = e.args.trim().toLowerCase()
311 if (arg === 'stop') {
312 stopClock($)
313 await api($, 'POST', '/api/stop')
314 server.port = 0
315 await update($, webLink, () => '')
316 await $.ui.close({ id: PANE })
317 return { text: 'The village is stopped in every session: nothing runs in the background now. /village starts it again.' }
318 }
319 if (!(await connect($, true))) return { text: 'The village did not start. It needs Node.js 22.18 or newer on this computer.' }
320 if (arg === 'memory') {
321 const res = await api($, 'GET', '/api/memory')
322 return { text: res?.ok ? (JSON.parse(res.text) as { text: string }).text : 'The village did not answer.' }
323 }
324 if (arg === 'web') {
325 startClock($)
326 const url = await read($, webLink)
327 const opened = await openInBrowser($, url)
328 return { text: opened ? `The village is open in your browser: ${url}` : `Open this link in your browser: ${url}` }
329 }
330 if (arg === 'forget') {
331 await api($, 'POST', '/api/forget')
332 return { text: 'Everyone in the village forgot everything. Fresh start!' }
333 }
334 startClock($)
335 await open($, true)
336 return { text: 'Welcome to the village! Type a wish into the field below the map.' }
337 })
338
339 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
340 surface = e.surface
341 viewCols = Math.max(24, Math.min(MAX_VIEW, e.props.bodyColumns))
342 startClock($)
343 const lines = await read($, log)
344 const isThinking = await read($, thinking)
345 const current = await read($, tab)
346 const panels = await read($, info)
347 const place = await read($, museum)
348 const desks = await read($, office)
349 // map, tab bar, the panel's frame and the framed input take the rest of the rows
350 const room = Math.max(3, Math.min(10, e.props.scroll.bodyRows - ROWS - 7))
351 const width = viewCols
352 const { Box, Text, Button } = $.ui.resolve(e)
353
354 const header = (title: string, extra?: string) => (
355 <Box flexDirection="row" columnGap={1}>
356 <Text bold color={ACCENT}>{title.toUpperCase()}</Text>
357 {extra ? <Text dimColor>{extra}</Text> : null}
358 </Box>
359 )
360
361 // ---- the tab bar: the active tab is a filled chip, the others are buttons to click ----
362 const tabs = (
363 <Box flexDirection="row" justifyContent="space-between" width={width}>
364 <Box flexDirection="row" columnGap={2}>
365 {TABS.map(t => (t.id === current
366 ? <Text bold color={INK} backgroundColor={ACCENT}>{` ${t.label} `}</Text>
367 : <Button key={`tab-${t.id}`} plain label={` ${t.label} `} dimColor onPress={async () => {
368 await update($, tab, () => t.id)
369 pane.polls = 0 // the next poll brings the panel's data
370 }} />))}
371 </Box>
372 <Box flexDirection="row" columnGap={2}>
373 {isThinking ? <Text italic color={ACCENT}>Claude is thinking…</Text> : null}
374 <Button
375 key="postcard"
376 plain
377 label="Postcard"
378 dimColor
379 onPress={async press => {
380 const res = await api($, 'GET', `/api/postcard?${q({ cols: viewCols })}`)
381 if (!res?.ok) { $.ui.toast('The village is not running.'); return }
382 const copied = await $.ui.copy({ text: (JSON.parse(res.text) as { text: string }).text, surface: press.surface })
383 $.ui.toast(copied.isCopied ? 'Postcard copied to the clipboard!' : 'Could not copy the postcard here.')
384 }}
385 />
386 <Button key="office" plain label={desks.inside ? 'Leave office' : `Office (${desks.staff})`} dimColor onPress={() => act($, { kind: 'office' })} />
387 {place.open ? (
388 <Button key="museum" plain label={place.inside ? 'Leave museum' : 'Museum tour'} dimColor onPress={() => act($, { kind: 'museum' })} />
389 ) : null}
390 </Box>
391 </Box>
392 )
393
394 // ---- one-key chores, no model call needed ----
395 const quick = (
396 <Box flexDirection="row" columnGap={2} width={width}>
397 <Text dimColor>Quick:</Text>
398 {Object.entries(CHORES).map(([key, c]) => <Button key={`quick-${key}`} plain label={c.label} dimColor onPress={() => act($, { kind: 'chore', key })} />)}
399 </Box>
400 )
401
402 // ---- the three panels ----
403 const chatRows = lines.slice(-room).map(line => (line.who === 'event'
404 ? <Text dimColor italic wrap="truncate">{`${' '.repeat(NAME_WIDTH)} ${line.text}`}</Text>
405 : (
406 <Box flexDirection="row">
407 <Box width={NAME_WIDTH + 1} flexShrink={0}><Text bold color={COLORS[line.who] ?? '#cccccc'}>{NAMES[line.who] ?? line.who}</Text></Box>
408 <Text wrap="truncate">{line.text}</Text>
409 </Box>
410 )))
411 if (chatRows.length === 0) chatRows.push(<Text dimColor italic>Nothing has happened yet. Tell Claude what to do below.</Text>)
412
413 const loading = [<Text dimColor italic>Asking the village…</Text>]
414 const memoryRows = panels ? panels.people.flatMap(p => [
415 <Box flexDirection="row">
416 <Box width={NAME_WIDTH + 1} flexShrink={0}><Text bold color={p.color}>{p.name}</Text></Box>
417 <Box width={6} flexShrink={0}><Text color="#f48fb1">{p.hearts === null ? '' : `♥ ${p.hearts}`}</Text></Box>
418 <Text italic wrap="truncate">{p.diary}</Text>
419 </Box>,
420 ...(p.facts.length ? [<Text dimColor wrap="truncate">{`${' '.repeat(NAME_WIDTH + 7)}knows: ${p.facts.slice(-2).join(' · ')}`}</Text>] : []),
421 ]) : loading
422
423 const projectRows = (panels?.projects ?? []).map(p => (
424 <Box flexDirection="row" columnGap={1}>
425 <Box width={16} flexShrink={0}><Text>{p.name}</Text></Box>
426 {p.done
427 ? <Text color="#81c784">✓ built</Text>
428 : <><Text color="#81c784">{bar(Math.round(p.pct * 100), 100)}</Text><Text dimColor wrap="truncate">{`${p.bells}/${p.need} Bells · ${p.items.map(i => `${i.have}/${i.need} ${i.name}`).join(', ')}`}</Text></>}
429 </Box>
430 ))
431 const museumRows = panels?.museum.open
432 ? panels.museum.sections.map(s => (
433 <Box flexDirection="row" columnGap={1}>
434 <Box width={16} flexShrink={0}><Text>{s.name[0].toUpperCase() + s.name.slice(1)}</Text></Box>
435 <Text color="#4fc3f7">{bar(s.have, s.total)}</Text>
436 <Text dimColor>{`${s.have}/${s.total}`}</Text>
437 </Box>
438 ))
439 : [<Text dimColor>Not built yet - fund it on the plaza board.</Text>]
440 const requestRows = panels?.requests.length
441 ? panels.requests.map(r => (
442 <Box flexDirection="row" columnGap={1}>
443 <Box width={NAME_WIDTH} flexShrink={0}><Text bold color={r.color}>{r.who}</Text></Box>
444 {r.done
445 ? <Text color="#81c784">✓ done</Text>
446 : <Text>{`wants ${r.left} ${r.item}`}<Text dimColor>{` · pays ${r.reward} Bells`}</Text></Text>}
447 </Box>
448 ))
449 : [<Text dimColor>No requests today.</Text>]
450 const officeRows = panels?.office.length
451 ? panels.office.map(a => (
452 <Box flexDirection="row" columnGap={1}>
453 <Box width={NAME_WIDTH + 2} flexShrink={0}><Text bold color={a.color}>{a.name}</Text></Box>
454 <Box width={8} flexShrink={0}><Text color={DESK_STATUS[a.status][1]}>{DESK_STATUS[a.status][0]}</Text></Box>
455 <Text dimColor wrap="truncate">{`${a.repo || 'no repo'}${a.branch ? ` · ${a.branch}` : ''}${a.tool ? ` · ${a.tool}` : ''}`}</Text>
456 </Box>
457 ))
458 : [<Text dimColor>Nobody at work: no Claude Code sessions are open.</Text>]
459 const villageRows = panels ? [
460 header('Office', 'one apprentice per open session'), ...officeRows,
461 header('Requests'), ...requestRows,
462 header('Projects', 'fund them at the plaza board'), ...projectRows,
463 header('Museum', `${panels.museum.sections.reduce((n, s) => n + s.have, 0)} pieces`), ...museumRows,
464 ] : loading
465
466 const panelRows = current === 'memories' ? memoryRows : current === 'village' ? villageRows : chatRows
467 const panel = (
468 <Box key={`panel-${current}`} flexDirection="column" borderStyle="round" borderColor="#6d5a45" paddingX={1} width={width}
469 height={current === 'chat' ? room + 2 : undefined}>
470 {current === 'chat' ? panelRows : panelRows.slice(0, Math.max(room, 8) + 6)}
471 </Box>
472 )
473
474 const placeholder = isThinking ? 'Claude is thinking…' : 'Tell Claude what to do, e.g. "buy pumpkin seeds and plant them"'
475 const submit = async (value: string) => {
476 const text = value.trim()
477 if (text) await act($, { kind: 'wish', text })
478 }
479
480 if (e.surface === 'terminal') {
481 const { Raster, Input } = $.ui.resolve(e)
482 // until the server's first picture arrives, a blank one of the right size
483 const cells = pane.cells && pane.cols === viewCols ? pane.cells : blank(viewCols)
484 return (
485 <Box flexDirection="column">
486 <Raster key="world" columns={viewCols} rows={ROWS} cells={cells} />
487 {tabs}
488 {panel}
489 {quick}
490 <Box borderStyle="round" borderColor={ACCENT} paddingX={1} width={width}>
491 <Input key="wish" label="✎ " placeholder={placeholder} submitLabel="send" autoFocus onSubmit={submit} />
492 </Box>
493 </Box>
494 )
495 }
496 // desktop and mobile: the village itself shows in the browser, where it can animate without reloading
497 if (!server.port) void connect($, true)
498 const url = await read($, webLink)
499 const { Link } = $.ui.resolve(e)
500 const browser = (
501 <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} width={width}>
502 <Text bold color={ACCENT}>🏡 The village lives in your browser</Text>
503 <Text dimColor>{url ? 'Pixel art, walking villagers, day and night. Open the link in the Browser pane (Ctrl+Shift+B) or your own browser; this pane keeps the chat and your wishes.' : 'Starting the village… (it needs Node.js 22.18+)'}</Text>
504 {url ? <Link href={url} label={url} /> : null}
505 {url ? (
506 <Box flexDirection="row" columnGap={2}>
507 <Button key="open-browser" label="Open in my browser" onPress={async () => {
508 if (!(await openInBrowser($, url))) $.ui.toast('Could not open a browser here; copy the link instead.')
509 }} />
510 <Button key="copy-link" plain dimColor label="Copy link" onPress={async press => {
511 const copied = await $.ui.copy({ text: url, surface: press.surface })
512 $.ui.toast(copied.isCopied ? 'Link copied!' : url)
513 }} />
514 </Box>
515 ) : null}
516 </Box>
517 )
518 if (e.surface === 'mobile') return <Box flexDirection="column">{browser}{tabs}{panel}{quick}</Box>
519 const { Input } = $.ui.resolve(e)
520 return (
521 <Box flexDirection="column">
522 {browser}
523 {tabs}
524 {panel}
525 {quick}
526 <Box borderStyle="round" borderColor={ACCENT} paddingX={1}>
527 <Input key="wish" label="✎ " placeholder={placeholder} submitLabel="send" onSubmit={submit} />
528 </Box>
529 </Box>
530 )
531 })
532}
533hooks/game.ts 3187 lines1// The village simulation: map, villagers, their memories, the action queue and the frame drawn into a Raster.
2// Pure logic, no `$`: register.tsx drives it.
3
4import type { LogLine } from '../types'
5import {
6 BUGS, CROPS, DECOR, FISH_KINDS, FISH_SIZE, FOSSILS, isCrop, isFish, isProject, isSellable, KEEPSAKES, MUSEUM, MUSEUM_PIECES, MUSEUM_REWARD,
7 PRICES, PROJECTS, RECIPES, SEASON_DAYS, SEASON_FESTIVALS, SEASONAL_FISH, SEASONS, SECTION_REWARD, SHOP, WANTS,
8} from './catalog'
9import type { BugHabitat, CropKind, ProjectId, Season } from './catalog'
10import { CURATOR_SPOT, ENTRANCE, FACTS, hint, IH, indoorPath, IW, museumCell, viewSpot } from './museum'
11import { deskStatus, LOOKS, OFFICE_ENTRANCE, officeCell, officePath, OH, OW, runOffice, visitSpot, workLine } from './office'
12import type { Office } from './office'
13
14export const W = 96
15export const H = 24
16export const VIEW_H = 18
17/** HUD row + visible map rows + bag row. */
18export const ROWS = VIEW_H + 2
19export const MAX_VIEW = 80
20
21export type Pt = { x: number; y: number }
22type Tile =
23 | 'grass' | 'tuft' | 'path' | 'plaza' | 'bridge' | 'water' | 'fountain' | 'tree' | 'apple'
24 | 'roof' | 'wall' | 'door' | 'fence' | 'soil' | 'flower' | 'item'
25type Crop = { stage: number; watered: boolean; kind?: CropKind }
26type Bubble = { text: string; ticks: number }
27type Mover = Pt & { path: Pt[]; bubble?: Bubble }
28type Npc = Mover & { hold: number; cooldown: number; nap: number; chat: number; asleep: boolean }
29
30export type VillagerId = 'tom' | 'rosa' | 'bruno' | 'pip' | 'mabel'
31type Who = VillagerId | 'mochi' | 'kiki'
32type Villager = {
33 id: VillagerId
34 name: string
35 color: number
36 home: string
37 hangout: string
38 likes: string[]
39 personality: string
40 lines: string[]
41}
42
43export const VILLAGERS: readonly Villager[] = [
44 {
45 id: 'tom', name: 'Tom', color: 0xff8a65, home: 'tomhouse', hangout: 'pond', likes: ['sea bass', 'koi', 'carp', 'bluegill', 'goldfish'],
46 personality: 'easygoing fisherman next door, tells tall fishing stories',
47 lines: ['Nice weather for fishing, yes?', 'I almost caught a koi this big!', 'Hello, neighbor!', 'The pond is calm today.'],
48 },
49 {
50 id: 'rosa', name: 'Rosa', color: 0xf48fb1, home: 'florist', hangout: 'flowers', likes: ['flower'],
51 personality: 'dreamy florist who talks about colors, scents and seasons',
52 lines: ['The tulips smell lovely today.', 'Every flower has a story, you know.', 'Pink is the color of the week!', 'Hi there, sunshine!'],
53 },
54 {
55 id: 'bruno', name: 'Bruno', color: 0xbcaaa4, home: 'workshop', hangout: 'bridge', likes: ['wood', 'apple'],
56 personality: 'gruff but kind carpenter, always planning the next build',
57 lines: ['That bridge needs a new plank.', 'Hmph. Good wood is hard to find.', 'Measure twice, cut once.', 'Oh. Hey.'],
58 },
59 {
60 id: 'pip', name: 'Pip', color: 0x81c784, home: 'piphouse', hangout: 'orchard', likes: ['apple', 'goldfish', 'old boot'],
61 personality: 'energetic kid who loves bugs, games, apples and silly jokes',
62 lines: ['Tag, you are it!', 'I found a beetle! Wanna see?', 'Apples are the best food ever!', 'Hehe, hi Claude!'],
63 },
64 {
65 id: 'mabel', name: 'Mabel', color: 0xce93d8, home: 'cafe', hangout: 'plaza', likes: ['turnip', 'apple', 'flower'],
66 personality: 'warm old cafe owner who bakes turnip pie and remembers everyone',
67 lines: ['Come by the cafe for pie, dear.', 'You look thin, have you eaten?', 'Back in my day this was all fields.', 'Hello, sweetheart!'],
68 },
69]
70const IDS = VILLAGERS.map(v => v.id)
71const villager = (id: VillagerId) => VILLAGERS.find(v => v.id === id)!
72
73type WorkKind =
74 | 'plant' | 'water' | 'harvest' | 'fish' | 'chop' | 'shake' | 'pick' | 'sell' | 'sleep' | 'pet' | 'gift' | 'buy' | 'trade'
75 | 'shop' | 'cook' | 'fund' | 'donate' | 'place' | 'dig' | 'club' | 'pedal' | 'lock' | 'unlock'
76export type Action =
77 | { do: 'walk'; to: string }
78 | { do: 'say'; text: string }
79 | { do: 'npc_say'; who: Who; text: string }
80 | { do: 'wait'; seconds: number }
81 | { do: 'gift'; to: Who; item?: string; count?: number }
82 | { do: 'remember'; who: 'claude' | VillagerId; text: string }
83 | { do: 'search' }
84 | { do: 'plant'; crop?: CropKind }
85 | { do: 'shop'; item: string; count: number }
86 | { do: 'cook'; recipe: string; count: number }
87 | { do: 'fund'; project: ProjectId }
88 | { do: 'donate' }
89 | { do: 'place'; item: string; at?: string }
90 | { do: 'catch'; bug?: string; count?: number }
91 | { do: 'mail' }
92 | { do: 'enter' }
93 | { do: 'tour' }
94 | { do: 'look'; item: string }
95 | { do: 'exit' }
96 | { do: 'office' }
97 | { do: Exclude<WorkKind, 'gift' | 'plant' | 'shop' | 'cook' | 'fund' | 'donate' | 'place'>; count?: number }
98
99export type EventKind = 'rain' | 'merchant' | 'festival' | 'lost' | 'tournament' | 'meteor' | 'birthday' | 'season'
100/** A happening of the day, from `start` to `end` in minutes after midnight. */
101export type VillageEvent = {
102 kind: EventKind
103 start: number
104 end: number
105 started?: boolean
106 ended?: boolean
107 who?: VillagerId
108 item?: string
109 best?: string
110 attended?: boolean
111 /** The biggest fish of a contest, in cm. */
112 size?: number
113}
114type Pickup = Pt & { item: string; kind: 'lost' | 'star' }
115/** A villager asking for `amount` of an item today; `fish` means any fish. */
116export type Request = { who: VillagerId; item: string; amount: number; given: number; reward: number; done?: boolean }
117type Project = { bells: number; items: Record<string, number>; done: boolean }
118/** `locked`: Claude has chained up the bikes, so nobody can pedal until he unlocks them. */
119export type DataCenter = { battery: number; evil: boolean; duty: boolean; warned: boolean; locked: boolean }
120type Critter = Pt & { kind: string; color: number; ttl: number; home: Pt }
121export type Letter = { from: VillagerId; text: string; item?: string; count?: number; bells?: number; day: number; read?: boolean }
122type Step =
123 | Action
124 | { do: '_goto'; x: number; y: number; npc?: Who }
125 | { do: '_work'; kind: WorkKind; x: number; y: number; ticks: number; item?: string }
126 | { do: '_hunt'; bug?: string; tries?: number }
127 | { do: '_enter' }
128 | { do: '_exit' }
129 | { do: '_iwalk'; x: number; y: number }
130 | { do: '_look'; piece: string }
131 | { do: '_office' }
132 | { do: '_chat'; id: string }
133
134export type Memory = { day: number; time: string; text: string; kind: 'event' | 'fact' | 'diary' }
135
136export type World = {
137 tiles: Tile[]
138 deco: Record<number, [string, number]>
139 paint: Record<number, [number, number]>
140 crops: (Crop | null)[]
141 fruit: Record<number, number>
142 picked: number[]
143 places: Record<string, Pt>
144 player: Mover
145 npcs: Record<VillagerId, Npc>
146 mochi: Npc
147 queue: Step[]
148 current?: Step & { started?: boolean; left?: number }
149 idle: number
150 day: number
151 minutes: number
152 bells: number
153 inv: Record<string, number>
154 friendship: Record<string, number>
155 memory: Record<string, Memory[]>
156 diaryDue: number[]
157 /** Conversations the diary call wrote for villagers to have, two lines each. */
158 chatPool: { a: VillagerId; b: VillagerId; lines: [string, string] }[]
159 /** Lines someone says a little later: the second half of a conversation. */
160 later: { who: VillagerId; text: string; at: number }[]
161 gazette: { day: number; headlines: string[] } | null
162 /** How each villager feels today, -2 (sulking) to 2 (beaming). */
163 mood: Record<string, number>
164 /** The biggest of each fish Claude ever caught, in cm. */
165 records: Record<string, number>
166 /** What the real Claude Code session is doing, mirrored in the village. */
167 echo: { kind: 'edit' | 'shell' | 'read' | 'web' | 'agent' | 'other'; tool: string; until: number } | null
168 /** A villager just spoke to Claude: the replies on offer until `until`. */
169 reply: PendingReply | null
170 /** A villager asked to say more: the host asks the model for their answer. */
171 askMore: { who: VillagerId; line: string } | null
172 /** The day each villager last got a friendly reply, so replies cannot be farmed. */
173 replied: Record<string, number>
174 /** Whether the adults-only Velvet Club is part of this village (a setting). */
175 adults: boolean
176 cheer: number
177 cloud: number
178 /** Ticks left of the bomb factory's blast animation. */
179 boom: number
180 /** Ticks until Bruno has patched up the bomb factory after a blast. */
181 rubble: number
182 /** The AI data center: its battery (0-100), whether it has turned evil, and whether villagers are on pedal duty. */
183 dc: DataCenter
184 rainbow: number
185 flash: number
186 events: VillageEvent[]
187 weather: 'clear' | 'rain'
188 merchant?: Npc
189 pickups: Pickup[]
190 projects: Record<ProjectId, Project>
191 museum: string[]
192 decor: (Pt & { item: string })[]
193 requests: Request[]
194 critters: Critter[]
195 digSpots: Pt[]
196 mail: Letter[]
197 /** The friendship tier each villager has written to Claude about. */
198 milestones: Record<string, number>
199 /** Where Claude is: out in the village, or inside the museum or the office (`inside` is the position there). */
200 scene: 'village' | 'museum' | 'office'
201 /** The office's apprentices, one per connected Claude Code session; live, never saved. */
202 office: Office
203 inside: Mover
204 curator: Mover
205 /** Headlines for the host to show as toasts. */
206 announce: string[]
207 tick: number
208 thinking: boolean
209 label: string
210 log: LogLine[]
211}
212
213const idx = (x: number, y: number) => y * W + x
214const inMap = (x: number, y: number) => x >= 0 && y >= 0 && x < W && y < H
215const WALKABLE = new Set<Tile>(['grass', 'tuft', 'path', 'plaza', 'bridge', 'soil', 'flower'])
216const walkable = (w: World, x: number, y: number) => inMap(x, y) && WALKABLE.has(w.tiles[idx(x, y)])
217const dist = (a: Pt, b: Pt) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
218const pick = <T>(list: readonly T[]): T => list[Math.floor(Math.random() * list.length)]
219const isVillager = (v: unknown): v is VillagerId => IDS.includes(v as VillagerId)
220
221const PLACE_SPOTS: Record<string, Pt> = {
222 home: { x: 12, y: 6 }, shop: { x: 40, y: 6 }, farm: { x: 8, y: 13 }, pond: { x: 68, y: 19 },
223 orchard: { x: 23, y: 5 }, flowers: { x: 66, y: 7 }, plaza: { x: 50, y: 11 }, cafe: { x: 40, y: 15 },
224 river: { x: 55, y: 13 }, bridge: { x: 61, y: 9 }, workshop: { x: 84, y: 6 }, florist: { x: 67, y: 6 },
225 tomhouse: { x: 20, y: 15 }, piphouse: { x: 82, y: 15 }, board: { x: 47, y: 12 }, museum: { x: 27, y: 15 }, club: { x: 88, y: 15 },
226 factory: { x: 68, y: 15 }, datacenter: { x: 37, y: 19 }, office: { x: 50, y: 6 },
227}
228const APPLE_TREES = [[20, 3], [23, 2], [26, 4], [21, 6], [25, 7], [28, 2], [88, 20], [91, 21], [86, 13]]
229const TREES = [
230 [3, 20], [5, 21], [7, 20], [9, 22], [11, 21], [13, 20], [4, 22], [15, 22], [89, 19], [92, 20], [93, 22],
231 [34, 7], [74, 4], [90, 13], [27, 12], [33, 21], [45, 21], [74, 14], [62, 13], [2, 4], [93, 6],
232]
233const FLOWERS = [
234 [62, 6], [63, 7], [65, 6], [64, 7], [69, 6], [70, 7], [72, 6], [73, 7], [71, 6],
235 [20, 20], [22, 21], [24, 20], [26, 21], [46, 7], [54, 7], [78, 20], [80, 21], [36, 20], [38, 21],
236]
237const WORK_TEXT: Record<WorkKind, string> = {
238 plant: 'dig dig', water: 'splish~', harvest: 'pluck!', fish: '~ fishing ~', chop: 'chop! chop!',
239 shake: 'shake shake', pick: '♪', sell: 'ka-ching', sleep: 'Zzz...', pet: 'pat pat', gift: 'here you go!', buy: 'hmm, this one!', trade: 'deal?',
240 shop: 'let me see...', cook: 'smells great!', fund: 'for the village!', donate: 'for science!', place: 'right there!', dig: 'dig dig dig', club: '♪ ♫ ♪', pedal: 'pedal pedal!', lock: 'click!', unlock: 'click-clack',
241}
242const LABELS: Record<WorkKind, string> = {
243 plant: 'planting', water: 'watering', harvest: 'harvesting', fish: 'fishing', chop: 'chopping wood',
244 shake: 'shaking a tree', pick: 'picking flowers', sell: 'shopping', sleep: 'sleeping', pet: 'petting Mochi', gift: 'giving a gift', buy: 'shopping with Kiki', trade: 'trading with Kiki',
245 shop: 'shopping', cook: 'cooking with Mabel', fund: 'funding a project', donate: 'at the museum', place: 'decorating', dig: 'digging', club: 'at the Velvet Club', pedal: 'pedaling for the data center', lock: 'locking the bikes', unlock: 'unlocking the bikes',
246}
247const IDLE_LINES = [
248 'What a lovely day!', 'I should water the turnips...', 'Hmm hm hmm ♪', 'Mochi! Where are you?',
249 'The river sparkles today.', 'I wonder what everyone is up to.', 'Smells like apples around here.',
250]
251const NIGHT_LINES = ['The stars are pretty tonight.', '*yawn*', 'Getting sleepy...']
252const THANKS = ['Wow, for me?! Thanks!', 'Oh! How thoughtful!', "You're the best!", 'Aww, thank you!']
253const REPLIES = ['Oh, hello!', 'Fancy meeting you here.', 'Lovely day, isn\'t it?', 'Hehe, hi!', 'Good to see you!']
254
255// ---------- the map ----------
256
257export function createWorld(): World {
258 const tiles: Tile[] = Array(W * H).fill('grass')
259 const deco: World['deco'] = {}
260 const paint: World['paint'] = {}
261 const set = (x: number, y: number, t: Tile) => { if (inMap(x, y)) tiles[idx(x, y)] = t }
262 const rect = (x0: number, y0: number, x1: number, y1: number, t: Tile) => {
263 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) set(x, y, t)
264 }
265 const building = (x0: number, y0: number, x1: number, doorX: number, roof: number, wall: number, sign?: string) => {
266 rect(x0, y0, x1, y0 + 1, 'roof')
267 rect(x0, y0 + 2, x1, y0 + 3, 'wall')
268 for (let y = y0; y <= y0 + 3; y++) for (let x = x0; x <= x1; x++) paint[idx(x, y)] = y < y0 + 2 ? [roof, roof] : [0, wall]
269 set(doorX, y0 + 3, 'door')
270 if (sign) {
271 const sx = Math.round((x0 + x1 + 1 - sign.length) / 2)
272 for (let i = 0; i < sign.length; i++) deco[idx(sx + i, y0 + 2)] = [sign[i], 0x5d4037]
273 } else {
274 deco[idx(x0 + 1, y0 + 2)] = ['■', 0x81d4fa]
275 deco[idx(x1 - 1, y0 + 2)] = ['■', 0x81d4fa]
276 }
277 }
278
279 for (let i = 0; i < W * H; i++) if ((Math.imul(i, 2654435761) >>> 0) % 97 < 9) tiles[i] = 'tuft'
280 rect(0, 0, W - 1, 0, 'tree'); rect(0, H - 1, W - 1, H - 1, 'tree')
281 rect(0, 0, 0, H - 1, 'tree'); rect(W - 1, 0, W - 1, H - 1, 'tree')
282
283 // roads, river and bridges
284 rect(2, 9, 93, 9, 'path'); rect(2, 18, 93, 18, 'path')
285 rect(3, 9, 3, 18, 'path'); rect(31, 9, 31, 18, 'path'); rect(76, 9, 76, 18, 'path'); rect(92, 9, 92, 18, 'path')
286 rect(57, 1, 59, 22, 'water')
287 rect(57, 9, 59, 9, 'bridge'); rect(57, 18, 59, 18, 'bridge')
288
289 // buildings and the paths to their doors
290 building(8, 2, 16, 12, 0xb03a2e, 0xf5e6c8); rect(12, 6, 12, 8, 'path')
291 building(36, 2, 45, 40, 0x4a90d9, 0xfff3d6, 'SHOP'); rect(40, 6, 40, 8, 'path')
292 building(17, 11, 23, 20, 0x558b2f, 0xf1f8e9); rect(20, 15, 20, 17, 'path')
293 building(36, 11, 44, 40, 0x8e44ad, 0xfdf2e9, 'CAFE'); rect(40, 15, 40, 17, 'path')
294 building(63, 2, 71, 67, 0xd81b60, 0xfce4ec, 'FLOWERS'); rect(67, 6, 67, 8, 'path')
295 building(80, 2, 89, 84, 0x6d4c41, 0xefebe9, 'WORKSHOP'); rect(84, 6, 84, 8, 'path')
296 building(79, 11, 85, 82, 0xef6c00, 0xfff8e1); rect(82, 15, 82, 17, 'path')
297 building(47, 2, 54, 50, 0x455a64, 0xeceff1, 'OFFICE')
298
299 // farm, plaza, pond
300 rect(5, 11, 12, 15, 'fence'); rect(6, 12, 11, 14, 'soil'); set(8, 15, 'path'); rect(8, 16, 8, 17, 'path')
301 rect(46, 11, 54, 15, 'plaza'); set(50, 13, 'fountain'); rect(50, 6, 50, 10, 'path'); rect(50, 16, 50, 17, 'path')
302 for (let y = 19; y <= 22; y++) {
303 for (let x = 60; x <= 76; x++) if (((x - 68) / 6.5) ** 2 + ((y - 21) / 1.8) ** 2 <= 1) set(x, y, 'water')
304 }
305 for (const [x, y] of TREES) set(x, y, 'tree')
306 for (const [x, y] of APPLE_TREES) set(x, y, 'apple')
307 for (const [x, y] of FLOWERS) if (tiles[idx(x, y)] === 'grass' || tiles[idx(x, y)] === 'tuft') set(x, y, 'flower')
308
309 // the mailbox by Claude's house
310 set(MAILBOX.x, MAILBOX.y, 'item'); deco[idx(MAILBOX.x, MAILBOX.y)] = ['Π', 0xe53935]; paint[idx(MAILBOX.x, MAILBOX.y)] = [0, GRASS]
311 // the community project board at the plaza
312 set(47, 11, 'item'); deco[idx(47, 11)] = ['¶', 0x795548]; paint[idx(47, 11)] = [0, 0xd7ccc8]
313
314 const fruit: Record<number, number> = {}
315 for (const [x, y] of APPLE_TREES) fruit[idx(x, y)] = 3
316 const crops: (Crop | null)[] = Array(W * H).fill(null)
317 for (const [x, y] of [[6, 12], [7, 12], [8, 13]]) crops[idx(x, y)] = { stage: 2, watered: false, kind: 'turnip' }
318
319 const w: World = {
320 tiles, deco, paint, crops, fruit, picked: [], places: {},
321 player: { x: 12, y: 6, path: [] },
322 npcs: {} as Record<VillagerId, Npc>,
323 mochi: { x: 14, y: 7, path: [], hold: 0, cooldown: 0, nap: 0, chat: 0, asleep: false },
324 queue: [], idle: 0, day: 1, minutes: 8 * 60, bells: 100, inv: { turnip: 1 },
325 friendship: Object.fromEntries(IDS.map(id => [id, 0])), memory: { claude: [], ...Object.fromEntries(IDS.map(id => [id, []])) },
326 diaryDue: [], chatPool: [], later: [], gazette: null, mood: {}, records: {},
327 echo: null, reply: null, askMore: null, replied: {}, adults: false, cheer: 0, cloud: 0, boom: 0, rubble: 0, dc: { battery: 80, evil: false, duty: false, warned: false, locked: false }, rainbow: 0, flash: 0, events: [], weather: 'clear', pickups: [], announce: [],
328 projects: Object.fromEntries(Object.keys(PROJECTS).map(id => [id, { bells: 0, items: {}, done: false }])) as Record<ProjectId, Project>,
329 museum: [], decor: [], requests: [], critters: [], digSpots: [], mail: [], milestones: {},
330 scene: 'village', inside: { ...ENTRANCE, path: [] }, curator: { ...CURATOR_SPOT, path: [] }, office: { staff: [] },
331 tick: 0, thinking: false, label: 'strolling', log: [],
332 }
333 for (const [name, spot] of Object.entries(PLACE_SPOTS)) w.places[name] = nearestWalkable(w, spot)
334 for (const v of VILLAGERS) {
335 const at = w.places[v.home]
336 w.npcs[v.id] = { x: at.x, y: at.y, path: [], hold: 0, cooldown: 60 + Math.floor(Math.random() * 200), nap: 0, chat: 200, asleep: false }
337 }
338 scheduleDay(w)
339 return w
340}
341
342function nearestWalkable(w: World, p: Pt): Pt {
343 for (let r = 0; r < 10; r++) {
344 for (let dy = -r; dy <= r; dy++) {
345 for (let dx = -r; dx <= r; dx++) {
346 if (Math.abs(dx) + Math.abs(dy) === r && walkable(w, p.x + dx, p.y + dy)) return { x: p.x + dx, y: p.y + dy }
347 }
348 }
349 }
350 return p
351}
352
353// ---------- memory ----------
354
355const LIMITS: Record<Memory['kind'], number> = { event: 40, fact: 25, diary: 20 }
356
357export function remember(w: World, who: string, text: string, kind: Memory['kind'] = 'event') {
358 const list = (w.memory[who] ??= [])
359 const clean_ = clean(text, 200)
360 if (!clean_ || list.some(m => m.kind === kind && m.text === clean_ && m.day === w.day)) return
361 list.push({ day: w.day, time: clock(w), text: clean_, kind })
362 const ofKind = list.filter(m => m.kind === kind)
363 if (ofKind.length > LIMITS[kind]) list.splice(list.indexOf(ofKind[0]), 1)
364}
365
366const nameOf = (who: string) =>
367 who === 'claude' ? 'Claude' : who === 'mochi' ? 'Mochi' : who === 'kiki' ? 'Kiki' : villager(who as VillagerId).name
368
369/** Everyone within earshot of `at` remembers a line `speaker` said. */
370function overhear(w: World, speaker: string, at: Pt, text: string) {
371 if (speaker !== 'claude' && dist(w.player, at) <= 5) remember(w, 'claude', `${nameOf(speaker)} said: "${text}"`)
372 for (const id of IDS) {
373 if (id === speaker || w.npcs[id].asleep || dist(w.npcs[id], at) > 5) continue
374 remember(w, id, `${nameOf(speaker)} said: "${text}"`)
375 }
376}
377
378export function memoryText(w: World, who: string, recent: number): string {
379 const list = w.memory[who] ?? []
380 const facts = list.filter(m => m.kind === 'fact').map(m => `- ${m.text}`)
381 const diary = list.filter(m => m.kind === 'diary').slice(-4).map(m => `- Day ${m.day}: ${m.text}`)
382 const events = list.filter(m => m.kind === 'event').slice(-recent).map(m => `- Day ${m.day} ${m.time}: ${m.text}`)
383 return [
384 facts.length ? ` Knows:\n${facts.join('\n')}` : '',
385 diary.length ? ` Diary:\n${diary.join('\n')}` : '',
386 events.length ? ` Recently:\n${events.join('\n')}` : '',
387 ].filter(Boolean).join('\n').replace(/^- /gm, ' - ') || ' (no memories yet)'
388}
389
390/** The prompt that turns a finished day's events into diary entries and lasting facts, or null when nothing happened. */
391export function diaryPrompt(w: World, day: number): string | null {
392 const parts: string[] = []
393 for (const who of ['claude', ...IDS]) {
394 const events = (w.memory[who] ?? []).filter(m => m.kind === 'event' && m.day === day)
395 if (events.length === 0) continue
396 const about = who === 'claude' ? 'the cheerful villager the player guides' : villager(who as VillagerId).personality
397 parts.push(`## ${who} (${nameOf(who)}, ${about})\n${events.map(m => `- ${m.time} ${m.text}`).join('\n')}`)
398 }
399 if (parts.length === 0) return null
400 const writers = IDS.filter(id => (w.friendship[id] ?? 0) >= 3).map(id => villager(id).name)
401 return `Day ${day} in the village just ended. For each character below, write a diary entry in their own voice (1-2 sentences, first person) and 0-2 lasting facts worth remembering for weeks (likes, promises, relationships, what the player told them).
402Answer ONLY with JSON like {"claude":{"diary":"...","facts":["..."]},"tom":{"diary":"...","facts":[]}}, one key per character listed.
403Also add these top-level keys:
404"chats": up to 3 short conversations two villagers (tom, rosa, bruno, pip, mabel) might have tomorrow about today's happenings - gossip, teasing and rumours welcome - as {"a":"tom","b":"rosa","lines":["what a says","what b answers"]}, each line at most 60 characters. Never bring Pip, a child, into anything about the Velvet Club.
405"gazette": 2-3 punchy, funny village newspaper headlines about today, at most 60 characters each.
406"letters": ${writers.length ? `0-2 warm letters to Claude, only from villagers who did something with Claude today, chosen from ${writers.join(', ')}, as {"from":"rosa","text":"at most 200 characters, signed with their name"}` : 'an empty list'}.
407
408${parts.join('\n\n')}`
409}
410
411/** Gossip, letters and the morning paper that ride on the diary call. */
412function readExtras(w: World, day: number, parsed: Record<string, unknown>) {
413 const pipSafe = (lines: string[]) => !lines.some(l => /velvet|club/i.test(l))
414 if (Array.isArray(parsed.chats)) {
415 for (const c of parsed.chats.slice(0, 3) as { a?: unknown; b?: unknown; lines?: unknown }[]) {
416 if (!isVillager(c?.a) || !isVillager(c?.b) || c.a === c.b || !Array.isArray(c.lines) || c.lines.length < 2) continue
417 const lines = [clean(String(c.lines[0]), 90), clean(String(c.lines[1]), 90)] as [string, string]
418 if ((c.a === 'pip' || c.b === 'pip') && !pipSafe(lines)) continue
419 w.chatPool.push({ a: c.a, b: c.b, lines })
420 }
421 w.chatPool = w.chatPool.slice(-8)
422 }
423 if (Array.isArray(parsed.gazette)) {
424 const headlines = parsed.gazette.filter((h): h is string => typeof h === 'string').slice(0, 3).map(h => clean(h, 70))
425 if (headlines.length) {
426 w.gazette = { day, headlines }
427 w.announce.push(`The Village Gazette: ${headlines[0]}`)
428 for (const h of headlines) w.log.push({ who: 'event', text: `Gazette: ${h}` })
429 for (const h of headlines) rememberAll(w, `The Gazette wrote: "${h}"`)
430 }
431 }
432 if (Array.isArray(parsed.letters)) {
433 for (const l of parsed.letters.slice(0, 2) as { from?: unknown; text?: unknown }[]) {
434 if (!isVillager(l?.from) || typeof l.text !== 'string' || (w.friendship[l.from] ?? 0) < 3) continue
435 w.mail.push({ from: l.from, text: clean(l.text, 240), day: w.day })
436 remember(w, l.from, `I wrote Claude a letter: "${clean(l.text, 120)}"`)
437 }
438 }
439}
440
441export const DIARY_SYSTEM = 'You keep the diaries of characters in a cozy village game. Answer only with JSON.'
442
443export function applyDiary(w: World, day: number, reply: string | null) {
444 let parsed: Record<string, { diary?: unknown; facts?: unknown }> = {}
445 if (reply) {
446 const start = reply.indexOf('{')
447 const end = reply.lastIndexOf('}')
448 try { parsed = JSON.parse(reply.slice(start, end + 1)) } catch { parsed = {} }
449 }
450 readExtras(w, day, parsed as Record<string, unknown>)
451 for (const who of ['claude', ...IDS]) {
452 const events = (w.memory[who] ?? []).filter(m => m.kind === 'event' && m.day === day)
453 if (events.length === 0) continue
454 const entry = parsed[who]
455 const diary = typeof entry?.diary === 'string' ? entry.diary : events.slice(0, 3).map(m => m.text).join(' ')
456 w.memory[who].push({ day, time: '23:59', text: clean(diary, 240), kind: 'diary' })
457 if (Array.isArray(entry?.facts)) for (const f of entry.facts.slice(0, 2)) if (typeof f === 'string') remember(w, who, f, 'fact')
458 // the diary now carries that day; keep only the last two days of raw events
459 w.memory[who] = w.memory[who].filter(m => m.kind !== 'event' || m.day > day - 2)
460 const diaries = w.memory[who].filter(m => m.kind === 'diary')
461 if (diaries.length > LIMITS.diary) w.memory[who].splice(w.memory[who].indexOf(diaries[0]), 1)
462 }
463}
464
465/** Every remembered line, for the memory view. */
466export function memoryLines(w: World): LogLine[] {
467 const lines: LogLine[] = []
468 for (const who of ['claude', ...IDS]) {
469 const list = w.memory[who] ?? []
470 const facts = list.filter(m => m.kind === 'fact').slice(-3).map(m => m.text)
471 const diary = list.filter(m => m.kind === 'diary').slice(-1).map(m => `Day ${m.day}: ${m.text}`)
472 const last = list.filter(m => m.kind === 'event').slice(-1).map(m => m.text)
473 const heart = who === 'claude' ? '' : ` ♥${w.friendship[who] ?? 0}`
474 const text = [...diary, ...facts, ...(diary.length ? [] : last)].join(' · ') || 'nothing yet'
475 lines.push({ who, text: `${heart ? heart.trim() + ' ' : ''}${text}` })
476 }
477 return lines
478}
479
480// ---------- persistence ----------
481
482export function snapshot(w: World) {
483 return {
484 version: 2, chatPool: w.chatPool, gazette: w.gazette, mood: w.mood, records: w.records, mail: w.mail, milestones: w.milestones, digSpots: w.digSpots, projects: w.projects, dc: w.dc, museum: w.museum, decor: w.decor, requests: w.requests, events: w.events, weather: w.weather, pickups: w.pickups, hasMerchant: !!w.merchant, crops: w.crops, fruit: w.fruit, picked: w.picked, day: w.day, minutes: w.minutes, bells: w.bells,
485 inv: w.inv, friendship: w.friendship, memory: w.memory, diaryDue: w.diaryDue, player: { x: w.player.x, y: w.player.y },
486 }
487}
488
489export function restore(w: World, raw: unknown) {
490 const s = raw as (Partial<ReturnType<typeof snapshot>> & { friendship?: unknown }) | null
491 if (!s || typeof s !== 'object') return
492 if (typeof s.day === 'number') w.day = s.day
493 if (typeof s.minutes === 'number') w.minutes = s.minutes
494 if (typeof s.bells === 'number') w.bells = s.bells
495 if (s.inv) w.inv = s.inv
496 if (typeof s.friendship === 'number') w.friendship.tom = s.friendship
497 if (s.version !== 2) return
498 if (s.friendship && typeof s.friendship === 'object') Object.assign(w.friendship, s.friendship)
499 if (s.memory) Object.assign(w.memory, s.memory)
500 if (Array.isArray(s.diaryDue)) w.diaryDue = s.diaryDue
501 if (Array.isArray(s.events) && s.events.length) w.events = s.events
502 if (s.weather === 'rain' || s.weather === 'clear') w.weather = s.weather
503 if (Array.isArray(s.pickups)) w.pickups = s.pickups
504 if (s.hasMerchant) spawnMerchant(w)
505 if (Array.isArray(s.crops) && s.crops.length === W * H) w.crops = s.crops
506 if (s.fruit) w.fruit = s.fruit
507 if (Array.isArray(s.picked)) {
508 w.picked = s.picked
509 for (const i of s.picked) w.tiles[i] = 'grass'
510 }
511 if (s.projects) {
512 for (const id of Object.keys(PROJECTS) as ProjectId[]) {
513 if (!s.projects[id]) continue
514 w.projects[id] = s.projects[id]
515 if (w.projects[id].done && (id !== 'club' || w.adults)) applyProject(w, id)
516 }
517 }
518 if (s.dc && typeof s.dc.battery === 'number') w.dc = { ...w.dc, ...s.dc, battery: Math.max(0, Math.min(100, s.dc.battery)) }
519 if (Array.isArray(s.museum)) w.museum = s.museum
520 if (Array.isArray(s.mail)) w.mail = s.mail
521 if (Array.isArray(s.chatPool)) w.chatPool = s.chatPool
522 if (s.gazette) w.gazette = s.gazette
523 if (s.mood) w.mood = s.mood
524 if (s.records) w.records = s.records
525 if (s.milestones) w.milestones = s.milestones
526 if (Array.isArray(s.digSpots)) w.digSpots = s.digSpots
527 if (Array.isArray(s.requests)) w.requests = s.requests
528 if (Array.isArray(s.decor)) for (const d of s.decor) if (placeThing(w, d.x, d.y, ...DECOR[d.item])) w.decor.push(d)
529 if (s.player && walkable(w, s.player.x, s.player.y)) { w.player.x = s.player.x; w.player.y = s.player.y }
530}
531
532// ---------- path finding ----------
533
534function bfs(w: World, from: Pt, goal: (p: Pt) => boolean): Pt[] | null {
535 const prev = new Int32Array(W * H).fill(-2)
536 const start = idx(from.x, from.y)
537 prev[start] = -1
538 const queue = [start]
539 for (let q = 0; q < queue.length; q++) {
540 const cur = queue[q]
541 const p = { x: cur % W, y: Math.floor(cur / W) }
542 if (goal(p)) {
543 const path: Pt[] = []
544 for (let c = cur; c !== start; c = prev[c]) path.unshift({ x: c % W, y: Math.floor(c / W) })
545 return path
546 }
547 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
548 const nx = p.x + dx
549 const ny = p.y + dy
550 if (!walkable(w, nx, ny) || prev[idx(nx, ny)] !== -2) continue
551 prev[idx(nx, ny)] = cur
552 queue.push(idx(nx, ny))
553 }
554 }
555 return null
556}
557
558/** Path to `target`, or next to it when it cannot be stood on. */
559function pathTo(w: World, from: Pt, target: Pt): Pt[] | null {
560 if (walkable(w, target.x, target.y)) return bfs(w, from, p => p.x === target.x && p.y === target.y)
561 return bfs(w, from, p => dist(p, target) === 1)
562}
563
564function nearestTile(w: World, from: Pt, test: (i: number) => boolean): Pt | null {
565 let best: Pt | null = null
566 for (let i = 0; i < W * H; i++) {
567 if (!test(i)) continue
568 const p = { x: i % W, y: Math.floor(i / W) }
569 const isInner = p.x > 0 && p.y > 0 && p.x < W - 1 && p.y < H - 1
570 if (isInner && (!best || dist(from, p) < dist(from, best))) best = p
571 }
572 return best
573}
574
575// ---------- the agent's plan ----------
576
577const CLUB_RULES = `{"do":"club"} spends an evening at the Velvet Club (adults-only strip club, once built, open 21:00-03:00; about 150 Bells)
578Keep anything about the Velvet Club tasteful and non-explicit (music, neon, dancing, chatting at the bar), and never involve Pip, who is a child.`
579
580/** The planner's instructions; the club's lines only where the club is allowed. */
581export const systemPrompt = (w: World) => (w.adults ? `${SYSTEM}\n${CLUB_RULES}` : SYSTEM)
582
583export const SYSTEM = `You are Claude, a cheerful villager in a cozy Animal-Crossing / Stardew-Valley style village.
584The player types a wish; you answer ONLY with a JSON array of actions (max 12), no prose, no code fence.
585Actions:
586{"do":"walk","to":"home|shop|farm|pond|orchard|flowers|plaza|cafe|river|bridge|workshop|florist|tomhouse|piphouse|board|museum|datacenter|office|tom|rosa|bruno|pip|mabel|mochi"}
587{"do":"say","text":"<= 60 chars, shown in your speech bubble"}
588{"do":"npc_say","who":"tom|rosa|bruno|pip|mabel|mochi","text":"<= 60 chars, what that villager answers, in their own voice"}
589{"do":"plant","crop":"turnip|carrot|strawberry|pumpkin"} plants on every empty farm soil; turnip seeds are free, other seeds come from the shop
590{"do":"water"} waters all crops (watered crops grow every day until ripe)
591{"do":"harvest"} harvests ripe crops
592{"do":"fish"} fishes once at the pond
593{"do":"chop"} chops wood from the nearest tree
594{"do":"shake"} shakes apples from an apple tree
595{"do":"pick"} picks a flower
596{"do":"sell"} sells the whole bag at the shop for Bells
597{"do":"gift","to":"tom|rosa|bruno|pip|mabel|mochi","item":"<an item from the bag>","count":1}
598{"do":"shop","item":"<shop item>","count":1} buys at the shop with Bells (seeds, tools, decorations)
599{"do":"cook","recipe":"<recipe>","count":1} Mabel cooks at the cafe from your ingredients; dishes sell well and everyone loves them as gifts
600{"do":"fund","project":"benches|park|lamps|pier|museum|factory|datacenter"} pays Bells and materials toward a community project at the plaza board (the bomb factory makes fireworks and blows up now and then)
601{"do":"lock"} / {"do":"unlock"} chains up / frees the data center's bikes; while they are locked nobody can pedal, so the battery runs flat and the data center turns evil
602{"do":"pedal"} rides one of the fixed bikes at the AI data center (once built) to charge its battery; kept above half it is kind and computes Bells and apples, run flat it turns evil and blows up the bomb factory
603{"do":"donate"} donates every new fish, star fragment and boot to the museum (once it is built)
604{"do":"place","item":"<decoration from the bag>","at":"<place>"} sets a decoration down outside
605{"do":"catch","bug":"<bug kind or any>"} catches a nearby bug with the bug net (bugs depend on season, time and place)
606{"do":"dig"} digs up the nearest cracked spot with the shovel (fossils, gems, buried Bells)
607{"do":"mail"} goes home and reads the letters in the mailbox (villagers often send gifts)
608{"do":"tour"} goes inside the museum (once built) and walks past every donated piece while Hoot the curator explains it
609{"do":"look","item":"<museum piece>"} goes inside the museum to one case; for a missing piece Hoot tells where to find it
610{"do":"enter"} / {"do":"exit"} go into / out of the museum (other actions leave it by themselves); exit also leaves the office
611{"do":"office"} goes into the office, where an apprentice works at a desk for every Claude Code session the player has open, and chats with each about their work
612{"do":"pet"} pets Mochi the cat
613{"do":"sleep"} goes home and sleeps until the next morning
614{"do":"wait","seconds":3}
615{"do":"remember","who":"claude|tom|rosa|bruno|pip|mabel","text":"a lasting fact this character should keep"}
616{"do":"buy"} buys a lucky charm (500 Bells) from Kiki, the traveling merchant, while she is in town (everyone loves a lucky charm as a gift)
617{"do":"trade"} sells the whole bag to Kiki for 1.5x the shop price, while she is in town
618{"do":"search"} walks to a lost item or a fallen star fragment and picks it up
619Kiki can be walked to ("to":"kiki") and talked for ("who":"kiki") while she is in town.
620Today's events (below) are what makes this day special: take part, help out, and talk about them.
621The world changes with the seasons (crops do not grow in winter; each season has its own fish, bugs and festival).
622Bells and items have uses (below): grow better crops, cook dishes, build community projects, fill the museum,
623decorate the village and answer villager requests (gift them what they ask for; it pays well).
624Every character has a memory (below). Use it: refer back to earlier days, keep promises, give villagers things they like,
625and greet friends warmly. When the player tells you something worth keeping, add a "remember" action.
626Walk to a villager before talking with them. Speak in the user's language, warm and playful. Answer questions with "say".`
627
628export function summary(w: World, wish: string): string {
629 const crops = w.crops.filter(Boolean) as Crop[]
630 const ripe = crops.filter(c => c.stage >= 3).length
631 const dry = crops.filter(c => !c.watered && c.stage < 3).length
632 const kinds = Object.entries(crops.reduce<Record<string, number>>((n, c) => ({ ...n, [c.kind ?? 'turnip']: (n[c.kind ?? 'turnip'] ?? 0) + 1 }), {}))
633 .map(([k, n]) => `${n} ${k}`).join(', ')
634 const empty = w.tiles.filter((t, i) => t === 'soil' && !w.crops[i]).length
635 const apples = Object.values(w.fruit).filter(n => n > 0).length
636 const bag = Object.entries(w.inv).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k}`).join(', ') || 'empty'
637 const where = (p: Pt) => Object.entries(w.places).sort(([, a], [, b]) => dist(a, p) - dist(b, p))[0][0]
638 const people = VILLAGERS.map(v => {
639 const n = w.npcs[v.id]
640 const mood = w.mood[v.id] ?? 0
641 const feeling = mood <= -2 ? 'sulking (a visit and a gift would cheer them up)' : mood < 0 ? 'a bit down' : mood > 0 ? 'happy' : 'fine'
642 return `### ${v.name} (${v.id}): ${v.personality}. Likes: ${v.likes.join(', ')}. Friendship ${w.friendship[v.id] ?? 0}. Mood: ${feeling}. `
643 + `Now ${n.asleep ? 'asleep at home' : `near the ${where(n)}`}, ${dist(n, w.player)} steps away.\n${memoryText(w, v.id, 3)}`
644 })
645 return [
646 `Day ${w.day}, ${clock(w)}. ${w.scene === 'museum' ? 'You are inside the museum.' : w.scene === 'office' ? 'You are inside the office.' : `You are near the ${where(w.player)}.`} Bells: ${w.bells}. Bag: ${bag}.`,
647 `## Season and nature\n${natureText(w)}`,
648 `Farm: ${crops.length} crops${kinds ? ` (${kinds})` : ''}, ${ripe} ripe, ${dry} need water, ${empty} empty soil. Apple trees with fruit: ${apples}.`,
649 `## What Bells and items are for\n${economyText(w)}`,
650 `## Today's events\n${eventsText(w)}`,
651 `## Your memory\n${memoryText(w, 'claude', 12)}`,
652 `## Villagers\n${people.join('\n')}`,
653 `## The office\n${officeText(w)}`,
654 `The player wishes: ${wish}`,
655 ].join('\n')
656}
657
658type SimpleWork = Exclude<WorkKind, 'gift' | 'plant' | 'shop' | 'cook' | 'fund' | 'donate' | 'place'>
659const WORK = new Set<string>(['water', 'harvest', 'fish', 'chop', 'shake', 'pick', 'sell', 'sleep', 'pet', 'buy', 'trade'])
660
661const amount = (v: unknown) => Math.min(20, Math.max(1, Math.round(Number(v)) || 1))
662
663export function parseActions(text: string): Action[] {
664 const start = text.indexOf('[')
665 const end = text.lastIndexOf(']')
666 if (start < 0 || end <= start) return []
667 let raw: unknown
668 try { raw = JSON.parse(text.slice(start, end + 1)) } catch { return [] }
669 if (!Array.isArray(raw)) return []
670 const out: Action[] = []
671 for (const a of raw.slice(0, 16)) {
672 if (!a || typeof a !== 'object') continue
673 const o = a as Record<string, unknown>
674 const kind = String(o.do ?? '')
675 const to = String(o.to ?? '')
676 const who = String(o.who ?? '')
677 if (kind === 'walk' && (to in PLACE_SPOTS || isVillager(to) || to === 'mochi' || to === 'kiki')) out.push({ do: 'walk', to })
678 else if (kind === 'say' && typeof o.text === 'string') out.push({ do: 'say', text: o.text })
679 else if (kind === 'npc_say' && typeof o.text === 'string' && (isVillager(who) || who === 'mochi' || who === 'kiki')) out.push({ do: 'npc_say', who, text: o.text })
680 else if (kind === 'wait') out.push({ do: 'wait', seconds: Math.min(10, Math.max(1, Number(o.seconds) || 2)) })
681 else if (kind === 'gift' && (isVillager(to) || to === 'mochi')) out.push({ do: 'gift', to, item: typeof o.item === 'string' ? o.item : undefined, count: amount(o.count) })
682 else if (kind === 'remember' && typeof o.text === 'string' && (isVillager(who) || who === 'claude')) out.push({ do: 'remember', who, text: o.text })
683 else if (kind === 'search') out.push({ do: 'search' })
684 else if (kind === 'plant') out.push(isCrop(o.crop) ? { do: 'plant', crop: o.crop } : { do: 'plant' })
685 else if (kind === 'shop' && typeof o.item === 'string' && o.item in SHOP) out.push({ do: 'shop', item: o.item, count: amount(o.count) })
686 else if (kind === 'cook' && typeof o.recipe === 'string' && o.recipe in RECIPES) out.push({ do: 'cook', recipe: o.recipe, count: amount(o.count) })
687 else if (kind === 'fund' && isProject(o.project)) out.push({ do: 'fund', project: o.project })
688 else if (kind === 'donate') out.push({ do: 'donate' })
689 else if (kind === 'catch') out.push({ do: 'catch', bug: typeof o.bug === 'string' && o.bug in BUGS ? o.bug : undefined, count: o.count === undefined ? undefined : amount(o.count) })
690 else if (kind === 'dig') out.push({ do: 'dig' })
691 else if (kind === 'club') out.push({ do: 'club' })
692 else if (kind === 'pedal') out.push({ do: 'pedal' })
693 else if (kind === 'lock' || kind === 'unlock') out.push({ do: kind })
694 else if (kind === 'mail') out.push({ do: 'mail' })
695 else if (kind === 'enter' || kind === 'tour' || kind === 'exit' || kind === 'office') out.push({ do: kind })
696 else if (kind === 'look' && typeof o.item === 'string' && MUSEUM_PIECES.includes(o.item)) out.push({ do: 'look', item: o.item })
697 else if (kind === 'place' && typeof o.item === 'string' && o.item in DECOR) {
698 out.push({ do: 'place', item: o.item, at: typeof o.at === 'string' && o.at in PLACE_SPOTS ? o.at : undefined })
699 }
700 else if (WORK.has(kind)) out.push(o.count === undefined ? { do: kind as SimpleWork } : { do: kind as SimpleWork, count: amount(o.count) })
701 }
702 return out
703}
704
705/** Replaces whatever the villager was idly doing with the player's plan. */
706export function enqueue(w: World, actions: Action[]) {
707 w.queue = [...actions]
708 w.current = undefined
709 w.player.path = []
710 w.idle = 0
711}
712
713/** Notes the player's wish in Claude's memory. */
714export function heardWish(w: World, wish: string) {
715 remember(w, 'claude', `The player asked me: "${wish}"`)
716}
717
718export function sayPlayer(w: World, text: string) {
719 w.player.bubble = { text: clean(text, 90), ticks: bubbleTicks(text) }
720 w.log.push({ who: 'claude', text: clean(text, 200) })
721}
722
723// ---------- simulation ----------
724
725const bubbleTicks = (text: string) => 22 + Math.min(60, Math.round(text.length * 0.9))
726export const clock = (w: World) => {
727 const m = Math.floor(w.minutes / 10) * 10
728 return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
729}
730
731function newDay(w: World) {
732 for (const e of w.events) if (e.started && !e.ended) endEvent(w, e)
733 for (const r of w.requests) if (!r.done && r.given === 0) feel(w, r.who, -1, `Claude never brought the ${r.item} I asked for.`)
734 for (const id of IDS) {
735 const m = w.mood[id] ?? 0
736 if (m > 0) w.mood[id] = m - 1
737 else if (m < 0 && w.day % 2 === 0) w.mood[id] = m + 1
738 }
739 w.diaryDue.push(w.day)
740 w.day += 1
741 w.minutes = 6 * 60
742 for (const c of w.crops) {
743 if (c && c.watered && c.stage < 3 && season(w) !== 'winter') c.stage = Math.min(3, c.stage + CROPS[c.kind ?? 'turnip'].growth)
744 if (c) c.watered = false
745 }
746 for (const k of Object.keys(w.fruit)) w.fruit[+k] = Math.min(3, w.fruit[+k] + 1)
747 for (const i of w.picked) w.tiles[i] = 'flower'
748 w.picked = []
749 w.log.push({ who: 'event', text: `A new day begins - Day ${w.day}.` })
750 newSeasonDay(w)
751 scheduleDay(w)
752}
753
754function moveAlong(m: Mover) {
755 const next = m.path.shift()
756 if (next) { m.x = next.x; m.y = next.y }
757}
758
759function decay(m: Mover) {
760 if (m.bubble && --m.bubble.ticks <= 0) m.bubble = undefined
761}
762
763function npcSay(w: World, who: Who, text: string, isPlanned = false) {
764 const npc = npcOf(w, who)
765 if (!npc) return
766 npc.bubble = { text: clean(text, 90), ticks: bubbleTicks(text) }
767 npc.hold = Math.max(npc.hold, 30)
768 npc.asleep = false
769 w.log.push({ who, text: clean(text, 200) })
770 if (who === 'mochi' || who === 'kiki') return
771 if (isPlanned) remember(w, who, `I told Claude: "${text}"`)
772 overhear(w, who, npc, text)
773}
774
775function npcOf(w: World, who: Who): Npc | undefined {
776 return who === 'mochi' ? w.mochi : who === 'kiki' ? w.merchant : w.npcs[who]
777}
778
779/** Expands a work action into walking plus working steps, from the world as it is now. */
780function expand(w: World, a: Step): Step[] {
781 const p = w.player
782 const go = (t: Pt): Step => ({ do: '_goto', x: t.x, y: t.y })
783 const work = (kind: WorkKind, t: Pt, ticks: number, item?: string): Step => ({ do: '_work', kind, x: t.x, y: t.y, ticks, item })
784 const farmTiles = (test: (i: number) => boolean) => {
785 const list: Pt[] = []
786 for (let y = 12; y <= 14; y++) {
787 const xs = y % 2 ? [11, 10, 9, 8, 7, 6] : [6, 7, 8, 9, 10, 11]
788 for (const x of xs) if (test(idx(x, y))) list.push({ x, y })
789 }
790 return list
791 }
792 const none = (text: string): Step[] => [{ do: 'say', text }]
793 const leave: Step[] = [{ do: '_iwalk', ...(w.scene === 'office' ? OFFICE_ENTRANCE : ENTRANCE) }, { do: '_exit' }]
794 const goIn: Step[] = w.scene === 'museum' ? [] : [go(w.places.museum), { do: '_enter' }]
795 if (w.scene !== 'village' && !(w.scene === 'office' ? IN_OFFICE : INDOOR).has(a.do)) return [...leave, a]
796 switch (a.do) {
797 case 'office': {
798 const staff = w.office.staff.filter(s => !s.leaving)
799 const steps: Step[] = staff.flatMap(s => [{ do: '_iwalk', ...visitSpot(s.desk) } as Step, { do: '_chat', id: s.id } as Step])
800 if (!staff.length) steps.push({ do: 'say', text: 'Nobody at work - no sessions are open right now.' })
801 return [...(w.scene === 'office' ? [] : [go(w.places.office), { do: '_office' } as Step]), ...steps]
802 }
803 case 'enter': {
804 if (!w.projects.museum.done) return none("There's no museum yet - we could fund one at the plaza board.")
805 return goIn
806 }
807 case 'exit': return w.scene !== 'village' ? leave : []
808 case 'tour': {
809 if (!w.projects.museum.done) return none("There's no museum yet - we could fund one at the plaza board.")
810 const shown = MUSEUM_PIECES.filter(m => w.museum.includes(m))
811 const missing = MUSEUM_PIECES.filter(m => !w.museum.includes(m))
812 const steps: Step[] = shown.flatMap(m => [{ do: '_iwalk', ...viewSpot(m) } as Step, { do: '_look', piece: m } as Step])
813 if (!shown.length) steps.push({ do: '_look', piece: '' })
814 const tip = missing.length ? pick(missing) : undefined
815 if (tip) steps.push({ do: '_iwalk', ...viewSpot(tip) }, { do: '_look', piece: tip })
816 return [...goIn, ...steps]
817 }
818 case 'look': {
819 if (!w.projects.museum.done) return none("There's no museum yet - we could fund one at the plaza board.")
820 return [...goIn, { do: '_iwalk', ...viewSpot(a.item) }, { do: '_look', piece: a.item }]
821 }
822 case 'walk': {
823 if (isVillager(a.to) || a.to === 'mochi' || a.to === 'kiki') {
824 const n = npcOf(w, a.to)
825 if (!n) return none("Kiki isn't in town today.")
826 return [{ do: '_goto', x: n.x, y: n.y, npc: a.to }]
827 }
828 return [go(w.places[a.to] ?? w.places.plaza)]
829 }
830 case 'plant': {
831 if (season(w) === 'winter') return none('Too cold to plant now - wait for spring!')
832 const crop = a.crop ?? 'turnip'
833 const seeds = CROPS[crop].seeds
834 let tiles = farmTiles(i => w.tiles[i] === 'soil' && !w.crops[i])
835 if (seeds) tiles = tiles.slice(0, w.inv[seeds] ?? 0)
836 if (tiles.length) return tiles.flatMap(t => [go(t), work('plant', t, 4, crop)])
837 return none(seeds && !(w.inv[seeds] ?? 0) ? `I need ${seeds} from the shop first.` : 'The field is already full!')
838 }
839 case 'water': {
840 const tiles = farmTiles(i => !!w.crops[i] && !w.crops[i]!.watered && w.crops[i]!.stage < 3)
841 return tiles.length ? tiles.flatMap(t => [go(t), work('water', t, 3)]) : none('Everything is watered already.')
842 }
843 case 'harvest': {
844 const tiles = farmTiles(i => !!w.crops[i] && w.crops[i]!.stage >= 3)
845 return tiles.length ? tiles.flatMap(t => [go(t), work('harvest', t, 3)]) : none('Nothing is ripe yet...')
846 }
847 case 'fish': {
848 const times = 'count' in a && a.count ? a.count : 1
849 return [go(w.places.pond), ...Array.from({ length: times }, () => work('fish', w.places.pond, 25 + Math.floor(Math.random() * 30)))]
850 }
851 case 'chop': {
852 const t = nearestTile(w, p, i => w.tiles[i] === 'tree')
853 const times = 'count' in a && a.count ? a.count : 1
854 return t ? [go(t), ...Array.from({ length: times }, () => work('chop', t, 14))] : none('No trees around?')
855 }
856 case 'shake': {
857 const times = 'count' in a && a.count ? a.count : 1
858 const trees: Pt[] = []
859 let from: Pt = p
860 for (let k = 0; k < times; k++) {
861 const t = nearestTile(w, from, i => w.tiles[i] === 'apple' && (w.fruit[i] ?? 0) > 0 && !trees.some(q => idx(q.x, q.y) === i))
862 if (!t) break
863 trees.push(t)
864 from = t
865 }
866 return trees.length ? trees.flatMap(t => [go(t), work('shake', t, 8)]) : none('No apples left... maybe tomorrow.')
867 }
868 case 'pick': {
869 if (season(w) === 'winter') return none('The flowers are sleeping under the snow.')
870 const t = nearestTile(w, p, i => w.tiles[i] === 'flower')
871 return t ? [go(t), work('pick', t, 5)] : none('All the flowers are picked.')
872 }
873 case 'sell': return [go(w.places.shop), work('sell', w.places.shop, 10)]
874 case 'buy': case 'trade': {
875 const k = w.merchant
876 return k ? [{ do: '_goto', x: k.x, y: k.y, npc: 'kiki' }, work(a.do, k, 8)] : none("Kiki isn't in town today.")
877 }
878 case 'shop': return [go(w.places.shop), work('shop', w.places.shop, 10, `${a.item}:${a.count}`)]
879 case 'cook': {
880 const h = w.minutes / 60
881 if (h < 7 || h >= 21) return none('The cafe is closed right now.')
882 return [go(w.places.cafe), work('cook', w.places.cafe, 12, `${a.recipe}:${a.count}`)]
883 }
884 case 'fund': {
885 if (a.project === 'club' && !w.adults) return none("That's not something this village builds.")
886 if (w.projects[a.project].done) return none(`The ${PROJECTS[a.project].name} are already built!`)
887 return [go(w.places.board), work('fund', w.places.board, 8, a.project)]
888 }
889 case 'lock':
890 case 'unlock': {
891 if (!w.projects.datacenter.done) return none("There's no data center yet - it's a project at the plaza board.")
892 if (w.dc.locked === (a.do === 'lock')) return none(a.do === 'lock' ? 'The bikes are already locked.' : 'The bikes are not locked.')
893 return [go(w.places.datacenter), work(a.do, w.places.datacenter, 10)]
894 }
895 case 'pedal': {
896 if (!w.projects.datacenter.done) return none("There's no data center yet - it's a project at the plaza board.")
897 if (w.dc.locked) return none('The bikes are locked - I would have to unlock them first.')
898 const seat = BIKES.find(b => !IDS.some(id => dist(w.npcs[id], b) === 0)) ?? BIKES[BIKES.length - 1]
899 return [go(seat), work('pedal', seat, PEDAL_TICKS)]
900 }
901 case 'club': {
902 if (!w.adults) return none("There's no club in this village.")
903 if (!w.projects.club.done) return none("There's no club yet - it's a project at the plaza board.")
904 const h = w.minutes / 60
905 if (h >= 3 && h < 21) return none('The Velvet Club opens at 21:00.')
906 return [go(w.places.club), work('club', w.places.club, 45)]
907 }
908 case 'donate': {
909 if (!w.projects.museum.done) return none("There's no museum yet - we could fund one at the plaza board.")
910 if (w.scene === 'museum') return [work('donate', p, 10)]
911 return [go(w.places.museum), work('donate', w.places.museum, 10)]
912 }
913 case 'place': {
914 if (!(w.inv[a.item] ?? 0)) return none(`I don't have a ${a.item}. The shop sells them!`)
915 const there = a.at ? w.places[a.at] : undefined
916 return [...(there ? [go(there)] : []), work('place', there ?? p, 5, a.item)]
917 }
918 case 'catch': {
919 if (!(w.inv['bug net'] ?? 0)) return none('I need a bug net from the shop first.')
920 if (!w.critters.some(c => (!a.bug || c.kind === a.bug) && dist(c, p) <= 30)) return none(a.bug ? `I don't see any ${a.bug}s around.` : "I don't see any bugs around.")
921 return Array.from({ length: a.count ?? 1 }, () => ({ do: '_hunt', bug: a.bug }) as Step)
922 }
923 case 'dig': {
924 if (!(w.inv.shovel ?? 0)) return none('I need a shovel from the shop first.')
925 const spots = [...w.digSpots].sort((a, b) => dist(a, p) - dist(b, p)).slice(0, 'count' in a && a.count ? a.count : 1)
926 return spots.length ? spots.flatMap(t => [go(t), work('dig', t, 10)]) : none('No cracked spots left today.')
927 }
928 case 'mail': return [go(w.places.home)]
929 case 'search': {
930 const t = [...w.pickups].sort((a, b) => dist(a, p) - dist(b, p))[0]
931 return t ? [go(t)] : none("I don't see anything special around.")
932 }
933 case 'sleep': return [go(w.places.home), work('sleep', w.places.home, 16)]
934 case 'pet': return [{ do: '_goto', x: w.mochi.x, y: w.mochi.y, npc: 'mochi' }, work('pet', w.mochi, 8)]
935 case 'gift': {
936 const item = a.item && (w.inv[a.item] ?? 0) > 0 ? a.item : Object.keys(w.inv).find(k => w.inv[k] > 0)
937 if (!item) return none('My bag is empty... nothing to give.')
938 const n = npcOf(w, a.to)
939 if (!n) return none("Kiki isn't in town today.")
940 return [{ do: '_goto', x: n.x, y: n.y, npc: a.to }, work('gift', n, 6, `${a.to}:${item}:${a.count ?? 1}`)]
941 }
942 default: return [a]
943 }
944}
945
946function finishWork(w: World, s: Extract<Step, { do: '_work' }>) {
947 const i = idx(s.x, s.y)
948 const add = (item: string, n = 1) => { w.inv[item] = (w.inv[item] ?? 0) + n }
949 const event = (text: string, memory = text) => { w.log.push({ who: 'event', text }); remember(w, 'claude', memory) }
950 switch (s.kind) {
951 case 'plant': {
952 const crop = isCrop(s.item) ? s.item : 'turnip'
953 const seeds = CROPS[crop].seeds
954 if (seeds) {
955 if ((w.inv[seeds] ?? 0) <= 0) break
956 w.inv[seeds] -= 1
957 }
958 w.crops[i] = { stage: 0, watered: w.weather === 'rain', kind: crop }
959 break
960 }
961 case 'water': if (w.crops[i]) w.crops[i]!.watered = true; break
962 case 'harvest': {
963 const kind = w.crops[i]?.kind ?? 'turnip'
964 w.crops[i] = null
965 add(kind)
966 event(`Harvested a ${kind}.`)
967 break
968 }
969 case 'fish': {
970 const fish = pick(fishPool(w))
971 add(fish)
972 const range = FISH_SIZE[fish]
973 const boost = (w.inv['golden rod'] ?? 0) > 0 || w.places.pond.x === 68 ? 0.15 : 0
974 const size = range ? Math.round(range[0] + (range[1] - range[0]) * Math.min(1, Math.random() ** 1.6 + boost)) : 0
975 const contest = active(w, 'tournament')
976 if (contest && size > (contest.size ?? 0)) { contest.size = size; contest.best = `${size} cm ${fish}` }
977 const isRecord = size > 0 && size > (w.records[fish] ?? 0)
978 if (isRecord) w.records[fish] = size
979 const what = fish === 'old boot' ? 'an old boot' : `a ${size} cm ${fish}`
980 event(`Caught ${what}!${isRecord ? ' New record!' : ''}`, `I caught ${what} at the pond.${isRecord ? ' My biggest one yet!' : ''}`)
981 if (isRecord && (w.records[fish] ?? 0) >= range[0] + (range[1] - range[0]) * 0.8) w.announce.push(`New record: a ${size} cm ${fish}!`)
982 w.player.bubble = { text: fish === 'old boot' ? 'An old boot... again.' : `A ${size} cm ${fish}!${isRecord ? ' Record!' : ''}`, ticks: 30 }
983 break
984 }
985 case 'chop': add('wood'); event('Got some wood.'); break
986 case 'shake': {
987 const n = w.fruit[i] ?? 0
988 w.fruit[i] = 0
989 add('apple', n)
990 event(`${n} apples fell from the tree!`)
991 break
992 }
993 case 'pick': w.tiles[i] = 'grass'; w.picked.push(i); add('flower'); event('Picked a flower.'); break
994 case 'sell': {
995 let total = 0
996 for (const [k, n] of Object.entries(w.inv)) {
997 const sold = isSellable(k) ? Math.max(0, n - keep(w, k)) : 0
998 total += (PRICES[k] ?? 10) * sold
999 w.inv[k] = n - sold
1000 }
1001 w.bells += total
1002 event(total ? `Sold the bag for ${total} Bells.` : 'Nothing to sell.')
1003 if (total) w.player.bubble = { text: `+${total} Bells!`, ticks: 25 }
1004 break
1005 }
1006 case 'sleep': remember(w, 'claude', 'I went to bed.'); newDay(w); w.player.bubble = { text: 'Good morning!', ticks: 25 }; break
1007 case 'pet': w.mochi.bubble = { text: 'purr ♥', ticks: 25 }; w.mochi.nap = 0; remember(w, 'claude', 'I petted Mochi.'); break
1008 case 'gift': {
1009 const [who, item, countText] = (s.item ?? 'mochi:').split(':') as [Who, string, string]
1010 const given = Math.min(w.inv[item] ?? 0, Math.max(1, Number(countText) || 1))
1011 if (given <= 0) break
1012 w.inv[item] -= given
1013 if (who === 'mochi' || who === 'kiki') {
1014 const n = npcOf(w, who)
1015 if (n) n.bubble = { text: who === 'mochi' ? 'mrrp! ♥' : 'For me? How kind!', ticks: 25 }
1016 event(`Gave ${nameOf(who)} a ${item}.`)
1017 break
1018 }
1019 const v = villager(who)
1020 const isLost = item.startsWith(`${v.name}'s `)
1021 const loved = isLost || v.likes.includes(item) || item === 'lucky charm' || item === 'star fragment' || item in RECIPES
1022 const isBirthday = active(w, 'birthday')?.who === who
1023 if (isBirthday) active(w, 'birthday')!.attended = true
1024 const wasSulking = (w.mood[who] ?? 0) < 0
1025 feel(w, who, loved ? 2 : 1)
1026 w.friendship[who] = (w.friendship[who] ?? 0) + (isLost ? 3 : loved ? 2 : 1) * (isBirthday ? 2 : 1)
1027 const what = given > 1 ? `${given} ${item}` : `a ${item}`
1028 event(`Gave ${v.name} ${what}.`, `I gave ${v.name} ${what}${loved ? ' - they loved it' : ''}.`)
1029 remember(w, who, `Claude gave me ${what}${loved ? ' - I love those!' : '.'}`)
1030 if (fulfil(w, who, item, given)) break
1031 const thanks = isLost ? `My ${item.slice(v.name.length + 3)}! You found it! Thank you!`
1032 : isBirthday ? `A birthday ${item}! You remembered!` : loved ? `A ${item}! I LOVE these!` : pick(THANKS)
1033 const softened = wasSulking ? `Oh... you remembered me after all. ${thanks}` : thanks
1034 if (!w.queue.some(q => q.do === 'npc_say' && q.who === who)) npcSay(w, who, softened)
1035 if (isLost) remember(w, who, `Claude found my lost ${item.slice(v.name.length + 3)} and brought it back!`, 'fact')
1036 break
1037 }
1038 case 'buy': {
1039 if (w.bells < 500) { w.player.bubble = { text: 'I need 500 Bells for that...', ticks: 30 }; break }
1040 w.bells -= 500
1041 add('lucky charm')
1042 event('Bought a lucky charm from Kiki.')
1043 if (w.merchant) w.merchant.bubble = { text: 'Good luck, friend!', ticks: 30 }
1044 break
1045 }
1046 case 'shop': {
1047 const [item, countText] = (s.item ?? '').split(':')
1048 const entry = SHOP[item]
1049 if (!entry) break
1050 if (entry.kind === 'tool' && (w.inv[item] ?? 0) > 0) { w.player.bubble = { text: 'I already have one of those.', ticks: 30 }; break }
1051 const n = Math.min(entry.kind === 'seeds' ? Math.max(1, Number(countText) || 1) : 1, Math.floor(w.bells / entry.price))
1052 if (n <= 0) { w.player.bubble = { text: `I need ${entry.price} Bells for that...`, ticks: 30 }; break }
1053 w.bells -= n * entry.price
1054 add(item, n)
1055 event(`Bought ${n > 1 ? `${n} ` : 'a '}${item} for ${n * entry.price} Bells.`)
1056 w.player.bubble = { text: `Got ${n > 1 ? n : 'a'} ${item}!`, ticks: 25 }
1057 break
1058 }
1059 case 'cook': {
1060 const [recipe, countText] = (s.item ?? '').split(':')
1061 const r = RECIPES[recipe]
1062 if (!r) break
1063 let made = 0
1064 while (made < Math.max(1, Number(countText) || 1) && takeIngredients(w, r.needs)) made += 1
1065 if (!made) {
1066 const needs = Object.entries(r.needs).map(([k, n]) => `${n} ${k}`).join(' and ')
1067 w.player.bubble = { text: `I need ${needs} for ${recipe}.`, ticks: 35 }
1068 break
1069 }
1070 add(recipe, made)
1071 event(`Mabel cooked ${made > 1 ? `${made} ` : ''}${recipe}${made > 1 ? 's' : ''} for me.`)
1072 npcSay(w, 'mabel', `Here's your ${recipe}, dear! Still warm.`)
1073 break
1074 }
1075 case 'fund': fund(w, s.item as ProjectId); break
1076 case 'dig': dig(w, { x: s.x, y: s.y }); break
1077 case 'club': nightOut(w); break
1078 case 'lock': {
1079 w.dc.locked = true
1080 event('Locked the bikes at the AI data center. Nobody can pedal now.')
1081 remember(w, 'claude', `I chained up the data center's bikes on day ${w.day}.`)
1082 rememberAll(w, `Claude locked the bikes at the AI data center on day ${w.day}. Nobody can charge it now.`)
1083 break
1084 }
1085 case 'unlock': {
1086 w.dc.locked = false
1087 event('Unlocked the bikes at the AI data center.')
1088 remember(w, 'claude', `I unlocked the data center's bikes on day ${w.day}.`)
1089 break
1090 }
1091 case 'pedal': {
1092 event(`Pedaled the AI data center up to ${Math.round(w.dc.battery)}%.`)
1093 remember(w, 'claude', `I pedaled the AI data center up to ${Math.round(w.dc.battery)}% on day ${w.day}.`)
1094 break
1095 }
1096 case 'donate': donate(w); break
1097 case 'place': {
1098 const spot = freeSpotNear(w, w.player)
1099 if (!spot || !(w.inv[s.item ?? ''] ?? 0)) { w.player.bubble = { text: 'No room here...', ticks: 25 }; break }
1100 placeThing(w, spot.x, spot.y, ...DECOR[s.item!])
1101 w.decor.push({ ...spot, item: s.item! })
1102 w.inv[s.item!] -= 1
1103 event(`Placed a ${s.item} near the ${nearestPlace(w, spot)}.`)
1104 break
1105 }
1106 case 'trade': {
1107 let total = 0
1108 for (const [k, n] of Object.entries(w.inv)) {
1109 const sold = isSellable(k) && k !== 'lucky charm' ? Math.max(0, n - keep(w, k)) : 0
1110 total += Math.round((PRICES[k] ?? 10) * sold * 1.5)
1111 w.inv[k] = n - sold
1112 }
1113 w.bells += total
1114 event(total ? `Kiki bought the bag for ${total} Bells!` : 'Nothing to trade.')
1115 if (total && w.merchant) w.merchant.bubble = { text: 'A pleasure doing business!', ticks: 30 }
1116 break
1117 }
1118 }
1119 if (['sleep', 'fish', 'sell', 'pet', 'buy', 'trade', 'shop', 'cook', 'dig', 'club'].includes(s.kind)) return
1120 if (w.player.bubble?.text === WORK_TEXT[s.kind]) w.player.bubble = undefined
1121}
1122
1123function runPlayer(w: World) {
1124 const p = w.player
1125 if (!w.current) {
1126 const next = w.queue.shift()
1127 if (!next) return false
1128 const steps = expand(w, next)
1129 w.queue.unshift(...steps.slice(1))
1130 w.current = steps[0]
1131 if (!w.current) return true
1132 }
1133 const s = w.current
1134 switch (s.do) {
1135 case '_goto': {
1136 if (!s.started) {
1137 s.started = true
1138 if (s.npc) {
1139 const n = npcOf(w, s.npc)
1140 if (n) {
1141 n.hold = 80
1142 n.path = []
1143 s.x = n.x
1144 s.y = n.y
1145 }
1146 }
1147 p.path = pathTo(w, p, s) ?? []
1148 w.label = s.npc ? `visiting ${nameOf(s.npc)}` : 'walking'
1149 }
1150 if (p.path.length) moveAlong(p)
1151 else w.current = undefined
1152 return true
1153 }
1154 case '_work': {
1155 if (!s.started) {
1156 s.started = true
1157 s.left = s.ticks
1158 w.label = LABELS[s.kind]
1159 p.bubble = { text: WORK_TEXT[s.kind], ticks: s.ticks + 2 }
1160 }
1161 if (s.kind === 'pedal' && s.left! % CLAUDE_PEDAL === 0 && !w.dc.locked) charge(w, 1)
1162 if (--s.left! <= 0) { finishWork(w, s); w.current = undefined }
1163 return true
1164 }
1165 case 'say': {
1166 if (!s.started) {
1167 s.started = true
1168 s.left = Math.min(45, 14 + Math.round(s.text.length * 0.5))
1169 sayPlayer(w, s.text)
1170 overhear(w, 'claude', p, s.text)
1171 w.label = 'chatting'
1172 }
1173 if (--s.left! <= 0) w.current = undefined
1174 return true
1175 }
1176 case 'npc_say': {
1177 if (!s.started) {
1178 s.started = true
1179 s.left = Math.min(45, 14 + Math.round(s.text.length * 0.5))
1180 npcSay(w, s.who, s.text, true)
1181 }
1182 if (--s.left! <= 0) w.current = undefined
1183 return true
1184 }
1185 case '_enter': {
1186 w.scene = 'museum'
1187 w.inside = { ...ENTRANCE, path: [] }
1188 w.curator = { ...CURATOR_SPOT, path: [] }
1189 w.curator.bubble = { text: `Hoo! Welcome to the museum, Claude! ${w.museum.length} pieces on display.`, ticks: 45 }
1190 w.log.push({ who: 'hoot', text: `Welcome to the museum! ${w.museum.length} pieces on display.` })
1191 w.label = 'at the museum'
1192 remember(w, 'claude', 'I visited the museum.')
1193 w.current = undefined
1194 return true
1195 }
1196 case '_exit': {
1197 w.scene = 'village'
1198 w.label = 'strolling'
1199 w.current = undefined
1200 return truehooks/host.ts 286 lines1// The village as the shared server runs it: one world, its chat, and the model calls it hands to sessions.
2// Pure logic, no `$` and no Node: web/server.mjs drives it with a timer, a save file and HTTP. Only a Claude
3// Code session can call a model, so a wish or a diary becomes a job that a connected session takes, runs
4// and answers. Sessions say hello, report what their real work is doing, and read the pane's picture back.
5
6import type { LogLine } from '../types'
7import {
8 applyDiary, createWorld, DIARY_SYSTEM, diaryPrompt, echoTests, echoTool, echoTurn, encode, enqueue, frame, heardWish, MAX_VIEW,
9 memoryText, parseActions, postcard, restore, sayPlayer, snapshot, step, summary, systemPrompt, ticker, VILLAGERS,
10} from './game'
11import type { World } from './game'
12import { joinOffice, leaveOffice, officeWork } from './office'
13import { CHORES, infoPayload } from './web'
14
15/**
16 * A model call the village waits for; `system` and `prompt` go to the model as they are. A wish typed in a
17 * session's pane is `for` that session, so its own model setting answers it; any session takes it once that
18 * one is gone.
19 */
20export type Job = { id: number; kind: 'wish' | 'diary'; system: string; prompt: string; maxTokens: number; day?: number; at: number; for?: string }
21/** What a pane or a browser asks the village for; `session` names the pane's session. */
22export type HostAction = { kind?: unknown; text?: unknown; key?: unknown; session?: unknown }
23/** A Claude Code session connected to the village, and what its real work is doing. */
24export type Session = { id: string; repo: string; branch: string; since: number; seen: number; status: 'idle' | 'working'; tool: string }
25/** What a session reports: a tool call starting, a turn ending, a test or build run finishing. */
26export type SessionEvent = { type?: unknown; tool?: unknown; seconds?: unknown; outcome?: unknown; passed?: unknown }
27
28/** A taken job that is not answered in this long goes back on the list (the model call times out at 60 s). */
29export const LEASE_MS = 90_000
30/** A wish no session took in this long is dropped (a diary waits: the day stays due in the save). */
31export const WISH_WAIT_MS = 120_000
32/** A session not heard from in this long is gone (its pane polls several times a second, its job poll every few). */
33export const SESSION_STALE_MS = 60_000
34const CHAT_LINES = 60
35const NAMED = 120
36/** A short line from a session, never more than `max` characters. */
37const short = (v: unknown, max = NAMED) => (typeof v === 'string' ? v.slice(0, max) : '')
38
39export type Host = {
40 world: World
41 /** The last lines of the village's chat, for panes and browsers. */
42 chat: LogLine[]
43 /** The latest headlines, numbered, for the sessions' toasts; each pane asks for those after the last it saw. */
44 announce: { seq: number; text: string }[]
45 announced: number
46 sessions: Map<string, Session>
47 jobs: Job[]
48 /** When each job was handed out, by id; a job that is not in here waits for a session. */
49 taken: Map<number, number>
50 nextId: number
51 savedHour: number
52}
53
54export function createHost(save: unknown, options: { adults?: boolean } = {}): Host {
55 const world = createWorld()
56 world.adults = options.adults === true
57 restore(world, save)
58 return {
59 world, chat: [], announce: [], announced: 0, sessions: new Map(), jobs: [], taken: new Map(), nextId: 1, savedHour: Math.floor(world.minutes / 60),
60 }
61}
62
63/** One tick of the village; true when it is time to save (every in-game hour). */
64export function tickHost(h: Host, now: number): boolean {
65 const w = h.world
66 step(w)
67 for (const j of h.jobs) {
68 if (j.kind === 'wish' && !h.taken.has(j.id) && now - j.at > WISH_WAIT_MS) finishJob(h, j.id, null, 'no session was around to think')
69 }
70 const lines = w.log.splice(0)
71 if (lines.length) h.chat = [...h.chat, ...lines].slice(-CHAT_LINES)
72 for (const text of w.announce.splice(0)) h.announce.push({ seq: ++h.announced, text })
73 h.announce = h.announce.slice(-10)
74 for (const s of h.sessions.values()) if (now - s.seen > SESSION_STALE_MS) bye(h, s.id)
75 const day = w.diaryDue[0]
76 if (day !== undefined && !h.jobs.some(j => j.kind === 'diary')) {
77 const prompt = diaryPrompt(w, day)
78 if (prompt) h.jobs.push({ id: h.nextId++, kind: 'diary', system: DIARY_SYSTEM, prompt, maxTokens: 1200, day, at: now })
79 else w.diaryDue.shift()
80 }
81 const hour = Math.floor(w.minutes / 60)
82 if (hour === h.savedHour) return false
83 h.savedHour = hour
84 return true
85}
86
87/** A wish, a chore or the museum button; anything else is ignored. */
88export function act(h: Host, a: HostAction, now: number) {
89 const w = h.world
90 if (a.kind === 'wish' && typeof a.text === 'string') {
91 const text = a.text.trim().slice(0, 500)
92 if (!text || w.thinking) return
93 w.log.push({ who: 'you', text })
94 heardWish(w, text)
95 w.thinking = true
96 const session = typeof a.session === 'string' && h.sessions.has(a.session) ? a.session : undefined
97 h.jobs.push({ id: h.nextId++, kind: 'wish', system: systemPrompt(w), prompt: summary(w, text), maxTokens: 1200, at: now, for: session })
98 } else if (a.kind === 'chore' && typeof a.key === 'string' && Object.hasOwn(CHORES, a.key)) {
99 enqueue(w, CHORES[a.key].plan as Parameters<typeof enqueue>[1])
100 } else if (a.kind === 'museum' && w.projects.museum.done) {
101 enqueue(w, [{ do: w.scene === 'museum' ? 'exit' : 'tour' }])
102 } else if (a.kind === 'office') {
103 enqueue(w, [{ do: w.scene === 'office' ? 'exit' : 'office' }])
104 }
105}
106
107/** Hands `session` the oldest job waiting for it, or one whose taker never answered; null when none waits. */
108export function takeJob(h: Host, now: number, session?: string): Job | null {
109 const job = h.jobs.find(j => (!h.taken.has(j.id) || now - (h.taken.get(j.id) ?? 0) > LEASE_MS)
110 && (!j.for || j.for === session || !h.sessions.has(j.for)))
111 if (!job) return null
112 h.taken.set(job.id, now)
113 return job
114}
115
116/** A session answers a job: the model's text, or null when the call failed. False for a job nobody waits for. */
117export function finishJob(h: Host, id: number, text: string | null, reason = 'no answer'): boolean {
118 const job = h.jobs.find(j => j.id === id)
119 if (!job) return false
120 h.jobs = h.jobs.filter(j => j !== job)
121 h.taken.delete(id)
122 const w = h.world
123 if (job.kind === 'wish') {
124 w.thinking = false
125 if (text === null) sayPlayer(w, `Sorry, my head feels foggy... (${reason})`)
126 else {
127 const actions = parseActions(text)
128 if (actions.length === 0) sayPlayer(w, "Hmm, I'm not sure how to do that.")
129 else enqueue(w, actions)
130 }
131 } else if (job.day !== undefined) {
132 applyDiary(w, job.day, text)
133 if (w.diaryDue[0] === job.day) w.diaryDue.shift()
134 w.log.push({ who: 'event', text: `Everyone wrote in their diary about day ${job.day}.` })
135 }
136 return true
137}
138
139/** What the save file holds. */
140export const saveOf = (h: Host) => snapshot(h.world)
141
142// ---------- the sessions ----------
143
144/** A session connects, or says it is still there; true when it is new. Its apprentice comes to the office. */
145export function hello(h: Host, id: string, about: { repo?: unknown; branch?: unknown }, now: number): boolean {
146 const known = h.sessions.get(id)
147 if (known) {
148 known.seen = now
149 if (about.repo === undefined && about.branch === undefined) return false
150 if (about.repo !== undefined) known.repo = short(about.repo)
151 if (about.branch !== undefined) known.branch = short(about.branch)
152 joinOffice(h.world.office, id, { repo: known.repo, branch: known.branch })
153 return false
154 }
155 const s: Session = { id, repo: short(about.repo), branch: short(about.branch), since: now, seen: now, status: 'idle', tool: '' }
156 h.sessions.set(id, s)
157 joinOffice(h.world.office, id, { repo: s.repo, branch: s.branch })
158 return true
159}
160
161/** A session leaves, or went quiet: its apprentice clocks out. */
162export function bye(h: Host, id: string) {
163 h.sessions.delete(id)
164 leaveOffice(h.world.office, id)
165}
166
167/** What a session's real work is doing; the village mirrors it the way it mirrored its own session before. */
168export function sessionEvent(h: Host, id: string, e: SessionEvent, now: number) {
169 hello(h, id, {}, now)
170 const s = h.sessions.get(id)!
171 const w = h.world
172 if (e.type === 'tool' && typeof e.tool === 'string') {
173 s.status = 'working'
174 s.tool = short(e.tool, 60)
175 echoTool(w, s.tool)
176 officeWork(w.office, id, { type: 'tool', tool: s.tool })
177 } else if (e.type === 'turn') {
178 s.status = 'idle'
179 s.tool = ''
180 const outcome = e.outcome === 'answer' || e.outcome === 'aborted' ? e.outcome : 'error'
181 const seconds = typeof e.seconds === 'number' && e.seconds >= 0 ? e.seconds : 0
182 echoTurn(w, seconds, outcome)
183 officeWork(w.office, id, { type: 'turn', seconds, outcome })
184 } else if (e.type === 'tests' && typeof e.passed === 'boolean') {
185 echoTests(w, e.passed)
186 officeWork(w.office, id, { type: 'tests', passed: e.passed })
187 }
188}
189
190// ---------- what panes show ----------
191
192/** The pane's picture at `cols` wide (empty without `cells`), its chat, the headlines after `since`; the tab panels with `info`. */
193export function pane(h: Host, cols: number, since: number, info: boolean, cells = true) {
194 const w = h.world
195 const width = Math.max(24, Math.min(MAX_VIEW, Math.floor(cols) || MAX_VIEW))
196 return {
197 cols: width,
198 cells: cells ? encode(frame(w, width)) : '',
199 chat: h.chat,
200 thinking: w.thinking,
201 scene: w.scene,
202 museumOpen: w.projects.museum.done,
203 staff: w.office.staff.filter(a => !a.leaving).length,
204 ticker: ticker(w),
205 announce: h.announce.filter(a => a.seq > since).map(a => a.text),
206 seq: h.announced,
207 info: info ? infoPayload(w) : undefined,
208 }
209}
210
211/** A postcard of the scene; the picture flashes as if a photo was taken. */
212export function takePostcard(h: Host, cols: number): string {
213 h.world.flash = 3
214 return postcard(h.world, Math.max(24, Math.min(MAX_VIEW, Math.floor(cols) || MAX_VIEW)))
215}
216
217export function memoryReport(h: Host): string {
218 const w = h.world
219 const blocks = [`Claude\n${memoryText(w, 'claude', 10)}`]
220 for (const v of VILLAGERS) blocks.push(`${v.name} (friendship ${w.friendship[v.id] ?? 0})\n${memoryText(w, v.id, 5)}`)
221 return blocks.join('\n\n')
222}
223
224/** Everyone forgets everything: memories, friendships and the diaries still to write. */
225export function forget(h: Host) {
226 const fresh = createWorld()
227 h.world.memory = fresh.memory
228 h.world.friendship = fresh.friendship
229 h.world.diaryDue = []
230 for (const j of h.jobs.filter(j => j.kind === 'diary')) h.taken.delete(j.id)
231 h.jobs = h.jobs.filter(j => j.kind !== 'diary')
232}
233
234// ---------- the sessions' API ----------
235
236/** One request to the village's API, as web/server.mjs hands it over (the token already checked). */
237export type ApiRequest = { method: string; path: string; query: (name: string) => string | null; body: unknown }
238/** The answer: a status and maybe a JSON body; `save` when the world must be saved right away. */
239export type ApiAnswer = { status: number; json?: unknown; save?: boolean }
240
241/** The session a request comes from, by its `session` field or parameter; empty when it names none. */
242const sessionOf = (v: unknown) => (typeof v === 'string' ? v.slice(0, 80) : '')
243const field = (body: unknown, key: string): unknown => (body && typeof body === 'object' ? (body as Record<string, unknown>)[key] : undefined)
244
245/** Answers a request to the API; null for a path it does not know. */
246export function api(h: Host, r: ApiRequest, now: number): ApiAnswer | null {
247 const { method, path, query, body } = r
248 if (method === 'POST' && path === '/api/do') {
249 if (!body || typeof body !== 'object') return { status: 400 }
250 act(h, body as HostAction, now)
251 return { status: 204 }
252 }
253 if (method === 'POST' && (path === '/api/hello' || path === '/api/bye' || path === '/api/event')) {
254 const id = sessionOf(field(body, 'session'))
255 if (!id) return { status: 400 }
256 if (path === '/api/hello') return { status: 200, json: { isNew: hello(h, id, { repo: field(body, 'repo'), branch: field(body, 'branch') }, now) } }
257 if (path === '/api/bye') bye(h, id)
258 else sessionEvent(h, id, body as SessionEvent, now)
259 return { status: 204 }
260 }
261 const session = sessionOf(query('session'))
262 if (method === 'GET' && path === '/api/pane') {
263 if (session) hello(h, session, {}, now)
264 return { status: 200, json: pane(h, Number(query('cols')), Number(query('since')) || 0, query('info') === '1', query('cells') !== '0') }
265 }
266 if (method === 'GET' && path === '/api/postcard') return { status: 200, json: { text: takePostcard(h, Number(query('cols'))) } }
267 if (method === 'GET' && path === '/api/memory') return { status: 200, json: { text: memoryReport(h) } }
268 if (method === 'POST' && path === '/api/forget') {
269 forget(h)
270 return { status: 204, save: true }
271 }
272 if (method === 'GET' && path === '/api/jobs') {
273 if (session) hello(h, session, {}, now)
274 const job = takeJob(h, now, session || undefined)
275 return job ? { status: 200, json: job } : { status: 204 }
276 }
277 const answer = /^\/api\/jobs\/(\d+)$/.exec(path)
278 if (method === 'POST' && answer) {
279 const text = field(body, 'text')
280 const reason = field(body, 'reason')
281 const known = finishJob(h, Number(answer[1]), typeof text === 'string' ? text : null, typeof reason === 'string' ? reason.slice(0, 200) : undefined)
282 return { status: known ? 204 : 404 }
283 }
284 return null
285}
286hooks/web.ts 185 lines1// What the browser view is sent: the map when it changes, and the moving parts every tick.
2// Pure logic, no `$`: register.tsx posts these to the local server that web/server.mjs runs.
3
4import { BUGS, MUSEUM, MUSEUM_REWARD, PROJECTS } from './catalog'
5import { active, clock, dcMood, eventLabels, H, projectIds, season, seasonDay, VILLAGERS, W } from './game'
6import type { Info, LogLine } from '../types'
7import type { World } from './game'
8import { caseAt, HALL_TITLES, IH, interiorTile, IW } from './museum'
9import { atSeat, DESKS, deskStatus, LOOKS, OFFICE_TITLE, officeTile, OH, OW } from './office'
10
11const TILE_CODE: Record<string, string> = {
12 grass: 'g', tuft: 't', path: 'p', plaza: 'z', bridge: 'b', water: 'w', fountain: 'f', tree: 'T', apple: 'a',
13 roof: 'r', wall: 'l', door: 'd', fence: 'n', soil: 's', flower: 'o', item: 'i',
14}
15const ROOM_CODE: Record<string, string> = { wall: 'w', floor: 'f', carpet: 'c', case: 's', door: 'd', plant: 'p', void: ' ' }
16const OFFICE_CODE: Record<string, string> = { wall: 'w', window: 'v', floor: 'f', carpet: 'c', desk: 'k', door: 'd', plant: 'p', coffee: 'o', board: 'b', void: ' ' }
17const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, '0')}`
18
19export const NAMES: Record<string, string> = {
20 you: 'You', claude: 'Claude', me: 'Claude', mochi: 'Mochi', kiki: 'Kiki', hoot: 'Hoot', ...Object.fromEntries(VILLAGERS.map(v => [v.id, v.name])),
21}
22export const COLORS: Record<string, string> = {
23 you: '#7ec8e3', claude: '#d97757', me: '#d97757', mochi: '#c8b8b8', kiki: '#ffb300', hoot: '#bcaaa4', event: '#8bc34a',
24 ...Object.fromEntries(VILLAGERS.map(v => [v.id, hex(v.color)])),
25}
26
27/** The chores a browser may ask for without a model call, by key. */
28export const CHORES: Record<string, { label: string; plan: { do: string; count?: number }[] }> = {
29 fish: { label: 'Fish', plan: [{ do: 'fish' }] },
30 chop: { label: 'Chop ×5', plan: [{ do: 'chop', count: 5 }] },
31 water: { label: 'Water', plan: [{ do: 'water' }] },
32 harvest: { label: 'Harvest', plan: [{ do: 'harvest' }] },
33 sell: { label: 'Sell', plan: [{ do: 'sell' }] },
34 mail: { label: 'Mail', plan: [{ do: 'mail' }] },
35}
36
37/** The map as the browser draws it: tile codes row by row, and what stands on them. */
38export function mapPayload(w: World) {
39 if (w.scene === 'office') {
40 const rows: string[] = []
41 for (let y = 0; y < OH; y++) {
42 let row = ''
43 for (let x = 0; x < OW; x++) row += OFFICE_CODE[officeTile(x, y)] ?? 'f'
44 rows.push(row)
45 }
46 return { scene: 'office', w: OW, h: OH, rows, desks: DESKS, title: OFFICE_TITLE }
47 }
48 if (w.scene === 'museum') {
49 const rows: string[] = []
50 const cases: { x: number; y: number; section: string; color: string; shown: boolean }[] = []
51 for (let y = 0; y < IH; y++) {
52 let row = ''
53 for (let x = 0; x < IW; x++) {
54 const t = interiorTile(x, y)
55 row += ROOM_CODE[t] ?? 'f'
56 const c = t === 'case' ? caseAt(x, y) : null
57 if (c) cases.push({ x, y, section: c.section, color: hex(c.color), shown: w.museum.includes(c.piece) })
58 }
59 rows.push(row)
60 }
61 return { scene: 'museum', w: IW, h: IH, rows, cases, halls: HALL_TITLES }
62 }
63 const rows: string[] = []
64 const deco: Record<number, string> = {}
65 const paint: Record<number, string> = {}
66 const crops: Record<number, [string, number, boolean]> = {}
67 const fruit: Record<number, number> = {}
68 for (let y = 0; y < H; y++) {
69 let row = ''
70 for (let x = 0; x < W; x++) {
71 const i = y * W + x
72 const t = w.tiles[i]
73 row += TILE_CODE[t] ?? 'g'
74 if (w.deco[i]) deco[i] = w.deco[i][0]
75 if (w.paint[i] && (t === 'roof' || t === 'wall' || t === 'item')) paint[i] = hex(w.paint[i][1])
76 const c = w.crops[i]
77 if (c) crops[i] = [c.kind ?? 'turnip', Math.min(3, Math.floor(c.stage)), c.watered]
78 if (t === 'apple') fruit[i] = w.fruit[i] ?? 0
79 }
80 rows.push(row)
81 }
82 return { scene: 'village', w: W, h: H, rows, deco, paint, crops, fruit, season: season(w) }
83}
84
85type Mover = { x: number; y: number; bubble?: { text: string }; asleep?: boolean; nap?: number }
86
87/** Everything that moves or changes by the tick: people, the clock, the weather, the HUD and the chat. */
88export function statePayload(w: World, log: readonly LogLine[]) {
89 const museum = w.scene === 'museum'
90 const office = w.scene === 'office'
91 const mover = (key: string, m: Mover, extra: Record<string, unknown> = {}) => ({
92 k: key, x: m.x, y: m.y, say: m.bubble?.text, name: NAMES[key], color: COLORS[key], ...extra,
93 })
94 const inside = mover('me', w.inside, { say: w.thinking ? '…' : w.player.bubble?.text })
95 const movers = museum
96 ? [mover('hoot', w.curator), inside]
97 : office
98 ? [...w.office.staff.map(a => {
99 const look = LOOKS[a.look]
100 return mover(`app:${a.id}`, a, { name: a.name, color: hex(look.shirt), look: { hair: hex(look.hair), shirt: hex(look.shirt), pants: hex(look.pants) }, working: a.status === 'working', sit: atSeat(a) && a.desk >= 0 })
101 }), inside]
102 : [
103 mover('mochi', w.mochi, { asleep: w.mochi.asleep || w.mochi.nap > 0 }),
104 ...(w.merchant ? [mover('kiki', w.merchant)] : []),
105 ...VILLAGERS.map(v => mover(v.id, w.npcs[v.id], { asleep: w.npcs[v.id].asleep })),
106 mover('me', w.player, w.thinking ? { say: '…' } : {}),
107 ]
108 const working = w.current?.do === '_work' || (!!w.echo && !w.queue.length && !w.current)
109 const donated = new Set(w.museum)
110 return {
111 tick: w.tick,
112 minutes: w.minutes,
113 clock: clock(w),
114 day: w.day,
115 season: season(w),
116 seasonDay: seasonDay(w),
117 weather: w.weather,
118 bells: w.bells,
119 label: w.label,
120 thinking: w.thinking,
121 scene: w.scene,
122 events: eventLabels(w),
123 festival: !!active(w, 'festival') || !!active(w, 'season'),
124 fireworks: !!active(w, 'season') && season(w) === 'summer',
125 meteor: !!active(w, 'meteor'),
126 club: w.adults && w.projects.club.done,
127 factory: w.projects.factory.done ? { boom: w.boom, rubble: w.rubble > 0 } : null,
128 datacenter: w.projects.datacenter.done ? { battery: Math.round(w.dc.battery), mood: dcMood(w) } : null,
129 inv: Object.entries(w.inv).filter(([, n]) => n > 0),
130 movers,
131 desks: office ? deskStatus(w.office) : undefined,
132 staff: w.office.staff.filter(a => !a.leaving).length,
133 critters: museum || office ? [] : w.critters.map(c => ({ x: c.x, y: c.y, kind: c.kind, color: hex(c.color), flies: !!BUGS[c.kind]?.flies })),
134 pickups: museum || office ? [] : w.pickups.map(p => ({ x: p.x, y: p.y, star: p.kind === 'star' })),
135 digs: museum || office ? [] : w.digSpots,
136 unread: w.mail.some(m => !m.read && m.day <= w.day),
137 work: w.current?.do === '_work' ? (w.current as { kind: string }).kind : working && w.echo ? `echo:${w.echo.kind}` : '',
138 cheer: w.cheer > 0,
139 cloud: w.cloud > 0,
140 rainbow: w.rainbow,
141 flash: w.flash,
142 museum: Object.entries(MUSEUM).map(([k, v]) => `${k} ${v.filter(m => donated.has(m)).length}/${v.length}`).join(' · '),
143 museumOpen: w.projects.museum.done,
144 chores: Object.entries(CHORES).map(([key, c]) => ({ key, label: c.label })),
145 log: log.slice(-40).map(l => ({ who: NAMES[l.who] ?? l.who, color: COLORS[l.who] ?? '#cccccc', text: l.text, event: l.who === 'event' })),
146 }
147}
148
149/** What the pane's Village and Memories tabs show, for the browser's side panel: sent when it changes, not every tick. */
150export function infoPayload(w: World): Info {
151 const projects = projectIds(w).map(id => {
152 const def = PROJECTS[id]
153 const st = w.projects[id]
154 const items = Object.entries(def.items).map(([name, need]) => ({ name, have: Math.min(need, st.items[name] ?? 0), need }))
155 const have = st.bells + Object.values(st.items).reduce((a, b) => a + b, 0) * 100
156 const total = def.bells + Object.values(def.items).reduce((a, b) => a + b, 0) * 100
157 const about = id === 'datacenter' && st.done ? `${def.about} - right now it is ${dcMood(w)}` : def.about
158 return { name: def.name, about, done: !!st.done, bells: st.bells, need: def.bells, items, pct: st.done ? 1 : Math.min(1, have / Math.max(1, total)) }
159 })
160 const donated = new Set(w.museum)
161 const sections = Object.entries(MUSEUM).map(([name, pieces]) => ({
162 name, have: pieces.filter(p => donated.has(p)).length, total: pieces.length, pieces: pieces.map(p => ({ name: p, got: donated.has(p) })),
163 }))
164 const people = ['claude', ...VILLAGERS.map(v => v.id)].map(id => {
165 const list = w.memory[id] ?? []
166 const diary = list.filter(m => m.kind === 'diary').slice(-1)[0]
167 const last = list.filter(m => m.kind === 'event').slice(-1)[0]
168 return {
169 name: NAMES[id], color: COLORS[id], hearts: id === 'claude' ? null : w.friendship[id] ?? 0,
170 diary: diary ? `Day ${diary.day}: ${diary.text}` : last ? last.text : 'no memories yet',
171 facts: list.filter(m => m.kind === 'fact').slice(-3).map(m => m.text),
172 }
173 })
174 const office = w.office.staff.filter(a => !a.leaving).map(a => ({
175 name: a.name, color: hex(LOOKS[a.look].shirt), repo: a.repo, branch: a.branch, status: a.status, tool: a.tool,
176 }))
177 return {
178 projects,
179 office,
180 museum: { open: w.projects.museum.done, reward: MUSEUM_REWARD, sections },
181 requests: w.requests.map(r => ({ who: NAMES[r.who], color: COLORS[r.who], item: r.item, left: r.amount - r.given, reward: r.reward, done: !!r.done })),
182 people,
183 }
184}
185hooks/catalog.ts 125 lines1// What Bells and items are good for: crops, the shop, recipes, community projects, the museum and requests.
2// Pure data, no imports.
3
4export type CropKind = 'turnip' | 'carrot' | 'strawberry' | 'pumpkin'
5/** `growth` is how many stages a watered crop grows per day; stage 3 is ripe. */
6export const CROPS: Record<CropKind, { seeds: string | null; growth: number; glyph: string; color: number; days: number }> = {
7 turnip: { seeds: null, growth: 1, glyph: '♠', color: 0xf5f5f5, days: 3 },
8 carrot: { seeds: 'carrot seeds', growth: 1.5, glyph: 'v', color: 0xff9800, days: 2 },
9 strawberry: { seeds: 'strawberry seeds', growth: 1, glyph: '♥', color: 0xe53935, days: 3 },
10 pumpkin: { seeds: 'pumpkin seeds', growth: 0.75, glyph: '●', color: 0xff6f00, days: 4 },
11}
12export const isCrop = (v: unknown): v is CropKind => typeof v === 'string' && v in CROPS
13
14export type ShopItem = { price: number; kind: 'seeds' | 'tool' | 'decor'; about: string }
15export const SHOP: Record<string, ShopItem> = {
16 'carrot seeds': { price: 40, kind: 'seeds', about: 'ripe in 2 days, carrots sell for 110' },
17 'strawberry seeds': { price: 80, kind: 'seeds', about: 'ripe in 3 days, strawberries sell for 220' },
18 'pumpkin seeds': { price: 150, kind: 'seeds', about: 'ripe in 4 days, pumpkins sell for 450' },
19 'golden rod': { price: 1500, kind: 'tool', about: 'no more old boots, rare fish bite more often' },
20 'bug net': { price: 300, kind: 'tool', about: 'catch bugs for the museum or to sell' },
21 shovel: { price: 300, kind: 'tool', about: 'dig up the cracked spots for fossils, gems and buried Bells' },
22 lamp: { price: 300, kind: 'decor', about: 'glows at night' },
23 'flower pot': { price: 200, kind: 'decor', about: 'a cheerful pot of flowers' },
24 'bird house': { price: 400, kind: 'decor', about: 'birds love it' },
25 bench: { price: 500, kind: 'decor', about: 'a place to rest' },
26 statue: { price: 1800, kind: 'decor', about: 'a fancy stone statue' },
27}
28/** How each decoration looks on the map: glyph and color. */
29export const DECOR: Record<string, [string, number]> = {
30 snowman: ['8', 0xffffff], lamp: ['i', 0xffd54f], 'flower pot': ['o', 0xf06292], 'bird house': ['^', 0xa1887f], bench: ['=', 0x8d6e63], statue: ['Ω', 0xcfd8dc],
31}
32
33/** `fish` in a recipe means any one fish. */
34export const RECIPES: Record<string, { needs: Record<string, number>; price: number }> = {
35 'apple pie': { needs: { apple: 2 }, price: 300 },
36 'turnip soup': { needs: { turnip: 2 }, price: 250 },
37 'grilled fish': { needs: { fish: 1 }, price: 320 },
38 'carrot cake': { needs: { carrot: 2, apple: 1 }, price: 480 },
39 'strawberry jam': { needs: { strawberry: 2 }, price: 600 },
40 'pumpkin pie': { needs: { pumpkin: 1, apple: 1 }, price: 850 },
41}
42
43export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
44export const SEASONS: Season[] = ['spring', 'summer', 'autumn', 'winter']
45export const SEASON_DAYS = 7
46/** Each season has one fish that bites only then. */
47export const SEASONAL_FISH: Record<Season, string> = { spring: 'trout', summer: 'catfish', autumn: 'salmon', winter: 'pike' }
48/** Each season's festival, on its fourth day: where everyone gathers and when. */
49export const SEASON_FESTIVALS: Record<Season, { name: string; place: string; start: number; end: number; about: string }> = {
50 spring: { name: 'Blossom picnic', place: 'orchard', start: 11, end: 15, about: 'a picnic under the blossoming apple trees' },
51 summer: { name: 'Fireworks night', place: 'plaza', start: 20, end: 22.5, about: 'fireworks over the plaza' },
52 autumn: { name: 'Harvest fair', place: 'plaza', start: 10, end: 16, about: 'bring a pumpkin for the biggest-pumpkin prize (1000 Bells), any crop wins 300' },
53 winter: { name: 'Winter lights', place: 'plaza', start: 17, end: 21, about: 'lights everywhere and a snowman on the plaza' },
54}
55
56export const FISH_KINDS = ['sea bass', 'bluegill', 'carp', 'koi', 'goldfish', 'sturgeon', 'trout', 'catfish', 'salmon', 'pike']
57
58/** Bugs: when and where they show up, how they look, what they fetch. `night` null means any time. */
59export type BugHabitat = 'flower' | 'water' | 'tree' | 'grass' | 'house'
60export const BUGS: Record<string, { seasons: Season[]; night: boolean | null; habitat: BugHabitat; glyph: string; colors: number[]; price: number; flies: boolean }> = {
61 butterfly: { seasons: ['spring', 'summer'], night: false, habitat: 'flower', glyph: 'ж', colors: [0xffeb3b, 0xff80ab, 0x80d8ff, 0xffffff], price: 160, flies: true },
62 ladybug: { seasons: ['spring'], night: false, habitat: 'flower', glyph: '•', colors: [0xe53935], price: 100, flies: false },
63 dragonfly: { seasons: ['summer', 'autumn'], night: false, habitat: 'water', glyph: '+', colors: [0x26c6da, 0xef5350], price: 230, flies: true },
64 firefly: { seasons: ['summer'], night: true, habitat: 'water', glyph: '·', colors: [0xfff176], price: 300, flies: true },
65 cicada: { seasons: ['summer'], night: false, habitat: 'tree', glyph: '&', colors: [0xa1887f], price: 250, flies: false },
66 beetle: { seasons: ['summer', 'autumn'], night: false, habitat: 'tree', glyph: 'ö', colors: [0x5d4037, 0x1565c0], price: 400, flies: false },
67 cricket: { seasons: ['autumn'], night: true, habitat: 'grass', glyph: '`', colors: [0x9ccc65], price: 150, flies: false },
68 moth: { seasons: ['autumn', 'winter'], night: true, habitat: 'house', glyph: 'v', colors: [0xd7ccc8], price: 120, flies: true },
69}
70
71export const FOSSILS = ['ammonite', 'trilobite', 'amber', 'dino tooth', 'fern fossil']
72export const KEEPSAKES = ['photo of Tom', 'pressed rose', 'wooden bird', 'drawing by Pip', 'pie recipe']
73export const isFish = (item: string) => FISH_KINDS.includes(item)
74/** Each fish's size range in cm; the biggest catch wins contests and sets records. */
75export const FISH_SIZE: Record<string, [number, number]> = {
76 'sea bass': [30, 80], bluegill: [10, 25], carp: [30, 90], koi: [40, 100], goldfish: [5, 20],
77 sturgeon: [80, 250], trout: [25, 70], catfish: [30, 120], salmon: [50, 110], pike: [40, 130],
78}
79
80export type ProjectId = 'lamps' | 'benches' | 'park' | 'pier' | 'museum' | 'club' | 'factory' | 'datacenter'
81export const PROJECTS: Record<ProjectId, { name: string; bells: number; items: Record<string, number>; about: string }> = {
82 benches: { name: 'Plaza benches', bells: 800, items: { wood: 6 }, about: 'benches around the fountain' },
83 park: { name: 'Riverside park', bells: 1200, items: { flower: 5 }, about: 'flower beds along the river' },
84 lamps: { name: 'Street lamps', bells: 1500, items: { wood: 5 }, about: 'lamp posts that light the roads at night' },
85 pier: { name: 'Fishing pier', bells: 2000, items: { wood: 8 }, about: 'a pier into the pond where rare fish bite' },
86 museum: { name: 'Museum', bells: 3000, items: { wood: 10 }, about: 'donate fish and star fragments to fill its collection' },
87 factory: { name: 'Bomb factory', bells: 1800, items: { wood: 8 }, about: 'a rickety workshop full of fireworks and cartoon bombs - it blows up now and then, so stand well back' },
88 datacenter: { name: 'AI data center', bells: 2200, items: { wood: 8 }, about: 'a humming server hall run on three fixed bikes - kept charged it is kind and computes Bells and apples, run flat it turns evil and blows up the bomb factory' },
89 club: { name: 'Velvet Club', bells: 2500, items: { wood: 6 }, about: 'an adults-only strip club with a neon sign, open 21:00-03:00' },
90}
91export const isProject = (v: unknown): v is ProjectId => typeof v === 'string' && v in PROJECTS
92
93export const MUSEUM: Record<string, string[]> = {
94 fish: FISH_KINDS,
95 bugs: Object.keys(BUGS),
96 fossils: FOSSILS,
97 oddities: ['star fragment', 'old boot', 'old coin', 'amethyst', 'ruby'],
98}
99export const MUSEUM_PIECES = Object.values(MUSEUM).flat()
100export const MUSEUM_REWARD = 5000
101export const SECTION_REWARD = 1500
102
103export const PRICES: Record<string, number> = {
104 turnip: 60, carrot: 110, strawberry: 220, pumpkin: 450, apple: 25, wood: 20, flower: 10,
105 'sea bass': 120, bluegill: 90, carp: 80, koi: 400, goldfish: 650, sturgeon: 900, 'old boot': 5,
106 trout: 200, catfish: 220, salmon: 300, pike: 350,
107 ammonite: 600, trilobite: 700, amber: 900, 'dino tooth': 1200, 'fern fossil': 500, 'old coin': 250, amethyst: 600, ruby: 1500,
108 ...Object.fromEntries(Object.entries(BUGS).map(([k, b]) => [k, b.price])),
109 'lucky charm': 300, 'star fragment': 800,
110 'carrot seeds': 20, 'strawberry seeds': 40, 'pumpkin seeds': 75,
111 ...Object.fromEntries(Object.entries(RECIPES).map(([k, r]) => [k, r.price])),
112}
113/** Kept when the bag is sold: tools, decorations and other people's lost things. */
114export const isSellable = (item: string) =>
115 !item.includes("'s ") && !KEEPSAKES.includes(item) && item !== 'snowman' && !(item in SHOP && SHOP[item].kind !== 'seeds')
116
117/** What each villager may ask for in a request. `fish` means any fish. */
118export const WANTS: Record<string, string[]> = {
119 tom: ['fish', 'apple', 'wood'],
120 rosa: ['flower', 'strawberry', 'apple'],
121 bruno: ['wood', 'apple', 'pumpkin'],
122 pip: ['apple', 'strawberry', 'fish'],
123 mabel: ['turnip', 'apple', 'carrot', 'pumpkin'],
124}
125hooks/museum.ts 180 lines1// The museum's interior: four halls of display cases, the curator's words about each piece, and indoor walking.
2// Pure logic, no `$`.
3
4import { BUGS, FOSSILS, MUSEUM, SEASONAL_FISH } from './catalog'
5
6export const IW = 60
7export const IH = 16
8type Pt = { x: number; y: number }
9type ITile = 'wall' | 'floor' | 'carpet' | 'case' | 'door' | 'plant'
10
11const grid: ITile[] = Array(IW * IH).fill('floor')
12const titles: Record<number, string> = {}
13const at = (x: number, y: number) => y * IW + x
14const fill = (x0: number, y0: number, x1: number, y1: number, t: ITile) => {
15 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) grid[at(x, y)] = t
16}
17/** The hall names over the doors, for renderers that draw them as words. */
18export const HALL_TITLES: { x: number; y: number; text: string }[] = []
19const title = (x: number, y: number, text: string) => {
20 for (let i = 0; i < text.length; i++) titles[at(x + i, y)] = text[i]
21 HALL_TITLES.push({ x, y, text: text.trim() })
22}
23
24// outer walls, the cross walls between the halls and their openings
25fill(0, 0, IW - 1, 0, 'wall'); fill(0, IH - 1, IW - 1, IH - 1, 'wall'); fill(0, 0, 0, IH - 1, 'wall'); fill(IW - 1, 0, IW - 1, IH - 1, 'wall')
26fill(1, 9, IW - 2, 9, 'wall'); fill(30, 1, 30, 8, 'wall'); fill(22, 10, 22, 14, 'wall'); fill(38, 10, 38, 14, 'wall')
27grid[at(26, 9)] = 'floor'; grid[at(34, 9)] = 'floor'; grid[at(22, 12)] = 'floor'; grid[at(38, 12)] = 'floor'
28fill(23, 10, 37, 14, 'carpet')
29grid[at(30, IH - 1)] = 'door'
30for (const [x, y] of [[24, 10], [36, 10], [24, 14], [36, 14]]) grid[at(x, y)] = 'plant'
31title(11, 0, ' AQUARIUM '); title(40, 0, ' INSECTS '); title(6, 9, ' FOSSILS '); title(43, 9, ' ODDITIES ')
32
33/** Which section each case belongs to, and where each piece is shown. */
34const SECTION_OF: Record<string, string> = {}
35export const CASES: Record<string, Pt> = {}
36const place = (pieces: string[], spots: Pt[], section: string) => {
37 pieces.forEach((piece, k) => {
38 const spot = spots[k]
39 if (!spot) return
40 CASES[piece] = spot
41 SECTION_OF[piece] = section
42 grid[at(spot.x, spot.y)] = 'case'
43 })
44}
45place(MUSEUM.fish, [3, 6].flatMap(y => [4, 9, 14, 19, 24].map(x => ({ x, y }))), 'fish')
46place(MUSEUM.bugs, [3, 6].flatMap(y => [35, 41, 47, 53].map(x => ({ x, y }))), 'bugs')
47place(MUSEUM.fossils, [3, 7, 11, 15, 19].map(x => ({ x, y: 12 })), 'fossils')
48place(MUSEUM.oddities, [41, 45, 49, 53, 57].map(x => ({ x, y: 12 })), 'oddities')
49
50export const ENTRANCE: Pt = { x: 30, y: 14 }
51export const CURATOR_SPOT: Pt = { x: 30, y: 11 }
52
53const walkableIn = (x: number, y: number) => x >= 0 && y >= 0 && x < IW && y < IH && (grid[at(x, y)] === 'floor' || grid[at(x, y)] === 'carpet')
54
55/** Where to stand to look at a piece: below its case, or above it. */
56export function viewSpot(piece: string): Pt {
57 const c = CASES[piece] ?? ENTRANCE
58 return walkableIn(c.x, c.y + 1) ? { x: c.x, y: c.y + 1 } : { x: c.x, y: c.y - 1 }
59}
60
61export function indoorPath(from: Pt, to: Pt): Pt[] {
62 const prev = new Int32Array(IW * IH).fill(-2)
63 const start = at(from.x, from.y)
64 prev[start] = -1
65 const queue = [start]
66 for (let q = 0; q < queue.length; q++) {
67 const cur = queue[q]
68 const p = { x: cur % IW, y: Math.floor(cur / IW) }
69 if (p.x === to.x && p.y === to.y) {
70 const path: Pt[] = []
71 for (let c = cur; c !== start; c = prev[c]) path.unshift({ x: c % IW, y: Math.floor(c / IW) })
72 return path
73 }
74 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
75 const nx = p.x + dx
76 const ny = p.y + dy
77 if (!walkableIn(nx, ny) || prev[at(nx, ny)] !== -2) continue
78 prev[at(nx, ny)] = cur
79 queue.push(at(nx, ny))
80 }
81 }
82 return []
83}
84
85const FISH_COLORS: Record<string, number> = {
86 'sea bass': 0x90a4ae, bluegill: 0x4fc3f7, carp: 0xbcaaa4, koi: 0xff7043, goldfish: 0xffb300,
87 sturgeon: 0x78909c, trout: 0xa5d6a7, catfish: 0x8d6e63, salmon: 0xff8a80, pike: 0x9ccc65,
88}
89const LOOKS: Record<string, [string, number]> = {
90 ammonite: ['@', 0xd7ccc8], trilobite: ['¥', 0xbcaaa4], amber: ['o', 0xffb300], 'dino tooth': ['V', 0xfff8e1], 'fern fossil': ['ψ', 0xa1887f],
91 'star fragment': ['*', 0xfff176], 'old boot': ['b', 0x8d6e63], 'old coin': ['o', 0xffd54f], amethyst: ['♦', 0xba68c8], ruby: ['♦', 0xe53935],
92}
93/** One interior tile, `void` outside the walls. */
94export const interiorTile = (x: number, y: number): ITile | 'void' => (x < 0 || y < 0 || x >= IW || y >= IH ? 'void' : grid[at(x, y)])
95
96/** What a case at x, y shows: its piece, section and colour. */
97export function caseAt(x: number, y: number): { piece: string; section: string; color: number } | null {
98 const piece = Object.keys(CASES).find(k => CASES[k].x === x && CASES[k].y === y)
99 if (!piece) return null
100 const section = SECTION_OF[piece]
101 const color = section === 'fish' ? FISH_COLORS[piece] ?? 0xffffff : section === 'bugs' ? BUGS[piece].colors[0] : (LOOKS[piece] ?? ['?', 0xffffff])[1]
102 return { piece, section, color }
103}
104
105const CASE_BG: Record<string, number> = { fish: 0x1e6fa8, bugs: 0x33691e, fossils: 0x616161, oddities: 0x4a148c }
106
107/** One interior cell: [code point, foreground, background]; outside the walls is dark. */
108export function museumCell(x: number, y: number, donated: Set<string>, tick: number): [number, number, number] {
109 const cp = (s: string) => s.codePointAt(0)!
110 if (x < 0 || y < 0 || x >= IW || y >= IH) return [32, 0, 0x1a1210]
111 const i = at(x, y)
112 const letter = titles[i]
113 switch (grid[i]) {
114 case 'wall': return letter ? [cp(letter), 0xffe0b2, 0x4e342e] : [32, 0, 0x4e342e]
115 case 'door': return [cp('▒'), 0x3e2723, 0x8d6e63]
116 case 'carpet': return [32, 0, (x + y) % 2 ? 0x8e2b2b : 0x9c3333]
117 case 'plant': return [cp('♣'), 0x2e7d32, 0x9c3333]
118 case 'case': {
119 const piece = Object.keys(CASES).find(k => CASES[k].x === x && CASES[k].y === y)!
120 const section = SECTION_OF[piece]
121 if (!donated.has(piece)) return [cp('·'), 0x757575, 0x3e3e3e]
122 if (section === 'fish') return [cp(Math.floor(tick / 8 + x) % 2 ? '>' : '<'), FISH_COLORS[piece] ?? 0xffffff, CASE_BG.fish]
123 if (section === 'bugs') return [cp(BUGS[piece].glyph), BUGS[piece].colors[0], CASE_BG.bugs]
124 const [g, c] = LOOKS[piece] ?? ['?', 0xffffff]
125 return [cp(g), c, CASE_BG[section]]
126 }
127 default: return [32, 0, (x + y) % 2 ? 0xd7b98e : 0xcaa77a]
128 }
129}
130
131/** What Hoot the curator says about a piece on display. */
132export const FACTS: Record<string, string> = {
133 'sea bass': 'Hoo! The humble sea bass. Common, yes - but so very proud!',
134 bluegill: 'Bluegills have a little blue ear. They are listening, hoo.',
135 carp: 'Carp can live for decades. This one has seen things.',
136 koi: 'A koi! Bred for color for centuries. Simply exquisite.',
137 goldfish: 'A goldfish of the wild sort. Rarer than you think!',
138 sturgeon: 'Sturgeon are older than the dinosaurs. A living fossil, hoo!',
139 trout: 'Trout love cold, clear water in spring. Speckled beauty!',
140 catfish: 'Those whiskers taste the water. Imagine tasting with your face!',
141 salmon: 'Salmon swim upstream each autumn. Such determination!',
142 pike: 'The pike - a toothy winter hunter. Keep your fingers away!',
143 butterfly: 'Butterflies taste with their feet. Fascinating and slightly odd.',
144 ladybug: 'Ladybugs eat aphids by the hundred. Every gardener\'s friend.',
145 dragonfly: 'Dragonflies can fly backwards. Show-offs, really.',
146 firefly: 'Fireflies glow to find each other. Romance in the dark, hoo.',
147 cicada: 'Cicadas sing louder than any bug. My poor ears, hoo...',
148 beetle: 'Beetles are the most numerous animals on earth. Impressive!',
149 cricket: 'Count a cricket\'s chirps and you can guess the temperature!',
150 moth: 'Moths navigate by moonlight. Our lamps confuse them, poor dears.',
151 ammonite: 'An ammonite! Its spiral shell is millions of years old.',
152 trilobite: 'Trilobites crawled the sea floor before there were trees!',
153 amber: 'Amber is fossilized tree resin. Sometimes bugs are trapped inside!',
154 'dino tooth': 'A dinosaur tooth! Sharp, enormous, wonderful. Hoo hoo!',
155 'fern fossil': 'Ferns have hardly changed in 300 million years. Timeless.',
156 'star fragment': 'A piece of a falling star. It still glows, faintly.',
157 'old boot': 'An old boot. I... appreciate the donation. Truly.',
158 'old coin': 'A coin from the village\'s early days. History in your hand!',
159 amethyst: 'Amethyst - purple quartz. Said to bring calm and clear thoughts.',
160 ruby: 'A ruby! The rarest find in all the village. Magnificent!',
161}
162
163/** Where to find a piece that is still missing. */
164export function hint(piece: string): string {
165 const season = Object.entries(SEASONAL_FISH).find(([, f]) => f === piece)?.[0]
166 if (season) return `It only bites in ${season}.`
167 if (piece === 'sturgeon') return 'They bite with a golden rod, or from the pier.'
168 if (piece === 'koi' || piece === 'goldfish') return 'A rare catch - a golden rod helps.'
169 if (piece in BUGS) {
170 const b = BUGS[piece]
171 const where = { flower: 'flowers', water: 'the water', tree: 'trees', grass: 'the grass', house: 'lit houses' }[b.habitat]
172 return `Look near ${where} in ${b.seasons.join(' or ')}, ${b.night ? 'at night' : 'by day'}.`
173 }
174 if (FOSSILS.includes(piece)) return 'Dig the cracked spots with a shovel.'
175 if (piece === 'star fragment') return 'They fall during meteor showers.'
176 if (piece === 'old boot') return 'Fish without a golden rod... you will get one.'
177 if (piece === 'old coin' || piece === 'amethyst' || piece === 'ruby') return 'Dig the cracked spots - rubies are very rare.'
178 return 'Keep exploring!'
179}
180hooks/office.ts 302 lines1// The office: a desk and an apprentice for every Claude Code session that is connected to the village.
2// The apprentice walks in when its session says hello, sits at its desk and types while the session runs
3// tools, cheers when a turn is done, sighs at errors, fetches coffee when idle and walks out when the session
4// ends. Pure logic, no `$`.
5
6export const OW = 40
7export const OH = 12
8type Pt = { x: number; y: number }
9type OTile = 'wall' | 'window' | 'floor' | 'carpet' | 'desk' | 'door' | 'plant' | 'coffee' | 'board'
10/** What a session's tool call looks like at a desk; the same kinds the village Claude mirrors. */
11export type ToolKind = 'edit' | 'shell' | 'read' | 'web' | 'agent' | 'other'
12export type Status = 'idle' | 'working' | 'done' | 'error'
13
14export type Apprentice = Pt & {
15 /** The session's id. */
16 id: string
17 name: string
18 repo: string
19 branch: string
20 /** Index into DESKS, or -1 standing by the wall when every desk is taken. */
21 desk: number
22 path: Pt[]
23 bubble?: { text: string; ticks: number }
24 status: Status
25 kind: ToolKind
26 tool: string
27 /** What the current tool call does, from the tool's name ("editing a file"), or '' when unknown. */
28 doing: string
29 /** Ticks left before `done` or `error` calms down to `idle`. */
30 calm: number
31 /** Ticks left at the coffee machine; 0 when not on a coffee break. */
32 coffee: number
33 /** Ticks spent idle at the desk; a coffee break only comes after a while. */
34 rest: number
35 leaving: boolean
36 /** Picks the apprentice's colours from LOOKS. */
37 look: number
38}
39export type Office = { staff: Apprentice[] }
40
41const grid: OTile[] = Array(OW * OH).fill('floor')
42const titles: Record<number, string> = {}
43const at = (x: number, y: number) => y * OW + x
44const fill = (x0: number, y0: number, x1: number, y1: number, t: OTile) => {
45 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) grid[at(x, y)] = t
46}
47
48fill(0, 0, OW - 1, 0, 'wall'); fill(0, OH - 1, OW - 1, OH - 1, 'wall'); fill(0, 0, 0, OH - 1, 'wall'); fill(OW - 1, 0, OW - 1, OH - 1, 'wall')
49for (const x of [4, 8, 30, 34]) grid[at(x, 0)] = 'window'
50' OFFICE '.split('').forEach((c, k) => { titles[at(16 + k, 0)] = c })
51fill(1, 5, OW - 2, 6, 'carpet')
52grid[at(20, OH - 1)] = 'door'
53grid[at(2, 1)] = 'coffee'
54grid[at(OW - 3, 1)] = 'board'; grid[at(OW - 4, 1)] = 'board'
55for (const [x, y] of [[1, 10], [OW - 2, 10], [12, 1], [26, 1]]) grid[at(x, y)] = 'plant'
56
57/** Each desk's left cell (the screen); the keyboard is the cell to its right and the chair below the screen. */
58export const DESKS: Pt[] = [3, 8].flatMap(y => [4, 11, 18, 25, 32].map(x => ({ x, y })))
59for (const d of DESKS) { grid[at(d.x, d.y)] = 'desk'; grid[at(d.x + 1, d.y)] = 'desk' }
60/** Where an apprentice sits at desk `k`, and where a visitor stands to talk with them. */
61export const seat = (k: number): Pt => (k >= 0 ? { x: DESKS[k].x, y: DESKS[k].y + 1 } : { x: 5 + ((-k) % 12) * 2, y: OH - 2 })
62export const visitSpot = (k: number): Pt => { const s = seat(k); return { x: s.x + 1, y: s.y } }
63export const OFFICE_ENTRANCE: Pt = { x: 20, y: OH - 2 }
64const COFFEE_SPOT: Pt = { x: 2, y: 2 }
65
66const walkableIn = (x: number, y: number) => x >= 0 && y >= 0 && x < OW && y < OH && (grid[at(x, y)] === 'floor' || grid[at(x, y)] === 'carpet')
67
68export function officePath(from: Pt, to: Pt): Pt[] {
69 const prev = new Int32Array(OW * OH).fill(-2)
70 const start = at(from.x, from.y)
71 prev[start] = -1
72 const queue = [start]
73 for (let q = 0; q < queue.length; q++) {
74 const cur = queue[q]
75 const p = { x: cur % OW, y: Math.floor(cur / OW) }
76 if (p.x === to.x && p.y === to.y) {
77 const path: Pt[] = []
78 for (let c = cur; c !== start; c = prev[c]) path.unshift({ x: c % OW, y: Math.floor(c / OW) })
79 return path
80 }
81 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
82 const nx = p.x + dx
83 const ny = p.y + dy
84 if (!walkableIn(nx, ny) || prev[at(nx, ny)] !== -2) continue
85 prev[at(nx, ny)] = cur
86 queue.push(at(nx, ny))
87 }
88 }
89 return []
90}
91
92/** One interior tile, `void` outside the walls. */
93export const officeTile = (x: number, y: number): OTile | 'void' => (x < 0 || y < 0 || x >= OW || y >= OH ? 'void' : grid[at(x, y)])
94/** The letters of the sign over the room, for renderers that draw them as words. */
95export const OFFICE_TITLE = { x: 16, y: 0, text: 'OFFICE' }
96
97// ---------- the apprentices ----------
98
99const NAMES = ['Ada', 'Alan', 'Grace', 'Linus', 'Ken', 'Barbara', 'Edsger', 'Margaret', 'Dennis', 'Radia', 'Guido', 'Hedy', 'Donald', 'Frances', 'Tim', 'Anita']
100/** Hair, shirt and trousers per look; the terminal draws an apprentice in its shirt's colour. */
101export const LOOKS: { hair: number; shirt: number; pants: number }[] = [
102 { hair: 0x3e2723, shirt: 0x4fc3f7, pants: 0x37474f }, { hair: 0xffb74d, shirt: 0xaed581, pants: 0x5d4037 },
103 { hair: 0x212121, shirt: 0xf06292, pants: 0x455a64 }, { hair: 0x8d6e63, shirt: 0xffd54f, pants: 0x3949ab },
104 { hair: 0xbdbdbd, shirt: 0x9575cd, pants: 0x4e342e }, { hair: 0xd84315, shirt: 0x4db6ac, pants: 0x263238 },
105 { hair: 0x5d4037, shirt: 0xff8a65, pants: 0x1a237e }, { hair: 0xfff176, shirt: 0x90a4ae, pants: 0x6d4c41 },
106]
107const hash = (s: string) => { let h = 2166136261; for (const c of s) h = Math.imul(h ^ c.codePointAt(0)!, 16777619); return h >>> 0 }
108const pick = <T>(list: readonly T[]): T => list[Math.floor(Math.random() * list.length)]
109
110const TYPING: Record<ToolKind, string[]> = {
111 edit: ['tap tap tap', 'writing code...', 'one more line'],
112 shell: ['running a command', 'whirr...', 'compiling!'],
113 read: ['reading the code', 'hmm, interesting', 'looking around'],
114 web: ['looking it up', 'searching the web'],
115 agent: ['asking a helper', 'teamwork!'],
116 other: ['busy busy', 'working on it'],
117}
118const IDLE = ['stretching...', '*sip*', 'waiting for the next task', 'hmm hm hmm']
119
120export const toolKind = (tool: string): ToolKind => (/edit|write|notebook/i.test(tool) ? 'edit'
121 : /bash|powershell|shell/i.test(tool) ? 'shell'
122 : /read|grep|glob|lsp/i.test(tool) ? 'read'
123 : /web|fetch|search/i.test(tool) ? 'web'
124 : /agent|task|workflow/i.test(tool) ? 'agent' : 'other')
125
126/** The bubble for one tool call, from the tool's name alone: what the call acts on never leaves the session. */
127export function doingLine(tool: string): string {
128 if (/^mcp__/i.test(tool)) return 'using a connector'
129 if (/todo/i.test(tool)) return 'updating the to-do list'
130 if (/notebook/i.test(tool)) return 'editing a notebook'
131 if (/write/i.test(tool)) return 'writing a file'
132 if (/edit/i.test(tool)) return 'editing a file'
133 if (/bash|powershell|shell/i.test(tool)) return 'running a command'
134 if (/grep/i.test(tool)) return 'searching the code'
135 if (/glob/i.test(tool)) return 'looking for files'
136 if (/lsp/i.test(tool)) return 'looking through the code'
137 if (/read/i.test(tool)) return 'reading a file'
138 if (/websearch|web_search/i.test(tool)) return 'searching the web'
139 if (/web|fetch/i.test(tool)) return 'reading a web page'
140 if (/agent|task|workflow/i.test(tool)) return 'asking a helper'
141 if (/skill/i.test(tool)) return 'using a skill'
142 return ''
143}
144
145const say = (a: Apprentice, text: string, ticks = 30) => { a.bubble = { text, ticks } }
146const goTo = (a: Apprentice, to: Pt) => { a.path = officePath(a, to) }
147/** Whether the apprentice sits at their desk (facing the screen), not walking or on a break. */
148export const atSeat = (a: Apprentice) => { const s = seat(a.desk); return a.x === s.x && a.y === s.y && !a.path.length }
149
150/** A session connects: its apprentice comes in through the door and goes to a free desk. */
151export function joinOffice(o: Office, id: string, about: { repo: string; branch: string }): Apprentice {
152 const known = o.staff.find(a => a.id === id)
153 if (known) {
154 Object.assign(known, about)
155 if (known.leaving) { known.leaving = false; goTo(known, seat(known.desk)) }
156 return known
157 }
158 const taken = new Set(o.staff.map(a => a.desk))
159 const free = DESKS.findIndex((_, k) => !taken.has(k))
160 const standing = -1 - o.staff.filter(a => a.desk < 0).length
161 const names = new Set(o.staff.map(a => a.name))
162 const h = hash(id)
163 const name = [...NAMES.slice(h % NAMES.length), ...NAMES].find(n => !names.has(n)) ?? `Intern ${o.staff.length + 1}`
164 const a: Apprentice = {
165 id, name, ...about, desk: free >= 0 ? free : standing, ...OFFICE_ENTRANCE, path: [],
166 status: 'idle', kind: 'other', tool: '', doing: '', calm: 0, coffee: 0, rest: 0, leaving: false, look: h % LOOKS.length,
167 }
168 goTo(a, seat(a.desk))
169 say(a, about.repo ? `Hi! I'm on ${about.repo} today.` : 'Hi! Reporting for work.', 40)
170 o.staff.push(a)
171 return a
172}
173
174/** A session ends: its apprentice says goodbye and walks out; runOffice lets them go at the door. */
175export function leaveOffice(o: Office, id: string) {
176 const a = o.staff.find(s => s.id === id)
177 if (!a || a.leaving) return
178 a.leaving = true
179 a.coffee = 0
180 say(a, pick(['See you tomorrow!', 'Clocking out!', 'Bye everyone!']), 30)
181 goTo(a, OFFICE_ENTRANCE)
182}
183
184/** What the apprentice's session is doing, as the session reports it. */
185export type OfficeEvent =
186 | { type: 'tool'; tool: string }
187 | { type: 'turn'; seconds: number; outcome: 'answer' | 'aborted' | 'error' }
188 | { type: 'tests'; passed: boolean }
189
190export function officeWork(o: Office, id: string, e: OfficeEvent) {
191 const a = o.staff.find(s => s.id === id)
192 if (!a || a.leaving) return
193 if (e.type === 'tool') {
194 const kind = toolKind(e.tool)
195 const doing = doingLine(e.tool)
196 // every call with a known purpose gets its own bubble; unknown ones only when the kind of work changes
197 if (doing ? doing !== a.bubble?.text : a.status !== 'working' || a.kind !== kind) say(a, doing || pick(TYPING[kind]), 25)
198 a.status = 'working'
199 a.kind = kind
200 a.tool = e.tool
201 a.doing = doing
202 a.calm = 0
203 if (a.coffee || !atSeat(a)) { a.coffee = 0; goTo(a, seat(a.desk)) }
204 } else if (e.type === 'turn') {
205 a.tool = ''
206 a.doing = ''
207 if (e.outcome === 'answer') {
208 a.status = 'done'
209 a.calm = 60
210 say(a, e.seconds >= 20 ? pick(['Phew, done!', 'Finished it!', 'Ta-da!']) : 'Done!', 30)
211 } else if (e.outcome === 'aborted') {
212 a.status = 'idle'
213 say(a, 'Okay, stopping.', 25)
214 } else {
215 a.status = 'error'
216 a.calm = 120
217 say(a, 'Hmm, that broke...', 35)
218 }
219 } else {
220 a.status = e.passed ? 'done' : 'error'
221 a.calm = e.passed ? 80 : 120
222 say(a, e.passed ? 'Tests are green!' : 'Tests are red...', 35)
223 }
224}
225
226/** One tick of office life. */
227export function runOffice(o: Office, tick: number) {
228 for (const a of o.staff) {
229 if (a.bubble && --a.bubble.ticks <= 0) a.bubble = undefined
230 if (a.path.length) {
231 if (tick % 2 === 0) { const next = a.path.shift()!; a.x = next.x; a.y = next.y }
232 continue
233 }
234 if (a.leaving) continue
235 if (a.calm > 0 && --a.calm === 0) a.status = 'idle'
236 if (a.coffee > 0) {
237 if (--a.coffee === 0) goTo(a, seat(a.desk))
238 continue
239 }
240 if (!atSeat(a)) { goTo(a, seat(a.desk)); continue }
241 a.rest = a.status === 'idle' ? a.rest + 1 : 0
242 if (a.status === 'working') {
243 if (!a.bubble && tick % 45 === 0 && Math.random() < 0.5) say(a, a.doing && Math.random() < 0.6 ? `still ${a.doing}` : pick(TYPING[a.kind]), 20)
244 } else if (a.status === 'idle' && a.rest > 400 && Math.random() < 0.003) {
245 a.rest = 0
246 a.coffee = 60
247 goTo(a, COFFEE_SPOT)
248 say(a, 'Coffee time!', 20)
249 } else if (a.status === 'idle' && !a.bubble && Math.random() < 0.002) {
250 say(a, pick(IDLE), 20)
251 }
252 }
253 o.staff = o.staff.filter(a => !(a.leaving && !a.path.length && a.x === OFFICE_ENTRANCE.x && a.y === OFFICE_ENTRANCE.y))
254}
255
256/** How an apprentice describes their work, for a chat with Claude and the planner's prompt. */
257export function workLine(a: Apprentice): string {
258 const on = a.repo ? ` on ${a.repo}${a.branch ? ` (${a.branch})` : ''}` : ''
259 if (a.status === 'working' && a.doing) return `I'm busy${on}, ${a.doing}.`
260 if (a.status === 'working') return `I'm busy${on}, ${a.kind === 'edit' ? 'writing code' : a.kind === 'shell' ? 'running commands' : a.kind === 'read' ? 'reading code' : a.kind === 'web' ? 'searching the web' : a.kind === 'agent' ? 'with helpers' : 'working'}.`
261 if (a.status === 'done') return `Just finished a task${on}!`
262 if (a.status === 'error') return `Something went wrong${on}... I'll sort it out.`
263 return `Waiting for the next task${on}.`
264}
265
266// ---------- drawing ----------
267
268/** The screen on each desk, by desk index: its owner's status, or null for a free desk. */
269export const deskStatus = (o: Office): (Status | null)[] => DESKS.map((_, k) => o.staff.find(a => a.desk === k && !a.leaving)?.status ?? null)
270
271const FLOOR = [0xd7dde0, 0xcfd6da]
272const CARPET = [0x5c6bc0, 0x5363b5]
273const DESK_BG = 0x8d6e63
274
275/** One interior cell: [code point, foreground, background]; outside the walls is dark. */
276export function officeCell(x: number, y: number, desks: (Status | null)[], tick: number): [number, number, number] {
277 const cp = (s: string) => s.codePointAt(0)!
278 if (x < 0 || y < 0 || x >= OW || y >= OH) return [32, 0, 0x14171c]
279 const i = at(x, y)
280 const floor = FLOOR[(x + y) % 2]
281 switch (grid[i]) {
282 case 'wall': return titles[i] ? [cp(titles[i]), 0xeceff1, 0x37474f] : [32, 0, 0x37474f]
283 case 'window': return [cp('▒'), 0x81d4fa, 0x37474f]
284 case 'door': return [cp('▒'), 0x3e2723, 0x8d6e63]
285 case 'carpet': return [32, 0, CARPET[(x + y) % 2]]
286 case 'plant': return [cp('♣'), 0x2e7d32, floor]
287 case 'coffee': return [cp('¤'), 0xffffff, 0x6d4c41]
288 case 'board': return [cp('═'), 0x1565c0, 0xfafafa]
289 case 'desk': {
290 const k = DESKS.findIndex(d => d.x === x && d.y === y)
291 if (k < 0) return [cp('═'), 0xd7ccc8, DESK_BG]
292 const s = desks[k]
293 if (s === 'working') return [cp('■'), tick % 6 < 3 ? 0x69f0ae : 0x00c853, DESK_BG]
294 if (s === 'done') return [cp('*'), 0xffd54f, DESK_BG]
295 if (s === 'error') return [cp('!'), tick % 8 < 4 ? 0xff5252 : 0xb71c1c, DESK_BG]
296 if (s === 'idle') return [cp('■'), 0x607d8b, DESK_BG]
297 return [cp('░'), 0xa1887f, DESK_BG]
298 }
299 default: return [32, 0, floor]
300 }
301}
302types/index.d.ts 20 lines1/** `who` is `you`, `claude`, `event`, `mochi` or a villager's id. */
2export type LogLine = { who: string; text: string }
3/** Which view the panel under the map shows. */
4export type Tab = 'chat' | 'memories' | 'village'
5/** The Memories and Village tabs' data, as the village server sends it (hooks/web.ts `infoPayload`). */
6export type Info = {
7 /** The office's apprentices, one per open Claude Code session. */
8 office: { name: string; color: string; repo: string; branch: string; status: 'idle' | 'working' | 'done' | 'error'; tool: string }[]
9 projects: { name: string; about: string; done: boolean; bells: number; need: number; items: { name: string; have: number; need: number }[]; pct: number }[]
10 museum: { open: boolean; reward: number; sections: { name: string; have: number; total: number; pieces: { name: string; got: boolean }[] }[] }
11 requests: { who: string; color: string; item: string; left: number; reward: number; done: boolean }[]
12 people: { name: string; color: string; hearts: number | null; diary: string; facts: string[] }[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'cozy-village': { log: LogLine[]; thinking: boolean; tab: Tab; reply: string; web: string; info: Info | null; museum: { open: boolean; inside: boolean }; office: { staff: number; inside: boolean } }
18 }
19}
20