SLOPSHOPPER

Vibehaus

A cozy pixel house for your Claude Code agents: the lead, subagents and background tasks from every session as a Clawd household in an autumn garden, with…

newpanebandguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-16rmguney/vibehaus/vibehaus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vibehaus
│ ┃ Vibehaus ✕ › fix the failing auth test and add an audit log call │ ┃ " '" "" " ` "`" │ ┃ ┌──════════════════───┬──════════════════─── ⏺ Read(src/auth.ts) │ ┃ │ library │ kitchen ⎿ 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 │ ┃ │ ┃ › /haus │ ┃ ⎿ vibehaus: Vibehaus opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ 5H ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 31% Week ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- ◧ Vibehaus ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5H ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 31% Week ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- ◧ Vibehaus
Pane · Vibehaus
" '" "" " ` "`" " * ┌──════════════════───┬──════════════════───┐ ' , │ library │ kitchen │ " ' , │ ▄▄▄▄▄▄▄▄▄ │ ▀▀▀▀ ● ○■ │ " " ║▌ │▌ │ , , ║ │ " * ` ' " "" "" ♣ ·, ",'
README

Vibehaus

<img src=".github/media/vibehaus-day.gif" width="100%" alt="Clawds working, napping and chatting in a cozy isometric pixel house in an autumn garden">

A cozy pixel house for your Claude Code agents. Your lead agent, subagents and background tasks live in an isometric house in an autumn garden as Clawds: they work at desks in the room for each kind of job, carry a box out to the mailbox when they finish, and nap, read, watch TV or goof about when idle.

Work in progress. Built on Claude Code's function hooks, which are early access and may change.

What it shows

