SLOPSHOPPER

bureau

Le Bureau des agents in a side pane: its pixel-art office and engine, playing your session. Max is Claude; each subagent walks in, works at a desk of its…

newpaneguardcommandtimeragents
v1.1.0MITupdated 2026-10-16arhunsaday/claude-plugins/plugins/bureau
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bureau
│ ┃ Bureau ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Bureau · Max t’attend │ ┃ ▣ Max t’attend (le bureau s’affiche dans un ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ● Max Claude, chef Attend ton proch ⏺ Update(src/auth.ts) │ ┃ 0 agent · 0 recherches · 0 pages · 2 lectur… ⎿ 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 │ │ › /bureau │ ⎿ bureau: Bureau ouvert. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Bureau
▣ Bureau · Max t’attend ▣ Max t’attend (le bureau s’affiche dans un terminal qui mon ● Max Claude, chef Attend ton prochain message 0 agent · 0 recherches · 0 pages · 2 lectures · 3 écritures…
README

bureau

Le Bureau des agents in a side pane of Claude Code: its pixel-art office, drawn by its own engine, playing the session you are in. Max, at the dark desk in the middle, is Claude. Each subagent Claude calls is hired for its trade: it comes in through the door, walks to a desk in its corner (Recherche, Code, Design, Rédaction, Analyse), works there with its screen lit and an icon for each action, brings its report to Max and walks out. The window follows the hour: day, evening, then night, when the lamps, the screens and the neon light the room.

DayNight
The office by day: Max at his desk, four teammates in their cornersThe same office at night, lit by the lamps and the screens

Adapted from Le Bureau des agents (commit 184daa8) by VibesMoney, MIT licensed. See LICENSE. Its engine (src/engine/ and shared/activity.ts, shared/protocol.ts) is in hooks/engine/, unchanged except that it draws on hooks/canvas.ts, a small stand-in for the browser canvas (a hooks module has no DOM), and imports without the .ts suffix. New here: the events come from this session instead of a claude -p the Bureau starts itself, each frame is encoded as a PNG (hooks/png.ts) for the pane, and the agent list under the picture. The Bureau's interface, sounds, music, voice and interactions are not part of it.

How the session maps to the office

In the sessionIn the office
A turn startsMax sits at his desk
A tool call (Read, WebSearch, Edit, Bash…)An icon over the robot, its screen lit; the list shows the action ("Je lis app.ts")
AskUserQuestionMax raises his hand
A subagent starts (the Agent tool)A teammate is hired: the trade comes from the agent type (Explore is Recherche, a reviewer or tester is Code, a frontend agent is Design, a writer is Rédaction, a planner or analyst is Analyse, anything else a helper who stands)
The subagent endsIt brings its report to Max's desk and leaves; one that failed looks sad first
The turn ends, no one at workMax walks back to the door; confetti when the turn went well
/clearThe office empties

Under the picture, a line per agent with what it is doing and for how long, and the session's counts (agents, searches, pages, reads, writes, commands).

On the desktop the office is a picture in the pane, scaled to its width. On a terminal it is a picture where the terminal can show one (kitty, Ghostty), its description elsewhere.

Commands

CommandDoes
/bureauOpen the office
/bureau-autoOpen it on its own the first time Claude calls a subagent (the default), or only with /bureau

The office only watches this session: it runs nothing and calls no model. While the pane is open it draws about three frames a second when something moves (a frame takes about 50 ms), one a second otherwise; closed, it draws nothing.

Install

/plugin install bureau@arhun-plugins
Source 20 files
hooks/register.tsx 141 lines
1// bureau: the session's agents in Le Bureau des agents (devlikemasta/bureau-des-agents, MIT), in a side pane. Its
2// pixel-art office and its engine run here unchanged, on a canvas of our own; instead of the agents of a mission the
3// Bureau would start itself, they play this session: Max is Claude, and each subagent Claude calls walks in through
4// the door, works at a desk of its trade, hands its report in and leaves. Nothing runs, nothing is billed: the
5// office only watches.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import { Office } from './office'
11import { renderPane } from './view'
12
13const PANE = 'bureau'
14/** The engine's own clock: its walks and timers advance in small steps. */
15const TICK_MS = 125
16/** A frame (drawn and encoded, about 50 ms) at most this often while something moves, */
17const MOVE_FRAME_MS = 300
18/** and once a second when the office is still, so it keeps blinking and twinkling. */
19const IDLE_MS = 1000
20
21const frameAtom = atom({ plugin: 'bureau', key: 'frame' } as const, 0)
22
23const COMMANDS = [
24  { name: 'bureau', description: "Ouvre le Bureau des agents dans un panneau" },
25  { name: 'bureau-auto', description: 'Ouvre le bureau tout seul quand Claude appelle un agent, ou plus' },
26] as const
27
28// Module state: the office lives here, rebuilt empty on a reload.
29const office = new Office()
30let isPaneOpen = false
31let lastStep = 0
32let lastFrame = 0
33
34async function openPane($: EngineInterface): Promise<boolean> {
35  const r = await $.ui.open({ id: PANE, title: 'Bureau' })
36  isPaneOpen = true
37  return r.isPlaced
38}
39
40/** Something happened in the office: the pane's list says so now, not at the next tick. */
41async function poke($: EngineInterface) {
42  if (isPaneOpen) await update($, frameAtom, n => n + 1)
43}
44
45/** The engine moves on; the pane redraws while something moves, and once a second otherwise. */
46async function tick($: EngineInterface) {
47  const now = await $.clock.now()
48  const dt = lastStep ? (now - lastStep) / 1000 : 0
49  lastStep = now
50  const isMoving = office.step(dt)
51  if (!isPaneOpen) return
52  if (now - lastFrame >= (isMoving ? MOVE_FRAME_MS : IDLE_MS)) {
53    lastFrame = now
54    await update($, frameAtom, n => n + 1)
55  }
56}
57
58export const register: Register = on => {
59  on('session.start', async ($, e, next) => {
60    for (const c of COMMANDS) await $.command.register(c)
61    isPaneOpen = (await $.ui.panes()).some(p => p.id === PANE)
62    $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
63    return next(e)
64  })
65
66  // ── Max: the main conversation ─────────────────────────────────────────
67
68  on('turn.start', async ($, e, next) => {
69    office.turnStart(e.text)
70    await poke($)
71    return next(e)
72  })
73
74  on('turn.complete', async ($, e, next) => {
75    const result = await next(e)
76    const now = await $.clock.now()
77    if (e.agentId === undefined) office.turnEnd(e.reason !== 'error', e.durationMs)
78    else office.leave(e.agentId, e.reason !== 'error', now)
79    await poke($)
80    return result
81  })
82
83  on('tool.call', async ($, e, next) => {
84    const input = e as unknown as Record<string, unknown>
85    office.act(e.tool, input, e.tool_use_id, e.agentId)
86    await poke($)
87    if (e.agentId === undefined && e.tool === 'AskUserQuestion') {
88      office.ask(e.tool_use_id)
89      const ran = await next(e)
90      office.answered(e.tool_use_id)
91      return ran
92    }
93    return next(e)
94  })
95
96  // ── A subagent: a teammate walks in ────────────────────────────────────
97
98  on('agent.spawn', async ($, e, next) => {
99    const r = await next(e)
100    if (r.deny !== undefined || !r.agentId) return r
101    office.hire(r.agentId, e.subagentType, e.description, e.prompt, await $.clock.now())
102    if (!isPaneOpen && (await $.store.get('isAuto')) !== false) await openPane($).catch(() => undefined)
103    await poke($)
104    return r
105  })
106
107  // /clear starts a new conversation: the office empties and Max goes back to the door.
108  on('session.end', async ($, e, next) => {
109    const result = await next(e)
110    if (e.reason === 'clear') office.reset()
111    await poke($)
112    return result
113  })
114
115  // ── Commands and the pane ──────────────────────────────────────────────
116
117  on('command.run', { command: 'bureau' }, async $ => {
118    const isPlaced = await openPane($)
119    return { text: isPlaced ? 'Bureau ouvert.' : 'Bureau ouvert ; élargis la fenêtre pour le voir.' }
120  })
121
122  on('command.run', { command: 'bureau-auto' }, async $ => {
123    const isAuto = (await $.store.get('isAuto')) === false
124    await $.store.set('isAuto', isAuto)
125    return { text: isAuto ? 'Le bureau s’ouvre tout seul quand Claude appelle un agent.' : 'Le bureau ne s’ouvre plus qu’avec /bureau.' }
126  })
127
128  on('ui.close', async ($, e, next) => {
129    const result = await next(e)
130    if (e.id === PANE) isPaneOpen = false
131    return result
132  })
133
134  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135    await read($, frameAtom)
136    const now = await $.clock.now()
137    const png = office.frame(new Date(now))
138    return renderPane($.ui.resolve(e) as Parameters<typeof renderPane>[0], e.surface, office, png, e.props.bodyColumns, now)
139  })
140}
141
hooks/office.ts 152 lines
1// The office: Le Bureau des agents' own world and renderer (hooks/engine, unchanged but for the canvas they draw on),
2// fed with this session's events instead of the events of a `claude -p` the Bureau would run itself. Max is the
3// main conversation; each subagent is a teammate hired for its trade, who walks in, works, hands in its report
4// and leaves. A frame is the engine's 352 × 256 image, encoded as a PNG.
5
6import { describeAct, NAMES } from './engine/activity'
7import { H, W } from './engine/layout'
8import { makeCanvas } from './engine/pixels'
9import type { ActKind, OfficeEvent, PlanItem, Role } from './engine/protocol'
10import { Renderer } from './engine/render'
11import type { TimeOfDay } from './engine/room'
12import { World } from './engine/world'
13import { base64, encodePng } from './png'
14
15export { H, W }
16
17/** The trade of a subagent, from the agent type Claude called it as. */
18export function roleOf(type: string): Role {
19  const t = type.toLowerCase()
20  if (/explor|research|search|find|scout|guide|cherch/.test(t)) return 'recherche'
21  if (/design|frontend|\bui\b|ux|visual|animat/.test(t)) return 'design'
22  if (/review|test|code|refactor|devops|secur|implement|debug|fix|build|migrat|develop/.test(t)) return 'code'
23  if (/doc|writ|spec|copy|content|redac/.test(t)) return 'redaction'
24  if (/plan|analy|data|observ|metric|estimat|audit|rag/.test(t)) return 'analyse'
25  return 'renfort'
26}
27
28export const timeOfDay = (hour: number): TimeOfDay => (hour >= 8 && hour < 18 ? 'day' : hour >= 18 && hour < 21 ? 'evening' : 'night')
29
30/** What the pane lists under the picture: who is in, what they do right now. */
31export type Member = {
32  id: string
33  name: string
34  role: Role
35  task: string
36  label: string
37  startedAt: number
38  endedAt: number | null
39  ok: boolean | null
40}
41
42export class Office {
43  world = new World()
44  private renderer = new Renderer()
45  private canvas = makeCanvas(W, H)
46  private g = this.canvas.getContext('2d')
47  /** The lead's bubble; null between turns. */
48  leadLabel: string | null = null
49  members = new Map<string, Member>()
50  stats = { agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 }
51
52  private apply(ev: OfficeEvent) {
53    this.world.apply(ev, true)
54  }
55
56  private count(kind: ActKind) {
57    const s = this.stats
58    if (kind === 'search') s.searches++
59    else if (kind === 'web') s.pages++
60    else if (kind === 'read') s.reads++
61    else if (kind === 'write' || kind === 'edit') s.writes++
62    else if (kind === 'bash') s.commands++
63  }
64
65  /** A turn of the main conversation starts: Max sits down to it. */
66  turnStart(text: string) {
67    if (!this.world.missionActive) this.apply({ k: 'start', text })
68    this.apply({ k: 'think', who: 'chef' })
69    this.leadLabel = 'Réfléchit'
70  }
71
72  /** A turn of the main conversation ends; with nobody still at work, Max goes back to the door to greet you. */
73  turnEnd(ok: boolean, ms: number) {
74    this.leadLabel = null
75    const busy = [...this.members.values()].some(m => m.endedAt === null)
76    if (!busy) this.apply({ k: 'end', ok, text: '', ms })
77  }
78
79  /** A tool call, by Max (`who` absent) or by a teammate. */
80  act(tool: string, input: Record<string, unknown>, callId: string, who?: string) {
81    const d = describeAct(tool, input)
82    this.count(d.kind)
83    const id = who ?? 'chef'
84    if (!who) {
85      this.leadLabel = d.label
86      const todos = input['todos']
87      if (tool === 'TodoWrite' && Array.isArray(todos)) {
88        const items: PlanItem[] = todos
89          .filter((x): x is Record<string, unknown> => Boolean(x) && typeof x === 'object')
90          .map(x => ({ text: String(x['content'] ?? ''), status: x['status'] === 'completed' ? 'completed' : x['status'] === 'in_progress' ? 'in_progress' : 'pending' }))
91        this.apply({ k: 'plan', items })
92      }
93    } else {
94      const m = this.members.get(who)
95      if (!m) return
96      m.label = d.label
97    }
98    this.apply({ k: 'act', who: id, id: callId, kind: d.kind, tool, label: d.label, detail: d.detail })
99  }
100
101  /** Max asks you something: a hand goes up until you answer. */
102  ask(callId: string) {
103    this.leadLabel = 'Te pose une question'
104    this.apply({ k: 'ask', who: 'chef', requestId: callId, tool: 'AskUserQuestion', label: '', detail: '', full: false })
105  }
106  answered(callId: string) {
107    this.apply({ k: 'ask_done', requestId: callId, allow: true })
108  }
109
110  /** A subagent starts: a teammate of its trade comes in through the door. */
111  hire(id: string, type: string, task: string, prompt: string, now: number) {
112    if (this.members.has(id)) return
113    const role = roleOf(type)
114    const taken = new Set([...this.world.actors.values()].map(a => a.name))
115    const pool = NAMES[role]
116    const name = pool.find(n => !taken.has(n)) ?? `${pool[0]} ${taken.size}`
117    this.members.set(id, { id, name, role, task, label: 'Arrive', startedAt: now, endedAt: null, ok: null })
118    this.stats.agents++
119    this.apply({ k: 'hire', id, role, type, name, task, prompt })
120  }
121
122  /** A subagent's run ended: its report goes on Max's desk, then it leaves. */
123  leave(id: string, ok: boolean, now: number) {
124    const m = this.members.get(id)
125    if (!m || m.endedAt !== null) return
126    m.endedAt = now
127    m.ok = ok
128    m.label = ok ? 'Rend son rapport' : 'A échoué'
129    this.apply({ k: 'leave', who: id, ok, tokens: 0, tools: 0, ms: now - m.startedAt })
130  }
131
132  /** Moves the world on by `dt` seconds; true while something moves. Forgets teammates who have walked out. */
133  step(dt: number): boolean {
134    const moving = this.world.update(dt)
135    for (const [id, m] of this.members) if (m.endedAt !== null && !this.world.actors.has(id)) this.members.delete(id)
136    return moving
137  }
138
139  reset() {
140    this.world.resetAll()
141    this.members.clear()
142    this.leadLabel = null
143    this.stats = { agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 }
144  }
145
146  /** The office as it stands, a PNG in base64. */
147  frame(now: Date): string {
148    this.renderer.draw(this.g, this.world, timeOfDay(now.getHours()), now)
149    return base64(encodePng(this.canvas.data, W, H))
150  }
151}
152
hooks/view.tsx 104 lines
1// The pane: a header row, the office as a picture (an SVG holding the frame's PNG on the desktop, the PNG itself on
2// a terminal that shows pictures), a line per agent with its bubble, and the session's counts. Host elements and
3// theme colours for the text, as the other mods draw.
4
5import type { Elements, RenderElement, RenderSurface } from 'claude-code'
6
7import { ROLES } from './engine/activity'
8import { H, W, type Member, type Office } from './office'
9
10type TermEls = Elements['terminal']
11type DeskEls = Elements['desktop']
12
13const NAME_W = 9
14const ROLE_W = 15
15const MAX_PX = 704
16
17const clock = (ms: number) => {
18  const s = Math.max(0, Math.floor(ms / 1000))
19  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
20}
21
22function summary(o: Office): string {
23  const team = [...o.members.values()]
24  const working = team.filter(m => m.endedAt === null).length
25  const parts = [o.leadLabel === null ? 'Max t’attend' : 'Max au travail']
26  if (working) parts.push(`${working} agent${working > 1 ? 's' : ''} au travail`)
27  return parts.join(' · ')
28}
29
30export function renderPane(E: TermEls, surface: RenderSurface, o: Office, png: string, columns: number, now: number): RenderElement {
31  const { Box, Text } = E
32  const alt = summary(o)
33  let picture: RenderElement
34  if (surface === 'terminal') {
35    // a picture where the terminal can show one (kitty, Ghostty), its alt elsewhere; cells are about twice as tall as wide
36    const cols = Math.max(20, Math.min(columns, 88))
37    picture = <E.Image key="office" source={{ png }} columns={cols} rows={Math.round((cols * H) / W / 2)} alt={`${alt} (le bureau s’affiche dans un terminal qui montre les images : kitty, Ghostty)`} />
38  } else {
39    const Svg = (E as unknown as DeskEls).Svg
40    const width = Math.max(W, Math.min(MAX_PX, columns * 8))
41    const height = Math.round((width * H) / W)
42    const source =
43      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${W} ${H}">` +
44      `<image href="data:image/png;base64,${png}" width="${W}" height="${H}" style="image-rendering:pixelated" image-rendering="pixelated"/></svg>`
45    picture = <Svg key="office" source={source} alt={alt} width={width} height={height} />
46  }
47
48  const row = (key: string, dot: 'claude' | 'warning' | 'success' | 'error' | 'inactive', name: string, role: string, what: string, time: string) => (
49    <Box key={key} flexDirection="row" columnGap={1}>
50      <Text color={dot}>●</Text>
51      <Box width={NAME_W} flexShrink={0}>
52        <Text wrap="truncate-end">{name}</Text>
53      </Box>
54      <Box width={ROLE_W} flexShrink={0}>
55        <Text dimColor wrap="truncate-end">
56          {role}
57        </Text>
58      </Box>
59      <Box flexGrow={1} flexShrink={1} minWidth={0}>
60        <Text wrap="truncate-end">{what}</Text>
61      </Box>
62      {time ? <Text dimColor>{time}</Text> : null}
63    </Box>
64  )
65  const memberRow = (m: Member) =>
66    row(
67      m.id,
68      m.endedAt === null ? 'claude' : m.ok ? 'success' : 'error',
69      m.name,
70      ROLES[m.role].label,
71      m.endedAt === null ? m.label : `${m.label} · ${m.task}`,
72      clock((m.endedAt ?? now) - m.startedAt),
73    )
74  const s = o.stats
75  const counts = [
76    `${s.agents} agent${s.agents > 1 ? 's' : ''}`,
77    `${s.searches} recherches`,
78    `${s.pages} pages`,
79    `${s.reads} lectures`,
80    `${s.writes} écritures`,
81    `${s.commands} commandes`,
82  ].join(' · ')
83
84  return (
85    <Box key="bureau" flexDirection="column" rowGap={1}>
86      <Box flexDirection="column">
87        <Text wrap="truncate-end">
88          <Text color="claude">▣ </Text>
89          <Text bold>Bureau</Text>
90          <Text dimColor>{` · ${alt}`}</Text>
91        </Text>
92        {picture}
93      </Box>
94      <Box flexDirection="column">
95        {row('chef', o.leadLabel === null ? 'inactive' : 'claude', 'Max', 'Claude, chef', o.leadLabel ?? 'Attend ton prochain message', '')}
96        {[...o.members.values()].map(memberRow)}
97        <Text dimColor wrap="truncate-end">
98          {counts}
99        </Text>
100      </Box>
101    </Box>
102  )
103}
104
hooks/engine/activity.ts 164 lines
1// Les métiers du bureau, et la traduction en français de ce que fait vraiment chaque agent :
2// chaque outil de Claude Code devient une action (une icône, une animation, une bulle).
3import type { ActKind, MissionStats, OfficeEvent, Role } from './protocol';
4
5export interface RoleInfo {
6  /** le nom du métier, tel qu'il s'affiche dans le bureau */
7  label: string;
8  icon: string;
9  color: string;
10  /** l'agent Claude Code (subagent_type) qui fait ce métier */
11  type: string;
12  what: string;
13}
14
15export const ROLES: Record<Role, RoleInfo> = {
16  chef: { label: 'Chef de projet', icon: '⭐', color: '#FF8A3D', type: '', what: 'Il reçoit ta demande, fait le plan, répartit le travail et assemble le rendu.' },
17  recherche: { label: 'Recherche', icon: '🔎', color: '#4FB3FF', type: 'chercheur', what: 'Cherche sur internet : concurrents, prix, avis, actualités, sources.' },
18  code: { label: 'Code', icon: '💻', color: '#3DDC97', type: 'developpeur', what: 'Code, sites, scripts, automatisations : ce qui se lance et se teste.' },
19  design: { label: 'Design', icon: '🎨', color: '#FF6FA8', type: 'designer', what: 'Pages web soignées, maquettes, visuels, mise en forme.' },
20  redaction: { label: 'Rédaction', icon: '✍️', color: '#A98BFF', type: 'redacteur', what: 'Posts, e-mails, articles, scripts vidéo, rapports.' },
21  analyse: { label: 'Analyse', icon: '📊', color: '#FFC93D', type: 'analyste', what: 'Chiffres, tableaux, fichiers, comparatifs, calculs.' },
22  renfort: { label: 'Renfort', icon: '🤖', color: '#9AA3B5', type: '', what: 'Un coup de main sur une tâche à part.' },
23};
24
25/** Les outils Claude Code de chaque métier (le chef a tous ceux de la séance). */
26export const ROLE_TOOLS: Record<Role, string[]> = {
27  chef: ['Agent', 'TodoWrite', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Bash'],
28  recherche: ['WebSearch', 'WebFetch', 'Read', 'Glob', 'Grep'],
29  code: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebFetch'],
30  design: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch'],
31  redaction: ['Read', 'Write', 'Edit', 'Glob', 'Grep'],
32  analyse: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
33  renfort: [],
34};
35
36/** Ce que veut dire chaque outil, en français (pour la fiche des métiers). */
37export const TOOL_INFO: Record<string, string> = {
38  Agent: 'appelle un membre de l’équipe', TodoWrite: 'écrit et coche le plan', Read: 'lit un fichier', Write: 'crée un fichier',
39  Edit: 'retouche un fichier', Glob: 'cherche des fichiers', Grep: 'cherche dans les fichiers', WebSearch: 'cherche sur internet',
40  WebFetch: 'lit une page web', Bash: 'lance une commande',
41};
42
43/** Le métier d'un agent, d'après le type d'agent que le chef a appelé. */
44export function roleOfType(type: string): Role {
45  for (const [role, info] of Object.entries(ROLES) as [Role, RoleInfo][]) if (info.type && info.type === type) return role;
46  return 'renfort';
47}
48
49/** Les prénoms des robots, par métier (jamais deux fois le même dans une mission). */
50export const NAMES: Record<Role, string[]> = {
51  chef: ['Max'],
52  recherche: ['Iris', 'Hugo', 'Léa', 'Malo', 'Zoé', 'Noé', 'Inès', 'Tom', 'Jade', 'Sacha', 'Lou', 'Nils'],
53  code: ['Ada', 'Linus', 'Grace', 'Alan', 'Margaret', 'Dennis'],
54  design: ['Frida', 'Coco', 'Henri', 'Sonia', 'Saul'],
55  redaction: ['Colette', 'Victor', 'Simone', 'Jules', 'George'],
56  analyse: ['Marie', 'Blaise', 'Émilie', 'Gaspard', 'Ada'],
57  renfort: ['Bob', 'Lulu', 'Gaston', 'Nina', 'Momo', 'Kiki'],
58};
59
60const base = (p: unknown) => (typeof p === 'string' ? p.split(/[\\/]/).filter(Boolean).pop() ?? p : '');
61export const cut = (s: unknown, n = 60) => {
62  const t = String(s ?? '').replace(/\s+/g, ' ').trim();
63  return t.length > n ? t.slice(0, n - 1) + '…' : t;
64};
65export const host = (u: unknown) => {
66  try { return new URL(String(u)).hostname.replace(/^www\./, ''); } catch { return cut(u, 40); }
67};
68
69const MCP_NAMES: Record<string, string> = {
70  gmail: 'Gmail', google_drive: 'Google Drive', notion: 'Notion', google_calendar: 'Google Agenda', calendar: 'Google Agenda',
71  slack: 'Slack', github: 'GitHub', stripe: 'Stripe', hubspot: 'HubSpot', airtable: 'Airtable',
72};
73
74/** Le nom lisible d'un connecteur MCP : mcp__claude_ai_Gmail__search_threads → Gmail */
75export function mcpName(tool: string): { server: string; action: string } {
76  const parts = tool.split('__');
77  const raw = (parts[1] ?? '').replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '');
78  const server = MCP_NAMES[raw.toLowerCase()] ?? raw.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
79  return { server, action: (parts[2] ?? '').replace(/[_-]+/g, ' ') };
80}
81
82/** L'action, sa phrase (courte, pour la bulle) et le détail complet (pour le panneau). */
83export function describeAct(tool: string, input: Record<string, unknown> | undefined): { kind: ActKind; label: string; detail: string } {
84  const i = input ?? {};
85  switch (tool) {
86    case 'WebSearch': return { kind: 'search', label: `Je cherche « ${cut(i.query, 44)} »`, detail: String(i.query ?? '') };
87    case 'WebFetch': return { kind: 'web', label: `Je lis ${host(i.url)}`, detail: String(i.url ?? '') };
88    case 'Read': return { kind: 'read', label: `Je lis ${cut(base(i.file_path), 36)}`, detail: String(i.file_path ?? '') };
89    case 'Glob': return { kind: 'read', label: 'Je fouille le dossier', detail: String(i.pattern ?? '') };
90    case 'Grep': return { kind: 'read', label: `Je cherche « ${cut(i.pattern, 30)} »`, detail: `${i.pattern ?? ''}${i.path ? ' dans ' + i.path : ''}` };
91    case 'LS': return { kind: 'read', label: 'Je regarde le dossier', detail: String(i.path ?? '') };
92    case 'Write': return { kind: 'write', label: `J’écris ${cut(base(i.file_path), 36)}`, detail: String(i.file_path ?? '') };
93    case 'Edit':
94    case 'MultiEdit': return { kind: 'edit', label: `Je retouche ${cut(base(i.file_path), 34)}`, detail: String(i.file_path ?? '') };
95    case 'NotebookEdit': return { kind: 'edit', label: `Je retouche ${cut(base(i.notebook_path), 34)}`, detail: String(i.notebook_path ?? '') };
96    case 'Bash': {
97      const c = String(i.command ?? '').replace(/\s+/g, ' ').trim();
98      return { kind: 'bash', label: `Je lance ${c.length <= 34 ? `« ${c} »` : cut(i.description || c, 40)}`, detail: String(i.command ?? '') };
99    }
100    case 'TodoWrite': return { kind: 'plan', label: 'Je mets le plan à jour', detail: '' };
101    case 'Skill': return { kind: 'skill', label: `J’applique la méthode ${cut(i.skill ?? i.name, 24)}`, detail: String(i.args ?? '') };
102    case 'ToolSearch': return { kind: 'other', label: 'Je cherche le bon outil', detail: String(i.query ?? '') };
103  }
104  if (tool.startsWith('mcp__')) {
105    const { server, action } = mcpName(tool);
106    return { kind: 'mcp', label: `Je passe par ${server}`, detail: action };
107  }
108  return { kind: 'other', label: `J’utilise ${tool}`, detail: '' };
109}
110
111export const emptyStats = (): MissionStats => ({ agents: 0, searches: 0, pages: 0, reads: 0, writes: 0, commands: 0 });
112
113/** Les compteurs de la mission (recherches, pages lues…), mis à jour événement par événement. */
114export function countStats(s: MissionStats, ev: OfficeEvent) {
115  if (ev.k === 'hire') s.agents++;
116  if (ev.k !== 'act') return;
117  if (ev.kind === 'search') s.searches++;
118  else if (ev.kind === 'web') s.pages++;
119  else if (ev.kind === 'read') s.reads++;
120  else if (ev.kind === 'write' || ev.kind === 'edit') s.writes++;
121  else if (ev.kind === 'bash') s.commands++;
122}
123
124/** Remplace le chemin du dossier de la mission par « . » partout où il apparaît en entier. */
125export function relToFolder(text: string, folder: string): string {
126  if (!folder) return text;
127  const esc = folder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128  return text.replace(new RegExp(esc + '(?=$|[/"\'\\s])', 'g'), '.').replace(/(^|[\s"'])\.\//g, '$1');
129}
130
131/** La commande telle qu'elle sera lancée, juste rendue lisible (le dossier de la mission devient « . »). Rien n'est caché. */
132export function readableCommand(cmd: unknown, folder: string): string {
133  return relToFolder(String(cmd ?? ''), folder).trim().replace(/^cd\s+(?:"\."|'\.'|\.)\s*&&\s*/, '');
134}
135
136const FULL_MAX = 72;
137
138/**
139 * La demande de permission : la phrase de la bulle, le détail complet, et `full`
140 * (vrai seulement si la bulle montre toute l'action : on peut alors répondre directement dans la bulle).
141 */
142export function permissionText(tool: string, input: Record<string, unknown> | undefined, folder: string): { label: string; detail: string; full: boolean } {
143  const i = input ?? {};
144  if (tool === 'Bash') {
145    const c = readableCommand(i.command, folder);
146    const full = c.length > 0 && c.length <= FULL_MAX && !c.includes('\n');
147    return { label: `Je peux lancer « ${full ? c : cut(c, 48)} » ?`, detail: c, full };
148  }
149  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
150    const raw = String(i.file_path ?? i.notebook_path ?? '');
151    const rel = relToFolder(raw, folder);
152    const inside = !!folder && raw.startsWith(folder + '/') && !rel.includes('..');
153    return { label: `Je peux ${tool === 'Write' ? 'écrire' : 'modifier'} ${inside ? rel : raw} ?`, detail: raw, full: inside && rel.length <= FULL_MAX };
154  }
155  if (tool === 'WebFetch') return { label: `Je peux lire ${host(i.url)} ?`, detail: String(i.url ?? ''), full: false };
156  const detail = relToFolder(JSON.stringify(i, null, 2), folder);
157  const clipped = detail.length > 1500 ? detail.slice(0, 1499) + '…' : detail;
158  if (tool.startsWith('mcp__')) {
159    const { server, action } = mcpName(tool);
160    return { label: `Je peux passer par ${server} (${action}) ?`, detail: clipped, full: false };
161  }
162  return { label: `Je peux utiliser ${tool} ?`, detail: clipped, full: false };
163}
164
hooks/engine/layout.ts 141 lines
1// Le plan du bureau : une grille de 22 × 16 cases de 16 pixels, vue de dessus (en 3/4, comme dans
2// les jeux en pixel art). Chaque métier a son coin, ses bureaux et ses chaises ; Max est au centre,
3// devant le tableau du plan ; la porte est en bas, c'est par là qu'arrive l'équipe.
4import type { Role } from './protocol';
5
6export const T = 16;
7export const COLS = 22;
8export const ROWS = 16;
9export const W = COLS * T;
10export const H = ROWS * T;
11
12export type FurnKind =
13  | 'desk' | 'chefDesk' | 'chair' | 'chefChair' | 'plantBig' | 'plantTall' | 'plantSmall' | 'rack' | 'printer'
14  | 'cooler' | 'coffee' | 'sofa' | 'tableLow' | 'shelf' | 'lamp' | 'easel' | 'bin' | 'jukebox';
15
16export interface Furn {
17  id: string;
18  kind: FurnKind;
19  /** case en haut à gauche de l'emprise au sol */
20  tx: number;
21  ty: number;
22  /** emprise au sol, en cases (bloque le passage) */
23  w: number;
24  h: number;
25  role?: Role;
26  seed?: number;
27}
28
29export interface Seat {
30  id: string;
31  role: Role;
32  /** la case où le robot s'arrête avant de s'asseoir */
33  tile: [number, number];
34  /** les pieds du robot une fois assis (en pixels) */
35  x: number;
36  y: number;
37  deskId: string;
38}
39
40export interface Zone { role: Role; label: string; x: number; y: number; w: number; h: number }
41
42/** Les coins du bureau (le tapis au sol et l'étiquette). */
43export const ZONES: Zone[] = [
44  { role: 'recherche', label: 'Recherche', x: 1, y: 3, w: 6, h: 6 },
45  { role: 'code', label: 'Atelier code', x: 15, y: 3, w: 6, h: 3 },
46  { role: 'design', label: 'Studio design', x: 15, y: 6, w: 6, h: 3 },
47  { role: 'redaction', label: 'Rédaction', x: 1, y: 10, w: 6, h: 3 },
48  { role: 'analyse', label: 'Analyse', x: 15, y: 10, w: 4, h: 3 },
49  { role: 'chef', label: 'Max, chef de projet', x: 9, y: 6, w: 4, h: 3 },
50];
51
52const furn: Furn[] = [];
53const seats: Seat[] = [];
54
55function desk(role: Role, tx: number, ty: number) {
56  const id = `desk-${role}-${tx}-${ty}`;
57  furn.push({ id, kind: 'desk', tx, ty, w: 2, h: 1, role, seed: tx * 31 + ty });
58  furn.push({ id: `chair-${id}`, kind: 'chair', tx, ty: ty - 1, w: 0, h: 0, role });
59  seats.push({ id: `seat-${id}`, role, tile: [tx, ty - 1], x: (tx + 1) * T, y: ty * T - 1, deskId: id });
60}
61
62// Recherche : deux rangées de trois bureaux (c'est le coin le plus demandé, un chercheur par sujet)
63for (const ty of [5, 8]) for (const tx of [1, 3, 5]) desk('recherche', tx, ty);
64// Atelier code
65for (const tx of [15, 17, 19]) desk('code', tx, 5);
66// Studio design
67for (const tx of [15, 17]) desk('design', tx, 8);
68// Rédaction
69for (const tx of [1, 3, 5]) desk('redaction', tx, 12);
70// Analyse
71for (const tx of [15, 17]) desk('analyse', tx, 12);
72
73// Le bureau du chef, face à toi
74furn.push({ id: 'chefDesk', kind: 'chefDesk', tx: 9, ty: 7, w: 4, h: 1, role: 'chef' });
75furn.push({ id: 'chefChair', kind: 'chefChair', tx: 10, ty: 6, w: 0, h: 0, role: 'chef' });
76export const CHEF_SEAT: Seat = { id: 'seat-chef', role: 'chef', tile: [10, 6], x: 11 * T, y: 7 * T - 1, deskId: 'chefDesk' };
77
78// Le matériel et la déco
79furn.push({ id: 'rack', kind: 'rack', tx: 14, ty: 3, w: 1, h: 1 });
80furn.push({ id: 'printer', kind: 'printer', tx: 12, ty: 4, w: 1, h: 1 });
81furn.push({ id: 'cooler', kind: 'cooler', tx: 9, ty: 4, w: 1, h: 1 });
82furn.push({ id: 'easel', kind: 'easel', tx: 19, ty: 8, w: 1, h: 1 });
83furn.push({ id: 'plant-design', kind: 'plantBig', tx: 20, ty: 8, w: 1, h: 1, seed: 3 });
84furn.push({ id: 'plant-entry-l', kind: 'plantTall', tx: 8, ty: 14, w: 1, h: 1, seed: 5 });
85furn.push({ id: 'plant-entry-r', kind: 'plantBig', tx: 13, ty: 14, w: 1, h: 1, seed: 9 });
86furn.push({ id: 'plant-analyse', kind: 'plantTall', tx: 20, ty: 11, w: 1, h: 1, seed: 11 });
87furn.push({ id: 'sofa', kind: 'sofa', tx: 2, ty: 14, w: 3, h: 1 });
88furn.push({ id: 'tableLow', kind: 'tableLow', tx: 2, ty: 13, w: 2, h: 0 });
89furn.push({ id: 'lamp', kind: 'lamp', tx: 1, ty: 14, w: 1, h: 1 });
90furn.push({ id: 'shelf', kind: 'shelf', tx: 5, ty: 14, w: 2, h: 1 });
91furn.push({ id: 'coffee', kind: 'coffee', tx: 16, ty: 14, w: 3, h: 1 });
92furn.push({ id: 'jukebox', kind: 'jukebox', tx: 14, ty: 14, w: 2, h: 1 });
93furn.push({ id: 'plant-coffee', kind: 'plantBig', tx: 19, ty: 14, w: 1, h: 1, seed: 21 });
94furn.push({ id: 'lamp-r', kind: 'lamp', tx: 20, ty: 14, w: 1, h: 1 });
95furn.push({ id: 'bin', kind: 'bin', tx: 7, ty: 12, w: 0, h: 0 });
96
97export const FURNITURE = furn;
98export const SEATS = seats;
99
100/** Les points de passage importants. Les pieds d'un robot debout sur une case : (x*16+8, y*16+13). */
101export const SPOTS = {
102  door: [10, 15] as [number, number],
103  doorOut: { x: 11 * T, y: 16 * T + 6 },
104  /** là où Max t'accueille, et où il te présente le rendu */
105  reception: [10, 12] as [number, number],
106  receptionPx: { x: 11 * T, y: 12 * T + 13 },
107  /** devant le tableau du plan */
108  board: [10, 3] as [number, number],
109  boardPx: { x: 11 * T, y: 3 * T + 13 },
110  /** devant le bureau du chef : on y rend son rapport */
111  deliver: [[10, 8], [11, 8], [9, 8], [12, 8], [10, 9], [11, 9]] as [number, number][],
112  /** pour ceux qui n'ont pas de bureau libre */
113  overflow: [[7, 4], [8, 7], [13, 6], [7, 10], [14, 10], [8, 11], [13, 11], [7, 13]] as [number, number][],
114};
115
116/** Le tableau du plan, accroché au mur du fond (en pixels). */
117export const BOARD = { x: 128, y: 7, w: 96, h: 35 };
118export const WINDOWS = [{ x: 22, y: 8, w: 66, h: 32 }, { x: 264, y: 8, w: 66, h: 32 }];
119export const NEON = { x: 97, y: 11 };
120export const CLOCK = { x: 232, y: 13 };
121export const POSTER = { x: 252, y: 12, w: 10, h: 14 };
122export const DOOR = { x: 160, y: 228, w: 32, h: 28 };
123
124/** La grille des cases où l'on peut marcher. */
125export function walkGrid(): boolean[][] {
126  const g: boolean[][] = [];
127  for (let y = 0; y < ROWS; y++) {
128    g.push([]);
129    for (let x = 0; x < COLS; x++) g[y].push(x >= 1 && x <= 20 && y >= 3 && y <= 14);
130  }
131  // la porte
132  g[15][10] = true;
133  g[15][11] = true;
134  for (const f of FURNITURE) {
135    for (let y = f.ty; y < f.ty + f.h; y++) for (let x = f.tx; x < f.tx + f.w; x++) if (g[y]?.[x] !== undefined) g[y][x] = false;
136  }
137  return g;
138}
139
140export const tileFeet = (t: [number, number]) => ({ x: t[0] * T + 8, y: t[1] * T + 13 });
141
hooks/engine/pixels.ts 173 lines
1// La boîte à outils du pixel art : des toiles minuscules dessinées pixel par pixel, puis agrandies
2// sans flou. Tout le bureau (meubles, robots, icônes) est dessiné ici, en code : aucune image externe.
3
4import { Canvas, type Ctx } from '../canvas';
5export type { Canvas };
6export type G = Ctx;
7
8export function makeCanvas(w: number, h: number): Canvas {
9  return new Canvas(Math.max(1, Math.ceil(w)), Math.max(1, Math.ceil(h)));
10}
11
12export function g2d(c: Canvas): G {
13  const g = c.getContext('2d', { willReadFrequently: false })!;
14  g.imageSmoothingEnabled = false;
15  return g;
16}
17
18export function rect(g: G, x: number, y: number, w: number, h: number, color: string) {
19  g.fillStyle = color;
20  g.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
21}
22
23export function px(g: G, x: number, y: number, color: string) {
24  g.fillStyle = color;
25  g.fillRect(Math.round(x), Math.round(y), 1, 1);
26}
27
28/** Une ligne horizontale ou verticale d'un pixel. */
29export function hline(g: G, x: number, y: number, w: number, color: string) { rect(g, x, y, w, 1, color); }
30export function vline(g: G, x: number, y: number, h: number, color: string) { rect(g, x, y, 1, h, color); }
31
32/** Ajoute un contour d'un pixel autour de tout ce qui est opaque (la silhouette « pixel art »). */
33export function outline(c: Canvas, color: string, opts: { diagonal?: boolean; skipBottom?: boolean } = {}) {
34  const g = c.getContext('2d')!;
35  const w = c.width, h = c.height;
36  const img = g.getImageData(0, 0, w, h);
37  const d = img.data;
38  const solid = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && d[(y * w + x) * 4 + 3] > 40;
39  const out: number[] = [];
40  for (let y = 0; y < h; y++) {
41    for (let x = 0; x < w; x++) {
42      if (solid(x, y)) continue;
43      let hit = solid(x - 1, y) || solid(x + 1, y) || solid(x, y - 1) || (!opts.skipBottom && solid(x, y + 1));
44      if (!hit && opts.diagonal) hit = solid(x - 1, y - 1) || solid(x + 1, y - 1) || solid(x - 1, y + 1) || solid(x + 1, y + 1);
45      if (hit) out.push(x, y);
46    }
47  }
48  g.fillStyle = color;
49  for (let i = 0; i < out.length; i += 2) g.fillRect(out[i], out[i + 1], 1, 1);
50  return c;
51}
52
53/**
54 * Un sprite décrit en texte : une ligne par rangée de pixels, un caractère par pixel.
55 * « . » ou « » = transparent ; les autres caractères renvoient à la palette.
56 */
57export function fromRows(rows: string[], pal: Record<string, string>): Canvas {
58  const h = rows.length;
59  const w = Math.max(...rows.map((r) => r.length));
60  const c = makeCanvas(w, h);
61  const g = g2d(c);
62  for (let y = 0; y < h; y++) {
63    const r = rows[y];
64    for (let x = 0; x < r.length; x++) {
65      const ch = r[x];
66      if (ch === '.' || ch === ' ') continue;
67      const col = pal[ch];
68      if (col) px(g, x, y, col);
69    }
70  }
71  return c;
72}
73
74export function mirror(src: Canvas): Canvas {
75  const c = makeCanvas(src.width, src.height);
76  const g = g2d(c);
77  g.translate(src.width, 0);
78  g.scale(-1, 1);
79  g.drawImage(src, 0, 0);
80  return c;
81}
82
83// ── Couleurs ──
84function parse(hex: string): [number, number, number] {
85  const h = hex.replace('#', '');
86  const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
87  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
88}
89const toHex = (r: number, g: number, b: number) => '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
90
91export function mix(a: string, b: string, t: number): string {
92  const [r1, g1, b1] = parse(a);
93  const [r2, g2, b2] = parse(b);
94  return toHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
95}
96export const lighten = (c: string, t: number) => mix(c, '#ffffff', t);
97export const darken = (c: string, t: number) => mix(c, '#100c1c', t);
98export function rgba(hex: string, a: number) {
99  const [r, g, b] = parse(hex);
100  return `rgba(${r},${g},${b},${a})`;
101}
102
103/** Un petit générateur pseudo-aléatoire, pour que le grain du bois soit toujours le même. */
104export function rng(seed: number) {
105  let s = seed >>> 0 || 1;
106  return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
107}
108
109// ── La palette du bureau (le soir, chez VibesMoney : noir, bois chaud, un seul orange) ──
110export const P = {
111  ink: '#15121d',
112  inkSoft: '#231e2e',
113  // murs
114  wallTop: '#14111c',
115  wallFace: '#262236',
116  wallFace2: '#2c2740',
117  wallLine: '#1f1b2d',
118  wallHi: '#37314d',
119  baseboard: '#191523',
120  // sol : parquet sombre et chaud
121  floor1: '#3f3138',
122  floor2: '#46363d',
123  floor3: '#3a2d34',
124  floorSeam: '#30252c',
125  floorHi: '#504048',
126  // bois clair des bureaux
127  wood: '#c08a57',
128  woodHi: '#d9a46c',
129  woodLight: '#e7bb84',
130  woodFront: '#9b6a3e',
131  woodDark: '#77502e',
132  woodLeg: '#563821',
133  // bois sombre (le bureau du chef)
134  ebony: '#2a2530',
135  ebonyHi: '#3b3443',
136  ebonyFront: '#1e1a24',
137  // métal et matériel
138  metal: '#9a9fb5',
139  metalHi: '#cfd2e0',
140  metalDark: '#62667c',
141  tech: '#262431',
142  techMid: '#363443',
143  techHi: '#4d4a5e',
144  screenOff: '#1a1f2e',
145  // l'orange VibesMoney
146  orange: '#ff8a3d',
147  orangeHi: '#ffb27a',
148  orangeLight: '#ffd2a8',
149  orangeDark: '#d9622a',
150  orangeDeep: '#9e4019',
151  // verdure
152  leafDeep: '#1f4733',
153  leafDark: '#285e40',
154  leaf: '#348050',
155  leafHi: '#52a868',
156  leafLight: '#86cf7c',
157  pot: '#b8643f',
158  potHi: '#d9875a',
159  potDark: '#8a462a',
160  potWhite: '#e9e5ef',
161  potWhiteShade: '#bdb7c9',
162  // papier
163  paper: '#f6f2e9',
164  paperShade: '#d8d1c3',
165  paperLine: '#a19a8c',
166  // tissu des fauteuils
167  fabric: '#2e2a3d',
168  fabricHi: '#403a54',
169  fabricDark: '#211d2c',
170  white: '#f7f5fc',
171  shadow: 'rgba(8,6,16,0.38)',
172};
173
hooks/engine/protocol.ts 128 lines
1// Le contrat entre le bureau (navigateur) et le pont (serveur Node qui pilote Claude Code).
2// Tout passe par une seule connexion WebSocket, en JSON. Syntaxe « effaçable » uniquement :
3// ce fichier est lu tel quel par Node (sans compilation) et par Vite.
4
5/** Les métiers du bureau. « chef » = l'agent principal, celui à qui tu parles. */
6export type Role = 'chef' | 'recherche' | 'code' | 'design' | 'redaction' | 'analyse' | 'renfort';
7
8/** Ce que fait un agent à un instant : sert à l'animation, à l'icône et à la bulle. */
9export type ActKind = 'search' | 'web' | 'read' | 'write' | 'edit' | 'bash' | 'plan' | 'skill' | 'mcp' | 'other';
10
11export interface PlanItem { text: string; status: 'pending' | 'in_progress' | 'completed' }
12
13/** Un fichier produit par l'équipe, dans le dossier de la mission. */
14export interface FileInfo { path: string; name: string; ext: string; size: number; at: number; by: string }
15
16export interface MissionStats { agents: number; searches: number; pages: number; reads: number; writes: number; commands: number }
17
18export type MissionStatus = 'working' | 'done' | 'stopped' | 'error';
19
20export interface MissionInfo {
21  id: string;
22  title: string;
23  prompt: string;
24  /** le dossier de la mission, sur ton ordinateur */
25  folder: string;
26  createdAt: number;
27  endedAt?: number;
28  /** temps de travail cumulé (toutes les reprises comprises) */
29  workMs: number;
30  status: MissionStatus;
31  sessionId?: string;
32  model?: string;
33  stats: MissionStats;
34  files: FileInfo[];
35  /** le dernier message du chef, une fois la mission rendue */
36  final?: string;
37}
38
39/**
40 * Ce qui se passe dans le bureau. `who` vaut « chef » ou l'identifiant d'un agent de l'équipe
41 * (l'identifiant de l'appel Agent qui l'a fait venir).
42 */
43export type OfficeEvent =
44  | { k: 'start'; text: string }
45  | { k: 'init'; model: string; auth: string }
46  | { k: 'think'; who: string }
47  | { k: 'typing'; who: string; text: string }
48  | { k: 'say'; who: string; text: string }
49  | { k: 'plan'; items: PlanItem[] }
50  | { k: 'hire'; id: string; role: Role; type: string; name: string; task: string; prompt: string }
51  | { k: 'act'; who: string; id: string; kind: ActKind; tool: string; label: string; detail: string }
52  | { k: 'act_done'; who: string; id: string; ok: boolean }
53  /** le rapport qu'un agent rend au chef */
54  | { k: 'report'; who: string; text: string }
55  /** un agent a fini son travail et quitte son poste */
56  | { k: 'leave'; who: string; ok: boolean; tokens: number; tools: number; ms: number }
57  | { k: 'file'; file: FileInfo; isNew: boolean }
58  /** full = la bulle montre l'action en entier (sinon, on répond dans le panneau, où tout est affiché) */
59  | { k: 'ask'; who: string; requestId: string; tool: string; label: string; detail: string; full: boolean }
60  | { k: 'ask_done'; requestId: string; allow: boolean; cancelled?: boolean }
61  | { k: 'end'; ok: boolean; text: string; ms: number; stopped?: boolean }
62  | { k: 'error'; message: string };
63
64/** Un événement daté, tel qu'il est enregistré. */
65export interface Logged { at: number; ev: OfficeEvent }
66
67export interface ClaudeInfo { found: boolean; version?: string; path?: string }
68
69/** Une version enregistrée d'un fichier rendu (à chaque modification par l'équipe). */
70export interface VersionInfo { id: string; at: number; size: number; by: string }
71
72/** Un passage d'un agent au bureau (pour l'historique de l'équipe). */
73export interface TeamEntry {
74  missionId: string;
75  missionTitle: string;
76  at: number;
77  id: string;
78  name: string;
79  role: Role;
80  task: string;
81  status: 'working' | 'done' | 'failed';
82  tools: number;
83  tokens: number;
84  ms: number;
85  report: string;
86}
87
88export interface Settings {
89  /** le cerveau du chef : '' = le modèle par défaut de Claude Code */
90  chefModel: string;
91  /** le cerveau de l'équipe : sonnet, haiku, opus, fable ou inherit (le même que le chef) */
92  teamModel: string;
93  /** auto : l'équipe décide seule et ne demande que pour les gestes risqués ; ask : elle demande avant chaque commande */
94  permission: 'auto' | 'ask';
95}
96
97export interface Quota { fiveHour: number; resetsAt: number | null }
98
99// ── Navigateur → serveur ──
100export type ClientMsg =
101  | { t: 'start'; prompt: string }
102  | { t: 'say'; text: string }
103  | { t: 'stop' }
104  | { t: 'answer'; requestId: string; allow: boolean }
105  | { t: 'new' }
106  | { t: 'open'; missionId: string; file?: string }
107  /** ouvre le dossier du bureau (ou son règlement) */
108  | { t: 'open_root'; rules?: boolean }
109  | { t: 'read'; missionId: string; file: string; version?: string }
110  | { t: 'versions'; missionId: string; file: string }
111  | { t: 'team_history' }
112  | { t: 'replay'; missionId: string }
113  | { t: 'settings'; settings: Partial<Settings> };
114
115// ── Serveur → navigateur ──
116export type ServerMsg =
117  | { t: 'hello'; claude: ClaudeInfo; dir: string; settings: Settings; quota: Quota | null; missions: MissionInfo[]; current: { mission: MissionInfo; log: Logged[] } | null; build?: string }
118  | { t: 'mission'; mission: MissionInfo }
119  | { t: 'ev'; missionId: string; at: number; ev: OfficeEvent }
120  | { t: 'closed'; missionId: string }
121  | { t: 'replay'; mission: MissionInfo; log: Logged[] }
122  | { t: 'file_text'; missionId: string; file: string; text: string; truncated: boolean; version?: string }
123  | { t: 'versions'; missionId: string; file: string; list: VersionInfo[] }
124  | { t: 'team_history'; entries: TeamEntry[] }
125  | { t: 'quota'; quota: Quota }
126  | { t: 'settings'; settings: Settings }
127  | { t: 'toast'; kind: 'info' | 'error'; text: string };
128
hooks/engine/render.ts 411 lines
1// Le dessin d'une image du bureau : la pièce, puis les meubles et les robots rangés du fond vers
2// l'avant (ceux du fond passent derrière), les effets, et enfin la lumière (le soir et la nuit,
3// la pièce s'assombrit et ce sont les écrans, les lampes et le néon qui éclairent).
4// Tout se dessine sur une toute petite image (352 × 256 pixels), agrandie sans flou : ça ne coûte presque rien.
5import type { ActKind } from './protocol';
6import { furniturePlacement } from './furniture';
7import { BOARD, CLOCK, FURNITURE, H, NEON, T, W, WINDOWS, type Furn } from './layout';
8import { g2d, makeCanvas, P, rgba, rng, type G } from './pixels';
9import { ANCHOR_X, ANCHOR_Y, robotSprite } from './robots';
10import { drawRoom, neon, signs, type TimeOfDay } from './room';
11import { ROLES } from './activity';
12import type { Actor, World } from './world';
13import type { Canvas } from '../canvas';
14
15interface Placed { f: Furn; c: Canvas; x: number; y: number; sortY: number }
16
17const SCREEN: Record<ActKind, string> = {
18  search: '#7fd6ff', web: '#7fffc4', read: '#cfe3ff', write: '#fff0d4', edit: '#fff0d4', bash: '#8affb0',
19  plan: '#ffd27a', skill: '#ffe08a', mcp: '#c9b4ff', other: '#dfe6ff',
20};
21
22const AMBIENT: Record<TimeOfDay, string> = { day: '#ffffff', evening: '#a596b2', night: '#3b3961' };
23
24/** Les couleurs du néon (clique dessus pour passer à la suivante). */
25export const NEON_COLORS: [string, string][] = [
26  [P.orange, P.orangeLight], ['#ff5fa2', '#ffc2dd'], ['#3fd8ff', '#c8f4ff'], ['#3ddc97', '#c6f7de'], ['#a98bff', '#e2d8ff'],
27];
28const DISCO = ['#ff5fa2', '#3fd8ff', '#ffd166', '#3ddc97', '#a98bff', '#ff8a3d'];
29
30export class Renderer {
31  private bg: Canvas | null = null;
32  private bgTod: TimeOfDay | null = null;
33  private light = makeCanvas(W, H);
34  private lg = g2d(this.light);
35  private placed: Placed[] = FURNITURE.map((f) => { const p = furniturePlacement(f); return { f, c: p.s.c, x: p.x, y: p.y, sortY: p.sortY }; });
36  private stars: { x: number; y: number; p: number }[] = [];
37
38  constructor() {
39    const r = rng(7);
40    for (const w of WINDOWS) for (let i = 0; i < 9; i++) this.stars.push({ x: w.x + 2 + Math.floor(r() * (w.w - 4)), y: w.y + 1 + Math.floor(r() * 12), p: r() * 6 });
41  }
42
43  draw(g: G, world: World, tod: TimeOfDay, now: Date) {
44    if (!this.bg || this.bgTod !== tod) { this.bg = drawRoom(tod); this.bgTod = tod; }
45    const t = world.time;
46    g.globalCompositeOperation = 'source-over';
47    g.globalAlpha = 1;
48    g.drawImage(this.bg, 0, 0);
49    if (tod === 'night') this.twinkle(g, t);
50    if (tod === 'day') this.clouds(g, t);
51    this.clockHands(g, now);
52    this.board(g, world, t);
53    // le néon (sa couleur change quand on clique dessus ; il grésille de temps en temps)
54    const [tube, core] = NEON_COLORS[world.neonIndex];
55    if (Math.sin(t * 0.9) <= 0.985) neon(g, tube, core);
56    // les écriteaux : le nom du coin, et combien d'agents y travaillent en ce moment
57    const busy = new Map<string, number>();
58    for (const a of world.actors.values()) if (a.seated && a.role !== 'chef') busy.set(a.role, (busy.get(a.role) ?? 0) + 1);
59    signs(g, (role) => busy.get(role) ?? 0);
60
61    // qui est assis à quel bureau
62    const atDesk = new Map<string, Actor>();
63    for (const a of world.actors.values()) if (a.seated && a.seat) atDesk.set(a.seat.deskId, a);
64
65    // du fond vers l'avant
66    const items: { y: number; k: number; draw: () => void }[] = [];
67    for (const p of this.placed) {
68      const shake = (world.wiggles.get(p.f.id) ?? 0) > t ? Math.round(Math.sin(t * 42)) : 0;
69      items.push({ y: p.sortY, k: 0, draw: () => { g.drawImage(p.c, p.x + shake, p.y); this.furnitureLive(g, p, world, atDesk.get(p.f.id) ?? null, t); } });
70    }
71    for (const a of world.actors.values()) {
72      items.push({ y: a.seated && a.seat ? a.seat.y : a.y, k: 1, draw: () => this.actor(g, a, world) });
73    }
74    items.push({ y: world.cat.sortY, k: 2, draw: () => world.cat.draw(g) });
75    items.sort((a, b) => a.y - b.y || a.k - b.k);
76    for (const it of items) it.draw();
77
78    if (t < world.discoUntil) this.discoBall(g, t);
79    world.fx.draw(g);
80    if (tod === 'day') this.sunbeams(g);
81    else this.lighting(g, world, tod, atDesk, t);
82    if (t < world.discoUntil) this.discoSpots(g, t, world.discoUntil - t);
83  }
84
85  private actor(g: G, a: Actor, world: World) {
86    const seated = a.seated && !!a.seat;
87    const x = Math.round(a.x);
88    // sur la musique, ceux qui ne marchent pas hochent la tête en rythme
89    const y = Math.round(a.y) - (world.musicOn && world.time < world.bobUntil && !a.moving ? 1 : 0);
90    g.globalAlpha = a.alpha;
91    if (!seated) {
92      g.fillStyle = 'rgba(8,6,16,0.32)';
93      g.fillRect(x - 5, y - 1, 10, 2);
94      g.fillRect(x - 4, y - 2, 8, 1);
95    }
96    const sprite = robotSprite(a.role, a.pose, seated ? 'down' : a.dir, a.frame, a.eyes);
97    g.drawImage(sprite, x - ANCHOR_X, y - ANCHOR_Y);
98    g.globalAlpha = 1;
99  }
100
101  /** Les parties vivantes des meubles : écrans allumés, voyants, imprimante, rapports sur le bureau du chef. */
102  private furnitureLive(g: G, p: Placed, world: World, who: Actor | null, t: number) {
103    const f = p.f;
104    if (f.kind === 'desk' && who) {
105      const on = t - who.actAt < 9;
106      const col = on && who.act ? SCREEN[who.act] : '#5b6b8c';
107      switch (f.role) {
108        case 'code': {
109          // le reflet de l'écran sur les bords des moniteurs
110          for (const mx of [1, 22]) {
111            g.fillStyle = col;
112            g.fillRect(p.x + mx, p.y + 1, 9, 1);
113          }
114          break;
115        }
116        case 'design': {
117          const cols = ['#ff6fa8', '#ffd166', '#4fb3ff', '#a98bff', P.orange];
118          for (let i = 0; i < 11; i++) {
119            g.fillStyle = cols[(i + Math.floor(t * (on ? 6 : 1))) % cols.length];
120            g.fillRect(p.x + 10 + i, p.y + 12 + (i % 3 === 0 ? 1 : 0), 1, i % 2 ? 2 : 3);
121          }
122          break;
123        }
124        case 'analyse': {
125          g.fillStyle = '#141a2c';
126          g.fillRect(p.x + 23, p.y + 2, 7, 6);
127          const bars = ['#ffd166', P.orange, '#4fb3ff', '#3ddc97'];
128          for (let i = 0; i < 4; i++) {
129            const h = on ? 2 + Math.floor((Math.sin(t * 3 + i * 1.7) + 1) * 1.6) : 2 + (i % 3);
130            g.fillStyle = bars[i];
131            g.fillRect(p.x + 24 + i + (i > 1 ? 1 : 0), p.y + 7 - h, 1, h);
132          }
133          this.logo(g, p.x + 14, p.y + 10, on, t);
134          break;
135        }
136        default:
137          this.logo(g, p.x + 15, p.y + 10, on, t);
138      }
139    }
140    if (f.kind === 'chefDesk') {
141      if (who) this.logo(g, p.x + 31, p.y + 10, true, t);
142      // les rapports rendus, empilés dans la corbeille
143      const n = Math.min(world.inbox, 7);
144      for (let i = 0; i < n; i++) {
145        g.fillStyle = P.ink;
146        g.fillRect(p.x + 46 + (i % 2), p.y + 10 - i, 12, 3);
147        g.fillStyle = i % 2 ? P.paperShade : P.paper;
148        g.fillRect(p.x + 47 + (i % 2), p.y + 10 - i, 10, 2);
149      }
150    }
151    if (f.kind === 'printer' && t < world.printUntil) {
152      const prog = 1 - (world.printUntil - t) / 1.8;
153      const h = Math.max(1, Math.round(Math.min(1, prog * 1.6) * 6));
154      g.fillStyle = P.paper;
155      g.fillRect(p.x + 4, p.y + 8, 8, h);
156      g.fillStyle = P.paperLine;
157      if (h > 3) g.fillRect(p.x + 5, p.y + 10, 5, 1);
158      g.fillStyle = Math.floor(t * 8) % 2 ? '#3ddc97' : '#1f6b45';
159      g.fillRect(p.x + 13, p.y + 9, 1, 1);
160    }
161    if (f.kind === 'rack') {
162      for (let i = 0; i < 6; i++) {
163        const blink = Math.sin(t * (2 + (i % 3)) + i * 2.3) > -0.2;
164        g.fillStyle = blink ? (i % 4 === 0 ? '#ff8a3d' : i % 3 === 0 ? '#4fb3ff' : '#3ddc97') : '#1f3a2e';
165        g.fillRect(p.x + 3, p.y + 7 + i * 4, 1, 1);
166        g.fillStyle = Math.sin(t * 5 + i) > 0.6 ? '#3ddc97' : '#1f3a2e';
167        g.fillRect(p.x + 5, p.y + 7 + i * 4, 1, 1);
168      }
169    }
170    if (f.kind === 'jukebox') this.jukeboxLights(g, p, world, t);
171    if (f.kind === 'coffee') {
172      g.fillStyle = Math.floor(t * 1.5) % 2 ? P.orange : P.orangeDark;
173      g.fillRect(p.x + 14, p.y + 2, 1, 1);
174    }
175  }
176
177  /** Les néons de l'arche du jukebox : éteints au repos, ils défilent et pulsent sur la musique. */
178  private jukeboxLights(g: G, p: Placed, world: World, t: number) {
179    const on = world.musicOn;
180    const arcs: [number, string, string][] = [[11, '#ff5fa2', '#5a2a3e'], [9, P.orange, '#5a3a24'], [7, '#3fd8ff', '#1f3e4a']];
181    arcs.forEach(([r, col, dim], i) => {
182      for (let a = 0; a <= 16; a++) {
183        const ang = Math.PI + (a / 16) * Math.PI;
184        const x = p.x + 16 + Math.round(Math.cos(ang) * r);
185        const y = p.y + 15 + Math.round(Math.sin(ang) * r);
186        const lit = on && (Math.floor(t * 8) + a + i * 3) % 6 !== 0;
187        g.fillStyle = lit ? col : dim;
188        g.fillRect(x, y, 1, 1);
189      }
190    });
191    if (on) {
192      // le disque tourne
193      const a = t * 8;
194      g.fillStyle = P.orangeLight;
195      g.fillRect(p.x + 16 + Math.round(Math.cos(a) * 3), p.y + 20 + Math.round(Math.sin(a) * 1.5), 1, 1);
196      // les barres du vumètre
197      for (let i = 0; i < 5; i++) {
198        const h = 1 + Math.round((world.musicLevel * 4) * (0.6 + 0.4 * Math.sin(t * 9 + i * 1.7)));
199        g.fillStyle = i % 2 ? '#ff5fa2' : P.orange;
200        g.fillRect(p.x + 7 + i * 4, p.y + 32 - h, 2, h);
201      }
202    }
203  }
204
205  /** La boule à facettes du mode soirée. */
206  private discoBall(g: G, t: number) {
207    const cx = 176, cy = 30;
208    g.fillStyle = '#8a8ea2';
209    g.fillRect(cx, 0, 1, cy - 5);
210    for (let y = -5; y <= 5; y++) {
211      const w = Math.round(Math.sqrt(25 - y * y));
212      for (let x = -w; x <= w; x++) {
213        const k = (x + y + Math.floor(t * 10)) & 3;
214        g.fillStyle = k === 0 ? '#ffffff' : k === 1 ? '#c9cbd6' : k === 2 ? '#8a8ea2' : DISCO[(x * 7 + y * 3 + Math.floor(t * 6)) % DISCO.length];
215        g.fillRect(cx + x, cy + y, 1, 1);
216      }
217    }
218  }
219
220  /** Les taches de lumière colorées qui tournent sur le sol pendant le mode soirée. */
221  private discoSpots(g: G, t: number, left: number) {
222    const fade = Math.min(1, left / 1.5);
223    g.globalCompositeOperation = 'lighter';
224    for (let i = 0; i < 9; i++) {
225      const a = t * (0.7 + i * 0.13) + i * 2.1;
226      const x = 176 + Math.cos(a) * (60 + i * 12);
227      const y = 140 + Math.sin(a * 1.3) * (40 + i * 4);
228      const grd = g.createRadialGradient(x, y, 0, x, y, 14);
229      grd.addColorStop(0, rgba(DISCO[i % DISCO.length], 0.32 * fade));
230      grd.addColorStop(1, rgba(DISCO[i % DISCO.length], 0));
231      g.fillStyle = grd;
232      g.fillRect(x - 14, y - 14, 28, 28);
233    }
234    g.globalCompositeOperation = 'source-over';
235  }
236
237  /** Deux petits nuages qui passent derrière les fenêtres, en journée. */
238  private clouds(g: G, t: number) {
239    for (const w of WINDOWS) {
240      g.save();
241      g.beginPath();
242      g.rect(w.x, w.y, w.w, 11);
243      g.clip();
244      for (let i = 0; i < 2; i++) {
245        const span = w.w + 30;
246        const x = w.x - 15 + ((t * (3 + i * 2) + i * 40 + w.x) % span);
247        const y = w.y + 3 + i * 4;
248        g.fillStyle = 'rgba(255,255,255,0.85)';
249        g.fillRect(Math.round(x), y, 9, 2);
250        g.fillRect(Math.round(x) + 2, y - 1, 5, 1);
251      }
252      g.restore();
253    }
254  }
255
256  /** Le petit cube orange au dos de l'ordinateur, qui s'allume quand l'agent travaille. */
257  private logo(g: G, x: number, y: number, on: boolean, t: number) {
258    g.fillStyle = on ? (Math.sin(t * 6) > -0.6 ? P.orange : P.orangeHi) : '#8a5a3e';
259    g.fillRect(x, y, 2, 2);
260  }
261
262  private clockHands(g: G, now: Date) {
263    const cx = CLOCK.x + 7, cy = CLOCK.y + 7;
264    const m = now.getMinutes() + now.getSeconds() / 60;
265    const h = (now.getHours() % 12) + m / 60;
266    const hand = (angle: number, len: number, color: string) => {
267      g.fillStyle = color;
268      for (let i = 0; i <= len; i++) g.fillRect(Math.round(cx + Math.sin(angle) * i), Math.round(cy - Math.cos(angle) * i), 1, 1);
269    };
270    hand((h / 12) * Math.PI * 2, 3, '#2b2836');
271    hand((m / 60) * Math.PI * 2, 4, '#2b2836');
272    g.fillStyle = P.orange;
273    g.fillRect(cx, cy, 1, 1);
274  }
275
276  private twinkle(g: G, t: number) {
277    for (const s of this.stars) {
278      const a = (Math.sin(t * 1.6 + s.p) + 1) / 2;
279      if (a < 0.35) continue;
280      g.fillStyle = `rgba(255,255,255,${(a * 0.9).toFixed(2)})`;
281      g.fillRect(s.x, s.y, 1, 1);
282    }
283  }
284
285  /** En journée : de grands rayons de soleil qui tombent des fenêtres. */
286  private sunbeams(g: G) {
287    g.save();
288    g.beginPath();
289    g.rect(T, 3 * T, W - 2 * T, H - 4 * T);
290    g.clip();
291    g.globalCompositeOperation = 'lighter';
292    for (const w of WINDOWS) {
293      g.fillStyle = 'rgba(255,236,190,0.06)';
294      g.beginPath();
295      g.moveTo(w.x + 4, 48);
296      g.lineTo(w.x + w.w - 4, 48);
297      g.lineTo(w.x + w.w + 26, 120);
298      g.lineTo(w.x + 30, 120);
299      g.closePath();
300      g.fill();
301    }
302    g.globalCompositeOperation = 'source-over';
303    g.restore();
304  }
305
306  private lighting(g: G, world: World, tod: TimeOfDay, atDesk: Map<string, Actor>, t: number) {
307    const l = this.lg;
308    l.globalCompositeOperation = 'source-over';
309    l.fillStyle = AMBIENT[tod];
310    l.fillRect(0, 0, W, H);
311    l.globalCompositeOperation = 'lighter';
312    const night = tod === 'night';
313    const add = (x: number, y: number, r: number, color: string, a: number) => {
314      const grd = l.createRadialGradient(x, y, 0, x, y, r);
315      grd.addColorStop(0, rgba(color, a));
316      grd.addColorStop(0.55, rgba(color, a * 0.45));
317      grd.addColorStop(1, rgba(color, 0));
318      l.fillStyle = grd;
319      l.fillRect(x - r, y - r, r * 2, r * 2);
320    };
321    // le néon (il grésille de temps en temps)
322    const flick = Math.sin(t * 0.9) > 0.985 ? 0.25 : 1;
323    const neonCol = NEON_COLORS[world.neonIndex][0];
324    add(NEON.x + 12, NEON.y + 12, 52, neonCol, 0.75 * flick);
325    // les fenêtres : coucher de soleil ou clair de lune
326    for (const w of WINDOWS) add(w.x + w.w / 2, w.y + w.h + 8, 58, night ? '#7080d0' : '#ff9a6a', night ? 0.42 : 0.55);
327    // le tableau du plan, éclairé
328    add(176, 28, 52, '#fff6e8', night ? 0.28 : 0.22);
329    // la lampe du chef et le liseré orange de son bureau
330    add(9 * T + 12, 7 * T - 6, 40, '#ffd9a0', 0.8);
331    add(11 * T, 7 * T + 14, 44, P.orange, 0.32);
332    // les lampadaires, le café, le serveur
333    if (!world.lampsOff.has('lamp')) add(1 * T + 8, 14 * T - 12, 52, '#ffcf8a', 0.85);
334    if (!world.lampsOff.has('lamp-r')) add(20 * T + 8, 14 * T - 12, 52, '#ffcf8a', 0.85);
335    // le jukebox, qui pulse sur la musique
336    if (world.musicOn) add(15 * T, 13 * T + 4, 30 + world.musicLevel * 18, ['#ff5fa2', P.orange, '#3fd8ff'][Math.floor(t * 1.5) % 3], 0.35 + world.musicLevel * 0.45);
337    // les yeux du chat brillent dans le noir
338    add(world.cat.x + (world.cat.right ? 4 : -4), world.cat.y - 8, 4, P.orange, 0.5);
339    add(16 * T + 14, 14 * T - 8, 14, P.orange, 0.5);
340    add(14 * T + 4, 3 * T + 6, 18, '#5fd0ff', 0.35);
341    add(11 * T, 15 * T + 8, 30, '#ffe2b8', 0.4);
342    if (t < world.printUntil) add(12 * T + 8, 4 * T - 2, 16, '#9effa8', 0.6);
343    // les écrans éclairent les visages
344    for (const a of atDesk.values()) {
345      const on = t - a.actAt < 9;
346      const col = on && a.act ? SCREEN[a.act] : '#8fa4d0';
347      const sx = a.seat!.x, sy = a.seat!.y - 12;
348      add(sx, sy, on ? 26 : 18, a.role === 'design' && on ? '#ff9fd0' : col, on ? 0.75 : 0.35);
349    }
350    // un projecteur doux suit Max (le patron reste toujours bien visible)
351    const chef = world.chef;
352    add(chef.x, (chef.seated && chef.seat ? chef.seat.y : chef.y) - 8, 26, '#ffe2c4', 0.42);
353    // les yeux des robots
354    for (const a of world.actors.values()) {
355      if (a.alpha < 0.3) continue;
356      add(a.x, (a.seated && a.seat ? a.seat.y : a.y) - 13, 7, P.orange, 0.55);
357    }
358    g.globalCompositeOperation = 'multiply';
359    g.drawImage(this.light, 0, 0);
360    // un léger halo sur ce qui brille
361    g.globalCompositeOperation = 'lighter';
362    const glow = (x: number, y: number, r: number, color: string, a: number) => {
363      const grd = g.createRadialGradient(x, y, 0, x, y, r);
364      grd.addColorStop(0, rgba(color, a));
365      grd.addColorStop(1, rgba(color, 0));
366      g.fillStyle = grd;
367      g.fillRect(x - r, y - r, r * 2, r * 2);
368    };
369    glow(NEON.x + 12, NEON.y + 12, 20, neonCol, 0.32 * flick);
370    for (const a of world.actors.values()) if (a.alpha > 0.3) glow(a.x, (a.seated && a.seat ? a.seat.y : a.y) - 13, 4, P.orange, 0.35);
371    for (const a of atDesk.values()) if (t - a.actAt < 9) glow(a.seat!.x, a.seat!.y - 6, 8, a.act ? SCREEN[a.act] : '#ffffff', 0.18);
372    g.globalCompositeOperation = 'source-over';
373  }
374
375  /** Le plan, au feutre sur le tableau : une ligne par étape, cochée quand elle est faite. */
376  private board(g: G, world: World, t: number) {
377    const items = world.plan.slice(0, 5);
378    const x0 = BOARD.x + 4;
379    items.forEach((it, i) => {
380      const y = BOARD.y + 12 + i * 4;
381      const done = it.status === 'completed';
382      const doing = it.status === 'in_progress';
383      // la case
384      g.fillStyle = done ? '#2fbf71' : doing ? (Math.floor(t * 3) % 2 ? P.orange : P.orangeDark) : '#8a8ea2';
385      g.fillRect(x0, y, 3, 3);
386      if (!done) { g.fillStyle = '#f4f5f9'; g.fillRect(x0 + 1, y + 1, 1, 1); }
387      // la ligne écrite à la main
388      const len = Math.max(18, Math.min(78, Math.round(it.text.length * 1.5)));
389      const ink = done ? '#b4b7c6' : doing ? '#3a2f44' : '#2b2836';
390      g.fillStyle = ink;
391      for (let k = 0; k < len; k++) {
392        if ((k * 7 + i * 3) % 11 === 0) continue; // des petits blancs entre les « mots »
393        g.fillRect(x0 + 6 + k, y + 1 + (Math.sin((k + i * 5) * 0.9) > 0.6 ? -1 : 0), 1, 1);
394      }
395      if (done) { g.fillStyle = '#2fbf71'; g.fillRect(x0 + 5, y + 1, len + 2, 1); }
396    });
397    if (world.plan.length > 5) { g.fillStyle = '#8a8ea2'; g.fillRect(x0 + 6, BOARD.y + 32, 1, 1); g.fillRect(x0 + 8, BOARD.y + 32, 1, 1); g.fillRect(x0 + 10, BOARD.y + 32, 1, 1); }
398    // qui fait quoi : une petite tête de robot par agent, en bas à droite du tableau, cochée quand il a rendu son travail
399    const team = world.assignments.slice(-12);
400    team.forEach((a, i) => {
401      const x = BOARD.x + BOARD.w - 8 - (team.length - 1 - i) * 6;
402      const y = BOARD.y + 27;
403      g.fillStyle = a.status === 'done' ? '#2fbf71' : a.status === 'failed' ? '#e5484d' : ROLES[a.role].color;
404      g.fillRect(x, y, 4, 4);
405      g.fillStyle = '#15121d';
406      g.fillRect(x + 1, y + 1, 2, 1);
407      if (a.status === 'working' && Math.floor(t * 2 + i) % 2) { g.fillStyle = '#ff8a3d'; g.fillRect(x + 1, y + 1, 1, 1); }
408    });
409  }
410}
411
hooks/engine/room.ts 255 lines
1// La pièce elle-même : le parquet, les tapis de chaque coin, les murs, les grandes fenêtres sur la
2// ville (le ciel suit l'heure), le néon VibesMoney, le tableau du plan, l'horloge et la porte.
3// Tout ça ne bouge pas : c'est dessiné une fois, puis recopié à chaque image.
4import { ROLES } from './activity';
5import { pixelText, textWidth } from './font';
6import { BOARD, CLOCK, H, NEON, T, W, WINDOWS, ZONES } from './layout';
7import { g2d, hline, lighten, makeCanvas, mix, P, px, rect, rng, vline, type G } from './pixels';
8import type { Canvas } from '../canvas';
9
10export type TimeOfDay = 'day' | 'evening' | 'night';
11
12const SKY: Record<TimeOfDay, { bands: string[]; city: string; city2: string; lit: string; litRate: number }> = {
13  day: { bands: ['#4f9fe8', '#68b1ef', '#83c2f3', '#a3d3f6', '#c3e3f8'], city: '#6f80a8', city2: '#8494b9', lit: '#d4e2f5', litRate: 0.08 },
14  evening: { bands: ['#2a2453', '#43306a', '#6c3a72', '#a64a6c', '#dc6a5c', '#ff9d5c'], city: '#1d1631', city2: '#2a2040', lit: '#ffcf6b', litRate: 0.22 },
15  night: { bands: ['#0a0a20', '#0f0f2c', '#151538', '#1c1a45', '#231e50'], city: '#0a0816', city2: '#120f22', lit: '#ffcf6b', litRate: 0.32 },
16};
17
18function floor(g: G) {
19  const r = rng(42);
20  const top = 3 * T;
21  const colors = [P.floor1, P.floor2, P.floor3, P.floor1];
22  // des lames de parquet de 6 pixels, aux raccords décalés
23  for (let y = top; y < H; y += 6) {
24    let x = -Math.floor(r() * 20);
25    while (x < W) {
26      const len = 18 + Math.floor(r() * 22);
27      const col = colors[Math.floor(r() * colors.length)];
28      rect(g, x, y, len, 5, col);
29      hline(g, x, y, len, mix(col, P.floorHi, 0.5));
30      rect(g, x, y + 5, len, 1, P.floorSeam);
31      vline(g, x + len - 1, y, 5, P.floorSeam);
32      if (r() < 0.12) px(g, x + 4 + Math.floor(r() * (len - 8)), y + 2, P.floorSeam);
33      x += len;
34    }
35  }
36}
37
38function rug(g: G, x: number, y: number, w: number, h: number, color: string) {
39  const base = mix(color, '#2b2232', 0.72);
40  const edge = mix(color, '#2b2232', 0.5);
41  const inner = mix(color, '#2b2232', 0.62);
42  const px0 = x * T + 2, py0 = y * T + 2, pw = w * T - 4, ph = h * T - 4;
43  rect(g, px0 + 1, py0, pw - 2, ph, base);
44  rect(g, px0, py0 + 1, pw, ph - 2, base);
45  // bordure
46  hline(g, px0 + 1, py0, pw - 2, edge);
47  hline(g, px0 + 1, py0 + ph - 1, pw - 2, edge);
48  vline(g, px0, py0 + 1, ph - 2, edge);
49  vline(g, px0 + pw - 1, py0 + 1, ph - 2, edge);
50  // un liseré intérieur, et un motif discret
51  hline(g, px0 + 3, py0 + 3, pw - 6, inner);
52  hline(g, px0 + 3, py0 + ph - 4, pw - 6, inner);
53  vline(g, px0 + 3, py0 + 3, ph - 6, inner);
54  vline(g, px0 + pw - 4, py0 + 3, ph - 6, inner);
55  for (let yy = py0 + 7; yy < py0 + ph - 6; yy += 6) for (let xx = px0 + 7 + ((yy / 6) % 2) * 3; xx < px0 + pw - 6; xx += 6) px(g, xx, yy, inner);
56}
57
58function backWall(g: G) {
59  rect(g, 0, 0, W, 5, P.wallTop);
60  rect(g, 0, 5, W, 39, P.wallFace);
61  // boiseries : un panneau bas plus sombre, une cimaise
62  rect(g, 0, 31, W, 13, P.wallFace2);
63  hline(g, 0, 30, W, P.wallHi);
64  hline(g, 0, 31, W, P.wallLine);
65  for (let x = 8; x < W; x += 22) vline(g, x, 33, 9, P.wallLine);
66  hline(g, 0, 5, W, P.wallHi);
67  rect(g, 0, 44, W, 4, P.baseboard);
68  hline(g, 0, 44, W, P.wallHi);
69  // l'ombre du mur sur le sol
70  g.fillStyle = 'rgba(8,6,16,0.35)';
71  g.fillRect(0, 48, W, 2);
72  g.fillStyle = 'rgba(8,6,16,0.18)';
73  g.fillRect(0, 50, W, 2);
74}
75
76function windowPane(g: G, wx: number, wy: number, ww: number, wh: number, tod: TimeOfDay, seed: number) {
77  const sky = SKY[tod];
78  const r = rng(seed);
79  // cadre
80  rect(g, wx - 3, wy - 3, ww + 6, wh + 6, '#1a1724');
81  rect(g, wx - 2, wy - 2, ww + 4, wh + 4, '#3b3550');
82  // le ciel, en bandes (comme dans les vieux jeux)
83  const bh = Math.ceil(wh / sky.bands.length);
84  sky.bands.forEach((c, i) => rect(g, wx, wy + i * bh, ww, bh, c));
85  if (tod === 'evening') {
86    for (let i = 0; i < 9; i++) rect(g, wx + 30 + i - 4, wy + wh - 10 - Math.round(Math.sqrt(16 - (i - 4) ** 2)), 1, 6, '#ffd27a');
87  }
88  if (tod === 'night' && seed === 1) {
89    rect(g, wx + 10, wy + 5, 6, 6, '#f2efd8');
90    rect(g, wx + 9, wy + 6, 8, 4, '#f2efd8');
91    px(g, wx + 12, wy + 7, '#d6d2b8');
92    px(g, wx + 14, wy + 9, '#d6d2b8');
93  }
94  if (tod === 'day') {
95    for (const [cx, cy, cw] of [[wx + 8, wy + 6, 12], [wx + 36, wy + 10, 9]]) {
96      rect(g, cx, cy, cw, 3, '#ffffff');
97      rect(g, cx + 2, cy - 2, cw - 5, 2, '#ffffff');
98      hline(g, cx, cy + 3, cw, '#dcecf8');
99    }
100  }
101  // la ville au loin
102  let x = wx;
103  while (x < wx + ww) {
104    const bw = 4 + Math.floor(r() * 8);
105    const bhh = 7 + Math.floor(r() * 13);
106    const far = r() < 0.4;
107    const top = wy + wh - bhh + (far ? 3 : 0);
108    rect(g, x, top, Math.min(bw, wx + ww - x), wy + wh - top, far ? sky.city2 : sky.city);
109    for (let yy = top + 2; yy < wy + wh - 1; yy += 2) {
110      for (let xx = x + 1; xx < Math.min(x + bw - 1, wx + ww - 1); xx += 2) if (r() < sky.litRate) px(g, xx, yy, sky.lit);
111    }
112    x += bw;
113  }
114  // montants et traverse
115  vline(g, wx + Math.floor(ww / 2), wy, wh, '#3b3550');
116  vline(g, wx + Math.floor(ww / 2) + 1, wy, wh, '#2a2538');
117  hline(g, wx, wy + 11, ww, '#3b3550');
118  // un reflet sur la vitre
119  for (let i = 0; i < 6; i++) px(g, wx + 4 + i, wy + 14 - i, 'rgba(255,255,255,0.18)');
120  // l'appui de fenêtre
121  rect(g, wx - 4, wy + wh + 2, ww + 8, 3, '#4a4560');
122  hline(g, wx - 4, wy + wh + 2, ww + 8, '#625c7c');
123}
124
125function whiteboard(g: G) {
126  const { x, y, w, h } = BOARD;
127  rect(g, x - 2, y - 2, w + 4, h + 4, '#7a7e93');
128  rect(g, x - 1, y - 1, w + 2, h + 2, '#c2c5d4');
129  hline(g, x - 1, y - 1, w + 2, '#e6e8f0');
130  rect(g, x, y, w, h, '#f4f5f9');
131  // un reflet en biais
132  for (let i = 0; i < 18; i++) px(g, x + w - 30 + i, y + 2 + Math.floor(i / 2), '#ffffff');
133  // le rebord et les feutres
134  rect(g, x + 4, y + h + 2, w - 8, 2, '#8a8ea2');
135  hline(g, x + 4, y + h + 2, w - 8, '#b3b6c6');
136  rect(g, x + 12, y + h + 1, 5, 1, '#e5484d');
137  rect(g, x + 19, y + h + 1, 5, 1, '#3d8ad1');
138  rect(g, x + 26, y + h + 1, 5, 1, '#2b2836');
139  rect(g, x + w - 18, y + h + 1, 6, 2, '#e9e5ef');
140  // le titre, au feutre orange
141  pixelText(g, 'LE PLAN', x + 4, y + 3, P.orangeDark);
142  hline(g, x + 4, y + 9, textWidth('LE PLAN'), P.orange);
143}
144
145const SIGN: Record<string, string> = { recherche: 'RECHERCHE', code: 'CODE', design: 'DESIGN', redaction: 'RÉDACTION', analyse: 'ANALYSE', chef: 'CHEF' };
146
147/** Les écriteaux posés au coin de chaque tapis, avec le nombre d'agents au travail dans le coin. */
148export function signs(g: G, busy: (role: string) => number) {
149  for (const z of ZONES) {
150    const n = busy(z.role);
151    const label = (SIGN[z.role] ?? z.label.toUpperCase()) + (n > 0 && z.role !== 'chef' ? ` ${n}` : '');
152    const color = ROLES[z.role].color;
153    const w = textWidth(label) + 6;
154    const x = z.x * T + 4, y = z.role === 'chef' ? (z.y + z.h) * T - 12 : z.y * T + 3;
155    rect(g, x + 1, y, w - 2, 9, '#120f19');
156    rect(g, x, y + 1, w, 7, '#120f19');
157    hline(g, x + 1, y, w - 2, mix(color, '#120f19', 0.45));
158    pixelText(g, label, x + 3, y + 2, lighten(color, 0.15));
159  }
160}
161
162/** Le néon : la tête du robot VibesMoney, en tubes de couleur (orange par défaut). */
163export function neon(g: G, tube: string, core: string) {
164  const { x, y } = NEON;
165  // antenne
166  vline(g, x + 12, y, 3, tube);
167  rect(g, x + 11, y - 2, 3, 2, tube);
168  // contour de la tête
169  rect(g, x + 1, y + 3, 23, 1, tube);
170  rect(g, x + 1, y + 20, 23, 1, tube);
171  rect(g, x + 1, y + 3, 1, 18, tube);
172  rect(g, x + 23, y + 3, 1, 18, tube);
173  hline(g, x + 2, y + 4, 21, core);
174  // la visière et les yeux
175  rect(g, x + 5, y + 8, 15, 1, tube);
176  rect(g, x + 5, y + 15, 15, 1, tube);
177  rect(g, x + 5, y + 8, 1, 8, tube);
178  rect(g, x + 19, y + 8, 1, 8, tube);
179  rect(g, x + 8, y + 10, 3, 3, core);
180  rect(g, x + 14, y + 10, 3, 3, core);
181  // le petit support au mur
182  px(g, x + 3, y + 22, '#3b3550');
183  px(g, x + 21, y + 22, '#3b3550');
184}
185
186function clockFace(g: G) {
187  const { x, y } = CLOCK;
188  rect(g, x + 3, y, 8, 14, '#2b2836');
189  rect(g, x, y + 3, 14, 8, '#2b2836');
190  rect(g, x + 1, y + 1, 12, 12, '#2b2836');
191  rect(g, x + 2, y + 2, 10, 10, '#f2f0f7');
192  rect(g, x + 4, y + 1, 6, 12, '#f2f0f7');
193  rect(g, x + 1, y + 4, 12, 6, '#f2f0f7');
194  px(g, x + 7, y + 2, '#2b2836');
195  px(g, x + 7, y + 11, '#2b2836');
196  px(g, x + 2, y + 7, '#2b2836');
197  px(g, x + 11, y + 7, '#2b2836');
198}
199
200function poster(g: G) {
201  const x = 252, y = 12;
202  rect(g, x, y, 10, 14, '#c08a57');
203  rect(g, x + 1, y + 1, 8, 12, '#16121f');
204  // une courbe qui monte, en orange
205  const pts = [[2, 10], [3, 9], [4, 9], [5, 7], [6, 6], [7, 4], [8, 3]];
206  for (const [px_, py_] of pts) px(g, x + px_, y + py_, P.orange);
207  px(g, x + 8, y + 2, P.orangeLight);
208}
209
210function sideWalls(g: G) {
211  for (const x0 of [0, W - T]) {
212    rect(g, x0, 0, T, H, P.wallTop);
213    const inner = x0 === 0 ? T - 2 : x0;
214    vline(g, inner, 5, H - 5, '#2c2740');
215    vline(g, x0 === 0 ? inner + 1 : inner + 1, 5, H - 5, '#1d1a28');
216  }
217  // l'ombre des murs de côté sur le sol
218  g.fillStyle = 'rgba(8,6,16,0.25)';
219  g.fillRect(T, 3 * T, 2, H - 4 * T);
220  g.fillRect(W - T - 2, 3 * T, 2, H - 4 * T);
221}
222
223function frontWall(g: G) {
224  const y = 15 * T;
225  rect(g, 0, y, 10 * T, T, P.wallTop);
226  rect(g, 12 * T, y, W - 12 * T, T, P.wallTop);
227  hline(g, 0, y, 10 * T, '#2c2740');
228  hline(g, 12 * T, y, W - 12 * T, '#2c2740');
229  // l'encadrement de la porte
230  for (const x of [10 * T - 2, 12 * T]) {
231    rect(g, x, y - 2, 2, T + 2, '#3b3550');
232    vline(g, x, y - 2, T + 2, '#4d4666');
233  }
234  // le paillasson orange
235  const mx = 10 * T + 2, my = 14 * T + 3;
236  rect(g, mx, my, 2 * T - 4, 10, P.orangeDeep);
237  for (let i = 0; i < 4; i++) hline(g, mx + 2, my + 2 + i * 2, 2 * T - 8, i % 2 ? P.orangeDark : P.orange);
238}
239
240/** Dessine toute la pièce (sans les meubles) dans une image de la taille du bureau. */
241export function drawRoom(tod: TimeOfDay): Canvas {
242  const c = makeCanvas(W, H);
243  const g = g2d(c);
244  floor(g);
245  for (const z of ZONES) rug(g, z.x, z.y, z.w, z.h, ROLES[z.role].color);
246  backWall(g);
247  WINDOWS.forEach((w, i) => windowPane(g, w.x, w.y, w.w, w.h, tod, i + 1));
248  whiteboard(g);
249  clockFace(g);
250  poster(g);
251  sideWalls(g);
252  frontWall(g);
253  return c;
254}
255
hooks/engine/world.ts 597 lines
1// Le monde du bureau : les robots, ce qu'ils font, où ils vont. Chaque événement venu de Claude Code
2// devient une petite mise en scène : un membre de l'équipe arrive par la porte, va à son bureau, tape
3// ou lit selon ce qu'il fait vraiment, se lève pour rendre son rapport au chef, puis repart.
4import type { ActKind, OfficeEvent, PlanItem, Role } from './protocol';
5import { Cat } from './cat';
6import { Fx } from './fx';
7import { iconFor } from './icons';
8import { CHEF_SEAT, SEATS, SPOTS, T, WINDOWS, tileFeet, walkGrid, type Seat } from './layout';
9import { findPath, type Tile } from './path';
10import type { Dir, Eyes, Pose } from './robots';
11
12type Pt = { x: number; y: number };
13
14type Task =
15  | { t: 'walk'; tile: Tile; face?: Dir; then?: Pt }
16  | { t: 'slide'; to: Pt; face?: Dir; speed?: number }
17  | { t: 'sit'; seat: Seat }
18  | { t: 'stand' }
19  | { t: 'wait'; ms: number; pose?: Pose; dir?: Dir; eyes?: Eyes }
20  | { t: 'call'; fn: () => void }
21  | { t: 'fade'; to: number; ms: number };
22
23const WALK_SPEED = 58; // pixels par seconde
24
25export class Actor {
26  id: string;
27  role: Role;
28  name: string;
29  x: number;
30  y: number;
31  dir: Dir = 'down';
32  pose: Pose = 'stand';
33  frame = 0;
34  eyes: Eyes = 'open';
35  seat: Seat | null = null;
36  seated = false;
37  standSpot: Tile | null = null;
38  tasks: Task[] = [];
39  cur: Task | null = null;
40  curT = 0;
41  path: Pt[] = [];
42  moving = false;
43  alpha = 1;
44  act: ActKind | null = null;
45  actAt = -99;
46  thinkAt = -99;
47  carrying = false;
48  asks = 0;
49  cheerUntil = 0;
50  happyUntil = 0;
51  sadUntil = 0;
52  reported = false;
53  delivered = false;
54  left = false;
55  gone = false;
56  animT = 0;
57  nextBlink = 1 + Math.random() * 3;
58  blinkUntil = 0;
59  nextLook = 2 + Math.random() * 3;
60  look: Eyes = 'open';
61  lookUntil = 0;
62  deliverSpot = -1;
63
64  constructor(id: string, role: Role, name: string, x: number, y: number) {
65    this.id = id;
66    this.role = role;
67    this.name = name;
68    this.x = x;
69    this.y = y;
70  }
71
72  get tile(): Tile {
73    if (this.seated && this.seat) return this.seat.tile;
74    return [Math.max(0, Math.floor(this.x / T)), Math.max(0, Math.min(15, Math.floor((this.y - 2) / T)))];
75  }
76
77  /** Le haut de la tête (pour accrocher les bulles). */
78  get head(): Pt { return { x: this.x, y: this.y - 22 }; }
79
80  get busy() { return !!this.cur || this.tasks.length > 0; }
81}
82
83export class World {
84  actors = new Map<string, Actor>();
85  fx = new Fx();
86  grid = walkGrid();
87  private seatOf = new Map<string, string>();
88  private deliverTaken = new Set<number>();
89  time = 0;
90  speed = 1;
91  /** les rapports posés sur le bureau du chef */
92  inbox = 0;
93  printUntil = 0;
94  boardUntil = 0;
95  missionActive = false;
96  plan: PlanItem[] = [];
97  /** qui fait quoi : écrit au tableau, coché quand l'agent a rendu son travail */
98  assignments: { id: string; role: Role; name: string; task: string; status: 'working' | 'done' | 'failed' }[] = [];
99  private planSize = -1;
100  // ── la vie du bureau (et ses œufs de Pâques) ──
101  cat = new Cat();
102  /** la couleur du néon (clique dessus pour en changer) */
103  neonIndex = 0;
104  /** les meubles qui frémissent (plantes…) : identifiant → jusqu'à quand */
105  wiggles = new Map<string, number>();
106  /** les lampes éteintes */
107  lampsOff = new Set<string>();
108  /** le mode soirée (boule à facettes, tout le monde danse) */
109  discoUntil = 0;
110  /** la musique du jukebox : est-ce qu'elle joue, et l'énergie des basses (0 à 1) */
111  musicOn = false;
112  musicLevel = 0;
113  bobUntil = 0;
114  private noteAt = 0;
115  private steamAt = 0;
116  private waveAt = 6;
117  rosterVersion = 0;
118  private rosterListeners = new Set<() => void>();
119
120  constructor() {
121    this.resetAll();
122  }
123
124  get chef() { return this.actors.get('chef')!; }
125
126  onRoster(fn: () => void) { this.rosterListeners.add(fn); return () => this.rosterListeners.delete(fn); }
127  private rosterChanged() {
128    this.rosterVersion++;
129    for (const fn of this.rosterListeners) fn();
130  }
131
132  /** Le bureau vide, Max à l'accueil (début, nouvelle mission, rediffusion). */
133  resetAll() {
134    this.actors.clear();
135    this.seatOf.clear();
136    this.deliverTaken.clear();
137    this.fx = new Fx();
138    this.inbox = 0;
139    this.printUntil = 0;
140    this.boardUntil = 0;
141    this.missionActive = false;
142    this.plan = [];
143    this.assignments = [];
144    this.planSize = -1;
145    const p = SPOTS.receptionPx;
146    this.actors.set('chef', new Actor('chef', 'chef', 'Max', p.x, p.y));
147    this.rosterChanged();
148  }
149
150  // ── Les tâches ──
151  private queue(a: Actor, ...tasks: Task[]) { a.tasks.push(...tasks); }
152
153  private startTask(a: Actor, task: Task) {
154    a.curT = 0;
155    switch (task.t) {
156      case 'walk': {
157        if (a.seated) { a.seated = false; this.standUp(a); }
158        const tiles = findPath(this.grid, a.tile, task.tile);
159        a.path = (tiles ?? [task.tile]).map(tileFeet);
160        if (task.then) a.path.push(task.then);
161        break;
162      }
163      case 'slide':
164        a.path = [task.to];
165        break;
166      case 'sit':
167        a.path = [{ x: task.seat.x, y: task.seat.y }];
168        break;
169      case 'stand':
170        if (a.seated) { a.seated = false; this.standUp(a); }
171        break;
172    }
173  }
174
175  private standUp(a: Actor) {
176    if (!a.seat) return;
177    const f = tileFeet(a.seat.tile);
178    a.x = f.x + (a.seat.x - f.x > 4 ? 6 : 0);
179    a.y = f.y;
180  }
181
182  /** Avance une tâche. Renvoie vrai quand elle est finie. */
183  private runTask(a: Actor, task: Task, dt: number): boolean {
184    switch (task.t) {
185      case 'walk':
186      case 'slide':
187      case 'sit': {
188        const speed = (task.t === 'slide' && task.speed) || (task.t === 'sit' ? 30 : WALK_SPEED);
189        let budget = speed * dt;
190        while (budget > 0 && a.path.length) {
191          const p = a.path[0];
192          const dx = p.x - a.x, dy = p.y - a.y;
193          const d = Math.hypot(dx, dy);
194          if (d > 0.01 && task.t !== 'sit') a.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';
195          if (d <= budget) { a.x = p.x; a.y = p.y; a.path.shift(); budget -= d; }
196          else { a.x += (dx / d) * budget; a.y += (dy / d) * budget; budget = 0; }
197        }
198        a.moving = a.path.length > 0 && task.t !== 'sit';
199        if (a.path.length) return false;
200        a.moving = false;
201        if (task.t === 'sit') { a.seated = true; a.seat = task.seat; a.dir = 'down'; }
202        else if (task.face) a.dir = task.face;
203        return true;
204      }
205      case 'stand':
206        return true;
207      case 'wait':
208        if (task.dir) a.dir = task.dir;
209        return a.curT * 1000 >= task.ms;
210      case 'call':
211        task.fn();
212        return true;
213      case 'fade': {
214        const step = dt / Math.max(0.05, task.ms / 1000);
215        a.alpha = task.to > a.alpha ? Math.min(task.to, a.alpha + step) : Math.max(task.to, a.alpha - step);
216        return a.alpha === task.to;
217      }
218    }
219  }
220
221  // ── Les places ──
222  private assignSeat(role: Role): Seat | null {
223    const free = (r: Role | null) => SEATS.find((s) => (r === null || s.role === r) && !this.seatOf.has(s.id)) ?? null;
224    return free(role) ?? (role === 'renfort' ? free(null) : null);
225  }
226
227  private overflowSpot(): Tile {
228    const used = new Set([...this.actors.values()].map((a) => a.standSpot?.join(',')).filter(Boolean));
229    return SPOTS.overflow.find((t) => !used.has(t.join(','))) ?? SPOTS.overflow[0];
230  }
231
232  private nearestWindow(a: Actor): Pt {
233    const w = a.x < 176 ? WINDOWS[0] : WINDOWS[1];
234    return { x: w.x + w.w / 2, y: w.y + w.h - 4 };
235  }
236
237  /** Les rapports se posent ici, dans la corbeille du bureau du chef. */
238  get inboxPt(): Pt { return { x: 9 * T + 52, y: 7 * T - 2 }; }
239  get printerPt(): Pt { return { x: 12 * T + 8, y: 4 * T - 4 }; }
240
241  // ── Les événements ──
242  apply(ev: OfficeEvent, live: boolean) {
243    const chef = this.chef;
244    switch (ev.k) {
245      case 'start':
246        this.missionActive = true;
247        chef.thinkAt = this.time;
248        if (live) {
249          if (!chef.seated && !chef.busy) this.queue(chef, { t: 'wait', ms: 500, pose: 'wave', dir: 'down' }, { t: 'walk', tile: CHEF_SEAT.tile }, { t: 'sit', seat: CHEF_SEAT });
250          else if (!chef.seated) this.queue(chef, { t: 'walk', tile: CHEF_SEAT.tile }, { t: 'sit', seat: CHEF_SEAT });
251        } else this.snapSeat(chef, CHEF_SEAT);
252        break;
253      case 'think': {
254        const a = this.actors.get(ev.who);
255        if (a) a.thinkAt = this.time;
256        break;
257      }
258      case 'plan': {
259        this.plan = ev.items;
260        const n = ev.items.length;
261        const fresh = n !== this.planSize;
262        this.planSize = n;
263        if (live && fresh) {
264          // Max va écrire le plan au tableau
265          chef.tasks = chef.tasks.filter((t) => t.t !== 'sit');
266          this.queue(chef,
267            { t: 'walk', tile: SPOTS.board, face: 'up', then: SPOTS.boardPx },
268            { t: 'call', fn: () => { this.boardUntil = this.time + 1.6; } },
269            { t: 'wait', ms: 1600, pose: 'write', dir: 'up' },
270            { t: 'call', fn: () => { this.fx.sparkle(SPOTS.boardPx.x, 24, 10, '#ffffff'); } },
271            { t: 'walk', tile: CHEF_SEAT.tile },
272            { t: 'sit', seat: CHEF_SEAT },
273          );
274        } else if (live) {
275          this.fx.sparkle(SPOTS.boardPx.x + (Math.random() - 0.5) * 60, 22, 5, '#3ddc97');
276        }
277        break;
278      }
279      case 'hire': {
280        if (this.actors.has(ev.id)) break;
281        const seat = this.assignSeat(ev.role);
282        const a = new Actor(ev.id, ev.role, ev.name, SPOTS.doorOut.x - 8 + Math.random() * 16, SPOTS.doorOut.y);
283        if (!this.assignments.some((x) => x.id === ev.id)) this.assignments.push({ id: ev.id, role: ev.role, name: ev.name, task: ev.task, status: 'working' });
284        if (seat) { this.seatOf.set(seat.id, a.id); a.seat = seat; }
285        else a.standSpot = this.overflowSpot();
286        this.actors.set(a.id, a);
287        this.rosterChanged();
288        if (live) {
289          a.alpha = 0;
290          a.dir = 'up';
291          this.fx.beam(SPOTS.doorOut.x, 15 * T + 10);
292          this.queue(a,
293            { t: 'fade', to: 1, ms: 250 },
294            { t: 'slide', to: tileFeet(SPOTS.door), speed: 40 },
295          );
296          if (seat) this.queue(a, { t: 'walk', tile: seat.tile }, { t: 'sit', seat });
297          else this.queue(a, { t: 'walk', tile: a.standSpot!, face: 'down' });
298          // Max se tourne vers celui qu'il appelle
299          chef.happyUntil = this.time + 0.8;
300        } else if (seat) this.snapSeat(a, seat);
301        else { const f = tileFeet(a.standSpot!); a.x = f.x; a.y = f.y; }
302        break;
303      }
304      case 'act': {
305        const a = this.actors.get(ev.who);
306        if (!a) break;
307        a.act = ev.kind;
308        a.actAt = this.time;
309        if (!live) break;
310        this.fx.floatIcon(a.x + (a.seated ? 0 : 1), a.y - 24, iconFor(ev.kind));
311        if (ev.kind === 'search' || ev.kind === 'web') this.fx.data(this.nearestWindow(a), { x: a.x, y: a.y - 8 }, ev.kind === 'web' ? '#3ddc97' : '#7fd6ff');
312        break;
313      }
314      case 'report': {
315        const a = this.actors.get(ev.who);
316        if (!a || a.reported) break;
317        a.reported = true;
318        if (live) this.deliver(a);
319        else this.inbox++;
320        break;
321      }
322      case 'leave': {
323        const asg = this.assignments.find((x) => x.id === ev.who);
324        if (asg) asg.status = ev.ok ? 'done' : 'failed';
325        const a = this.actors.get(ev.who);
326        if (!a || a.left) break;
327        a.left = true;
328        if (!live) { this.remove(a); break; }
329        if (ev.ok && !a.reported) { a.reported = true; this.deliver(a); }
330        if (!ev.ok) a.sadUntil = this.time + 2;
331        this.exit(a);
332        break;
333      }
334      case 'file':
335        if (live) {
336          this.printUntil = this.time + 1.8;
337          this.fx.floatIcon(this.printerPt.x, this.printerPt.y - 6, 'paper');
338        }
339        break;
340      case 'ask': {
341        const a = this.actors.get(ev.who) ?? chef;
342        a.asks++;
343        if (live) this.fx.floatIcon(a.x, a.y - 26, 'alert');
344        break;
345      }
346      case 'ask_done':
347        for (const a of this.actors.values()) if (a.asks > 0) { a.asks = Math.max(0, a.asks - 1); break; }
348        break;
349      case 'end':
350        this.missionActive = false;
351        for (const a of this.actors.values()) if (a !== chef && !a.left) { a.left = true; if (live) this.exit(a); else this.remove(a); }
352        if (live) {
353          if (ev.ok) { this.fx.rain(22 * T); chef.happyUntil = this.time + 3; }
354          chef.tasks = [];
355          this.queue(chef,
356            { t: 'walk', tile: SPOTS.reception, then: SPOTS.receptionPx },
357            { t: 'wait', ms: 100, dir: 'down' },
358            { t: 'call', fn: () => {
359              if (ev.ok) {
360                chef.cheerUntil = this.time + 2.6;
361                this.fx.confetti(chef.x, chef.y - 20, 60);
362              } else chef.sadUntil = this.time + 2.5;
363            } },
364          );
365        } else {
366          chef.tasks = [];
367          chef.cur = null;
368          chef.seated = false;
369          chef.x = SPOTS.receptionPx.x;
370          chef.y = SPOTS.receptionPx.y;
371          chef.dir = 'down';
372        }
373        break;
374      case 'error':
375        chef.sadUntil = this.time + 2;
376        break;
377    }
378  }
379
380  private snapSeat(a: Actor, seat: Seat) {
381    a.tasks = [];
382    a.cur = null;
383    a.seat = seat;
384    a.seated = true;
385    a.x = seat.x;
386    a.y = seat.y;
387    a.dir = 'down';
388    a.alpha = 1;
389  }
390
391  /** Il se lève, prend son rapport et va le poser sur le bureau du chef. */
392  private deliver(a: Actor) {
393    let spot = SPOTS.deliver.findIndex((_, i) => !this.deliverTaken.has(i));
394    if (spot < 0) spot = Math.floor(Math.random() * SPOTS.deliver.length);
395    this.deliverTaken.add(spot);
396    a.deliverSpot = spot;
397    this.queue(a,
398      { t: 'stand' },
399      { t: 'call', fn: () => { a.carrying = true; } },
400      { t: 'walk', tile: SPOTS.deliver[spot], face: 'up' },
401      { t: 'call', fn: () => {
402        a.carrying = false;
403        this.fx.paper({ x: a.x, y: a.y - 12 }, this.inboxPt, () => { this.inbox++; this.fx.sparkle(this.inboxPt.x, this.inboxPt.y, 6, '#ffd2a8'); });
404        a.happyUntil = this.time + 1.2;
405        this.chef.happyUntil = this.time + 1.2;
406      } },
407      { t: 'wait', ms: 700, dir: 'up' },
408      { t: 'call', fn: () => { this.deliverTaken.delete(spot); a.delivered = true; } },
409    );
410  }
411
412  /** Il repart par la porte (et libère son bureau). */
413  private exit(a: Actor) {
414    this.queue(a,
415      { t: 'call', fn: () => { if (a.seat) this.seatOf.delete(a.seat.id); } },
416      { t: 'walk', tile: SPOTS.door },
417      { t: 'call', fn: () => this.fx.beam(SPOTS.doorOut.x, 15 * T + 10) },
418      { t: 'slide', to: { x: a.x, y: SPOTS.doorOut.y }, speed: 40 },
419      { t: 'fade', to: 0, ms: 200 },
420      { t: 'call', fn: () => this.remove(a) },
421    );
422  }
423
424  private remove(a: Actor) {
425    if (a.seat && this.seatOf.get(a.seat.id) === a.id) this.seatOf.delete(a.seat.id);
426    if (a.deliverSpot >= 0) this.deliverTaken.delete(a.deliverSpot);
427    a.gone = true;
428    this.actors.delete(a.id);
429    this.rosterChanged();
430  }
431
432  // ── Une image ──
433  /** Fait avancer le monde. Renvoie vrai si quelque chose bouge (pour savoir à quel rythme redessiner). */
434  update(dtReal: number): boolean {
435    const dt = Math.min(0.1, dtReal) * this.speed;
436    this.time += dt;
437    this.fx.update(dt);
438    let moving = this.fx.busy || this.time < this.printUntil || this.time < this.boardUntil;
439    const chef = this.chef;
440    // la vapeur du café
441    if (this.time > this.steamAt) { this.steamAt = this.time + 0.35; this.fx.steam(16 * T + 11, 13 * T + 4); }
442    // le chat
443    if (this.cat.update(dt, this.time, this.grid, (x, y) => this.fx.floatIcon(x, y, 'zzz'))) moving = true;
444    // le jukebox : des notes qui s'envolent quand il joue
445    if (this.musicOn && this.time > this.noteAt) {
446      this.noteAt = this.time + 0.7 + Math.random() * 0.5;
447      this.fx.floatIcon(14 * T + 10 + Math.random() * 12, 13 * T + 2, Math.random() < 0.5 ? 'note' : 'note2');
448    }
449    if (this.musicOn) moving = true;
450    // le mode soirée : des étincelles, et tout le monde danse
451    if (this.time < this.discoUntil) {
452      moving = true;
453      if (Math.random() < 0.25) this.fx.sparkle(32 + Math.random() * 288, 60 + Math.random() * 160, 2, ['#ff6fa8', '#4fb3ff', '#ffd166', '#3ddc97', '#ff8a3d'][Math.floor(Math.random() * 5)]);
454    }
455    for (const until of this.wiggles.values()) if (until > this.time) moving = true;
456    for (const a of this.actors.values()) {
457      if (a.id.startsWith('demo-') && this.time - a.actAt > 2 + Math.random() * 3) {
458        const kinds: ActKind[] = a.role === 'recherche' ? ['search', 'web'] : a.role === 'code' ? ['write', 'bash'] : a.role === 'analyse' ? ['read', 'write'] : ['write', 'edit'];
459        a.act = kinds[Math.floor(Math.random() * kinds.length)];
460        a.actAt = this.time;
461        this.fx.floatIcon(a.x, a.y - 24, iconFor(a.act));
462      }
463    }
464    // Max salue de temps en temps quand il attend à l'accueil
465    if (!this.missionActive && !chef.busy && !chef.seated && this.time > this.waveAt) {
466      this.waveAt = this.time + 9 + Math.random() * 6;
467      this.queue(chef, { t: 'wait', ms: 1200, pose: 'wave', dir: 'down' });
468    }
469    for (const a of [...this.actors.values()]) {
470      if (!a.cur && a.tasks.length) { a.cur = a.tasks.shift()!; this.startTask(a, a.cur); }
471      if (a.cur) {
472        const task = a.cur;
473        a.curT += dt;
474        if (this.runTask(a, task, dt) && a.cur === task) a.cur = null;
475      }
476      this.animate(a, dt);
477      if (a.moving || a.cur || a.seated || a.cheerUntil > this.time) moving = true;
478    }
479    return moving;
480  }
481
482  // ── Les petits gestes du bureau (quand on clique sur les objets) ──
483  neonNext() {
484    this.neonIndex = (this.neonIndex + 1) % 5;
485    this.fx.sparkle(97 + 12, 11 + 11, 10, '#ffffff');
486  }
487
488  wiggle(id: string, x: number, y: number) {
489    this.wiggles.set(id, this.time + 1.1);
490    this.fx.sparkle(x, y, 6, '#86cf7c');
491  }
492
493  coffee() {
494    for (let i = 0; i < 12; i++) this.fx.steam(16 * T + 9 + Math.random() * 6, 13 * T + 4 - Math.random() * 4);
495    this.fx.floatIcon(16 * T + 12, 13 * T - 2, 'coffee');
496  }
497
498  cooler() {
499    for (let i = 0; i < 6; i++) this.fx.sparkle(9 * T + 8, 3 * T + 4 - i, 1, '#bfe3ff');
500    this.fx.floatIcon(9 * T + 8, 3 * T - 4, 'drop');
501  }
502
503  toggleLamp(id: string) {
504    if (this.lampsOff.has(id)) this.lampsOff.delete(id); else this.lampsOff.add(id);
505  }
506
507  /** Le mode soirée : la boule à facettes descend, et toute l'équipe danse. */
508  disco(seconds = 16) {
509    this.discoUntil = this.time + seconds;
510    for (const a of this.actors.values()) a.cheerUntil = this.discoUntil;
511    this.fx.rain(22 * T, 70);
512  }
513
514  /** Pour la visite guidée sans mission : un agent par coin, assis à son bureau, qui travaille. */
515  demoTeam(on: boolean) {
516    const demo: [string, Role, string][] = [['demo-1', 'recherche', 'Iris'], ['demo-2', 'recherche', 'Hugo'], ['demo-3', 'code', 'Ada'], ['demo-4', 'design', 'Frida'], ['demo-5', 'redaction', 'Colette'], ['demo-6', 'analyse', 'Marie']];
517    if (on) {
518      for (const [id, role, name] of demo) {
519        if (this.actors.has(id)) continue;
520        const seat = this.assignSeat(role);
521        if (!seat) continue;
522        const a = new Actor(id, role, name, seat.x, seat.y);
523        this.seatOf.set(seat.id, id);
524        this.snapSeat(a, seat);
525        a.act = Math.random() < 0.5 ? 'search' : 'write';
526        a.actAt = this.time;
527        this.actors.set(id, a);
528      }
529    } else {
530      for (const [id] of demo) { const a = this.actors.get(id); if (a) this.remove(a); }
531    }
532    this.rosterChanged();
533  }
534
535  petCat() {
536    this.cat.pet(this.time);
537    this.fx.floatIcon(this.cat.x, this.cat.y - 12, 'heart');
538  }
539
540  /** La musique : appelé à chaque image avec l'énergie des basses. */
541  setMusic(on: boolean, level: number, beat: boolean) {
542    this.musicOn = on;
543    this.musicLevel = level;
544    if (beat) this.bobUntil = this.time + 0.12;
545  }
546
547  private animate(a: Actor, dt: number) {
548    a.animT += dt;
549    const t = this.time;
550    const recent = t - a.actAt < 9;
551    let pose: Pose;
552    if (a.cur?.t === 'wait' && a.cur.pose) pose = a.cur.pose;
553    else if (a.moving) pose = a.carrying ? 'carry' : 'walk';
554    else if (a.asks > 0) pose = 'raise';
555    else if (a.cheerUntil > t) pose = 'cheer';
556    else if (a.carrying) pose = 'carry';
557    else if (a.seated) {
558      if (recent && a.act && ['write', 'edit', 'bash', 'plan'].includes(a.act)) pose = 'type';
559      else if (recent) pose = 'read';
560      else pose = 'sit';
561    } else pose = 'stand';
562    if (pose !== a.pose) { a.pose = pose; a.animT = 0; }
563    const ft = a.animT;
564    switch (pose) {
565      case 'walk': a.frame = Math.floor(ft * 8) % 4; break;
566      case 'carry': a.frame = a.moving ? Math.floor(ft * 8) % 4 : 0; break;
567      case 'type': a.frame = Math.floor(ft * 7) % 2; break;
568      case 'cheer': case 'wave': a.frame = Math.floor(ft * 6) % 2; break;
569      case 'raise': a.frame = Math.floor(ft * 3) % 2; break;
570      case 'write': a.frame = Math.floor(ft * 5) % 2; break;
571      default: a.frame = 0;
572    }
573    if (pose === 'carry' && a.moving) {
574      // en marchant avec sa feuille : on garde la pose « porter », jambes en mouvement
575      a.frame = Math.floor(ft * 8) % 4;
576    }
577    // le regard
578    if (t > a.nextBlink) { a.blinkUntil = t + 0.12; a.nextBlink = t + 2.2 + Math.random() * 3.5; }
579    if (t > a.nextLook) {
580      const opts: Eyes[] = ['left', 'right', 'open', 'open', 'up'];
581      a.look = opts[Math.floor(Math.random() * opts.length)];
582      a.lookUntil = t + 0.8 + Math.random() * 0.8;
583      a.nextLook = t + 2.5 + Math.random() * 3;
584    }
585    let eyes: Eyes = 'open';
586    if (a.happyUntil > t || pose === 'cheer') eyes = 'happy';
587    else if (a.sadUntil > t) eyes = 'down';
588    else if (pose === 'type') eyes = 'down';
589    else if (pose === 'read') eyes = Math.floor(ft * 2.2) % 2 ? 'left' : 'right';
590    else if (pose === 'write') eyes = 'up';
591    else if (t - a.thinkAt < 2.5 && !a.moving) eyes = 'up';
592    else if (a.lookUntil > t && !a.moving) eyes = a.look;
593    if (a.blinkUntil > t && eyes !== 'happy') eyes = 'blink';
594    a.eyes = eyes;
595  }
596}
597
hooks/png.ts 190 lines
1// PNG out of an RGBA buffer, for a hooks module that has no canvas to call toDataURL on: 8-bit RGB, each row
2// filtered with Sub, compressed with deflate (LZ77 over a hash chain, fixed Huffman codes). Pixel art is mostly
3// runs, so a 352 × 256 frame comes out at a few tens of kilobytes.
4
5const CRC = (() => {
6  const t = new Uint32Array(256)
7  for (let n = 0; n < 256; n++) {
8    let c = n
9    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
10    t[n] = c >>> 0
11  }
12  return t
13})()
14
15function crc32(bytes: Uint8Array, start: number, end: number): number {
16  let c = 0xffffffff
17  for (let i = start; i < end; i++) c = CRC[(c ^ bytes[i]!) & 255]! ^ (c >>> 8)
18  return (c ^ 0xffffffff) >>> 0
19}
20
21function adler32(bytes: Uint8Array): number {
22  let a = 1
23  let b = 0
24  for (let i = 0; i < bytes.length; i++) {
25    a = (a + bytes[i]!) % 65521
26    b = (b + a) % 65521
27  }
28  return ((b << 16) | a) >>> 0
29}
30
31class Bits {
32  private out: number[] = []
33  private acc = 0
34  private n = 0
35  /** `count` bits of `value`, least significant first, as deflate packs them. */
36  put(value: number, count: number) {
37    for (let i = 0; i < count; i++) {
38      this.acc |= ((value >> i) & 1) << this.n
39      if (++this.n === 8) {
40        this.out.push(this.acc)
41        this.acc = 0
42        this.n = 0
43      }
44    }
45  }
46  /** A Huffman code, most significant bit first. */
47  code(value: number, count: number) {
48    for (let i = count - 1; i >= 0; i--) this.put((value >> i) & 1, 1)
49  }
50  bytes(): Uint8Array {
51    if (this.n > 0) this.out.push(this.acc)
52    return Uint8Array.from(this.out)
53  }
54}
55
56const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
57const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
58const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
59const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
60
61function literal(b: Bits, v: number) {
62  if (v < 144) b.code(0x30 + v, 8)
63  else if (v < 256) b.code(0x190 + v - 144, 9)
64  else if (v < 280) b.code(v - 256, 7)
65  else b.code(0xc0 + v - 280, 8)
66}
67
68function lengthCode(b: Bits, len: number) {
69  let i = LEN_BASE.length - 1
70  while (LEN_BASE[i]! > len) i--
71  literal(b, 257 + i)
72  b.put(len - LEN_BASE[i]!, LEN_EXTRA[i]!)
73}
74
75function distCode(b: Bits, dist: number) {
76  let i = DIST_BASE.length - 1
77  while (DIST_BASE[i]! > dist) i--
78  b.code(i, 5)
79  b.put(dist - DIST_BASE[i]!, DIST_EXTRA[i]!)
80}
81
82/** Raw deflate: one final block of fixed Huffman codes. */
83function deflate(data: Uint8Array): Uint8Array {
84  const b = new Bits()
85  b.put(1, 1) // final block
86  b.put(1, 2) // fixed Huffman
87  const WIN = 32768
88  const head = new Int32Array(1 << 15).fill(-1)
89  const prev = new Int32Array(WIN)
90  const hash = (i: number) => ((data[i]! << 10) ^ (data[i + 1]! << 5) ^ data[i + 2]!) & 0x7fff
91  const insert = (i: number) => {
92    if (i + 2 >= data.length) return
93    const h = hash(i)
94    prev[i % WIN] = head[h]!
95    head[h] = i
96  }
97  let i = 0
98  while (i < data.length) {
99    let bestLen = 0
100    let bestDist = 0
101    if (i + 2 < data.length) {
102      let j = head[hash(i)]!
103      let chain = 48
104      while (j >= 0 && i - j <= WIN && chain-- > 0) {
105        let l = 0
106        while (l < 258 && i + l < data.length && data[j + l] === data[i + l]) l++
107        if (l > bestLen) {
108          bestLen = l
109          bestDist = i - j
110          if (l === 258) break
111        }
112        const nj = prev[j % WIN]!
113        if (nj >= j) break
114        j = nj
115      }
116    }
117    if (bestLen >= 3) {
118      lengthCode(b, bestLen)
119      distCode(b, bestDist)
120      for (let k = 0; k < bestLen; k++) insert(i + k)
121      i += bestLen
122    } else {
123      literal(b, data[i]!)
124      insert(i)
125      i++
126    }
127  }
128  literal(b, 256) // end of block
129  return b.bytes()
130}
131
132function chunk(type: string, body: Uint8Array): Uint8Array {
133  const out = new Uint8Array(12 + body.length)
134  const v = new DataView(out.buffer)
135  v.setUint32(0, body.length)
136  for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
137  out.set(body, 8)
138  v.setUint32(8 + body.length, crc32(out, 4, 8 + body.length))
139  return out
140}
141
142/** A PNG of `w × h` RGBA pixels, alpha dropped (the office is opaque). */
143export function encodePng(rgba: Uint8ClampedArray, w: number, h: number): Uint8Array {
144  // filter Sub on every row: the difference with the pixel to the left, mostly zeros in flat colour
145  const raw = new Uint8Array(h * (1 + w * 3))
146  let k = 0
147  for (let y = 0; y < h; y++) {
148    raw[k++] = 1
149    for (let x = 0; x < w; x++) {
150      const i = (y * w + x) * 4
151      for (let c = 0; c < 3; c++) raw[k++] = (rgba[i + c]! - (x > 0 ? rgba[i - 4 + c]! : 0)) & 255
152    }
153  }
154  const z = deflate(raw)
155  const zlib = new Uint8Array(2 + z.length + 4)
156  zlib[0] = 0x78
157  zlib[1] = 0x01
158  zlib.set(z, 2)
159  new DataView(zlib.buffer).setUint32(2 + z.length, adler32(raw))
160  const ihdr = new Uint8Array(13)
161  const hv = new DataView(ihdr.buffer)
162  hv.setUint32(0, w)
163  hv.setUint32(4, h)
164  ihdr[8] = 8 // bit depth
165  ihdr[9] = 2 // RGB
166  const parts = [Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10), chunk('IHDR', ihdr), chunk('IDAT', zlib), chunk('IEND', new Uint8Array(0))]
167  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
168  let o = 0
169  for (const p of parts) {
170    out.set(p, o)
171    o += p.length
172  }
173  return out
174}
175
176const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
177
178export function base64(bytes: Uint8Array): string {
179  let out = ''
180  for (let i = 0; i < bytes.length; i += 3) {
181    const a = bytes[i]!
182    const b = bytes[i + 1]
183    const c = bytes[i + 2]
184    out += B64[a >> 2]! + B64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
185    out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
186    out += c === undefined ? '=' : B64[c & 63]!
187  }
188  return out
189}
190
hooks/canvas.ts 293 lines
1// A canvas for a hooks module, which has no DOM: just enough of CanvasRenderingContext2D for the office engine of
2// Le Bureau des agents to draw unchanged. Integer pixels on an RGBA buffer, with fillStyle (hex, rgb[a] and radial
3// gradients), fillRect, drawImage, globalAlpha, the source-over, lighter and multiply composites, save/restore,
4// rectangular clips, filled polygons, getImageData, and the translate/scale pair the engine uses to mirror sprites.
5
6type RGBA = [number, number, number, number]
7
8class RadialGradient {
9  private stops: { at: number; c: RGBA }[] = []
10  constructor(
11    readonly x: number,
12    readonly y: number,
13    readonly r: number,
14  ) {}
15  addColorStop(at: number, color: string) {
16    this.stops.push({ at, c: parseColor(color) })
17    this.stops.sort((a, b) => a.at - b.at)
18  }
19  colorAt(px: number, py: number): RGBA {
20    const t = this.r > 0 ? Math.min(1, Math.hypot(px - this.x, py - this.y) / this.r) : 1
21    const s = this.stops
22    if (s.length === 0) return [0, 0, 0, 0]
23    if (t <= s[0]!.at) return s[0]!.c
24    for (let i = 1; i < s.length; i++) {
25      const a = s[i - 1]!
26      const b = s[i]!
27      if (t <= b.at) {
28        const k = b.at > a.at ? (t - a.at) / (b.at - a.at) : 1
29        return [a.c[0] + (b.c[0] - a.c[0]) * k, a.c[1] + (b.c[1] - a.c[1]) * k, a.c[2] + (b.c[2] - a.c[2]) * k, a.c[3] + (b.c[3] - a.c[3]) * k]
30      }
31    }
32    return s[s.length - 1]!.c
33  }
34}
35
36const colorCache = new Map<string, RGBA>()
37
38export function parseColor(s: string): RGBA {
39  const hit = colorCache.get(s)
40  if (hit) return hit
41  let c: RGBA = [0, 0, 0, 1]
42  const t = s.trim()
43  if (t.startsWith('#')) {
44    let h = t.slice(1)
45    if (h.length === 3 || h.length === 4) h = h.split('').map(x => x + x).join('')
46    const n = parseInt(h.slice(0, 6), 16)
47    c = [(n >> 16) & 255, (n >> 8) & 255, n & 255, h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1]
48  } else {
49    const m = /rgba?\(([^)]+)\)/.exec(t)
50    if (m) {
51      const p = m[1]!.split(',').map(x => parseFloat(x))
52      c = [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0, p[3] ?? 1]
53    } else if (t === 'transparent') c = [0, 0, 0, 0]
54    else if (t === 'white') c = [255, 255, 255, 1]
55    else if (t === 'black') c = [0, 0, 0, 1]
56  }
57  colorCache.set(s, c)
58  return c
59}
60
61type Composite = 'source-over' | 'lighter' | 'multiply'
62type Clip = { x0: number; y0: number; x1: number; y1: number }
63type Saved = { alpha: number; op: Composite; fill: string | RadialGradient; clip: Clip; sx: number; sy: number; tx: number; ty: number }
64
65export class Canvas {
66  data: Uint8ClampedArray
67  private ctx: Ctx | null = null
68  constructor(
69    public width: number,
70    public height: number,
71  ) {
72    this.data = new Uint8ClampedArray(width * height * 4)
73  }
74  getContext(_kind?: string, _opts?: unknown): Ctx {
75    this.ctx ??= new Ctx(this)
76    return this.ctx
77  }
78}
79
80export class Ctx {
81  globalAlpha = 1
82  globalCompositeOperation: Composite = 'source-over'
83  fillStyle: string | RadialGradient = '#000'
84  imageSmoothingEnabled = false
85  private clipR: Clip
86  private stack: Saved[] = []
87  private path: [number, number][][] = []
88  // the only transform the engine uses: translate then scale by ±1, to mirror a sprite
89  private sx = 1
90  private sy = 1
91  private tx = 0
92  private ty = 0
93
94  constructor(readonly canvas: Canvas) {
95    this.clipR = { x0: 0, y0: 0, x1: canvas.width, y1: canvas.height }
96  }
97
98  save() {
99    this.stack.push({ alpha: this.globalAlpha, op: this.globalCompositeOperation, fill: this.fillStyle, clip: { ...this.clipR }, sx: this.sx, sy: this.sy, tx: this.tx, ty: this.ty })
100  }
101  restore() {
102    const s = this.stack.pop()
103    if (!s) return
104    this.globalAlpha = s.alpha
105    this.globalCompositeOperation = s.op
106    this.fillStyle = s.fill
107    this.clipR = s.clip
108    this.sx = s.sx
109    this.sy = s.sy
110    this.tx = s.tx
111    this.ty = s.ty
112  }
113  translate(x: number, y: number) {
114    this.tx += x * this.sx
115    this.ty += y * this.sy
116  }
117  scale(x: number, y: number) {
118    this.sx *= x
119    this.sy *= y
120  }
121
122  createRadialGradient(_x0: number, _y0: number, _r0: number, x1: number, y1: number, r1: number) {
123    return new RadialGradient(x1, y1, r1)
124  }
125
126  /** One device pixel: the colour blended in with the alpha and composite of the moment. */
127  private put(x: number, y: number, c: RGBA) {
128    const cl = this.clipR
129    if (x < cl.x0 || y < cl.y0 || x >= cl.x1 || y >= cl.y1) return
130    const d = this.canvas.data
131    const i = (y * this.canvas.width + x) * 4
132    const a = c[3] * this.globalAlpha
133    if (a <= 0) return
134    const op = this.globalCompositeOperation
135    if (op === 'lighter') {
136      d[i] = d[i]! + c[0] * a
137      d[i + 1] = d[i + 1]! + c[1] * a
138      d[i + 2] = d[i + 2]! + c[2] * a
139      d[i + 3] = d[i + 3]! + a * 255
140      return
141    }
142    if (op === 'multiply') {
143      d[i] = d[i]! * (1 - a) + ((d[i]! * c[0]) / 255) * a
144      d[i + 1] = d[i + 1]! * (1 - a) + ((d[i + 1]! * c[1]) / 255) * a
145      d[i + 2] = d[i + 2]! * (1 - a) + ((d[i + 2]! * c[2]) / 255) * a
146      return
147    }
148    const da = d[i + 3]! / 255
149    const oa = a + da * (1 - a)
150    if (oa <= 0) return
151    d[i] = (c[0] * a + d[i]! * da * (1 - a)) / oa
152    d[i + 1] = (c[1] * a + d[i + 1]! * da * (1 - a)) / oa
153    d[i + 2] = (c[2] * a + d[i + 2]! * da * (1 - a)) / oa
154    d[i + 3] = oa * 255
155  }
156
157  /** A user-space rectangle as device pixels, through the mirror transform. */
158  private span(x: number, y: number, w: number, h: number): Clip {
159    const ax = this.tx + this.sx * x
160    const bx = this.tx + this.sx * (x + w)
161    const ay = this.ty + this.sy * y
162    const by = this.ty + this.sy * (y + h)
163    return { x0: Math.round(Math.min(ax, bx)), x1: Math.round(Math.max(ax, bx)), y0: Math.round(Math.min(ay, by)), y1: Math.round(Math.max(ay, by)) }
164  }
165
166  fillRect(x: number, y: number, w: number, h: number) {
167    const r = this.span(x, y, w, h)
168    const f = this.fillStyle
169    if (typeof f === 'string') {
170      const c = parseColor(f)
171      for (let py = r.y0; py < r.y1; py++) for (let px = r.x0; px < r.x1; px++) this.put(px, py, c)
172    } else {
173      for (let py = r.y0; py < r.y1; py++) for (let px = r.x0; px < r.x1; px++) this.put(px, py, f.colorAt(px + 0.5, py + 0.5))
174    }
175  }
176
177  clearRect(x: number, y: number, w: number, h: number) {
178    const r = this.span(x, y, w, h)
179    const d = this.canvas.data
180    for (let py = Math.max(0, r.y0); py < Math.min(this.canvas.height, r.y1); py++)
181      for (let px = Math.max(0, r.x0); px < Math.min(this.canvas.width, r.x1); px++) d.fill(0, (py * this.canvas.width + px) * 4, (py * this.canvas.width + px) * 4 + 4)
182  }
183
184  drawImage(src: Canvas, ...a: number[]) {
185    let sx = 0
186    let sy = 0
187    let sw = src.width
188    let sh = src.height
189    let dx: number
190    let dy: number
191    let dw: number
192    let dh: number
193    if (a.length >= 8) [sx, sy, sw, sh, dx, dy, dw, dh] = a as [number, number, number, number, number, number, number, number]
194    else if (a.length >= 4) [dx, dy, dw, dh] = a as [number, number, number, number]
195    else {
196      ;[dx, dy] = a as [number, number]
197      dw = sw
198      dh = sh
199    }
200    const r = this.span(dx, dy, dw, dh)
201    const flipX = this.sx < 0
202    const flipY = this.sy < 0
203    const s = src.data
204    const W = r.x1 - r.x0
205    const H = r.y1 - r.y0
206    if (W <= 0 || H <= 0) return
207    const c: RGBA = [0, 0, 0, 0]
208    for (let j = 0; j < H; j++) {
209      const v = flipY ? H - 1 - j : j
210      const syy = Math.floor(sy + (v * sh) / H)
211      if (syy < 0 || syy >= src.height) continue
212      for (let i = 0; i < W; i++) {
213        const u = flipX ? W - 1 - i : i
214        const sxx = Math.floor(sx + (u * sw) / W)
215        if (sxx < 0 || sxx >= src.width) continue
216        const k = (syy * src.width + sxx) * 4
217        const alpha = s[k + 3]!
218        if (alpha === 0) continue
219        c[0] = s[k]!
220        c[1] = s[k + 1]!
221        c[2] = s[k + 2]!
222        c[3] = alpha / 255
223        this.put(r.x0 + i, r.y0 + j, c)
224      }
225    }
226  }
227
228  getImageData(x: number, y: number, w: number, h: number) {
229    const out = new Uint8ClampedArray(w * h * 4)
230    const d = this.canvas.data
231    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
232      const sx = x + i
233      const sy = y + j
234      if (sx < 0 || sy < 0 || sx >= this.canvas.width || sy >= this.canvas.height) continue
235      out.set(d.subarray((sy * this.canvas.width + sx) * 4, (sy * this.canvas.width + sx) * 4 + 4), (j * w + i) * 4)
236    }
237    return { data: out, width: w, height: h }
238  }
239
240  // ── paths: rectangles (for clips) and polygons (filled) ──
241  beginPath() {
242    this.path = []
243  }
244  moveTo(x: number, y: number) {
245    this.path.push([[x, y]])
246  }
247  lineTo(x: number, y: number) {
248    if (this.path.length === 0) this.path.push([])
249    this.path[this.path.length - 1]!.push([x, y])
250  }
251  closePath() {}
252  rect(x: number, y: number, w: number, h: number) {
253    this.path.push([
254      [x, y],
255      [x + w, y],
256      [x + w, y + h],
257      [x, y + h],
258    ])
259  }
260  clip() {
261    // the engine clips to one rectangle at a time: its bounds, met with the clip already set
262    const pts = this.path.flat()
263    if (!pts.length) return
264    const r = this.span(Math.min(...pts.map(p => p[0])), Math.min(...pts.map(p => p[1])), 0, 0)
265    const r2 = this.span(Math.max(...pts.map(p => p[0])), Math.max(...pts.map(p => p[1])), 0, 0)
266    const cl = this.clipR
267    this.clipR = { x0: Math.max(cl.x0, Math.min(r.x0, r2.x0)), y0: Math.max(cl.y0, Math.min(r.y0, r2.y0)), x1: Math.min(cl.x1, Math.max(r.x0, r2.x0)), y1: Math.min(cl.y1, Math.max(r.y0, r2.y0)) }
268  }
269  fill() {
270    const f = this.fillStyle
271    const solid = typeof f === 'string' ? parseColor(f) : null
272    for (const poly of this.path) {
273      if (poly.length < 3) continue
274      const pts = poly.map(([x, y]) => [this.tx + this.sx * x, this.ty + this.sy * y] as [number, number])
275      const y0 = Math.floor(Math.min(...pts.map(p => p[1])))
276      const y1 = Math.ceil(Math.max(...pts.map(p => p[1])))
277      for (let py = y0; py < y1; py++) {
278        const cy = py + 0.5
279        const xs: number[] = []
280        for (let i = 0; i < pts.length; i++) {
281          const [ax, ay] = pts[i]!
282          const [bx, by] = pts[(i + 1) % pts.length]!
283          if ((ay <= cy && by > cy) || (by <= cy && ay > cy)) xs.push(ax + ((cy - ay) / (by - ay)) * (bx - ax))
284        }
285        xs.sort((p, q) => p - q)
286        for (let k = 0; k + 1 < xs.length; k += 2) {
287          for (let px = Math.round(xs[k]!); px < Math.round(xs[k + 1]!); px++) this.put(px, py, solid ?? (f as RadialGradient).colorAt(px + 0.5, cy))
288        }
289      }
290    }
291  }
292}
293