SLOPSHOPPER

clawd-office

A living 8-bit office of Claude crabs in a side panel: the main session, its subagents and your other sessions act out what they are really doing.

newpaneguardcommandprompttimer
v0.1.3MITupdated 2026-10-16Djdhmf/clawd-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-office
│ ┃ office ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./office/client.ts │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /office │ ⎿ clawd-office: Clawd Office is off. /office on brings it back. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · office
▣ client module ./office/client.ts
README

Clawd Office

A Claude Code mod that turns a side panel into a living 8-bit office of Claude crabs. The crabs act out what is really happening:

  • main: the crab with the crown, standing for this session.
  • subagents: one hatted crab each, in the Agent workshop.
  • your other sessions: one crab each, in the Sessions room.

Background crabs wander, chat, nap and squabble between the rooms, riding a two-crab elevator.

The plugin is named clawd-office, because Claude Code reserves names starting with claude-. The design is in docs/claude-office-design.md; open docs/mockup/crab-town-v5.html in a browser to see the approved look.

Install

At the prompt of a Claude Code session:

/plugin install clawd-office --marketplace Djdhmf/clawd-office

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install clawd-office --marketplace Djdhmf/clawd-office

Then run /reload-plugins, or start a new session.

Use

  • Auto-open: the office opens by itself when a session starts.
  • /office toggles it. /office on and /office off set it, and the choice is kept across sessions.
  • When it shows: only docked beside the conversation. That needs Claude Code's fullscreen layout and a window at least 110 columns wide; an office the mod opens by itself waits for 144 columns.
  • When it can't dock: the office stays closed and /office says why.
  • Sizes: a large window gets the full building, a medium one three floors, and a small one a single strip with main and the subagents.

What the crabs act out

Each row is a real event, the way the mod sees it, and what happens in the office. The list in hooks/events.ts (CATALOG) is the source of truth.

GroupEvents
Youprompt sent, Enhance used (prompt-enhancer), typing, image pasted, @mention, slash command expanded, Esc
Turnturn starts, thinking, parallel tools, turn done, turn failed
ToolsRead, Grep/Glob, Edit/Write, NotebookEdit, LSP, ToolSearch, tool failed
ShellBash, tests (pass / fail), build, install, background shell, Monitor
Gitcommit, push, pull request, branch switch, worktree
WebWebSearch, WebFetch
Agentssubagent starts, finishes or fails, Skill, team task created / done, teammate idle
Sessionsa message between two other sessions, a message to and from main, ListAgents, sessions opening, busy, idle, closing
MCPMCP tool call, MCP asks for input
Permissionsneeds permission, granted, denied, AskUserQuestion, plan mode
Taskstodos, scheduling, notifications, remote trigger, artifact published
Contextcontext filling, compaction, CLAUDE.md loaded, cost milestone, rate limited
Settingsmodel switch, fast mode, settings changed, folder changed, file changed outside, remote control, /clear, resume, long idle, session end

Not shown, and why

The design listed a few rows that Claude Code 2.1.301 gives a mod no signal for. They are left out rather than guessed:

  • Rewind: no event is raised when a conversation is rewound.
  • Hooks run (the wall gears): a mod cannot see your settings-file hooks run. The gears stay as decoration.

Partly shown

  • Permission granted: inferred when a tool that just asked for permission goes on to run. No event says "granted".
  • Fast mode: shown where the setting change is reported to mods through config.set.
  • Cost milestone: shown where the session reports a cost.
  • **Messages between two other sessions:** shown only when both sessions run this mod, since each writes its events to ~/.claude/claude-office/<session>.json.

Privacy

The shared files in ~/.claude/claude-office/ hold short labels only: event kind, tool name, file base name, agent type and session name. They never hold prompt, message or command text, and they stay on this machine.

License

MIT