<img src=".github/media/vibehaus-tall.gif" alt="The house at night in a tall docked pane, centred in an autumn garden of copses, ponds and flower beds" width="220" align="right">

  • A room for each kind of work: library (reading, search), study (the lead), sunroom (web and MCP tools), kitchen (shell), workshop (edits) and a bedroom lounge. Desk screens follow the tool in use; a permission prompt raises an amber tag.
  • Roles, with hats: the lead is the Host in a crown, Explore agents are Snoops in deerstalkers, Plan agents the Schemer in a wizard's hat, general-purpose agents Handymen in hard hats; shells are Cooks in toques, monitors the Neighbor in a sun hat, workflows the Butler in a top hat. Other agent types take a role from their name, such as the Inspector, Taster or Scribbler.
  • Shipping: each finished task walks out the front door with a box and stacks it on the pallets along the road, where the day's boxes from every session pile up by the mailbox.
  • Your other sessions move in too: their agents walk in from the street and leave the same way. Each session has its own colour on rings at the feet, box tape and names.
  • Hover cards for every agent in the house, on the desktop and in a terminal: who it is, its task, what it is doing, for how long, how many tokens (this session's own) and whose session it came from.
  • A garden all round: the house stays centred in the pane, and a taller pane shows more of the autumn garden above and below it: copses and lone trees, ponds big and small, flower beds, stones and toadstools, with fireflies by night.
  • Vibe Fatigue: a band right above the prompt with your five-hour and weekly usage and when each resets, beside the ◧ Vibehaus button. Every session shows the newest reading any of them took. Claude Code reads these only on a Claude subscription; elsewhere the bars show --.
  • On the desktop, light mode is a sunny day and dark mode a lamplit night.

Your background tasks themselves are listed in Claude Code's own background tasks view; in Vibehaus they live in the house as Clawds.

In a terminal

<img src=".github/media/vibehaus-terminal.gif" alt="The house in a terminal: a floor plan of six rooms with their furniture, Clawds working and walking, the yard by the front door" width="300" align="right">

A terminal can't show the picture, so there the house is drawn as a floor plan from above, in the terminal's own character cells, and stays crisp at any size.

  • Turned to fit a docked pane: the plan is rotated a quarter turn, so the rooms run down the pane in two columns, with the yard and front door at the side.
  • The same rooms and furniture: each room is a box with its name over it, holding what the picture has: desks and their screens, bookshelves, the fireplace, the fridge and stove, the workbench, beds, the TV and the couch. Desk screens change colour with the tool in use.
  • Every agent is a Clawd, drawn the way Claude Code draws its own, with its role written under it. They walk from room to room as they do on the desktop, while fires, screens and lights flicker. Hover a Clawd for its card.
  • The yard holds the day's boxes by the mailbox.
  • As tall as the pane: the plan grows with the pane, up to rooms about as tall as they are wide, and stays in its middle; the garden fills the rows above and below it, with copses of autumn trees and pines, flowers, fallen leaves and ponds.
  • Vibe Fatigue and the ◧ Vibehaus button sit in the band above the prompt, as on the desktop.

Install

/plugin install vibehaus --marketplace rmguney/vibehaus

Needs Claude Code 2.1.295 or later, in the terminal or the desktop Code tab. Tested on 2.1.299 and 2.1.301.

Where an organization manages Claude Code, its classic hooks run only for plugins the organization allows. Without them there is no amber tag, no sync with the background tasks list and no session titles.

Use

  • It opens when a session starts (in a terminal 144 columns wide or more, 110 once you have opened it, until you close it by hand); /haus opens it anywhere.
  • /haus demo moves a made-up household in for five minutes; run it again to send them home.
  • /haus snapshot saves the house as vibehaus-snapshot.svg, an animated SVG, in the folder Claude Code runs in.
  • ◧ Vibehaus in the band above the prompt opens or closes it; the band also shows your vibe fatigue. It gives way while Claude Code shows a survey there.

Privacy and permissions

  • What it sends: nothing. It contacts no host and never uses your sign-in: the vibe fatigue bars show Claude Code's own readings of your five-hour and weekly windows, off its responses.
  • What it writes: vibehaus-snapshot.svg in the folder Claude Code runs in, over the last one, only when you run /haus snapshot. It edits no build, start-up, settings or instructions file. It shows what this session's agents are doing as the house does, commands included, so look it over before you share it. Besides that only Claude Code's local plugin store, which every session on this machine reads, whatever its project. It holds the Host's token counts, the newest five-hour and weekly reading, when each box shipped, and each session's household: the session's title (its folder's name until it has one), and for each member its id, agent type, role, task name, room and status, when it started, last worked and finished and the times of its last few tool calls, whether its last call failed, the tool in use (with the file's name for file tools), and the Host's context use. Never a command, pattern, address or query.
  • What it runs: nothing. It calls no tools, runs no commands or processes and starts no agents. It adds the /haus command and answers only that one.
  • What its hooks do: they watch, and pass everything on unchanged. None blocks, rewrites or answers an event, other than its own /haus command, pane and band.
  • session.start adds /haus and clears stale entries from the store; session.end takes the session's household out of the store.
  • command.run answers /haus alone. Every other command, and any made by other code, goes by untouched.
  • session.measure reads the five-hour and weekly windows from Claude Code's measurements.
  • tool.call puts each agent in the room for its tool and picks up the background tasks it starts. The call and its result go on as they were.
  • agent.spawn, turn.start, turn.complete and turn.step move agents in and out and count each one's tokens. The agent, turn and request go on as they were.
  • session.append reads background tasks' end notices from the session's rows, and keeps every row as it is.
  • The classic events change nothing either: settings, instructions, other hooks' output and tool descriptions pass through them untouched.
  • Stop and SubagentStop sync the house with Claude Code's own list of background work.
  • SessionStart and UserPromptSubmit give the session's title; after a /clear or a resume, SessionStart sets the house up again.
  • Notification of a permission prompt raises the amber tag over the agent that asks. The plugin hooks no permission check: the prompt and your decision are untouched.
  • ui.render draws its own pane, and the band above the prompt, which it leaves to Claude Code's surveys whenever one is showing.
  • It reads no credentials, environment variables or files, and depends on no other plugin. Like every mod it is not sandboxed; the source is plain TypeScript in vibehaus/hooks/.

Develop

claude --plugin-dir vibehaus
node tests/run.mjs
claude plugin validate --strict .
claude plugin validate --strict vibehaus
npx -p typescript tsc -p vibehaus
npx -p typescript tsc -p tests

The tests sit in tests/, outside the plugin, so they are not installed with it; tests/run.mjs runs them in a copy of the plugin. The type checks use the types Claude Code writes into vibehaus/.claude-plugin/types/ when it loads the plugin, so run claude --plugin-dir vibehaus once first.

License

MIT, see LICENSE. Clawd, the Claude Code mascot, and the Claude and Claude Code names are Anthropic's and not covered by the license. Vibehaus is an unofficial fan project, not affiliated with or endorsed by Anthropic.

Source 12 files
hooks/register.tsx 1804 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement, SessionRateLimit } from 'claude-code'
3
4import type { Resident, ResidentStatus, PlanUse, Room, Track } from '../types'
5import { cardSvg } from './cards'
6import { ALL_ROOMS } from './house'
7import { cellAt, gardenRaster, packCells, planCells, planOf, planSize, RASTER_ROWS, reachOf } from './plan'
8import type { Plan, Walker } from './plan'
9import type { HouseGrid, Meter } from './scene'
10import {
11  clip, colorFor, displayName, errandEnds, gardenPicture, houseGrid, housePicture, houseTracks, meterColor, onePicture, own, paintSessions,
12  reanchor, spotOf, svg, SVG_LIMIT, taskTypeOf,
13} from './scene'
14import { visit } from './visitors'
15import type { Household } from './visitors'
16
17const PANE = 'vibehaus'
18const MAIN = 'main'
19const KEEP_DONE = 4
20const MAX_TEXT = 160
21const MAX_CALLS = 8
22
23const residents = atom({ plugin: 'vibehaus', key: 'residents' } as const, [])
24const plan = atom({ plugin: 'vibehaus', key: 'plan' } as const, {})
25const minute = atom({ plugin: 'vibehaus', key: 'minute' } as const, 0)
26// The pending redraw, so pictures and hover zones catch up.
27let nextDraw: { cancel: () => void } | undefined
28
29function redrawSoon($: EngineInterface, ms: number): void {
30  nextDraw?.cancel()
31  nextDraw = undefined
32  try {
33    nextDraw = $.clock.after(ms, () => {
34      try {
35        $.ui.invalidate('ui.render')
36      } catch {
37        // the pane or this plugin load is gone
38      }
39    })
40  } catch {
41    // no timer: the next change redraws instead
42  }
43}
44
45// The terminal house, played into a Raster frame by frame (see plan.ts); repeat frames are skipped.
46const RASTER = 'vh-house'
47const FRAME_MS = 200
48const INLINE_ROWS = 22
49let played: { plan: Plan; walkers: Walker[] } | undefined
50let shownCells: Uint32Array | undefined
51let frames: { cancel: () => void } | undefined
52let isPainting = false
53
54const sameCells = (a: Uint32Array, b: Uint32Array | undefined) => b !== undefined && a.length === b.length && a.every((v, i) => v === b[i])
55
56function playHouse($: EngineInterface): void {
57  if (frames) return
58  try {
59    frames = $.clock.every(FRAME_MS, () => void paintFrame($))
60  } catch {
61    // no timer: the house moves with each redraw instead
62  }
63}
64
65async function paintFrame($: EngineInterface): Promise<void> {
66  const house = played
67  if (!house || isPainting) return
68  isPainting = true
69  try {
70    const now = await $.clock.now()
71    const cells = planCells(house.plan, house.walkers, now)
72    if (played !== house || sameCells(cells, shownCells)) return
73    const done = await $.ui.blit({ requestId: PANE, key: RASTER, cells: packCells(cells) })
74    // a redraw replaced the plan meanwhile
75    if (played !== house) return
76    // not shown or resized: the next redraw restarts it
77    if (done.deny) stopHouse()
78    else shownCells = cells
79  } catch {
80    stopHouse()
81  } finally {
82    isPainting = false
83  }
84}
85
86function stopHouse(): void {
87  frames?.cancel()
88  frames = undefined
89  played = undefined
90  shownCells = undefined
91}
92
93// A permission prompt's asker waits: the agent it names (any agent asking is named), else the lead.
94async function askFor($: EngineInterface, agentId: string | undefined, message: string): Promise<void> {
95  const tool = /permission to use ([^\s.,!]+)/i.exec(message)?.[1]
96  await move($, agentId ?? MAIN, c => (c.status === 'working' ? { status: 'waiting', waitingTool: tool ?? c.tool } : {}))
97}
98
99const LEAD: Resident = {
100  id: MAIN, name: 'Lead', kind: 'main', room: 'study',
101  movedAt: 0, status: 'idle', activity: 'Waiting for you',
102}
103
104// BashOutput, KillShell, MultiEdit and NotebookRead are older builds' tools, kept for them.
105function roomFor(tool: string): Room {
106  if (/^(Read|Grep|Glob|LSP|NotebookRead|ListMcpResourcesTool|ReadMcpResourceTool)$/.test(tool)) return 'library'
107  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'workshop'
108  if (/^(Bash|PowerShell|Monitor|BashOutput|KillShell|TaskStop)$/.test(tool)) return 'kitchen'
109  if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) return 'sunroom'
110  return 'study'
111}
112
113function roomForKind(kind: string): Room {
114  return kind === 'Explore' ? 'library' : 'study'
115}
116
117// One short line, so no heredoc reaches the state or alt text.
118function oneLine(text: string): string {
119  return clip(text.replace(/\s+/g, ' ').trim(), MAX_TEXT)
120}
121
122function detail(call: object): string {
123  const args = call as Record<string, unknown>
124  for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'skill']) {
125    const v = args[key]
126    if (typeof v === 'string' && v.length > 0) {
127      return oneLine(key === 'file_path' ? v.split(/[\\/]/).pop() ?? v : v)
128    }
129  }
130  return ''
131}
132
133function activityOf(tool: string, call: object): string {
134  const what = detail(call)
135  return what ? `${tool}: ${what}` : tool
136}
137
138function str(v: unknown): string | undefined {
139  return typeof v === 'string' && v.length > 0 ? v : undefined
140}
141
142// Fields naming a tool's background task; the todo tools' `taskId` is a todo.
143const BACKGROUND_FIELD: Record<string, string> = {
144  Bash: 'backgroundTaskId', PowerShell: 'backgroundTaskId', Monitor: 'taskId', Workflow: 'taskId',
145}
146
147function backgroundId(tool: string, result: unknown): string | undefined {
148  const field = own(BACKGROUND_FIELD, tool)
149  if (!field || typeof result !== 'object' || result === null) return undefined
150  return str((result as Record<string, unknown>)[field])
151}
152
153function backgroundKind(tool: string): string {
154  return tool === 'Bash' || tool === 'PowerShell' ? 'shell' : tool.toLowerCase()
155}
156
157const isLive = (c: Resident) => c.status === 'working' || c.status === 'waiting'
158
159// Dropped finished members, so a brief late listing does not bring one back.
160const forgotten = new Set<string>()
161
162// Keeps a few finished members, none still carrying a box; the demo's few never push out real work.
163function settle(list: Resident[]): Resident[] {
164  const byAge = (a: Resident, b: Resident) => (a.doneAt ?? 0) - (b.doneAt ?? 0)
165  const demoDone = list.filter(c => c.status === 'done' && c.isDemo && !c.carrying && !c.session).sort(byAge)
166  if (demoDone.length > 2) {
167    const drop = new Set(demoDone.slice(0, demoDone.length - 2).map(c => c.id))
168    list = list.filter(c => !drop.has(c.id))
169  }
170  const done = list.filter(c => c.status === 'done' && !c.isDemo && !c.carrying && !c.session).sort(byAge)
171  if (done.length <= KEEP_DONE) return list
172  const drop = new Set(done.slice(0, done.length - KEEP_DONE).map(c => c.id))
173  for (const id of drop) forgotten.add(id)
174  for (const id of [...forgotten].slice(0, Math.max(0, forgotten.size - 500))) forgotten.delete(id)
175  return list.filter(c => !drop.has(c.id))
176}
177
178function without<K extends keyof Resident>(c: Resident, key: K): Resident {
179  const copy = { ...c }
180  delete copy[key]
181  return copy
182}
183
184// Desks stick, so nobody slides over when others leave.
185function assignSeats(prev: Resident[], wanted: Resident[]): Resident[] {
186  const was = new Map(prev.map(c => [c.id, c]))
187  const taken = new Map<Room, Set<number>>()
188  const takenIn = (room: Room) => taken.get(room) ?? taken.set(room, new Set()).get(room)!
189  const kept = wanted.map(c => {
190    if (!isLive(c)) return c.seat === undefined ? c : without(c, 'seat')
191    const before = was.get(c.id)
192    const stays = c.seat !== undefined && before !== undefined && isLive(before) && before.room === c.room && !takenIn(c.room).has(c.seat)
193    if (stays && c.seat !== undefined) {
194      takenIn(c.room).add(c.seat)
195      return c
196    }
197    return without(c, 'seat')
198  })
199  return kept.map(c => {
200    if (!isLive(c) || c.seat !== undefined) return c
201    const used = takenIn(c.room)
202    let seat = 0
203    while (used.has(seat)) seat++
204    used.add(seat)
205    return { ...c, seat }
206  })
207}
208
209// All household changes: changed plans start where shown now; unchanged writes are skipped.
210async function change($: EngineInterface, fn: (list: Resident[]) => Resident[]): Promise<void> {
211  void startSharing($).catch(() => undefined)
212  const now = await $.clock.now()
213  const apply = (list: Resident[]) => {
214    const base = ordered(list.length === 0 ? [LEAD] : list)
215    const seated = ordered(assignSeats(base, fn(base)))
216    return reanchor(base, seated, now)
217  }
218  const before = await read($, residents)
219  const after = apply(before)
220  if (JSON.stringify(after) === JSON.stringify(before)) return
221  // as worked out, unless another change landed first
222  await update($, residents, list => (list === before ? after : apply(list)))
223  void publish($).catch(() => undefined)
224}
225
226type Patch = Partial<Resident> | ((c: Resident) => Partial<Resident>)
227
228async function move($: EngineInterface, id: string, patch: Patch, fallback?: Resident): Promise<void> {
229  // not in the house: nothing to write (the lead always is)
230  if (!fallback && id !== MAIN && !(await read($, residents)).some(c => c.id === id)) return
231  const now = await $.clock.now()
232  await change($, base => {
233    const all = base.some(c => c.id === id) || !fallback ? base : [...base, fallback]
234    return settle(all.map((c): Resident => {
235      if (c.id !== id) return c
236      const p = typeof patch === 'function' ? patch(c) : patch
237      const room = p.room ?? c.room
238      const moved = room !== c.room
239      const status = p.status ?? c.status
240      const wasIdle = c.status === 'idle' || c.status === 'done'
241      const isIdle = status === 'idle' || status === 'done'
242      return {
243        ...c, ...p, room, movedAt: moved ? now : c.movedAt,
244        idleSince: !wasIdle && isIdle ? now : c.idleSince,
245        workSince: wasIdle && !isIdle ? now : c.workSince,
246      }
247    }))
248  })
249}
250
251async function join($: EngineInterface, member: Resident): Promise<void> {
252  forgotten.delete(member.id)
253  await change($, base => settle([...base.filter(c => c.id !== member.id), member]))
254}
255
256// Read-then-write store jobs run one at a time, so none counts from a stale value.
257let storeQueue: Promise<unknown> = Promise.resolve()
258
259function serial<T>(job: () => Promise<T>): Promise<T> {
260  const run = storeQueue.then(job, job)
261  storeQueue = run.catch(() => undefined)
262  return run
263}
264
265// Today's shipping, a key per session; boxes stack by time so each keeps its place and tape.
266type Shipment = { at: number; session: string }
267let shippedToday: { day: string; boxes: Shipment[] } = { day: '', boxes: [] }
268
269function dayOf(now: number): string {
270  const d = new Date(now)
271  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
272}
273
274// This session's id, before sharing starts too.
275const ownId = async ($: EngineInterface) => self?.id ?? await $.session.id().catch(() => 'local')
276
277async function readShipped($: EngineInterface, now: number): Promise<void> {
278  const day = dayOf(now)
279  const prefix = `shipped:${day}:`
280  const own = await ownId($)
281  const all: { at: number; id: string }[] = []
282  for (const key of await $.store.keys()) {
283    if (!key.startsWith(prefix)) continue
284    const id = key.slice(prefix.length)
285    for (const at of numbers(await $.store.get(key))) all.push({ at, id })
286  }
287  // one order in every house; this session's own named ''
288  all.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
289  shippedToday = { day, boxes: all.map(b => ({ at: b.at, session: b.id === own ? '' : b.id })) }
290}
291
292async function shippedOn($: EngineInterface, now: number): Promise<Shipment[]> {
293  if (shippedToday.day !== dayOf(now)) await readShipped($, now).catch(() => undefined)
294  return shippedToday.day === dayOf(now) ? shippedToday.boxes : []
295}
296
297// One more box on this session's key; an unreadable key is not started over.
298async function countShipped($: EngineInterface, now: number): Promise<void> {
299  const key = `shipped:${dayOf(now)}:${await ownId($)}`
300  const sofar = await $.store.get(key)
301  await $.store.set(key, [...numbers(sofar), now])
302  await readShipped($, now)
303}
304
305// Today's boxes from every session and the demo, in order, each as its shipper.
306async function yardBoxes($: EngineInterface, now: number): Promise<string[]> {
307  const boxes = [...await shippedOn($, now), ...demoBoxes.map(at => ({ at, session: '' }))]
308  return boxes.sort((a, b) => a.at - b.at).map(b => b.session)
309}
310
311// A finished member carries a box to the pallets (see `deliver`), one at a time so none share a place.
312let finishing: Promise<unknown> = Promise.resolve()
313
314function finish($: EngineInterface, id: string, activity: string): Promise<void> {
315  const run = finishing.then(() => finishOne($, id, activity), () => finishOne($, id, activity))
316  finishing = run.catch(() => undefined)
317  return run
318}
319
320async function finishOne($: EngineInterface, id: string, activity: string): Promise<void> {
321  const member = (await read($, residents)).find(c => c.id === id)
322  if (!member || member.status === 'done' || member.session) return
323  const now = await $.clock.now()
324  // the next free pallet place, counting every session's latest
325  await readShipped($, now).catch(() => undefined)
326  const box = (await yardBoxes($, now)).length
327  let isShipped = false
328  let isDemo = false
329  await change($, list => {
330    // the update may run again on a clash: decide afresh each time
331    isShipped = false
332    return settle(list.map((c): Resident => {
333      if (c.id !== id || c.status === 'done' || c.session) return c
334      isShipped = true
335      isDemo = c.isDemo === true
336      // a shell or monitor keeps its command; the end goes to its status
337      const keepsCommand = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
338      return { ...c, status: 'done', ...(keepsCommand ? {} : { activity }), taskStatus: activity.toLowerCase(), doneAt: now, idleSince: now, carrying: true, box }
339    }))
340  })
341  if (!isShipped) return
342  if (isDemo) demoBoxes.push(now)
343  else await serial(() => countShipped($, now)).catch(() => undefined)
344  await sendOut($, id)
345}
346
347// Corrects a task taken as completed once the engine's notice says how it ended.
348async function restate($: EngineInterface, id: string, word: string): Promise<void> {
349  await change($, list => list.map(c => {
350    if (c.id !== id || c.status !== 'done' || c.session || c.taskStatus === word.toLowerCase()) return c
351    const keepsCommand = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
352    return { ...c, ...(keepsCommand ? {} : { activity: word }), taskStatus: word.toLowerCase() }
353  }))
354}
355
356// One timer per courier on its way.
357const errands = new Map<string, { cancel: () => void }>()
358
359// Brings a courier back when its errand ends; after a reload, one overdue lands at once.
360async function sendOut($: EngineInterface, id: string): Promise<void> {
361  const now = await $.clock.now()
362  const end = errandEnds(ordered(await read($, residents)), now).get(id)
363  errands.get(id)?.cancel()
364  errands.delete(id)
365  if (end === undefined) return
366  errands.set(id, $.clock.after(Math.max(0, end - now), () => {
367    errands.delete(id)
368    void deliver($, id).catch(() => undefined)
369  }))
370}
371
372// Back from the pallets, box stacked.
373async function deliver($: EngineInterface, id: string): Promise<void> {
374  const now = await $.clock.now()
375  await change($, list => settle(list.map(c => (c.id === id && c.carrying ? { ...without(without(c, 'carrying'), 'box'), idleSince: now } : c))))
376}
377
378function ordered(list: Resident[]): Resident[] {
379  const rank = (c: Resident) => (c.id === MAIN ? 0 : isLive(c) ? 1 : c.status === 'idle' ? 2 : 3)
380  return list.map((c, i) => ({ c, i })).sort((a, b) => rank(a.c) - rank(b.c) || a.i - b.i).map(x => x.c)
381}
382
383// Where a member stands, for its hover card.
384type State = 'running' | 'waiting' | 'pending' | 'idle' | 'done' | 'failed' | 'stopped'
385
386function endState(c: Resident): State {
387  const w = (c.taskStatus ?? c.activity).toLowerCase()
388  if (/kill|stop|cancel|abort/.test(w)) return 'stopped'
389  if (/fail|error/.test(w)) return 'failed'
390  return 'done'
391}
392
393const STATE_WORD: Record<State, string> = {
394  running: 'running', waiting: 'awaiting approval', pending: 'pending', idle: 'waiting for you',
395  done: 'completed', failed: 'failed', stopped: 'stopped',
396}
397
398// ---------- token usage, kept as time and token pairs ----------
399
400const HOUR = 60 * 60 * 1000
401const WEEK = 7 * 24 * HOUR
402
403function tokensSince(usage: number[] | undefined, since: number): number {
404  let sum = 0
405  for (let i = 0; i + 1 < (usage?.length ?? 0); i += 2) if ((usage?.[i] ?? 0) >= since) sum += usage?.[i + 1] ?? 0
406  return sum
407}
408
409// One pair per hour, at its latest time, so a week is at most 168; pairs before `since` drop.
410function byHour(flat: number[], since = 0): number[] {
411  const hours = new Map<number, [number, number]>()
412  for (let i = 0; i + 1 < flat.length; i += 2) {
413    const [t, n] = [flat[i] ?? 0, flat[i + 1] ?? 0]
414    if (t < since) continue
415    const was = hours.get(Math.floor(t / HOUR))
416    hours.set(Math.floor(t / HOUR), [Math.max(t, was?.[0] ?? 0), n + (was?.[1] ?? 0)])
417  }
418  return [...hours.values()].sort((a, b) => a[0] - b[0]).flat()
419}
420
421function addUsage(usage: number[] | undefined, now: number, tokens: number): number[] {
422  return byHour([...(usage ?? []), now, tokens], now - WEEK)
423}
424
425function compact(n: number): string {
426  if (n < 1000) return String(n)
427  if (n < 10000) return `${(n / 1000).toFixed(1)}k`
428  if (n < 1e6) return `${Math.round(n / 1000)}k`
429  return `${(n / 1e6).toFixed(1)}M`
430}
431
432// The week's tokens: the lead's span sessions, anyone else's is its one run.
433function usageText(c: Resident, now: number): string | undefined {
434  const week = tokensSince(c.usage, now - WEEK)
435  return week === 0 ? undefined : compact(week)
436}
437
438// A request's cost; cache reads are left out, being nearly free.
439function tokensOf(u: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number } | null | undefined): number {
440  return u ? u.input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
441}
442
443// The lead's usage spans sessions, a key each, summed so none overwrite another.
444const LEAD_USAGE = 'usage:lead'
445let ownKey: string | undefined
446
447async function leadKey($: EngineInterface): Promise<string> {
448  if (ownKey) return ownKey
449  const started = await $.session.usage({}).then(u => u.startedAt).catch(() => 0)
450  ownKey = `${LEAD_USAGE}:${started || await $.clock.now()}`
451  return ownKey
452}
453
454const numbers = (v: unknown) => (Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : [])
455
456// The lead's usage over all sessions, others' read at most once a minute; undefined if the store fails.
457let othersUsage: { at: number; flat: number[] } | undefined
458
459async function leadUsage($: EngineInterface, mine?: number[]): Promise<number[] | undefined> {
460  try {
461    const now = await $.clock.now()
462    const own = await leadKey($)
463    if (!othersUsage || now - othersUsage.at > MINUTE) {
464      const flat: number[] = []
465      for (const key of (await $.store.keys()).filter(k => k.startsWith(LEAD_USAGE) && k !== own)) flat.push(...numbers(await $.store.get(key)))
466      othersUsage = { at: now, flat: byHour(flat) }
467    }
468    return byHour([...othersUsage.flat, ...(mine ?? numbers(await $.store.get(own)))])
469  } catch {
470    return undefined
471  }
472}
473
474// Drops old days' counts, week-old usage and dead sessions (a crashed one never deletes its own).
475async function pruneStore($: EngineInterface): Promise<void> {
476  const now = await $.clock.now()
477  const today = dayOf(now)
478  for (const key of await $.store.keys()) {
479    if (key.startsWith('shipped:') && !key.startsWith(`shipped:${today}`)) await $.store.delete(key)
480    if (key.startsWith(PEER)) {
481      const v = await $.store.get(key) as Partial<Snapshot> | undefined
482      if (now - (typeof v?.at === 'number' ? v.at : 0) > FORGET_AFTER) await $.store.delete(key)
483    }
484    if (key.startsWith(LEAD_USAGE)) {
485      const v = numbers(await $.store.get(key))
486      const newest = v.filter((_t, i) => i % 2 === 0).reduce((a, b) => Math.max(a, b), 0)
487      if (newest < now - WEEK) await $.store.delete(key)
488    }
489  }
490}
491
492// No plan changes here, so this skips `change` and its reanchoring.
493async function recordUsage($: EngineInterface, id: string, tokens: number): Promise<void> {
494  if (tokens <= 0) return
495  const now = await $.clock.now()
496  if (id === MAIN) {
497    await serial(async () => {
498      const key = await leadKey($)
499      const kept = (await read($, residents)).find(c => c.id === MAIN)?.usage
500      const stored = await $.store.get(key).then(numbers, () => undefined)
501      let usage = addUsage(kept, now, tokens)
502      if (stored !== undefined) {
503        const mine = addUsage(stored, now, tokens)
504        if (await $.store.set(key, mine).then(() => true, () => false)) usage = await leadUsage($, mine) ?? mine
505      }
506      await update($, residents, list => (list.length === 0 ? [LEAD] : list).map(c => (c.id === MAIN ? { ...c, usage } : c)))
507    })
508    return
509  }
510  // nobody to add to: an engine fork's step, say
511  if (!(await read($, residents)).some(c => c.id === id)) return
512  await update($, residents, list => list.map(c => (c.id === id ? { ...c, usage: addUsage(c.usage, now, tokens) } : c)))
513}
514
515// ---------- the plan's windows, for the vibe fatigue band ----------
516
517// Read off Claude Code's response headers, so nothing leaves the machine; the account's newest is shared.
518const PLAN_KEY = 'plan'
519const MINUTE = 60 * 1000
520
521// A reading taken `at`; none off a subscription or before the first response.
522function readingOf(limits: readonly SessionRateLimit[], at: number): PlanUse | undefined {
523  const five = limits.find(r => r.kind === 'five_hour')
524  const week = limits.find(r => r.kind === 'seven_day')
525  if (!five && !week) return undefined
526  const resets = (r: SessionRateLimit) => (r.resetsAt ? Date.parse(r.resetsAt) : NaN)
527  const out: PlanUse = { at }
528  if (five) {
529    out.fiveHour = five.percentUsed
530    if (Number.isFinite(resets(five))) out.fiveHourResets = resets(five)
531  }
532  if (week) {
533    out.week = week.percentUsed
534    if (Number.isFinite(resets(week))) out.weekResets = resets(week)
535  }
536  return out
537}
538
539// A stored reading, checked field by field, percentages floored at zero.
540function planFrom(raw: unknown): PlanUse | undefined {
541  if (typeof raw !== 'object' || raw === null) return undefined
542  const v = raw as Record<string, unknown>
543  const out: PlanUse = {}
544  for (const k of ['at', 'fiveHour', 'fiveHourResets', 'week', 'weekResets'] as const) {
545    const n = v[k]
546    if (typeof n === 'number' && Number.isFinite(n)) out[k] = k === 'fiveHour' || k === 'week' ? Math.max(0, n) : n
547  }
548  return out.at !== undefined ? out : undefined
549}
550
551// A newer reading over an older, window by window.
552function newer(was: PlanUse, r: PlanUse): PlanUse {
553  const five = r.fiveHour !== undefined ? r : was
554  const week = r.week !== undefined ? r : was
555  return {
556    ...(r.at !== undefined ? { at: r.at } : {}),
557    ...(five.fiveHour !== undefined ? { fiveHour: five.fiveHour } : {}),
558    ...(five.fiveHourResets !== undefined ? { fiveHourResets: five.fiveHourResets } : {}),
559    ...(week.week !== undefined ? { week: week.week } : {}),
560    ...(week.weekResets !== undefined ? { weekResets: week.weekResets } : {}),
561  }
562}
563
564// The windows at `now`: one past its reset is blank until a response brings the next.
565function current(p: PlanUse, now: number): PlanUse {
566  const fiveOver = p.fiveHourResets !== undefined && p.fiveHourResets <= now
567  const weekOver = p.weekResets !== undefined && p.weekResets <= now
568  if (!fiveOver && !weekOver) return p
569  return {
570    ...(p.at !== undefined ? { at: p.at } : {}),
571    ...(!fiveOver && p.fiveHour !== undefined ? { fiveHour: p.fiveHour } : {}),
572    ...(!fiveOver && p.fiveHourResets !== undefined ? { fiveHourResets: p.fiveHourResets } : {}),
573    ...(!weekOver && p.week !== undefined ? { week: p.week } : {}),
574    ...(!weekOver && p.weekResets !== undefined ? { weekResets: p.weekResets } : {}),
575  }
576}
577
578const sameWindows = (a: PlanUse, b: PlanUse) =>
579  a.fiveHour === b.fiveHour && a.fiveHourResets === b.fiveHourResets && a.week === b.week && a.weekResets === b.weekResets
580
581// Shows a newer, different local reading, and stores it unless the store's is newer.
582async function takeReading($: EngineInterface, reading: PlanUse | undefined, now: number): Promise<void> {
583  const at = reading?.at
584  if (!reading || at === undefined) return
585  // the reading applied to `was`, if it says anything new
586  const over = (was: PlanUse) => {
587    if (at <= (was.at ?? 0)) return undefined
588    const merged = current(newer(was, reading), now)
589    return sameWindows(merged, was) ? undefined : merged
590  }
591  if (!over(await read($, plan))) return
592  let wrote: PlanUse | undefined
593  await update($, plan, was => (wrote = over(was)) ?? was)
594  if (!wrote) return
595  const stored = planFrom(await $.store.get(PLAN_KEY).catch(() => undefined))
596  if ((stored?.at ?? 0) < at) await $.store.set(PLAN_KEY, wrote).catch(() => undefined)
597}
598
599// Adopts the store's newer reading, else restores this one's (two writes may race); false if none stored.
600async function adoptPlan($: EngineInterface, now: number): Promise<boolean> {
601  const stored = planFrom(await $.store.get(PLAN_KEY))
602  if (!stored) return false
603  const at = stored.at ?? 0
604  const was = await read($, plan)
605  if (at > (was.at ?? 0)) await update($, plan, p => (at > (p.at ?? 0) ? current(newer(p, stored), now) : p))
606  else if ((was.at ?? 0) > at) await $.store.set(PLAN_KEY, was).catch(() => undefined)
607  return true
608}
609
610// At start: the store's newest reading, else Claude Code's own.
611async function startPlan($: EngineInterface): Promise<void> {
612  const now = await $.clock.now()
613  await update($, minute, () => now)
614  if (await adoptPlan($, now)) return
615  const { rateLimits } = await $.session.usage({})
616  await takeReading($, readingOf(rateLimits, now), now)
617}
618
619// Session setup, each step on its own so one failing leaves the rest.
620async function begin($: EngineInterface): Promise<void> {
621  // a session resumed here after ending shares again
622  endedHere.clear()
623  await $.command.register({
624    name: 'haus',
625    description: 'Show Vibehaus · "demo" fills it with a demo household · "snapshot" saves it as an SVG',
626    argumentHint: '[demo | snapshot]',
627    immediate: true,
628  }).catch(() => undefined)
629  try {
630    const usage = await leadUsage($)
631    await change($, list => list.filter(c => !c.isDemo).map(c => (c.id === MAIN && usage ? { ...c, usage } : c)))
632    for (const c of await read($, residents)) if (c.carrying && !c.session) await sendOut($, c.id)
633  } catch {
634    // the household as it is
635  }
636  void endQuietly($).catch(() => undefined)
637  $.clock.after(ENDING_GRACE, () => void endQuietly($).catch(() => undefined))
638  void startSharing($).catch(() => undefined)
639  void startPlan($).catch(() => undefined)
640  void pruneStore($).catch(() => undefined)
641}
642
643// Each minute: resets count down and a window past its reset loses its reading.
644async function tickPlan($: EngineInterface): Promise<void> {
645  const now = await $.clock.now()
646  await update($, minute, () => now)
647  const was = await read($, plan)
648  if (current(was, now) !== was) await update($, plan, p => current(p, now))
649}
650let ticker: { cancel: () => void } | undefined
651
652// Cells per plan bar when the band's width is unknown.
653const BAR = 8
654
655function metersOf(p: PlanUse): Meter[] {
656  return [
657    { label: '5H', color: '#52d2f5', ...(p.fiveHour !== undefined ? { percent: p.fiveHour } : {}), ...(p.fiveHourResets !== undefined ? { resets: p.fiveHourResets } : {}) },
658    { label: 'Week', color: '#4cf08c', ...(p.week !== undefined ? { percent: p.week } : {}), ...(p.weekResets !== undefined ? { resets: p.weekResets } : {}) },
659  ]
660}
661
662// A reset time as the band words it: minutes or hours today, else the weekday.
663const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
664function resetText(at: number, now: number): string {
665  const m = Math.max(1, Math.ceil((at - now) / MINUTE))
666  if (m < 60) return `${m}m`
667  if (m < 24 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`
668  return DAYS[new Date(at).getDay()] ?? ''
669}
670
671// Bar width and reset wording: full resets while bars keep BAR_ROOMY cells, then short, then none.
672const TOGGLE = '◧ Vibehaus'
673const BAR_MIN = 3
674const BAR_ROOMY = 4
675const BAR_MAX = 40
676const percentText = (m: Meter) => (m.percent === undefined ? '--' : `${Math.round(m.percent)}%`)
677
678function bandOf(meters: Meter[], columns: number | undefined, now: number): { bar: number; words: string[] } {
679  const wordings = [(t: string) => `Resets ${t}`, (t: string) => t, () => '']
680  let last = { bar: BAR_MIN, words: meters.map(() => '') }
681  for (const [i, say] of wordings.entries()) {
682    const words = meters.map(m => (m.percent !== undefined && m.resets !== undefined ? say(resetText(m.resets, now)) : ''))
683    if (columns === undefined) return { bar: BAR, words }
684    // the toggle, gaps, a spare cell, and each meter's words: "5H ", " 42%", " · 2h 10m"
685    const fixed = TOGGLE.length + 2 * meters.length + 1
686      + meters.reduce((n, m, k) => n + m.label.length + 2 + percentText(m).length + (words[k] ? 3 + [...(words[k] as string)].length : 0), 0)
687    const bar = Math.floor((columns - fixed) / meters.length)
688    if (bar >= (i < wordings.length - 1 ? BAR_ROOMY : BAR_MIN)) return { bar: Math.min(BAR_MAX, bar), words }
689    last = { bar: BAR_MIN, words }
690  }
691  return last
692}
693
694function stateOf(c: Resident): State {
695  if (c.kind === 'main') return c.status === 'waiting' ? 'waiting' : c.status === 'working' ? 'running' : 'idle'
696  if (c.status === 'waiting') return 'waiting'
697  if (c.isTeammate && c.status === 'idle') return 'idle'
698  if (isLive(c)) return c.taskStatus === 'pending' ? 'pending' : 'running'
699  return endState(c)
700}
701
702function ended(status: string): string {
703  return status.charAt(0).toUpperCase() + status.slice(1)
704}
705
706// One of the Stop hooks' `background_tasks`; long texts end in "... [+N chars]".
707type TaskSummary = {
708  id: string
709  type: string
710  status: string
711  description: string
712  command?: string
713  agent_type?: string
714  name?: string
715  server?: string
716}
717const CLIPPED = /\.\.\. \[\+\d+ chars\]$/
718
719function fromSummary(t: TaskSummary): Resident {
720  const description = oneLine(t.description.replace(CLIPPED, '…'))
721  const isAgent = t.type === 'subagent'
722  const kind = isAgent ? t.agent_type ?? 'agent' : t.type
723  return {
724    id: t.id,
725    name: isAgent ? description || kind : `Background ${t.type}`,
726    kind,
727    room: isAgent ? roomForKind(kind) : t.type === 'shell' || t.type === 'monitor' ? 'kitchen' : t.server ? 'sunroom' : 'study',
728    // start unknown: none rather than a wrong one
729    movedAt: 0,
730    status: 'working',
731    activity: oneLine(t.command?.replace(CLIPPED, '…') || description || t.name || t.type),
732    isBackground: true,
733    taskType: t.type,
734    taskStatus: t.status,
735  }
736}
737
738// Syncs to the engine's background list as of `asOf`: missing tasks join, vanished ones end, newer stay.
739// Agents go by the agent list instead (foreground ones are not background); workflow agents are in neither.
740async function reconcile($: EngineInterface, tasks: readonly TaskSummary[] | undefined, asOf: number): Promise<void> {
741  if (!tasks) return
742  const live = new Map(tasks.map(t => [t.id, t]))
743  let agents: Map<string, string> | undefined
744  try {
745    agents = new Map((await $.agent.list()).map(a => [a.id, a.status as string]))
746  } catch {
747    agents = undefined
748  }
749  let over: { id: string; word: string }[] = []
750  let isEnding = false
751  const hasEnded = (s: string | undefined) => s === undefined || s === 'completed' || s === 'failed' || s === 'killed'
752  await change($, list => {
753    over = []
754    isEnding = false
755    const synced = list.map((c): Resident => {
756      if (c.session) return c
757      const t = live.get(c.id)
758      if (t) return c.status === 'done' ? c : { ...without(c, 'endingSince'), taskType: t.type, taskStatus: t.status }
759      const seen = c.spawnedAt ?? c.workSince
760      const isOld = seen === undefined || seen < asOf
761      // a teammate, working or idle, ends once the engine stops listing it
762      if (c.isTeammate && agents && c.status !== 'done') {
763        const s = agents.get(c.id)
764        if (isOld && hasEnded(s)) over.push({ id: c.id, word: s ?? 'completed' })
765        return c
766      }
767      if (c.id === MAIN || c.isDemo || !isLive(c) || !isOld) return c
768      if (taskTypeOf(c) === 'subagent') {
769        if (!agents || c.isWorkflow) return c
770        const s = agents.get(c.id)
771        if (hasEnded(s)) over.push({ id: c.id, word: s ?? 'completed' })
772        return c
773      }
774      // gone from the list: its end notice may be coming, so it ends as completed after a moment
775      isEnding = true
776      return c.endingSince !== undefined ? c : { ...c, endingSince: asOf }
777    })
778    for (const t of tasks) {
779      if (list.some(c => c.id === t.id) || forgotten.has(t.id)) continue
780      if (t.status !== 'running' && t.status !== 'pending') continue
781      synced.push(fromSummary(t))
782    }
783    return settle(synced)
784  })
785  for (const o of over) await finish($, o.id, ended(o.word))
786  if (isEnding) $.clock.after(ENDING_GRACE, () => void endQuietly($).catch(() => undefined))
787}
788
789// How long work gone from the engine's list waits for its notice.
790const ENDING_GRACE = 5000
791
792async function endQuietly($: EngineInterface): Promise<void> {
793  const now = await $.clock.now()
794  const due = (await read($, residents)).filter(c => !c.session && c.status !== 'done' && c.endingSince !== undefined && now - c.endingSince >= ENDING_GRACE)
795  for (const c of due) await finish($, c.id, 'Completed')
796}
797
798async function contextNow($: EngineInterface): Promise<number | undefined> {
799  try {
800    const usage = await $.session.usage({})
801    return usage.context.percent
802  } catch {
803    return undefined
804  }
805}
806
807// ---------- other sessions in the same house ----------
808
809// Each session shares its household under its own key; the others' agents walk in from the street.
810// One that stops writing, or idles unmessaged for five hours, moves out until busy again.
811const PEER = 'session:'
812const SYNC_EVERY = 3000
813const BEAT_EVERY = 10000
814const GONE_AFTER = 35000
815const FORGET_AFTER = HOUR
816const ACTIVE_FOR = 5 * HOUR
817
818// What a session writes, with when it last woke from a long sleep.
819type Snapshot = { at: number; since: number; activeAt: number; name: string; residents: Resident[]; wokeAt?: number }
820
821// When a session was last busy, by its members' latest times.
822const activeAtOf = (mine: Resident[]) => Math.max(0, ...mine.flatMap(c => [c.workSince, c.spawnedAt, c.resultAt, c.doneAt, c.idleSince, ...(c.calls ?? [])].filter((t): t is number => typeof t === 'number')))
823type Peer = { id: string; name: string; since: number }
824
825// This session as the others know it, and the others as last read.
826let self: { id: string; name: string; since: number } | undefined
827let peers: Peer[] = []
828// sessions gone, whose households are still walking out
829let going: Peer[] = []
830let lastSync = 0
831let shared = { at: 0, text: '' }
832// sessions seen ending here, so late changes do not share them again; then the sleep state
833const endedHere = new Map<string, number>()
834const END_QUIET = 10000
835let wasAsleep = false
836let wokeAt = 0
837let publishing: { cancel: () => void } | undefined
838let sharing: Promise<void> | undefined
839// counts starts, so one stopped mid-start gives up
840let runs = 0
841let beats: { cancel: () => void }[] = []
842
843const peerName = (id: string) => [...peers, ...going].find(p => p.id === id)?.name ?? 'another session'
844
845async function whoAmI($: EngineInterface): Promise<NonNullable<typeof self>> {
846  const now = await $.clock.now()
847  const id = await $.session.id().catch(() => 'local')
848  const root = await $.session.root().catch(() => '')
849  const since = await $.session.usage({}).then(u => u.startedAt).catch(() => 0)
850  // its name before a reload: its title, once it had one
851  const was = await $.store.get(PEER + id).catch(() => undefined) as Partial<Snapshot> | undefined
852  const name = typeof was?.name === 'string' && was.name ? was.name : root.split(/[\\/]/).filter(Boolean).pop() ?? 'session'
853  return { id, name, since: since || now }
854}
855
856// A session goes by its title once the engine sends one, else its folder.
857async function retitle($: EngineInterface, title: string | undefined): Promise<void> {
858  const name = title ? oneLine(title).slice(0, 40) : ''
859  if (!name) return
860  await startSharing($)
861  const me = self
862  if (!me || me.name === name) return
863  me.name = name
864  await publish($)
865}
866
867// What others see: nothing house-local, of its work only the tool and file name, never a command or query.
868const FILE_TOOLS = /^(Read|Edit|Write|MultiEdit|NotebookEdit|NotebookRead)$/
869function outward(c: Resident): Resident {
870  const kept = without(without(without(without(c, 'anchor'), 'seat'), 'usage'), 'endingSince')
871  const isTask = c.id !== MAIN && taskTypeOf(c) !== 'subagent'
872  const activity = isTask
873    ? c.status === 'done' ? ended(c.taskStatus ?? 'done') : 'Running'
874    : c.tool && c.activity.startsWith(`${c.tool}: `) && !FILE_TOOLS.test(c.tool) ? c.tool : c.activity
875  return { ...kept, activity }
876}
877
878// Shares this session's members at most every two seconds, and each beat as a sign of life.
879async function publish($: EngineInterface, beat = false): Promise<void> {
880  const me = self
881  if (!me) return
882  const now = await $.clock.now()
883  if (!beat && now - shared.at < 2000) {
884    publishing ??= $.clock.after(2000 - (now - shared.at), () => {
885      publishing = undefined
886      void publish($).catch(() => undefined)
887    })
888    return
889  }
890  const mine = (await read($, residents)).filter(c => !c.session && !c.isDemo).map(outward)
891  // a session just started is not asleep, whatever its members did
892  const activeAt = Math.max(me.since, activeAtOf(mine))
893  // marks waking from a long sleep, so every house sees it arrive at once
894  const asleep = !mine.some(isLive) && now - activeAt > ACTIVE_FOR
895  if (wasAsleep && !asleep) wokeAt = now
896  wasAsleep = asleep
897  const body = { since: me.since, activeAt, name: me.name, residents: mine, ...(wokeAt ? { wokeAt } : {}) }
898  const text = JSON.stringify(body)
899  if ((!beat && text === shared.text) || self !== me) return
900  shared = { at: now, text }
901  await $.store.set(PEER + me.id, { ...body, at: now } satisfies Snapshot)
902}
903
904const ROOMS = new Set<string>(ALL_ROOMS)
905const STATUSES = new Set<string>(['working', 'waiting', 'idle', 'done'])
906
907// Another session's member, checked field by field; one lacking essentials is dropped.
908function memberOf(raw: unknown): Resident | undefined {
909  if (typeof raw !== 'object' || raw === null) return undefined
910  const m = raw as Record<string, unknown>
911  const text = (k: string) => (typeof m[k] === 'string' ? oneLine(m[k] as string) : undefined)
912  const num = (k: string) => (typeof m[k] === 'number' && Number.isFinite(m[k]) ? m[k] as number : undefined)
913  const { id, kind, name, room, status } = { id: text('id'), kind: text('kind'), name: text('name'), room: text('room'), status: text('status') }
914  if (!id || !kind || name === undefined || !room || !ROOMS.has(room) || !status || !STATUSES.has(status)) return undefined
915  const out: Resident = { id, kind, name, room: room as Room, status: status as ResidentStatus, movedAt: num('movedAt') ?? 0, activity: text('activity') ?? '' }
916  for (const k of ['tool', 'taskType', 'taskStatus', 'waitingTool'] as const) {
917    const v = text(k)
918    if (v !== undefined) out[k] = v
919  }
920  for (const k of ['resultAt', 'spawnedAt', 'doneAt', 'idleSince', 'workSince', 'context', 'box'] as const) {
921    const v = num(k)
922    if (v !== undefined) out[k] = v
923  }
924  for (const k of ['isBackground', 'isOk', 'isWorkflow', 'isTeammate', 'carrying'] as const) {
925    if (typeof m[k] === 'boolean') out[k] = m[k] as boolean
926  }
927  const from = text('spawnRoom')
928  if (from && ROOMS.has(from)) out.spawnRoom = from as Room
929  if (Array.isArray(m.calls)) out.calls = m.calls.filter((t): t is number => typeof t === 'number').slice(-MAX_CALLS)
930  return out
931}
932
933// Session names as every house shows them, repeats numbered.
934function nameAll(list: { id: string; name: string; since: number }[]): Map<string, string> {
935  const seen = new Map<string, number>()
936  const out = new Map<string, string>()
937  for (const s of [...list].sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))) {
938    const n = (seen.get(s.name) ?? 0) + 1
939    seen.set(s.name, n)
940    out.set(s.id, n > 1 ? `${s.name} ${n}` : s.name)
941  }
942  return out
943}
944
945type Visible = Peer & Household
946
947// Other sessions to show: neither stopped writing nor asleep. Long-stale keys are cleared.
948async function readPeers($: EngineInterface, own: string, now: number): Promise<Visible[]> {
949  const found: Visible[] = []
950  const time = (t: unknown) => (typeof t === 'number' && Number.isFinite(t) ? t : undefined)
951  for (const key of await $.store.keys()) {
952    if (!key.startsWith(PEER) || key === PEER + own) continue
953    const v = await $.store.get(key) as Partial<Snapshot> | undefined
954    const at = time(v?.at) ?? 0
955    if (now - at > FORGET_AFTER) {
956      await $.store.delete(key).catch(() => undefined)
957      continue
958    }
959    if (!v || now - at > GONE_AFTER || !Array.isArray(v.residents)) continue
960    const members = v.residents.flatMap(m => memberOf(m) ?? [])
961    if (!members.some(isLive) && now - (time(v.activeAt) ?? 0) > ACTIVE_FOR) continue
962    const woke = time(v.wokeAt)
963    found.push({
964      id: key.slice(PEER.length), name: oneLine(String(v.name || 'session')).slice(0, 40),
965      since: time(v.since) ?? 0, residents: members, ...(woke !== undefined ? { wokeAt: woke } : {}),
966    })
967  }
968  return found.sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))
969}
970
971// Moves other sessions in and quiet ones out; all go if the store stays out of reach as long.
972let syncing = false
973
974async function syncPeers($: EngineInterface): Promise<void> {
975  if (syncing) return
976  syncing = true
977  try {
978    await syncOnce($)
979  } finally {
980    syncing = false
981  }
982}
983
984async function syncOnce($: EngineInterface): Promise<void> {
985  const me = self
986  if (!me) return
987  const now = await $.clock.now()
988  let found: Visible[]
989  try {
990    found = await readPeers($, me.id, now)
991  } catch {
992    if (now - lastSync > GONE_AFTER) {
993      going = [...peers, ...going]
994      peers = []
995      paintHomes()
996      await change($, list => visit(list, [], now))
997    }
998    return
999  }
1000  lastSync = now
1001  await readShipped($, now).catch(() => undefined)
1002  await adoptPlan($, now).catch(() => false)
1003  const names = nameAll([me, ...found])
1004  const was = [...peers, ...going]
1005  peers = found.map(f => ({ id: f.id, name: names.get(f.id) ?? f.name, since: f.since }))
1006  // a session gone keeps its colour and name while its household walks out
1007  const here = await read($, residents)
1008  going = was.filter(p => !found.some(f => f.id === p.id) && here.some(c => c.session === p.id))
1009  paintHomes()
1010  // alone, with no visitors left to see out: nothing to change
1011  if (found.length === 0 && !here.some(c => c.session)) return
1012  await change($, list => visit(list, found, now))
1013}
1014
1015// Starts sharing once: at session start, or the first change if the world could not answer yet.
1016function startSharing($: EngineInterface): Promise<void> {
1017  if (sharing) return sharing
1018  const run = ++runs
1019  sharing = (async () => {
1020    const me = await whoAmI($)
1021    // stopped while it asked
1022    if (run !== runs) return
1023    // ended just now: after /clear the next change uses the new id; a resumed one shares again
1024    const endedAt = endedHere.get(me.id)
1025    if (endedAt !== undefined && await $.clock.now() - endedAt < END_QUIET) {
1026      sharing = undefined
1027      return
1028    }
1029    self = me
1030    for (const beat of beats) beat.cancel()
1031    beats = [
1032      $.clock.every(SYNC_EVERY, () => void syncPeers($).catch(() => undefined)),
1033      $.clock.every(BEAT_EVERY, () => void publish($, true).catch(() => undefined)),
1034    ]
1035    await publish($, true).catch(() => undefined)
1036    await syncPeers($).catch(() => undefined)
1037  })()
1038  // failed: the next change tries again
1039  sharing.catch(() => {
1040    if (run === runs) sharing = undefined
1041  })
1042  return sharing
1043}
1044
1045async function stopSharing($: EngineInterface): Promise<void> {
1046  for (const beat of beats) beat.cancel()
1047  beats = []
1048  publishing?.cancel()
1049  publishing = undefined
1050  sharing = undefined
1051  runs++
1052  const me = self
1053  self = undefined
1054  if (me) endedHere.set(me.id, await $.clock.now().catch(() => 0))
1055  peers = []
1056  going = []
1057  paintHomes()
1058  shared = { at: 0, text: '' }
1059  wasAsleep = false
1060  wokeAt = 0
1061  if (me) await $.store.delete(PEER + me.id).catch(() => undefined)
1062}
1063
1064// Colours each session by when it moved in; none while this one is alone.
1065function paintHomes(): void {
1066  const others = [...peers, ...going]
1067  if (!self || others.length === 0) {
1068    paintSessions([], '')
1069    return
1070  }
1071  const me = self
1072  const all = [{ id: '', since: me.since, key: me.id }, ...others.map(p => ({ id: p.id, since: p.since, key: p.id }))]
1073    .sort((a, b) => a.since - b.since || (a.key < b.key ? -1 : 1))
1074  paintSessions(all.map(s => s.id), me.id)
1075}
1076
1077// ---------- hover cards ----------
1078
1079// Whole percentages (all a Box takes), rounded at running edges so the pieces add up.
1080function percents(edges: number[], whole: number): string[] {
1081  const at = edges.map(edge => Math.round((Math.min(Math.max(edge, 0), whole) / Math.max(1, whole)) * 100))
1082  return at.slice(1).map((p, i) => `${Math.max(0, p - (at[i] ?? 0))}%`)
1083}
1084
1085function ago(ms: number): string {
1086  const s = Math.max(0, Math.floor(ms / 1000))
1087  if (s < 60) return `${s}s`
1088  const m = Math.floor(s / 60)
1089  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
1090}
1091
1092// An agent's hover card: who, the task, what it does, how long, at what cost.
1093function agentCard(c: Resident, now: number): string[] {
1094  const type = taskTypeOf(c)
1095  const state = stateOf(c)
1096  const live = state === 'running' || state === 'waiting' || state === 'pending'
1097  const started = c.spawnedAt ?? c.workSince
1098  const took = started && started > 0 ? live ? ago(now - started) : c.doneAt !== undefined ? ago(c.doneAt - started) : '' : ''
1099  const doing = state === 'waiting' ? `awaiting approval${c.waitingTool ? ` for ${c.waitingTool}` : ''}` : live || (state === 'idle' && c.isTeammate) ? c.activity : STATE_WORD[state]
1100  const used = usageText(c, now)
1101  const from = c.session ? `from "${peerName(c.session)}"` : ''
1102  if (c.kind === 'main') {
1103    return ['Host', from, doing, [c.context !== undefined ? `context ${Math.round(c.context)}%` : '', live ? took : '', used ? `${used} tokens this week` : ''].filter(Boolean).join(' · ')].filter(Boolean)
1104  }
1105  if (type === 'subagent') return [`${displayName(c)} · ${c.kind}`, c.name, from, doing, [took, used ? `${used} tokens` : ''].filter(Boolean).join(' · ')].filter(Boolean)
1106  return [`⧗ ${displayName(c)}`, c.activity || c.name, from, [STATE_WORD[state], took].filter(Boolean).join(' · ')].filter(Boolean)
1107}
1108
1109// The desktop picture is centred, garden clipped evenly. A row's height against a column is unknown;
1110// code fonts run about 2.3 to 2.8, and the low end is taken so the house is never cut.
1111const ROW_RATIO = 2.3
1112// the high end, for the most garden a pane could show
1113const TALLEST_ROW = 2.8
1114
1115// A picture is an image with its own clock, which stops while the window is hidden and restarts on reload.
1116// So each redraw draws every picture afresh, at least this often; no oftener, as each resends them all.
1117const REFRESH_MS = 2000
1118// the redraw's pace while nobody in the house walks
1119const QUIET_MS = 4000
1120
1121// A sprite's reach round its spot: body, hat and name tag.
1122const REACH = { left: 8, right: 8, up: 15, down: 6 }
1123// In ms: a redraw's lag to the screen, which a zone outlasts, and how finely a walk is followed.
1124const LAG_MS = 400
1125const STEP_MS = 50
1126
1127// Hover zones cover where each agent goes until the next redraw shows. Overlapping zones share a band,
1128// split halfway, so each is one keyed Box holding the card.
1129type Zone = { c: Resident; x0: number; x1: number; y0: number; y1: number }
1130type Band = { y0: number; y1: number; cells: { c: Resident; x0: number; x1: number }[] }
1131
1132// zones over the desktop picture, in its units: a sprite's reach round each spot it takes
1133function houseBands(list: Resident[], tracks: Track[], now: number, grid: HouseGrid): Band[] {
1134  const zones: Zone[] = []
1135  const byId = new Map(list.map(c => [c.id, c]))
1136  for (const t of tracks) {
1137    const c = byId.get(t.id)
1138    if (!c) continue
1139    let span: Omit<Zone, 'c'> | undefined
1140    for (let ms = now; ms <= now + REFRESH_MS + LAG_MS; ms += STEP_MS) {
1141      const p = spotOf(t, ms)
1142      if (!p) continue
1143      span = span
1144        ? { x0: Math.min(span.x0, p.x), x1: Math.max(span.x1, p.x), y0: Math.min(span.y0, p.y), y1: Math.max(span.y1, p.y) }
1145        : { x0: p.x, x1: p.x, y0: p.y, y1: p.y }
1146    }
1147    if (!span) continue
1148    zones.push({
1149      c,
1150      x0: Math.max(0, span.x0 - REACH.left), x1: Math.min(grid.w, span.x1 + REACH.right),
1151      y0: Math.max(0, span.y0 - REACH.up), y1: Math.min(grid.h, span.y1 + REACH.down),
1152    })
1153  }
1154  return bandsOf(zones)
1155}
1156
1157// zones over the terminal plan, in cells: each agent's Clawd and name at each cell it takes
1158function planBands(plan: Plan, walkers: Walker[], now: number): Band[] {
1159  const zones = walkers.flatMap(w => {
1160    let zone: Zone | undefined
1161    for (let ms = now; ms <= now + REFRESH_MS + LAG_MS; ms += STEP_MS) {
1162      const cell = cellAt(plan, w, ms)
1163      if (!cell) continue
1164      const [x0, y0, x1, y1] = reachOf(plan, w, cell)
1165      zone = zone
1166        ? { c: w.c, x0: Math.min(zone.x0, x0), y0: Math.min(zone.y0, y0), x1: Math.max(zone.x1, x1), y1: Math.max(zone.y1, y1) }
1167        : { c: w.c, x0, y0, x1, y1 }
1168    }
1169    return zone ? [zone] : []
1170  })
1171  return bandsOf(zones)
1172}
1173
1174function bandsOf(zones: Zone[]): Band[] {
1175  const bands: { y0: number; y1: number; zones: Zone[] }[] = []
1176  for (const z of [...zones].sort((a, b) => a.y0 - b.y0)) {
1177    const last = bands[bands.length - 1]
1178    if (last && z.y0 < last.y1) {
1179      last.y1 = Math.max(last.y1, z.y1)
1180      last.zones.push(z)
1181    } else bands.push({ y0: z.y0, y1: z.y1, zones: [z] })
1182  }
1183  return bands.map(band => {
1184    const cells = [...band.zones].sort((p, q) => p.x0 + p.x1 - (q.x0 + q.x1)).map(z => ({ c: z.c, x0: z.x0, x1: z.x1 }))
1185    for (let i = 1; i < cells.length; i++) {
1186      const a = cells[i - 1] as { x0: number; x1: number }
1187      const c = cells[i] as { x0: number; x1: number }
1188      if (c.x0 < a.x1) {
1189        const mid = (Math.max(a.x0, c.x0) + Math.min(a.x1, c.x1)) / 2
1190        a.x1 = mid
1191        c.x0 = mid
1192      }
1193    }
1194    return { y0: band.y0, y1: band.y1, cells }
1195  })
1196}
1197
1198// A card beside a zone on the roomier side, narrower when neither fits it.
1199function beside(left: number, span: number, columns: number): { isRight: boolean; width: number } {
1200  const toRight = columns - left - span - 1
hooks/cards.ts 54 lines
1/* Hover cards for the desktop, in the house's pixel font and scale. */
2import { fitText, glyphText, wrapText } from './font'
3
4const PX = 0.5
5const LINE = 6
6
7const BRIGHT = '#e8e8f0'
8const DIM = '#8c96a0'
9
10// Dark theme colours; light mode gives neutral text the light theme's greys and darkens colour for contrast.
11const LIGHT_CARD = '@media (prefers-color-scheme: light){' +
12  '.vh-p1{fill:#1f2328}.vh-p2{fill:#57606a}' +
13  '.vh-ink{filter:brightness(.6) saturate(1.35)}.vh-cbg{fill:#ffffff}' +
14  '}'
15const NEUTRAL: Record<string, string> = { [BRIGHT]: 'vh-p1', [DIM]: 'vh-p2' }
16
17// Pixel text that follows the theme, each glyph defined once in `glyphs`.
18function say(text: string, x: number, y: number, color: string, glyphs: Map<string, string>): string {
19  return glyphText(text, x, y, { color, cls: NEUTRAL[color] ?? 'vh-ink' }, (id, d) => glyphs.set(id, `<path id="${id}" d="${d}"/>`))
20}
21
22// A card `w` units wide, sized by the caller to the house's pixels. Kept, as cards repeat each redraw,
23// the least recently drawn dropped first: a live agent's card, new each second, leaves the rest be.
24const cards = new Map<string, string>()
25
26export function cardSvg(lines: string[], color: string, w: number): string {
27  const key = JSON.stringify([lines, color, w])
28  const known = cards.get(key)
29  if (known !== undefined) {
30    cards.delete(key)
31    cards.set(key, known)
32    return known
33  }
34  const made = drawCard(lines, color, w)
35  if (cards.size >= 300) cards.delete(cards.keys().next().value as string)
36  cards.set(key, made)
37  return made
38}
39
40function drawCard(lines: string[], color: string, w: number): string {
41  const textW = w - 8
42  const shown = lines.flatMap((line, i) => wrapText(line, textW, PX, 2).map(text => ({ text, i })))
43  const h = 4 + shown.length * LINE + 1
44  const tone = (i: number) => (i === 0 ? color : i === 1 ? BRIGHT : DIM)
45  const frame =
46    `<g class="vh-ink"><rect x="1" y="0" width="${w - 2}" height="1" fill="${color}"/><rect x="1" y="${h - 1}" width="${w - 2}" height="1" fill="${color}"/>` +
47    `<rect x="0" y="1" width="1" height="${h - 2}" fill="${color}"/><rect x="${w - 1}" y="1" width="1" height="${h - 2}" fill="${color}"/></g>` +
48    `<rect class="vh-cbg" x="1" y="1" width="${w - 2}" height="${h - 2}" fill="#0d1117"/>`
49  const glyphs = new Map<string, string>()
50  const text = shown.map((line, k) => say(fitText(line.text, textW, PX), 4, 3 + k * LINE, tone(line.i), glyphs)).join('')
51  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * 8}" height="${h * 8}" shape-rendering="crispEdges" style="display:block"><style>${LIGHT_CARD}</style>` +
52    `<defs>${[...glyphs.values()].join('')}</defs>${frame}${text}</svg>`
53}
54
hooks/house.ts 248 lines
1/*
2 * The house: six rooms seen from above at an angle, near walls cut down. Plan x runs to the picture's
3 * lower right, y to its lower left. Back row: library, study, sunroom; front: kitchen, workshop, lounge.
4 */
5import type { Room } from '../types'
6import { Canvas, faces, mat, warm, warmFaces } from './iso'
7import type { Faces, Mat, Pt } from './iso'
8
9export type Dir = 'N' | 'S' | 'E' | 'W'
10export type Cell = { x: number; y: number; w: number; h: number; col: number; row: number }
11// `w`, `h`: picture size; `pw`, `ph`: plan size; `ox`, `oy`: where the plan's corner lands.
12// `extra`: in a tall picture, the garden added above and below the house, in picture units each way.
13export type Layout = { w: number; h: number; pw: number; ph: number; ox: number; oy: number; grid: Room[][]; rooms: Record<Room, Cell>; extra?: number }
14
15// Room floors are S units square; walls are T thick.
16const S = 40
17export const T = 2
18export const TALL_WALL = 26
19const LOW_WALL = 5
20const PAD = 3
21// for the back walls' labels
22const HEADROOM = 4
23// margins: the sides, the trees behind, the yard's far end below
24const SIDE = 14
25const TREES = 10
26const BELOW = 14
27
28const HOUSE: Room[][] = [['library', 'study', 'sunroom'], ['kitchen', 'workshop', 'lounge']]
29export const ALL_ROOMS: Room[] = ['study', 'library', 'workshop', 'kitchen', 'sunroom', 'lounge']
30
31export function house(): Layout {
32  const rooms = {} as Record<Room, Cell>
33  HOUSE.forEach((line, row) => line.forEach((room, col) => {
34    rooms[room] = { x: T + col * (S + T), y: T + row * (S + T), w: S, h: S, col, row }
35  }))
36  const pw = T + 3 * (S + T)
37  const ph = T + 2 * (S + T)
38  const ox = ph + PAD + SIDE
39  const oy = TALL_WALL + PAD + HEADROOM + TREES
40  return { w: pw + ph + 2 * (PAD + SIDE), h: Math.ceil((pw + ph) / 2 + oy + PAD + BELOW), pw, ph, ox, oy, grid: HOUSE, rooms }
41}
42
43// The house in a picture `by` units taller each way, more garden above and below.
44export function stretch(L: Layout, by: number): Layout {
45  return { ...L, oy: L.oy + by, h: L.h + 2 * by, extra: by }
46}
47
48// A plan point at height z, on the picture.
49export function at(L: Layout, x: number, y: number, z = 0): Pt {
50  return { x: x - y + L.ox, y: (x + y) / 2 - z + L.oy }
51}
52
53// Doorways near each inner wall's front end, clear of the furniture at the back.
54const DOOR = 32
55export const doorY = (c: Cell) => c.y + DOOR
56export const doorX = (c: Cell) => c.x + DOOR
57export const floorBottom = (c: Cell) => c.y + c.h - 3
58
59// The front door, where the road starts.
60export function frontDoor(L: Layout): { x0: number; x1: number; mid: number } {
61  const mid = L.rooms.workshop.x + 21
62  return { x0: mid - 5, x1: mid + 5, mid }
63}
64
65// Where idle agents may stand in a room, clear of its furniture.
66export function freeFloor(room: Room, c: Cell): { x0: number; x1: number; y0: number; y1: number } {
67  if (room === 'lounge') return { x0: c.x + 17, x1: c.x + c.w - 4, y0: c.y + 23, y1: c.y + c.h - 3 }
68  return { x0: c.x + 11, x1: c.x + c.w - 5, y0: c.y + 15, y1: c.y + c.h - 3 }
69}
70
71// ---------- materials ----------
72
73const MAT = {
74  slab: faces('ks', '#40362e', '#9c8a76'),
75  trim: warmFaces('kt', '#f3ebde'),
76  low: warmFaces('kw', '#e8dfd1'),
77  glass: mat('kg', '#141c33', '#a9daf5'),
78  star: mat('kx', '#f7e7a6', '#a9daf5'),
79  cloud: mat('kc', '#141c33', '#ffffff'),
80  moon: mat('km', '#f1e3a8', '#fff3b8'),
81  frame: warm('kf', '#fbf8f1'),
82}
83
84// Each room's wallpaper, floor, and `ink` for its name.
85export const ROOM_STYLE: Record<Room, { paper: Mat; stripe: Mat; floor: Mat; seam: Mat; kind: 'planks' | 'tiles' | 'carpet'; ink: string }> = {
86  library: { paper: warm('pl', '#7aa587'), stripe: warm('ql', '#8bb697'), floor: warm('fl', '#a26f47'), seam: warm('gl', '#86593a'), kind: 'planks', ink: '#9fe0b4' },
87  study: { paper: warm('pb', '#7b98c8'), stripe: warm('qb', '#8aa6d3'), floor: warm('fb', '#b3814f'), seam: warm('gb', '#946840'), kind: 'planks', ink: '#9cc4ff' },
88  sunroom: { paper: warm('pc', '#86c7bc'), stripe: warm('qc', '#97d2c8'), floor: warm('fc', '#cf9271'), seam: warm('gc', '#bb7c5c'), kind: 'tiles', ink: '#8ef0d8' },
89  kitchen: { paper: warm('pe', '#d4e3e8'), stripe: warm('qe', '#e4eef1'), floor: warm('fe', '#e3e9ec'), seam: warm('ge', '#bcc8cf'), kind: 'tiles', ink: '#ff9aa2' },
90  workshop: { paper: warm('pw', '#d8b38a'), stripe: warm('qw', '#e1c09a'), floor: warm('fw', '#cda46d'), seam: warm('gw', '#b38b56'), kind: 'planks', ink: '#ffc27a' },
91  lounge: { paper: warm('pq', '#c3b2df'), stripe: warm('qq', '#cfc0e7'), floor: warm('fq', '#bcadd9'), seam: warm('gq', '#c8bbe2'), kind: 'carpet', ink: '#d6b8ff' },
92}
93
94// ---------- patterns ----------
95
96// Floor and wallpaper patterns, on the pixel grid.
97export function patterns(cv: Canvas): string {
98  const parts: string[] = []
99  const use = (m: Mat) => {
100    cv.used.set(m.cls, m)
101    return `class="${m.cls}" fill="${m.night}"`
102  }
103  for (const room of ALL_ROOMS) {
104    const s = ROOM_STYLE[room]
105    if (s.kind === 'planks') {
106      // a seam every four units, a joint now and then
107      let seam = ''
108      for (let i = 0; i < 8; i++) seam += `<rect x="${i}" y="${Math.floor(i / 2)}" width="1" height="1"/>`
109      parts.push(`<pattern id="f-${room}" width="8" height="4" patternUnits="userSpaceOnUse"><rect width="8" height="4" ${use(s.floor)}/><g ${use(s.seam)}>${seam}</g></pattern>`)
110    } else if (s.kind === 'tiles') {
111      parts.push(`<pattern id="f-${room}" width="8" height="4" patternUnits="userSpaceOnUse"><rect width="8" height="4" ${use(s.floor)}/>` +
112        `<path ${use(s.seam)} d="M3 0h2v1h2v2h-2v1h-2v-1h-2v-2h2z"/></pattern>`)
113    } else {
114      parts.push(`<pattern id="f-${room}" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" ${use(s.floor)}/>` +
115        `<path ${use(s.seam)} d="M0 0h1v1h-1zM2 2h1v1h-1z"/></pattern>`)
116    }
117    if (HOUSE[0]?.includes(room) || HOUSE.some(line => line[0] === room)) {
118      parts.push(`<pattern id="w-${room}" width="4" height="1" patternUnits="userSpaceOnUse"><rect width="4" height="1" ${use(s.paper)}/><rect x="3" width="1" height="1" ${use(s.stripe)}/></pattern>`)
119    }
120  }
121  return parts.join('')
122}
123
124// A box stamped once per size and material: stretches of wall, mostly.
125function boxStamp(cv: Canvas, L: Layout, tag: string, x: number, y: number, w: number, d: number, h: number, f: Faces, z = 0): void {
126  cv.stamp(`${tag}${w}x${d}x${h}`, at(L, x, y, z), sub => box(sub, L, x, y, w, d, h, f, z))
127}
128
129// ---------- boxes and faces ----------
130
131// A box: back corner `x`, `y`, `h` tall from `z`; only the visible faces.
132export function box(cv: Canvas, L: Layout, x: number, y: number, w: number, d: number, h: number, f: Faces, z = 0): void {
133  const z1 = z + h
134  cv.poly([at(L, x, y, z1), at(L, x + w, y, z1), at(L, x + w, y + d, z1), at(L, x, y + d, z1)], f.top)
135  cv.poly([at(L, x, y + d, z1), at(L, x + w, y + d, z1), at(L, x + w, y + d, z), at(L, x, y + d, z)], f.left)
136  cv.poly([at(L, x + w, y, z1), at(L, x + w, y + d, z1), at(L, x + w, y + d, z), at(L, x + w, y, z)], f.right)
137}
138
139// A patch on a face looking along y, at depth y: x0 to x1, heights z0 to z1.
140export function faceY(cv: Canvas, L: Layout, y: number, x0: number, x1: number, z0: number, z1: number, m: Mat | string): void {
141  cv.poly([at(L, x0, y, z1), at(L, x1, y, z1), at(L, x1, y, z0), at(L, x0, y, z0)], m)
142}
143
144// The same, looking along x.
145export function faceX(cv: Canvas, L: Layout, x: number, y0: number, y1: number, z0: number, z1: number, m: Mat | string): void {
146  cv.poly([at(L, x, y0, z1), at(L, x, y1, z1), at(L, x, y1, z0), at(L, x, y0, z0)], m)
147}
148
149// ---------- the shell: base, floors, walls ----------
150
151export function shell(cv: Canvas, L: Layout): void {
152  // the low base: only its front sides show
153  faceY(cv, L, L.ph, 0, L.pw, -3, 0, MAT.slab.left)
154  faceX(cv, L, L.pw, 0, L.ph, -3, 0, MAT.slab.right)
155  cv.layer()
156  for (const room of ALL_ROOMS) floor(cv, L, L.rooms[room], `url(#f-${room})`)
157  cv.layer()
158  // the tall back walls, papered per room: bare only at tops, ends, a top band and pillars
159  const top = (pts: Pt[]) => cv.poly(pts, MAT.trim.top)
160  top([at(L, 0, 0, TALL_WALL), at(L, T, T, TALL_WALL), at(L, T, L.ph, TALL_WALL), at(L, 0, L.ph, TALL_WALL)])
161  top([at(L, 0, 0, TALL_WALL), at(L, L.pw, 0, TALL_WALL), at(L, L.pw, T, TALL_WALL), at(L, T, T, TALL_WALL)])
162  faceY(cv, L, L.ph, 0, T, 0, TALL_WALL, MAT.trim.left)
163  faceX(cv, L, L.pw, 0, T, 0, TALL_WALL, MAT.trim.right)
164  faceX(cv, L, T, T, L.ph, TALL_WALL - 1, TALL_WALL, MAT.trim.right)
165  faceY(cv, L, T, T, L.pw, TALL_WALL - 1, TALL_WALL, MAT.trim.left)
166  // each wall's inner front end, past the last room's paper
167  faceX(cv, L, T, L.ph - T, L.ph, 0, TALL_WALL - 1, MAT.trim.right)
168  faceY(cv, L, T, L.pw - T, L.pw, 0, TALL_WALL - 1, MAT.trim.left)
169  for (const room of ALL_ROOMS) {
170    const c = L.rooms[room]
171    if (c.col === 0) cv.stamp('wy', at(L, T, c.y), sub => faceX(sub, L, T, c.y, c.y + c.h, 0, TALL_WALL - 1, 'inherit'), `url(#w-${room})`)
172    if (c.row === 0) cv.stamp('wx', at(L, c.x, T), sub => faceY(sub, L, T, c.x, c.x + c.w, 0, TALL_WALL - 1, 'inherit'), `url(#w-${room})`)
173    if (c.col === 0 && c.row > 0) faceX(cv, L, T, c.y - T, c.y, 0, TALL_WALL - 1, MAT.trim.right)
174    if (c.row === 0 && c.col > 0) faceY(cv, L, T, c.x - T, c.x, 0, TALL_WALL - 1, MAT.trim.left)
175  }
176  cv.layer()
177  for (const room of ALL_ROOMS) {
178    const c = L.rooms[room]
179    // skirting boards
180    if (c.col === 0) faceX(cv, L, T, c.y, c.y + c.h, 0, 2, MAT.trim.right)
181    if (c.row === 0) faceY(cv, L, T, c.x, c.x + c.w, 0, 2, MAT.trim.left)
182  }
183  cv.layer()
184  // low walls between rooms, a doorway in each
185  for (const room of ALL_ROOMS) {
186    const c = L.rooms[room]
187    if (c.col > 0) {
188      boxStamp(cv, L, 'lw', c.x - T, c.y - (c.row > 0 ? T : 0), T, DOOR - 5 + (c.row > 0 ? T : 0), LOW_WALL, MAT.low)
189      boxStamp(cv, L, 'lw', c.x - T, c.y + DOOR + 5, T, c.h - DOOR - 5, LOW_WALL, MAT.low)
190    }
191    if (c.row > 0) {
192      boxStamp(cv, L, 'lw', c.x, c.y - T, DOOR - 5, T, LOW_WALL, MAT.low)
193      boxStamp(cv, L, 'lw', c.x + DOOR + 5, c.y - T, c.w - DOOR - 5, T, LOW_WALL, MAT.low)
194    }
195  }
196  cv.layer()
197}
198
199// A room's floor, filled with its pattern or a lamp's light.
200export function floor(cv: Canvas, L: Layout, c: Cell, fill: string, attrs = '', inner = ''): void {
201  cv.stamp('fl', at(L, c.x, c.y), sub => sub.poly([at(L, c.x, c.y), at(L, c.x + c.w, c.y), at(L, c.x + c.w, c.y + c.h), at(L, c.x, c.y + c.h)], 'inherit'), fill, attrs, inner)
202}
203
204// The cut-down front walls, drawn last to sit in front of the floors.
205export function frontWalls(cv: Canvas, L: Layout): void {
206  // stamped room-long stretches; the front door's split around a doorstep
207  const d = frontDoor(L)
208  for (let x = 0; x < L.pw; x += S + T) {
209    const w = Math.min(S + T, L.pw - x)
210    if (d.x0 > x && d.x1 < x + w) {
211      cv.poly([at(L, d.x0, L.ph - T), at(L, d.x1, L.ph - T), at(L, d.x1, L.ph), at(L, d.x0, L.ph)], MAT.trim.top)
212      box(cv, L, x, L.ph - T, d.x0 - x, T, 3, MAT.low)
213      box(cv, L, d.x1, L.ph - T, x + w - d.x1, T, 3, MAT.low)
214    } else boxStamp(cv, L, 'fw', x, L.ph - T, w, T, 3, MAT.low)
215  }
216  for (let y = 0; y < L.ph - T; y += S + T) boxStamp(cv, L, 'fw', L.pw - T, y, T, Math.min(S + T, L.ph - T - y), 3, MAT.low)
217  cv.layer()
218}
219
220// A back-wall window: frame, sky (stars and moon by night, cloud by day), bars. `along`: which wall.
221export function windowOn(cv: Canvas, L: Layout, along: 'x' | 'y', a0: number, a1: number, z0: number, z1: number, seed: number): void {
222  const origin = along === 'x' ? at(L, a0, T, z1) : at(L, T, a0, z1)
223  cv.stamp(`win-${along}${a1 - a0}x${z1 - z0}s${seed}`, origin, sub => windowDrawn(sub, L, along, a0, a1, z0, z1, seed))
224}
225
226function windowDrawn(cv: Canvas, L: Layout, along: 'x' | 'y', a0: number, a1: number, z0: number, z1: number, seed: number): void {
227  const face = (b0: number, b1: number, c0: number, c1: number, m: Mat) => (along === 'x'
228    ? faceY(cv, L, T, b0, b1, c0, c1, m)
229    : faceX(cv, L, T, b0, b1, c0, c1, m))
230  face(a0, a1, z0, z1, MAT.frame)
231  face(a0 + 1, a1 - 1, z0 + 1, z1 - 1, MAT.glass)
232  cv.layer()
233  const mid = Math.round((a0 + a1) / 2)
234  const span = a1 - a0 - 2
235  // a few stars, fixed by the seed
236  for (let i = 0; i < 4; i++) {
237    const u = a0 + 1.5 + ((seed * 7 + i * 5) % Math.max(1, span - 1))
238    const v = z0 + 2 + ((seed + i * 3) % Math.max(1, z1 - z0 - 4))
239    face(u, u + 1, v, v + 1, MAT.star)
240  }
241  if (seed % 2 === 0) face(a1 - 4, a1 - 2, z1 - 4, z1 - 2, MAT.moon)
242  face(a0 + 2, a0 + 6, z1 - 4, z1 - 3, MAT.cloud)
243  cv.layer()
244  face(mid, mid + 1, z0 + 1, z1 - 1, MAT.frame)
245  face(a0 + 1, a1 - 1, Math.round((z0 + z1) / 2), Math.round((z0 + z1) / 2) + 1, MAT.frame)
246  cv.layer()
247}
248
hooks/plan.ts 540 lines
1/*
2 * The house in the terminal: its floor plan from above in cells, crisp in any pane. Turned a quarter so a
3 * tall docked pane fits it big, the furniture drawn flat. Agents are Clawds as Claude Code draws its own,
4 * named in their colour, walking room to room; fires, screens and lights flicker.
5 */
6import type { Resident, Room, Track } from '../types'
7import { ALL_ROOMS, doorX, doorY, frontDoor, house, ROOM_STYLE } from './house'
8import type { Cell } from './house'
9import { appFor, bodyColor, colorFor, displayName, LAYOUT, spotOf } from './scene'
10import type { App } from './scene'
11import { yardOf } from './yard'
12
13// the terminal's own colour
14const DEFAULT = 0x01000000
15const L = house()
16const YARD = yardOf(L)
17const ROAD = YARD.road
18// The plan turned: depth runs across, width down; `x`, `y` are the plan's own, `X`, `Y` turned.
19const ACROSS = L.ph
20const DOWN = L.pw
21const YARD_DEPTH = ROAD.y1 - L.ph
22
23// the picture's colours by night
24const C = {
25  wall: '#cbb397', glass: '#9cc4ff', grass: '#1d3324', tuft: '#3f6b45', blade: '#5c8a4c', tree: '#2f6b3a', path: '#4d4842',
26  amber: '#f5c47a', woodDark: '#9a6740', wood: '#b8834f', pine: '#d6b07a', white: '#f4f2ec', stone: '#9ea3aa',
27  dark: '#24282e', brick: '#b5644f', brass: '#d8b25a', leaf: '#5fae62', linen: '#fbf9f4', blue: '#6f8fd6', green: '#79b89a',
28  armchair: '#5d8f76', couch: '#c77fae', couchBack: '#a8618f', shade: '#f5dca0', red: '#d9584c', parcel: '#c9965a',
29  mail: '#d0782e', zz: '#cfd8ff', steam: '#c9cdd3',
30}
31const BOOKS = ['#c75a5a', '#5a86c7', '#c7ad5a', '#5ac79a', '#9a5ac7', '#c7865a']
32const RUGS: Partial<Record<Room, [string, string]>> = { library: ['#b85c5c', '#d07a7a'], lounge: ['#d9a9c9', '#e8bfd8'] }
33const FIRE = ['#f5a623', '#ffd166', '#f26b3a']
34const TV = ['#5ad0ff', '#ff6ad5', '#ffd34a', '#7df08a']
35const LAMP = { working: '#4cf08c', waiting: '#f5ac45', failed: '#f2503f' }
36
37// Claude Code's own Clawd, nine cells by three, its legs stepping as it walks.
38const CLAWD = [' ▐▛███▜▌ ', '▝▜█████▛▘', '  ▘▘ ▝▝  ']
39const STEPPING = '  ▝▝ ▘▘  '
40const CLAWD_W = 9
41
42// box drawing by a line's arms: up 1, right 2, down 4, left 8
43const LINES = ' ╵╶└╷│┌├╴┘─┴┐┤┬┼'
44
45// What a terminal draws one cell wide, all a Raster takes; anything else becomes a question mark.
46const NARROW = /^[\x20-\x7e\u00a0-\u00ac\u00ae-\u024f\u2010-\u2027\u2261\u2500-\u25a1\u25ac\u25b2\u25cb\u25cf\u2663\u273d]$/
47const letter = (ch: string) => (NARROW.test(ch) ? ch.codePointAt(0) ?? 63 : 63)
48
49// A cell per letter: joiners, variation selectors and skin tones dropped, so a joined emoji is one
50// letter (a question mark: two cells wide).
51const SHAPER = /^(?:[\ufe0e\ufe0f]|[\u{1f3fb}-\u{1f3ff}])$/u
52function lettersOf(text: string): string[] {
53  const out: string[] = []
54  let joined = false
55  for (const ch of text) {
56    if (ch === '\u200d') joined = true
57    else if (SHAPER.test(ch)) continue
58    else if (joined) joined = false
59    else out.push(ch)
60  }
61  return out
62}
63
64// colours as numbers, each parsed once: the plan is rebuilt every redraw
65const inks = new Map<string, number>()
66const hex = (c: string) => {
67  let n = inks.get(c)
68  if (n === undefined) {
69    n = parseInt(c.replace('#', '').padEnd(6, '0').slice(0, 6), 16)
70    if (inks.size > 512) inks.clear()
71    inks.set(c, n)
72  }
73  return n
74}
75// a colour toward black, so things read on a floor
76const dim = (c: string, f: number) => {
77  const n = hex(c)
78  return (Math.round(((n >> 16) & 255) * f) << 16) | (Math.round(((n >> 8) & 255) * f) << 8) | Math.round((n & 255) * f)
79}
80const dimmed = (c: string, f: number) => `#${dim(c, f).toString(16).padStart(6, '0')}`
81
82// A cell of lawn, now and then a tuft in one of two greens.
83const TUFTS = ['"', ',', "'", '"']
84function grassAt(k: number, row: number): [string, string] {
85  const n = ((Math.imul(k, 73856093) ^ Math.imul(row, 19349663)) >>> 0) % 19
86  return n < TUFTS.length ? [TUFTS[n] as string, n % 2 ? C.blade : C.tuft] : [' ', C.tuft]
87}
88
89// One agent: who, and its walk.
90export type Walker = { c: Resident; t: Track }
91
92// A cell cycling glyphs and inks every `ms`. Turns land on BEATs of whole house frames, so frames
93// between change nothing and are not sent.
94const BEAT = 400
95type Flicker = { at: number; frames: [string, string][]; ms: number }
96type Frames = { at: number; frames: [number, number][]; ms: number }
97
98// A room's box in cells: name row, floor rows below, columns between its walls.
99type Box = { head: number; top: number; bottom: number; left: number; right: number }
100
101// The plan for a pane: fixed cells, flickers, where agents may stand, beds for naps.
102export type Plan = {
103  columns: number; rows: number
104  base: Uint32Array; flickers: Frames[]; walkable: Uint8Array
105  cellOf: (x: number, y: number) => [number, number]
106  boxOf: (col: number, row: number) => Box
107  beds: [number, number][]
108}
109
110// The plan's size in a pane `columns` by `bodyRows`: rooms two across and three down beside the yard, each
111// up to about square; rooms and yard each fit a Clawd, all within what a Raster takes.
112type PlanSize = { columns: number; across: number; down: number; rows: number }
113export const RASTER_COLUMNS = 512
114export const RASTER_ROWS = 256
115
116export function planSize(columns: number, bodyRows: number): PlanSize {
117  const yard = Math.max(CLAWD_W + 1, Math.min(18, Math.round(columns * 0.2)))
118  const across = Math.max(CLAWD_W + 2, Math.min(Math.floor((RASTER_COLUMNS - yard - 1) / 2), Math.floor((columns - yard - 1) / 2)))
119  const down = Math.max(5, Math.min(Math.floor((RASTER_ROWS - 1) / 3), Math.round(across / 1.8), Math.floor((bodyRows - 1) / 3)))
120  return { columns: 2 * across + 1 + yard, across, down, rows: 3 * down + 1 }
121}
122
123// desk screen colours by tool, as in the picture
124const SCREENS: Record<App, string> = { code: '#5ad0ff', diff: '#5ad0ff', term: '#4cf08c', web: '#52d2f5', files: '#e6c35c', notes: '#f4f1e8' }
125const screenOf = (tool: string | undefined) => (tool ? SCREENS[appFor(tool)] : C.dark)
126
127// The plan: rooms `across` cells wide and `down` tall, walls included.
128export function planOf(size: PlanSize, boxes: number, residents: Resident[]): Plan {
129  const { columns, across, down, rows } = size
130  const houseRight = 2 * across
131  const yardCols = columns - houseRight - 1
132  const base = new Uint32Array(columns * rows * 3)
133  const walkable = new Uint8Array(columns * rows)
134  const flickers: Flicker[] = []
135  const at = (k: number, r: number) => r * columns + k
136  const inside = (k: number, r: number) => k >= 0 && k < columns && r >= 0 && r < rows
137  // a cell; its paper only if given
138  const set = (k: number, r: number, ch: string, ink: string | number, paper?: string | number) => {
139    if (!inside(k, r)) return
140    const i = at(k, r) * 3
141    base[i] = letter(ch)
142    base[i + 1] = typeof ink === 'number' ? ink : hex(ink)
143    if (paper !== undefined) base[i + 2] = typeof paper === 'number' ? paper : hex(paper)
144  }
145  const write = (k: number, r: number, text: string, ink: string) => lettersOf(text).forEach((ch, i) => set(k + i, r, ch, ink))
146  for (let i = 0; i < base.length; i += 3) {
147    base[i] = 0x20
148    base[i + 1] = DEFAULT
149    base[i + 2] = DEFAULT
150  }
151
152  // Turned plan units to cells, walls on whole cells, each room's name row above its floor.
153  const colOf = (X: number) => (X <= ACROSS
154    ? Math.max(0, Math.min(houseRight, Math.round(((X - 1) / (ACROSS - 2)) * houseRight)))
155    : houseRight + Math.min(yardCols, Math.ceil(((X - ACROSS) / YARD_DEPTH) * yardCols)))
156  const wallRow = (Y: number) => Math.max(0, Math.min(3 * down, Math.round(((Y - 1) / (DOWN - 2)) * 3 * down)))
157  const rooms = Object.values(L.rooms)
158  const boxOfRoom = (c: Cell): Box => {
159    const head = wallRow(c.x - 1) + 1
160    return { head, top: head + 1, bottom: wallRow(c.x + c.w + 1) - 1, left: colOf(c.y - 1), right: colOf(c.y + c.h + 1) }
161  }
162  const roomBoxes = rooms.map(boxOfRoom)
163  const rowOf = (X: number, Y: number) => {
164    const i = X <= ACROSS ? rooms.findIndex(r => Y >= r.x && Y <= r.x + r.w) : -1
165    const c = rooms[i]
166    const b = roomBoxes[i]
167    if (!c || !b) return wallRow(Y)
168    return Math.min(b.bottom, b.top + Math.floor(((Y - c.x) / c.w) * (b.bottom - b.top + 1)))
169  }
170  const cellOf = (x: number, y: number): [number, number] => [colOf(y), rowOf(y, x)]
171  // a rect of the floor plan as cells, at least one
172  const fill = (x0: number, y0: number, x1: number, y1: number, paint: (k: number, r: number, i: number, j: number) => void) => {
173    const [a, b] = [cellOf(x0 + 0.5, y0 + 0.5), cellOf(x1 - 0.5, y1 - 0.5)]
174    const [k0, k1, r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[1], b[1])]
175    for (let r = r0; r <= r1; r++) for (let k = k0; k <= k1; k++) paint(k, r, k - k0, r - r0)
176  }
177
178  // floors (planks in rows, tiles checkered, carpet plain) under a wallpaper strip: name and activity
179  for (const name of ALL_ROOMS) {
180    const c = L.rooms[name]
181    const style = ROOM_STYLE[name]
182    const b = boxOfRoom(c)
183    for (let r = b.head; r <= b.bottom; r++) {
184      for (let k = b.left + 1; k < b.right; k++) {
185        const shade = style.kind === 'tiles' ? ((k + r) % 2 ? 0.3 : 0.25) : style.kind === 'planks' ? (r % 2 ? 0.3 : 0.26) : 0.28
186        set(k, r, ' ', DEFAULT, r === b.head ? dim(style.paper.night, 0.32) : dim(style.floor.night, shade))
187        if (r > b.head) walkable[at(k, r)] = 1
188      }
189    }
190    write(b.left + 2, b.head, name, style.ink)
191    const busy = residents.find(m => m.room === name && (m.status === 'working' || m.status === 'waiting') && m.leaving === undefined)
192    if (busy) {
193      const doing = lettersOf(busy.status === 'waiting' ? `wants ${busy.waitingTool ?? 'you'}` : busy.activity)
194      const from = b.left + name.length + 4
195      const room = b.right - 1 - from
196      if (room > 3) write(from, b.head, doing.length > room ? `${doing.slice(0, room - 1).join('')}…` : doing.join(''), busy.status === 'waiting' ? C.amber : dimmed(style.ink, 0.75))
197    }
198  }
199
200  // the rugs, under everything on them
201  const R = L.rooms
202  for (const [name, [edge, middle]] of Object.entries(RUGS) as [Room, [string, string]][]) {
203    const [x0, y0, x1, y1] = name === 'library' ? [12, 15, 34, 33] : [15, 21, 37, 36]
204    const [a, b] = [cellOf(R[name].x + x0, R[name].y + y0), cellOf(R[name].x + x1, R[name].y + y1)]
205    const [k0, k1, r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[1], b[1])]
206    for (let r = r0; r <= r1; r++) {
207      for (let k = k0; k <= k1; k++) set(k, r, ' ', DEFAULT, dim(r === r0 || r === r1 || k === k0 || k === k1 ? edge : middle, 0.55))
208    }
209  }
210
211  // Furniture, a little of each piece, against the same walls as in the picture.
212  const piece = (room: Room, x0: number, y0: number, x1: number, y1: number, paint: (k: number, r: number, i: number, j: number) => void) =>
213    fill(R[room].x + x0, R[room].y + y0, R[room].x + x1, R[room].y + y1, paint)
214  // a small thing: one cell at any pane size
215  const spot = (room: Room, x: number, y: number, ch: string, ink: string, paper?: string, frames?: [string, string][], ms = BEAT) => {
216    const [k, r] = cellOf(R[room].x + x, R[room].y + y)
217    set(k, r, ch, ink, paper)
218    if (frames) flickers.push({ at: at(k, r), frames, ms })
219  }
220  // desks on each work room's back wall, screens lit by whoever works there, a light by each
221  for (const name of ALL_ROOMS) {
222    if (name === 'lounge') continue
223    for (let d = 0; d < 2; d++) {
224      const sitter = residents.find(c => c.room === name && c.seat === d && (c.status === 'working' || c.status === 'waiting') && c.leaving === undefined)
225      const lit = sitter ? screenOf(sitter.tool) : C.dark
226      const x0 = 2 + 14 * d
227      piece(name, x0, 1, x0 + 12, 7, (k, r, i, j) => {
228        const isScreen = i === 0 && j > 0 && j < 3
229        set(k, r, isScreen ? '▌' : ' ', lit, name === 'kitchen' ? C.stone : C.wood)
230        if (isScreen && sitter) flickers.push({ at: at(k, r), frames: [['▌', lit], ['▌', lit], ['▌', dimmed(lit, 0.6)]], ms: BEAT * j })
231      })
232      if (sitter) {
233        const light = sitter.status === 'waiting' ? LAMP.waiting : sitter.isOk === false ? LAMP.failed : LAMP.working
234        spot(name, x0 + 11, 4, '•', light, name === 'kitchen' ? C.stone : C.wood, [['•', light], ['•', C.woodDark]], 2 * BEAT)
235      }
236    }
237  }
238  // library: bookshelves, reading lamp
239  piece('library', 0, 9, 5, 27, (k, r, i, j) => set(k, r, '▄', BOOKS[(i * 2 + j) % BOOKS.length] ?? C.red, BOOKS[(i * 2 + j + 3) % BOOKS.length] ?? C.blue))
240  spot('library', 33.5, 3.5, '●', C.shade)
241  // study: fireplace, armchair, plant, lamp
242  piece('study', 29, 1, 39, 6, (k, r) => set(k, r, '▐', C.dark, C.brick))
243  for (const x of [32.5, 35.5]) spot('study', x, 4, '▲', FIRE[0] ?? C.red, C.dark, FIRE.map((f, n) => ['▲', FIRE[(n + Math.round(x)) % FIRE.length] ?? f]), BEAT)
244  piece('study', 1, 16, 8, 23, (k, r, _, j) => set(k, r, j === 0 ? '▀' : ' ', C.armchair, C.green))
245  spot('study', 3.5, 10.5, '♣', C.leaf)
246  spot('study', 1.5, 26.5, '●', C.shade)
247  // sunroom: telescope, radio, plant
248  spot('sunroom', 6, 14, '╲', C.brass)
249  spot('sunroom', 3.5, 21.5, '■', C.dark, C.wood)
250  spot('sunroom', 3.5, 24, '•', LAMP.working, C.wood, [['•', LAMP.working], ['•', '#1f5f3a']], 2 * BEAT)
251  spot('sunroom', 35.5, 4.5, '♣', C.leaf)
252  // kitchen: fridge, stove and steaming pot, coffee machine
253  piece('kitchen', 0, 8, 6, 15, (k, r, _, j) => set(k, r, j === 0 ? '▀' : ' ', C.stone, C.white))
254  piece('kitchen', 0, 15, 6, 27, (k, r) => set(k, r, ' ', DEFAULT, C.stone))
255  spot('kitchen', 2.5, 17.5, '●', C.red, C.stone, [['●', C.red], ['●', C.red], ['~', C.steam]], BEAT)
256  spot('kitchen', 2.5, 21, '○', C.dark, C.stone)
257  spot('kitchen', 2.5, 24.5, '■', C.dark, C.stone, [['■', C.dark], ['■', C.red]], 3 * BEAT)
258  // workshop: workbench, toolbox, vise, level, plant
259  piece('workshop', 0, 9, 7, 24, (k, r) => set(k, r, ' ', DEFAULT, C.pine))
260  spot('workshop', 2.5, 12, '▬', C.red, C.pine)
261  spot('workshop', 2.5, 18, '≡', C.dark, C.pine)
262  spot('workshop', 4.5, 21.5, '▬', C.brass, C.pine)
263  spot('workshop', 36.5, 36.5, '♣', C.leaf)
264  // lounge: two beds, TV, couch, plant
265  for (let b = 0; b < 2; b++) {
266    piece('lounge', 0, 1 + 11 * b, 15, 10 + 11 * b, (k, r, _, j) => set(k, r, j === 0 ? ' ' : '▄', b === 0 ? C.blue : C.green, j === 0 ? C.linen : b === 0 ? C.blue : C.green))
267  }
268  piece('lounge', 18, 0, 28, 4, (k, r, i) => {
269    set(k, r, '▌', C.dark, C.wood)
270    if (i === 0) flickers.push({ at: at(k, r), frames: TV.map(c => ['▌', c] as [string, string]), ms: 2 * BEAT })
271  })
272  piece('lounge', 17, 13, 30, 18, (k, r, i) => set(k, r, i === 0 ? ' ' : '▐', C.couchBack, C.couch))
273  spot('lounge', 36.5, 36.5, '♣', C.leaf)
274  const beds: [number, number][] = [[8, 5.5], [8, 16.5]].map(([x, y]) => cellOf(R.lounge.x + (x ?? 0), R.lounge.y + (y ?? 0)))
275
276  // The walls, but for doorways, with windows in the outer walls.
277  const arms = new Uint8Array(columns * rows)
278  const line = (k0: number, r0: number, k1: number, r1: number) => {
279    for (let r = r0; r <= r1; r++) {
280      for (let k = k0; k <= k1; k++) {
281        const i = at(k, r)
282        if (k0 === k1 && r0 !== r1) arms[i] = (arms[i] ?? 0) | (r > r0 ? 1 : 0) | (r < r1 ? 4 : 0)
283        else arms[i] = (arms[i] ?? 0) | (k > k0 ? 8 : 0) | (k < k1 ? 2 : 0)
284      }
285    }
286  }
287  const walls = [0, down, 2 * down, 3 * down]
288  for (const k of [0, across, houseRight]) line(k, 0, k, 3 * down)
289  for (const r of walls) line(0, r, houseRight, r)
290  const gap = (k0: number, r0: number, k1: number, r1: number) => {
291    for (let r = r0; r <= r1; r++) {
292      for (let k = k0; k <= k1; k++) {
293        arms[at(k, r)] = 0
294        walkable[at(k, r)] = 1
295      }
296    }
297  }
298  for (const c of rooms) {
299    // doorways to the rooms once beside it (now below) and once below it (now beside)
300    if (c.col < 2) {
301      const r = boxOfRoom(c).bottom + 1
302      gap(colOf(doorY(c) - 3), r, colOf(doorY(c) + 3), r)
303    }
304    if (c.row < 1) gap(across, cellOf(doorX(c), c.y + c.h - 1)[1], across, cellOf(doorX(c), c.y + c.h - 1)[1])
305  }
306  const door = frontDoor(L)
307  gap(houseRight, cellOf(door.x0 + 2, L.ph - 2)[1], houseRight, cellOf(door.x1 - 2, L.ph - 2)[1])
308  const glass = new Set<number>()
309  for (const c of rooms) {
310    const b = boxOfRoom(c)
311    if (c.y < L.ph / 2) for (let r = b.top + 1; r < b.bottom; r++) glass.add(at(0, r))
312    if (c.x < 10) for (let k = b.left + 3; k < b.right - 3; k++) glass.add(at(k, 0))
313    if (c.x > L.pw - 50) for (let k = b.left + 3; k < b.right - 3; k++) glass.add(at(k, 3 * down))
314  }
315  for (let r = 0; r < rows; r++) {
316    for (let k = 0; k < columns; k++) {
317      const i = at(k, r)
318      if (!arms[i]) continue
319      const has = (dk: number, dr: number) => inside(k + dk, r + dr) && (arms[at(k + dk, r + dr)] ?? 0) > 0
320      const a = (arms[i] as number) & ((has(0, -1) ? 1 : 0) | (has(1, 0) ? 2 : 0) | (has(0, 1) ? 4 : 0) | (has(-1, 0) ? 8 : 0))
321      walkable[i] = 0
322      const isGlass = glass.has(i) && (a === 10 || a === 5)
323      set(k, r, isGlass ? (a === 10 ? '═' : '║') : LINES[a] ?? ' ', isGlass ? C.glass : C.wall, DEFAULT)
324    }
325  }
326
327  // The yard: lawn, trees, road, the day's boxes, mailbox.
328  const [roadTop, roadBottom] = [cellOf(ROAD.x0 + 1, L.ph + 1)[1], cellOf(ROAD.x1 - 1, L.ph + 1)[1]]
329  for (let r = 0; r < rows; r++) {
330    for (let k = houseRight + 1; k < columns; k++) {
331      const isRoad = r >= roadTop && r <= roadBottom
332      const [ch, ink] = isRoad ? [' ', C.tuft] : grassAt(k, r)
333      set(k, r, ch, ink, isRoad ? C.path : C.grass)
334      walkable[at(k, r)] = 1
335    }
336  }
337  for (const [x, y] of [[20, 100], [110, 120], [40, 128], [100, 105]] as [number, number][]) {
338    const [k, r] = cellOf(x, y)
339    if (r < roadTop - 1 || r > roadBottom + 1) set(k, r, '♣', C.tree)
340  }
341  const count = `${boxes}`
342  const squares = Math.max(0, Math.min(3, boxes, columns - houseRight - 3 - count.length))
343  write(houseRight + 2, roadTop - 2, `${'■'.repeat(squares)}${squares > 0 ? ' ' : ''}${count}`, C.parcel)
344  set(columns - 2, roadTop - 1, '▐', C.mail)
345
346  // the room a cell is in, for a doorway either one; past the house, the yard
347  const yard: Box = { head: 0, top: 1, bottom: rows - 1, left: houseRight, right: columns }
348  const boxOf = (k: number, r: number): Box => (k >= houseRight ? yard
349    : roomBoxes.find(b => k > b.left && k < b.right && r >= b.head && r <= b.bottom) ??
350      roomBoxes.find(b => k >= b.left && k <= b.right && r >= b.head - 1 && r <= b.bottom + 1) ?? yard)
351  // one flicker per cell, the first; none where a wall covers it in a tiny pane
352  const flickering = new Set<number>()
353  const frames = flickers
354    .filter(f => !arms[f.at] && !flickering.has(f.at) && flickering.add(f.at))
355    .map(f => ({ at: f.at, ms: f.ms, frames: f.frames.map(([ch, ink]): [number, number] => [letter(ch), hex(ink)]) }))
356  return { columns, rows, base, flickers: frames, walkable, cellOf, boxOf, beds }
357}
358
359// A cell the walker may stand on at `now`; none out of sight.
360export function cellAt(plan: Plan, w: Walker, now: number): [number, number] | undefined {
361  const p = spotOf(w.t, now)
362  if (!p) return undefined
363  // the picture's point back on the floor plan
364  const { ox, oy } = LAYOUT
365  const [k, r] = plan.cellOf((p.x - ox) / 2 + (p.y - oy), p.y - oy - (p.x - ox) / 2)
366  const ok = (dk: number, dr: number) => k + dk >= 0 && k + dk < plan.columns && plan.walkable[(r + dr) * plan.columns + k + dk] === 1
367  const near = [[0, 0], [1, 0], [-1, 0], [0, -1], [0, 1], [1, 1], [-1, -1], [1, -1], [-1, 1]].find(([dk, dr]) => ok(dk ?? 0, dr ?? 0))
368  return near ? [k + (near[0] ?? 0), r + (near[1] ?? 0)] : [k, r]
369}
370
371// A Clawd's place, feet at `cell`, in the room; its name's row below, else above, else none.
372function spriteAt(plan: Plan, cell: [number, number]): { left: number; feet: number; name: number } {
373  const [k, r] = cell
374  const b = plan.boxOf(k, r)
375  const feet = Math.max(b.top + 2, Math.min(b.bottom, r))
376  const left = Math.max(b.left + 1, Math.min(b.right - CLAWD_W, k - 2))
377  return { left, feet, name: feet + 1 <= b.bottom ? feet + 1 : feet - 3 > b.head ? feet - 3 : -1 }
378}
379
380// A name's columns, centred under its Clawd in the room, to one past its last; none if it cannot fit.
381function nameSpan(plan: Plan, w: Walker, s: { left: number; feet: number; name: number }): [number, number] | undefined {
382  if (s.name < 0) return undefined
383  const length = lettersOf(displayName(w.c)).length
384  const b = plan.boxOf(s.left + 4, s.feet)
385  const start = Math.max(b.left + 1, Math.min(b.right - length, s.left + 4 - Math.floor(length / 2)))
386  return start >= 0 && start + length <= plan.columns ? [start, start + length] : undefined
387}
388
389// The cells a Clawd and its name cover, feet at `cell`.
390export function reachOf(plan: Plan, w: Walker, cell: [number, number]): [number, number, number, number] {
391  const s = spriteAt(plan, cell)
392  const span = nameSpan(plan, w, s)
393  const row = span ? s.name : s.feet
394  return [
395    Math.max(0, Math.min(s.left, span?.[0] ?? s.left)), Math.max(0, Math.min(s.feet - 2, row)),
396    Math.min(plan.columns, Math.max(s.left + CLAWD_W, span?.[1] ?? 0)), Math.min(plan.rows, Math.max(s.feet, row) + 1),
397  ]
398}
399
400// The garden for rows a taller pane leaves above or below the plan; fixed per size.
401const AUTUMN = ['#e2702a', '#cf3a2a', '#e8b23a', '#f0663e']
402const FLOWERS = ['#e48aa6', '#b89ae6', '#f2cf5c', '#f4f2ec']
403
404const gardens = new Map<string, string>()
405
406// Garden rows packed for a Raster, once per size.
407export function gardenRaster(columns: number, rows: number, side: 'above' | 'below'): string {
408  const key = `${columns}x${rows}:${side}`
409  const known = gardens.get(key)
410  if (known !== undefined) return known
411  if (gardens.size > 16) gardens.clear()
412  const packed = packCells(gardenCells(columns, rows, side))
413  gardens.set(key, packed)
414  return packed
415}
416
417function gardenCells(columns: number, rows: number, side: 'above' | 'below'): Uint32Array {
418  const cells = new Uint32Array(columns * rows * 3)
419  let seed = (columns * 131 + rows * 7 + (side === 'above' ? 0 : 977)) >>> 0
420  const r = () => (seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296
421  const pick = <T,>(list: T[]) => list[Math.floor(r() * list.length)] as T
422  const taken = new Set<number>()
423  const set = (k: number, row: number, ch: string, ink: string, bg = C.grass) => {
424    if (k < 0 || k >= columns || row < 0 || row >= rows || taken.has(row * columns + k)) return false
425    cells.set([letter(ch), hex(ink), hex(bg)], (row * columns + k) * 3)
426    return true
427  }
428  for (let row = 0; row < rows; row++) {
429    for (let k = 0; k < columns; k++) {
430      const [ch, ink] = grassAt(k, side === 'above' ? -1 - row : 1000 + row)
431      set(k, row, ch, ink)
432    }
433  }
434  const area = columns * rows
435  // a pond, its far corners off
436  const pond = (pw: number, ph: number) => {
437    const pk = 1 + Math.floor(r() * Math.max(1, columns - pw - 2))
438    const pr = Math.floor(r() * Math.max(1, rows - ph + 1))
439    for (let dr = 0; dr < ph; dr++) {
440      for (let dk = dr === ph - 1 && ph > 1 ? 1 : 0; dk < pw - (dr === ph - 1 && ph > 1 ? 1 : 0); dk++) {
441        if (set(pk + dk, pr + dr, (pk + dk + pr) % 3 === 0 ? '~' : ' ', C.glass, '#16202e')) taken.add((pr + dr) * columns + pk + dk)
442      }
443    }
444  }
445  if (rows >= 4 && columns >= 16) for (let n = Math.max(1, Math.round(area / 1200)); n > 0; n--) pond(6 + Math.floor(r() * 4), 2)
446  for (let n = Math.round(area / 400); n > 0; n--) pond(3 + Math.floor(r() * 3), 1)
447  // a tree, and its leaves round it
448  const plant = (k: number, row: number, isPine: boolean, ink: string) => {
449    if (!set(k, row, isPine ? '▲' : '♣', ink)) return
450    taken.add(row * columns + k)
451    if (!isPine) for (let m = Math.floor(r() * 2); m > 0; m--) set(k + pick([-2, -1, 1, 2]), row + pick([0, 0, 1]), pick(['·', ',']), ink)
452  }
453  // copses of two to four, pines or one autumn colour
454  for (let n = Math.round(area / 90); n > 0; n--) {
455    const [k, row] = [Math.floor(r() * columns), Math.floor(r() * rows)]
456    const isPine = r() < 0.3
457    const ink = isPine ? C.tree : pick(AUTUMN)
458    for (let m = 2 + Math.floor(r() * 3); m > 0; m--) plant(k + Math.round((r() - 0.5) * 7), row + Math.round((r() - 0.5) * 3), isPine, ink)
459  }
460  for (let n = Math.round(area / 115); n > 0; n--) {
461    const isPine = r() < 0.3
462    plant(Math.floor(r() * columns), Math.floor(r() * rows), isPine, isPine ? C.tree : pick(AUTUMN))
463  }
464  // flowers in twos and threes
465  for (let n = Math.round(area / 40); n > 0; n--) {
466    const k = Math.floor(r() * columns)
467    const row = Math.floor(r() * rows)
468    const ink = pick(FLOWERS)
469    for (let m = 2 + Math.floor(r() * 2), dk = 0; m > 0; m--, dk += 1 + Math.floor(r() * 2)) set(k + dk, row + (m % 2), pick(['*', '✽', '·']), ink)
470  }
471  // leaves blown about the lawn
472  for (let n = Math.round(area / 34); n > 0; n--) set(Math.floor(r() * columns), Math.floor(r() * rows), pick(['·', ',', '`']), pick(AUTUMN))
473  return cells
474}
475
476// The plan at `now`: codepoint, ink and paper per cell, row by row.
477export function planCells(plan: Plan, walkers: Walker[], now: number): Uint32Array {
478  const out = plan.base.slice()
479  const { columns } = plan
480  for (const f of plan.flickers) {
481    const [ch, ink] = f.frames[Math.floor(now / f.ms) % f.frames.length] ?? [0x20, 0]
482    out[f.at * 3] = ch
483    out[f.at * 3 + 1] = ink
484  }
485  const taken = new Uint8Array(columns * plan.rows)
486  const put = (k: number, r: number, ch: string, ink: string, isWord = false) => {
487    const i = r * columns + k
488    if (k < 0 || k >= columns || r < 0 || r >= plan.rows || (isWord && taken[i])) return false
489    taken[i] = 1
490    out[i * 3] = letter(ch)
491    out[i * 3 + 1] = hex(ink)
492    return true
493  }
494  // the Clawds first, nearest the front last, so a word never hides one
495  const placed = walkers
496    .map(w => ({ w, cell: cellAt(plan, w, now), before: cellAt(plan, w, now - 250) }))
497    .filter((p): p is typeof p & { cell: [number, number] } => p.cell !== undefined)
498    .sort((a, b) => a.cell[1] - b.cell[1])
499    .map(p => ({ ...p, s: spriteAt(plan, p.cell) }))
500  for (const { w, cell, before, s } of placed) {
501    const isWalking = before !== undefined && (before[0] !== cell[0] || before[1] !== cell[1])
502    const body = w.c.status === 'waiting' ? C.amber : bodyColor(w.c)
503    const lines = [CLAWD[0] ?? '', CLAWD[1] ?? '', isWalking && Math.floor(now / (BEAT / 2)) % 2 ? STEPPING : CLAWD[2] ?? '']
504    lines.forEach((line, j) => [...line].forEach((ch, i) => ch !== ' ' && put(s.left + i, s.feet - 2 + j, ch, body)))
505    // a nap in a bed: z's rising
506    if (!isWalking && plan.beds.some(([k, r]) => Math.abs(k - cell[0]) <= 3 && Math.abs(r - cell[1]) <= 2)) {
507      put(s.left + CLAWD_W - 1, s.feet - 2 - (Math.floor(now / (2 * BEAT)) % 2), 'z', C.zz, true)
508    }
509    if (w.c.status === 'waiting' && Math.floor(now / BEAT) % 2) put(s.left + CLAWD_W - 1, s.feet - 2, '!', C.amber, true)
510  }
511  // the names, under their Clawds
512  for (const { w, s } of placed) {
513    const span = nameSpan(plan, w, s)
514    if (!span) continue
515    const [start] = span
516    const name = lettersOf(displayName(w.c))
517    if (name.some((_, i) => taken[s.name * columns + start + i])) continue
518    name.forEach((ch, i) => put(start + i, s.name, ch, colorFor(w.c), true))
519  }
520  return out
521}
522
523// Cells as a Raster takes them: little-endian words in base64; on Claude Code's little-endian machines
524// the bytes go as they are.
525const LITTLE = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
526
527export function packCells(cells: Uint32Array): string {
528  let bytes = new Uint8Array(cells.buffer, cells.byteOffset, cells.byteLength)
529  if (!LITTLE) {
530    bytes = new Uint8Array(cells.length * 4)
531    const view = new DataView(bytes.buffer)
532    cells.forEach((w, i) => view.setUint32(i * 4, w, true))
533  }
534  const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
535  if (native) return native.call(bytes)
536  let text = ''
537  for (let i = 0; i < bytes.length; i += 0x2000) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000) as unknown as number[])
538  return btoa(text)
539}
540
hooks/scene.ts 2178 lines
1/*
2 * The house: six rooms seen from above at an angle, near walls cut down (see house.ts, furniture.ts).
3 * Working agents type at the desk for their kind of work; idle ones drift through moods, use the
4 * furniture and huddle up to talk in runes.
5 *
6 * Plans are made on the floor plan and projected into layered SVGs (garden, house, agents), animated
7 * with SMIL timelines anchored to the clock so a redraw picks every agent up mid-stride.
8 */
9import type { Resident, Place, Room, Track } from '../types'
10import { fitText, glyphText, pixelText, textWidth } from './font'
11import { desk, deskAt, furniture, machine } from './furniture'
12import { frontTrees, garden, gardenPatterns, lamps, treesBehind, weather } from './garden'
13import { boxSpots, road, signAt, yardOf, yardPieces } from './yard'
14import type { Parcel } from './yard'
15import { ALL_ROOMS, at, doorX, doorY, floor, floorBottom, freeFloor, frontDoor, frontWalls, house, patterns, ROOM_STYLE, shell, stretch, T, TALL_WALL, windowOn } from './house'
16import type { Cell, Dir, Layout } from './house'
17import { Canvas, loopBegin, pathOf, rng, setClock, shade, styleOf } from './iso'
18import type { Pt } from './iso'
19
20// Seconds in every idle loop, shared so meetings and props line up.
21const LOOP = 90
22// Pixel text: each font pixel half an art pixel; an agent's name smaller.
23const PX = 0.5
24const NAME_PX = 0.375
25
26const CLAUDE_ORANGE = '#d97757'
27
28const ACC = { work: '#4cf08c', data: '#52d2f5', alert: '#f2503f' }
29const MACHINE_GLOW: Record<Exclude<Room, 'lounge'>, string> = {
30  library: '#e6c35c', sunroom: ACC.data, kitchen: ACC.work, workshop: '#e6c35c', study: ACC.work,
31}
32const PROP_FAIL = '#f25a5a'
33const LAMP = { idle: '#f59a3a', working: '#4cf08c', waiting: '#f5ac45', failed: ACC.alert }
34const PHOSPHOR = '#66f5a0'
35
36type Span = [number, number]
37type Run = { x: number; y: number; w: number; h: number; c: string }
38
39// ---------- things agents say ----------
40
41const LINES_UNWIND = ['five minutes', 'long day of tokens', 'this couch gets it', 'just resting my eyes']
42const LINES_BORED = ['anyone need a hand?', 'high score time', 'one more game', 'beat that']
43const LINES_STANDBY = ['ping me', 'ready', 'all yours', 'say the word']
44const LINES_BROWSE = ['hm, interesting', 'never read this one', 'where was that file', 'good reference']
45const LINES_LOOK = ['nice work', 'ooh', 'go on', 'that compiles?']
46const LINES_FIRE = ['so toasty', 'crackle crackle', 'cozy', 'warming up']
47const LINES_READ = ['one more chapter', 'plot twist!', 'good book', 'ahh, cozy']
48const LINES_MAIL = ['no mail today', 'a postcard!', 'bills, bills', 'look at that stack']
49const LINES_SHIP = ['shipped!', 'one more box', 'special delivery', 'stack it high']
50const LINES_SNACK = ['just one bite', 'who ate my sandwich', 'midnight snack', 'mmm, leftovers']
51const LINES_STARS = ['so many stars', 'is that a satellite?', 'clear skies', 'a comet!']
52const LINES_RADIO = ['good song', 'turn it up', 'static again', 'this station rocks']
53const LINES_TINKER = ['almost...', 'tap tap', 'needs a screw', 'there we go']
54const LINES_TV = ['one more episode', 'no spoilers', 'plot twist!', 'cozy night in']
55const LINES_COFFEE = ['decaf again?', 'smells like progress', 'refill', 'brew time']
56const LINES_SLEEP = ['zzz', 'back soon', 'nap protocol', 'dreaming in json']
57const LINES_ERROR = ['oops', 'that did not work', 'hmm, error', 'retrying…', 'ugh']
58const LINES_WAVE = ['hi!', 'o/', 'hello!', 'hey you', 'hiya!']
59const LINES_SPIN = ['wheee', 'whee!', 'so dizzy', 'again!']
60const LINES_MOON = ['smooth', 'hee-hee', 'like butter', 'look ma']
61const LINES_JUGGLE = ['look!', 'ta-da', 'watch this', 'no bugs dropped']
62const LINES_SNEEZE = ['achoo!', 'ah-choo', 'bless me', 'dusty logs']
63const LINES_STUMBLE = ['oof!', 'whoops', 'nobody saw that', 'who put that there']
64const LINES_YAWN = ['*yawn*', 'so sleepy', 'mmh', 'is it friday']
65const LINES_HOP = ['♪', 'yay!', 'tests pass!', 'woo']
66const LINES_HIGHFIVE = ['yeah!', 'nice!', 'teamwork', 'up top!']
67const LINES_DANCE = ['♪', '♪♪', 'groovy', 'dance break']
68const LINES_WIN = ['ha!', 'again?', 'too easy', 'undefeated']
69const LINES_LOSE = ['nooo', 'best of 3', 'rigged', 'rematch!']
70const THROWS = ['rock!', 'paper!', 'scissors!']
71const BEATS: Record<string, string> = { 'rock!': 'scissors!', 'paper!': 'rock!', 'scissors!': 'paper!' }
72
73// ---------- helpers ----------
74
75function hash(text: string): number {
76  let n = 2166136261
77  for (const ch of text) n = Math.imul(n ^ ch.charCodeAt(0), 16777619) >>> 0
78  return n
79}
80
81const between = (r: () => number, a: number, b: number) => a + r() * (b - a)
82const pick = <T>(list: readonly T[], r: () => number): T => list[Math.floor(r() * list.length) % list.length] as T
83const r1 = (n: number) => Math.round(n * 10) / 10
84const r2 = (n: number) => Math.round(n * 100) / 100
85const k4 = (n: number) => n.toFixed(4)
86
87// By code points, so an emoji is never split.
88export function clip(text: string, max: number): string {
89  const chars = [...text]
90  return chars.length > max ? chars.slice(0, Math.max(1, max - 1)).join('') + '…' : text
91}
92
93// Own entries only: an agent type named like one of an object's built-in keys is a name like any other.
94export const own = <T>(table: Record<string, T>, key: string): T | undefined =>
95  (Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined)
96
97function rect(r: Run, extra = ''): string {
98  return `<rect x="${r1(r.x)}" y="${r1(r.y)}" width="${r.w}" height="${r.h}" fill="${r.c}"${extra}/>`
99}
100
101function blinkAnim(attr: string, on: string, off: string, periodMs: number): string {
102  const dur = periodMs / 1000
103  return `<animate attributeName="${attr}" values="${on};${off}" keyTimes="0;0.5" calcMode="discrete" dur="${dur}s" ${loopBegin(dur)} repeatCount="indefinite"/>`
104}
105
106// The on (1) and off (0) steps of `spans` in a timeline `total` seconds long.
107type Step = { t: number; v: number }
108function stepsOf(spans: Span[], total: number): Step[] {
109  const merged = spans
110    .map(([a, b]) => [Math.max(0, a), Math.min(total, b)] as Span)
111    .filter(([a, b]) => b > a)
112    .sort((p, q) => p[0] - q[0])
113  if (merged.length === 0) return [{ t: 0, v: 0 }]
114  const points: Step[] = [{ t: 0, v: (merged[0] as Span)[0] <= 0 ? 1 : 0 }]
115  for (const [a, b] of merged) {
116    if (a > 0) points.push({ t: a, v: 1 })
117    if (b < total) points.push({ t: b, v: 0 })
118  }
119  const clean: Step[] = []
120  for (const p of points) {
121    const last = clean[clean.length - 1]
122    if (last && p.t / total - last.t / total < 0.0002) { last.v = p.v; continue }
123    if (last && last.v === p.v) continue
124    clean.push({ ...p })
125  }
126  return clean
127}
128
129function stepAnim(attr: string, values: string[], steps: Step[], total: number, begin: number, isLoop: boolean): string {
130  const repeat = isLoop ? 'repeatCount="indefinite"' : 'fill="freeze"'
131  return `${attr} values="${values.join(';')}" keyTimes="${steps.map(p => k4(p.t / total)).join(';')}" calcMode="discrete" dur="${r2(Math.max(0.1, total))}s" begin="-${r2(begin)}s" ${repeat}/>`
132}
133
134// A discrete 0/1 opacity track, on inside `spans`, looping or held at its end.
135function track(spans: Span[], total: number, begin: number, isLoop: boolean): { attr: string; anim: string } {
136  const steps = stepsOf(spans, total)
137  const attr = ` opacity="${steps[0]?.v ?? 0}"`
138  if (steps.length === 1) return { attr, anim: '' }
139  return { attr, anim: stepAnim('<animate attributeName="opacity"', steps.map(p => String(p.v)), steps, total, begin, isLoop) }
140}
141
142function group(content: string, t: { attr: string; anim: string }): string {
143  if (t.attr === ' opacity="0"' && !t.anim) return ''
144  return `<g${t.attr}>${t.anim}${content}</g>`
145}
146
147// ---------- pixel art ----------
148
149const JUGGLE = ['#f2503f', '#52d2f5', '#ffd166']
150
151const KIND_COLOR: Record<string, string> = {
152  main: '#ffd166',
153  Explore: '#5ad0ff',
154  Plan: '#e0a0ff',
155  'general-purpose': '#7df08a',
156  shell: '#5affd0',
157  monitor: '#ff8a5a',
158  workflow: '#ff6a9a',
159}
160const OTHER_COLORS = ['#5cc8f5', '#f58c5c', '#80e88c', '#d9a3f5', '#f5d05c', '#5cf5cc', '#f5709c']
161
162export function colorFor(c: Resident): string {
163  return teamOf(c.session ?? '') ?? own(KIND_COLOR, c.kind) ?? OTHER_COLORS[hash(c.kind) % OTHER_COLORS.length] ?? '#5cc8f5'
164}
165
166// A session's colour; none while it is alone in the house.
167const teamOf = (session: string): string | undefined => hues.get(session)
168
169// With several sessions, each colours its names, rings and tape; alone, names keep their kind's colour.
170const SESSION_COLORS = ['#ffd166', '#5ad0ff', '#f56fcf', '#7df08a', '#7b8cff', '#c9a0ff', '#5affd0', '#e4e6f0']
171let hues = new Map<string, string>()
172
173// `sessions` oldest first, this one as '' (`self`), so every house picks the same colours.
174export function paintSessions(sessions: string[], self: string): void {
175  hues = new Map()
176  if (sessions.length < 2) return
177  const taken = new Set<number>()
178  for (const id of sessions) {
179    let k = hash('session:' + (id || self)) % SESSION_COLORS.length
180    while (taken.has(k) && taken.size < SESSION_COLORS.length) k = (k + 1) % SESSION_COLORS.length
181    taken.add(k)
182    hues.set(id, SESSION_COLORS[k] ?? '#ffd166')
183  }
184}
185
186// Members saved before `taskType` existed are told apart by their name.
187export function taskTypeOf(c: Resident): string {
188  if (c.taskType) return c.taskType
189  return c.isBackground && c.name.startsWith('Background ') ? c.kind : 'subagent'
190}
191
192// Everyone goes by a one-word role, homely and a little silly; background work goes by its chore.
193const BUILT_IN_ROLES: Record<string, string> = {
194  Explore: 'Snoop',
195  Plan: 'Schemer',
196  'general-purpose': 'Handyman',
197  'claude-code-guide': 'Granny',
198  'statusline-setup': 'Plumber',
199}
200const ROLE_HINTS: [RegExp, string][] = [
201  [/review|critic|audit|lint/i, 'Inspector'],
202  [/test|qa\b|verif/i, 'Taster'],
203  [/secur|vuln|pentest/i, 'Watchdog'],
204  [/fix|debug|bug|repair/i, 'Mouser'],
205  [/(^|[-_ ])ui([-_ ]|$)|front|design|css|style/i, 'Decorator'],
206  [/setup|config|deploy|infra|ops|install|(^|[-_ ])ci([-_ ]|$)/i, 'Plumber'],
207  [/data|sql|(^|[-_ ])db([-_ ]|$)|analy|metric/i, 'Bookkeeper'],
208  [/doc|write|readme|scribe|copy/i, 'Scribbler'],
209  [/guide|help|question|oracle/i, 'Granny'],
210  [/explor|research|search|find|scout/i, 'Snoop'],
211  [/plan|architect|strateg/i, 'Schemer'],
212  [/build|implement|code|dev|engineer/i, 'Tinkerer'],
213]
214const WORK_ROLES: Record<string, string> = { shell: 'Cook', monitor: 'Neighbor', workflow: 'Butler' }
215
216export function displayName(c: Resident): string {
217  if (c.kind === 'main') return 'Host'
218  const type = taskTypeOf(c)
219  if (type !== 'subagent') return own(WORK_ROLES, type) ?? 'Gremlin'
220  return own(BUILT_IN_ROLES, c.kind) ?? ROLE_HINTS.find(([hint]) => hint.test(c.kind))?.[1] ?? 'Handyman'
221}
222
223export function bodyColor(c: Resident): string {
224  return c.status === 'done' ? '#7a7a88' : CLAUDE_ORANGE
225}
226
227// ---------- glyph chatter ----------
228
229// A made-up script: each glyph is a few strokes on a 3x4 grid.
230const GLYPHS: number[][][] = [
231  [[0, 0, 3, 0], [1.5, 0, 1.5, 4]],
232  [[0, 4, 1.5, 0], [1.5, 0, 3, 4]],
233  [[0, 0, 0, 4], [0, 4, 3, 4]],
234  [[0, 2, 3, 2], [3, 0, 3, 4]],
235  [[0, 0, 3, 4], [0, 4, 3, 0]],
236  [[0, 1, 3, 1], [0, 3, 3, 3]],
237  [[1.5, 0, 1.5, 4], [0, 2, 1.5, 0]],
238  [[0, 0, 3, 0], [3, 0, 0, 4], [0, 4, 3, 4]],
239  [[0, 4, 0, 1], [0, 1, 3, 1], [3, 1, 3, 4]],
240  [[0, 0, 3, 2], [3, 2, 0, 4]],
241  [[1.5, 0, 1.5, 1], [1.5, 3, 1.5, 4], [0, 2, 3, 2]],
242  [[0, 0, 0, 4], [3, 0, 3, 4], [0, 0, 3, 0]],
243  [[0, 4, 3, 0], [0, 1, 1, 0]],
244  [[0, 0, 1.5, 2], [3, 0, 1.5, 2], [1.5, 2, 1.5, 4]],
245]
246
247// Each agent favours its own handful of glyphs, so it has an accent.
248function accentOf(seed: number): number[] {
249  const r = rng(seed ^ 0xa5a5a5a5)
250  const mine = GLYPHS.map((_, i) => i).filter(() => r() < 0.5)
251  return mine.length >= 4 ? mine : [0, 3, 6, 9]
252}
253
254function chatterLine(seed: number, accent: number[]): number[][] {
255  const r = rng(seed)
256  return Array.from({ length: r() < 0.55 ? 1 : 2 }, () =>
257    Array.from({ length: r() < 0.5 ? 2 : 3 }, () => accent[Math.floor(r() * accent.length)] ?? 0))
258}
259
260function runeBubble(phrase: number[][]): string {
261  let x = 0
262  let d = ''
263  for (const word of phrase) {
264    for (const glyph of word) {
265      for (const [x1, y1, x2, y2] of GLYPHS[glyph] ?? []) d += `M${r1(x + (x1 ?? 0))} ${y1 ?? 0}L${r1(x + (x2 ?? 0))} ${y2 ?? 0}`
266      x += 4.2
267    }
268    x += 2
269  }
270  const w = Math.max(6, x - 1.6)
271  return `<g transform="translate(${r1(-w / 2)} -24)"><rect x="-2" y="-2" width="${r1(w + 4)}" height="8" fill="#f4f1e8"/><rect x="${r1(w / 2 - 1)}" y="6" width="2" height="2" fill="#f4f1e8"/>` +
272    `<path d="${d}" stroke="#2b261f" stroke-width="0.7" fill="none" stroke-linecap="square"/></g>`
273}
274
275// Speech bubble text width; every line an agent says fits.
276const BUBBLE = 56
277
278function textBubble(text: string, color = '#1a1a1a'): string {
279  const fill = '#f4f1e8'
280  const t = fitText(text, BUBBLE, PX)
281  const w = r1(textWidth(t, PX) + 4)
282  return `<g transform="translate(${r1(-w / 2)} -25)"><rect x="0" y="0" width="${w}" height="6" fill="${fill}"/><rect x="${r1(w / 2 - 1)}" y="6" width="2" height="2" fill="${fill}"/>` +
283    `${agentText(t, 2, 1.2, { color })}</g>`
284}
285
286// ---------- layout and routes ----------
287
288// One layout for every picture: the house amid 440 units of garden above and below; a pane shows what fits.
289export const LAYOUT = stretch(house(), 440)
290
291const heading = (dx: number, dy: number): Dir => (Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'E' : 'W') : (dy > 0 ? 'S' : 'N'))
292const BACK = { E: 'W', W: 'E', N: 'S', S: 'N' } as const
293
294function cellAt(L: Layout, col: number, row: number): Cell {
295  return L.rooms[L.grid[row]?.[col] ?? 'lounge']
296}
297
298// Through the doorways, row then column, the yard by the front door; `lane` lets two walkers pass abreast.
299function route(L: Layout, fromPlace: Place, from: Pt, toPlace: Place, to: Pt, lane = 0): Pt[] {
300  if (fromPlace === 'yard' || toPlace === 'yard') {
301    if (fromPlace === toPlace) return [from, to]
302    const d = frontDoor(L)
303    const inside = { x: d.mid + lane, y: L.ph - T - 10 }
304    const outside = { x: d.mid + lane, y: L.ph + 6 }
305    return toPlace === 'yard'
306      ? [...route(L, fromPlace, from, 'workshop', inside, lane), outside, to]
307      : [from, outside, ...route(L, 'workshop', inside, toPlace, to, lane)]
308  }
309  const pts: Pt[] = [from]
310  let { col, row } = L.rooms[fromPlace]
311  const target = L.rooms[toPlace]
312  while (col !== target.col) {
313    const step = target.col > col ? 1 : -1
314    const cur = cellAt(L, col, row)
315    const left = step > 0 ? cur : cellAt(L, col - 1, row)
316    const edge = left.x + left.w
317    const y = doorY(cur) + lane
318    pts.push({ x: step > 0 ? edge - 12 : edge + T + 12, y }, { x: step > 0 ? edge + T + 12 : edge - 12, y })
319    col += step
320  }
321  while (row !== target.row) {
322    const step = target.row > row ? 1 : -1
323    const cur = cellAt(L, col, row)
324    const top = step > 0 ? cur : cellAt(L, col, row - 1)
325    const edge = top.y + top.h
326    const x = doorX(cur) + lane
327    pts.push({ x, y: step > 0 ? edge - 12 : edge + T + 12 }, { x, y: step > 0 ? edge + T + 12 : edge - 12 })
328    row += step
329  }
330  pts.push(to)
331  return pts
332}
333
334const laneOf = (id: string) => (hash('lane:' + id) % 5) - 2
335
336// A doorway's gap counts as the place the walker was in.
337function roomAt(L: Layout, p: Pt, fallback: Place): Place {
338  if (p.y > L.ph) return 'yard'
339  for (const room of ALL_ROOMS) {
340    const c = L.rooms[room]
341    if (p.x >= c.x && p.x <= c.x + c.w && p.y >= c.y && p.y <= c.y + c.h) return room
342  }
343  return fallback
344}
345
346// A room half a body clear of its walls, or the road out front.
347function floorOf(L: Layout, place: Place): { x0: number; x1: number; y0: number; y1: number } {
348  if (place === 'yard') return yardOf(L).floor
349  const c = L.rooms[place]
350  return { x0: c.x + 4, x1: c.x + c.w - 4, y0: c.y + 4, y1: floorBottom(c) }
351}
352
353function onFloor(L: Layout, place: Place, p: Pt): Pt {
354  const f = floorOf(L, place)
355  return { x: Math.max(f.x0, Math.min(f.x1, p.x)), y: Math.max(f.y0, Math.min(f.y1, p.y)) }
356}
357
358function floorSpot(L: Layout, place: Place, r: () => number): Pt {
359  const f = place === 'yard' ? yardOf(L).floor : freeFloor(place, L.rooms[place])
360  return { x: Math.round(between(r, f.x0, f.x1)), y: Math.round(between(r, f.y0, f.y1)) }
361}
362
363// Two desks to a work room; extra workers stand about.
364const DESKS = 2
365
366function workSpot(c: Cell, i: number): Pt {
367  if (i < DESKS) return { x: deskAt(c, i).x + 6, y: c.y + 11 }
368  // past the desks, a floor grid; a bigger crowd fills its gaps, then the gaps' gaps
369  const k = i - DESKS
370  const rows = Math.max(1, Math.floor((floorBottom(c) - (c.y + 19)) / 6) + 1)
371  const pass = Math.floor(k / (4 * rows))
372  return { x: c.x + 10 + (k % 4) * 7 + (pass % 2) * 3.5, y: Math.min(c.y + 19 + (Math.floor(k / 4) % rows) * 6 + (pass % 3) * 2, floorBottom(c)) }
373}
374
375// ---------- props agents use ----------
376
377type Pose = 'stand' | 'sit' | 'sleep'
378type Drive = 'rest' | 'stim'
379// `absorbed`: holds attention, so no glancing about
380type Prop = { id: string; room: Place; spot: Pt; face: Dir; dwell: Span; pose: Pose; drive: Drive; lines: string[]; absorbed?: boolean }
381
382// What there is to do, a spot beside each piece of furniture.
383function furnish(L: Layout): Prop[] {
384  const props: Prop[] = []
385  const r = L.rooms
386  const add = (room: Room, id: string, x: number, y: number, face: Dir, pose: Pose, drive: Drive, dwell: Span, lines: string[], absorbed = false) =>
387    props.push({ id, room, spot: { x: r[room].x + x, y: r[room].y + y }, face, dwell, pose, drive, lines, absorbed })
388  add('lounge', 'bed1', 8, 5.5, 'S', 'sleep', 'rest', [24, 40], LINES_SLEEP)
389  add('lounge', 'bed2', 8, 16.5, 'S', 'sleep', 'rest', [24, 40], LINES_SLEEP)
390  add('lounge', 'couch1', 21, 15, 'N', 'sit', 'rest', [15, 32], [...LINES_UNWIND, ...LINES_TV])
391  add('lounge', 'couch2', 26, 15, 'N', 'sit', 'stim', [16, 30], LINES_BORED, true)
392  add('study', 'fire', 34, 10, 'N', 'stand', 'rest', [6, 12], LINES_FIRE)
393  add('study', 'armchair', 6, 19.5, 'E', 'sit', 'rest', [10, 24], LINES_READ, true)
394  add('kitchen', 'fridge', 11, 11.5, 'W', 'stand', 'rest', [4, 8], LINES_SNACK)
395  add('kitchen', 'coffee', 11, 24, 'W', 'stand', 'rest', [5, 10], LINES_COFFEE)
396  add('library', 'shelf', 10, 17, 'W', 'stand', 'stim', [12, 22], LINES_BROWSE, true)
397  add('sunroom', 'telescope', 6, 19, 'N', 'stand', 'stim', [8, 16], LINES_STARS, true)
398  add('sunroom', 'radio', 10, 22.5, 'W', 'stand', 'stim', [5, 10], LINES_RADIO)
399  add('workshop', 'bench', 12, 16, 'W', 'stand', 'stim', [8, 14], LINES_TINKER)
400  const m = yardOf(L).mailbox
401  props.push({ id: 'mailbox', room: 'yard', spot: { x: m.x + 3.5, y: m.y + 1 }, face: 'W', dwell: [4, 8], pose: 'stand', drive: 'stim', lines: LINES_MAIL })
402  return props
403}
404const PROPS = furnish(LAYOUT)
405
406// ---------- temperament and moods ----------
407
408function temperament(id: string) {
409  const r = rng(hash('temper:' + id))
410  return {
411    pace: between(r, 0.85, 1.2),
412    restless: between(r, 0.5, 1.4),
413    curious: between(r, 0.4, 1.2),
414    homebody: between(r, 0.4, 1.2),
415    chatty: between(r, 0.5, 1.3),
416  }
417}
418
419// Moods take turns every few minutes, each agent on its own clock.
420const MOODS = [
421  { tag: 'busy', rest: 0.7, stim: 1.0, restless: 1.3 },
422  { tag: 'wander', rest: 0.75, stim: 1.5, restless: 1.1 },
423  { tag: 'unwind', rest: 1.5, stim: 0.8, restless: 0.75 },
424  { tag: 'doze', rest: 1.3, stim: 0.6, restless: 0.5 },
425]
426const MOOD_MS = 200000
427
428// A mood holds for a whole idle stretch, so a redraw never swaps the plan.
429function moodOf(id: string, idleSince: number) {
430  const index = (hash(id) + Math.floor(idleSince / MOOD_MS)) % MOODS.length
431  return { index, ...(MOODS[index] ?? MOODS[0]!) }
432}
433
434// ---------- planning an idle loop ----------
435
436const mod = (n: number) => ((n % LOOP) + LOOP) % LOOP
437
438type Key = { t: number; p: Pt; room: Place }
439
440function posAt(keys: Key[], t: number): Key {
441  let prev = keys[0] as Key
442  for (const k of keys) {
443    if (k.t >= t) {
444      const span = k.t - prev.t
445      const u = span > 0 ? (t - prev.t) / span : 1
446      return { t, p: { x: prev.p.x + (k.p.x - prev.p.x) * u, y: prev.p.y + (k.p.y - prev.p.y) * u }, room: u >= 1 ? k.room : prev.room }
447    }
448    prev = k
449  }
450  return prev
451}
452type Say = { t0: number; t1: number; text: string }
453// A meeting; `talk` is glyph chatter.
454type Appointment = {
455  t0: number; t1: number; room: Room; spot: Pt; face: Dir
456  talk: Span[]; hop: Span[]; spark: Span[]; says: Say[]; spins: { t: number; dir: Dir }[]
457}
458
459class Plan {
460  keys: Key[]
461  walk: Span[] = []
462  sit: Span[] = []
463  sleep: Span[] = []
464  faces: { t: number; dir: Dir }[] = [{ t: 0, dir: 'S' }]
465  says: Say[] = []
466  talk: Span[] = []
467  // goofing about
468  hop: Span[] = []
469  wave: Span[] = []
470  juggle: Span[] = []
471  spark: Span[] = []
472  t = 0
473  room: Place
474  at: Pt
475  speed: number
476  private L: Layout
477  private r: () => number
478  private lane: number
479  constructor(room: Place, at: Pt, speed: number, L: Layout, r: () => number, lane = 0) {
480    this.room = room
481    this.at = at
482    this.speed = speed
483    this.L = L
484    this.r = r
485    this.lane = lane
486    this.keys = [{ t: 0, p: at, room }]
487  }
488
489  face(dir: Dir, t = this.t): void {
490    this.faces.push({ t, dir })
491  }
492
493  hold(seconds: number): void {
494    if (seconds <= 0) return
495    this.t += seconds
496    this.keys.push({ t: this.t, p: this.at, room: this.room })
497  }
498
499  // `backwards`: a moonwalk
500  walkTo(room: Place, to: Pt, backwards = false): void {
501    this.walkAlong(room, route(this.L, this.room, this.at, room, to, this.lane), this.at, to, backwards, true)
502  }
503
504  // `stops`: maybe one short pause on the way
505  private walkAlong(room: Place, pts: Pt[], last: Pt, to: Pt, backwards: boolean, stops: boolean): void {
506    const start = this.t
507    const end = pts[pts.length - 1]
508    for (const p of pts.slice(1)) {
509      const dx = p.x - last.x
510      const dy = p.y - last.y
511      const d = Math.hypot(dx, dy)
512      if (d < 0.5) continue
513      const dir = heading(dx, dy)
514      this.face(backwards ? BACK[dir] : dir)
515      this.t += d / this.speed
516      this.keys.push({ t: this.t, p, room: p === end ? room : roomAt(this.L, p, this.room) })
517      last = p
518      if (stops && p !== end && this.r() < 0.06) {
519        this.walk.push([start, this.t])
520        this.room = roomAt(this.L, p, this.room)
521        this.at = p
522        this.hold(between(this.r, 0.3, 0.8))
523        const rest = pts.slice(pts.indexOf(p))
524        return this.walkAlong(room, rest, p, to, false, false)
525      }
526    }
527    this.walk.push([start, this.t])
528    this.room = room
529    this.at = to
530  }
531
532  travel(room: Place, to: Pt): number {
533    const pts = route(this.L, this.room, this.at, room, to, this.lane)
534    let d = 0
535    for (let i = 1; i < pts.length; i++) d += Math.hypot((pts[i] as Pt).x - (pts[i - 1] as Pt).x, (pts[i] as Pt).y - (pts[i - 1] as Pt).y)
536    return d / this.speed + 0.8
537  }
538
539  // squeeze a routine that ran long back into the loop
540  fit(total: number): void {
541    if (this.t <= total) return
542    const f = total / this.t
543    const s = (span: Span): Span => [span[0] * f, span[1] * f]
544    this.keys = this.keys.map(k => ({ ...k, t: k.t * f }))
545    this.walk = this.walk.map(s)
546    this.sit = this.sit.map(s)
547    this.sleep = this.sleep.map(s)
548    this.talk = this.talk.map(s)
549    this.hop = this.hop.map(s)
550    this.wave = this.wave.map(s)
551    this.juggle = this.juggle.map(s)
552    this.spark = this.spark.map(s)
553    this.faces = this.faces.map(x => ({ t: x.t * f, dir: x.dir }))
554    this.says = this.says.map(x => ({ t0: x.t0 * f, t1: x.t1 * f, text: x.text }))
555    this.t = total
556  }
557
558  say(text: string, seconds = 4.2): void {
559    this.says.push({ t0: this.t, t1: this.t + seconds, text })
560  }
561
562  // a look to one side and back
563  glance(): void {
564    const back = this.faces[this.faces.length - 1]?.dir ?? 'S'
565    this.face(this.r() < 0.5 ? 'W' : 'E')
566    this.hold(between(this.r, 0.5, 1.2))
567    this.face(back)
568  }
569}
570
571// Props are booked on the house's clock so no two agents share one; `offset` maps own time to it.
572type Bookings = Map<string, Span[]>
573
574function book(bookings: Bookings, id: string, span: Span, offset: number): boolean {
575  const a = mod(span[0] + offset)
576  const len = Math.min(LOOP, span[1] - span[0])
577  const pieces: Span[] = a + len <= LOOP ? [[a, a + len]] : [[a, LOOP], [0, a + len - LOOP]]
578  const list = bookings.get(id) ?? []
579  if (pieces.some(([x, y]) => list.some(([p, q]) => x < q && p < y))) return false
580  list.push(...pieces)
581  bookings.set(id, list)
582  return true
583}
584
585type IdleContext = {
586  bookings: Bookings
587  offset: number
588  // idle agents so far: the more, the less each talks
589  crowd: number
590  // desks the lead may look in on
591  watch: { room: Room; spot: Pt }[]
592  // where workers stand: an idle agent starting on one steps off it
593  busy: Pt[]
594}
595
596const near = (a: Pt, b: Pt, d = 6) => Math.hypot(a.x - b.x, a.y - b.y) < d
597
598// ---------- goofing about ----------
599
600// Bits of silliness an idle agent gets up to, by mood.
601type Antic = 'hop' | 'spin' | 'moonwalk' | 'wave' | 'juggle' | 'sneeze' | 'stumble' | 'doze' | 'yawn'
602const ANTICS: Record<string, Antic[]> = {
603  busy: ['juggle', 'hop', 'spin', 'wave', 'sneeze', 'stumble'],
604  wander: ['moonwalk', 'spin', 'wave', 'hop', 'stumble', 'juggle'],
605  unwind: ['yawn', 'wave', 'hop', 'sneeze', 'doze', 'moonwalk'],
606  doze: ['doze', 'yawn', 'stumble', 'sneeze'],
607}
608const SPIN_DIRS: Dir[] = ['S', 'E', 'N', 'W']
609
610// One antic on the spot, if it fits before `deadline` with time to get `back`.
611function goof(plan: Plan, L: Layout, kind: Antic, r: () => number, chatty: number, deadline: number, back: { room: Place; spot: Pt }): boolean {
612  const left = deadline - plan.travel(back.room, back.spot)
613  const fitsIn = (seconds: number) => plan.t + seconds <= left
614  const maybeSay = (lines: string[], chance = 0.7, seconds = 2.4) => {
615    if (r() < chance * chatty) plan.say(pick(lines, r), seconds)
616  }
617  switch (kind) {
618    case 'hop': {
619      const dur = between(r, 1.6, 2.6)
620      if (!fitsIn(dur)) return false
621      plan.face('S')
622      maybeSay(LINES_HOP)
623      plan.hop.push([plan.t, plan.t + dur])
624      plan.hold(dur)
625      return true
626    }
627    case 'spin': {
628      const dur = 1.8
629      if (!fitsIn(dur + 0.4)) return false
630      maybeSay(LINES_SPIN)
631      for (let k = 0; k < 12; k++) plan.face(SPIN_DIRS[k % 4] as Dir, plan.t + k * 0.15)
632      plan.hold(dur)
633      plan.face('S')
634      plan.hold(0.4)
635      return true
636    }
637    case 'moonwalk': {
638      if (plan.room === 'yard' || roomAt(L, plan.at, 'yard') !== plan.room) return false
639      const room = L.rooms[plan.room]
640      const dx = (r() < 0.5 ? -1 : 1) * between(r, 12, 20)
641      const spot = { x: Math.max(room.x + 8, Math.min(room.x + room.w - 10, plan.at.x + dx)), y: plan.at.y }
642      if (Math.abs(spot.x - plan.at.x) < 6) return false
643      const there = plan.travel(plan.room, spot) * 1.7
644      if (!fitsIn(there + 0.8)) return false
645      maybeSay(LINES_MOON, 0.6, 2.8)
646      const speed = plan.speed
647      plan.speed = speed * 0.6
648      plan.walkTo(plan.room, spot, true)
649      plan.speed = speed
650      plan.hold(0.8)
651      return true
652    }
653    case 'wave': {
654      const dur = 2.2
655      if (!fitsIn(dur)) return false
656      plan.face('S')
657      maybeSay(LINES_WAVE, 0.9)
658      plan.wave.push([plan.t, plan.t + dur])
659      plan.hold(dur)
660      return true
661    }
662    case 'juggle': {
663      const dur = between(r, 3, 5)
664      if (!fitsIn(dur)) return false
665      plan.face('S')
666      maybeSay(LINES_JUGGLE, 0.5)
667      plan.juggle.push([plan.t, plan.t + dur])
668      plan.hold(dur)
669      return true
670    }
671    case 'sneeze': {
672      if (!fitsIn(1.8)) return false
673      plan.say(pick(LINES_SNEEZE, r), 1.6)
674      plan.hop.push([plan.t + 0.3, plan.t + 0.5])
675      plan.hold(1.8)
676      return true
677    }
678    case 'stumble': {
679      if (!fitsIn(2)) return false
680      plan.say(pick(LINES_STUMBLE, r), 1.8)
681      plan.sit.push([plan.t, plan.t + 0.7])
682      plan.hold(2)
683      return true
684    }
685    case 'doze': {
686      const dur = between(r, 4, 7)
687      if (!fitsIn(dur)) return false
688      plan.face('S')
689      plan.sleep.push([plan.t, plan.t + dur])
690      plan.hold(dur)
691      return true
692    }
693    case 'yawn': {
694      if (!fitsIn(2.6)) return false
695      plan.face('S')
696      plan.say(pick(LINES_YAWN, r), 2.2)
697      plan.hold(2.6)
698      return true
699    }
700  }
701}
702
703// Depends only on the agent, the house, its huddle and the agents idle before it.
704function planIdle(
705  L: Layout, props: Prop[], c: Resident, home: { room: Place; at: Pt }, appts: Appointment[], ctx: IdleContext,
706): Plan {
707  const p = temperament(c.id)
708  const ph = moodOf(c.id, c.idleSince ?? 0)
709  const r = rng(hash(`${c.id}:${ph.index}`))
710  const plan = new Plan(home.room, home.at, speedOf(c), L, r, laneOf(c.id))
711  const talkChance = 0.35 / Math.sqrt(Math.max(1, ctx.crowd))
712
713  // starting on a busy desk: step off, and come back only as the loop ends
714  const onBusy = ctx.busy.some(b => near(b, home.at))
715  const base = onBusy ? { room: home.room, at: onFloor(L, home.room, { x: home.at.x, y: home.at.y + 9 }) } : home
716  if (onBusy) plan.walkTo(base.room, base.at)
717  plan.hold(between(r, 0.4, 1.6))
718  const queue = [...appts].sort((a, b) => a.t0 - b.t0)
719  const endBy = LOOP - 0.5 - (onBusy ? plan.travel(home.room, home.at) : 0)
720
721  // whether going, staying and going on to `back` all end by the deadline
722  const fits = (room: Place, spot: Pt, dwell: number, deadline: number, back: { room: Place; spot: Pt }, go = plan.travel(room, spot)) => {
723    const saved = { room: plan.room, at: plan.at }
724    plan.room = room
725    plan.at = spot
726    const ret = plan.travel(back.room, back.spot)
727    plan.room = saved.room
728    plan.at = saved.at
729    return plan.t + go + dwell + ret <= deadline
730  }
731
732  let guard = 0
733  while (guard++ < 40) {
734    const next = queue[0]
735    const deadline = next ? next.t0 : endBy
736    const back = next ? { room: next.room, spot: next.spot } : { room: base.room, spot: base.at }
737
738    // what it feels like: rest, something to do, or nothing
739    const wRest = 38 * (0.6 + 0.7 * p.homebody) * ph.rest
740    const wStim = (36 * (0.6 + 0.7 * p.curious) + 14 * p.restless) * ph.stim
741    const wContent = 30
742    const roll = r() * (wRest + wStim + wContent)
743    const drive: Drive | 'content' = roll < wRest ? 'rest' : roll < wRest + wStim ? 'stim' : 'content'
744
745    let done = false
746    // the lead looks in on whoever is working, a few seconds each
747    if (ctx.watch.length > 0 && r() < 0.45) {
748      const w = pick(ctx.watch, r)
749      const spot = onFloor(L, w.room, { x: w.spot.x + (r() < 0.5 ? -7 : 7), y: w.spot.y + 11 })
750      const dwell = between(r, 3, 6)
751      const go = plan.travel(w.room, spot)
752      if (fits(w.room, spot, dwell, deadline, back, go) && book(ctx.bookings, `watch:${w.room}:${w.spot.x}`, [plan.t + go, plan.t + go + dwell], ctx.offset)) {
753        plan.walkTo(w.room, spot)
754        plan.face('N')
755        if (r() < talkChance) plan.say(pick(LINES_LOOK, r))
756        plan.hold(dwell)
757        done = true
758      }
759    }
760    if (!done && drive !== 'content') {
761      const options = props.filter(o => o.drive === drive && (o.pose !== 'sleep' || ph.tag === 'doze' || ph.tag === 'unwind'))
762      if (options.length > 0) {
763        const o = pick(options, r)
764        const dwell = between(r, o.dwell[0], o.dwell[1])
765        const go = plan.travel(o.room, o.spot)
766        if (fits(o.room, o.spot, dwell, deadline, back, go) && book(ctx.bookings, o.id, [plan.t + go - 0.5, plan.t + go + dwell + 0.5], ctx.offset)) {
767          plan.walkTo(o.room, o.spot)
768          plan.face(o.face)
769          const t0 = plan.t
770          if (r() < talkChance) plan.say(pick(o.lines, r))
771          // glancing about now and then, unless absorbed
772          let left = dwell
773          while (left > 0) {
774            const chunk = Math.min(left, between(r, 3.5, 7.5))
775            plan.hold(chunk)
776            left -= chunk
777            if (left > 1.5 && o.pose === 'stand' && !o.absorbed && r() < 0.4) {
778              const t = plan.t
779              plan.glance()
780              plan.face(o.face)
781              left -= plan.t - t
782            }
783          }
784          if (o.pose === 'sit') plan.sit.push([t0, plan.t])
785          if (o.pose === 'sleep') {
786            plan.sleep.push([t0, plan.t])
787            plan.face('S', t0)
788          }
789          done = true
790        }
791      }
792      if (!done && drive === 'stim' && r() < 0.6) {
793        // explore: a stroll to another room
794        const room = pick(ALL_ROOMS, r)
795        const spot = floorSpot(L, room, r)
796        const dwell = between(r, 1.5, 4)
797        if (fits(room, spot, dwell + 1.2, deadline, back)) {
798          plan.walkTo(room, spot)
799          plan.hold(dwell)
800          plan.glance()
801          done = true
802        }
803      }
804      if (!done && drive === 'rest' && c.kind === 'main' && r() < 0.5) {
805        // a step to whichever side has room
806        const f = floorOf(L, base.room)
807        const spot = onFloor(L, base.room, { x: base.at.x + (base.at.x + 6 > f.x1 ? -6 : 6), y: base.at.y })
808        const dwell = between(r, 3, 7)
809        if (fits(base.room, spot, dwell, deadline, back)) {
810          plan.walkTo(base.room, spot)
811          plan.face('N')
812          if (r() < talkChance) plan.say(pick(LINES_STANDBY, r))
813          plan.hold(dwell)
814          done = true
815        }
816      }
817    }
818    if (!done && r() < 0.3 * p.restless * ph.restless) {
819      done = goof(plan, L, pick(ANTICS[ph.tag] ?? ANTICS.busy ?? ['hop'], r), r, p.chatty, deadline, back)
820    }
821    if (!done) {
822      // content: stand, look round or stroll; lengths drawn first so no deadline is overrun
823      const x = r() * 100
824      const left = deadline - plan.travel(back.room, back.spot)
825      const stand = between(r, 4, 10)
826      const settle = between(r, 2, 4.5)
827      if (x < 55 && plan.t + stand <= left) {
828        plan.hold(stand)
829        done = true
830      } else if (x < 80 && plan.t + 1.2 + settle <= left) {
831        plan.glance()
832        plan.hold(settle)
833        done = true
834      } else {
835        const spot = floorSpot(L, plan.room, r)
836        const pause = between(r, 1.5, 4)
837        if (fits(plan.room, spot, pause, deadline, back)) {
838          plan.walkTo(plan.room, spot)
839          plan.hold(pause)
840          done = true
841        }
842      }
843    }
844    if (done) continue
845
846    // nothing fits: keep the next appointment, unless it cannot be reached in time
847    if (!next) break
848    queue.shift()
849    const go = plan.travel(next.room, next.spot)
850    if (plan.t + go > next.t0 + 1) continue
851    plan.hold(Math.max(0, next.t0 - plan.t - go))
852    plan.walkTo(next.room, next.spot)
853    plan.hold(Math.max(0, next.t0 - plan.t))
854    plan.face(next.face)
855    plan.talk.push(...next.talk)
856    plan.hop.push(...next.hop)
857    plan.spark.push(...next.spark)
858    plan.says.push(...next.says)
859    for (const f of next.spins) plan.face(f.dir, f.t)
860    plan.hold(next.t1 - plan.t)
861  }
862  plan.walkTo(base.room, base.at)
863  plan.face('S')
864  if (onBusy) {
865    // arrive back on the starting spot exactly as the loop comes round
866    plan.hold(LOOP - plan.t - plan.travel(home.room, home.at) + 0.8)
867    plan.walkTo(home.room, home.at)
868  }
869  plan.hold(LOOP - plan.t)
870  plan.fit(LOOP)
871  return plan
872}
873
874// Huddles: two or three idle agents chatter in glyphs, high-five, dance or play rock, paper, scissors.
875// An agent's id picks its meeting time, so its plan changes only with its meeting group (huddleKey).
876const MEETINGS = [14, 40, 64]
877
878const slotOf = (c: Resident) => hash('meet:' + c.id) % MEETINGS.length
879const joins = (c: Resident, slot: number) => rng(hash(`joins:${slot}:${c.id}`))() < 0.45 * temperament(c.id).chatty + 0.3
880
881// The meeting group with each loop start, since who can come depends on it.
882function huddleKey(L: Layout, c: Resident, idle: Resident[]): string {
883  const slot = slotOf(c)
884  return idle.filter(o => slotOf(o) === slot).map(o => `${o.id}@${t0Of(o, L)}`).sort().join('|')
885}
886
887function huddles(L: Layout, idle: Resident[], offsetOf: (c: Resident) => number): Map<string, Appointment[]> {
888  const out = new Map<string, Appointment[]>()
889  MEETINGS.forEach((g0, slot) => {
890    const members = idle.filter(c => slotOf(c) === slot).sort((a, b) => (a.id < b.id ? -1 : 1))
891    const r = rng(hash(`huddle:${slot}:${members.map(m => m.id).join('|')}`))
892    const kind = pick(['chat', 'chat', 'highfive', 'dance', 'rps'] as const, r)
893    const hold = kind === 'chat' ? between(r, 8, 14) : kind === 'highfive' ? between(r, 4, 5) : kind === 'dance' ? between(r, 7, 10) : between(r, 6.5, 7.5)
894    // an agent whose loop would end mid-meeting sits out; under two, the meeting is off
895    const comes = (m: Resident) => {
896      const t0 = mod(g0 - offsetOf(m))
897      return t0 >= 6 && t0 + hold <= LOOP - 18
898    }
899    const party = members.filter(m => joins(m, slot) && comes(m)).slice(0, 3)
900    if (party.length < 2) return
901    const throws = party.map(() => pick(THROWS, r))
902    const winner = BEATS[throws[0] ?? ''] === throws[1] ? 0 : BEATS[throws[1] ?? ''] === throws[0] ? 1 : -1
903    const room = pick(['lounge', 'study', 'lounge', 'sunroom', 'library'] as Room[], r)
904    const f = freeFloor(room, L.rooms[room])
905    const center = { x: Math.round(between(r, f.x0 + 9, f.x1 - 9)), y: Math.round(between(r, f.y0 + 1, f.y1 - 5)) }
906    const offsets = party.length === 2 ? [-7, 7] : [-9, 0, 9]
907    party.forEach((m, idx) => {
908      const t0 = mod(g0 - offsetOf(m))
909      const spot = { x: center.x + (offsets[idx] ?? 0), y: center.y + (party.length === 3 && idx === 1 ? 5 : 0) }
910      const face: Dir = party.length === 3 && idx === 1 ? 'N' : (offsets[idx] ?? 0) < 0 ? 'E' : 'W'
911      const mine = rng(hash(`${kind}:${slot}:${m.id}`))
912      const appt: Appointment = { t0, t1: t0 + hold, room, spot, face, talk: [], hop: [], spark: [], says: [], spins: [] }
913      if (kind === 'chat') {
914        for (let k = 0; k * 2 < hold; k++) {
915          if (k % party.length === idx) appt.talk.push([t0 + k * 2, t0 + k * 2 + 1.5])
916        }
917      } else if (kind === 'highfive') {
918        appt.hop.push([t0 + 1, t0 + 1.36])
919        if (idx === 0) appt.spark.push([t0 + 1.1, t0 + 1.8])
920        appt.says.push({ t0: t0 + 1.4 + idx * 0.3, t1: t0 + 3.4 + idx * 0.3, text: pick(LINES_HIGHFIVE, mine) })
921      } else if (kind === 'dance') {
922        appt.hop.push([t0 + 0.6 + idx * 0.18, t0 + hold - 0.6])
923        if (idx % 2 === 1) {
924          for (let k = 0; t0 + 1.5 + k * 0.3 < t0 + hold - 1; k++) appt.spins.push({ t: t0 + 1.5 + k * 0.3, dir: SPIN_DIRS[k % 4] as Dir })
925          appt.spins.push({ t: t0 + hold - 1, dir: face })
926        }
927        appt.says.push({ t0: t0 + 0.8 + idx * 1.6, t1: t0 + 2.8 + idx * 1.6, text: pick(LINES_DANCE, mine) })
928      } else {
929        if (idx === 0) appt.says.push({ t0: t0 + 0.3, t1: t0 + 2, text: 'rock, paper...' })
930        appt.says.push({ t0: t0 + 2.2, t1: t0 + 4, text: idx < 2 ? throws[idx] ?? 'rock!' : 'go go go' })
931        const result = winner === -1 ? 'again!' : idx === winner ? pick(LINES_WIN, mine) : idx < 2 ? pick(LINES_LOSE, mine) : 'ooh'
932        appt.says.push({ t0: t0 + 4.3, t1: t0 + 6.2, text: result })
933        if (idx === winner) appt.hop.push([t0 + 4.3, t0 + 5])
934      }
935      out.set(m.id, [...(out.get(m.id) ?? []), appt])
936    })
937  })
938  return out
939}
940
941// A spot of its own clear of the furniture, so a crowd that went idle together does not stack up.
942function idleHome(L: Layout, c: Resident): { room: Room; at: Pt } {
943  const f = freeFloor(c.room, L.rooms[c.room])
944  const h = hash('home:' + c.id)
945  return { room: c.room, at: { x: f.x0 + (h % Math.max(1, Math.floor(f.x1 - f.x0))), y: f.y0 + ((h >>> 5) % Math.max(1, Math.floor(f.y1 - f.y0))) } }
946}
947
948// ---------- drawing an agent ----------
949
950type AgentDraw = {
951  at: Pt
952  // a box on its head, out to the pallets
953  carry?: Span[]
954  motion: string
955  total: number
956  begin: number
957  isLoop: boolean
958  walk: Span[]
959  sit: Span[]
960  sleep: Span[]
961  faces: { t: number; dir: Dir }[]
962  typing?: boolean
963  bubbles: string
964  hop?: Span[]
965  wave?: Span[]
966  juggle?: Span[]
967  spark?: Span[]
968  // drops the name a line, so neighbours at desks do not overprint
969  nameDy?: number
970}
971
972// A face at `total` gets no span, so a finished walk keeps its last step's pose for the few seconds until
973// the hooks end the errand. Holding the end longer shifts the next plans' timelines, and they jump.
974function faceSpans(faces: { t: number; dir: Dir }[], total: number): Record<Dir, Span[]> {
975  const out: Record<Dir, Span[]> = { N: [], S: [], E: [], W: [] }
976  const sorted = [...faces].sort((a, b) => a.t - b.t)
977  sorted.forEach((f, i) => {
978    const end = sorted[i + 1]?.t ?? total
979    if (end > f.t) out[f.dir].push([f.t, end])
980  })
981  return out
982}
983
984function invert(spans: Span[], total: number): Span[] {
985  const sorted = [...spans].sort((a, b) => a[0] - b[0])
986  const out: Span[] = []
987  let t = 0
988  for (const [a, b] of sorted) {
989    if (a > t) out.push([t, a])
990    t = Math.max(t, b)
991  }
992  if (t < total) out.push([t, total])
993  return out
994}
995
996// Clawd is a block seen at the house's angle, feet at (0, 0).
997const iso = (x: number, y: number, z: number): Pt => ({ x: x - y, y: (x + y) / 2 - z })
998
999// Pixels of one colour as one path: [x, y, w, h] each.
1000function pix(color: string, boxes: number[][]): string {
1001  return `<path fill="${color}" d="${boxes.map(([x, y, w, h]) => `M${r1(x ?? 0)} ${r1(y ?? 0)}h${w}v${h}h-${w}z`).join('')}"/>`
1002}
1003
1004// Shared parts are defined once per picture and placed by reference.
1005let bodyDefs = new Map<string, string>()
1006
1007function part(id: string, draw: () => string): string {
1008  if (!bodyDefs.has(id)) bodyDefs.set(id, `<g id="${id}">${draw()}</g>`)
1009  return `<use href="#${id}"/>`
1010}
1011
1012// Font glyphs, defined once for all the agents' words.
1013const glyph = (id: string, d: string) => {
1014  if (!bodyDefs.has(id)) bodyDefs.set(id, `<path id="${id}" d="${d}"/>`)
1015}
1016
1017const agentText = (text: string, x: number, y: number, opts: Parameters<typeof pixelText>[3]) => glyphText(text, x, y, opts, glyph)
1018
1019// One pose: legs standing (`s`), stepping (`w`) or tucked (`n`), body `wide` across x or along y.
1020function poseOf(color: string, wide: 'x' | 'y', legs: 's' | 'w' | 'n', typing: boolean): string {
1021  const id = `ps${wide}${legs}${typing ? 't' : ''}${color.slice(1)}`
1022  if (!bodyDefs.has(id)) bodyDefs.set(id, `<g id="${id}">${legs === 'n' ? '' : clawdLegs(color, wide, legs === 'w')}${clawdBody(color, wide, typing)}</g>`)
1023  return id
1024}
1025
1026// Every pose in a strip seen through a one-pose window, so one sliding animation does for all.
1027const POSE_GAP = 48
1028const POSE_WINDOW = '<clipPath id="vh-pose"><rect x="-20" y="-30" width="40" height="38"/></clipPath>'
1029
1030function poseStrip(color: string, typing: boolean, across: Span[], along: Span[], walk: Span[], still: Span[], d: AgentDraw): string {
1031  const total = d.total
1032  const inside = (spans: Span[], t: number) => spans.some(([a, b]) => t >= a && t < b)
1033  const edges = [...new Set([0, ...[across, along, walk, still].flatMap(list => list.flat())])].filter(t => t >= 0 && t < total).sort((a, b) => a - b)
1034  const poses: string[] = []
1035  const steps: Step[] = []
1036  edges.forEach((t0, i) => {
1037    const mid = (t0 + (edges[i + 1] ?? total)) / 2
1038    const wide = inside(across, mid) ? 'x' : inside(along, mid) ? 'y' : undefined
1039    const legs = inside(walk, mid) ? 'w' : inside(still, mid) ? 's' : 'n'
1040    let v = -1
1041    if (wide) {
1042      const id = poseOf(color, wide, legs, wide === 'x' && typing)
1043      v = poses.indexOf(id)
1044      if (v < 0) v = poses.push(id) - 1
1045    }
1046    if (steps[steps.length - 1]?.v !== v) steps.push({ t: t0, v })
1047  })
1048  const only = poses[0]
1049  if (only !== undefined && poses.length === 1 && steps.every(p => p.v === 0)) return `<use href="#${only}"/>`
1050  if (!bodyDefs.has('vh-pose')) bodyDefs.set('vh-pose', POSE_WINDOW)
1051  const strip = poses.map((id, i) => `<use href="#${id}"${i ? ` x="${i * POSE_GAP}"` : ''}/>`).join('')
1052  const values = steps.map(p => (p.v < 0 ? '-9999 0' : `${-p.v * POSE_GAP} 0`))
1053  const slide = steps.length > 1
1054    ? `<g>${stepAnim('<animateTransform attributeName="transform" type="translate"', values, steps, total, d.begin, d.isLoop)}`
1055    : `<g transform="translate(${values[0] ?? '0 0'})">`
1056  return `<g clip-path="url(#vh-pose)">${slide}${strip}</g></g>`
1057}
1058
1059// `wide` across x faces S or N, along y faces E or W; typing, the arms take turns going up.
1060function clawdBody(color: string, wide: 'x' | 'y', typing: boolean): string {
1061  return part(`cb${wide}${typing ? 't' : ''}${color.slice(1)}`, () => {
1062    const w = wide === 'x' ? 8 : 4.5
1063    const d = wide === 'x' ? 4.5 : 8
1064    const [x0, x1, y0, y1, z0, z1] = [-w / 2, w / 2, -d / 2, d / 2, 3, 10]
1065    const top = [iso(x0, y0, z1), iso(x1, y0, z1), iso(x1, y1, z1), iso(x0, y1, z1)]
1066    const left = [iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0)]
1067    const right = [iso(x1, y0, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0)]
1068    const a = ARMS[wide]
1069    const nub = (p: Pt, lift = 0) => [p.x, p.y - lift, 2, 2]
1070    const arms = (front: boolean) => {
1071      const p = front ? a.front : a.back
1072      if (!typing) return pix(color, [nub(p)])
1073      const [up, down] = front ? [1, 0] : [0, 1]
1074      return `<g>${pix(color, [nub(p, up)])}${blinkAnim('opacity', '1', '0', 330)}</g><g>${pix(color, [nub(p, down)])}${blinkAnim('opacity', '0', '1', 330)}</g>`
1075    }
1076    return arms(false) + `<path fill="${shade(color, 0.22)}" d="${pathOf(top)}"/><path fill="${color}" d="${pathOf(left)}"/>` +
1077      `<path fill="${shade(color, -0.26)}" d="${pathOf(right)}"/>` + arms(true)
1078  })
1079}
1080
1081// The session's ring at an agent's feet: an outline over a faint wash.
1082function teamRing(color: string): string {
1083  return part(`ring${color.slice(1)}`, () => {
1084    const inside = (x: number, y: number, rx: number, ry: number) => ((x + 0.5) / rx) ** 2 + ((y + 0.5) / ry) ** 2 <= 1
1085    const line: number[][] = []
1086    const wash: number[][] = []
1087    for (let y = -5; y <= 5; y++) {
1088      for (let x = -9; x <= 9; x++) {
1089        const run = inside(x, y, 8.2, 4.1) ? (inside(x, y, 6.4, 2.9) ? wash : line) : undefined
1090        const last = run?.[run.length - 1]
1091        if (!run) continue
1092        if (last && last[1] === y && (last[0] ?? 0) + (last[2] ?? 0) === x) last[2] = (last[2] ?? 0) + 1
1093        else run.push([x, y, 1, 1])
1094      }
1095    }
1096    return `<g opacity="0.18">${pix(color, wash)}</g><g opacity="0.8">${pix(color, line)}</g>`
1097  })
1098}
1099
1100// A taped box icon, 5x4.
1101const TAPE = '#ecd6a6'
1102
1103function boxIcon(tape: string): string {
1104  return pix('#e2b57a', [[0, 0, 5, 1]]) + pix('#c9965a', [[0, 1, 5, 3]]) + pix('#9a6c3c', [[4, 1, 1, 3]]) + pix(tape, [[2, 0, 1, 4]])
1105}
1106
1107// ---------- hats ----------
1108
1109// A hat per role, the same from every side: [x, y, w, h] pixels per colour, the brim's row at y -12.
1110const HATS: Record<string, [string, number[][]][]> = {
1111  // a crown with a jewel
1112  Host: [['#ffd166', [[-2, -12, 5, 1], [-2, -13, 1, 1], [0, -13, 1, 1], [2, -13, 1, 1]]], ['#e84a5f', [[0, -12, 1, 1]]]],
1113  // a checked deerstalker
1114  Snoop: [['#7a532e', [[-3, -12, 7, 1], [0, -15, 1, 1]]], ['#b5844f', [[-2, -13, 5, 1], [-1, -14, 3, 1]]], ['#dcae74', [[-1, -13, 1, 1], [1, -13, 1, 1]]]],
1115  // a wizard's pointed hat with a star
1116  Schemer: [['#5e3c9c', [[-3, -12, 7, 1]]], ['#8e5fd6', [[-2, -13, 5, 1], [-1, -14, 3, 1], [0, -15, 2, 1], [1, -16, 1, 1]]], ['#ffe066', [[0, -14, 1, 1]]]],
1117  // a hard hat with a ridge
1118  Handyman: [['#c9971f', [[-3, -12, 7, 1]]], ['#f5c242', [[-2, -13, 5, 1], [-1, -14, 3, 1]]], ['#ffe9a3', [[0, -14, 1, 2]]]],
1119  // silver hair in a bun, a flower in it
1120  Granny: [['#cfd2dc', [[-2, -12, 5, 1], [-1, -14, 3, 2]]], ['#9da1b0', [[1, -13, 1, 1]]], ['#ff8fb8', [[2, -13, 1, 1]]]],
1121  // a flat red cap
1122  Plumber: [['#a32c29', [[-3, -12, 7, 1]]], ['#d64541', [[-2, -13, 5, 1]]], ['#ffffff', [[0, -13, 1, 1]]]],
1123  // a peaked cap with a badge
1124  Inspector: [['#1b1b26', [[-2, -12, 5, 1]]], ['#3a5a94', [[-3, -13, 7, 1], [-2, -14, 5, 1]]], ['#ffd166', [[0, -12, 1, 1]]]],
1125  // lab goggles pushed up
1126  Taster: [['#3a3a48', [[-3, -12, 7, 1]]], ['#8fd3ff', [[-2, -14, 2, 2], [1, -14, 2, 2]]], ['#ffffff', [[-2, -14, 1, 1], [1, -14, 1, 1]]]],
1127  // floppy dog's ears
1128  Watchdog: [['#8b5a2b', [[-2, -12, 5, 1], [-3, -12, 1, 3], [3, -12, 1, 3]]], ['#b07a45', [[-1, -13, 3, 1]]]],
1129  // cat ears
1130  Mouser: [['#a3a3b2', [[-2, -12, 5, 1], [-2, -13, 1, 2], [2, -13, 1, 2]]], ['#f2a0b5', [[-1, -13, 1, 1], [1, -13, 1, 1]]]],
1131  // a beret worn to one side
1132  Decorator: [['#d1477a', [[-2, -12, 6, 1], [-1, -13, 5, 1]]], ['#9e2f58', [[2, -14, 1, 1]]]],
1133  // a green eyeshade
1134  Bookkeeper: [['#2d6e46', [[-2, -13, 5, 1]]], ['#5fd08a', [[-3, -12, 7, 1]]]],
1135  // a quill tucked in a band
1136  Scribbler: [['#7a5a3a', [[-2, -12, 5, 1]]], ['#f3ead2', [[1, -13, 1, 1], [1, -14, 2, 1], [2, -15, 2, 1], [3, -16, 1, 1]]]],
1137  // a propeller beanie (spun in hatOf)
1138  Tinkerer: [['#4a7fd6', [[-2, -12, 5, 1]]], ['#e2483d', [[-1, -13, 1, 1]]], ['#f5c242', [[0, -13, 1, 1]]], ['#4cbf6a', [[1, -13, 1, 1]]], ['#5a5a68', [[0, -14, 1, 1]]]],
1139  // a chef's toque
1140  Cook: [['#b9bab4', [[-2, -12, 5, 1], [3, -15, 1, 2], [2, -16, 1, 1], [2, -13, 1, 1]]], ['#f7f7f2', [[-2, -13, 4, 1], [-3, -15, 6, 2], [-2, -16, 4, 1]]], ['#dcddd8', [[-1, -15, 1, 1], [0, -16, 1, 1]]]],
1141  // a wide straw sun hat
1142  Neighbor: [['#e2c27a', [[-4, -12, 9, 1], [-2, -14, 5, 1]]], ['#d9534f', [[-2, -13, 5, 1]]]],
1143  // a top hat
1144  Butler: [['#2e2e3a', [[-3, -12, 7, 1], [-2, -16, 5, 3]]], ['#6c6c84', [[-2, -16, 1, 3]]], ['#d4d4de', [[-2, -13, 5, 1]]]],
1145  // little horns
1146  Gremlin: [['#c0392b', [[-2, -12, 1, 1], [-2, -13, 1, 1], [-3, -14, 1, 1], [2, -12, 1, 1], [2, -13, 1, 1], [3, -14, 1, 1]]]],
1147}
1148
1149function hatOf(c: Resident): string {
1150  const role = displayName(c)
1151  return part(`hat-${role}`, () => {
1152    const hat = (own(HATS, role) ?? []).map(([color, boxes]) => pix(color, boxes)).join('')
1153    if (role !== 'Tinkerer') return hat
1154    // the propeller: blades across, then end on, turning
1155    return hat + `<g>${pix('#c9ccd6', [[-2, -15, 5, 1]])}${blinkAnim('opacity', '1', '0', 240)}</g><g>${pix('#c9ccd6', [[0, -15, 1, 1]])}${blinkAnim('opacity', '0', '1', 240)}</g>`
1156  })
1157}
1158// Eyes on the side facing you; none from behind.
1159const EYES: Record<'S' | 'E', [Pt, Pt]> = {
1160  S: [iso(-2.2, 2.25, 9), iso(1.6, 2.25, 9)],
1161  E: [iso(2.25, -1.6, 9), iso(2.25, 2.2, 9)],
1162}
1163// An arm at each end; the back one is drawn first so the body hides its root.
1164const ARMS: Record<'x' | 'y', { back: Pt; front: Pt }> = {
1165  x: { back: { x: -6.6, y: -10.3 }, front: { x: 4.6, y: -5.7 } },
1166  y: { back: { x: 4.6, y: -10.3 }, front: { x: -6.6, y: -5.7 } },
1167}
1168
1169// Four three-pixel posts along the long side, back ones first; walking, diagonal pairs lift in turn.
1170function clawdLegs(color: string, wide: 'x' | 'y', walking: boolean): string {
1171  return part(`lg${wide}${walking ? 'w' : 's'}${color.slice(1)}`, () => {
1172    const [ax, ay] = wide === 'x' ? [2.6, 1.2] : [1.2, 2.6]
1173    const corners = [[-ax, -ay], [ax, -ay], [-ax, ay], [ax, ay]] as const
1174    const post = (cx: number, cy: number, z0: number) => {
1175      const [x0, x1, y0, y1, z1] = [cx - 0.7, cx + 0.7, cy - 0.7, cy + 0.7, 3]
1176      return `<path fill="${shade(color, -0.12)}" d="${pathOf([iso(x0, y1, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x0, y1, z0)])}"/>` +
1177        `<path fill="${shade(color, -0.34)}" d="${pathOf([iso(x1, y0, z1), iso(x1, y1, z1), iso(x1, y1, z0), iso(x1, y0, z0)])}"/>`
1178    }
1179    const frame = (lifted: (i: number) => boolean) => corners.map(([cx, cy], i) => post(cx, cy, lifted(i) ? 1 : 0)).join('')
1180    if (!walking) return frame(() => false)
1181    return `<g>${frame(i => i === 0 || i === 3)}${blinkAnim('opacity', '1', '0', 360)}</g><g>${frame(i => i === 1 || i === 2)}${blinkAnim('opacity', '0', '1', 360)}</g>`
1182  })
1183}
1184
1185function agentSvg(c: Resident, d: AgentDraw): string {
1186  const tk = (spans: Span[]) => track(spans, d.total, d.begin, d.isLoop)
1187  const body = bodyColor(c)
1188  const faces = faceSpans(d.faces, d.total)
1189  const sleeping = d.sleep
1190  const across = [...faces.S, ...faces.N]
1191  const along = [...faces.E, ...faces.W]
1192  const team = teamOf(c.session ?? '')
1193  const hat = hatOf(c)
1194  // the ring stays on the floor: not lifted by hops, hidden while sitting or lying
1195  const ring = team ? group(teamRing(team), tk(invert([...d.sit, ...d.sleep], d.total))) : ''
1196  const carry = d.carry ?? []
1197  const tape = team ?? TAPE
1198  const load = carry.length > 0 ? group(part(`load${tape.slice(1)}`, () => `<g transform="translate(-2.5 -16)">${boxIcon(tape)}</g>`), tk(carry)) : ''
1199  const capped = carry.length > 0 ? group(hat, tk(invert(carry, d.total))) : hat
1200  // eyes blink out of step with the others
hooks/visitors.ts 61 lines
1// Other sessions' visitors walking in from the street and out. Set-off times come from the store so all
2// houses agree; one let go leaves from where it stands now, as an earlier start would jump it back.
3import type { Resident } from '../types'
4import { arrive, errandEnds } from './scene'
5
6// Another session's household from the store; `wokeAt`: when it last woke from a long sleep.
7export type Household = { id: string; since: number; residents: Resident[]; wokeAt?: number }
8
9// seconds between one session's visitors setting off together
10const ARRIVE_GAP = 1.5
11// set off longer ago than this (ms): already in, no walk to show
12const WALK_FOR = 60000
13
14// Each member's set-off: when it joined (the lead: its session's start) or the session woke, if later;
15// those that came together a moment apart.
16function arrivals(f: Household): Map<string, number> {
17  const joined = (m: Resident) => Math.max(m.spawnedAt ?? (m.id === 'main' ? f.since : m.workSince ?? 0), f.wokeAt ?? 0)
18  const out = new Map<string, number>()
19  let next = -Infinity
20  for (const m of [...f.residents].sort((a, b) => joined(a) - joined(b))) {
21    const t = Math.max(joined(m), next)
22    out.set(m.id, t)
23    next = t + ARRIVE_GAP * 1000
24  }
25  return out
26}
27
28// The others' members now, each keeping its spot and desk. Newcomers walk in from their set-off time;
29// those let go walk out and are gone at the road's end.
30export function visit(list: Resident[], found: Household[], now: number): Resident[] {
31  const here = new Map(list.filter(c => c.session).map(c => [c.id, c]))
32  const ends = here.size > 0 ? errandEnds(list, now) : new Map<string, number>()
33  const isThere = (c: Resident) => (ends.get(c.id) ?? 0) <= now
34  const seen = new Set<string>()
35  const theirs = found.flatMap(f => {
36    const starts = arrivals(f)
37    return f.residents.map((m): Resident => {
38      const id = `${f.id}/${m.id}`
39      seen.add(id)
40      const had = here.get(id)
41      const member: Resident = { ...m, id, session: f.id }
42      if (!had) {
43        const start = starts.get(m.id) ?? now
44        if (now - start > WALK_FOR) return member
45        const from = Math.min(now, start)
46        return arrive(member, from, (start - from) / 1000)
47      }
48      return {
49        ...member,
50        ...(had.anchor ? { anchor: had.anchor } : {}),
51        ...(had.seat !== undefined ? { seat: had.seat } : {}),
52        ...(had.arriving !== undefined && !isThere(had) ? { arriving: had.arriving } : {}),
53      }
54    })
55  })
56  const gone = [...here.values()]
57    .filter(c => !seen.has(c.id) && !(c.leaving !== undefined && isThere(c)))
58    .map(c => (c.leaving !== undefined ? c : { ...c, leaving: now }))
59  return [...list.filter(c => !c.session), ...theirs, ...gone]
60}
61
hooks/font.ts 266 lines
1/*
2 * Vibehaus's pixel font, 5x7 with descenders, as SVG rectangles on the art's pixel grid so text stays
3 * crisp at any size. Widths vary; a blank column follows each letter.
4 */
5
6const GLYPHS: Record<string, string[]> = {
7  ' ': ['...'],
8  A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
9  B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
10  C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
11  D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
12  E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
13  F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
14  G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
15  H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
16  I: ['###', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
17  J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
18  K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
19  L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
20  M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
21  N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'],
22  O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
23  P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
24  Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
25  R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
26  S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
27  T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
28  U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
29  V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
30  W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'],
31  X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
32  Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
33  Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
34  a: ['.....', '.....', '.###.', '....#', '.####', '#...#', '.####'],
35  b: ['#....', '#....', '#.##.', '##..#', '#...#', '#...#', '####.'],
36  c: ['.....', '.....', '.###.', '#....', '#....', '#...#', '.###.'],
37  d: ['....#', '....#', '.##.#', '#..##', '#...#', '#...#', '.####'],
38  e: ['.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
39  f: ['..##', '.#..', '.#..', '###.', '.#..', '.#..', '.#..'],
40  g: ['.....', '.....', '.####', '#...#', '#...#', '#...#', '.####', '....#', '.###.'],
41  h: ['#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#'],
42  i: ['.#.', '...', '##.', '.#.', '.#.', '.#.', '###'],
43  j: ['..#', '...', '.##', '..#', '..#', '..#', '..#', '#.#', '.#.'],
44  k: ['#...', '#...', '#..#', '#.#.', '##..', '#.#.', '#..#'],
45  l: ['##.', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
46  m: ['.....', '.....', '##.#.', '#.#.#', '#.#.#', '#...#', '#...#'],
47  n: ['.....', '.....', '#.##.', '##..#', '#...#', '#...#', '#...#'],
48  o: ['.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
49  p: ['.....', '.....', '####.', '#...#', '#...#', '#...#', '####.', '#....', '#....'],
50  q: ['.....', '.....', '.####', '#...#', '#...#', '#...#', '.####', '....#', '....#'],
51  r: ['.....', '.....', '#.##.', '##..#', '#....', '#....', '#....'],
52  s: ['.....', '.....', '.####', '#....', '.###.', '....#', '####.'],
53  t: ['.#..', '.#..', '####', '.#..', '.#..', '.#.#', '..#.'],
54  u: ['.....', '.....', '#...#', '#...#', '#...#', '#..##', '.##.#'],
55  v: ['.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
56  w: ['.....', '.....', '#...#', '#...#', '#.#.#', '#.#.#', '.#.#.'],
57  x: ['.....', '.....', '#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
58  y: ['.....', '.....', '#...#', '#...#', '#...#', '#...#', '.####', '....#', '.###.'],
59  z: ['.....', '.....', '#####', '...#.', '..#..', '.#...', '#####'],
60  0: ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
61  1: ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
62  2: ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
63  3: ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
64  4: ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
65  5: ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
66  6: ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
67  7: ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
68  8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
69  9: ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
70  '.': ['..', '..', '..', '..', '..', '##', '##'],
71  ',': ['..', '..', '..', '..', '..', '##', '.#', '#.'],
72  ':': ['..', '##', '##', '..', '##', '##', '..'],
73  ';': ['..', '##', '##', '..', '##', '##', '.#', '#.'],
74  '!': ['#', '#', '#', '#', '#', '.', '#'],
75  '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
76  "'": ['#', '#'],
77  '"': ['#.#', '#.#'],
78  '`': ['#.', '.#'],
79  '-': ['....', '....', '....', '####'],
80  '_': ['.....', '.....', '.....', '.....', '.....', '.....', '.....', '#####'],
81  '+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..'],
82  '=': ['.....', '.....', '#####', '.....', '#####'],
83  '*': ['.....', '..#..', '#.#.#', '.###.', '#.#.#', '..#..'],
84  '/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'],
85  '|': ['#', '#', '#', '#', '#', '#', '#', '#'],
86  '(': ['..#', '.#.', '#..', '#..', '#..', '.#.', '..#'],
87  ')': ['#..', '.#.', '..#', '..#', '..#', '.#.', '#..'],
88  '[': ['###', '#..', '#..', '#..', '#..', '#..', '###'],
89  ']': ['###', '..#', '..#', '..#', '..#', '..#', '###'],
90  '{': ['..##', '.#..', '.#..', '#...', '.#..', '.#..', '..##'],
91  '}': ['##..', '..#.', '..#.', '...#', '..#.', '..#.', '##..'],
92  '<': ['...#', '..#.', '.#..', '#...', '.#..', '..#.', '...#'],
93  '>': ['#...', '.#..', '..#.', '...#', '..#.', '.#..', '#...'],
94  '#': ['.#.#.', '.#.#.', '#####', '.#.#.', '#####', '.#.#.', '.#.#.'],
95  '%': ['##..#', '##..#', '...#.', '..#..', '.#...', '#..##', '#..##'],
96  '&': ['.##..', '#..#.', '#.#..', '.#...', '#.#.#', '#..#.', '.##.#'],
97  '@': ['.###.', '#...#', '#.###', '#.#.#', '#.###', '#....', '.###.'],
98  $: ['..#..', '.####', '#.#..', '.###.', '..#.#', '####.', '..#..'],
99  '^': ['..#..', '.#.#.', '#...#'],
100  '~': ['.....', '.....', '.#...', '#.#.#', '...#.'],
101  '…': ['.....', '.....', '.....', '.....', '.....', '.....', '#.#.#'],
102  '·': ['..', '..', '..', '##', '##'],
103  '⧗': ['#####', '.###.', '..#..', '..#..', '.#.#.', '#...#', '#####'],
104  '♥': ['.....', '.#.#.', '#####', '#####', '.###.', '..#..'],
105  '♪': ['..###', '..#.#', '..#..', '..#..', '###..', '###..'],
106}
107GLYPHS[String.fromCharCode(92)] = ['#....', '#....', '.#...', '..#..', '...#.', '....#', '....#']
108
109const UNKNOWN = ['#####', '#...#', '#...#', '#...#', '#...#', '#...#', '#####']
110
111// Accents dropped; dotless i, ß, long dashes and curly quotes made plain.
112const FOLD: Record<string, string> = { 'ı': 'i', 'İ': 'I', 'ß': 'ss' }
113for (const [code, plain] of [[0x2014, '-'], [0x2013, '-'], [0x201c, '"'], [0x201d, '"'], [0x2018, "'"], [0x2019, "'"]] as const) {
114  FOLD[String.fromCharCode(code)] = plain
115}
116const MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
117
118// joiners, variation selectors and skin tones, which only shape the emoji beside them
119const SHAPERS = /[\u200d\ufe0e\ufe0f]|\ud83c[\udffb-\udfff]/g
120
121function normalize(text: string): string[] {
122  return [...[...text.replace(SHAPERS, '')].map(ch => FOLD[ch] ?? ch).join('').normalize('NFD').replace(MARKS, '').normalize('NFC')]
123}
124
125const glyphOf = (ch: string): string[] => GLYPHS[ch] ?? (/\s/.test(ch) ? (GLYPHS[' '] as string[]) : UNKNOWN)
126
127const widthOf = (g: string[]) => g[0]?.length ?? 5
128
129// Width of `text` at `px` units a pixel.
130export function textWidth(text: string, px: number): number {
131  const chars = normalize(text)
132  if (chars.length === 0) return 0
133  return (chars.reduce((n, ch) => n + widthOf(glyphOf(ch)) + 1, 0) - 1) * px
134}
135
136// Cuts `text` to `maxWidth`, ending in "…" when cut.
137export function fitText(text: string, maxWidth: number, px: number): string {
138  if (textWidth(text, px) <= maxWidth) return text
139  const chars = normalize(text)
140  let out = ''
141  for (const ch of chars) {
142    if (textWidth(`${out}${ch}…`, px) > maxWidth) break
143    out += ch
144  }
145  return `${out.trimEnd()}…`
146}
147
148// Wraps at spaces, cutting overlong words; at most `maxLines`, the last ending in "…" if text is left.
149export function wrapText(text: string, maxWidth: number, px: number, maxLines: number): string[] {
150  const lines: string[] = []
151  // by code points, so an emoji is never split
152  let rest = text.trim()
153  while (rest.length > 0 && lines.length < maxLines) {
154    if (textWidth(rest, px) <= maxWidth) {
155      lines.push(rest)
156      rest = ''
157      break
158    }
159    const chars = [...rest]
160    let cut = 0
161    for (let i = 1; i <= chars.length; i++) {
162      if (textWidth(chars.slice(0, i).join(''), px) > maxWidth) break
163      cut = i
164    }
165    const space = chars.lastIndexOf(' ', cut)
166    const end = space > 0 ? space : Math.max(1, cut)
167    lines.push(chars.slice(0, end).join('').trimEnd())
168    rest = chars.slice(end).join('').trimStart()
169  }
170  if (rest.length > 0 && lines.length > 0) {
171    lines[lines.length - 1] = fitText(`${lines[lines.length - 1]} ${rest}`, maxWidth, px)
172  }
173  return lines
174}
175
176const r2 = (n: number) => Math.round(n * 100) / 100
177
178type Rect = [x: number, y: number, w: number, h: number]
179
180// Lit pixels as few rectangles: each run in a row grows down while rows below match it.
181function rectsOf(rows: string[]): Rect[] {
182  const used = rows.map(row => [...row].map(() => false))
183  const lit = (x: number, y: number) => rows[y]?.[x] === '#' && used[y]?.[x] === false
184  const out: Rect[] = []
185  rows.forEach((row, y) => {
186    for (let x = 0; x < row.length; x++) {
187      if (!lit(x, y)) continue
188      let w = 1
189      while (lit(x + w, y)) w++
190      let h = 1
191      while (Array.from({ length: w }, (_, k) => lit(x + k, y + h)).every(Boolean)) h++
192      for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) (used[yy] as boolean[])[xx] = true
193      out.push([x, y, w, h])
194    }
195  })
196  return out
197}
198
199const glyphRects = new Map<string, Rect[]>()
200
201// by letter; letters the font lacks share one box, so the map never outgrows the font
202function rectsOfChar(ch: string): Rect[] {
203  const glyph = glyphOf(ch)
204  const key = glyph === UNKNOWN ? '' : ch
205  const known = glyphRects.get(key)
206  if (known) return known
207  const rects = rectsOf(glyph)
208  glyphRects.set(key, rects)
209  return rects
210}
211
212const pathOf = (rects: Rect[], dx = 0, dy = 0) =>
213  rects.map(([x, y, w, h]) => `M${x + dx} ${y + dy}h${w}v${h}h${-w}z`).join('')
214
215// Outlines in font pixels from the top-left, kept since names repeat.
216const outlines = new Map<string, string>()
217
218function outline(text: string): string {
219  const known = outlines.get(text)
220  if (known !== undefined) return known
221  let d = ''
222  let cx = 0
223  for (const ch of normalize(text)) {
224    d += pathOf(rectsOfChar(ch), cx)
225    cx += widthOf(glyphOf(ch)) + 1
226  }
227  if (outlines.size > 500) outlines.clear()
228  outlines.set(text, d)
229  return d
230}
231
232// One path, its capitals' top at (x, y), `anchor` as text-anchor; `cls` a class the light theme recolours.
233export function pixelText(
234  text: string, x: number, y: number,
235  { px = 0.5, color, anchor = 'start', cls }: { px?: number; color: string; anchor?: 'start' | 'middle' | 'end'; cls?: string },
236): string {
237  const d = outline(text)
238  if (!d) return ''
239  const w = textWidth(text, px)
240  const left = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x
241  return `<path${cls ? ` class="${cls}"` : ''} transform="translate(${r2(left)} ${r2(y)}) scale(${Math.round(px * 1000) / 1000})" fill="${color}" d="${d}"/>`
242}
243
244// As pixelText, but each glyph defined once through `define` and reused: far shorter once letters repeat.
245export function glyphText(
246  text: string, x: number, y: number,
247  { px = 0.5, color, anchor = 'start', cls }: { px?: number; color: string; anchor?: 'start' | 'middle' | 'end'; cls?: string },
248  define: (id: string, d: string) => void,
249): string {
250  let uses = ''
251  let cx = 0
252  for (const ch of normalize(text)) {
253    const rects = rectsOfChar(ch)
254    if (rects.length > 0) {
255      const id = `gl${(ch.codePointAt(0) ?? 63).toString(36)}`
256      define(id, pathOf(rects))
257      uses += `<use href="#${id}"${cx ? ` x="${cx}"` : ''}/>`
258    }
259    cx += widthOf(glyphOf(ch)) + 1
260  }
261  if (!uses) return ''
262  const w = textWidth(text, px)
263  const left = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x
264  return `<g${cls ? ` class="${cls}"` : ''} transform="translate(${r2(left)} ${r2(y)}) scale(${Math.round(px * 1000) / 1000})" fill="${color}">${uses}</g>`
265}
266
hooks/iso.ts 242 lines
1/*
2 * Isometric pixel drawing: a 2:1 projection and a canvas that fills shapes on the pixel grid. Each fill
3 * is a material with night (dark theme) and day colours; a style swaps in the day ones in light mode.
4 */
5
6export type Pt = { x: number; y: number }
7
8// A material: night and day colours, and a short class for the style.
9export type Mat = { night: string; day: string; cls: string }
10
11// A box's faces: lit top, left side, darkest right side.
12export type Faces = { top: Mat; left: Mat; right: Mat }
13
14export function shade(hex: string, f: number): string {
15  const v = parseInt(hex.slice(1), 16)
16  const c = (s: number) => {
17    const ch = (v >> s) & 255
18    return Math.max(0, Math.min(255, Math.round(f < 0 ? ch * (1 + f) : ch + (255 - ch) * f)))
19  }
20  return '#' + ((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')
21}
22
23// mulberry32: a small seeded generator, so a picture is the same on every redraw.
24export function rng(seed: number): () => number {
25  let a = seed >>> 0
26  return () => {
27    a = (a + 0x6d2b79f5) >>> 0
28    let t = a
29    t = Math.imul(t ^ (t >>> 15), t | 1)
30    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
31    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
32  }
33}
34
35export function mat(cls: string, night: string, day: string): Mat {
36  return { cls, night, day }
37}
38
39// Indoors by night: the day colour dimmed towards amber, so the house glows in the dark garden.
40function lamplit(day: string): string {
41  const v = parseInt(day.slice(1), 16)
42  const ch = (s: number, f: number) => Math.min(255, Math.round(((v >> s) & 255) * f))
43  return '#' + ((ch(16, 0.8) << 16) | (ch(8, 0.67) << 8) | ch(0, 0.54)).toString(16).padStart(6, '0')
44}
45
46export function warm(cls: string, day: string): Mat {
47  return mat(cls, lamplit(day), day)
48}
49
50export function warmFaces(cls: string, day: string): Faces {
51  return faces(cls, lamplit(day), day)
52}
53
54export function faces(cls: string, night: string, day: string): Faces {
55  return {
56    top: mat(`${cls}t`, shade(night, 0.14), shade(day, 0.18)),
57    left: mat(`${cls}l`, night, day),
58    right: mat(`${cls}r`, shade(night, -0.28), shade(day, -0.2)),
59  }
60}
61
62// A convex polygon's pixel rows (centres inside; left edge in, right out), so shared edges tile exactly.
63function rows(pts: Pt[]): [number, number, number][] {
64  if (pts.length < 3) return []
65  const ys = pts.map(p => p.y)
66  const top = Math.floor(Math.min(...ys))
67  const bottom = Math.ceil(Math.max(...ys))
68  const out: [number, number, number][] = []
69  for (let r = top; r < bottom; r++) {
70    const yc = r + 0.5
71    let lo = Infinity
72    let hi = -Infinity
73    for (let i = 0; i < pts.length; i++) {
74      const a = pts[i] as Pt
75      const b = pts[(i + 1) % pts.length] as Pt
76      if ((a.y <= yc && yc < b.y) || (b.y <= yc && yc < a.y)) {
77        const x = a.x + ((yc - a.y) * (b.x - a.x)) / (b.y - a.y)
78        lo = Math.min(lo, x)
79        hi = Math.max(hi, x)
80      }
81    }
82    if (lo > hi) continue
83    const c0 = Math.ceil(lo - 0.5 - 1e-9)
84    const c1 = Math.ceil(hi - 0.5 - 1e-9)
85    if (c1 > c0) out.push([r, c0, c1 - c0])
86  }
87  return out
88}
89
90// One picture's paths: one per material in each layer; layers stack in the order opened.
91export class Canvas {
92  private parts: string[] = []
93  private open = new Map<string, { m: Mat | string; d: string[] }>()
94  readonly used = new Map<string, Mat>()
95  // stamped pieces: where each was first drawn
96  private stamps = new Map<string, Pt>()
97  private defs: string[] = []
98
99  // Draws a recurring piece once, then copies moved by whole pixels. `origin`: the same point on each;
100  // `fill` fills pieces drawn with `inherit` (floors, wallpaper).
101  stamp(name: string, origin: Pt, draw: (cv: Canvas) => void, fill?: string, attrs = '', inner = ''): void {
102    // pieces at different half-pixel phases rasterise differently: one stamp each
103    const id = `${name}${phaseOf(origin)}`
104    const first = this.stamps.get(id)
105    const paint = (fill ? ` fill="${fill}"` : '') + attrs
106    const end = inner ? `>${inner}</use>` : '/>'
107    if (!first) {
108      const sub = new Canvas()
109      draw(sub)
110      for (const [k, m] of sub.used) this.used.set(k, m)
111      this.defs.push(`<g id="${id}">${sub.svg()}</g>`)
112      this.stamps.set(id, origin)
113      this.raw(`<use href="#${id}"${paint}${end}`)
114      return
115    }
116    const dx = Math.round(origin.x - first.x)
117    const dy = Math.round(origin.y - first.y)
118    this.raw(`<use href="#${id}" x="${dx}" y="${dy}"${paint}${end}`)
119  }
120
121  // Defines a piece without placing it, for hand-placed copies; `draw` may return an animation.
122  define(id: string, draw: (cv: Canvas) => string | void): void {
123    if (this.stamps.has(id)) return
124    const sub = new Canvas()
125    const anim = draw(sub) ?? ''
126    for (const [k, m] of sub.used) this.used.set(k, m)
127    this.defs.push(`<g id="${id}">${anim}${sub.svg()}</g>`)
128    this.stamps.set(id, { x: 0, y: 0 })
129  }
130
131  // The pieces stamped, for the picture's defs.
132  stamped(): string {
133    return this.defs.join('')
134  }
135
136  // Starts a new layer over everything drawn before.
137  layer(): void {
138    for (const { m, d } of this.open.values()) {
139      if (d.length === 0) continue
140      this.parts.push(typeof m === 'string'
141        ? `<path fill="${m}" d="${d.join('')}"/>`
142        : `<path class="${m.cls}" d="${d.join('')}"/>`)
143    }
144    this.open.clear()
145  }
146
147  private runs(m: Mat | string, list: [number, number, number][]): void {
148    if (list.length === 0) return
149    const key = typeof m === 'string' ? m : m.cls
150    if (typeof m !== 'string') this.used.set(m.cls, m)
151    const slot = this.open.get(key) ?? { m, d: [] }
152    this.open.set(key, slot)
153    // each stretch of consecutive rows is one stair-stepped outline
154    let start = 0
155    for (let i = 1; i <= list.length; i++) {
156      const prev = list[i - 1] as [number, number, number]
157      const next = list[i]
158      if (next && next[0] === prev[0] + 1) continue
159      slot.d.push(outline(list.slice(start, i)))
160      start = i
161    }
162  }
163
164  poly(pts: Pt[], m: Mat | string): void {
165    this.runs(m, rows(pts))
166  }
167
168  rect(x: number, y: number, w: number, h: number, m: Mat | string): void {
169    if (w <= 0 || h <= 0) return
170    this.runs(m, Array.from({ length: h }, (_, i) => [y + i, x, w] as [number, number, number]))
171  }
172
173  // Raw markup over everything drawn so far.
174  raw(svg: string): void {
175    this.layer()
176    this.parts.push(svg)
177  }
178
179  svg(): string {
180    this.layer()
181    return this.parts.join('')
182  }
183}
184
185// A point's half-pixel phase, to tell stamps apart.
186function phaseOf(p: Pt): string {
187  const part = (v: number) => Math.round((((v % 1) + 1) % 1) * 2) % 2
188  return part(p.x) || part(p.y) ? `-h${part(p.x)}${part(p.y)}` : ''
189}
190
191// The outline of consecutive pixel rows: down the right side, back up the left.
192function outline(list: [number, number, number][]): string {
193  const first = list[0] as [number, number, number]
194  let d = `M${first[1]} ${first[0]}`
195  let x = first[1]
196  let down = 0
197  const across = (to: number) => {
198    if (to === x) return
199    if (down !== 0) {
200      d += `v${down}`
201      down = 0
202    }
203    d += `h${to - x}`
204    x = to
205  }
206  for (const [, left, w] of list) {
207    across(left + w)
208    down += 1
209  }
210  for (let i = list.length - 1; i >= 0; i--) {
211    across((list[i] as [number, number, number])[1])
212    down -= 1
213  }
214  return `${d}${down !== 0 ? `v${down}` : ''}z`
215}
216
217// CSS for every material a picture used, by night or day.
218export function styleOf(used: Iterable<Mat>, time: 'night' | 'day'): string {
219  let css = ''
220  for (const m of used) css += `.${m.cls}{fill:${m[time]}}`
221  return css
222}
223
224// A convex polygon's pixels as their own path, for shapes that animate alone.
225export function pathOf(pts: Pt[]): string {
226  return rows(pts).map(([r, x, w]) => `M${x} ${r}h${w}v1h-${w}z`).join('')
227}
228
229// ---------- the clock ----------
230
231// Draw time (s): loops (a sway, a blink) start where this clock puts them, so redraws never show.
232let clockS = 0
233
234export function setClock(now: number): void {
235  clockS = now / 1000
236}
237
238// `begin` for a loop of `dur` seconds, running `lead` seconds ahead.
239export function loopBegin(dur: number, lead = 0): string {
240  return `begin="-${((((clockS + lead) % dur) + dur) % dur).toFixed(2)}s"`
241}
242
hooks/yard.ts 176 lines
1/*
2 * The front yard: a road from the workshop door to the picture's edge, pallets where the Clawds stack
3 * a box per piece of work shipped, and a mailbox.
4 */
5import { at, box, frontDoor } from './house'
6import type { Layout } from './house'
7import { faces, mat, shade } from './iso'
8import type { Canvas, Mat, Pt } from './iso'
9
10const Y = {
11  kerb: mat('ye', '#2b2925', '#8f8574'),
12  stone: mat('yo', '#47433d', '#c9bfae'),
13  seam: mat('yn', '#36332e', '#aa9f8c'),
14  doormat: mat('ya', '#4e2d20', '#9c5a3c'),
15  card: faces('yc', '#8a6440', '#cf9a5c'),
16  tape: mat('yt', '#9c8058', '#ecd6a6'),
17  pallet: faces('yp', '#4f3d2c', '#a8865e'),
18  post: faces('yq', '#2e241b', '#6b4f36'),
19  mailbox: faces('ym', '#6e2a26', '#c94f45'),
20  flag: mat('yf', '#c9a227', '#ffd34a'),
21}
22
23// Boxes stack two by two, three high, on whole-pixel steps so stacks line up.
24const BOX = 3
25const STEP = 4
26const BOX_H = 3
27const PALLET = 2 * STEP
28const LAYERS = 3
29const PALLETS = 5
30
31// Yard places on the plan: pallets by back corner, `drops` where a Clawd stacks from, `floor` to idle on.
32type Yard = {
33  road: { x0: number; x1: number; y0: number; y1: number }
34  mailbox: Pt
35  pallets: Pt[]
36  drops: Pt[]
37  floor: { x0: number; x1: number; y0: number; y1: number }
38}
39
40const yards = new WeakMap<Layout, Yard>()
41
42export function yardOf(L: Layout): Yard {
43  const known = yards.get(L)
44  if (known) return known
45  const d = frontDoor(L)
46  // out until its near kerb meets the picture's left edge
47  const road = { x0: d.mid - 6, x1: d.mid + 6, y0: L.ph, y1: d.mid + 6 + L.ox }
48  // pallets along the road's far side, the mailbox past them
49  const pallets = Array.from({ length: PALLETS }, (_, i) => ({ x: road.x0 - 2 - PALLET, y: road.y0 + 5 + i * (PALLET + 4) }))
50  const mailbox = { x: road.x0 - 2.5, y: road.y0 + 6 + PALLETS * (PALLET + 4) }
51  const yard = {
52    road,
53    mailbox,
54    pallets,
55    drops: pallets.map(p => ({ x: road.x0 + 2, y: p.y + PALLET / 2 })),
56    floor: { x0: road.x0 + 2, x1: road.x1 - 2, y0: road.y0 + 6, y1: mailbox.y },
57  }
58  yards.set(L, yard)
59  return yard
60}
61
62const quad = (cv: Canvas, L: Layout, x0: number, x1: number, y0: number, y1: number, m: typeof Y.stone) =>
63  cv.poly([at(L, x0, y0), at(L, x1, y0), at(L, x1, y1), at(L, x0, y1)], m)
64
65// The road: slabs in a running bond, kerbed both sides.
66export function road(cv: Canvas, L: Layout): void {
67  const r = yardOf(L).road
68  quad(cv, L, r.x0 - 1, r.x1 + 1, r.y0, r.y1 + 1, Y.kerb)
69  cv.layer()
70  quad(cv, L, r.x0, r.x1, r.y0, r.y1, Y.stone)
71  cv.layer()
72  for (let y = r.y0 + 4, k = 0; y < r.y1; y += 4, k++) {
73    quad(cv, L, r.x0, r.x1, y, y + 0.6, Y.seam)
74    const x = (r.x0 + r.x1) / 2 + (k % 2 ? 3 : -3)
75    quad(cv, L, x, x + 0.6, y + 0.6, Math.min(r.y1, y + 4), Y.seam)
76  }
77  cv.layer()
78}
79
80// A yard box: `tape` colour (else plain); `lands`: seconds until it lands, if in transit.
81export type Parcel = { tape?: string; lands?: number }
82
83// The yard's things with their depth (plan x + y), drawn back to front among the trees; `pile` by fill order.
84export function yardPieces(L: Layout, pile: Map<number, Parcel>): { depth: number; draw: (cv: Canvas) => void }[] {
85  const y = yardOf(L)
86  const d = frontDoor(L)
87  return [
88    // the doormat, over the foot of the house's base
89    { depth: d.mid + y.road.y0, draw: cv => (quad(cv, L, d.x0 + 1.5, d.x1 - 1.5, y.road.y0 + 0.5, y.road.y0 + 3.5, Y.doormat), cv.layer()) },
90    { depth: y.mailbox.x + y.mailbox.y, draw: cv => mailbox(cv, L, pile.size > 0) },
91    ...y.pallets.map((p, i) => ({ depth: p.x + p.y + PALLET, draw: (cv: Canvas) => pallet(cv, L, i, pile) })),
92  ]
93}
94
95// The mailbox, its flag up once anything has been sent.
96function mailbox(cv: Canvas, L: Layout, hasMail: boolean): void {
97  const m = yardOf(L).mailbox
98  box(cv, L, m.x - 0.5, m.y - 0.5, 1, 1, 7, Y.post)
99  cv.layer()
100  box(cv, L, m.x - 1.5, m.y - 2.5, 3, 5, 3, Y.mailbox, 7)
101  cv.layer()
102  const up = hasMail ? 3 : 0
103  cv.poly([at(L, m.x + 1.5, m.y + 1, 8 + up), at(L, m.x + 1.5, m.y + 2.2, 8 + up), at(L, m.x + 1.5, m.y + 2.2, 9 + up), at(L, m.x + 1.5, m.y + 1, 9 + up)], Y.flag)
104  cv.poly([at(L, m.x + 1.5, m.y + 1, 7.5), at(L, m.x + 1.5, m.y + 1.4, 7.5), at(L, m.x + 1.5, m.y + 1.4, 9 + up), at(L, m.x + 1.5, m.y + 1, 9 + up)], Y.flag)
105  cv.layer()
106}
107
108// Box places in fill order: pallets and corners nearest the mailbox first, a layer at a time.
109type Spot = { pallet: number; x: number; y: number; z: number }
110
111const spotsOf = new WeakMap<Layout, Spot[]>()
112
113export function boxSpots(L: Layout): Spot[] {
114  const known = spotsOf.get(L)
115  if (known) return known
116  const { pallets: list, mailbox: m } = yardOf(L)
117  const far = (x: number, y: number) => Math.hypot(x + BOX / 2 - m.x, y + BOX / 2 - m.y)
118  const order = list.map((_, i) => i).sort((a, b) => {
119    const pa = list[a] as Pt
120    const pb = list[b] as Pt
121    return far(pa.x + PALLET / 2, pa.y + PALLET / 2) - far(pb.x + PALLET / 2, pb.y + PALLET / 2)
122  })
123  const spots = order.flatMap(i => {
124    const p = list[i] as Pt
125    const slots = [0, 1, 2, 3].map(k => ({ x: p.x + 0.5 + (k % 2) * STEP, y: p.y + 0.5 + Math.floor(k / 2) * STEP }))
126      .sort((a, b) => far(a.x, a.y) - far(b.x, b.y))
127    return Array.from({ length: LAYERS }, (_, layer) => slots.map(s => ({ pallet: i, ...s, z: 1 + layer * BOX_H }))).flat()
128  })
129  spotsOf.set(L, spots)
130  return spots
131}
132
133// Pallet `i` and its boxes, back to front, bottom to top.
134function pallet(cv: Canvas, L: Layout, i: number, pile: Map<number, Parcel>): void {
135  const p = yardOf(L).pallets[i] as Pt
136  box(cv, L, p.x, p.y, PALLET, PALLET, 1, Y.pallet)
137  cv.layer()
138  const here = boxSpots(L)
139    .flatMap((s, n) => {
140      const parcel = pile.get(n)
141      return s.pallet === i && parcel ? [{ ...s, parcel }] : []
142    })
143    .sort((a, b) => a.z - b.z || a.x + a.y - (b.x + b.y))
144  for (const s of here) {
145    const { lands } = s.parcel
146    const tape = s.parcel.tape ? tapeOf(s.parcel.tape) : Y.tape
147    cv.stamp(`ybox${tape.cls}`, at(L, s.x, s.y, s.z), sub => {
148      box(sub, L, s.x, s.y, BOX, BOX, BOX_H, Y.card, s.z)
149      sub.layer()
150      sub.poly([at(L, s.x + 1.2, s.y, s.z + BOX_H), at(L, s.x + 1.8, s.y, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.2, s.y + BOX, s.z + BOX_H)], tape)
151      sub.poly([at(L, s.x + 1.2, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + BOX_H), at(L, s.x + 1.8, s.y + BOX, s.z + 1.5), at(L, s.x + 1.2, s.y + BOX, s.z + 1.5)], tape)
152    }, undefined, lands === undefined ? '' : ' opacity="0"',
153    lands === undefined ? '' : `<set attributeName="opacity" to="1" begin="${Math.max(0, lands).toFixed(2)}s" fill="freeze"/>`)
154  }
155}
156
157// Tape in a session's colour, dimmer by night.
158const tapeOf = (color: string): Mat => mat(`yt${color.slice(1)}`, shade(color, -0.35), color)
159
160// The road, pallet and mailbox footprints on the picture.
161export function yardShapes(L: Layout): { road: Pt[]; pallets: Pt[][]; mailbox: Pt } {
162  const y = yardOf(L)
163  const corners = (x0: number, x1: number, y0: number, y1: number) => [at(L, x0, y0), at(L, x1, y0), at(L, x1, y1), at(L, x0, y1)]
164  return {
165    road: corners(y.road.x0 - 1, y.road.x1 + 1, y.road.y0, y.road.y1 + 1),
166    pallets: y.pallets.map(p => corners(p.x, p.x + PALLET, p.y, p.y + PALLET)),
167    mailbox: at(L, y.mailbox.x, y.mailbox.y),
168  }
169}
170
171// Top left of the day's box count, across the road from the mailbox.
172export function signAt(L: Layout): Pt {
173  const y = yardOf(L)
174  return at(L, y.road.x1 + 3, y.mailbox.y - 4)
175}
176
hooks/furniture.ts 239 lines
1/*
2 * Each room's furniture, built of boxes on the plan: desks with monitors along the back of every work
3 * room, then each room's own pieces. Flames, screens and steam move.
4 */
5import type { Room } from '../types'
6import { box, faceX, faceY, at } from './house'
7import type { Cell, Layout } from './house'
8import { faces, loopBegin, mat, pathOf, warm, warmFaces } from './iso'
9import type { Canvas, Mat, Pt } from './iso'
10
11const F = {
12  woodDark: warmFaces('wd', '#9a6740'),
13  wood: warmFaces('wm', '#b8834f'),
14  pine: warmFaces('wp', '#d6b07a'),
15  white: warmFaces('wh', '#f4f2ec'),
16  stone: warmFaces('st', '#c9cbcf'),
17  dark: faces('dk', '#1c2024', '#3a4047'),
18  brick: warmFaces('br', '#b5644f'),
19  brass: warmFaces('bs', '#d8b25a'),
20  leaf: warmFaces('lf', '#5fae62'),
21  pot: warmFaces('pt', '#c4714e'),
22  linen: warmFaces('ln', '#fbf9f4'),
23  blue: warmFaces('bb', '#6f8fd6'),
24  green: warmFaces('bg', '#79b89a'),
25  couch: warmFaces('cu', '#c77fae'),
26  shade: warmFaces('ls', '#f5dca0'),
27  red: warmFaces('rd', '#d9584c'),
28}
29const RUG = {
30  library: [warm('rl', '#b85c5c'), warm('rm', '#d07a7a')],
31  lounge: [warm('rq', '#d9a9c9'), warm('rr', '#e8bfd8')],
32} as const
33const BOOKS = [
34  warm('b1', '#c75a5a'), warm('b2', '#5a86c7'), warm('b3', '#c7ad5a'),
35  warm('b4', '#5ac79a'), warm('b5', '#9a5ac7'), warm('b6', '#c7865a'),
36]
37const HOLE = mat('ho', '#0d0b0a', '#3a2a24')
38const DESK_BODY: Record<Room, typeof F.wood> = {
39  library: F.woodDark, study: F.wood, sunroom: F.pine, kitchen: F.white, workshop: F.pine, lounge: F.wood,
40}
41
42// Desk i's back corner on the plan.
43export const deskAt = (c: Cell, i: number): { x: number; y: number } => ({ x: c.x + 2 + 14 * i, y: c.y + 1 })
44
45// A desk and monitor on the back wall; returns where the screen (drawn separately) and light go.
46export function desk(cv: Canvas, L: Layout, room: Room, c: Cell, i: number): { screen: Pt; light: Pt } {
47  const d = deskAt(c, i)
48  const body = DESK_BODY[room]
49  cv.stamp(`desk-${body.top.cls}${room === 'kitchen' ? 's' : ''}`, at(L, d.x, d.y), sub => {
50    box(sub, L, d.x, d.y, 12, 6, 5, body)
51    box(sub, L, d.x - 0.5, d.y, 13, 6.5, 1, room === 'kitchen' ? F.stone : body, 5)
52    sub.layer()
53    // the monitor on its stand
54    box(sub, L, d.x + 5.5, d.y + 1.5, 1, 1, 2, F.dark, 6)
55    box(sub, L, d.x + 2, d.y + 1, 8, 1, 6, F.dark, 8)
56  })
57  return { screen: at(L, d.x + 2.5, d.y + 2, 13), light: at(L, d.x + 10.5, d.y + 5.5, 6) }
58}
59
60function plant(cv: Canvas, L: Layout, x: number, y: number, tall: boolean): void {
61  cv.stamp(tall ? 'plant-t' : 'plant', at(L, x, y), sub => {
62    box(sub, L, x, y, 3, 3, 3, F.pot)
63    sub.layer()
64    box(sub, L, x - 0.5, y - 0.5, 4, 4, tall ? 5 : 3, F.leaf, 3)
65    sub.layer()
66    box(sub, L, x + 0.5, y + 0.5, 2, 2, tall ? 3 : 2, F.leaf, tall ? 8 : 6)
67  })
68}
69
70function rug(cv: Canvas, L: Layout, x: number, y: number, w: number, d: number, m: readonly [Mat, Mat]): void {
71  cv.poly([at(L, x, y), at(L, x + w, y), at(L, x + w, y + d), at(L, x, y + d)], m[1])
72  cv.poly([at(L, x + 1.5, y + 1.5), at(L, x + w - 1.5, y + 1.5), at(L, x + w - 1.5, y + d - 1.5), at(L, x + 1.5, y + d - 1.5)], m[0])
73  cv.layer()
74}
75
76// A pixel shape with its own animation, as a flame or a screen.
77function lit(pts: Pt[], fill: string, anim: string): string {
78  return `<path fill="${fill}" d="${pathOf(pts)}">${anim}</path>`
79}
80
81// Steam rising from a point, wisp after wisp.
82function steam(p: Pt): string {
83  return [0, 1].map(i => `<rect x="${Math.round(p.x) + i}" y="${Math.round(p.y)}" width="1" height="1" fill="#e8e8f0" opacity="0">` +
84    `<animate attributeName="y" values="${Math.round(p.y)};${Math.round(p.y) - 6}" dur="2.4s" ${loopBegin(2.4, i * 1.2)} repeatCount="indefinite"/>` +
85    `<animate attributeName="opacity" values="0;0.6;0" dur="2.4s" ${loopBegin(2.4, i * 1.2)} repeatCount="indefinite"/></rect>`).join('')
86}
87
88function library(cv: Canvas, L: Layout, c: Cell): void {
89  rug(cv, L, c.x + 12, c.y + 15, 22, 18, RUG.library)
90  for (let k = 0; k < 2; k++) {
91    const y = c.y + 9 + 9 * k
92    cv.stamp('shelf', at(L, c.x, y), sub => {
93      box(sub, L, c.x, y, 5, 8, 18, F.woodDark)
94      sub.layer()
95      for (const z of [2, 8, 14]) {
96        for (let j = 0; j < 4; j++) {
97          const tall = 3 + ((j * 3 + z) % 2)
98          faceX(sub, L, c.x + 5, y + 0.5 + 2 * j, y + 2 + 2 * j, z, z + tall, BOOKS[(j * 2 + z) % BOOKS.length] as Mat)
99        }
100      }
101    })
102  }
103  // reading lamp by the window
104  box(cv, L, c.x + 33, c.y + 3, 1, 1, 12, F.brass)
105  box(cv, L, c.x + 31.5, c.y + 1.5, 4, 4, 3, F.shade, 12)
106  cv.layer()
107}
108
109function study(cv: Canvas, L: Layout, c: Cell): void {
110  // fireplace and chimney breast on the back wall
111  box(cv, L, c.x + 30, c.y, 8, 2, 25, F.brick)
112  box(cv, L, c.x + 29, c.y, 10, 4, 11, F.brick)
113  box(cv, L, c.x + 28.5, c.y, 11, 5, 1, F.woodDark, 11)
114  faceY(cv, L, c.y + 4, c.x + 31, c.x + 37, 1, 8, HOLE)
115  cv.layer()
116  const fire = lit([at(L, c.x + 32, c.y + 4, 1), at(L, c.x + 36, c.y + 4, 1), at(L, c.x + 35, c.y + 4, 5), at(L, c.x + 34, c.y + 4, 7), at(L, c.x + 33, c.y + 4, 4)], '#f5a623',
117    `<animate attributeName="fill" values="#f5a623;#ffd166;#f26b3a;#f5a623" dur="0.9s" ${loopBegin(0.9)} repeatCount="indefinite"/>`)
118  const embers = lit([at(L, c.x + 33, c.y + 4, 1), at(L, c.x + 35, c.y + 4, 1), at(L, c.x + 34.5, c.y + 4, 3), at(L, c.x + 33.5, c.y + 4, 3)], '#fff1b0',
119    `<animate attributeName="opacity" values="1;0.4;0.8;1" dur="0.6s" ${loopBegin(0.6)} repeatCount="indefinite"/>`)
120  cv.raw(fire + embers)
121  // floor lamp and armchair turned to the fire
122  plant(cv, L, c.x + 2, c.y + 9, true)
123  box(cv, L, c.x + 1, c.y + 26, 1, 1, 11, F.brass)
124  box(cv, L, c.x - 0.5, c.y + 24.5, 4, 4, 3, F.shade, 11)
125  cv.layer()
126  box(cv, L, c.x + 1, c.y + 16, 2, 7, 7, F.green)
127  cv.layer()
128  box(cv, L, c.x + 3, c.y + 16, 5, 7, 3, F.green)
129  cv.layer()
130  box(cv, L, c.x + 3, c.y + 16, 5, 1.5, 5, F.green)
131  cv.layer()
132  box(cv, L, c.x + 3, c.y + 21.5, 5, 1.5, 5, F.green)
133  cv.layer()
134}
135
136function sunroom(cv: Canvas, L: Layout, c: Cell): string {
137  // telescope on a tripod, aimed at the window
138  const tx = c.x + 6
139  const ty = c.y + 14
140  box(cv, L, tx - 2, ty, 1, 1, 6, F.woodDark)
141  box(cv, L, tx + 1, ty, 1, 1, 6, F.woodDark)
142  box(cv, L, tx, ty + 2, 1, 1, 6, F.woodDark)
143  cv.layer()
144  cv.poly([at(L, tx - 1, ty + 2, 8), at(L, tx + 4, ty - 3, 13), at(L, tx + 4, ty - 3, 11), at(L, tx - 1, ty + 2, 6)], F.brass.left)
145  cv.layer()
146  // radio on a side table
147  box(cv, L, c.x + 1, c.y + 20, 5, 5, 5, F.wood)
148  box(cv, L, c.x + 1.5, c.y + 21, 4, 3, 3, F.dark, 5)
149  cv.layer()
150  const light = at(L, c.x + 5.5, c.y + 23, 7)
151  plant(cv, L, c.x + 34, c.y + 3, true)
152  return `<rect x="${Math.round(light.x)}" y="${Math.round(light.y)}" width="1" height="1" fill="#4cf08c"><animate attributeName="opacity" values="1;0.2;1" dur="1.4s" ${loopBegin(1.4)} repeatCount="indefinite"/></rect>`
153}
154
155function kitchen(cv: Canvas, L: Layout, c: Cell): string {
156  box(cv, L, c.x, c.y + 8, 6, 7, 21, F.white)
157  faceX(cv, L, c.x + 6, c.y + 13.5, c.y + 14.2, 9, 15, F.stone.right)
158  faceX(cv, L, c.x + 6, c.y + 8, c.y + 15, 17, 17.6, F.stone.right)
159  cv.layer()
160  box(cv, L, c.x, c.y + 15, 6, 12, 7, F.white)
161  box(cv, L, c.x - 0.5, c.y + 15, 7, 12.5, 1, F.stone, 7)
162  cv.layer()
163  // burners, a pot and a coffee machine
164  for (const y of [c.y + 16.5, c.y + 20]) cv.poly([at(L, c.x + 1, y, 8), at(L, c.x + 4, y, 8), at(L, c.x + 4, y + 2.5, 8), at(L, c.x + 1, y + 2.5, 8)], HOLE)
165  cv.layer()
166  box(cv, L, c.x + 1, c.y + 16.5, 3, 2.5, 2, F.red, 8)
167  box(cv, L, c.x + 1, c.y + 23, 3, 3, 4, F.dark, 8)
168  cv.layer()
169  const pot = at(L, c.x + 2.5, c.y + 17.5, 10)
170  const cup = at(L, c.x + 2.5, c.y + 24, 12)
171  return steam(pot) + `<rect x="${Math.round(cup.x)}" y="${Math.round(cup.y)}" width="1" height="1" fill="#f2503f"><animate attributeName="opacity" values="1;0.3;1" dur="2s" ${loopBegin(2)} repeatCount="indefinite"/></rect>`
172}
173
174function workshop(cv: Canvas, L: Layout, c: Cell): void {
175  box(cv, L, c.x, c.y + 9, 7, 15, 6, F.pine)
176  box(cv, L, c.x - 0.5, c.y + 9, 8, 15.5, 1, F.woodDark, 6)
177  cv.layer()
178  box(cv, L, c.x + 1, c.y + 11, 3, 4, 2, F.red, 7)
179  box(cv, L, c.x + 1.5, c.y + 18, 2, 2, 3, F.dark, 7)
180  box(cv, L, c.x + 4, c.y + 20, 2, 3, 1, F.brass, 7)
181  cv.layer()
182  plant(cv, L, c.x + 35, c.y + 35, false)
183}
184
185function lounge(cv: Canvas, L: Layout, c: Cell): void {
186  rug(cv, L, c.x + 15, c.y + 21, 22, 15, RUG.lounge)
187  for (let k = 0; k < 2; k++) {
188    const y = c.y + 1 + 11 * k
189    box(cv, L, c.x, y, 1, 9, 7, F.woodDark)
190    cv.layer()
191    box(cv, L, c.x + 1, y, 14, 9, 3, F.woodDark)
192    box(cv, L, c.x + 1.5, y + 0.5, 13, 8, 2, F.linen, 3)
193    box(cv, L, c.x + 5, y + 0.5, 9.5, 8, 1, k === 0 ? F.blue : F.green, 5)
194    cv.layer()
195    box(cv, L, c.x + 1.8, y + 1.5, 2.6, 6, 1, F.linen, 5)
196    cv.layer()
197  }
198  // TV on its stand, couch facing it
199  box(cv, L, c.x + 18, c.y, 10, 4, 4, F.wood)
200  box(cv, L, c.x + 19, c.y + 1, 8, 1, 7, F.dark, 4)
201  cv.layer()
202  const tv = lit([at(L, c.x + 19.5, c.y + 2, 10.5), at(L, c.x + 26.5, c.y + 2, 10.5), at(L, c.x + 26.5, c.y + 2, 5.5), at(L, c.x + 19.5, c.y + 2, 5.5)], '#5ad0ff',
203    `<animate attributeName="fill" values="#5ad0ff;#ff6ad5;#ffd34a;#7df08a" calcMode="discrete" dur="3.2s" ${loopBegin(3.2)} repeatCount="indefinite"/>`)
204  cv.raw(tv)
205  box(cv, L, c.x + 18, c.y + 13, 11, 4, 3, F.couch)
206  cv.layer()
207  box(cv, L, c.x + 18, c.y + 16, 11, 2, 6, F.couch)
208  cv.layer()
209  box(cv, L, c.x + 17, c.y + 13, 1.5, 5, 5, F.couch)
210  box(cv, L, c.x + 28.5, c.y + 13, 1.5, 5, 5, F.couch)
211  cv.layer()
212  plant(cv, L, c.x + 35, c.y + 35, true)
213}
214
215// A room's own furniture (not desks); returns animated bits to lay over it.
216export function furniture(cv: Canvas, L: Layout, room: Room, c: Cell): string {
217  switch (room) {
218    case 'library': library(cv, L, c); return ''
219    case 'study': study(cv, L, c); return ''
220    case 'sunroom': return sunroom(cv, L, c)
221    case 'kitchen': return kitchen(cv, L, c)
222    case 'workshop': workshop(cv, L, c); return ''
223    case 'lounge': lounge(cv, L, c); return ''
224  }
225}
226
227// Each work room's machine, which flashes as a tool call returns: its place and size.
228export function machine(L: Layout, room: Room, c: Cell): { at: Pt; w: number; h: number } | undefined {
229  switch (room) {
230    case 'library': return { at: at(L, c.x + 2.5, c.y + 17, 9), w: 14, h: 20 }
231    case 'study': return { at: at(L, c.x + 1.5, c.y + 26.5, 13), w: 8, h: 6 }
232    case 'sunroom': return { at: at(L, c.x + 3.5, c.y + 22.5, 6), w: 8, h: 6 }
233    case 'kitchen': return { at: at(L, c.x + 3, c.y + 21, 8), w: 10, h: 8 }
234    case 'workshop': return { at: at(L, c.x + 3.5, c.y + 16.5, 7), w: 12, h: 8 }
235    default: return undefined
236  }
237}
238
239
hooks/garden.ts 535 lines
1/*
2 * The garden: an autumn lawn round the house with trees, ponds, beds, stones and toadstools. Crowns sway,
3 * leaves fall, fireflies wander by night. Drawn on the picture, not the floor plan, as its own picture
4 * under the house's.
5 */
6import { TALL_WALL } from './house'
7import type { Layout } from './house'
8import { Canvas, loopBegin, mat, rng } from './iso'
9import type { Mat, Pt } from './iso'
10import { signAt, yardShapes } from './yard'
11
12const G = {
13  grass: mat('na', '#2f3b27', '#9aa24c'),
14  lit: mat('nl', '#35432c', '#a7ae56'),
15  shade: mat('nh', '#29341f', '#8c9443'),
16  speck: mat('ns', '#3a4730', '#b3b35a'),
17  dry: mat('nd', '#4a4228', '#c9a85a'),
18  dark: mat('nk', '#232c1d', '#6f7a34'),
19  blade: mat('nb', '#4e5d36', '#c2c76a'),
20  tip: mat('nt', '#6b6a3a', '#e6d27a'),
21  trunk: mat('tk', '#33241a', '#6b4429'),
22  bark: mat('tl', '#24190f', '#4e301c'),
23  birch: mat('tb', '#8d877b', '#e9e3d6'),
24  cap: mat('mr', '#8f2a22', '#d93a2c'),
25  water: mat('wa', '#16202e', '#5d8fb0'),
26  ripple: mat('wr', '#2a3a52', '#9cc6e0'),
27  post: mat('lp', '#1c1f24', '#3a3f47'),
28  lamp: mat('ll', '#ffd27a', '#e8dcc0'),
29  stone: mat('sn', '#3d4046', '#a9aeb5'),
30  stoneLit: mat('sl', '#585c63', '#cfd3d8'),
31  aster: mat('fa', '#6a4a9a', '#a77fe0'),
32  mum: mat('fm', '#a8822a', '#ffd166'),
33  rose: mat('fr', '#8f2a3a', '#e0566e'),
34}
35
36type Kind = 'oak' | 'maple' | 'birch' | 'pine' | 'bush'
37
38// Crown colours: shadow, body, light, and a fleck.
39const CROWNS: Record<Exclude<Kind, 'pine'>, [Mat, Mat, Mat, Mat]> = {
40  oak: [mat('c1', '#5a2416', '#8e3a1e'), mat('c2', '#a24a1c', '#e2702a'), mat('c3', '#c9762c', '#ffa64a'), mat('c4', '#d9a23a', '#ffd166')],
41  maple: [mat('m1', '#4a1716', '#7e2320'), mat('m2', '#8c2620', '#cf3a2a'), mat('m3', '#b4432c', '#f0663e'), mat('m4', '#d0782e', '#ff9b45')],
42  birch: [mat('y1', '#5e4a1a', '#a37a22'), mat('y2', '#a8822a', '#e8b23a'), mat('y3', '#c9a43c', '#ffd35a'), mat('y4', '#e0c060', '#fff0a0')],
43  bush: [mat('h1', '#262d1c', '#4f5a2a'), mat('h2', '#3f4a24', '#7f8f3a'), mat('h3', '#9a4f1c', '#e07a2c'), mat('h4', '#c9a23a', '#ffd166')],
44}
45const PINE = [mat('p1', '#142219', '#1f3a28'), mat('p2', '#1d3324', '#2e5a3a'), mat('p3', '#284a31', '#3f7a4c')]
46const LEAF = [mat('l1', '#b5541f', '#e8762a'), mat('l2', '#8f2a22', '#d23c2c'), mat('l3', '#b08a2a', '#f0b43a'), mat('l4', '#7a3a1a', '#b85a26')]
47
48// Per kind: trunk height, crown half-sizes, lean in the wind and its beat.
49const BUILD: Record<Kind, { trunk: number; rx: number; ry: number; lean: number; beat: number }> = {
50  oak: { trunk: 12, rx: 14, ry: 10, lean: 2.4, beat: 4.6 },
51  maple: { trunk: 11, rx: 11, ry: 12, lean: 2.4, beat: 4.1 },
52  birch: { trunk: 15, rx: 8, ry: 12, lean: 3, beat: 3.6 },
53  pine: { trunk: 5, rx: 11, ry: 16, lean: 1.2, beat: 5.2 },
54  bush: { trunk: 0, rx: 7, ry: 4.5, lean: 2, beat: 3.3 },
55}
56
57// A rounded blob: an octagon about `c`.
58function blob(cv: Canvas, c: Pt, rx: number, ry: number, m: Mat | string): void {
59  const a = rx * 0.45
60  const b = ry * 0.45
61  cv.poly([
62    { x: c.x - a, y: c.y - ry }, { x: c.x + a, y: c.y - ry }, { x: c.x + rx, y: c.y - b }, { x: c.x + rx, y: c.y + b },
63    { x: c.x + a, y: c.y + ry }, { x: c.x - a, y: c.y + ry }, { x: c.x - rx, y: c.y + b }, { x: c.x - rx, y: c.y - b },
64  ], m)
65}
66
67// The house floor's four corners on the picture.
68function corners(L: Layout) {
69  return {
70    top: { x: L.ox, y: L.oy },
71    left: { x: L.ox - L.ph, y: L.oy + L.ph / 2 },
72    right: { x: L.ox + L.pw, y: L.oy + L.pw / 2 },
73    bottom: { x: L.ox + L.pw - L.ph, y: L.oy + (L.pw + L.ph) / 2 },
74  }
75}
76
77const lerp = (a: Pt, b: Pt, u: number, dy = 0): Pt => ({ x: Math.round(a.x + (b.x - a.x) * u), y: Math.round(a.y + (b.y - a.y) * u + dy) })
78
79// ---------- the lawn ----------
80
81const pondAt = (L: Layout) => lerp(corners(L).bottom, corners(L).right, 0.42, 18)
82const lampsAt = (L: Layout) => [lerp(corners(L).left, corners(L).bottom, 0.62, 9), lerp(corners(L).bottom, corners(L).right, 0.45, 9)]
83const toadstoolsAt = (L: Layout) => [lerp(corners(L).left, corners(L).bottom, 0.5, 26), lerp(corners(L).bottom, corners(L).right, 0.2, 24), lerp(corners(L).left, corners(L).top, 0.24, -30)]
84
85// How far a point lies outside a convex outline: 0 inside.
86function outside(poly: Pt[], p: Pt): number {
87  let pos = false
88  let neg = false
89  let best = Infinity
90  poly.forEach((a, i) => {
91    const b = poly[(i + 1) % poly.length] as Pt
92    const ex = b.x - a.x
93    const ey = b.y - a.y
94    const cross = ex * (p.y - a.y) - ey * (p.x - a.x)
95    if (cross > 0) pos = true
96    if (cross < 0) neg = true
97    const t = Math.max(0, Math.min(1, ((p.x - a.x) * ex + (p.y - a.y) * ey) / (ex * ex + ey * ey || 1)))
98    best = Math.min(best, Math.hypot(p.x - a.x - t * ex, p.y - a.y - t * ey))
99  })
100  return pos && neg ? best : 0
101}
102
103// A clump of longer grass, `n` blades about (x, y).
104function blades(cv: Canvas, r: () => number, x: number, y: number, n: number): void {
105  for (let k = 0; k < n; k++) {
106    const tall = 2 + Math.floor(r() * 4)
107    const bx = Math.round(x + (k - n / 2) * 1.2 + r())
108    cv.rect(bx, Math.round(y) - tall, 1, tall, k % 3 === 0 ? G.dark : G.blade)
109    if (r() < 0.4) cv.rect(bx, Math.round(y) - tall, 1, 1, G.tip)
110  }
111}
112
113// A pond about `at`, with a ripple and a leaf or two.
114type Pond = { at: Pt; rx: number; ry: number }
115
116function pond(cv: Canvas, { at: p, rx, ry }: Pond): void {
117  const k = (n: number) => Math.round((n * rx) / 12)
118  blob(cv, { x: p.x + 1, y: p.y + 1 }, rx + 1, ry + 0.5, G.dark)
119  blob(cv, p, rx, ry, G.water)
120  cv.layer()
121  cv.rect(p.x - k(6), p.y - k(2), Math.max(2, k(5)), 1, G.ripple)
122  cv.rect(p.x - k(2), p.y, 2, 1, LEAF[0] as Mat)
123  if (rx < 9) return
124  cv.rect(p.x + 3, p.y + 1, 4, 1, G.ripple)
125  cv.rect(p.x + 6, p.y - 2, 1, 1, LEAF[2] as Mat)
126}
127
128function toadstool(cv: Canvas, p: Pt): void {
129  cv.rect(p.x, p.y - 1, 1, 1, G.birch)
130  cv.rect(p.x - 1, p.y - 2, 3, 1, G.cap)
131}
132
133// Lawn patterns, tiles of different sizes so repeats never line up; patches span the picture's width,
134// wrapped at the edges.
135const PATCHES = { w: 248, h: 173 }
136// drawn once: the same for every picture
137let patchTile: Canvas | undefined
138
139function patchesTile(): Canvas {
140  if (patchTile) return patchTile
141  const patches = new Canvas()
142  const lawn = rng(7)
143  for (let n = Math.round((PATCHES.w * PATCHES.h) / 2600); n > 0; n--) {
144    const p = { x: lawn() * PATCHES.w, y: lawn() * PATCHES.h }
145    const m = lawn() < 0.5 ? G.lit : G.shade
146    for (let k = 1 + Math.floor(lawn() * 3); k > 0; k--) {
147      const q = { x: Math.round(p.x + (lawn() - 0.5) * 20), y: Math.round(p.y + (lawn() - 0.5) * 8) }
148      const [rx, ry] = [7 + lawn() * 10, 3 + lawn() * 4]
149      for (const dx of [0, -PATCHES.w, PATCHES.w]) {
150        for (const dy of [0, -PATCHES.h, PATCHES.h]) {
151          const c = { x: q.x + dx, y: q.y + dy }
152          if (c.x + rx >= 0 && c.x - rx <= PATCHES.w && c.y + ry >= 0 && c.y - ry <= PATCHES.h) blob(patches, c, rx, ry, m)
153        }
154      }
155    }
156  }
157  patchTile = patches
158  return patches
159}
160
161export function gardenPatterns(cv: Canvas): string {
162  const use = (m: Mat) => {
163    cv.used.set(m.cls, m)
164    return `class="${m.cls}"`
165  }
166  const patches = patchesTile()
167  for (const [k, m] of patches.used) cv.used.set(k, m)
168  const tile = (id: string, w: number, h: number, body: string) => `<pattern id="${id}" width="${w}" height="${h}" patternUnits="userSpaceOnUse">${body}</pattern>`
169  const dots = (m: Mat, d: string) => `<path ${use(m)} d="${d}"/>`
170  return tile('grass', 16, 12, `<rect width="16" height="12" ${use(G.grass)}/>` +
171      dots(G.speck, 'M1 1h1v1h-1zM6 3h1v1h-1zM11 0h1v1h-1zM14 5h1v1h-1zM3 7h1v1h-1zM9 8h2v1h-2zM13 10h1v1h-1zM5 11h1v1h-1zM0 4h1v1h-1z') +
172      dots(G.dark, 'M4 2h1v1h-1zM12 3h1v1h-1zM7 6h1v1h-1zM2 9h1v1h-1zM15 8h1v1h-1zM10 11h1v1h-1z') +
173      dots(G.dry, 'M8 1h1v1h-1zM1 6h1v1h-1zM12 7h1v1h-1z') +
174      dots(G.blade, 'M3 4h1v2h-1zM10 5h1v2h-1zM6 9h1v2h-1zM14 1h1v2h-1z')) +
175    tile('tufts', 29, 23,
176      dots(G.blade, 'M2 3h1v3h-1zM4 4h1v2h-1zM17 9h1v3h-1zM19 10h1v2h-1zM9 16h1v3h-1zM11 17h1v2h-1zM24 19h1v3h-1zM26 20h1v2h-1z') +
177      dots(G.tip, 'M2 3h1v1h-1zM17 9h1v1h-1zM9 16h1v1h-1zM24 19h1v1h-1z') +
178      dots(G.dark, 'M3 6h1v1h-1zM18 12h1v1h-1zM10 19h1v1h-1zM25 22h1v1h-1z')) +
179    tile('leaves', 43, 37,
180      dots(LEAF[0] as Mat, 'M5 7h2v1h-2zM30 22h1v1h-1z') + dots(LEAF[1] as Mat, 'M18 3h1v1h-1zM37 30h2v1h-2z') +
181      dots(LEAF[2] as Mat, 'M12 26h2v1h-2zM40 12h1v1h-1z') + dots(LEAF[3] as Mat, 'M24 15h1v1h-1zM2 33h2v1h-2z')) +
182    tile('patches', PATCHES.w, PATCHES.h, patches.svg()) +
183    tile('flowers', 61, 47,
184      dots(G.aster, 'M7 9h1v1h-1zM9 10h1v1h-1zM44 33h1v1h-1z') + dots(G.mum, 'M29 20h1v1h-1zM31 19h1v1h-1zM52 6h1v1h-1z') +
185      dots(G.rose, 'M17 38h1v1h-1zM19 39h1v1h-1zM38 44h1v1h-1z') +
186      dots(G.dark, 'M7 10h1v1h-1zM9 11h1v1h-1zM44 34h1v1h-1zM29 21h1v1h-1zM31 20h1v1h-1zM52 7h1v1h-1zM17 39h1v1h-1zM19 40h1v1h-1zM38 45h1v1h-1z'))
187}
188
189// A kind's crown about (0, 0), the trunk top; trees of a kind sway on one beat, as in one gust.
190function crown(cv: Canvas, kind: Kind): string {
191  const { rx, ry, lean, beat } = BUILD[kind]
192  const c = { x: 0, y: -ry + (kind === 'bush' ? 1 : 3) }
193  if (kind === 'pine') {
194    const [s, b, l] = PINE as [Mat, Mat, Mat]
195    for (let k = 0; k < 4; k++) {
196      const y = c.y + ry - 2 - k * 8
197      const w = rx - k * 2.5
198      cv.poly([{ x: c.x, y: y - 11 }, { x: c.x + w, y: y + 1 }, { x: c.x - w, y: y + 1 }], s)
199      cv.poly([{ x: c.x - 0.5, y: y - 11 }, { x: c.x + w - 3, y: y - 1 }, { x: c.x - w + 1, y: y }], k % 2 ? l : b)
200      cv.layer()
201    }
202  } else {
203    const [s, b, l, f] = CROWNS[kind]
204    blob(cv, { x: c.x + 1, y: c.y + 1 }, rx, ry, s)
205    cv.layer()
206    blob(cv, c, rx - 1, ry - 1, b)
207    blob(cv, { x: c.x - rx * 0.45, y: c.y - ry * 0.25 }, rx * 0.55, ry * 0.55, b)
208    cv.layer()
209    blob(cv, { x: c.x - rx * 0.3, y: c.y - ry * 0.45 }, rx * 0.45, ry * 0.4, l)
210    blob(cv, { x: c.x + rx * 0.35, y: c.y - ry * 0.2 }, rx * 0.35, ry * 0.35, l)
211    cv.layer()
212    const r = rng([...kind].reduce((h, ch) => (Math.imul(h, 31) + ch.charCodeAt(0)) | 0, 7))
213    for (let i = 0; i < (kind === 'bush' ? 4 : 9); i++) {
214      cv.rect(Math.round(c.x - rx * 0.7 + r() * rx * 1.4), Math.round(c.y - ry * 0.7 + r() * ry * 1.3), 1, 1, f)
215    }
216  }
217  return `<animateTransform attributeName="transform" type="skewX" values="0;${lean};${(lean * 0.4).toFixed(1)};${(-lean * 0.5).toFixed(1)};0" dur="${beat}s" ${loopBegin(beat)} repeatCount="indefinite"/>`
218}
219
220// The house's trees: `u` along the edge from floor corner `from` to `to`, `dy` above it (behind) or below.
221type Spot = { kind: Kind; from: 'top' | 'left' | 'right' | 'bottom'; to: 'top' | 'left' | 'right' | 'bottom'; u: number; dy: number }
222const TREES: Spot[] = [
223  // behind the house, over the back walls
224  { kind: 'pine', from: 'left', to: 'top', u: 0.14, dy: -22 },
225  { kind: 'oak', from: 'left', to: 'top', u: 0.36, dy: -26 },
226  { kind: 'maple', from: 'left', to: 'top', u: 0.58, dy: -24 },
227  { kind: 'birch', from: 'top', to: 'right', u: 0.4, dy: -24 },
228  { kind: 'pine', from: 'top', to: 'right', u: 0.6, dy: -26 },
229  { kind: 'oak', from: 'top', to: 'right', u: 0.8, dy: -22 },
230  { kind: 'maple', from: 'top', to: 'right', u: 1.03, dy: -12 },
231  // in front, in the two gaps below the front edges
232  { kind: 'maple', from: 'left', to: 'bottom', u: 0.03, dy: 26 },
233  { kind: 'birch', from: 'left', to: 'bottom', u: 0.7, dy: 30 },
234  { kind: 'bush', from: 'left', to: 'bottom', u: 0.56, dy: 4 },
235  { kind: 'bush', from: 'left', to: 'bottom', u: 0.66, dy: 14 },
236  { kind: 'bush', from: 'bottom', to: 'right', u: 0.14, dy: 12 },
237  { kind: 'bush', from: 'bottom', to: 'right', u: 0.72, dy: 20 },
238  { kind: 'pine', from: 'bottom', to: 'right', u: 0.8, dy: 38 },
239]
240
241// Two lamp posts at the front, lit by night.
242export function lamps(cv: Canvas, L: Layout): string {
243  let glow = ''
244  for (const p of lampsAt(L)) {
245    cv.rect(p.x, p.y - 12, 1, 12, G.post)
246    cv.rect(p.x - 1, p.y - 15, 3, 3, G.post)
247    cv.rect(p.x, p.y - 14, 1, 1, G.lamp)
248    glow += `<ellipse class="vh-lamp" cx="${p.x}" cy="${p.y - 13}" rx="7" ry="5" fill="#ffcf7a" opacity="0.18"/>` +
249      `<ellipse class="vh-lamp" cx="${p.x}" cy="${p.y}" rx="9" ry="3" fill="#ffcf7a" opacity="0.1"/>`
250  }
251  cv.layer()
252  return glow
253}
254
255function place(L: Layout, t: Spot): Pt {
256  const c = corners(L)
257  return lerp(c[t.from], c[t.to], t.u, t.dy)
258}
259
260// One of the house's trees, in the house's picture.
261function tree(cv: Canvas, L: Layout, t: Spot): void {
262  treeAt(cv, t.kind, place(L, t), 'vh-h')
263}
264
265// A tree at `at`, one element: a copy of its kind. `ns` keeps definitions apart where pictures are
266// laid into one.
267function treeAt(cv: Canvas, kind: Kind, at: Pt, ns: string): void {
268  const b = BUILD[kind]
269  cv.define(`${ns}-crown-${kind}`, sub => crown(sub, kind))
270  cv.define(`${ns}-tree-${kind}`, sub => {
271    if (b.trunk > 0) {
272      sub.rect(-1, -b.trunk, 1, b.trunk, kind === 'birch' ? G.birch : G.trunk)
273      sub.rect(0, -b.trunk, 1, b.trunk, kind === 'birch' ? G.trunk : G.bark)
274    }
275    sub.raw(`<use href="#${ns}-crown-${kind}" y="${-b.trunk}"/>`)
276  })
277  cv.raw(`<use href="#${ns}-tree-${kind}" x="${at.x}" y="${at.y}"/>`)
278}
279
280export function treesBehind(cv: Canvas, L: Layout): void {
281  for (const t of TREES) if (t.dy < 0) tree(cv, L, t)
282}
283
284// Front trees with their depth (the plan's x + y at the trunk), to sort among the yard's things.
285export function frontTrees(L: Layout): { depth: number; draw: (cv: Canvas) => void }[] {
286  return TREES.filter(t => t.dy >= 0).map(t => ({ depth: 2 * (place(L, t).y - L.oy), draw: (cv: Canvas) => tree(cv, L, t) }))
287}
288
289// Whether a point lies over the house, back walls included, with a margin.
290const hulls = new WeakMap<Layout, Pt[]>()
291function hullOf(L: Layout): Pt[] {
292  const known = hulls.get(L)
293  if (known) return known
294  const c = corners(L)
295  const up = (q: Pt) => ({ x: q.x, y: q.y - TALL_WALL })
296  const hull = [up(c.left), up(c.top), up(c.right), c.right, c.bottom, c.left]
297  hulls.set(L, hull)
298  return hull
299}
300
301function overHouse(L: Layout, p: Pt): boolean {
302  const hull = hullOf(L)
303  return hull.every((a, i) => {
304    const b = hull[(i + 1) % hull.length] as Pt
305    return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) > -3 * Math.hypot(b.x - a.x, b.y - a.y)
306  })
307}
308
309// Falling leaves: under the house where they fall behind it, else over all; a front tree's leaf that
310// would cross the house tries again.
311export function weather(cv: Canvas, L: Layout): { behind: string; front: string } {
312  const r = rng(11)
313  const out = { behind: '', front: '' }
314  const from = TREES.filter(t => t.kind !== 'pine' && t.kind !== 'bush')
315  for (let i = 0; i < 7; i++) {
316    const t = from[i % from.length] as Spot
317    const b = BUILD[t.kind]
318    const top = place(L, t)
319    let path: Pt[] = []
320    let isBehind = false
321    for (let tries = 0; tries < 6 && path.length === 0; tries++) {
322      const tried = leafPath(r, top, b, 24, 16)
323      isBehind = t.dy < 0 && tried.some(p => overHouse(L, p))
324      if (isBehind || tried.every(p => !overHouse(L, p))) path = tried
325    }
326    if (path.length === 0) continue
327    out[isBehind ? 'behind' : 'front'] += fallingLeaf(cv, LEAF[i % LEAF.length] as Mat, path, r)
328  }
329  return out
330}
331
332// A leaf's path from the crown of a tree at `foot`, falling up to twice `fall`, drifting up to 8 + `drift`.
333function leafPath(r: () => number, foot: Pt, b: (typeof BUILD)[Kind], fall: number, drift: number): Pt[] {
334  const sx = Math.round(foot.x - b.rx * 0.6 + r() * b.rx * 1.2)
335  const sy = Math.round(foot.y - b.trunk - b.ry * 1.5 + r() * b.ry)
336  const f = fall + Math.round(r() * fall)
337  const d = 8 + Math.round(r() * drift)
338  return [0, 1, 2, 3, 4].map(k => ({ x: Math.round(sx + (d * k) / 4 + (k % 2 ? 3 : 0)), y: Math.round(sy + (f * k) / 4) }))
339}
340
341// One leaf drifting down `path`, on a beat of its own.
342function fallingLeaf(cv: Canvas, leaf: Mat, path: Pt[], r: () => number): string {
343  const dur = Math.round((6 + r() * 5) * 10) / 10
344  const lead = Math.round(r() * 100) / 10
345  cv.used.set(leaf.cls, leaf)
346  return `<rect class="${leaf.cls}" width="2" height="1" opacity="0">` +
347    `<animateTransform attributeName="transform" type="translate" values="${path.map(p => `${p.x} ${p.y}`).join(';')}" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
348    `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.8;1" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
349    `<animate attributeName="width" values="2;1" calcMode="discrete" dur="0.7s" ${loopBegin(0.7)} repeatCount="indefinite"/></rect>`
350}
351
352// ---------- the garden round the house ----------
353
354// What grows past the house's trees, from a fixed seed, once per layout. Nothing stands on or reaches over
355// the house, its trees, the road or the yard, so the house's picture over it never cuts one wrongly.
356// `own`: the house's trees; `order`: back to front; `leafy`: those that drop leaves.
357type Standing = { kind: Kind; at: Pt }
358type Grove = {
359  trees: Standing[]; ponds: Pond[]; beds: { at: Pt; n: number }[]; stones: Pt[]; clumps: Pt[]; toadstools: Pt[]
360  own: Standing[]; order: Standing[]; leafy: Standing[]
361}
362const groves = new Map<string, Grove>()
363
364function groveOf(L: Layout): Grove {
365  const key = `${L.w}x${L.h}x${L.oy}`
366  const known = groves.get(key)
367  if (known) return known
368  const r = rng(29)
369  const c = corners(L)
370  const up = (q: Pt, dy: number) => ({ x: q.x, y: q.y - dy })
371  const hull = [up(c.left, TALL_WALL + 6), up(c.top, TALL_WALL + 6), up(c.right, TALL_WALL + 6), up(c.right, -6), up(c.bottom, -6), up(c.left, -6)]
372  const yard = yardShapes(L)
373  const sign = signAt(L)
374  const own = TREES.map(t => ({ kind: t.kind, at: place(L, t) }))
375  const lit = lampsAt(L)
376  const wet0 = pondAt(L)
377  // whether the house, its trees or the yard come within `m` of p
378  const blocked = (p: Pt, m: number) =>
379    outside(hull, p) < m || outside(yard.road, p) < m + 4 || yard.pallets.some(q => outside(q, p) < m + 4) ||
380    Math.hypot(p.x - yard.mailbox.x, p.y - yard.mailbox.y) < m + 8 || Math.hypot(p.x - sign.x - 6, p.y - sign.y - 3) < m + 10 ||
381    own.some(q => Math.hypot(p.x - q.at.x, (p.y - q.at.y) * 0.8) < m + 24) || lit.some(q => Math.hypot(p.x - q.x, p.y - q.y) < m + 6) ||
382    Math.hypot((p.x - wet0.x) / 2, p.y - wet0.y) < m + 10
383  // a tree's whole reach, trunk foot to crown top, kept clear
384  const clear = (kind: Kind, at: Pt) => {
385    const b = BUILD[kind]
386    const top = at.y - b.trunk - 2 * b.ry
387    return [at, { x: at.x - b.rx, y: at.y - b.trunk - b.ry }, { x: at.x + b.rx, y: at.y - b.trunk - b.ry }, { x: at.x, y: top }]
388      .every(p => !blocked(p, 3))
389  }
390  const grove: Grove = { trees: [], ponds: [], beds: [], stones: [], clumps: [], toadstools: [], own, order: [], leafy: [] }
391  const near = (at: Pt, list: Pt[], d: number) => list.some(q => Math.hypot(q.x - at.x, (q.y - at.y) * 1.5) < d)
392  const spaced = (at: Pt, d: number) => !near(at, grove.trees.map(t => t.at), d)
393  // candidate spots on a jittered grid, shuffled
394  const spots = (step: number) => {
395    const out: Pt[] = []
396    for (let y = 42; y <= L.h - 4; y += step) for (let x = 6; x <= L.w - 6; x += step) out.push({ x: Math.round(x + (r() - 0.5) * step), y: Math.round(y + (r() - 0.5) * step) })
397    for (let i = out.length - 1; i > 0; i--) {
398      const k = Math.floor(r() * (i + 1))
399      ;[out[i], out[k]] = [out[k] as Pt, out[i] as Pt]
400    }
401    return out
402  }
403  // up to `max` spots `gap` apart that pass `ok`
404  const scatter = (step: number, max: number, gap: number, ok: (p: Pt) => boolean) => {
405    const out: Pt[] = []
406    for (const p of spots(step)) if (out.length < max && ok(p) && !near(p, out, gap)) out.push(p)
407    return out
408  }
409  const pick = <T,>(list: T[]) => list[Math.floor(r() * list.length)] as T
410  // copses of a kind or two, a bush or two at the edge
411  const MIXES: Kind[][] = [['oak', 'maple'], ['birch'], ['pine'], ['maple', 'birch'], ['pine', 'oak'], ['oak', 'maple', 'birch'], ['pine', 'birch']]
412  for (const p of scatter(23, Math.round(L.h / 46), 52, p => !blocked(p, 12))) {
413    const mix = pick(MIXES)
414    for (let n = 2 + Math.floor(r() * 4), tries = 0; n > 0 && tries < 30; tries++) {
415      const at = { x: Math.round(p.x + (r() - 0.5) * 50), y: Math.round(p.y + (r() - 0.5) * 24) }
416      const kind = pick(mix)
417      if (spaced(at, 17) && clear(kind, at)) (grove.trees.push({ kind, at }), n--)
418    }
419    for (let n = Math.floor(r() * 3), tries = 0; n > 0 && tries < 12; tries++) {
420      const at = { x: Math.round(p.x + (r() - 0.5) * 64), y: Math.round(p.y + 8 + r() * 10) }
421      if (spaced(at, 13) && clear('bush', at)) (grove.trees.push({ kind: 'bush', at }), n--)
422    }
423  }
424  // a lone tree or bush here and there
425  for (const at of scatter(29, Math.round(L.h / 32), 28, p => spaced(p, 28))) {
426    const kind = pick<Kind>(['oak', 'maple', 'birch', 'pine', 'bush', 'bush'])
427    if (clear(kind, at)) grove.trees.push({ kind, at })
428  }
429  // ponds where clearest of trees: a big one or two, then smaller ones
430  const wet = (p: Pt) => Math.min(99, ...grove.trees.map(t => Math.hypot((t.at.x - p.x) / 1.7, t.at.y - p.y)))
431  const pondSpots = spots(13).filter(p => p.x > 20 && p.x < L.w - 20 && !blocked(p, 14)).map(p => ({ p, w: wet(p) })).sort((a, b) => b.w - a.w)
432  const apart = (p: Pt, d: number) => grove.ponds.every(q => Math.hypot(q.at.x - p.x, (q.at.y - p.y) * 1.5) > d + q.rx)
433  for (const { p, w } of pondSpots) if (grove.ponds.length < Math.max(1, Math.round(L.h / 300)) && w > 16 && apart(p, 90)) grove.ponds.push({ at: p, rx: 12, ry: 4.5 })
434  for (const { p, w } of pondSpots) {
435    if (grove.ponds.length >= Math.round(L.h / 300) + Math.round(L.h / 120)) break
436    const rx = 6 + Math.floor(r() * 4)
437    if (w > rx + 6 && apart(p, 36)) grove.ponds.push({ at: p, rx, ry: rx * 0.4 })
438  }
439  const dry = (p: Pt, m: number) => !blocked(p, m) && grove.ponds.every(q => Math.hypot((p.x - q.at.x) / (q.rx + 3), (p.y - q.at.y) / (q.ry + 2)) > 1.4)
440  // reeds at the ponds' edges, longer grass about the lawn
441  for (const q of grove.ponds) grove.clumps.push({ x: q.at.x + (r() < 0.5 ? -1 : 1) * Math.round(q.rx * 0.8), y: Math.round(q.at.y + q.ry * 0.5) })
442  grove.clumps.push(...scatter(17, Math.round(L.h / 30), 22, p => dry(p, 2) && spaced(p, 10)))
443  // flower beds, stones, toadstools at a few trees' feet
444  grove.beds.push(...scatter(19, Math.round(L.h / 34), 28, p => dry(p, 6) && spaced(p, 16)).map(at => ({ at, n: Math.floor(r() * 3) })))
445  for (const p of scatter(31, Math.round(L.h / 70), 40, p => dry(p, 3) && spaced(p, 10))) {
446    grove.stones.push(p)
447    if (r() < 0.5) grove.stones.push({ x: p.x + 4, y: p.y + 1 })
448  }
449  for (const t of grove.trees) if (t.kind !== 'bush' && r() < 0.18) grove.toadstools.push({ x: t.at.x + (r() < 0.5 ? -5 : 4), y: t.at.y + 2 })
450  // nothing wholly off the picture
451  grove.trees = grove.trees.filter(t => t.at.x + BUILD[t.kind].rx > 0 && t.at.x - BUILD[t.kind].rx < L.w)
452  grove.toadstools = grove.toadstools.filter(p => p.x > -2 && p.x < L.w + 1)
453  grove.order = [...grove.trees].sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x)
454  grove.leafy = grove.trees.filter(t => t.kind !== 'pine' && t.kind !== 'bush')
455  groves.set(key, grove)
456  return grove
457}
458
459// The garden under the house; returns what drifts over it (leaves, fireflies). The house's own trees are
460// in its picture. `band`: only what shows in those rows, as drawn in the whole.
461export function garden(cv: Canvas, L: Layout, band = { y0: 0, y1: L.h }): string {
462  const c = corners(L)
463  const grove = groveOf(L)
464  // whether a thing standing at y, `tall` above it, shows in the band
465  const shows = (y: number, tall = 0) => y + 8 >= band.y0 && y - tall - 8 <= band.y1
466  const [y0, y1] = [Math.max(0, band.y0), Math.min(L.h, band.y1)]
467  cv.rect(0, y0, L.w, y1 - y0, 'url(#grass)')
468  cv.layer()
469  for (const id of ['patches', 'tufts', 'leaves', 'flowers']) cv.rect(0, y0, L.w, y1 - y0, `url(#${id})`)
470  cv.layer()
471  // round the house: clumps of long grass, its pond and toadstools
472  const tufts = rng(8)
473  const clumps = [
474    lerp(c.left, c.bottom, 0.18, 22), lerp(c.left, c.bottom, 0.62, 16), lerp(c.bottom, c.right, 0.28, 20),
475    lerp(c.bottom, c.right, 0.86, 16), lerp(c.left, c.top, 0.15, -20), lerp(c.top, c.right, 0.84, -16),
476    { x: c.left.x - 4, y: c.left.y + 6 }, { x: c.right.x + 4, y: c.right.y + 6 },
477  ]
478  for (const p of clumps) blades(cv, tufts, p.x, p.y, 5 + Math.floor(tufts() * 3))
479  grove.clumps.forEach((p, i) => shows(p.y, 6) && blades(cv, rng(200 + i), p.x, p.y, 3 + (i % 4)))
480  pond(cv, { at: pondAt(L), rx: 12, ry: 4.5 })
481  for (const p of grove.ponds) if (shows(p.at.y + p.ry, 2 * p.ry)) pond(cv, p)
482  for (const p of [...toadstoolsAt(L), ...grove.toadstools]) if (shows(p.y)) toadstool(cv, p)
483  for (const s of grove.stones) {
484    if (!shows(s.y)) continue
485    blob(cv, { x: s.x + 0.5, y: s.y + 0.5 }, 2.6, 1.6, G.stone)
486    cv.rect(s.x - 1, s.y - 1, 2, 1, G.stoneLit)
487  }
488  // fallen leaves under every tree
489  const litter = rng(9)
490  for (const t of [...grove.own, ...grove.trees]) {
491    const b = BUILD[t.kind]
492    for (let i = 0; i < (t.kind === 'pine' ? 0 : 3); i++) {
493      const [x, y, w, m] = [Math.round(t.at.x - b.rx + litter() * b.rx * 2), Math.round(t.at.y - 2 + litter() * 5), litter() < 0.5 ? 2 : 1, LEAF[Math.floor(litter() * LEAF.length)] as Mat]
494      if (shows(y)) cv.rect(x, y, w, 1, m)
495    }
496  }
497  cv.layer()
498  // flower beds, each drawn once and copied
499  for (const b of grove.beds) {
500    if (!shows(b.at.y)) continue
501    cv.stamp(`bed${b.n}`, b.at, sub => {
502      const m = [G.aster, G.mum, G.rose][b.n] as Mat
503      const br = rng(41 + b.n)
504      for (let k = 0; k < 14; k++) {
505        const p = { x: Math.round(b.at.x + (br() - 0.5) * 13), y: Math.round(b.at.y + (br() - 0.5) * 5) }
506        sub.rect(p.x, p.y, 1, 1, G.dark)
507        sub.rect(p.x, p.y - 1, 1, 1, k % 4 === 0 ? G.mum : m)
508      }
509    })
510  }
511  const crownOf = (kind: Kind) => BUILD[kind].trunk + 2 * BUILD[kind].ry
512  for (const t of grove.order) if (shows(t.at.y, crownOf(t.kind))) treeAt(cv, t.kind, t.at, 'vh-g')
513  let over = ''
514  const { leafy } = grove
515  const falling = Math.min(10, leafy.length)
516  for (let i = 0; i < falling; i++) {
517    const t = leafy[Math.floor((i * leafy.length) / falling)] as Standing
518    if (!shows(t.at.y, crownOf(t.kind))) continue
519    const r = rng(60 + i)
520    over += fallingLeaf(cv, LEAF[i % LEAF.length] as Mat, leafPath(r, t.at, BUILD[t.kind], 20, 14), r)
521  }
522  const flies = rng(12)
523  for (let i = 0; i < Math.min(12, Math.round(L.h / 45)); i++) {
524    const p = { x: Math.round(L.w * 0.08 + flies() * L.w * 0.84), y: Math.round(10 + flies() * (L.h - 20)) }
525    const dur = Math.round((5 + flies() * 4) * 10) / 10
526    const lead = Math.round(flies() * 50) / 10
527    if (overHouse(L, p) || !shows(p.y)) continue
528    const loop = [[0, 0], [4, -3], [7, 1], [3, 4], [0, 0]].map(([dx, dy]) => `${p.x + (dx as number)} ${p.y + (dy as number)}`).join(';')
529    over += `<rect class="vh-lamp" width="1" height="1" fill="#ffe08a" opacity="0">` +
530      `<animateTransform attributeName="transform" type="translate" values="${loop}" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/>` +
531      `<animate attributeName="opacity" values="0;0.9;0.2;0.9;0" dur="${dur}s" ${loopBegin(dur, lead)} repeatCount="indefinite"/></rect>`
532  }
533  return over
534}
535
types/index.d.ts 76 lines
1// What an agent is doing, which picks the room it works in.
2export type Room = 'study' | 'library' | 'workshop' | 'kitchen' | 'sunroom' | 'lounge'
3// Where someone can stand: a room or the front yard.
4export type Place = Room | 'yard'
5
6export type ResidentStatus = 'working' | 'waiting' | 'idle' | 'done'
7
8export type Resident = {
9  id: string
10  name: string
11  kind: string
12  room: Room
13  movedAt: number
14  status: ResidentStatus
15  activity: string
16  isBackground?: boolean
17  // the tool in hand; picks its desk screen
18  tool?: string
19  // when its last tool call returned, and if it went well
20  resultAt?: number
21  isOk?: boolean
22  spawnedAt?: number
23  // the lead's room when this agent got its task
24  spawnRoom?: Room
25  doneAt?: number
26  // when it last went idle (starting its idle routine) and last started work
27  idleSince?: number
28  workSince?: number
29  // where it stood at its last plan change, so the next starts there; `key`: the house build it is from
30  anchor?: { t: number; x: number; y: number; room: Place; key: string }
31  // its desk while working, kept until it leaves the room
32  seat?: number
33  // engine task kind (`subagent`, `shell`, `monitor`, `workflow`...) and status (`running`, `completed`...)
34  taskType?: string
35  taskStatus?: string
36  // a workflow's agent or a teammate: the engine lists neither as a subagent
37  isWorkflow?: boolean
38  isTeammate?: boolean
39  // the tool a permission prompt asks about
40  waitingTool?: string
41  // its last few tool calls, for how hot its desk runs
42  calls?: number[]
43  // the lead's context window use, in percent
44  context?: number
45  // finished, carrying its box to the pallets; `box`: the yard's count then, which picks its pallet
46  carrying?: boolean
47  box?: number
48  // part of `/haus demo`'s made-up household, never real work
49  isDemo?: boolean
50  // another session's member: that session's id (also its own id's prefix)
51  session?: string
52  // when such a visitor steps onto the road coming in, or was let go; never shared
53  arriving?: number
54  leaving?: number
55  // tokens used, as flat time, tokens pairs; the last week only, the lead's across sessions
56  usage?: number[]
57  // when a background task left the engine's list, so a late end notice still applies
58  endingSince?: number
59}
60
61// The plan's five-hour and weekly use (%) and resets (clock ms), for the vibe fatigue band or corner
62// bars: the newest reading any session took, `at` when; windows without one are left out.
63export type PlanUse = { at?: number; fiveHour?: number; fiveHourResets?: number; week?: number; weekResets?: number }
64
65// One agent's walk, replayed to place its hover zone: start (clock ms), length (s), whether it loops,
66// keys as flat [t, x, y...] in picture units. `shown`: a visitor's time in sight (s), road to road's end.
67export type Track = { id: string; t0: number; total: number; isLoop: boolean; keys: number[]; shown?: [number, number] }
68
69
70declare module 'claude-code' {
71  interface PluginState {
72    // `minute`: the clock at the last minute tick, counting the band's resets down
73    'vibehaus': { residents: Resident[]; plan: PlanUse; minute: number }
74  }
75}
76