SLOPSHOPPER

cozy-village

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.

newpaneguardcommandtoaststatus
★ 9v0.7.0MITupdated 2026-10-22NicoReber/cozy-village
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cozy-village
│ ┃ cozy-village ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ● cozy-village: cozy-village: the village server did not start (0); i │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /village │ ┃ ⎿ cozy-village: The village did not start. It needs Node.js 22.18 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ Chat Memories Village Postcard │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Nothing has happened yet. Tell Claude what │ ┃ │ below. │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cozy-village
Chat Memories Village Postcard Office (0) ╭──────────────────────────────────────────────────────╮ │ Nothing has happened yet. Tell Claude what to do │ │ below. │ │ │ ╰──────────────────────────────────────────────────────╯ Quick: Fish Chop ×5 Water Harvest Sell Mail ╭─────────────────────────────────────────────────────────── │ ✎ : Tell Claude what to do, e.g. "buy pumpkin seeds and pl ╰───────────────────────────────────────────────────────────
README

Cozy Village

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

The village through the seasons: a spring morning, summer rain at the pond, fireworks night, autumn and snow

In the browser (from the desktop app)

The browser view through the seasons: a spring morning at Claude's house, dusk on the lamp-lit plaza, autumn rain by the river and the first snow at the shop, in top-down pixel art

The office: an apprentice for every session

The office in the browser: apprentices type at their desks, a new one walks in through the door, the screens turn green, gold and red as their sessions work, finish and fail, and Claude drops by each desk to chat

What's in the village

  • Claude and five neighbours: Tom the fisherman, Rosa the florist, Bruno the carpenter, Pip the kid and Mabel from the café, plus Mochi the cat. Every villager has a daily routine, a mood, a friendship level with Claude and a memory. Their memory holds what happened, lasting facts, and a diary written at the end of each in-game day. Your choice changes their mood and friendship.
  • A living calendar:
  • Four seasons of seven days each, with their own colours, fish, bugs and festivals.
  • Daily events: rain and snow, a travelling merchant, lost items, fishing contests, meteor showers, birthdays.
  • Villager gossip and a morning Village Gazette.
  • Things to do with your Bells:
  • Crops and seeds, cooking at the café, decorations.
  • Community projects that change the map: benches, a riverside park, street lamps, a fishing pier, a museum, a bomb factory that blows up now and then (Bruno patches it up afterwards), and an AI data center run on three fixed bikes: villagers (and Claude, with 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.
  • A walkable museum with a curator owl, plus bug catching, digging for fossils, fish records, letters by mail and villager requests.
  • One village for all your sessions: every Claude Code session on the computer joins the same village, whether its pane is open or not.
  • The office: a building north of the plaza with a desk and an apprentice for every open session. The apprentice walks in when the session starts, types while it runs tools, cheers when a turn is done, sighs at errors and failing tests, fetches coffee when idle and walks out when the session ends. Send Claude in with Office (or a wish) and he chats with each apprentice about their repo and branch; he stays until a wish or Leave office takes him out.
  • It notices your real work:
  • While Claude Code runs tools in any of your sessions, the village Claude hammers, reads or stokes a forge to match.
  • Finished turns get a cheer, passing tests a rainbow, failing tests a little rain cloud.
  • Postcards: one click copies the current scene plus a caption to the clipboard.

In the browser

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:

  • Pixel-art houses with shingled roofs, windows, signs and awnings, trees, fences, crops, bridges, lamps and a fountain, in the season's colours.
  • Villagers walk smoothly, wear their own outfits and name tags, and talk in speech bubbles; night falls with glowing lamps and windows; rain, snow, petals and fireworks.
  • Drag to look around, scroll to zoom, double-click (or Follow) to follow Claude, Whole village to see the entire island.
  • The chat, the chores and the wish field work from the page too.
  • Village info opens a panel with the same details as the pane's tabs: requests, project progress, the museum collection and everyone's memories and friendship.

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.

Requirements

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.

Install

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.

Running from a working copy

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.

Playing

WhereWhat
Input field under the mapType a wish; Claude plans it and carries it out
Tabs: Chat · Memories · VillageThe chat log, what everyone remembers, and projects / museum / requests
Quick barOne-click chores: fish, chop wood, water, harvest, sell, read mail (no model call)
PostcardCopies the scene to the clipboard
Office / Leave officeSends Claude into the office to visit the apprentices, or back out
Museum tourWalk through the museum once it is built
Village info (browser)Requests, project progress, museum collection and memories
Open in my browserDesktop app: opens the browser view (/village web anywhere)
/village stopStops 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 memoryPrints every character's memory
/village forgetWipes all memories and friendships

Buttons are clicked with the mouse; there are no keyboard shortcuts.

Settings

Open /config and look for cozy-village:

SettingDefaultWhat it does
Open the village at session startoffOpens the pane by itself; otherwise use /village
Village ticker in the status lineoffSeason, Bells, events and requests under the prompt
Model for the villagershaikuhaiku, sonnet or opus
Velvet Club (adults only)offAdds an adults-only club as a community project. Content stays non-explicit, and Pip (a child) is always kept out of it

Model usage

The village calls the chosen model:

  • once per wish (the planning prompt is about 3–5k tokens)
  • once per in-game day for diaries, gossip, letters and the Gazette (a game day is about seven real minutes)

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.

Your save

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.

Development

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.

License

MIT

Source 8 files
hooks/register.tsx 533 lines
1import { 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}
533
hooks/game.ts 3187 lines
1// 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 true
hooks/host.ts 286 lines
1// 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}
286
hooks/web.ts 185 lines
1// 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}
185
hooks/catalog.ts 125 lines
1// 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}
125
hooks/museum.ts 180 lines
1// 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}
180
hooks/office.ts 302 lines
1// 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}
302
types/index.d.ts 20 lines
1/** `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