Source 12 files
hooks/register.ts 323 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { classifyTool, labelFor } from './events'
3import type { EventKind, OfficeEvent } from './events'
4import { createFeed } from './feed'
5import type { OfficeProps, Roster } from './office/client'
6import { claudeDir, lettersFrom, readOpenSessions, readPeerFiles, reconcileAgents, trimRoster, writeOwnFile } from './sessions'
7import type { Disk } from './sessions'
8
9const POLL_MS = 2000
10
11export const OFFICE = 'office'
12const TITLE = 'Clawd Office'
13const ENABLED_KEY = 'enabled'
14const COST_MILESTONES = [1, 2, 5, 10, 20, 50, 100]
15const IDLE_LONG_MS = 10 * 60 * 1000
16
17export const DOCK_HELP =
18  'Clawd Office only opens as a side panel docked beside the conversation: that needs Claude Code\'s fullscreen layout and a window at least 110 columns wide. Turn on the fullscreen layout (or widen the window), then run /office again.'
19
20// What the hooks module keeps for the office: a bounded feed of events, the
21// roster of real crabs, and what the pane last showed.
22const feed = createFeed()
23const roster: Roster = { agents: [], sessions: [] }
24let placement: 'dock' | 'inline' | undefined
25let isOpen = false
26let size = { columns: 110, rows: 40 }
27let lastTurnAt = Date.now()
28let idleReported = false
29let lastContext = 0
30let costShown = 0
31let lastTyping = 0
32let lastThinking = 0
33const askedTools = new Set<string>()
34
35const push = (kind: EventKind, extra: Omit<OfficeEvent, 'seq' | 'kind'> = {}) => feed.push({ kind, ...extra })
36const field = (e: unknown, key: string): unknown => (e && typeof e === 'object' ? (e as Record<string, unknown>)[key] : undefined)
37const text = (e: unknown, key: string): string | undefined => {
38  const v = field(e, key)
39
40  return typeof v === 'string' && v !== '' ? v : undefined
41}
42const base = (path: string | undefined) => path?.split(/[\\/]/).filter(Boolean).pop()?.slice(0, 40)
43
44const propsFor = (since: number): OfficeProps => ({
45  events: feed.since(since),
46  roster: trimRoster({ agents: [...roster.agents], sessions: [...roster.sessions] }),
47  columns: size.columns,
48  rows: size.rows,
49})
50
51// Opens the office and says how it went: the dock help when it cannot dock.
52async function open($: EngineInterface): Promise<string> {
53  const opened = await $.ui.open({ id: OFFICE, title: TITLE }).catch(() => undefined)
54  if (!opened || !opened.isPlaced) {
55    const why = opened && !opened.isPlaced && opened.reason ? ` (${opened.reason})` : ''
56
57    return `${DOCK_HELP}${why}`
58  }
59  isOpen = true
60  if (placement === 'inline') return DOCK_HELP
61
62  return 'Clawd Office is open.'
63}
64
65export const register: Register = on => {
66  on('session.start', async ($, e, next) => {
67    const started = await next(e)
68    await $.command.register({ name: 'office', description: 'Show or hide Clawd Office, the crab office side panel', argumentHint: '[on|off]' }).catch(() => undefined)
69    if ((await $.store.get(ENABLED_KEY)) !== false) void open($).catch(() => undefined)
70    // A long quiet spell: main naps.
71    $.clock.every(60_000, () => {
72      if (!idleReported && Date.now() - lastTurnAt > IDLE_LONG_MS) { idleReported = true; push('idleLong') }
73    })
74    void startSharing($).catch(() => undefined)
75
76    return started
77  })
78
79  on('command.run', { command: 'office' }, async ($, e) => {
80    const arg = e.args.trim().toLowerCase()
81    const enable = arg === 'on' ? true : arg === 'off' ? false : !isOpen
82    await $.store.set(ENABLED_KEY, enable)
83    if (!enable) {
84      isOpen = false
85      await $.ui.close({ id: OFFICE }).catch(() => undefined)
86
87      return { text: 'Clawd Office is off. /office on brings it back.' }
88    }
89
90    return { text: await open($) }
91  })
92
93  // Docked, the office draws; seated inline, it closes rather than take the prompt's strip.
94  on('ui.render', { component: 'Pane', requestId: OFFICE }, async ($, e) => {
95    placement = e.props.placement
96    if (e.props.placement !== 'dock' || e.surface !== 'terminal') {
97      isOpen = false
98      void $.ui.close({ id: OFFICE }).catch(() => undefined)
99
100      return $.ui.resolve(e).Box({ children: [] })
101    }
102    const { Client } = $.ui.resolve(e)
103    isOpen = true
104    size = { columns: e.props.bodyColumns, rows: e.props.scroll.bodyRows }
105
106    return Client({ key: OFFICE, module: './office/client.ts', props: propsFor(0), width: size.columns, height: size.rows })
107  })
108
109  // The office asks for what happened since the last event it saw.
110  on('ui.message', async ($, e, next) => {
111    const since = field(e.data, 'since')
112    if (e.element === OFFICE && typeof since === 'number') return { props: propsFor(since) }
113
114    return next(e)
115  })
116
117  // ---------- observers: each passes the event on unchanged ----------
118
119  on('tool.call', async ($, e, next) => {
120    if (e.agentId) return next(e)
121    const input = e as unknown as Record<string, unknown>
122    const kinds = classifyTool(e.tool, input)
123    const label = labelFor(e.tool, input)
124    if (askedTools.delete(e.tool)) push('permOk', { label: e.tool })
125    const isTest = kinds.includes('test')
126    for (const kind of kinds) {
127      if (kind === 'todo') {
128        const todos = field(input, 'todos')
129        push('todo', { value: Array.isArray(todos) ? todos.filter(t => field(t, 'status') === 'completed').length : 0 })
130      } else if (kind !== 'test') push(kind, label ? { label } : {})
131    }
132    const ran = await next(e)
133    const failed = 'isError' in ran && ran.isError === true
134    if (isTest) push('test', { ok: !failed })
135    else if (failed) push('toolFail', { label: e.tool })
136
137    return ran
138  })
139
140  on('turn.start', async ($, e, next) => {
141    if (!field(e, 'agentId')) { lastTurnAt = Date.now(); idleReported = false; push('turnStart') }
142
143    return next(e)
144  })
145  // turn.step streams: forward its chunks untouched.
146  on('turn.step', async function* ($, e, next) {
147    if (!field(e, 'agentId') && Date.now() - lastThinking > 20000) { lastThinking = Date.now(); push('thinking') }
148
149    return yield* next(e)
150  })
151  on('turn.complete', async ($, e, next) => {
152    if (!field(e, 'agentId')) {
153      lastTurnAt = Date.now()
154      const reason = field(e, 'reason')
155      push(reason === 'aborted' ? 'interrupt' : reason === 'error' ? 'turnFail' : 'turnDone')
156    }
157
158    return next(e)
159  })
160
161  on('prompt.submit', async ($, e, next) => {
162    push('prompt')
163
164    return next(e)
165  })
166  on('prompt.change', async ($, e, next) => {
167    if (Date.now() - lastTyping > 15000) { lastTyping = Date.now(); push('typing') }
168
169    return next(e)
170  })
171  on('prompt.attachment', async ($, e, next) => {
172    push('image')
173
174    return next(e)
175  })
176  on('prompt.mention', async ($, e, next) => {
177    const label = base(text(e, 'path') ?? text(e, 'text'))
178    push('mention', label ? { label } : {})
179
180    return next(e)
181  })
182  on('prompt.fill', async ($, e, next) => {
183    if (e.origin.kind === 'plugin' && e.origin.name === 'prompt-enhancer') push('enhance')
184
185    return next(e)
186  })
187
188  on('agent.spawn', async ($, e, next) => {
189    const spawned = await next(e)
190    const agentId = field(spawned, 'agentId')
191    if (typeof agentId === 'string') {
192      roster.agents = trimRoster({ ...roster, agents: [...roster.agents.filter(a => a.id !== agentId), { id: agentId, type: e.subagentType, status: 'running' }] }).agents
193      push('agentStart', { realId: agentId, agentType: e.subagentType })
194    }
195
196    return spawned
197  })
198
199  on('session.send', async ($, e, next) => {
200    push('msgOut', { session: e.to.slice(0, 40) })
201
202    return next(e)
203  })
204  on('session.receive', async ($, e, next) => {
205    const from = text(e.origin, 'name')?.slice(0, 40)
206    push('msgIn', from ? { session: from } : {})
207
208    return next(e)
209  })
210  on('session.measure', async ($, e, next) => {
211    const tokens = e.context.tokens
212    const used = tokens !== undefined && e.context.window > 0 ? tokens / e.context.window : undefined
213    if (used !== undefined && Math.abs(used - lastContext) >= 0.1) { lastContext = used; push('context', { value: Math.round(used * 100) / 100 }) }
214    const usd = e.cost?.usd
215    const milestone = usd === undefined ? undefined : [...COST_MILESTONES].reverse().find(m => usd >= m)
216    if (milestone !== undefined && milestone > costShown) { costShown = milestone; push('cost', { value: milestone }) }
217
218    return next(e)
219  })
220  on('session.end', async ($, e, next) => {
221    push('sessionEnd')
222
223    return next(e)
224  })
225  on('session.attach', async ($, e, next) => {
226    push('remoteControl')
227
228    return next(e)
229  })
230  on('config.set', async ($, e, next) => {
231    if (/fast/i.test(String(field(e, 'key') ?? ''))) push('fast', { ok: field(e, 'value') === true })
232
233    return next(e)
234  })
235
236  // Classic hook events: observed, never answered. The engine reads each
237  // event name and hook off the source, so every one is written out.
238  on('classic.PreCompact', async ($, e, next) => { push('compact'); return next(e) })
239  on('classic.PermissionRequest', async ($, e, next) => {
240    const tool = text(e, 'tool_name')
241    if (tool) askedTools.add(tool)
242    push('permAsk', labelOf(tool))
243
244    return next(e)
245  })
246  on('classic.PermissionDenied', async ($, e, next) => { askedTools.delete(text(e, 'tool_name') ?? ''); push('permDenied'); return next(e) })
247  on('classic.Notification', async ($, e, next) => { push('notify'); return next(e) })
248  on('classic.StopFailure', async ($, e, next) => { push(field(e, 'error') === 'rate_limit' ? 'rateLimit' : 'turnFail'); return next(e) })
249  on('classic.PostToolBatch', async ($, e, next) => { push('batch'); return next(e) })
250  on('classic.SubagentStop', async ($, e, next) => {
251    const id = text(e, 'agent_id')
252    if (id) {
253      roster.agents = roster.agents.map(a => (a.id === id ? { ...a, status: 'completed' as const } : a))
254      push('agentDone', { realId: id, ok: true })
255    }
256
257    return next(e)
258  })
259  on('classic.TaskCreated', async ($, e, next) => { push('taskCreated'); return next(e) })
260  on('classic.TaskCompleted', async ($, e, next) => { push('taskDone'); return next(e) })
261  on('classic.TeammateIdle', async ($, e, next) => { push('teammateIdle', labelOf(text(e, 'teammate_name'), 20)); return next(e) })
262  on('classic.InstructionsLoaded', async ($, e, next) => { push('instructions'); return next(e) })
263  on('classic.ConfigChange', async ($, e, next) => { push('config'); return next(e) })
264  on('classic.CwdChanged', async ($, e, next) => { push('cwd', labelOf(base(text(e, 'new_cwd') ?? text(e, 'cwd')))); return next(e) })
265  on('classic.DirectoryAdded', async ($, e, next) => { push('cwd', labelOf(base(text(e, 'directory') ?? text(e, 'path')))); return next(e) })
266  on('classic.FileChanged', async ($, e, next) => { push('fileChanged', labelOf(base(text(e, 'file_path') ?? text(e, 'path')))); return next(e) })
267  on('classic.WorktreeCreate', async ($, e, next) => { push('worktree'); return next(e) })
268  on('classic.PostModelSwitch', async ($, e, next) => { push('model', labelOf(text(e, 'model') ?? text(e, 'new_model'))); return next(e) })
269  on('classic.Elicitation', async ($, e, next) => { push('elicitation', labelOf(text(e, 'mcp_server_name'), 20)); return next(e) })
270  on('classic.UserPromptExpansion', async ($, e, next) => { push('expand'); return next(e) })
271  on('classic.SessionStart', async ($, e, next) => {
272    const source = field(e, 'source')
273    if (source === 'clear') push('clear')
274    if (source === 'resume') push('resume')
275
276    return next(e)
277  })
278}
279
280// Every 2 s: the other sessions into the roster, their messages to each
281// other as letters, subagents settled against the engine's list, and this
282// session's events written for the other offices to read.
283async function startSharing($: EngineInterface): Promise<void> {
284  const dir = await claudeDir($).catch(() => undefined)
285  if (!dir) return
286  const self = await $.session.id().catch(() => '')
287  const name = ((await $.session.cwd().catch(() => '')).split(/[\\/]/).filter(Boolean).pop() ?? 'session').slice(0, 24)
288  const disk: Disk = {
289    list: d => $.fs.list(d),
290    read: async p => String(await $.fs.read(p)),
291    write: (p, t) => $.fs.write(p, t),
292  }
293  const seen = new Map<string, number>()
294  let primed = false
295  let written = 0
296  let polling = false
297  const poll = async () => {
298    const open = await readOpenSessions(disk, dir, self)
299    roster.sessions = open.map(s => ({ id: s.sessionId, name: s.name, busy: s.busy }))
300    const letters = lettersFrom(await readPeerFiles(disk, dir, self), open, seen)
301    // The first read only learns where each peer is, so old messages are not replayed.
302    if (primed) for (const l of letters) push('peerLetter', { realId: l.from, session: l.to })
303    primed = true
304    if (roster.agents.some(a => a.status === 'running')) {
305      const list = await $.agent.list().catch(() => [])
306      roster.agents = reconcileAgents(roster, list.map(a => ({ id: a.id, status: a.status }))).agents
307    }
308    if (self && feed.last() > written) {
309      written = feed.last()
310      await writeOwnFile(disk, dir, self, name, feed.since(0))
311    }
312  }
313  $.clock.every(POLL_MS, () => {
314    if (polling) return
315    polling = true
316    void poll().catch(() => undefined).finally(() => { polling = false })
317  })
318}
319
320function labelOf(v: string | undefined, max = 30): { label?: string } {
321  return v ? { label: v.slice(0, max) } : {}
322}
323
hooks/events.ts 180 lines
1// Every real Claude Code event the office acts out, as a kind; what each
2// means and how the mod sees it; and the classifiers that turn a tool call
3// into kinds. Only events this build (2.1.301) actually raises have a kind:
4// see README.md, "Not shown, and why", for the rows the design dropped.
5
6export const KINDS = [
7  // you and the prompt box
8  'prompt', 'enhance', 'typing', 'image', 'mention', 'expand', 'interrupt',
9  // the turn
10  'turnStart', 'thinking', 'batch', 'turnDone', 'turnFail',
11  // files and search
12  'read', 'grep', 'edit', 'notebook', 'lsp', 'toolSearch', 'toolFail',
13  // the shell
14  'bash', 'test', 'build', 'install', 'background', 'monitor',
15  // git
16  'commit', 'push', 'pr', 'checkout', 'worktree',
17  // the web
18  'webSearch', 'webFetch',
19  // agents, skills, teams
20  'agentStart', 'agentDone', 'skill', 'taskCreated', 'taskDone', 'teammateIdle',
21  // sessions talking to each other
22  'peerLetter', 'msgIn', 'msgOut', 'listAgents', 'sessionOpen', 'sessionBusy', 'sessionIdle', 'sessionClose',
23  // MCP
24  'mcp', 'elicitation',
25  // permissions and modes
26  'permAsk', 'permOk', 'permDenied', 'ask', 'plan',
27  // tasks and time
28  'todo', 'cron', 'notify', 'remote', 'artifact',
29  // context and cost
30  'context', 'compact', 'instructions', 'cost', 'rateLimit',
31  // settings and the session
32  'model', 'fast', 'config', 'cwd', 'fileChanged', 'remoteControl', 'clear', 'resume', 'idleLong', 'sessionEnd',
33] as const
34
35export type EventKind = (typeof KINDS)[number]
36
37/** One thing that happened, as the office acts it out. */
38export type OfficeEvent = {
39  seq: number
40  kind: EventKind
41  /** A short label: a file's base name, an MCP server, a model; never prompt or command text. */
42  label?: string
43  /** The real crab it concerns: a subagent's id or a session's id. */
44  realId?: string
45  /** The other session in a message between sessions. */
46  session?: string
47  /** For a subagent, its type (Explore, Plan, ...). */
48  agentType?: string
49  /** Whether it went well, where that is known (tests, subagents, tools). */
50  ok?: boolean
51  /** A number some kinds carry: context used (0..1), dollars spent. */
52  value?: number
53}
54
55type Row = { cat: string; name: string; how: string; act: string }
56
57export const CATALOG: Record<EventKind, Row> = {
58  prompt: { cat: 'You', name: 'You send a prompt', how: 'prompt.submit', act: 'a letter drops into the inbox; main reads it' },
59  enhance: { cat: 'You', name: 'Enhance used', how: 'prompt.fill from prompt-enhancer', act: 'the librarian crab hurries up with a book' },
60  typing: { cat: 'You', name: 'You are typing', how: 'prompt.change', act: 'main looks up, listening' },
61  image: { cat: 'You', name: 'Image pasted', how: 'prompt.attachment', act: 'a picture frame appears on the wall' },
62  mention: { cat: 'You', name: '@file mentioned', how: 'prompt.mention', act: 'a sticky note goes on the whiteboard' },
63  expand: { cat: 'You', name: 'Slash command expanded', how: 'UserPromptExpansion', act: 'unrolls a scroll of instructions' },
64  interrupt: { cat: 'You', name: 'Interrupted (Esc)', how: 'turn.complete (aborted)', act: 'freezes and holds up a stop sign' },
65  turnStart: { cat: 'Turn', name: 'Turn starts', how: 'turn.start', act: 'hops up and gets to work' },
66  thinking: { cat: 'Turn', name: 'Thinking', how: 'turn.step', act: 'paces with a thought cloud' },
67  batch: { cat: 'Turn', name: 'Several tools at once', how: 'PostToolBatch', act: 'juggles' },
68  turnDone: { cat: 'Turn', name: 'Turn done, waiting for you', how: 'turn.complete', act: 'waves "your turn!"' },
69  turnFail: { cat: 'Turn', name: 'Turn failed', how: 'StopFailure / turn.complete (error)', act: 'red alarm, smoke, shakes' },
70  read: { cat: 'Tools', name: 'Read a file', how: 'tool.call Read', act: 'pulls a book from the shelf' },
71  grep: { cat: 'Tools', name: 'Grep / Glob', how: 'tool.call Grep, Glob', act: 'rifles the card catalogue with a magnifier' },
72  edit: { cat: 'Tools', name: 'Edit / Write a file', how: 'tool.call Edit, Write', act: 'types at the desk' },
73  notebook: { cat: 'Tools', name: 'Notebook edit', how: 'tool.call NotebookEdit', act: 'scribbles in a lab notebook' },
74  lsp: { cat: 'Tools', name: 'Code intelligence', how: 'tool.call LSP', act: 'checks the big dictionary' },
75  toolSearch: { cat: 'Tools', name: 'Tool search', how: 'tool.call ToolSearch', act: 'rummages in the toolbox' },
76  toolFail: { cat: 'Tools', name: 'Tool failed', how: 'tool.call result isError', act: 'smoke puff, shakes it off' },
77  bash: { cat: 'Shell', name: 'Bash command', how: 'tool.call Bash', act: 'works the console' },
78  test: { cat: 'Shell', name: 'Tests run', how: 'Bash running tests; pass or fail from its result', act: 'confetti when green, red alarm when not' },
79  build: { cat: 'Shell', name: 'Build', how: 'Bash running a build', act: 'hammers away' },
80  install: { cat: 'Shell', name: 'Install packages', how: 'Bash installing packages', act: 'a delivery truck pulls up' },
81  background: { cat: 'Shell', name: 'Background shell', how: 'Bash run_in_background', act: 'an hourglass on the console' },
82  monitor: { cat: 'Shell', name: 'Watching output', how: 'tool.call Monitor', act: 'watches through binoculars' },
83  commit: { cat: 'Git', name: 'git commit', how: 'Bash git commit', act: 'stamps a parcel' },
84  push: { cat: 'Git', name: 'git push', how: 'Bash git push', act: 'drops the parcel down the mail chute' },
85  pr: { cat: 'Git', name: 'Pull request opened', how: 'Bash gh pr create', act: 'hangs a green PR frame on the wall' },
86  checkout: { cat: 'Git', name: 'Branch switch', how: 'Bash git checkout / switch', act: 'reads the map' },
87  worktree: { cat: 'Git', name: 'Worktree created', how: 'WorktreeCreate / tool.call EnterWorktree', act: 'packs a box for the new desk' },
88  webSearch: { cat: 'Web', name: 'Web search', how: 'tool.call WebSearch', act: 'looks through the telescope' },
89  webFetch: { cat: 'Web', name: 'Fetch a page', how: 'tool.call WebFetch', act: 'a carrier pigeon brings a letter' },
90  agentStart: { cat: 'Agents', name: 'Subagent starts', how: 'agent.spawn', act: 'main calls one out; a hatted crab rides up to the workshop' },
91  agentDone: { cat: 'Agents', name: 'Subagent finishes or fails', how: 'SubagentStop / $.agent.list() status', act: 'rides up with a report (or a bandage), retires, later goes home' },
92  skill: { cat: 'Agents', name: 'Skill used', how: 'tool.call Skill', act: 'opens a glowing spellbook' },
93  taskCreated: { cat: 'Agents', name: 'Team task created', how: 'TaskCreated', act: 'stickies go up on the team kanban' },
94  taskDone: { cat: 'Agents', name: 'Team task done', how: 'TaskCompleted', act: 'a sticky moves to Done' },
95  teammateIdle: { cat: 'Agents', name: 'Teammate idle', how: 'TeammateIdle', act: 'a red-cap teammate waits in the team room' },
96  peerLetter: { cat: 'Sessions', name: 'Session messages session', how: 'another session\'s shared event file', act: 'one session crab carries a letter to the other' },
97  msgIn: { cat: 'Sessions', name: 'Message to main', how: 'session.receive', act: 'a capsule shoots up the tube; main reads it' },
98  msgOut: { cat: 'Sessions', name: 'Main messages a session', how: 'session.send / tool.call SendMessage', act: 'main sends a capsule down the tube' },
99  listAgents: { cat: 'Sessions', name: 'List other sessions', how: 'tool.call ListAgents', act: 'checks the staff directory' },
100  sessionOpen: { cat: 'Sessions', name: 'Session opens', how: '~/.claude/sessions', act: 'a crab walks into Sessions' },
101  sessionBusy: { cat: 'Sessions', name: 'Session busy', how: '~/.claude/sessions', act: 'its crab types' },
102  sessionIdle: { cat: 'Sessions', name: 'Session idle', how: '~/.claude/sessions', act: 'its crab dozes' },
103  sessionClose: { cat: 'Sessions', name: 'Session closes', how: '~/.claude/sessions', act: 'its crab heads home' },
104  mcp: { cat: 'MCP', name: 'MCP tool call', how: 'tool.call mcp__*', act: 'makes a phone call' },
105  elicitation: { cat: 'MCP', name: 'MCP asks for input', how: 'Elicitation', act: 'holds out a clipboard form' },
106  permAsk: { cat: 'Permissions', name: 'Needs permission', how: 'PermissionRequest', act: 'rings a bell, claw raised' },
107  permOk: { cat: 'Permissions', name: 'Permission granted', how: 'a tool that was asked about runs (partial)', act: 'a green stamp' },
108  permDenied: { cat: 'Permissions', name: 'Permission denied', how: 'PermissionDenied', act: 'a red stamp, a sigh' },
109  ask: { cat: 'Permissions', name: 'Asks you a question', how: 'tool.call AskUserQuestion', act: 'holds up a "?" sign' },
110  plan: { cat: 'Permissions', name: 'Plan mode', how: 'tool.call ExitPlanMode / EnterPlanMode', act: 'unrolls a blueprint' },
111  todo: { cat: 'Tasks', name: 'Todo list written or ticked', how: 'tool.call TodoWrite', act: 'writes or ticks items on the whiteboard' },
112  cron: { cat: 'Tasks', name: 'Scheduled for later', how: 'tool.call CronCreate / ScheduleWakeup', act: 'sets an alarm clock' },
113  notify: { cat: 'Tasks', name: 'Sends you a notification', how: 'tool.call PushNotification / Notification', act: 'shouts through a megaphone' },
114  remote: { cat: 'Tasks', name: 'Remote trigger', how: 'tool.call RemoteTrigger', act: 'the rooftop dish blinks' },
115  artifact: { cat: 'Tasks', name: 'Artifact published', how: 'tool.call Artifact', act: 'pins a poster on the wall' },
116  context: { cat: 'Context', name: 'Context filling up', how: 'session.measure', act: 'the wall gauge rises; papers pile up near full' },
117  compact: { cat: 'Context', name: 'Compacting', how: 'PreCompact / session.compact', act: 'sweeps the papers away' },
118  instructions: { cat: 'Context', name: 'CLAUDE.md loaded', how: 'InstructionsLoaded', act: 'reads the red rulebook' },
119  cost: { cat: 'Context', name: 'Cost milestone', how: 'session.measure cost (partial: where cost is reported)', act: 'coins pop out' },
120  rateLimit: { cat: 'Context', name: 'Rate limited', how: 'StopFailure rate_limit', act: 'waits by the hourglass' },
121  model: { cat: 'Settings', name: 'Model switched', how: 'PostModelSwitch', act: 'the crown changes colour' },
122  fast: { cat: 'Settings', name: 'Fast mode', how: 'config.set (partial: where the setting is reported)', act: 'puts on running shoes, walks faster' },
123  config: { cat: 'Settings', name: 'Settings changed', how: 'ConfigChange', act: 'tightens a bolt with a wrench' },
124  cwd: { cat: 'Settings', name: 'Folder changed', how: 'CwdChanged / DirectoryAdded', act: 'carries a box to a new spot' },
125  fileChanged: { cat: 'Settings', name: 'File changed outside', how: 'FileChanged', act: 'a newspaper arrives' },
126  remoteControl: { cat: 'Settings', name: 'Remote control connected', how: 'session.attach', act: 'a phone on the desk lights up' },
127  clear: { cat: 'Settings', name: '/clear', how: 'SessionStart (clear)', act: 'wipes the whiteboard' },
128  resume: { cat: 'Settings', name: 'Session resumed', how: 'SessionStart (resume)', act: 'walks back in with a coffee' },
129  idleLong: { cat: 'Settings', name: 'Idle a long time', how: 'no turn for 10 minutes', act: 'naps at the desk' },
130  sessionEnd: { cat: 'Settings', name: 'Session ends', how: 'SessionEnd / session.end', act: 'turns the lights off' },
131}
132
133/** What a Bash command runs, told apart by its text. */
134export function classifyBash(command: string): 'test' | 'build' | 'install' | 'commit' | 'push' | 'pr' | 'checkout' | 'bash' {
135  const c = command.toLowerCase()
136  if (/\bgh\s+pr\s+create\b/.test(c)) return 'pr'
137  if (/\bgit\s+commit\b/.test(c)) return 'commit'
138  if (/\bgit\s+push\b/.test(c)) return 'push'
139  if (/\bgit\s+(checkout|switch)\b/.test(c)) return 'checkout'
140  if (/\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|\bpip3?\s+install\b|\bcargo\s+add\b|\bgo\s+get\b/.test(c)) return 'install'
141  if (/\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\b(cargo|go|plugin)\s+test\b|\b(jest|vitest|mocha)\b/.test(c)) return 'test'
142  if (/\b(npm|pnpm|yarn|bun)\s+(run\s+)?build\b|\btsc\b|\bmake\b|\b(cargo|go)\s+build\b|\bvite\s+build\b/.test(c)) return 'build'
143
144  return 'bash'
145}
146
147const TOOLS: Record<string, EventKind | undefined> = {
148  Read: 'read', Grep: 'grep', Glob: 'grep', Edit: 'edit', Write: 'edit', MultiEdit: 'edit', NotebookEdit: 'notebook',
149  LSP: 'lsp', ToolSearch: 'toolSearch', WebSearch: 'webSearch', WebFetch: 'webFetch', Skill: 'skill',
150  AskUserQuestion: 'ask', ExitPlanMode: 'plan', EnterPlanMode: 'plan', TodoWrite: 'todo', CronCreate: 'cron',
151  ScheduleWakeup: 'cron', PushNotification: 'notify', RemoteTrigger: 'remote', Monitor: 'monitor', SendMessage: 'msgOut',
152  ListAgents: 'listAgents', Artifact: 'artifact', EnterWorktree: 'worktree',
153}
154
155/**
156 * The kinds a tool call shows as. The Agent tool shows none: its crab comes
157 * from `agent.spawn`, so it is never counted twice.
158 */
159export function classifyTool(tool: string, input: Record<string, unknown>): EventKind[] {
160  if (tool === 'Bash') {
161    if (input.run_in_background === true) return ['background']
162
163    return [classifyBash(typeof input.command === 'string' ? input.command : '')]
164  }
165  if (tool.startsWith('mcp__')) return ['mcp']
166  const kind = TOOLS[tool]
167
168  return kind ? [kind] : []
169}
170
171/** A short label for a tool call: a file's base name or an MCP server; never prompt or command text. */
172export function labelFor(tool: string, input: Record<string, unknown>): string | undefined {
173  if (tool.startsWith('mcp__')) return tool.split('__')[1]
174  const path = input.file_path ?? input.notebook_path ?? input.path
175  if (typeof path !== 'string' || path === '') return undefined
176  const base = path.split(/[\\/]/).pop()
177
178  return base ? base.slice(0, 40) : undefined
179}
180
hooks/feed.ts 31 lines
1// The events the office has yet to act out, numbered in order and bounded:
2// while the office is closed only the last 100 are kept, so nothing grows.
3import type { OfficeEvent } from './events'
4
5export const FEED_MAX = 100
6
7export type Feed = {
8  push: (ev: Omit<OfficeEvent, 'seq'>) => OfficeEvent
9  /** The kept events numbered above `seq`, oldest first. */
10  since: (seq: number) => OfficeEvent[]
11  /** The newest event's number; 0 before any. */
12  last: () => number
13}
14
15export function createFeed(max = FEED_MAX): Feed {
16  let seq = 0
17  let kept: OfficeEvent[] = []
18
19  return {
20    push: ev => {
21      const numbered = { ...ev, seq: ++seq }
22      kept.push(numbered)
23      if (kept.length > max) kept = kept.slice(-max)
24
25      return numbered
26    },
27    since: from => kept.filter(ev => ev.seq > from),
28    last: () => seq,
29  }
30}
31
hooks/office/client.ts 151 lines
1// The office's surface module: it keeps a world on the frame clock, takes in
2// the hooks module's new events and roster, lays the town out again when the
3// pane is resized, and draws the cells as rows of coloured text runs.
4import type { ClientModule, ClientSurface } from 'claude-code'
5import type { OfficeEvent } from '../events'
6import { applyEvent } from './actions'
7import { runs } from './cells'
8import type { Cell } from './cells'
9import { chooseLayout } from './layout'
10import { drawWorld } from './render'
11import type { Detail } from './render'
12import { createWorld, frame, relayout, removeReal, retireReal, setSessionIdle, spawnReal } from './sim'
13import type { World } from './sim'
14import type { Accessory } from './sprites'
15
16export type RosterAgent = { id: string; type: string; status: 'running' | 'completed' | 'failed' }
17export type RosterSession = { id: string; name: string; busy: boolean }
18export type Roster = { agents: RosterAgent[]; sessions: RosterSession[] }
19export type OfficeProps = { events: OfficeEvent[]; roster: Roster; columns: number; rows: number }
20
21const FRAME_MS = 50
22const POLL_MS = 500
23const AGENT_LOOKS: Record<string, Accessory[]> = {
24  Explore: ['miner'], Plan: ['plan'], 'general-purpose': ['hard'], reviewer: ['detective'], 'code-reviewer': ['detective'],
25}
26const SESSION_LOOKS: Accessory[] = ['headphones', 'scarf', 'beanie', 'party']
27
28type Office = {
29  w: World; seen: number; columns: number; rows: number; done: Set<string>; drawMs: number[]; fps: 10 | 20
30  /** How much detail fits, and how much of the panel the town uses, under the size budget. */
31  detail: Detail; scale: number
32}
33
34const SLOW_DRAW_MS = 25
35const DRAW_SAMPLES = 40
36// The Client unmounts past 100,000 serialized characters. The budget is
37// measured on the very tree the engine serializes, so 5% under is safe.
38const SIZE_BUDGET = 95_000
39const MIN_SCALE = 0.5
40
41/** Redraws at 20 a second unless drawing has been slow, then at 10. */
42export function chooseFps(drawMs: readonly number[]): 10 | 20 {
43  if (drawMs.length === 0) return 20
44  const average = drawMs.reduce((a, b) => a + b, 0) / drawMs.length
45
46  return average > SLOW_DRAW_MS ? 10 : 20
47}
48const offices = new WeakMap<object, Office>()
49
50const OfficeView: ClientModule<OfficeProps, { tick: number }> = (props, surface) => {
51  const columns = Math.max(20, surface.columns || props.columns)
52  const rows = Math.max(10, surface.rows || props.rows)
53  let o = offices.get(surface)
54  if (!o) {
55    o = { w: createWorld(chooseLayout(columns, rows), 1), seen: 0, columns, rows, done: new Set(), drawMs: [], fps: 20, detail: 'full', scale: 1 }
56    offices.set(surface, o)
57    start(surface)
58  } else if (o.columns !== columns || o.rows !== rows) {
59    o.columns = columns
60    o.rows = rows
61    o.detail = 'full'
62    o.scale = 1
63    relayout(o.w, chooseLayout(columns, rows))
64  }
65  absorb(o, props)
66  const started = Date.now()
67  const tree = fitDrawing(o, surface.elements)
68  o.drawMs = [...o.drawMs, Date.now() - started].slice(-DRAW_SAMPLES)
69  o.fps = chooseFps(o.drawMs)
70
71  return tree
72}
73
74/**
75 * Draws the town, never past the size budget: when a drawing would be too
76 * big it draws less detail, then a smaller town, until it fits.
77 */
78export function fitDrawing(o: Office, elements: ClientSurface['elements']) {
79  const { Box, Text } = elements
80  const build = () => Box({ flexDirection: 'column', children: drawWorld(o.w, o.detail).map(line => Text({ children: runs(line).map(run => cellText(Text, run)) })) })
81  let tree = build()
82  while (JSON.stringify(tree).length > SIZE_BUDGET) {
83    if (o.detail === 'full') o.detail = 'low'
84    else if (o.scale > MIN_SCALE) {
85      o.scale = Math.max(MIN_SCALE, Math.round((o.scale - 0.1) * 10) / 10)
86      relayout(o.w, chooseLayout(Math.floor(o.columns * o.scale), Math.floor(o.rows * o.scale)))
87    } else break
88    tree = build()
89  }
90
91  return tree
92}
93
94export default OfficeView
95
96function start(surface: ClientSurface<{ tick: number }>): void {
97  surface.setState({ tick: 0 })
98  surface.every(FRAME_MS, () => {
99    const o = offices.get(surface)
100    if (!o) return
101    frame(o.w)
102    // The world always moves at 20 frames a second; when drawing is slow,
103    // only every second frame is drawn.
104    if (o.fps === 20 || o.w.frameNo % 2 === 0) surface.setState({ tick: o.w.frameNo })
105  })
106  // Asks the hooks module for what happened since the last event seen.
107  surface.every(POLL_MS, () => surface.post({ since: offices.get(surface)?.seen ?? 0 }))
108}
109
110// New events are acted out; the roster seats, retires and removes real crabs.
111function absorb(o: Office, props: OfficeProps): void {
112  for (const ev of props.events) {
113    if (ev.seq <= o.seen) continue
114    o.seen = ev.seq
115    applyEvent(o.w, ev)
116  }
117  for (const a of props.roster.agents) {
118    if (a.status === 'running' && !o.done.has(a.id)) {
119      spawnReal(o.w, { role: 'agent', realId: a.id, tag: a.type, acc: AGENT_LOOKS[a.type] ?? ['hard'] })
120    } else if (a.status !== 'running' && !o.done.has(a.id)) {
121      o.done.add(a.id)
122      retireReal(o.w, a.id, a.status === 'failed' ? 'failed' : 'done')
123    }
124  }
125  const open = new Set(props.roster.sessions.map(s => s.id))
126  props.roster.sessions.forEach(s => {
127    spawnReal(o.w, { role: 'session', realId: s.id, tag: s.name, acc: [SESSION_LOOKS[hash(s.id) % SESSION_LOOKS.length] ?? 'scarf'] })
128    setSessionIdle(o.w, s.id, !s.busy)
129  })
130  for (const c of o.w.crabs) if (c.role === 'session' && c.realId && !open.has(c.realId)) removeReal(o.w, c.realId)
131  for (const spec of o.w.street.waiting) if (spec.role === 'session' && !open.has(spec.realId)) removeReal(o.w, spec.realId)
132}
133
134function cellText(Text: ClientSurface['elements']['Text'], run: Cell) {
135  if (run.fg === undefined && run.bg === undefined) return run.ch
136
137  return Text({
138    ...(run.fg === undefined ? {} : { color: run.fg }),
139    ...(run.bg === undefined ? {} : { backgroundColor: run.bg }),
140    ...(run.bold ? { bold: true } : {}),
141    children: [run.ch],
142  })
143}
144
145function hash(s: string): number {
146  let h = 0
147  for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
148
149  return h
150}
151
hooks/sessions.ts 148 lines
1// The person's other Claude Code sessions, as the office sees them: each
2// open session's record in ~/.claude/sessions, and the small event file each
3// running office writes to ~/.claude/claude-office/<session>.json so every
4// office can show what the others are doing. Files hold short labels only.
5import type { EngineInterface } from 'claude-code'
6import type { EventKind, OfficeEvent } from './events'
7import type { Roster } from './office/client'
8
9/** The file access the session functions need; the hooks module wraps $.fs. */
10export type Disk = {
11  list: (dir: string) => Promise<{ name: string; kind: string }[]>
12  read: (path: string) => Promise<string>
13  write: (path: string, text: string) => Promise<void>
14}
15
16export type SessionInfo = { sessionId: string; name: string; busy: boolean; updatedAt: number }
17
18const SHARED_MAX = 50
19// The kinds whose label is a file's base name, a tool, a server or a model:
20// safe to share. Every other label is dropped from the shared file.
21const LABELLED = new Set<EventKind>([
22  'read', 'edit', 'notebook', 'mcp', 'toolFail', 'permAsk', 'permOk', 'cwd', 'fileChanged', 'model', 'teammateIdle', 'elicitation', 'mention',
23])
24
25const parse = (body: string): unknown => {
26  try { return JSON.parse(body) } catch { return undefined }
27}
28const field = (o: unknown, key: string): unknown => (o && typeof o === 'object' ? (o as Record<string, unknown>)[key] : undefined)
29const str = (o: unknown, key: string): string | undefined => {
30  const v = field(o, key)
31
32  return typeof v === 'string' && v !== '' ? v : undefined
33}
34
35/** Where Claude Code keeps its files: CLAUDE_CONFIG_DIR, else ~/.claude. */
36export async function claudeDir($: EngineInterface): Promise<string | undefined> {
37  const configured = await $.env.get('CLAUDE_CONFIG_DIR')
38  if (configured) return configured
39  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
40
41  return home ? `${home}/.claude` : undefined
42}
43
44async function jsonFiles(disk: Disk, dir: string): Promise<{ name: string; body: unknown }[]> {
45  const entries = await disk.list(dir).catch(() => [])
46  const out: { name: string; body: unknown }[] = []
47  for (const entry of entries) {
48    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
49    const text = await disk.read(`${dir}/${entry.name}`).catch(() => undefined)
50    if (typeof text === 'string') out.push({ name: entry.name, body: parse(text) })
51  }
52
53  return out
54}
55
56/** The other open sessions, busy or idle; this one and unreadable records left out. */
57export async function readOpenSessions(disk: Disk, dir: string, selfId: string): Promise<SessionInfo[]> {
58  const out: SessionInfo[] = []
59  for (const { body } of await jsonFiles(disk, `${dir}/sessions`)) {
60    const sessionId = str(body, 'sessionId')
61    if (!sessionId || sessionId === selfId) continue
62    const updatedAt = field(body, 'updatedAt')
63    out.push({
64      sessionId,
65      name: (str(body, 'name') ?? str(body, 'cwd')?.split(/[\\/]/).pop() ?? 'session').slice(0, 24),
66      busy: str(body, 'status') === 'busy',
67      updatedAt: typeof updatedAt === 'number' ? updatedAt : 0,
68    })
69  }
70
71  return out
72}
73
74/** An event as it may be shared: kind and outcome, and a label only where it is a name, not text. */
75export function sanitize(ev: OfficeEvent): OfficeEvent {
76  const out: OfficeEvent = { seq: ev.seq, kind: ev.kind }
77  if (ev.label && LABELLED.has(ev.kind)) out.label = ev.label.slice(0, 40)
78  if (ev.session && (ev.kind === 'msgOut' || ev.kind === 'msgIn')) out.session = ev.session.slice(0, 40)
79  if (ev.agentType) out.agentType = ev.agentType.slice(0, 30)
80  if (ev.ok !== undefined) out.ok = ev.ok
81  if (ev.value !== undefined) out.value = ev.value
82
83  return out
84}
85
86/** Writes this session's recent events for the other offices to read. */
87export async function writeOwnFile(disk: Disk, dir: string, sessionId: string, name: string, events: OfficeEvent[]): Promise<void> {
88  const body = { sessionId, name, updatedAt: Date.now(), events: events.slice(-SHARED_MAX).map(sanitize) }
89  await disk.write(`${dir}/claude-office/${sessionId}.json`, JSON.stringify(body))
90}
91
92/** The other offices' recent events, by session; unreadable files and this one left out. */
93export async function readPeerFiles(disk: Disk, dir: string, selfId: string): Promise<Record<string, OfficeEvent[]>> {
94  const out: Record<string, OfficeEvent[]> = {}
95  for (const { body } of await jsonFiles(disk, `${dir}/claude-office`)) {
96    const sessionId = str(body, 'sessionId')
97    const events = field(body, 'events')
98    if (!sessionId || sessionId === selfId || !Array.isArray(events)) continue
99    out[sessionId] = events.filter((ev): ev is OfficeEvent => typeof field(ev, 'seq') === 'number' && typeof field(ev, 'kind') === 'string')
100  }
101
102  return out
103}
104
105/**
106 * The messages one open session sent another since last seen, as letters to
107 * carry between their crabs. `seen` keeps the last event read per session.
108 */
109export function lettersFrom(peers: Record<string, OfficeEvent[]>, open: SessionInfo[], seen: Map<string, number>): { from: string; to: string }[] {
110  const letters: { from: string; to: string }[] = []
111  for (const [from, events] of Object.entries(peers)) {
112    if (!open.some(s => s.sessionId === from)) continue
113    const last = seen.get(from) ?? 0
114    for (const ev of events) {
115      if (ev.seq <= last) continue
116      seen.set(from, Math.max(seen.get(from) ?? 0, ev.seq))
117      if (ev.kind !== 'msgOut' || !ev.session) continue
118      const to = open.find(s => s.name === ev.session || s.sessionId === ev.session)
119      if (to && to.sessionId !== from) letters.push({ from, to: to.sessionId })
120    }
121  }
122
123  return letters
124}
125
126/** Settles the roster's subagents against what `$.agent.list()` reports. */
127export function reconcileAgents(roster: Roster, list: { id: string; status: string }[]): Roster {
128  return {
129    ...roster,
130    agents: roster.agents.map(a => {
131      const status = list.find(l => l.id === a.id)?.status
132      if (status === 'failed' || status === 'killed') return { ...a, status: 'failed' as const }
133      if (status === 'completed') return { ...a, status: 'completed' as const }
134
135      return a
136    }),
137  }
138}
139
140const FINISHED_KEPT = 20
141
142/** Every running subagent, and only the latest finished ones, so the panel's props stay small. */
143export function trimRoster(roster: Roster): Roster {
144  const finished = roster.agents.filter(a => a.status !== 'running').slice(-FINISHED_KEPT)
145
146  return { ...roster, agents: roster.agents.filter(a => a.status === 'running' || finished.includes(a)) }
147}
148
hooks/office/actions.ts 323 lines
1// What each real event looks like in the office: the main crab walks to a
2// station and picks up a prop, a subagent's crab arrives or reports, a
3// session's crab types, dozes or carries a letter, and the rooms light up.
4// Main's actions play one after another from a short queue; an event of the
5// kind already playing merges into it, so three Reads keep main at the shelf.
6import type { EventKind, OfficeEvent } from '../events'
7import type { StationName } from './layout'
8import { fxOn, later, removeReal, retireReal, roomNamed, say, setSessionIdle, spawnReal, standY, station } from './sim'
9import type { Crab, World } from './sim'
10import type { Accessory } from './sprites'
11
12// How long each of main's actions holds the stage, in ticks (10 a second).
13const HOLD = 25
14const QUEUE_MAX = 5
15
16const AGENT_LOOKS: Record<string, Accessory[]> = {
17  Explore: ['miner'], Plan: ['plan'], 'general-purpose': ['hard'], reviewer: ['detective'], 'code-reviewer': ['detective'],
18}
19const CROWNS: Record<string, string> = { opus: '#E8C15A', sonnet: '#C9CED2', haiku: '#C08A4A', fable: '#B07FE8' }
20
21type Stage = { queue: OfficeEvent[]; until: number; playing?: EventKind; scheduled: boolean }
22const stages = new WeakMap<World, Stage>()
23const stageOf = (w: World): Stage => {
24  let s = stages.get(w)
25  if (!s) { s = { queue: [], until: 0, scheduled: false }; stages.set(w, s) }
26
27  return s
28}
29
30// The kinds that are played by someone other than main, at once.
31const ELSEWHERE = new Set<EventKind>([
32  'agentStart', 'agentDone', 'taskCreated', 'taskDone', 'teammateIdle', 'peerLetter',
33  'sessionOpen', 'sessionBusy', 'sessionIdle', 'sessionClose', 'context', 'cost', 'enhance',
34])
35
36export function applyEvent(w: World, ev: OfficeEvent): void {
37  if (ELSEWHERE.has(ev.kind)) { playElsewhere(w, ev); return }
38  const s = stageOf(w)
39  if (s.playing === ev.kind && !s.queue.length && w.tick < s.until) {
40    s.until = w.tick + HOLD
41    const text = bubbleFor(ev)
42    if (text) say(w, w.main, text, 30)
43
44    return
45  }
46  s.queue.push(ev)
47  if (s.queue.length > QUEUE_MAX) s.queue.shift()
48  pump(w)
49}
50
51function pump(w: World): void {
52  const s = stageOf(w)
53  if (w.tick < s.until) {
54    if (!s.scheduled) { s.scheduled = true; later(w, s.until - w.tick, () => { s.scheduled = false; pump(w) }) }
55
56    return
57  }
58  const ev = s.queue.shift()
59  if (!ev) return
60  s.playing = ev.kind
61  s.until = w.tick + HOLD
62  playMain(w, ev)
63  if (s.queue.length) pump(w)
64}
65
66function bubbleFor(ev: OfficeEvent): string | undefined {
67  const l = ev.label
68  switch (ev.kind) {
69    case 'read': return l ? `Read ${l}` : 'reading...'
70    case 'edit': return l ? `Edit ${l}` : 'editing...'
71    default: return undefined
72  }
73}
74
75// ---------- main's actions ----------
76
77// How long a prop stays in main's claws once its action has played: 6 s.
78const PROP_TICKS = 60
79let propToken = 0
80
81function playMain(w: World, ev: OfficeEvent): void {
82  const m = w.main
83  m.sleep = false
84  m.shake = false
85  m.acc = base(w)
86  const l = ev.label
87  // Puts a prop in main's claws and takes it away later, unless a newer action has.
88  const hold = (acc: Accessory[]) => {
89    m.acc = [...base(w), ...acc]
90    if (!acc.length) return
91    const token = ++propToken
92    later(w, PROP_TICKS, () => { if (token === propToken) m.acc = base(w) })
93  }
94  const at = (name: StationName, offset: number, acc: Accessory[], text: string, then?: () => void) => {
95    const s = station(w, name)
96    const room = roomNamed(w, 'Main office')
97    const x = Math.max((room?.x0 ?? 0) + 5, Math.min((room?.x1 ?? w.layout.width) - 6, (s?.x ?? m.x) + offset))
98    goWalk(w, m, s?.floor ?? m.floor, x)
99    m.arrive = () => { hold(acc); if (text) say(w, m, text, 40); then?.() }
100  }
101  const now = (acc: Accessory[], text: string, then?: () => void) => {
102    hold(acc)
103    say(w, m, text, 40)
104    then?.()
105  }
106  const fail = (text: string) => {
107    fxOn(w, 'alarm', 40)
108    m.shake = true
109    say(w, m, text, 30)
110    puff(w, m)
111    later(w, 25, () => { m.shake = false })
112  }
113
114  switch (ev.kind) {
115    case 'prompt': w.fx.letterY = 0; fxOn(w, 'letter', 45); return at('inbox', 0, ['letter'], 'a new task!')
116    case 'typing': return now([], 'hm? go on...', () => { m.hop = -1; later(w, 4, () => { m.hop = 0 }) })
117    case 'image': return at('desk', -2, [], 'ooh, a screenshot', () => { w.fx.picture = true })
118    case 'mention': return at('board', 0, [], `${l ? '@' + l : 'a file'} noted`, () => { w.fx.stickies = Math.min(4, num(w.fx.stickies) + 1) })
119    case 'expand': return now(['blueprint'], 'a slash command!')
120    case 'interrupt': return now(['stop'], 'stopping!')
121    case 'turnStart': return now([], 'right, lets go', () => { m.hop = -1; later(w, 5, () => { m.hop = 0 }) })
122    case 'thinking': {
123      const desk = station(w, 'desk')?.x ?? m.x
124      goWalk(w, m, m.floor, m.x > desk ? desk - 10 : desk + 10)
125      return now(['think'], '...')
126    }
127    case 'batch': return now(['juggle'], 'tools in parallel!')
128    case 'turnDone': return at('inbox', 2, ['wave'], 'your turn!')
129    case 'turnFail': return fail('something broke, retrying')
130    case 'read': return at('shelf', 0, ['book'], bubbleFor(ev) ?? 'reading...')
131    case 'grep': return at('catalog', 0, ['magnifier'], 'searching the files')
132    case 'edit': fxOn(w, 'typing', 60); return at('desk', 0, [], bubbleFor(ev) ?? 'editing...')
133    case 'notebook': return at('desk', 4, ['notebook'], l ? `notebook ${l}` : 'notebook edit')
134    case 'lsp': return at('shelf', 0, ['dictionary'], 'go to definition...')
135    case 'toolSearch': return at('phone', 0, ['toolbox'], 'which tool fits?')
136    case 'toolFail': return fail('oops, that failed')
137    case 'bash': return at('console', 0, [], 'running a command')
138    case 'test': return at('console', 0, [], 'running tests', () => {
139      if (ev.ok === true) later(w, 20, () => { say(w, m, 'all green!', 30); burst(w, 'confetti') })
140      if (ev.ok === false) later(w, 20, () => fail('tests failing!'))
141    })
142    case 'build': return at('console', 0, ['hammer'], 'building...')
143    case 'install': w.fx.truckAt = w.tick; fxOn(w, 'truck', 45); return at('console', 0, [], 'packages incoming')
144    case 'background': return at('console', 0, [], 'started in the background', () => fxOn(w, 'hourglass', 300))
145    case 'monitor': return at('console', -8, ['binoculars'], 'watching the output')
146    case 'commit': return at('desk', 6, ['parcel'], 'git commit')
147    case 'push': return at('chute', 4, ['parcel'], 'git push!', () => {
148      later(w, 15, () => { m.acc = base(w); say(w, m, 'whoosh, pushed', 30); w.dust.push({ x: station(w, 'chute')?.x ?? m.x, y: 8, until: w.tick + 10 }) })
149    })
150    case 'pr': return at('desk', 4, [], 'PR opened!', () => { w.fx.pr = true })
151    case 'checkout': return now(['map'], 'switching branch')
152    case 'worktree': return now(['box'], 'a new worktree!')
153    case 'webSearch': return at('telescope', 0, [], 'searching the web')
154    case 'webFetch': w.fx.birdAt = w.tick; fxOn(w, 'bird', 40); return at('telescope', 0, [], 'a pigeon brought a page!')
155    case 'skill': return at('desk', -6, ['spellbook'], l ? `casting ${l}` : 'casting a skill')
156    case 'listAgents': return at('board', 0, ['clipboard'], 'who else is in?')
157    case 'msgIn': tube(w, 'up'); return later(w, 20, () => at('tube', 0, ['capsule'], ev.session ? `message from ${ev.session}` : 'a message!'))
158    case 'msgOut': return at('tube', 0, ['capsule'], ev.session ? `message to ${ev.session}` : 'sending a message', () => { m.acc = base(w); tube(w, 'down') })
159    case 'mcp': return at('phone', 0, ['phone'], `calling ${l ?? 'a server'}...`, () => fxOn(w, 'phone', 45))
160    case 'elicitation': return at('inbox', 2, ['clipboard'], 'needs your input')
161    case 'permAsk': return at('inbox', 0, ['bell'], 'may I?')
162    case 'permOk': return now(['stampOk'], 'approved!')
163    case 'permDenied': return now(['stampNo'], 'denied... fair')
164    case 'ask': return at('inbox', 2, ['sign'], 'a question for you')
165    case 'plan': return at('board', 6, ['blueprint'], 'heres the plan')
166    case 'todo': return at('board', 0, [], ev.value ? 'tick!' : 'making a todo list', () => {
167      w.fx.todos = Math.max(3, num(w.fx.todos))
168      if (ev.value !== undefined) w.fx.ticked = Math.min(3, ev.value)
169    })
170    case 'cron': return now(['alarmclock'], 'see you later')
171    case 'notify': return now(['megaphone'], 'hey! over here!')
172    case 'remote': return now([], 'pinging the cloud', () => fxOn(w, 'dish', 35))
173    case 'artifact': return at('catalog', 0, [], 'published a page!', () => { w.fx.poster = true })
174    case 'compact': return at('desk', 3, ['broom'], 'compacting...', () => {
175      for (let i = 0; i < 6; i++) w.dust.push({ x: m.x + (i - 3) * 2, y: standY(w, m.floor) - 1, until: w.tick + 30 })
176      w.fx.context = 0.25
177    })
178    case 'instructions': return at('shelf', 0, ['rulebook'], 'reading CLAUDE.md')
179    case 'rateLimit': return at('console', 0, [], 'rate limited, waiting', () => fxOn(w, 'hourglass', 45))
180    case 'model': {
181      const name = (l ?? '').toLowerCase()
182      w.crown = Object.entries(CROWNS).find(([k]) => name.includes(k))?.[1] ?? '#9FC4E8'
183      return now([], l ? `now: ${l}` : 'a new model!')
184    }
185    case 'fast': m.fast = ev.ok ?? !m.fast; return now([], m.fast ? 'fast mode on!' : 'fast mode off')
186    case 'config': return now(['wrench'], 'settings updated')
187    case 'cwd': return at('desk', -10, ['box'], l ? `moved to ${l}` : 'a new folder')
188    case 'fileChanged': return now(['newspaper'], l ? `${l} changed!` : 'a file changed!')
189    case 'remoteControl': return at('desk', 4, [], 'hi from your phone!', () => fxOn(w, 'phoneDock', 6000))
190    case 'clear': return at('board', 0, ['broom'], 'fresh start', () => {
191      Object.assign(w.fx, { todos: 0, ticked: 0, stickies: 0, picture: false, pr: false, poster: false })
192    })
193    case 'resume': return now(['mug'], 'where were we?')
194    case 'idleLong': return at('desk', 0, [], 'zzz', () => { m.sleep = true })
195    case 'sessionEnd': say(w, m, 'night all', 30); return fxOn(w, 'lightsOff', 60)
196    default: return undefined
197  }
198}
199
200// ---------- everyone else ----------
201
202function playElsewhere(w: World, ev: OfficeEvent): void {
203  switch (ev.kind) {
204    case 'agentStart': {
205      const type = ev.agentType ?? 'general-purpose'
206      say(w, w.main, `${type}, you're up!`, 30)
207      spawnReal(w, { role: 'agent', realId: ev.realId ?? `agent-${ev.seq}`, tag: type, acc: AGENT_LOOKS[type] ?? ['hard'] })
208      return
209    }
210    case 'agentDone': if (ev.realId) retireReal(w, ev.realId, ev.ok === false ? 'failed' : 'done'); return
211    case 'taskCreated':
212      w.fx.tasksTodo = Math.min(6, num(w.fx.tasksTodo) + 1)
213      say(w, w.main, 'a new team task', 25)
214      return
215    case 'taskDone':
216      if (num(w.fx.tasksTodo) > 0) w.fx.tasksTodo = num(w.fx.tasksTodo) - 1
217      w.fx.tasksDone = Math.min(6, num(w.fx.tasksDone) + 1)
218      say(w, w.main, 'team task done!', 25)
219      return
220    case 'teammateIdle': {
221      const id = ev.realId ?? `teammate-${ev.label ?? ev.seq}`
222      const t = w.crabs.find(c => c.realId === id) ?? spawnReal(w, { role: 'teammate', realId: id, tag: ev.label ?? 'teammate', acc: ['teamcap'] })
223      if (t) say(w, t, 'anything for me?', 30)
224      return
225    }
226    case 'peerLetter': return letter(w, ev.realId, ev.session)
227    case 'sessionOpen':
228      if (ev.realId) spawnReal(w, { role: 'session', realId: ev.realId, tag: ev.label ?? 'session', acc: [lookFor(ev.realId)] })
229      return
230    case 'sessionBusy': if (ev.realId) setSessionIdle(w, ev.realId, false); return
231    case 'sessionIdle': if (ev.realId) setSessionIdle(w, ev.realId, true); return
232    case 'sessionClose': if (ev.realId) removeReal(w, ev.realId); return
233    case 'context':
234      w.fx.context = Math.max(0, Math.min(1, ev.value ?? num(w.fx.context)))
235      say(w, w.main, `context ${Math.round(num(w.fx.context) * 100)}%`, 25)
236      return
237    case 'cost': say(w, w.main, `$${ev.value ?? '?'} spent!`, 30); burst(w, 'coins'); return
238    case 'enhance': {
239      const lib = w.librarian
240      if (!lib || !w.crabs.includes(lib)) { say(w, w.main, 'prompt polished!', 30); return }
241      const inbox = station(w, 'inbox')
242      goWalk(w, lib, inbox?.floor ?? 0, (inbox?.x ?? w.main.x) + 12)
243      say(w, lib, 'polishing your prompt!', 40)
244      const home = roomNamed(w, 'Library') ?? roomNamed(w, 'Break room')
245      lib.arrive = () => {
246        lib.dir = -1
247        say(w, lib, 'clearer now!', 30)
248        later(w, 35, () => { if (home) goWalk(w, lib, home.floor, home.stations.books ?? (home.x0 + home.x1) / 2) })
249      }
250      return
251    }
252    default: return undefined
253  }
254}
255
256// One session's crab carries a letter to the other's desk.
257function letter(w: World, fromId?: string, toId?: string): void {
258  const from = w.crabs.find(c => c.realId === fromId && c.role === 'session')
259  const to = w.crabs.find(c => c.realId === toId && c.role === 'session')
260  if (!from || !to || from.plan.length) return
261  const home = from.deskX ?? from.x
262  from.acc = [...from.acc, 'letter']
263  say(w, from, `a note for ${to.tag ?? 'you'}`, 30)
264  goWalk(w, from, to.floor, to.x - 11)
265  from.arrive = () => {
266    from.dir = 1
267    to.dir = -1
268    to.sleep = false
269    later(w, 26, () => { from.acc = from.acc.filter(a => a !== 'letter'); to.acc = [...to.acc, 'letter']; say(w, to, 'thanks!', 30) })
270    later(w, 52, () => { to.acc = to.acc.filter(a => a !== 'letter'); goWalk(w, from, from.floor, home) })
271  }
272}
273
274// ---------- helpers ----------
275
276function base(w: World): Accessory[] {
277  return w.main.fast ? ['crown', 'shoes'] : ['crown']
278}
279function num(v: number | boolean | undefined): number {
280  return typeof v === 'number' ? v : 0
281}
282function lookFor(id: string): Accessory {
283  const looks: Accessory[] = ['headphones', 'scarf', 'beanie', 'party']
284  let h = 0
285  for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0
286
287  return looks[h % looks.length] ?? 'scarf'
288}
289// Walks exactly to a station (stations are fixed places, not claimed spots).
290function goWalk(w: World, c: Crab, floor: number, x: number): void {
291  c.plan = []
292  if (floor !== c.floor && w.layout.shaftW > 0) {
293    c.waitX = w.layout.waitX
294    c.plan.push({ walk: w.layout.waitX }, { lift: floor })
295  }
296  c.tFloor = floor
297  c.tx = Math.round(x)
298  c.plan.push({ walk: Math.round(x) })
299}
300function tube(w: World, way: 'up' | 'down'): void {
301  const t = w.layout.tube
302  if (!t) return
303  w.fx.tubeAt = w.tick
304  w.fx.tubeFrom = way === 'up' ? t.floors[1] : t.floors[0]
305  w.fx.tubeTo = way === 'up' ? t.floors[0] : t.floors[1]
306  fxOn(w, 'tube', 22)
307}
308function burst(w: World, kind: 'confetti' | 'coins'): void {
309  const colours = ['#E85A9A', '#7FD1A8', '#E8C15A', '#9FC4E8', '#D97757']
310  for (let i = 0; i < 24; i++) {
311    w.parts.push({
312      kind, x: w.main.x + w.rng.range(-4, 4), y: standY(w, w.main.floor) - 8,
313      vx: w.rng.range(-0.4, 0.4), vy: w.rng.range(-0.6, -0.15), until: w.tick + 30,
314      c: kind === 'coins' ? '#E8C15A' : w.rng.pick(colours),
315    })
316  }
317}
318function puff(w: World, c: Crab): void {
319  for (let i = 0; i < 8; i++) {
320    w.parts.push({ kind: 'smoke', x: c.x + w.rng.range(-2, 2), y: standY(w, c.floor) - 7, vx: w.rng.range(-0.1, 0.1), vy: w.rng.range(-0.2, -0.05), until: w.tick + 25, c: '#888888' })
321  }
322}
323
hooks/office/cells.ts 54 lines
1// Pixels to terminal cells: two pixel rows to a text row with half blocks,
2// text laid over cells, and neighbouring cells drawn alike merged into runs.
3import type { Pixels } from './sprites'
4
5/** One terminal cell: a glyph, its colour and the colour behind it. */
6export type Cell = { ch: string; fg?: string; bg?: string; bold?: boolean }
7
8/** `▀` paints the top pixel over the bottom one; `▄` the bottom alone. */
9export function halfBlocks(px: Pixels): Cell[][] {
10  const width = Math.max(0, ...px.map(row => row.length))
11  const rows: Cell[][] = []
12  for (let y = 0; y < px.length; y += 2) {
13    const cells: Cell[] = []
14    for (let x = 0; x < width; x++) {
15      const top = px[y]?.[x]
16      const bottom = px[y + 1]?.[x]
17      if (top === undefined && bottom === undefined) cells.push({ ch: ' ' })
18      else if (top === undefined) cells.push({ ch: '▄', fg: bottom })
19      else if (bottom === undefined) cells.push({ ch: '▀', fg: top })
20      else if (top === bottom) cells.push({ ch: '█', fg: top })
21      else cells.push({ ch: '▀', fg: top, bg: bottom })
22    }
23    rows.push(cells)
24  }
25
26  return rows
27}
28
29/** Lays `text` over row `row` from column `col`, clipped to the row; `bg` paints behind it. */
30export function writeText(cells: Cell[][], row: number, col: number, text: string, fg: string, bold = false, bg?: string): void {
31  const line = cells[row]
32  if (!line) return
33  ;[...text].forEach((ch, i) => {
34    const at = col + i
35    if (at < 0 || at >= line.length) return
36    const cell: Cell = { ch, fg }
37    if (bold) cell.bold = true
38    if (bg) cell.bg = bg
39    line[at] = cell
40  })
41}
42
43/** Adjacent cells drawn alike, merged into one run each. */
44export function runs(row: Cell[]): Cell[] {
45  const out: Cell[] = []
46  for (const cell of row) {
47    const last = out[out.length - 1]
48    if (last && last.fg === cell.fg && last.bg === cell.bg && !!last.bold === !!cell.bold) last.ch += cell.ch
49    else out.push({ ...cell })
50  }
51
52  return out
53}
54
hooks/office/layout.ts 134 lines
1// Where everything in the office is, for the room the panel gives it: the
2// full building when the panel is large, fewer floors when it is medium, one
3// strip when small. Positions are in pixels; one terminal cell is 1 x 2.
4
5export const TOP = 5
6export const FLOOR_H = 17
7const MAX_FLOOR_H = 26
8const MAX_TOP = 12
9const STREET_H = 6
10const SHAFT = 22
11
12export type Size = 'large' | 'medium' | 'small'
13export type RoomName = 'Main office' | 'Agent workshop' | 'Team room' | 'Sessions' | 'Library' | 'Break room' | 'Kitchen'
14export type StationName =
15  | 'chute' | 'telescope' | 'inbox' | 'board' | 'desk' | 'console' | 'phone' | 'shelf' | 'catalog' | 'tube'
16  | 'kanban' | 'couch' | 'tv' | 'pingpong' | 'coffee' | 'cooler' | 'fridge' | 'books'
17
18export type Room = {
19  name: RoomName
20  floor: number
21  x0: number
22  x1: number
23  /** Where a crab stands to use each thing in the room. */
24  stations: Partial<Record<StationName, number>>
25  /** Seats for real crabs (subagents, sessions), at least 11 px apart. */
26  desks: number[]
27  /** Standing spots for background crabs, at least 11 px apart. */
28  spots: number[]
29}
30export type Floor = { index: number; top: number; standY: number; rooms: Room[] }
31export type Layout = {
32  size: Size
33  width: number
34  height: number
35  /** Each floor's height in pixels: 17, taller where the panel has room. */
36  floorH: number
37  floors: Floor[]
38  /** The elevator shaft's width; 0 where there is one floor. */
39  shaftW: number
40  /** Where crabs queue for the elevator. */
41  waitX: number
42  tube?: { x: number; floors: [number, number] }
43  street: { y: number; doorX: number }
44}
45
46// Each floor's rooms and where each ends, as a fraction of the floor past the shaft.
47const PLANS: Record<Size, [RoomName, number][][]> = {
48  large: [
49    [['Main office', 1]],
50    [['Agent workshop', 0.73], ['Team room', 1]],
51    [['Sessions', 0.6], ['Library', 1]],
52    [['Break room', 0.655], ['Kitchen', 1]],
53  ],
54  medium: [[['Main office', 1]], [['Agent workshop', 0.6], ['Sessions', 1]], [['Break room', 1]]],
55  small: [[['Main office', 0.5], ['Agent workshop', 1]]],
56}
57
58// The main office's stations, left to right, as fractions of its inside.
59const MAIN: [StationName, number][] = [
60  ['chute', 0], ['telescope', 0.0875], ['inbox', 0.175], ['board', 0.275], ['desk', 0.4125],
61  ['console', 0.575], ['phone', 0.6875], ['shelf', 0.8], ['catalog', 0.9125], ['tube', 0.9625],
62]
63
64export function chooseLayout(columns: number, rows: number): Layout {
65  const width = columns
66  const height = rows * 2
67  const fits = (floors: number) => height >= TOP + floors * FLOOR_H + STREET_H
68  const size: Size = columns >= 100 && fits(4) ? 'large' : columns >= 70 && fits(3) ? 'medium' : 'small'
69  const shaftW = size === 'small' ? 0 : SHAFT
70  const span = width - shaftW
71  // A tall panel gets taller floors, then a little more sky; the street keeps the rest.
72  const count = PLANS[size].length
73  const minTop = size === 'small' ? 3 : TOP
74  const floorH = Math.max(FLOOR_H, Math.min(MAX_FLOOR_H, Math.floor((height - minTop - STREET_H) / count)))
75  const top = Math.max(minTop, Math.min(MAX_TOP, height - STREET_H - count * floorH))
76
77  const floors = PLANS[size].map((plan, index): Floor => {
78    const floorTop = top + index * floorH
79    let x0 = shaftW
80    const rooms = plan.map(([name, end]): Room => {
81      const x1 = Math.round(shaftW + end * span)
82      const room = furnish(name, index, x0, x1, plan.map(([n]) => n))
83      x0 = x1
84
85      return room
86    })
87
88    return { index, top: floorTop, standY: floorTop + floorH - 1, rooms }
89  })
90  const lastStand = floors[floors.length - 1]?.standY ?? top + floorH - 1
91
92  return {
93    size,
94    width,
95    height,
96    floorH,
97    floors,
98    shaftW,
99    waitX: shaftW + 5,
100    ...(size === 'large' ? { tube: { x: width - 4, floors: [0, 2] as [number, number] } } : {}),
101    street: { y: lastStand + 1, doorX: width - 4 },
102  }
103}
104
105// A room's stations, desks and spots, from its kind and its neighbours.
106function furnish(name: RoomName, floor: number, x0: number, x1: number, onFloor: RoomName[]): Room {
107  const lo = x0 + 4
108  const hi = x1 - 5
109  const at = (f: number) => Math.round(lo + f * (hi - lo))
110  const every = (from: number, step: number) => {
111    const out: number[] = []
112    for (let x = from; x <= hi; x += step) out.push(x)
113
114    return out
115  }
116  const stations: Room['stations'] = {}
117  let desks: number[] = []
118
119  if (name === 'Main office') for (const [s, f] of MAIN) stations[s] = at(f)
120  if (name === 'Agent workshop' || name === 'Sessions') desks = every(lo + 6, 18)
121  if (name === 'Agent workshop' && !onFloor.includes('Team room')) stations.kanban = at(0.92)
122  if (name === 'Team room') { stations.kanban = at(0.5); stations.desk = at(0.3) }
123  if (name === 'Library') { stations.books = at(0.4); stations.shelf = at(0.15) }
124  if (name === 'Break room') {
125    stations.couch = at(0.1)
126    stations.tv = at(0.35)
127    stations.pingpong = at(0.6)
128    if (!onFloor.includes('Kitchen')) { stations.coffee = at(0.8); stations.books = at(0.95) }
129  }
130  if (name === 'Kitchen') { stations.coffee = at(0.15); stations.cooler = at(0.35); stations.fridge = at(0.85) }
131
132  return { name, floor, x0, x1, stations, desks, spots: every(lo + 2, 12) }
133}
134
hooks/office/render.ts 239 lines
1// Draws the world as terminal cells: the building, its furniture and
2// effects, the crabs with their accessories, then text over the pixels (room
3// names, name tags, speech bubbles, the elevator's "n/2" and the street's
4// "+N" sign). Ported from docs/mockup/crab-town-v5.html.
5import { halfBlocks, writeText } from './cells'
6import type { Cell } from './cells'
7import type { Room } from './layout'
8import { LIFT_CAP, isFx, standY } from './sim'
9import type { Crab, World } from './sim'
10import { blank, drawCrab, paint } from './sprites'
11import type { Pixels } from './sprites'
12
13const WALLS: Record<Room['name'], [string, string]> = {
14  'Main office': ['#3B302A', '#2B2622'],
15  'Agent workshop': ['#2C3A40', '#232A2E'],
16  'Team room': ['#3A2C36', '#2A2028'],
17  Sessions: ['#332D40', '#26232E'],
18  Library: ['#3A2E26', '#2A231E'],
19  'Break room': ['#3A332A', '#2A2620'],
20  Kitchen: ['#2F3A2A', '#242A20'],
21}
22const SPINES = ['#3E6AA8', '#D97757', '#E8C15A', '#2E9E6A', '#9A5CB4']
23const num = (v: number | boolean | undefined) => (typeof v === 'number' ? v : 0)
24
25/**
26 * How much fine pixel work to draw: `low` merges the busiest details (book
27 * spines, window bars, lamps, drawer knobs, rack lights) so a big panel's
28 * drawing stays under the Client's size limit.
29 */
30export type Detail = 'full' | 'low'
31
32export function drawWorld(w: World, detail: Detail = 'full'): Cell[][] {
33  const L = w.layout
34  const t = w.tick
35  const px = blank(L.width, L.height)
36  const rect = (x: number, y: number, ww: number, h: number, c: string) => paint(px, x, y, ww, h, c)
37  const dot = (x: number, y: number, c: string) => paint(px, x, y, 1, 1, c)
38  const top = L.floors[0]?.top ?? 0
39
40  // the sky and its drifting clouds
41  rect(0, 0, L.width, L.height, '#0E0D0C')
42  rect(0, 0, L.width, top, '#3E6E9E')
43  for (const seed of [8, 40, 75, 100]) {
44    const cx = ((seed + t * 0.015 * 2) % (L.width + 8)) - 8
45    rect(cx, seed % 3, 6, 1, '#DDE8F2')
46    rect(cx + 1, (seed % 3) + 1, 3, 1, '#F2F6FA')
47  }
48  if (L.size === 'large') { rect(L.width - 20, top - 3, 1, 3, '#888888'); rect(L.width - 22, top - 4, 5, 1, '#BBBBBB'); dot(L.width - 20, top - 5, isFx(w, 'dish') && t % 4 < 2 ? '#7FD1A8' : '#555555') }
49
50  // rooms: walls, floors, windows, lamps, doorways
51  for (const floor of L.floors) {
52    for (const r of floor.rooms) {
53      const [wall, fl] = WALLS[r.name]
54      const dark = r.name === 'Main office' && isFx(w, 'lightsOff')
55      rect(r.x0, floor.top, r.x1 - r.x0, L.floorH, dark ? '#16120F' : wall)
56      rect(r.x0, floor.standY - 1, r.x1 - r.x0, 2, fl)
57      if (r.name !== 'Library' && r.name !== 'Team room' && r.x1 - r.x0 > 24) {
58        const wx = r.x0 + 3
59        rect(wx, floor.top + 4, 7, 5, '#4D7FB0')
60        if (detail === 'full') {
61          rect(wx + ((t / 30 + floor.index * 2) % 6), floor.top + 5, 2, 1, '#E8F0F6')
62          rect(wx + 3, floor.top + 4, 1, 5, '#2A2420')
63          rect(wx, floor.top + 6, 7, 1, '#2A2420')
64        }
65      }
66      if (!dark && detail === 'full') {
67        for (const f of [0.35, 0.75]) {
68          const lx = Math.floor(r.x0 + (r.x1 - r.x0) * f)
69          const alarm = r.name === 'Main office' && isFx(w, 'alarm')
70          dot(lx, floor.top + 1, '#555555')
71          rect(lx - 1, floor.top + 2, 3, 1, alarm ? (t % 4 < 2 ? '#FF3B30' : '#7A1A15') : '#E8D7A0')
72        }
73      }
74      if (r.x0 > L.shaftW) rect(r.x0, floor.top, 1, 8, '#1A1816')
75      furnish(w, px, r, floor.top, floor.standY, t, detail)
76    }
77    rect(0, floor.top, L.width, 1, '#1A1816')
78  }
79
80  // the elevator shaft and its car
81  if (L.shaftW > 0) {
82    const last = L.floors[L.floors.length - 1]
83    const shaftH = (last?.standY ?? 0) + 1 - top
84    rect(0, top, L.shaftW, shaftH, '#1E1C1A')
85    rect(L.shaftW - 1, top, 1, shaftH, '#111111')
86    for (const f of L.floors) { rect(1, f.standY - 10, L.shaftW - 2, 1, '#3A3A3A'); dot(L.shaftW, f.standY - 11, w.lift.floor === f.index && w.lift.door > 0.5 ? '#7FD1A8' : '#7A2A2A') }
87    const cy = w.lift.y - 9
88    rect(10, top + 1, 1, Math.max(0, cy - top - 1), '#444444')
89    rect(1, cy, L.shaftW - 2, 10, '#6B6B70')
90    rect(2, cy + 1, L.shaftW - 4, 8, '#2B2B2E')
91    w.lift.riders.forEach((r, i) => crab(w, px, r, 6 + i * 10, w.lift.y))
92    const half = ((L.shaftW - 4) / 2) * (1 - w.lift.door)
93    if (half >= 0.5) { rect(2, cy + 1, half, 8, '#8A8A90'); rect(2 + (L.shaftW - 4) - half, cy + 1, half, 8, '#8A8A90') }
94  }
95
96  // the street and its door
97  rect(0, L.street.y, L.width, L.height - L.street.y, '#2A2A2A')
98  for (let x = (t / 4) % 10; x < L.width; x += 10) rect(x, L.height - 2, 4, 1, '#555555')
99  const lastStand = L.floors[L.floors.length - 1]?.standY ?? L.street.y
100  rect(L.width - 7, lastStand - 9, 6, 9, '#4B3325')
101  dot(L.width - 3, lastStand - 5, '#E8C15A')
102  if (isFx(w, 'truck')) { const tx = L.width - ((t - num(w.fx.truckAt)) / 40) * 30; rect(tx, L.height - 6, 10, 4, '#3E6AA8'); rect(tx - 3, L.height - 5, 3, 3, '#9FD3E8') }
103  if (isFx(w, 'bird')) { const k = Math.min(1, (t - num(w.fx.birdAt)) / 30); rect(2 + k * 30, top + 1 + k * 4, 2, 1, '#DDDDDD') }
104
105  // the pneumatic tube between Sessions and the Main office
106  const tube = L.tube
107  if (tube) {
108    const a = L.floors[tube.floors[0]]?.standY ?? 0
109    const b = L.floors[tube.floors[1]]?.standY ?? 0
110    rect(tube.x, a - 6, 2, b - a + 1, '#7F9AA8')
111    if (isFx(w, 'tube')) {
112      const k = Math.min(1, (t - num(w.fx.tubeAt)) / 20)
113      const from = (L.floors[num(w.fx.tubeFrom)]?.standY ?? a) - 5
114      const to = (L.floors[num(w.fx.tubeTo)]?.standY ?? a) - 5
115      rect(tube.x, from + (to - from) * k, 2, 2, '#E8C15A')
116    }
117  }
118
119  // the crabs, then dust and particles
120  for (const c of w.crabs) if (!c.inLift) crab(w, px, c, c.x - 4, standY(w, c.floor))
121  for (const d of w.dust) dot(d.x + ((t * 7) % 5) - 2, d.y - ((t * 3) % 3), '#DDDDDD')
122  for (const p of w.parts) { p.x += p.vx; p.y += p.vy; p.vy += p.kind === 'smoke' ? -0.005 : 0.04; dot(p.x, p.y, p.c) }
123
124  // text over the pixels
125  const cells = halfBlocks(px)
126  const row = (y: number) => Math.floor(y / 2)
127  for (const floor of L.floors) for (const r of floor.rooms) writeText(cells, row(floor.top + 2), r.x0 + 2, r.name, '#DDDDDD', true)
128  for (const c of w.crabs) {
129    if (c.inLift || !c.tag || (c.tagUntil !== undefined && t >= c.tagUntil)) continue
130    writeText(cells, row(standY(w, c.floor) + 1), Math.round(c.x) - 4, c.tag, c.tagColor ?? '#DDDDDD', true)
131  }
132  for (const b of w.bubbles) {
133    const ri = w.lift.riders.indexOf(b.crab)
134    const x = ri >= 0 ? 4 + ri * 10 : Math.round(b.crab.x) - 3
135    const y = ri >= 0 ? w.lift.y : standY(w, b.crab.floor)
136    const col = Math.max(0, Math.min(L.width - b.text.length - 2, x))
137    writeText(cells, row(y - 12), col, ` ${b.text} `, '#2A211D', false, '#F5EEDC')
138  }
139  if (L.shaftW > 0) writeText(cells, row(w.lift.y - 11), 8, `${w.lift.riders.length}/${LIFT_CAP}`, '#E8C15A', true)
140  const waiting = w.street.waiting.length
141  if (waiting > 0) writeText(cells, row(L.street.y + 2), L.width - 10, ` +${waiting} `, '#2A211D', true, '#E8C15A')
142
143  return cells
144}
145
146function crab(w: World, px: Pixels, c: Crab, x: number, stand: number): void {
147  const shake = c.shake && w.tick % 2 ? 1 : 0
148  drawCrab(px, Math.round(x) + shake, stand - 6, {
149    legsPhase: c.moving ? ((Math.floor(c.x * 1.2) & 3) as 0 | 1 | 2 | 3) : 0,
150    blink: !!c.sleep || (w.tick + c.seed) % 60 < 2,
151    dir: c.dir,
152    acc: c.acc,
153    t: w.tick,
154    hop: c.hop,
155    crown: c.role === 'main' ? w.crown : undefined,
156  })
157}
158
159// Each room's furniture, from its stations and desks.
160function furnish(w: World, px: Pixels, r: Room, top: number, f: number, t: number, detail: Detail): void {
161  const rect = (x: number, y: number, ww: number, h: number, c: string) => paint(px, x, y, ww, h, c)
162  const fine = detail === 'full'
163  const dot = (x: number, y: number, c: string) => { if (fine) paint(px, x, y, 1, 1, c) }
164  const s = r.stations
165  const desk = (x: number, on: boolean) => {
166    rect(x - 5, f - 3, 11, 1, '#6B4A35'); rect(x - 4, f - 2, 1, 2, '#4B3325'); rect(x + 4, f - 2, 1, 2, '#4B3325')
167    rect(x - 3, f - 7, 5, 4, '#2A2A2A'); rect(x - 2, f - 6, 3, 2, on ? (t % 4 < 2 ? '#7FD1A8' : '#5FB18A') : '#2F3F45')
168  }
169  // Full detail: a spine per book. Low: each shelf's books as one coloured band.
170  const shelf = (x: number) => {
171    rect(x, f - 12, 6, 12, '#5A3E2A')
172    for (let j = 0; j < 3; j++) {
173      if (fine) for (let i = 0; i < 5; i++) rect(x + i, f - 11 + j * 4, 1, 3, SPINES[(i + j * 2 + x) % 5] ?? '#3E6AA8')
174      else rect(x, f - 11 + j * 4, 5, 3, SPINES[(j + x) % 5] ?? '#3E6AA8')
175    }
176  }
177
178  if (s.chute !== undefined) { rect(s.chute - 2, top + 10, 3, f - top - 10, '#5A5A60'); rect(s.chute - 3, top + 10, 5, 2, '#777777') }
179  if (s.telescope !== undefined) { const x = s.telescope - 2; rect(x, f - 4, 1, 4, '#777777'); rect(x + 3, f - 4, 1, 4, '#777777'); rect(x, f - 7, 6, 2, '#B08D57'); rect(x + 5, f - 8, 2, 2, '#B08D57') }
180  if (s.inbox !== undefined) {
181    const x = s.inbox
182    rect(x, f - 5, 1, 5, '#555555'); rect(x - 2, f - 6, 5, 1, '#8A6A4A')
183    if (isFx(w, 'letter')) { w.fx.letterY = Math.min(f - 7, num(w.fx.letterY) + 0.3); rect(x - 1, top + num(w.fx.letterY), 3, 2, '#F5EEDC') }
184  }
185  if (s.board !== undefined) {
186    const x = s.board - 4
187    rect(x, top + 4, 11, 7, '#EEEEEE')
188    for (let i = 0; i < Math.min(3, num(w.fx.todos)); i++) { dot(x + 2, top + 6 + i * 2, i < num(w.fx.ticked) ? '#2E9E6A' : '#AAAAAA'); rect(x + 4, top + 6 + i * 2, 5, 1, '#3E6AA8') }
189    for (let i = 0; i < num(w.fx.stickies); i++) rect(x + 8 - i * 2, top + 9, 2, 1, '#E8C15A')
190    if (w.fx.picture) { rect(x + 12, top + 4, 6, 5, '#B08D57'); rect(x + 13, top + 5, 4, 3, '#4D7FB0') }
191  }
192  if (s.desk !== undefined && r.name === 'Main office') {
193    const x = s.desk
194    rect(x - 6, f - 3, 14, 1, '#6B4A35'); rect(x - 5, f - 2, 1, 2, '#4B3325'); rect(x + 6, f - 2, 1, 2, '#4B3325')
195    rect(x - 4, f - 7, 5, 4, '#2A2A2A'); rect(x - 3, f - 6, 3, 2, isFx(w, 'typing') ? (t % 4 < 2 ? '#7FD1A8' : '#5FB18A') : '#2F3F45')
196    if (num(w.fx.context) > 0.8) for (let i = 0; i < 4; i++) rect(x + 2 + (i % 2), f - 5 - i, 3, 1, '#F5EEDC')
197    if (isFx(w, 'phoneDock') && t % 4 < 2) dot(x + 5, f - 5, '#7FD1A8')
198    if (w.fx.pr) { rect(x + 4, top + 4, 5, 4, '#2E9E6A'); rect(x + 5, top + 5, 3, 1, '#F5EEDC') }
199    // the context gauge on the wall
200    const g = num(w.fx.context) || 0.3
201    rect(x + 11, top + 3, 1, 8, '#222222'); rect(x + 11, top + 11 - Math.round(8 * g), 1, Math.round(8 * g), g > 0.8 ? '#D9534F' : '#7FD1A8')
202  }
203  if (s.console !== undefined) {
204    const x = s.console - 4
205    const alarm = isFx(w, 'alarm')
206    rect(x, f - 9, 8, 9, '#222222'); rect(x + 1, f - 8, 6, 4, alarm ? '#3A0B0B' : '#0B2A1A')
207    for (let i = 0; i < 3; i++) rect(x + 1, f - 8 + i, 1 + ((i * 3 + Math.floor(t / 2)) % 5), 1, alarm ? '#FF6B5B' : '#3DB47A')
208    if (isFx(w, 'hourglass')) { rect(x + 5, f - 12, 3, 1, '#B08D57'); rect(x + 5, f - 9, 3, 1, '#B08D57'); dot(x + 6, f - 10 - (Math.floor(t / 6) % 2), '#E8C15A') }
209  }
210  if (s.phone !== undefined) { const x = s.phone - 3; rect(x, f - 3, 7, 1, '#6B4A35'); rect(x + 1, f - 2, 1, 2, '#4B3325'); rect(x + 5, f - 2, 1, 2, '#4B3325'); if (!isFx(w, 'phone')) rect(x + 2, f - 5, 3, 2, '#D9534F') }
211  if (s.shelf !== undefined) shelf(s.shelf - 3)
212  if (s.catalog !== undefined) {
213    const x = s.catalog - 4
214    rect(x, f - 7, 8, 7, '#7A5A3A')
215    for (let j = 0; j < 3; j++) for (let i = 0; i < 2; i++) { rect(x + 1 + i * 4, f - 6 + j * 2, 3, 1, '#5A3E2A'); dot(x + 2 + i * 4, f - 6 + j * 2, '#E8C15A') }
216    if (w.fx.poster) { rect(x, top + 3, 7, 5, '#F5EEDC'); rect(x + 1, top + 4, 5, 1, '#D97757') }
217  }
218  for (const x of r.desks) desk(x, w.crabs.some(c => c.deskX === x && c.state === 'work' && !c.idle && c.floor === r.floor))
219  if (s.kanban !== undefined) {
220    const x = s.kanban - 8
221    rect(x, top + 3, 16, 8, '#EEEEEE')
222    for (let i = 0; i < Math.min(6, num(w.fx.tasksTodo)); i++) rect(x + 1 + (i % 2) * 3, top + 5 + Math.floor(i / 2) * 2, 2, 1, '#E8C15A')
223    for (let i = 0; i < Math.min(6, num(w.fx.tasksDone)); i++) rect(x + 10 + (i % 2) * 3, top + 5 + Math.floor(i / 2) * 2, 2, 1, '#7FD1A8')
224  }
225  if (r.name === 'Agent workshop' && r.x1 - r.x0 > 50) {
226    const x = r.x1 - 8
227    rect(x, f - 12, 5, 12, '#222222')
228    for (let j = 0; j < 5; j++) dot(x + 3, f - 11 + j * 2, Math.floor(t / 4 + j * 3) % 4 ? '#3DB47A' : '#E8C15A')
229  }
230  if (r.name === 'Library') { for (let x = r.x0 + 6; x < r.x1 - 10; x += 8) shelf(x); rect(r.x1 - 7, f - 3, 3, 3, '#8A5A3A'); rect(r.x1 - 7, f - 7, 3, 4, '#3DB47A') }
231  if (s.couch !== undefined) { const x = s.couch - 6; rect(x, f - 3, 14, 2, '#5A3E66'); rect(x, f - 5, 14, 2, '#6E4C7D') }
232  if (s.tv !== undefined) { rect(s.tv - 4, top + 4, 8, 6, '#222222'); rect(s.tv - 3, top + 5, 6, 4, ['#4D7FB0', '#7FD1A8', '#D97757'][Math.floor(t / 20) % 3] ?? '#4D7FB0') }
233  if (s.pingpong !== undefined) { rect(s.pingpong - 6, f - 3, 12, 1, '#2E7D5B'); dot(s.pingpong, f - 4, '#DDDDDD') }
234  if (s.coffee !== undefined) { const x = s.coffee - 2; rect(x, f - 6, 4, 6, '#3A3A3A'); dot(x + 1, f - 4, '#D97757'); dot(x + 1 + (Math.floor(t / 5) % 2), f - 8, '#888888') }
235  if (s.cooler !== undefined) { rect(s.cooler - 1, f - 7, 3, 3, '#9FD3E8'); rect(s.cooler - 1, f - 4, 3, 4, '#DDDDDD') }
236  if (s.fridge !== undefined) { rect(s.fridge - 3, f - 11, 7, 11, '#C9CED2'); rect(s.fridge + 2, f - 9, 1, 2, '#888888') }
237  if (s.books !== undefined && r.name !== 'Library') shelf(s.books - 3)
238}
239
hooks/office/sim.ts 554 lines
1// The office's simulation, deterministic from a seed: crabs that walk with
2// eased steps, an elevator that carries two, standing spots that keep crabs
3// apart, background crabs living their own lives, and real crabs (subagents,
4// sessions, teammates) seated at desks or queued on the street behind a
5// "+N" sign. Ported from docs/mockup/crab-town-v5.html; one frame is 50 ms,
6// and the logic ticks every second frame (10 a second).
7import type { Layout, Room, RoomName, StationName } from './layout'
8import { createRng } from './rng'
9import type { Rng } from './rng'
10import type { Accessory } from './sprites'
11
12export type Role = 'main' | 'librarian' | 'agent' | 'teammate' | 'session' | 'decor' | 'leaving'
13type Step = { walk: number } | { lift: number }
14
15export type Crab = {
16  id: number
17  role: Role
18  floor: number
19  /** The body's centre column, in pixels. */
20  x: number
21  v: number
22  dir: 1 | -1
23  acc: Accessory[]
24  seed: number
25  plan: Step[]
26  moving: boolean
27  wait: number
28  tag?: string
29  tagColor?: string
30  tagUntil?: number
31  realId?: string
32  deskX?: number
33  /** A real crab's stage: arriving, at work, reporting, or retired. */
34  state?: 'arriving' | 'work' | 'reporting' | 'retired'
35  tFloor?: number
36  tx?: number
37  waitX?: number
38  awaitLift?: boolean
39  liftTo?: number
40  queuedAt?: number
41  inLift?: boolean
42  sleep?: boolean
43  hop?: number
44  shake?: boolean
45  fast?: boolean
46  idle?: boolean
47  fight?: Crab
48  fightUntil?: number
49  goHomeAt?: number
50  arrive?: () => void
51}
52
53export type RealSpec = { role: 'agent' | 'session' | 'teammate'; realId: string; tag: string; acc: Accessory[] }
54export type Bubble = { crab: Crab; text: string; until: number }
55export type Particle = { kind: 'confetti' | 'coins' | 'smoke'; x: number; y: number; vx: number; vy: number; until: number; c: string }
56
57export type World = {
58  layout: Layout
59  rng: Rng
60  frameNo: number
61  tick: number
62  crabs: Crab[]
63  main: Crab
64  librarian?: Crab
65  lift: { floor: number; y: number; door: number; riders: Crab[]; target: number | null; dwell: number; v: number; state: 'idle' | 'closing' | 'moving' | 'opening' | 'open' }
66  street: { waiting: RealSpec[] }
67  bubbles: Bubble[]
68  timers: { at: number; fn: () => void }[]
69  /** Room effects keyed by name: an end tick, or a value (todos, context). */
70  fx: Record<string, number | boolean | undefined>
71  parts: Particle[]
72  dust: { x: number; y: number; until: number }[]
73  crown: string
74  nextId: number
75  /** Background crabs the current layout has no room for, kept for a bigger one. */
76  parked: Crab[]
77}
78
79export const GAP = 11
80export const DECOR_CAP = 6
81export const LIFT_CAP = 2
82const RANK: Record<Role, number> = { main: 0, librarian: 1, agent: 2, teammate: 2, session: 3, decor: 4, leaving: 5 }
83
84export const LINES: string[][] = [
85  ['ship it!', 'tests first.'], ['who broke main?', 'not me', 'git blame says u'], ['need coffee', 'same'],
86  ['LGTM', 'u didnt read it', '...LGTM'], ['tabs or spaces?', 'FIGHT ME'], ['works on my machine', 'ship ur machine'],
87  ['found the bug!', 'what was it', 'a typo'], ['deploy friday?', 'no.'], ['merge conflict :(', 'rebase?', 'scary'],
88  ['nice PR!', 'ty <3'], ['token budget!!', 'compact it'], ['is it lunch', 'its 10am'],
89  ['did u see that diff?', '400 files', 'lol'], ['who ate my snack', '...', 'the Explore crab'],
90  ['how was the job?', 'grep grep grep', 'living the dream'], ['the boss is busy', 'big refactor', 'brave'],
91  ['i got a hat today', 'nice hat'], ['elevator again?', 'only fits two', 'cozy'],
92]
93const DECOR_LOOKS: Accessory[][] = [['mug'], ['party'], [], ['beanie'], ['scarf'], ['headphones']]
94
95// ---------- the layout, by name ----------
96
97export function rooms(w: World): Room[] {
98  return w.layout.floors.flatMap(f => f.rooms)
99}
100export function roomNamed(w: World, name: RoomName): Room | undefined {
101  return rooms(w).find(r => r.name === name)
102}
103/** The first room holding a station, and where it is. */
104export function station(w: World, name: StationName): { floor: number; x: number } | undefined {
105  for (const r of rooms(w)) {
106    const x = r.stations[name]
107    if (x !== undefined) return { floor: r.floor, x }
108  }
109
110  return undefined
111}
112export function standY(w: World, floor: number): number {
113  return w.layout.floors[floor]?.standY ?? w.layout.floors[0]?.standY ?? 0
114}
115const bottom = (w: World) => w.layout.floors.length - 1
116const bounds = (w: World): [number, number] => [w.layout.shaftW + 5, w.layout.width - 8]
117
118// ---------- the world ----------
119
120export function createWorld(layout: Layout, seed: number): World {
121  const main = { id: 1, role: 'main' as const, floor: 0, x: 0, v: 0, dir: -1 as const, acc: ['crown' as const], seed: 0, plan: [], moving: false, wait: 0, tag: 'main', tagColor: '#E8C15A' }
122  const w: World = {
123    layout, rng: createRng(seed), frameNo: 0, tick: 0, crabs: [main], main,
124    lift: { floor: 0, y: 0, door: 1, riders: [], target: null, dwell: 0, v: 0, state: 'idle' },
125    street: { waiting: [] }, bubbles: [], timers: [], fx: {}, parts: [], dust: [], crown: '#E8C15A', nextId: 2, parked: [],
126  }
127  placeAll(w)
128  topUpDecor(w)
129
130  return w
131}
132
133// How many background crabs each size starts with.
134const DECOR_START: Record<Layout['size'], number> = { large: 4, medium: 3, small: 0 }
135
136// Brings the background crabs up to the layout's starting number.
137function topUpDecor(w: World): void {
138  for (let i = w.crabs.filter(c => c.role === 'decor').length; i < DECOR_START[w.layout.size]; i++) {
139    const floor = Math.max(1, bottom(w) - (i % 2))
140    const c = makeCrab(w, { role: 'decor', floor, acc: [...(DECOR_LOOKS[i] ?? [])], wait: i * 15 })
141    c.x = freeSpot(w, c, floor, w.rng.range(...bounds(w)))
142  }
143}
144
145// Seats the main crab and the librarian, and parks the lift, for the layout.
146function placeAll(w: World): void {
147  const desk = station(w, 'desk')
148  w.main.floor = desk?.floor ?? 0
149  w.main.x = desk?.x ?? w.layout.width / 2
150  w.lift.floor = bottom(w)
151  w.lift.y = standY(w, bottom(w))
152  const home = roomNamed(w, 'Library') ?? roomNamed(w, 'Break room')
153  if (home && !w.librarian) {
154    w.librarian = makeCrab(w, { role: 'librarian', floor: home.floor, acc: ['librarian', 'book'], tag: 'enhancer', tagColor: '#E8A0C8' })
155  }
156  if (w.librarian && home) {
157    w.librarian.floor = home.floor
158    w.librarian.x = freeSpot(w, w.librarian, home.floor, home.stations.books ?? (home.x0 + home.x1) / 2)
159  }
160}
161
162function makeCrab(w: World, o: Partial<Crab> & Pick<Crab, 'role' | 'floor'>): Crab {
163  const c: Crab = { id: w.nextId++, x: 0, v: 0, dir: 1, acc: [], seed: Math.floor(w.rng.range(0, 60)), plan: [], moving: false, wait: 0, ...o }
164  w.crabs.push(c)
165
166  return c
167}
168
169export function say(w: World, crab: Crab, text: string, ticks = 30): void {
170  w.bubbles.push({ crab, text, until: w.tick + ticks })
171}
172export function later(w: World, ticks: number, fn: () => void): void {
173  w.timers.push({ at: w.tick + ticks, fn })
174}
175export function fxOn(w: World, name: string, ticks: number): void {
176  w.fx[name] = w.tick + ticks
177}
178export function isFx(w: World, name: string): boolean {
179  const v = w.fx[name]
180
181  return typeof v === 'number' && v > w.tick
182}
183
184// ---------- standing room ----------
185
186function taken(w: World, floor: number, x: number, self: Crab): boolean {
187  return w.crabs.some(o => o !== self && !o.inLift && (
188    (o.tFloor === floor && o.tx !== undefined && Math.abs(o.tx - x) < GAP) ||
189    (o.floor === floor && (!o.plan.length || o.awaitLift) && Math.abs(o.x - x) < GAP) ||
190    (o.floor === floor && o.waitX !== undefined && Math.abs(o.waitX - x) < GAP)))
191}
192
193export function freeSpot(w: World, c: Crab, floor: number, want: number): number {
194  const [lo, hi] = bounds(w)
195  for (let d = 0; d < 120; d++) {
196    for (const s of [1, -1]) {
197      const x = Math.round(want + s * d)
198      if (x >= lo && x <= hi && !taken(w, floor, x, c)) return x
199    }
200  }
201
202  return Math.round(Math.min(hi, Math.max(lo, want)))
203}
204
205/** Walks to a spot on a floor, taking the elevator to another, claiming it. */
206export function goTo(w: World, c: Crab, floor: number, want: number, exact = false): number {
207  const x = exact ? want : freeSpot(w, c, floor, want)
208  c.plan = []
209  c.tFloor = floor
210  c.tx = x
211  if (floor !== c.floor) {
212    const wx = freeSpot(w, c, c.floor, w.layout.waitX)
213    c.waitX = wx
214    c.plan.push({ walk: wx }, { lift: floor })
215  }
216  c.plan.push({ walk: x })
217
218  return x
219}
220
221// When two standing crabs end up too close, the less important steps aside.
222function unclump(w: World): void {
223  const standing = w.crabs.filter(c => !c.plan.length && !c.inLift && !c.awaitLift && !c.fight && c.role !== 'leaving')
224  for (const a of standing) {
225    for (const b of standing) {
226      if (a === b || a.floor !== b.floor || Math.abs(a.x - b.x) >= GAP - 2 || b.plan.length) continue
227      if (RANK[a.role] > RANK[b.role] || (RANK[a.role] === RANK[b.role] && a.id > b.id)) continue
228      if ((b.role === 'session' || b.role === 'agent') && b.x === b.deskX) continue
229      const x = freeSpot(w, b, b.floor, b.x)
230      if (Math.abs(x - b.x) >= 1) goTo(w, b, b.floor, x, true)
231    }
232  }
233}
234
235// ---------- motion ----------
236
237function move(w: World, c: Crab): void {
238  if (c.fight) {
239    const mate = c.fight
240    if (w.tick < (c.fightUntil ?? 0)) {
241      const b = w.frameNo % 4 < 2
242      c.hop = b ? -1 : 0
243      mate.hop = b ? 0 : -1
244      if (w.frameNo % 6 === 0) w.dust.push({ x: (c.x + mate.x) / 2, y: standY(w, c.floor) - 4, until: w.tick + 3 })
245
246      return
247    }
248    c.hop = 0
249    mate.hop = 0
250    c.fight = undefined
251  }
252  const step = c.plan[0]
253  if (!step || c.inLift) { c.moving = false; c.v = 0; return }
254  if ('walk' in step) {
255    const dx = step.walk - c.x
256    if (Math.abs(dx) < 0.15) {
257      c.x = step.walk
258      c.plan.shift()
259      c.moving = false
260      c.v = 0
261      if (!c.plan.length) {
262        c.tx = undefined
263        const f = c.arrive
264        c.arrive = undefined
265        f?.()
266      }
267
268      return
269    }
270    const max = c.fast ? 0.5 : 0.25
271    const want = Math.sign(dx) * Math.min(max, Math.abs(dx) * 0.18 + 0.04)
272    c.v += (want - c.v) * 0.25
273    c.dir = dx > 0 ? 1 : -1
274    c.x += c.v
275    c.moving = true
276    if (Math.sign(step.walk - c.x) !== Math.sign(dx)) c.x = step.walk
277  } else {
278    c.moving = false
279    c.v = 0
280    if (c.floor === step.lift && !c.inLift) { c.plan.shift(); c.awaitLift = false; return }
281    if (!c.awaitLift) { c.awaitLift = true; c.queuedAt = w.tick }
282    c.liftTo = step.lift
283  }
284}
285
286function stepLift(w: World): void {
287  const lift = w.lift
288  const waiting = () => w.crabs.filter(c => c.awaitLift && !c.inLift).sort((a, b) => (a.queuedAt ?? 0) - (b.queuedAt ?? 0))
289  if (lift.state === 'idle') {
290    if (lift.riders.length) lift.target = lift.riders[0]?.liftTo ?? lift.floor
291    else {
292      const first = waiting()[0]
293      if (!first) return
294      lift.target = first.floor
295    }
296    lift.state = lift.target === lift.floor ? 'open' : 'closing'
297    lift.dwell = 0
298  }
299  if (lift.state === 'closing') { lift.door = Math.max(0, lift.door - 0.08); if (lift.door === 0) lift.state = 'moving'; return }
300  if (lift.state === 'moving') {
301    const ty = standY(w, lift.target ?? 0)
302    const dist = ty - lift.y
303    if (Math.abs(dist) < 0.05) { lift.y = ty; lift.floor = lift.target ?? 0; lift.v = 0; lift.state = 'opening'; return }
304    const want = Math.sign(dist) * Math.min(0.45, Math.abs(dist) * 0.12 + 0.04)
305    lift.v += (want - lift.v) * 0.2
306    lift.y += lift.v
307    if (Math.sign(ty - lift.y) !== Math.sign(dist)) lift.y = ty
308
309    return
310  }
311  if (lift.state === 'opening') { lift.door = Math.min(1, lift.door + 0.08); if (lift.door === 1) { lift.state = 'open'; lift.dwell = 0 } return }
312  if (lift.dwell++ === 4) {
313    lift.riders.filter(r => r.liftTo === lift.floor).forEach((r, i) => {
314      r.inLift = false
315      r.awaitLift = false
316      r.waitX = undefined
317      r.floor = lift.floor
318      r.x = w.layout.shaftW + 3 + i * 10
319      r.v = 0
320    })
321    lift.riders = lift.riders.filter(r => r.inLift)
322    for (const c of waiting().filter(c => c.floor === lift.floor)) {
323      if (lift.riders.length >= LIFT_CAP) break
324      c.inLift = true
325      c.waitX = undefined
326      lift.riders.push(c)
327    }
328  }
329  if (lift.dwell > 14) lift.state = 'idle'
330}
331
332// ---------- background crabs ----------
333
334function idle(w: World, c: Crab): void {
335  if (c.plan.length || c.inLift || c.awaitLift) return
336  if (c.goHomeAt !== undefined && w.tick > c.goHomeAt) { leave(w, c); return }
337  if (c.wait > 0) { c.wait--; return }
338  c.sleep = false
339  const roll = w.rng.next()
340  const floors = w.layout.floors.length
341  const at = (s: StationName) => station(w, s)
342  if (roll < 0.26) {
343    const f = floors > 1 && w.rng.next() < 0.3 ? 1 + Math.floor(w.rng.range(0, floors - 1)) : c.floor
344    goTo(w, c, f, w.rng.range(...bounds(w)))
345    c.wait = 20
346  } else if (roll < 0.52) {
347    const mate = w.crabs.find(o => o !== c && o.role === 'decor' && o.floor === c.floor && !o.plan.length && !o.inLift && !o.awaitLift && Math.abs(o.x - c.x) < 30 && Math.abs(o.x - c.x) >= GAP)
348    if (!mate) { c.wait = 30; return }
349    const lines = w.rng.pick(LINES)
350    c.dir = mate.x > c.x ? 1 : -1
351    mate.dir = c.dir === 1 ? -1 : 1
352    mate.sleep = false
353    lines.forEach((line, i) => later(w, i * 22, () => say(w, i % 2 ? mate : c, line, 24)))
354    c.wait = 30 + lines.length * 22
355    mate.wait = c.wait
356    if (lines[1] === 'FIGHT ME' || w.rng.next() < 0.08) { c.fight = mate; c.fightUntil = w.tick + 40 + lines.length * 22 }
357  } else if (roll < 0.64 && at('couch')) {
358    const s = at('couch')
359    if (s) { goTo(w, c, s.floor, s.x); c.arrive = () => { c.sleep = true; c.wait = 120; say(w, c, 'zzz', 40) } }
360  } else if (roll < 0.74 && at('coffee')) {
361    const s = at('coffee')
362    if (s) goTo(w, c, s.floor, s.x)
363    c.arrive = () => { if (!c.acc.includes('mug')) c.acc = [...c.acc, 'mug']; say(w, c, w.rng.pick(['ahh coffee', 'refill!', '*sip*'])); c.wait = 40 }
364  } else if (roll < 0.82 && at('tv')) {
365    const s = at('tv')
366    if (s) goTo(w, c, s.floor, s.x)
367    c.arrive = () => { c.dir = -1; say(w, c, w.rng.pick(['ooh a show', 'whats on?', 'turn it up'])); c.wait = 80 }
368  } else if (roll < 0.9 && at('books')) {
369    const s = at('books')
370    if (s) goTo(w, c, s.floor, s.x)
371    c.arrive = () => { if (!c.acc.includes('book')) c.acc = [...c.acc, 'book']; say(w, c, w.rng.pick(['good read', 'hmm, docs', 'chapter 3'])); c.wait = 70 }
372  } else c.wait = Math.floor(w.rng.range(30, 80))
373}
374
375/** Sends a crab out through the street door; it is gone when it gets there. */
376export function leave(w: World, c: Crab): void {
377  c.role = 'leaving'
378  c.tag = undefined
379  c.sleep = false
380  c.fight = undefined
381  say(w, c, w.rng.pick(['bye all!', 'heading home', 'see ya']))
382  goTo(w, c, bottom(w), w.layout.street.doorX, true)
383  c.arrive = () => {
384    const i = w.crabs.indexOf(c)
385    if (i >= 0) w.crabs.splice(i, 1)
386  }
387}
388
389function keepTidy(w: World): void {
390  const decor = w.crabs.filter(o => o.role === 'decor')
391  if (decor.length > DECOR_CAP) {
392    const oldest = decor.find(o => o.goHomeAt === undefined) ?? decor[0]
393    if (oldest) leave(w, oldest)
394  }
395}
396
397// ---------- real crabs ----------
398
399function deskRoom(w: World, role: RealSpec['role']): Room | undefined {
400  if (role === 'agent') return roomNamed(w, 'Agent workshop')
401  if (role === 'session') return roomNamed(w, 'Sessions')
402
403  return roomNamed(w, 'Team room') ?? roomNamed(w, 'Agent workshop')
404}
405
406function freeDesk(w: World, room: Room, role: RealSpec['role']): number | undefined {
407  if (role === 'teammate') {
408    const spot = room.spots.find(x => !w.crabs.some(c => c.role === 'teammate' && c.deskX === x))
409
410    return spot
411  }
412
413  return room.desks.find(x => !w.crabs.some(c => c.deskX === x && c.floor !== undefined && (c.role === role) && c.tFloor !== -1))
414}
415
416/** A real crab arrives through the street door to its desk; with no desk free it waits on the street. */
417export function spawnReal(w: World, spec: RealSpec): Crab | undefined {
418  if (w.crabs.some(c => c.realId === spec.realId) || w.street.waiting.some(s => s.realId === spec.realId)) return undefined
419  const room = deskRoom(w, spec.role)
420  if (!room) return undefined
421  const deskX = freeDesk(w, room, spec.role)
422  if (deskX === undefined) { w.street.waiting.push(spec); return undefined }
423  const c = makeCrab(w, {
424    role: spec.role, realId: spec.realId, tag: spec.tag, acc: [...spec.acc], floor: bottom(w), dir: -1, deskX, state: 'arriving',
425    tagColor: spec.role === 'agent' ? '#9FC4E8' : spec.role === 'session' ? '#C9B6F2' : '#E89A9A',
426  })
427  c.x = w.layout.street.doorX
428  goTo(w, c, room.floor, deskX, true)
429  c.arrive = () => { c.state = 'work'; c.dir = 1; say(w, c, spec.role === 'session' ? 'hello, new session' : 'on it!') }
430
431  return c
432}
433
434// Seats the next real crab waiting on the street when a desk frees up.
435function seatWaiting(w: World): void {
436  for (const spec of [...w.street.waiting]) {
437    const room = deskRoom(w, spec.role)
438    if (!room || freeDesk(w, room, spec.role) === undefined) continue
439    w.street.waiting.splice(w.street.waiting.indexOf(spec), 1)
440    spawnReal(w, spec)
441  }
442}
443
444/** A subagent finished: it reports to main, retires to the break room, and later goes home. */
445export function retireReal(w: World, realId: string, outcome: 'done' | 'failed'): void {
446  const queued = w.street.waiting.findIndex(s => s.realId === realId)
447  if (queued >= 0) { w.street.waiting.splice(queued, 1); return }
448  const c = w.crabs.find(o => o.realId === realId && o.role !== 'leaving' && o.state !== 'reporting' && o.state !== 'retired')
449  if (!c) return
450  const failed = outcome === 'failed'
451  c.state = 'reporting'
452  c.deskX = undefined
453  say(w, c, failed ? 'ow... it broke' : 'done!')
454  c.acc = [...c.acc, failed ? 'bandage' : 'report']
455  goTo(w, c, w.main.floor, w.main.x + 11)
456  c.arrive = () => {
457    c.dir = w.main.x < c.x ? -1 : 1
458    say(w, c, failed ? 'it crashed, sorry' : 'heres my report', 35)
459    later(w, 25, () => say(w, w.main, failed ? 'no worries, rest up' : w.rng.pick(['thanks!', 'nice work', 'perfect'])))
460    later(w, 50, () => {
461      c.acc = c.acc.filter(a => a !== 'report')
462      c.role = 'decor'
463      c.state = 'retired'
464      c.tag = `${c.tag ?? 'agent'} (done)`
465      c.tagColor = '#888888'
466      c.tagUntil = w.tick + 150
467      c.goHomeAt = w.tick + Math.floor(w.rng.range(500, 800))
468      const rest = station(w, 'couch') ?? { floor: bottom(w), x: w.layout.width / 2 }
469      goTo(w, c, rest.floor, rest.x + w.rng.range(0, 20))
470      c.arrive = () => { c.wait = 40; say(w, c, w.rng.pick(['break time!', 'phew', 'coffee?'])) }
471      keepTidy(w)
472    })
473  }
474}
475
476/** A session closed, or a real crab is gone for good: it heads home. */
477export function removeReal(w: World, realId: string): void {
478  const queued = w.street.waiting.findIndex(s => s.realId === realId)
479  if (queued >= 0) w.street.waiting.splice(queued, 1)
480  const c = w.crabs.find(o => o.realId === realId && o.role !== 'leaving')
481  if (c) { c.deskX = undefined; leave(w, c) }
482}
483
484/** A session's crab types while busy and dozes while idle. */
485export function setSessionIdle(w: World, realId: string, isIdle: boolean): void {
486  const c = w.crabs.find(o => o.realId === realId && o.role === 'session')
487  if (c) c.idle = isIdle
488}
489
490function runReal(w: World, c: Crab): void {
491  if (c.state !== 'work' || c.plan.length) return
492  c.moving = false
493  if (c.role === 'session') {
494    c.sleep = !!c.idle
495    if (c.idle && w.tick % 50 === 0) say(w, c, 'z z', 20)
496  }
497}
498
499/** The panel was resized: the town is laid out again, every real crab kept. */
500export function relayout(w: World, layout: Layout): void {
501  w.layout = layout
502  const real = w.crabs.filter(c => c.realId && c.role !== 'leaving' && c.state !== 'retired')
503  // Background crabs, including any parked by a smaller layout: a small one parks them all.
504  const keep = [...w.crabs.filter(c => c.role === 'decor'), ...w.parked]
505  w.parked = layout.size === 'small' ? keep : keep.slice(DECOR_CAP)
506  w.crabs = [w.main, ...(w.librarian ? [w.librarian] : [])]
507  w.lift = { floor: 0, y: 0, door: 1, riders: [], target: null, dwell: 0, v: 0, state: 'idle' }
508  w.bubbles = []
509  for (const c of [w.main, ...(w.librarian ? [w.librarian] : [])]) { c.plan = []; c.tx = undefined; c.awaitLift = false; c.inLift = false }
510  if (w.librarian && !roomNamed(w, 'Library') && !roomNamed(w, 'Break room')) w.crabs = w.crabs.filter(c => c !== w.librarian)
511  placeAll(w)
512  for (const c of keep.slice(0, layout.size === 'small' ? 0 : DECOR_CAP)) {
513    Object.assign(c, { plan: [], tx: undefined, tFloor: undefined, awaitLift: false, inLift: false, waitX: undefined, fight: undefined, arrive: undefined })
514    c.floor = Math.min(c.floor, bottom(w))
515    w.crabs.push(c)
516    c.x = freeSpot(w, c, c.floor, c.x)
517  }
518  const waiting = [...w.street.waiting]
519  w.street.waiting = []
520  for (const c of real) {
521    const spec: RealSpec = { role: c.role as RealSpec['role'], realId: c.realId ?? '', tag: c.tag ?? '', acc: c.acc }
522    const room = deskRoom(w, spec.role)
523    const deskX = room ? freeDesk(w, room, spec.role) : undefined
524    if (!room || deskX === undefined) { w.street.waiting.push(spec); continue }
525    Object.assign(c, { plan: [], tx: undefined, tFloor: undefined, awaitLift: false, inLift: false, waitX: undefined, arrive: undefined, floor: room.floor, x: deskX, deskX, state: 'work' })
526    w.crabs.push(c)
527  }
528  w.street.waiting.push(...waiting)
529  topUpDecor(w)
530}
531
532// ---------- the loop ----------
533
534/** One 50 ms frame: motion every frame, logic every second one. */
535export function frame(w: World): void {
536  w.frameNo++
537  if (w.frameNo % 2 === 0) {
538    w.tick++
539    const due = w.timers.filter(t => t.at <= w.tick)
540    w.timers = w.timers.filter(t => t.at > w.tick)
541    for (const t of due) t.fn()
542    if (w.tick % 5 === 0) { unclump(w); seatWaiting(w) }
543    for (const c of [...w.crabs]) {
544      if (c.role === 'decor') idle(w, c)
545      else if (c.role === 'agent' || c.role === 'session' || c.role === 'teammate') runReal(w, c)
546    }
547    w.bubbles = w.bubbles.filter(b => b.until > w.tick && w.crabs.includes(b.crab))
548    w.dust = w.dust.filter(d => d.until > w.tick)
549    w.parts = w.parts.filter(p => p.until > w.tick)
550  }
551  for (const c of [...w.crabs]) move(w, c)
552  if (w.layout.shaftW > 0) stepLift(w)
553}
554
hooks/office/sprites.ts 147 lines
1// The office's crabs and their accessories, painted pixel by pixel onto a
2// canvas: Claude Code's classic 8-bit crab (9 x 6, small eyes, arms out,
3// four legs, no blush or mouth) with hats and props that say who it is or
4// what it is doing. Ported from docs/mockup/crab-town-v5.html.
5
6/** Rows of pixels, `undefined` clear. */
7export type Pixels = (string | undefined)[][]
8
9export const CRAB = { body: '#D97757', shade: '#B9583B', eye: '#1A1412', w: 9, h: 6 } as const
10
11export function blank(width: number, height: number): Pixels {
12  return Array.from({ length: height }, () => Array<string | undefined>(width).fill(undefined))
13}
14
15/** Paints a box onto the canvas, clipped; fractional sizes paint at least a pixel. */
16export function paint(canvas: Pixels, x: number, y: number, w: number, h: number, c: string | undefined): void {
17  const x0 = Math.round(x)
18  const y0 = Math.round(y)
19  const x1 = x0 + Math.max(1, Math.round(w))
20  const y1 = y0 + Math.max(1, Math.round(h))
21  for (let j = Math.max(0, y0); j < y1; j++) {
22    const row = canvas[j]
23    if (!row) break
24    for (let i = Math.max(0, x0); i < Math.min(x1, row.length); i++) row[i] = c
25  }
26}
27
28/** Copies `sprite` onto `canvas` at (x, y), its clear pixels left alone. */
29export function stamp(canvas: Pixels, sprite: Pixels, x: number, y: number): void {
30  sprite.forEach((row, j) =>
31    row.forEach((c, i) => {
32      const target = canvas[y + j]
33      if (c !== undefined && target && x + i >= 0 && x + i < target.length) target[x + i] = c
34    }),
35  )
36}
37
38const BODY = ['.OOOOOOO.', '.OEOOOEO.', 'OOOOOOOOO', '.OOOOOOS.']
39const LEGS = [
40  ['.O.O.O.O.', '.O.O.O.O.'],
41  ['.O.O.O.O.', '..O.O.O.O'],
42  ['.O.O.O.O.', '.O.O.O.O.'],
43  ['.O.O.O.O.', 'O.O.O.O..'],
44]
45
46export type Accessory =
47  | 'crown' | 'miner' | 'plan' | 'hard' | 'detective' | 'wizard' | 'teamcap' | 'librarian' | 'headphones' | 'scarf'
48  | 'beanie' | 'party' | 'bandage' | 'shoes' | 'mug' | 'book' | 'rulebook' | 'dictionary' | 'report' | 'letter'
49  | 'capsule' | 'newspaper' | 'notebook' | 'clipboard' | 'magnifier' | 'binoculars' | 'toolbox' | 'wrench' | 'hammer'
50  | 'megaphone' | 'alarmclock' | 'phone' | 'spellbook' | 'blueprint' | 'map' | 'sign' | 'stop' | 'bell' | 'stampOk'
51  | 'stampNo' | 'broom' | 'parcel' | 'box' | 'juggle' | 'wave' | 'think' | 'eyes'
52
53type Draw = (x: number, y: number, t: number, d: 1 | -1, crown: string) => void
54
55// Each accessory drawn relative to the body's top-left.
56function accessories(canvas: Pixels): Record<Accessory, Draw> {
57  const rect = (x: number, y: number, w: number, h: number, c: string) => paint(canvas, x, y, w, h, c)
58  const px = (x: number, y: number, c: string) => paint(canvas, x, y, 1, 1, c)
59  const hand = (x: number, d: number) => (d > 0 ? x + 9 : x - 2)
60  const held = (fill: (bx: number, y: number, t: number, d: 1 | -1) => void): Draw => (x, y, t, d) => fill(hand(x, d), y, t, d)
61  const OR = CRAB.body
62
63  return {
64    crown: (x, y, _t, _d, crown) => { rect(x + 2, y - 2, 5, 2, crown); px(x + 2, y - 3, crown); px(x + 4, y - 3, crown); px(x + 6, y - 3, crown); px(x + 4, y - 1, '#D9534F') },
65    miner: (x, y, _t, d) => { rect(x + 1, y - 2, 7, 2, '#7A4A2A'); rect(x, y - 1, 9, 1, '#5C3720'); rect(d > 0 ? x + 6 : x + 1, y - 2, 2, 1, '#FFF3B0') },
66    plan: (x, y, t) => { rect(x + 1, y - 2, 7, 2, '#2E9E6A'); rect(x + 3, y - 2, 3, 1, '#F2F2F2'); rect(x + 9, y - 6, 1, 7, '#AAAAAA'); rect(x + 10, y - 6 + (Math.floor(t / 5) % 2), 3, 2, '#FFFFFF') },
67    hard: (x, y) => { rect(x + 1, y - 2, 7, 2, '#E8B83A'); rect(x, y - 1, 9, 1, '#C9971F'); px(x + 4, y - 2, '#F6D77A') },
68    detective: (x, y) => { rect(x + 1, y - 2, 7, 2, '#6B5440'); rect(x, y - 1, 9, 1, '#4B3A2C'); rect(x + 2, y - 2, 5, 1, '#3A2C20') },
69    wizard: (x, y) => { rect(x + 2, y - 2, 5, 2, '#5B3FA0'); rect(x + 3, y - 4, 3, 2, '#5B3FA0'); px(x + 4, y - 5, '#5B3FA0'); px(x + 3, y - 2, '#E8C15A') },
70    teamcap: (x, y) => { rect(x + 1, y - 2, 7, 2, '#D9534F'); rect(x + 6, y - 1, 3, 1, '#B03A36') },
71    librarian: (x, y) => { rect(x + 2, y - 2, 5, 2, '#6B3A5A'); px(x + 4, y - 3, '#6B3A5A'); rect(x + 1, y + 1, 3, 1, '#2A211D'); rect(x + 5, y + 1, 3, 1, '#2A211D') },
72    headphones: (x, y) => { rect(x + 1, y - 1, 7, 1, '#2A2A2A'); rect(x, y, 1, 2, '#D97757'); rect(x + 8, y, 1, 2, '#D97757') },
73    scarf: (x, y) => { rect(x + 1, y + 2, 7, 1, '#3E6AA8'); rect(x + 6, y + 3, 1, 2, '#3E6AA8') },
74    beanie: (x, y) => { rect(x + 1, y - 2, 7, 2, '#2E9E6A'); px(x + 4, y - 3, '#E8C15A') },
75    party: (x, y) => { rect(x + 3, y - 2, 3, 2, '#E85A9A'); px(x + 4, y - 3, '#E85A9A'); px(x + 4, y - 4, '#E8C15A') },
76    bandage: (x, y) => { rect(x + 1, y - 1, 7, 1, '#F5F5F5'); px(x + 6, y - 1, '#D9534F') },
77    shoes: (x, y) => { rect(x, y + 5, 3, 1, '#E8C15A'); rect(x + 6, y + 5, 3, 1, '#E8C15A') },
78    mug: held((bx, y, t) => { rect(bx, y + 1, 2, 2, '#F5F5F5'); if (Math.floor(t / 5) % 2) px(bx, y - 1, '#888888') }),
79    book: held((bx, y) => { rect(bx, y, 2, 3, '#3E6AA8'); px(bx, y + 1, '#E8C15A') }),
80    rulebook: held((bx, y) => { rect(bx, y, 2, 3, '#B03A36'); px(bx, y + 1, '#F5EEDC') }),
81    dictionary: held((bx, y) => { rect(bx, y, 3, 3, '#777777'); px(bx + 1, y + 1, '#E8C15A') }),
82    report: held((bx, y) => { rect(bx, y, 2, 3, '#F5EEDC'); px(bx, y + 1, '#888888') }),
83    letter: held((bx, y) => { rect(bx, y, 3, 2, '#F5EEDC'); px(bx + 1, y, '#D97757') }),
84    capsule: held((bx, y) => { rect(bx, y, 2, 3, '#E8C15A'); px(bx, y + 1, '#B08D57') }),
85    newspaper: held((bx, y) => { rect(bx, y, 3, 2, '#DDDDDD'); rect(bx, y, 3, 1, '#555555') }),
86    notebook: held((bx, y, t) => { rect(bx, y, 2, 3, '#2E9E6A'); px(bx + (t % 2), y + 1, '#F5EEDC') }),
87    clipboard: held((bx, y) => { rect(bx, y, 2, 3, '#B08D57'); rect(bx, y + 1, 2, 1, '#F5EEDC') }),
88    magnifier: held((bx, y, _t, d) => { rect(bx, y - 1, 2, 2, '#9FD3E8'); px(bx + (d > 0 ? -1 : 2), y + 1, '#6B5440') }),
89    binoculars: held((bx, y) => { rect(bx, y, 1, 2, '#333333'); rect(bx + 1, y, 1, 2, '#333333') }),
90    toolbox: held((bx, y) => { rect(bx - 1, y + 1, 4, 2, '#D9534F'); rect(bx, y, 2, 1, '#888888') }),
91    wrench: held((bx, y) => { rect(bx, y, 1, 3, '#AAAAAA'); px(bx + 1, y, '#AAAAAA') }),
92    hammer: held((bx, y, t) => { rect(bx, y, 1, 3, '#8A6A4A'); rect(bx - 1, y - (t % 4 < 2 ? 1 : 0), 3, 1, '#777777') }),
93    megaphone: held((bx, y) => { rect(bx, y, 2, 2, '#E8C15A'); rect(bx + 2, y - 1, 1, 4, '#E8C15A') }),
94    alarmclock: held((bx, y, t) => { rect(bx, y, 3, 3, '#D9534F'); px(bx + 1, y + 1, '#FFFFFF'); if (t % 4 < 2) { px(bx - 1, y - 1, '#FFFFFF'); px(bx + 3, y - 1, '#FFFFFF') } }),
95    phone: (x, y, _t, d) => { rect(d > 0 ? x + 8 : x, y, 1, 3, '#D9534F') },
96    spellbook: held((bx, y, t) => { rect(bx, y, 3, 2, '#5B3FA0'); if (t % 3) px(bx + 1, y - 1 - (t % 2), '#E8C15A'); if (t % 4 < 2) px(bx + 2, y - 2, '#C9B6F2') }),
97    blueprint: (x, y) => { rect(x - 1, y + 1, 11, 3, '#3E6AA8'); rect(x, y + 2, 4, 1, '#9FD3E8'); rect(x + 5, y + 2, 3, 1, '#9FD3E8') },
98    map: (x, y) => { rect(x - 1, y + 1, 11, 3, '#E8D7A0'); rect(x + 1, y + 2, 3, 1, '#2E9E6A'); px(x + 7, y + 2, '#D9534F') },
99    sign: (x, y) => { rect(x + 3, y - 7, 1, 5, '#8A6A4A'); rect(x + 1, y - 9, 5, 3, '#F5EEDC'); px(x + 3, y - 8, '#D97757') },
100    stop: (x, y) => { rect(x + 3, y - 6, 1, 5, '#8A6A4A'); rect(x + 1, y - 10, 5, 4, '#D9534F'); rect(x + 2, y - 9, 3, 1, '#FFFFFF') },
101    bell: (x, y, t) => { rect(x + 9, y - 3, 1, 3, OR); rect(x + 9, y - 5, 2, 2, '#E8C15A'); if (t % 4 < 2) { px(x + 12, y - 6, '#FFFFFF'); px(x + 7, y - 6, '#FFFFFF') } },
102    stampOk: (x, y) => { rect(x + 3, y - 6, 3, 3, '#2E9E6A'); px(x + 4, y - 5, '#FFFFFF') },
103    stampNo: (x, y) => { rect(x + 3, y - 6, 3, 3, '#D9534F'); px(x + 3, y - 6, '#FFFFFF'); px(x + 5, y - 4, '#FFFFFF') },
104    broom: (x, y, t, d) => { const bx = d > 0 ? x + 9 : x - 1; rect(bx, y - 2, 1, 6, '#8A6A4A'); rect(bx - 1 + (t % 2), y + 4, 3, 1, '#E8C15A') },
105    parcel: held((bx, y) => { rect(bx, y, 3, 3, '#B08D57'); rect(bx, y + 1, 3, 1, '#8A6A3A') }),
106    box: held((bx, y) => { rect(bx - 1, y - 1, 4, 4, '#B08D57') }),
107    juggle: (x, y, t) => { for (let i = 0; i < 3; i++) { const a = t / 3 + i * 2.1; px(x + 4 + Math.cos(a) * 3, y - 3 + Math.sin(a) * 2, ['#D9534F', '#7FD1A8', '#E8C15A'][i] ?? '#FFFFFF') } },
108    wave: (x, y, t, d) => { if (Math.floor(t / 4) % 2) rect(d > 0 ? x + 9 : x - 1, y - 2, 1, 3, OR) },
109    think: (x, y, t) => { px(x + 8, y - 2, '#DDDDDD'); rect(x + 9, y - 5, 4, 2, '#DDDDDD'); px(x + 10 + (Math.floor(t / 4) % 2), y - 4, '#555555') },
110    eyes: (x, y, t) => { px(x + 2 + (Math.floor(t / 6) % 2), y + 1, '#FFFFFF') },
111  }
112}
113
114/** Every accessory's name. */
115export const ACCESSORIES = Object.keys(accessories(blank(1, 1))) as Accessory[]
116
117export type CrabPose = {
118  /** The 4-step leg cycle; 0 standing. */
119  legsPhase: 0 | 1 | 2 | 3
120  blink: boolean
121  dir: 1 | -1
122  acc: readonly Accessory[]
123  /** The animation clock, in ticks. */
124  t: number
125  /** Pixels up (negative) for a hop. */
126  hop?: number
127  /** The crown's colour, by model. */
128  crown?: string
129}
130
131/** Paints a crab with its body's top-left at (x, y), then its accessories. */
132export function drawCrab(canvas: Pixels, x: number, y: number, pose: CrabPose): void {
133  const top = y + (pose.hop ?? 0)
134  const rows = [...BODY, ...(LEGS[pose.legsPhase] ?? [])]
135  rows.forEach((row, j) => [...row].forEach((ch, i) => {
136    const ix = pose.dir < 0 ? CRAB.w - 1 - i : i
137    const c = ch === 'O' ? CRAB.body : ch === 'S' ? CRAB.shade : ch === 'E' ? (pose.blink ? CRAB.body : CRAB.eye) : undefined
138    if (c) paint(canvas, x + ix, top + j, 1, 1, c)
139  }))
140  if (pose.blink) {
141    paint(canvas, x + 1, top + 2, 1, 1, CRAB.eye)
142    paint(canvas, x + 7, top + 2, 1, 1, CRAB.eye)
143  }
144  const draw = accessories(canvas)
145  for (const a of pose.acc) draw[a](x, top, pose.t, pose.dir, pose.crown ?? '#E8C15A')
146}
147
hooks/office/rng.ts 23 lines
1// A small seeded random source (mulberry32), so a town replays the same way
2// from the same seed: what makes the simulation testable.
3
4export type Rng = { next: () => number; range: (lo: number, hi: number) => number; pick: <T>(list: readonly T[]) => T }
5
6export function createRng(seed: number): Rng {
7  let s = seed >>> 0
8  const next = () => {
9    s = (s + 0x6d2b79f5) >>> 0
10    let t = s
11    t = Math.imul(t ^ (t >>> 15), t | 1)
12    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
13
14    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
15  }
16
17  return {
18    next,
19    range: (lo, hi) => lo + next() * (hi - lo),
20    pick: list => list[Math.floor(next() * list.length)] as (typeof list)[number],
21  }
22}
23