SLOPSHOPPER

hollowzinho

Um Hollow de estimação no Claude Code: reage às ferramentas, abate os bugs com um Cero e dorme quando você some.

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hollowzinho
│ ┃ Hollowzinho ✕ › fix the failing auth test and add an audit log call │ ┃ Hollowzinho · Hollow │ ┃ “There is still 1 live bug…” ⏺ Read(src/auth.ts) │ ┃ ███████░ reiatsu 83 ♥ 0 ✦ 0 ⎿ Read 6 lines │ ┃ bond 0 · hunted 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▄▀█▄ ⏺ Bash(bun test) │ ┃ █▀▀▀▀▀▀▀▄▄▄█ ⎿ 3 pass, 1 fail │ ┃ ▄▀▀████████▀▀▀ │ ┃ ▄ ▄ ██▀▀██▀▀██▀▀██ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▄ ▀▄▄▀ ▄ ▀█▀▀██████▀▀█▀ │ ┃ ▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ ┃ ▀ ▀▀ ▀ ▀█▀█▀▀█▀█▀ │ ┃ ▀▀█▀▀▀▀█▀▀ › /hollowzinho │ ┃ ⎿ hollowzinho: Hollowzinho pane opened. │ ┃ [ Pet ] [ Feed ] [ Cero ] [ Sleep ] [ Hide │ ┃ │ ┃ Live bugs │ ┃ ● bun test │ ┃ │ ┃ A bug is born when a build or test command │ ┃ fails and dies when the same command │ ┃ passes. │ ╭────────────────────────────╮ ▄▀█▄▄▄▄ ▄ │ There is still 1 live bug… │ ▄▀▀██████▀▀▀ ╰────────────────────────────╯ ▀▄ ▄▀ █▀▀██▀▀█▀▀██ ███████░ reiatsu 83 ♥ 0 ✦ 0 ▀▄█▀▀█▄▀ ██▀▀█████▀▀█ ▄▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭────────────────────────────╮ ▄▀█▄▄▄▄ ▄ │ There is still 1 live bug… │ ▄▀▀██████▀▀▀ ╰────────────────────────────╯ ▀▄ ▄▀ █▀▀██▀▀█▀▀██ ███████░ reiatsu 83 ♥ 0 ✦ 0 ▀▄█▀▀█▄▀ ██▀▀█████▀▀█ ▄▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀
Pane · Hollowzinho
Hollowzinho · Hollow “There is still 1 live bug…” ███████░ reiatsu 83 ♥ 0 ✦ 0 bond 0 · hunted 0 ▄▀█▄ █▀▀▀▀▀▀▀▄▄▄█ ▄▀▀████████▀▀▀ ▄ ▄ ██▀▀██▀▀██▀▀██ ▄ ▀▄▄▀ ▄ ▀█▀▀██████▀▀█▀ ▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀ ▀ ▀█▀█▀▀█▀█▀ ▀▀█▀▀▀▀█▀▀ [ Pet ] [ Feed ] [ Cero ] [ Sleep ] [ Hide ] Live bugs ● bun test A bug is born when a build or test command fails and dies when the same command passes.
README

Hollowzinho para o Claude Code

Um Hollow de estimação que mora no seu Claude Code. Ele reage ao que o Claude faz: cada build ou teste que falha vira um bug, e quando o mesmo comando passa ele abate o bug com um Cero. Também come os seus prompts e commits, caça enquanto o Claude trabalha e dorme quando você some.

<img src="../../docs/claude-code/terminal-cero.webp" alt="Gravação do Claude Code de verdade: o npm test falha e um bug aparece ao lado do Hollowzinho; o teste passa e o Cero sai da boca dele, atinge o bug, explode, e ele respira aliviado" width="100%">

Os prints deste README são do Claude Code de verdade (v2.1.289, num terminal de 120 colunas), com o plugin instalado: nada aqui é maquete. Só os do desktop são prévias, e dizem isso.

É o mesmo bichinho do <hollow-pet> e da extensão do VS Code, agora em pixel art de terminal. Não precisa de mais nada: não usa rede, não escreve arquivos e não chama modelo.

Instalar

No Claude Code:

/plugin marketplace add juninmd/hueco-mundo-theme
/plugin install hollowzinho@hueco-mundo

Ou, do terminal: claude plugin marketplace add juninmd/hueco-mundo-theme && claude plugin install hollowzinho@hueco-mundo.

Para experimentar sem instalar, aponte para a pasta (num clone deste repositório):

claude --plugin-dir ./claude-code/hollowzinho

A faixa aparece acima do prompt assim que a sessão abre. Na instalação o Claude Code avisa que 6 opções ainda não foram definidas; pode ignorar, os padrões servem. Para mudar alguma: /plugin configure hollowzinho@hueco-mundo.

Precisa de um Claude Code com plugins de function hooks (módulos em hooks/hooks.json). Foi testado na 2.1.289.

O que ele percebe

O que aconteceO que ele faz
um comando de build ou teste falha (npm test, cargo build, pytest, tsc, make…)nasce um bug ao lado dele: "Opa… falhou: npm test."
o mesmo comando falha de novoo bug conta mais uma falha (×2, ×3…) e, na terceira, vira um chefe dourado
o mesmo comando passaatira o Cero e abate o bug (o chefe vale dois): "Sem bugs! Respirei."
o Claude roda `npm test 2>&1 \tail`o tail esconde o código de saída, então ele lê o final da saída: FAIL, 1 failed, error TS…, Traceback…
você manda um promptcome um pouco do texto e acorda
o Claude trabalha (turno em andamento)modo caçador: a marca vermelha no rosto
o turno acaba com bugs vivosfica preocupado e diz quantos faltam
você interrompe o turnoleva um susto
commit, push, PR criado ou mergeado, branch novacome, comemora, cria vínculo
o Claude edita arquivoscome um pouco, de vez em quando comenta
nada acontece por 5 minutosdorme (e acorda no próximo prompt)

O reiatsu (barra verde) cai devagar e o bichinho avisa quando está com fome; o vínculo (♥ vermelho) cresce com comida, carinho, commits e bugs abatidos (✦). Com 15 de vínculo ele vira Adjuchas e com 40, Vasto Lorde.

<img src="../../docs/claude-code/terminal-bug.png" alt="Claude Code: o npm test falhou e um bug pixelado anda até o Hollowzinho, que diz Opa… falhou: npm test." width="100%">

Um bug é um comando, não uma linha. npm test, npm test -- a.test.ts e npm test --watch=false são o mesmo bug. Só o que parece build, teste ou verificação conta: um grep sem resultado também sai com erro e não é problema. Ele não corrige nada: quem faz o teste passar é o Claude (ou você).

<img src="../../docs/claude-code/terminal-cero.png" alt="Claude Code: o Cero atinge o bug, que explode, e o Hollowzinho diz Sem bugs! Respirei." width="100%">

O comando /hollowzinho

/hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]
SubcomandoFaz
paneabre o painel do bicho (veja abaixo)
petcarinho
feeddá reiatsu (+26)
ceroatira no bug mais perto. Custa 12 de reiatsu e só raspa: quem abate é o teste que passa
sleep / wakepõe para dormir / acorda
hide / showesconde / mostra o bicho (e ele continua escondido nas próximas sessões). O [-] ao lado da faixa, que é do próprio Claude Code, só a recolhe
statusuma linha com o estado
resetrecomeça: vínculo e abatidos voltam a 0 e o reiatsu ao valor inicial

Os nomes em inglês e em português valem (/hollowzinho dormir, /hollowzinho painel, /hollowzinho alimentar…).

O painel

/hollowzinho pane abre o painel: o corpo inteiro do bicho, os cinco botões (Carinho, Alimentar, Cero, Dormir, Esconder, também nas teclas 1 a 5) e a lista de bugs vivos. Em tela cheia com 110 colunas ou mais ele fica ao lado da conversa; nas outras, acima do prompt.

<img src="../../docs/claude-code/terminal-pane.png" alt="Claude Code com o painel do Hollowzinho ao lado da conversa: o corpo inteiro do bicho, o Cero a caminho do bug, os botões e a lista Bugs vivos" width="100%">

Onde ele aparece

SuperfícieFaixa acima do promptPainel
Terminal✅ pixel art em células (▀▄█, 2 pixels por célula)✅
Desktop✅ SVG animado✅
Celular e VS Codenão existe lá✅ SVG

Num terminal estreito a faixa se adapta: abaixo de 64 colunas a pista dos bugs some (ficam o bicho, o texto e um +N bugs) e abaixo de 40 vira uma linha de texto. Dá para trocar a faixa por só uma linha na barra de status (display: status) ou deixar tudo escondido e abrir só pelo /hollowzinho (display: off).

<img src="../../docs/claude-code/desktop-band.png" alt="Prévia da faixa no app desktop: o Hollowzinho em SVG ao lado de dois bugs, com a fala Opa… falhou: tsc." width="100%">

<img src="../../docs/claude-code/desktop-cero.png" alt="Prévia da faixa no app desktop: o Cero cruza a faixa e atinge o bug, com a fala Boom, um a menos." width="100%">

<img src="../../docs/claude-code/desktop-pane.png" alt="Prévia do painel no app desktop: o corpo inteiro do Hollowzinho em SVG, os cinco botões e a lista Bugs vivos com um bug comum e um chefe dourado" width="100%">

As imagens do desktop são prévias: o app não dá para ser capturado do jeito que o terminal foi. Elas são a árvore que o plugin entrega ao Claude Code (npm run claude:preview), desenhada num HTML. Já os do terminal são do Claude Code de verdade, num tmux.

Configurações

Todas são opcionais (/plugin configure hollowzinho@hueco-mundo):

OpçãoValoresPadrãoO que muda
displayband, status, offbanda faixa acima do prompt, só uma linha na barra de status, ou nada
languageauto, pt, enautoidioma dos balões; auto segue LC_ALL/LC_MESSAGES/LANG (pt* fala português, o resto, inglês)
nametextoHollowzinhoo nome do bicho
tintcero, reishi, ouroceroa cor das marcas e do raio: vermelho, verde ou dourado
chatteroff, low, normallowquanto ele fala sozinho: nada, de vez em quando, bastante
sleepAfterMinutesnúmero ≥ 05minutos sem atividade até dormir; 0 nunca dorme

O que ele guarda e o que ele vê

  • Vê, só na memória: o nome da ferramenta que o Claude usou, o comando do Bash (para saber se parece build ou teste e de qual família ele é) e as últimas linhas da saída, e só quando o comando esconde o código de saída (um pipe, um || true). Os bugs vivos mostram só o comando (npm test), nunca a saída.
  • Guarda ($.store, no armazenamento do plugin do Claude Code): reiatsu, vínculo, abatidos, se está escondido e a hora da última vez. Nenhum comando, nenhuma saída, nenhum prompt, nenhum arquivo seu.
  • Não faz: rede, leitura nem escrita de arquivos, chamadas a modelo, alteração de ferramentas (ele só observa; o resultado de cada ferramenta volta intacto) nem negação de nada.

Ele só roda em sessões interativas: em claude -p fica desligado.

Limites conhecidos

  • O Cero não conserta nada. O bug morre quando o comando passa; /hollowzinho cero só raspa.
  • Um "bug" é um comando de build ou teste que sai com erro (ou que a saída entrega, quando há pipe). Um teste que falha sem mostrar FAIL, failed ou parecidos, atrás de um pipe, passa despercebido.
  • No terminal, a pixel art usa a paleta de 256 cores do xterm, então ela sai igual em qualquer terminal (o Claude Code leva cada célula à cor mais próxima da paleta quando o terminal não tem cor verdadeira).
  • Dentro do tmux o Claude Code limita as cores a 256, a menos que CLAUDE_CODE_TMUX_TRUECOLOR=1. A paleta do bicho já é de 256 cores, então nada muda.
  • Não foi testado no app desktop nem no celular: a árvore é validada e testada (claude plugin test), mas a prévia é um HTML, não o app.

Desenvolvimento

npm run claude:check     # os manifestos; com o `claude` no PATH, também validate --strict e test (é o que o npm test roda)
npm run claude:test      # claude plugin test: a lógica do bicho e o plugin inteiro contra o motor
npm run claude:preview   # refaz as prévias do desktop em docs/claude-code/ (precisa do Chromium do Playwright)
npx tsc -p claude-code/hollowzinho   # tipos (o motor escreve .claude-plugin/types/ ao carregar o plugin)
.claude-plugin/plugin.json   o manifesto: nome, versão e as seis opções
hooks/hooks.json             um módulo só: register.tsx
hooks/register.tsx           os eventos (sessão, prompt, turno, ferramenta, comando, desenho), o relógio e o que se guarda
hooks/pet.ts                 a lógica pura: bugs, Cero, humor, fome, sono, evolução
hooks/view.tsx               a faixa e o painel (terminal em Raster, as outras superfícies em Svg)
hooks/sprite.ts              a pixel art (o avatar 12×10, o corpo 16×16, o bug, o raio e a explosão) e o empacotamento em células
hooks/art.ts                 a mesma arte em SVG, para o desktop, o celular e o VS Code
hooks/text.ts                as falas em português e em inglês
types/index.d.ts             o que fica em `$.state`
tests/                       pet.test.ts (lógica) e engine.test.tsx (o plugin contra o motor)

A lógica é toda em funções puras sobre um único valor (hollowzinho.pet); o desenho só lê esse valor, então escrever nele redesenha a faixa e o painel sozinho. O bichinho é o mesmo do pet da página e da extensão do VS Code: a arte em SVG é a mesma, com os mesmos estágios, rostos e cores.

Source 7 files
hooks/register.tsx 252 lines
1// Hollowzinho para o Claude Code: um Hollow de estimação que mora acima do prompt.
2//
3//  - Cada comando de build ou teste que falha vira um bugzinho na cena; quando o mesmo comando passa, o pet atira um Cero e abate.
4//  - Edições, commits, pushes e PRs alimentam o bicho; um turno em andamento o põe em modo caçador; parado, ele dorme.
5//  - /hollowzinho abre o painel (ou carinho, comida, Cero, sono) e guarda o vínculo entre as sessões.
6//
7// Este arquivo só liga o Claude Code à lógica do pet.ts e ao desenho do view.tsx. Nada aqui altera o que o Claude faz:
8// as ferramentas rodam como sempre e o resultado volta intacto, mesmo que o pet tropece.
9
10import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
11
12import type { Hollow } from '../types'
13import { act, begin, nextDue, onPrompt, onTool, onTurnEnd, onTurnStart, restore, rulesOf, save, seed, statusLine, step, type Action, type Rules, type ToolInfo } from './pet'
14import type { Tint } from './sprite'
15import { WORDS } from './text'
16import { remoteBand, remotePane, statusText, terminalBand, terminalLine, terminalPane, type Look } from './view'
17
18type Dollar = EngineInterface
19
20const PET = { plugin: 'hollowzinho', key: 'pet' } as const
21const PANE = 'hollowzinho'
22const SAVED = 'pet'
23// Do que o Bash escreveu, só o final importa (o resumo do teste vem por último).
24const OUTPUT_TAIL = 4000
25
26const text = (v: unknown, d: string): string => (typeof v === 'string' && v.trim() !== '' ? v.trim() : d)
27const oneOf = <T extends string>(v: unknown, list: readonly T[], d: T): T => (list.includes(v as T) ? (v as T) : d)
28
29/** O que o `/hollowzinho` entende, em português e em inglês. */
30const WORDS_OF: Readonly<Record<string, Action | 'status' | 'pane' | 'help'>> = {
31  '': 'pane', pane: 'pane', painel: 'pane', open: 'pane', abrir: 'pane',
32  pet: 'pet', carinho: 'pet',
33  feed: 'feed', alimentar: 'feed', comer: 'feed',
34  cero: 'cero',
35  sleep: 'sleep', dormir: 'sleep',
36  wake: 'wake', acordar: 'wake',
37  hide: 'hide', esconder: 'hide',
38  show: 'show', mostrar: 'show',
39  reset: 'reset', recomecar: 'reset', 'recomeçar': 'reset',
40  status: 'status', estado: 'status',
41  help: 'help', ajuda: 'help', '?': 'help',
42}
43
44/* ───────────────────────── configuração e estado do módulo ─────────────────────────
45 * O motor só deixa passar o `$` a funções declaradas no topo do arquivo (assim `claude plugin validate` lista tudo o que o
46 * módulo chama). Por isso o que `register` recebe fica aqui em cima, em variáveis do módulo: elas valem por carga, e uma
47 * recarga a quente começa do zero. */
48
49type Config = { options: PluginOptions; name: string; tint: Tint; display: 'band' | 'status' | 'off' }
50
51let cfg: Config = { options: {}, name: 'Hollowzinho', tint: 'cero', display: 'band' }
52/** Sem pessoa à frente (`claude -p`, SDK) não há o que desenhar: o bicho dorme quieto. */
53let live = true
54let rulesCache: Promise<Rules> | undefined
55let timer: Timer | undefined
56let saver: Timer | undefined
57
58const lookOf = (r: Rules): Look => ({ name: cfg.name, lang: r.lang, tint: cfg.tint })
59
60async function rules($: Dollar): Promise<Rules> {
61  rulesCache ??= (async () => {
62    const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
63    return rulesOf(cfg.options, env, Math.random)
64  })()
65  return rulesCache
66}
67
68/* ───────── estado: uma única fonte, lida pela tela e escrita só por aqui ───────── */
69
70async function persist($: Dollar): Promise<void> {
71  const { value } = await $.state.get(PET)
72  if (value) await $.store.set(SAVED, save(value, await $.clock.now()))
73}
74
75function saveSoon($: Dollar): void {
76  saver?.cancel()
77  saver = $.clock.after(1500, () => void persist($).catch(() => {}))
78}
79
80async function paintStatus($: Dollar, p: Hollow): Promise<void> {
81  if (cfg.display !== 'status') return
82  $.ui.status(p.hidden ? undefined : statusText(p, lookOf(await rules($))))
83}
84
85/** Aplica `fn` ao estado. Se `fn` devolve o mesmo objeto, nada é escrito e nada é redesenhado. */
86async function change($: Dollar, fn: (p: Hollow) => Hollow): Promise<{ state: Hollow; changed: boolean }> {
87  for (let tries = 0; tries < 8; tries++) {
88    const held = await $.state.get(PET)
89    const before = held.value ?? seed(0)
90    const after = fn(before)
91    if (after === before) return { state: before, changed: false }
92    const wrote = await $.state.set(PET, after, { ifVersion: held.version })
93    if (wrote.isSet) {
94      void paintStatus($, after).catch(() => {})
95      saveSoon($)
96      return { state: after, changed: true }
97    }
98  }
99  return { state: (await $.state.get(PET)).value ?? seed(0), changed: false }
100}
101
102/* ───────── o tempo: um único relógio que acorda quando há o que fazer ───────── */
103
104async function arm($: Dollar, justChanged = true): Promise<void> {
105  timer?.cancel()
106  const r = await rules($)
107  const now = await $.clock.now()
108  const p = (await $.state.get(PET)).value ?? seed(0)
109  const due = nextDue(p, r)
110  if (!Number.isFinite(due)) return
111  // Vencido e nada mudou na última volta: espera um pouco em vez de rodar sem parar.
112  const delay = due <= now ? (justChanged ? 40 : 1000) : Math.min(Math.max(due - now, 40), 60_000)
113  timer = $.clock.after(delay, () => void tick($))
114}
115
116async function tick($: Dollar): Promise<void> {
117  try {
118    const r = await rules($)
119    const now = await $.clock.now()
120    const { changed } = await change($, (p) => step(p, now, r))
121    await arm($, changed)
122  } catch (_) {
123    timer = $.clock.after(5000, () => void tick($))
124  }
125}
126
127async function run($: Dollar, fn: (p: Hollow, now: number, r: Rules) => Hollow): Promise<Hollow> {
128  const r = await rules($)
129  const now = await $.clock.now()
130  const { state } = await change($, (p) => fn(p, now, r))
131  await arm($)
132  return state
133}
134
135/* ───────────────────────── os ganchos ───────────────────────── */
136
137export const register: Register = (on, options) => {
138  cfg = {
139    options,
140    name: text(options.name, 'Hollowzinho').slice(0, 24),
141    tint: oneOf<Tint>(options.tint, ['cero', 'reishi', 'ouro'], 'cero'),
142    display: oneOf(options.display, ['band', 'status', 'off'] as const, 'band'),
143  }
144  live = true
145  rulesCache = undefined
146  timer = undefined
147  saver = undefined
148
149  /* ───────── a sessão ───────── */
150
151  on('session.start', async ($, e, next) => {
152    live = e.isInteractive
153    if (live) {
154      const r = await rules($)
155      const now = await $.clock.now()
156      const saved = restore(await $.store.get(SAVED), now)
157      await $.command.register({ name: 'hollowzinho', description: WORDS[r.lang].ui.help, argumentHint: '[pet|feed|cero|sleep|wake|hide|show|status|reset|pane]' })
158      await change($, (p) => begin(p, now, saved, r))
159      await arm($)
160    }
161    return next(e)
162  })
163
164  on('session.end', async ($, e, next) => {
165    timer?.cancel()
166    saver?.cancel()
167    if (live) await persist($).catch(() => {})
168    return next(e)
169  })
170
171  /* ───────── o que o Claude faz ───────── */
172
173  on('prompt.submit', async ($, e, next) => {
174    if (live) await run($, (p, now, r) => onPrompt(p, now, e.text, r)).catch(() => {})
175    return next(e)
176  })
177
178  on('turn.start', async ($, e, next) => {
179    if (live) await run($, (p, now, r) => onTurnStart(p, now, r)).catch(() => {})
180    return next(e)
181  })
182
183  on('turn.complete', async ($, e, next) => {
184    const out = await next(e)
185    if (live && e.agentId === undefined) await run($, (p, now, r) => onTurnEnd(p, now, e.reason, r)).catch(() => {})
186    return out
187  })
188
189  // Só observa: o resultado da ferramenta volta como veio, e qualquer tropeço aqui é engolido.
190  on('tool.call', async ($, e, next) => {
191    const ran = await next(e)
192    if (!live || ran.deny !== undefined) return ran
193    try {
194      const info: ToolInfo = { tool: String(e.tool), ok: ran.isError !== true }
195      if (e.tool === 'Bash') {
196        info.command = e.command
197        const bash = (ran.result ?? {}) as { stdout?: unknown; stderr?: unknown; gitOperation?: { commit?: unknown; push?: unknown; pr?: { action?: string } } }
198        if (ran.isError !== true) info.output = `${typeof bash.stdout === 'string' ? bash.stdout : ''}\n${typeof bash.stderr === 'string' ? bash.stderr : ''}`.slice(-OUTPUT_TAIL)
199        const git = bash.gitOperation
200        const newBranch = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\b/.test(e.command)
201        info.git = { commit: !!git?.commit, push: !!git?.push, pr: git?.pr?.action === 'created' || git?.pr?.action === 'merged', branch: newBranch }
202      }
203      await run($, (p, now, r) => onTool(p, now, info, r))
204    } catch (_) {
205      // o bicho tropeçar não pode derrubar a ferramenta
206    }
207    return ran
208  })
209
210  /* ───────── /hollowzinho ───────── */
211
212  on('command.run', { command: 'hollowzinho' }, async ($, e) => {
213    const r = await rules($)
214    const w = WORDS[r.lang].ui
215    const arg = e.args.trim().toLowerCase()
216    const what = WORDS_OF[arg]
217    if (what === undefined) return { text: w.unknown(arg) }
218    if (what === 'help') return { text: w.help }
219    if (what === 'pane') {
220      const opened = await $.ui.open({ id: PANE, title: cfg.name })
221      return { text: opened.isPlaced ? w.paneOpened : w.paneNarrow }
222    }
223    if (what === 'status') return { text: statusLine((await $.state.get(PET)).value ?? seed(0), cfg.name, r.lang) }
224    const after = await run($, (p, now, rr) => act(p, now, what, rr))
225    return { text: `${cfg.name}: ${after.say !== '' ? after.say : statusLine(after, cfg.name, r.lang)}` }
226  })
227
228  /* ───────── o que aparece na tela ───────── */
229
230  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231    if (cfg.display !== 'band' || e.props.hasSurvey) return next(e)
232    const p = (await $.state.get(PET)).value
233    if (!p || p.hidden) return next(e)
234    const look = lookOf(await rules($))
235    if (e.surface === 'terminal') {
236      const t = $.ui.resolve(e)
237      const width = e.props.bodyColumns
238      return width < 40 ? terminalLine(t, p, look) : terminalBand(t, p, look, width)
239    }
240    if (e.surface === 'desktop') return remoteBand($.ui.resolve(e), p, look)
241    return next(e)
242  })
243
244  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245    const p = (await $.state.get(PET)).value ?? seed(0)
246    const look = lookOf(await rules($))
247    const handlers = { act: (action: Action) => void run($, (q, now, r) => act(q, now, action, r)).catch(() => {}) }
248    if (e.surface === 'terminal') return terminalPane($.ui.resolve(e), p, look, e.props.bodyColumns, handlers)
249    return remotePane($.ui.resolve(e), p, look, handlers)
250  })
251}
252
hooks/pet.ts 419 lines
1// A vida do Hollowzinho no Claude Code, sem nenhuma dependência do motor: funções puras que recebem o estado, o
2// instante e as regras e devolvem o estado novo (ou o mesmo objeto, quando nada mudou).
3// Quem liga isso ao Claude Code é o register.tsx; quem desenha é o view.tsx.
4
5import type { Hollow, HollowBug, HollowFace, HollowSaved } from '../types'
6import { WORDS, choose, pickLang, type Lang } from './text'
7
8export type Chatter = 'off' | 'low' | 'normal'
9
10export type Rules = {
11  lang: Lang
12  chatter: Chatter
13  /** Parado por este tempo, ele dorme. 0 nunca dorme. */
14  sleepAfterMs: number
15  /** Um número em [0, 1): trocar por um fixo deixa os testes previsíveis. */
16  roll: () => number
17}
18
19export const MAX_BUGS = 8
20/** Até quantos bugs a cena desenha (os outros só contam). */
21export const MAX_VISIBLE_BUGS = 6
22export const CERO_STEP_MS = 90
23export const CERO_FRAMES = 9
24/** O quadro em que o raio acerta o alvo. */
25export const CERO_HIT = 4
26export const CERO_COST = 12
27export const BOSS_FAILS = 3
28const SAY_MS = 3600
29const BLINK_MS = 170
30const DECAY_EVERY_MS = 30_000
31const CHEER_GAP_MS = 4_000
32
33/** Lê as opções do plugin (`userConfig`) sem confiar nos tipos que chegam. */
34export function rulesOf(options: Readonly<Record<string, unknown>>, env: string | undefined, roll: () => number = Math.random): Rules {
35  const chatter = options.chatter === 'off' || options.chatter === 'normal' ? options.chatter : 'low'
36  const minutes = typeof options.sleepAfterMinutes === 'number' && options.sleepAfterMinutes >= 0 ? options.sleepAfterMinutes : 5
37  return { lang: pickLang(options.language, env), chatter, sleepAfterMs: Math.round(minutes * 60_000), roll }
38}
39
40const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
41
42export const stageOf = (bond: number): 1 | 2 | 3 => (bond >= 40 ? 3 : bond >= 15 ? 2 : 1)
43export const stageName = (bond: number, lang: Lang): string => WORDS[lang].stages[stageOf(bond) - 1] ?? ''
44
45/** O bicho recém-nascido. Com `now` = 0 é o valor inicial do estado, antes de o motor dizer que horas são. */
46export function seed(now: number): Hollow {
47  return {
48    v: 1,
49    reiatsu: 80,
50    bond: 0,
51    hunted: 0,
52    mood: 'calm',
53    moodUntil: 0,
54    pose: '',
55    poseUntil: 0,
56    say: '',
57    sayUntil: 0,
58    bugs: [],
59    cero: null,
60    hidden: false,
61    working: false,
62    blink: false,
63    lastActive: now,
64    lastTick: now,
65    lastChat: now,
66    lastCheer: 0,
67    blinkAt: now + 4000,
68    edits: 0,
69    greeted: false,
70  }
71}
72
73/* ───────────────────────── guardar e restaurar ───────────────────────── */
74
75export function save(p: Hollow, now: number): HollowSaved {
76  return { v: 1, reiatsu: Math.round(p.reiatsu), bond: p.bond, hunted: p.hunted, hidden: p.hidden, seen: p.greeted, t: now }
77}
78
79/** Valida o que veio do disco: formato desconhecido vira `null`, e o reiatsu cai pelo tempo que o bicho ficou sozinho. */
80export function restore(raw: unknown, now: number): HollowSaved | null {
81  if (!raw || typeof raw !== 'object') return null
82  const r = raw as Record<string, unknown>
83  if (r.v !== 1) return null
84  const num = (v: unknown, d: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
85  const t = num(r.t, now)
86  const away = Math.max(0, (now - t) / 60_000)
87  return {
88    v: 1,
89    reiatsu: clamp(num(r.reiatsu, 80) - Math.min(30, away / 8), 5, 100),
90    bond: Math.max(0, Math.floor(num(r.bond, 0))),
91    hunted: Math.max(0, Math.floor(num(r.hunted, 0))),
92    hidden: r.hidden === true,
93    seen: r.seen === true,
94    t,
95  }
96}
97
98/* ───────────────────────── pequenos efeitos ───────────────────────── */
99
100const speak = (p: Hollow, now: number, text: string, ms = SAY_MS): Hollow => ({ ...p, say: text, sayUntil: now + ms })
101const feel = (p: Hollow, now: number, mood: Hollow['mood'], ms: number): Hollow => ({ ...p, mood, moodUntil: now + ms })
102const wakeUp = (p: Hollow, now: number): Hollow => ({ ...p, pose: p.pose === 'sleep' ? '' : p.pose, lastActive: now })
103const eat = (p: Hollow, amount: number): Hollow => ({ ...p, reiatsu: clamp(p.reiatsu + amount, 0, 100) })
104
105/** Soma vínculo e anuncia a evolução quando passa de estágio. */
106function grow(p: Hollow, now: number, by: number, rules: Rules): Hollow {
107  const before = stageOf(p.bond)
108  const q = { ...p, bond: p.bond + by }
109  const after = stageOf(q.bond)
110  if (after > before) return feel(speak(q, now, WORDS[rules.lang].levelup[after === 3 ? 3 : 2], 4200), now, 'happy', 3000)
111  return q
112}
113
114const pick = (list: readonly string[], rules: Rules): string => choose(list, rules.roll())
115
116/** Comemora sem repetir: no máximo uma vez a cada poucos segundos. */
117function cheer(p: Hollow, now: number, text: string, rules: Rules): Hollow {
118  if (now - p.lastCheer < CHEER_GAP_MS) return p
119  return { ...feel(speak(p, now, text), now, 'happy', 2200), lastCheer: now }
120}
121
122/* ───────────────────────── comandos de build e teste ───────────────────────── */
123
124// Só o que parece build, teste ou verificação vira bug: um `grep` sem resultado também sai com erro e não é problema.
125const BUILD_RE =
126  /\b(test|tests|jest|vitest|mocha|pytest|tox|rspec|phpunit|build|compile|tsc|webpack|rollup|esbuild|make|cmake|gradle|gradlew|mvn|cargo|go\s+(?:test|build|vet)|dotnet\s+(?:test|build)|lint|eslint|ruff|mypy|ctest|bazel|typecheck|type-check)\b/i
127const NOT_BUILD = new Set(['git', 'gh', 'echo', 'cat', 'grep', 'rg', 'ls', 'cd', 'sed', 'awk', 'head', 'tail', 'curl', 'wget', 'mkdir', 'rm', 'cp', 'mv', 'touch', 'which', 'find', 'printf', 'pwd', 'export', 'source', 'sleep', 'kill', 'tmux', 'ps', 'open', 'code', 'less', 'tree', 'diff'])
128const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'cargo', 'go', 'dotnet', 'make', 'gradle', 'gradlew', 'mvn', 'poetry', 'uv', 'python', 'python3', 'node', 'deno', 'rake', 'bundle', 'composer', 'php', 'swift', 'flutter', 'dart', 'mix', 'sbt', 'tox', 'nox', 'ctest', 'bazel'])
129
130/** Tira o que está entre aspas, para que `git commit -m "add test"` não pareça um teste. */
131const unquote = (s: string): string => s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'|`[^`]*`/g, '""')
132
133/** Primeiro pedaço do comando que parece build ou teste, sem prefixos de ambiente. `null` se nenhum parece. */
134export function buildPart(command: string): string | null {
135  const bare = unquote(command)
136  for (const raw of bare.split(/&&|\|\||;|\||\n/)) {
137    const seg = raw
138      .trim()
139      .replace(/^(?:cd\s+\S+\s*)+/, '')
140      .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')
141      .replace(/^(?:time|nice|sudo|exec)\s+/, '')
142      .trim()
143    if (!seg) continue
144    const first = (seg.split(/\s+/)[0] ?? '').replace(/^.*\//, '')
145    if (NOT_BUILD.has(first)) continue
146    if (BUILD_RE.test(seg)) return seg
147  }
148  return null
149}
150
151/** A "família" do comando: `npm test -- a.test.ts` e `npm test` são o mesmo bug. */
152export function family(segment: string): string {
153  const words = segment.split(/\s+/).filter(Boolean)
154  const first = (words[0] ?? '').replace(/^.*\//, '')
155  const second = words[1] ?? ''
156  const third = words[2] ?? ''
157  if (!RUNNERS.has(first) || !/^[a-z][\w:.-]*$/i.test(second)) return first.slice(0, 40)
158  if (/^(?:npm|pnpm|yarn|bun)$/.test(first) && second === 'run' && /^[a-z][\w:.-]*$/i.test(third)) return `${first} run ${third}`.slice(0, 40)
159  return `${first} ${second}`.slice(0, 40)
160}
161
162/* ───────────────────────── o código de saída escondido ───────────────────────── */
163
164// O modelo costuma escrever `npm test 2>&1 | tail -30`: o `tail` sai com 0 e o teste que falhou passa como se nada tivesse
165// acontecido. Quando o comando esconde o código de saída (um pipe, um `|| true`), a saída é a única pista.
166const FAIL_MARKS: readonly RegExp[] = [
167  /^\s*(?:---\s+)?(?:FAIL|FAILED|FAILURES|ERROR)\b/m,
168  /^\s*not ok\b|^# fail [1-9]/m,
169  /\b[1-9]\d*\s+(?:failed|failing|failures?|errors?)\b/i,
170  /\bnpm ERR!|\berror\s+TS\d+\s*:|\berror\[E\d+\]\s*:|\berror:\s+(?:could not compile|aborting)/i,
171  /^Traceback \(most recent call last\)|\bAssertionError\b|\bpanicked at\b|\bBUILD (?:FAILURE|FAILED)\b|^make: \*\*\*/m,
172  /[✗✖]/,
173]
174
175/** O comando esconde o código de saída do que roda (um pipe, `|| true`)? */
176export function masksExit(command: string): boolean {
177  const bare = unquote(command)
178  if (/\|\|\s*(?:true|:|exit\s+0)\b/.test(bare)) return true
179  return bare.replace(/\|\|/g, ' ').includes('|')
180}
181
182/** A saída de um build ou teste diz que ele falhou? */
183export function readsFailed(output: string): boolean {
184  return FAIL_MARKS.some((re) => re.test(output))
185}
186
187/* ───────────────────────── o que o Claude Code conta ───────────────────────── */
188
189export type ToolInfo = {
190  tool: string
191  /** A ferramenta terminou sem erro. */
192  ok: boolean
193  /** O comando do Bash, quando é ele. */
194  command?: string
195  /** O final do que o Bash escreveu (stdout e stderr). Só serve quando o comando esconde o código de saída. */
196  output?: string
197  /** O que o Bash diz ter feito com o git (`gitOperation` do resultado), mais a criação de branch, que ele não anuncia. */
198  git?: { commit?: boolean; push?: boolean; pr?: boolean; branch?: boolean }
199}
200
201const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
202
203/** Um comando de build ou teste terminou. */
204function onBuild(p: Hollow, now: number, segment: string, ok: boolean, rules: Rules): Hollow {
205  const w = WORDS[rules.lang]
206  const id = family(segment)
207  const known = p.bugs.find((b) => b.id === id)
208  if (ok) {
209    if (known) {
210      const done = finishCero(p, now, rules)
211      const slot = done.bugs.findIndex((b) => b.id === id)
212      return { ...done, cero: { frame: 0, nextAt: now + CERO_STEP_MS, target: id, slot: Math.max(0, slot), kill: true }, mood: 'calm', moodUntil: 0 }
213    }
214    return cheer(eat(p, 0.4), now, pick(w.passed, rules), rules)
215  }
216  if (known) {
217    const fails = known.fails + 1
218    const bugs = p.bugs.map((b) => (b.id === id ? { ...b, fails, hit: false } : b))
219    const line = fails === BOSS_FAILS ? w.bugBoss(known.label) : w.bugAgain(known.label, fails)
220    return feel(speak({ ...p, bugs }, now, line, 4200), now, 'worried', 3200)
221  }
222  const bug: HollowBug = { id, label: id, fails: 1 }
223  const bugs = [...p.bugs, bug].slice(-MAX_BUGS)
224  return feel(speak({ ...p, bugs }, now, w.bugNew(id), 4200), now, 'worried', 3200)
225}
226
227/** Uma ferramenta terminou. */
228export function onTool(p: Hollow, now: number, info: ToolInfo, rules: Rules): Hollow {
229  const w = WORDS[rules.lang]
230  let q = wakeUp(p, now)
231  const part = info.tool === 'Bash' && info.command ? buildPart(info.command) : null
232  if (part !== null) q = onBuild(q, now, part, info.ok && !(masksExit(info.command ?? '') && readsFailed(info.output ?? '')), rules)
233  else if (!info.ok) {
234    q = feel(q, now, 'worried', 1800)
235    if (rules.chatter !== 'off' && now - q.lastChat > 8_000) q = { ...speak(q, now, pick(w.toolFail, rules), 2400), lastChat: now }
236  }
237  if (!info.ok) return q
238  if (info.git?.commit) q = grow(feel(speak(eat(q, 5), now, pick(w.commit, rules)), now, 'happy', 2200), now, 1, rules)
239  if (info.git?.push) q = grow(cheer({ ...q, lastCheer: 0 }, now, pick(w.push, rules), rules), now, 1, rules)
240  if (info.git?.pr) q = grow(cheer({ ...q, lastCheer: 0 }, now, pick(w.pr, rules), rules), now, 2, rules)
241  if (info.git?.branch) q = feel(speak(q, now, pick(w.branch, rules)), now, 'happy', 2200)
242  if (EDIT_TOOLS.has(info.tool)) {
243    q = { ...eat(q, 0.35), edits: q.edits + 1 }
244    if (rules.chatter === 'normal' && q.edits % 8 === 0 && now - q.lastChat > 25_000 && q.say === '') q = { ...speak(q, now, pick(w.edit, rules), 2600), lastChat: now }
245  } else if (part === null && !info.git) q = eat(q, 0.05)
246  return q
247}
248
249/** O usuário mandou um prompt: o bicho acorda e come um pouco do texto. */
250export function onPrompt(p: Hollow, now: number, text: string, rules: Rules): Hollow {
251  const was = p.pose === 'sleep'
252  let q = eat(wakeUp(p, now), Math.min(1.5, 0.3 + text.length / 160))
253  if (was) return feel(speak(q, now, pick(WORDS[rules.lang].back, rules)), now, 'happy', 1800)
254  if (rules.chatter === 'normal' && now - q.lastChat > 20_000 && q.say === '') q = { ...speak(q, now, pick(WORDS[rules.lang].prompt, rules), 2200), lastChat: now }
255  return q
256}
257
258export function onTurnStart(p: Hollow, now: number, rules: Rules): Hollow {
259  let q = { ...wakeUp(p, now), working: true }
260  if (rules.chatter === 'normal' && now - q.lastChat > 25_000 && q.say === '') q = { ...speak(q, now, pick(WORDS[rules.lang].hunt, rules), 2400), lastChat: now }
261  return q
262}
263
264export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
265
266export function onTurnEnd(p: Hollow, now: number, reason: TurnEnd, rules: Rules): Hollow {
267  const w = WORDS[rules.lang]
268  const q = { ...p, working: false, lastActive: now }
269  if (reason === 'aborted') return { ...speak(q, now, pick(w.aborted, rules), 2400), pose: 'alert', poseUntil: now + 1400, edits: 0 }
270  if (reason !== 'answer') return feel(speak({ ...q, edits: 0 }, now, pick(w.failed, rules), 3200), now, 'sad', 3200)
271  const fed = eat(q, 1.5)
272  if (fed.bugs.length > 0) return rules.chatter === 'off' ? { ...fed, edits: 0 } : feel(speak({ ...fed, edits: 0 }, now, w.turnBugs(fed.bugs.length), 3600), now, 'worried', 2400)
273  if (fed.edits > 0 && rules.chatter !== 'off') return { ...cheer(fed, now, pick(w.turnDone, rules), rules), edits: 0 }
274  return { ...fed, edits: 0 }
275}
276
277/** Começa a sessão: aplica o que estava guardado e cumprimenta. */
278export function begin(p: Hollow, now: number, saved: HollowSaved | null, rules: Rules): Hollow {
279  const w = WORDS[rules.lang]
280  let q: Hollow = { ...seed(now), greeted: true }
281  let line = pick(w.hello, rules)
282  if (saved) {
283    q = { ...q, reiatsu: saved.reiatsu, bond: saved.bond, hunted: saved.hunted, hidden: saved.hidden }
284    line = now - saved.t > 10 * 60_000 ? pick(w.back, rules) : ''
285  }
286  q = { ...q, bugs: p.bugs }
287  if (!saved || rules.chatter !== 'off') q = line ? { ...speak(q, now, line, 4200), mood: 'happy', moodUntil: now + 2400 } : q
288  return q
289}
290
291/* ───────────────────────── ações do usuário ───────────────────────── */
292
293export type Action = 'pet' | 'feed' | 'cero' | 'sleep' | 'wake' | 'hide' | 'show' | 'reset'
294
295export function act(p: Hollow, now: number, action: Action, rules: Rules): Hollow {
296  const w = WORDS[rules.lang]
297  switch (action) {
298    case 'pet':
299      return grow(feel(speak(eat(wakeUp(p, now), 1), now, pick(w.pet, rules)), now, 'happy', 1800), now, 1, rules)
300    case 'feed': {
301      const awake = wakeUp(p, now)
302      if (awake.reiatsu >= 98) return feel(speak(awake, now, w.full), now, 'happy', 1500)
303      return grow(feel(speak(eat(awake, 26), now, pick(w.feed, rules)), now, 'happy', 1800), now, 2, rules)
304    }
305    case 'cero': {
306      const awake = wakeUp(p, now)
307      if (awake.reiatsu < CERO_COST) return feel(speak(awake, now, w.weak), now, 'sad', 1800)
308      const done = finishCero(awake, now, rules)
309      const target = done.bugs[0]?.id
310      return grow({ ...eat(done, -CERO_COST), cero: { frame: 0, nextAt: now + CERO_STEP_MS, target, slot: target === undefined ? undefined : 0, kill: false } }, now, 1, rules)
311    }
312    case 'sleep':
313      return { ...speak(p, now, w.sleep, 2200), pose: 'sleep', poseUntil: 0, mood: 'calm', moodUntil: 0 }
314    case 'wake':
315      return feel(speak(wakeUp(p, now), now, pick(w.wake, rules)), now, 'happy', 1500)
316    case 'hide':
317      return { ...p, hidden: true }
318    case 'show':
319      return feel(speak({ ...wakeUp(p, now), hidden: false }, now, w.shown), now, 'happy', 1500)
320    case 'reset':
321      return speak({ ...seed(now), greeted: true, hunted: 0, bugs: p.bugs }, now, w.reset)
322  }
323}
324
325/* ───────────────────────── o tempo passa ───────────────────────── */
326
327/** Termina na hora o Cero que está no ar (o abate e as contas valem como se ele tivesse chegado). */
328function finishCero(p: Hollow, now: number, rules: Rules): Hollow {
329  if (!p.cero) return p
330  let q = p
331  while (q.cero) q = advanceCero(q, now, rules)
332  return q
333}
334
335/** O Cero dá um passo; no quadro do acerto, abate (ou só raspa) o alvo. */
336function advanceCero(p: Hollow, now: number, rules: Rules): Hollow {
337  const cero = p.cero
338  if (!cero) return p
339  const w = WORDS[rules.lang]
340  const frame = cero.frame + 1
341  let q: Hollow = { ...p, cero: { ...cero, frame, nextAt: now + CERO_STEP_MS } }
342  if (frame === CERO_HIT && cero.target) {
343    const bug = q.bugs.find((b) => b.id === cero.target)
344    if (bug && cero.kill) {
345      const boss = bug.fails >= BOSS_FAILS
346      const left = q.bugs.filter((b) => b.id !== bug.id)
347      const line = left.length === 0 ? w.bugsClear : pick(boss ? w.bossKill : w.bugKill, rules)
348      q = grow(feel(speak({ ...eat(q, 4), bugs: left, hunted: q.hunted + (boss ? 2 : 1) }, now, line, 4200), now, 'happy', 2600), now, boss ? 2 : 1, rules)
349    } else if (bug) {
350      q = speak({ ...q, bugs: q.bugs.map((b) => (b.id === bug.id ? { ...b, hit: true } : b)) }, now, pick(w.bugHit, rules), 3800)
351    }
352  } else if (frame === CERO_HIT) {
353    q = speak(q, now, pick(w.cero, rules), 1600)
354  }
355  if (frame >= CERO_FRAMES - 1) q = { ...q, cero: null, bugs: q.bugs.some((b) => b.hit) ? q.bugs.map((b) => ({ ...b, hit: false })) : q.bugs }
356  return q
357}
358
359const chatGap = (rules: Rules): number => (rules.chatter === 'normal' ? 3.5 * 60_000 : 7 * 60_000)
360
361/** Faz o que já venceu: balão e humor que acabam, piscar, Cero, sono, fome e fala sozinho. */
362export function step(p: Hollow, now: number, rules: Rules): Hollow {
363  let q = p
364  if (q.cero && now >= q.cero.nextAt) q = advanceCero(q, now, rules)
365  if (q.say !== '' && now >= q.sayUntil) q = { ...q, say: '' }
366  if (q.mood !== 'calm' && now >= q.moodUntil) q = { ...q, mood: 'calm' }
367  if (q.pose === 'alert' && now >= q.poseUntil) q = { ...q, pose: '' }
368  const dozing = q.pose === 'sleep'
369  if (now >= q.blinkAt) {
370    const closing = !q.blink && !dozing && !q.cero
371    q = { ...q, blink: closing, blinkAt: now + (closing ? BLINK_MS : 3500 + Math.floor(rules.roll() * 3000)) }
372  }
373  if (now - q.lastTick >= DECAY_EVERY_MS) {
374    const minutes = (now - q.lastTick) / 60_000
375    q = { ...q, reiatsu: clamp(q.reiatsu - minutes * (dozing ? 0.05 : 0.25), 0, 100), lastTick: now }
376  }
377  if (!dozing && !q.working && !q.cero && rules.sleepAfterMs > 0 && now - q.lastActive >= rules.sleepAfterMs) {
378    q = { ...speak(q, now, WORDS[rules.lang].sleep, 2600), pose: 'sleep', poseUntil: 0, mood: 'calm', moodUntil: 0, blink: false }
379  } else if (!dozing && !q.working && !q.cero && !q.hidden && rules.chatter !== 'off' && q.say === '' && now - q.lastChat >= chatGap(rules) && now - q.lastActive >= 60_000) {
380    const w = WORDS[rules.lang]
381    const line = q.reiatsu < 30 ? pick(w.hungry, rules) : pick(w.idle, rules)
382    q = { ...speak(q, now, line, 4200), lastChat: now }
383  }
384  return q
385}
386
387/** Quando `step` volta a ter o que fazer (ms desde 1970). `Infinity` se nada está marcado. */
388export function nextDue(p: Hollow, rules: Rules): number {
389  if (p.cero) return p.cero.nextAt
390  const due: number[] = [p.lastTick + DECAY_EVERY_MS]
391  if (p.say !== '') due.push(p.sayUntil)
392  if (p.mood !== 'calm') due.push(p.moodUntil)
393  if (p.pose === 'alert') due.push(p.poseUntil)
394  if (p.pose !== 'sleep') due.push(p.blinkAt)
395  if (p.pose !== 'sleep' && !p.working && rules.sleepAfterMs > 0) due.push(p.lastActive + rules.sleepAfterMs)
396  if (p.pose !== 'sleep' && !p.working && !p.hidden && rules.chatter !== 'off') due.push(Math.max(p.lastChat + chatGap(rules), p.lastActive + 60_000))
397  return Math.min(...due)
398}
399
400/* ───────────────────────── o rosto e as contas para a tela ───────────────────────── */
401
402export function faceOf(p: Hollow): HollowFace {
403  if (p.cero) return 'fire'
404  if (p.pose === 'alert') return 'alert'
405  if (p.pose === 'sleep') return 'sleep'
406  if (p.mood === 'worried') return 'worried'
407  if (p.mood === 'sad') return 'sad'
408  if (p.mood === 'happy') return 'happy'
409  if (p.working) return 'scan'
410  return p.blink ? 'blink' : 'idle'
411}
412
413export const isBoss = (b: HollowBug): boolean => b.fails >= BOSS_FAILS
414
415/** A linha de estado para o `/hollowzinho status` e para o status do Claude Code. */
416export function statusLine(p: Hollow, name: string, lang: Lang): string {
417  return WORDS[lang].ui.status(name, stageName(p.bond, lang), Math.round(p.reiatsu), p.bond, p.bugs.length, p.hunted)
418}
419
hooks/sprite.ts 358 lines
1// A arte do terminal: o Hollowzinho em pixel art, desenhado em tempo de execução e empacotado em meias-células.
2// Cada célula do terminal mostra dois pixels, um sobre o outro (▀ com a cor da frente no de cima e a de fundo no de baixo),
3// então 12×10 pixels ocupam 12 colunas por 5 linhas. O fundo é sempre o do terminal, para servir a qualquer tema.
4// Sem imports: dá para rodar este arquivo direto no Node para espiar os quadros (veja scripts/claude-plugin-preview.mjs).
5
6import type { HollowFace } from '../types'
7
8export type Tint = 'cero' | 'reishi' | 'ouro'
9export type Grid = readonly string[]
10
11/** Sem cor: o terminal escolhe (0x01000000, o bit 24 sozinho). */
12export const DEFAULT = 0x01000000
13const CLEAR = -1
14
15// As cores são da paleta de 256 do xterm (os níveis 0, 95, 135, 175, 215 e 255 e a rampa de cinzas): o Claude Code leva
16// cada célula do Raster à cor mais próxima dessa paleta, e com estas o desenho sai igual em qualquer terminal.
17const BASE: Readonly<Record<string, number>> = {
18  W: 0xffffd7, // osso
19  w: 0xd7d7af, // sombra do osso
20  d: 0x87875f, // contorno
21  K: 0x080808, // preto das órbitas e da boca
22  B: 0x1c1c1c, // corpo
23  b: 0x3a3a3a, // braços
24  C: 0x5f5f87, // borda clara do corpo
25  G: 0xffaf00, // íris
26  O: 0xd78700, // íris, sombra
27  H: 0xffffff, // brilho
28  S: 0x87d7ff, // suor e lágrima
29  P: 0xff87af, // bochecha
30  Z: 0xd7d7ff, // o Z do sono
31  A: 0xffd75f, // bug dourado
32  Y: 0xffffaf, // bug dourado, brilho
33  E: 0xffafaf, // clarão do Cero
34  L: 0xffffff, // miolo do raio
35}
36
37const TINTS: Readonly<Record<Tint, { R: number; r: number; D: number }>> = {
38  cero: { R: 0xd7005f, r: 0xff5f5f, D: 0xaf0000 },
39  reishi: { R: 0x00af5f, r: 0x5fff87, D: 0x00875f },
40  ouro: { R: 0xaf8700, r: 0xffd75f, D: 0x875f00 },
41}
42
43export function palette(tint: Tint): Readonly<Record<string, number>> {
44  return { ...BASE, ...(TINTS[tint] ?? TINTS.cero) }
45}
46
47/* ───────────────────────── tela de pixels ───────────────────────── */
48
49export class Canvas {
50  readonly width: number
51  readonly height: number
52  readonly px: Int32Array
53
54  constructor(width: number, height: number) {
55    this.width = width
56    this.height = height
57    this.px = new Int32Array(width * height).fill(CLEAR)
58  }
59
60  set(x: number, y: number, color: number): void {
61    if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.px[y * this.width + x] = color
62  }
63
64  get(x: number, y: number): number {
65    return x >= 0 && y >= 0 && x < this.width && y < this.height ? (this.px[y * this.width + x] ?? CLEAR) : CLEAR
66  }
67
68  rect(x0: number, y0: number, x1: number, y1: number, color: number): void {
69    for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) this.set(x, y, color)
70  }
71
72  /** Cola uma grade de letras na posição dada; `.` e espaço são transparentes. */
73  blit(grid: Grid, x: number, y: number, pal: Readonly<Record<string, number>>): void {
74    grid.forEach((row, dy) => {
75      for (let dx = 0; dx < row.length; dx++) {
76        const c = pal[row[dx] ?? '.']
77        if (c !== undefined) this.set(x + dx, y + dy, c)
78      }
79    })
80  }
81}
82
83/* ───────────────────────── montar grades por camadas ───────────────────────── */
84
85type Draft = string[][]
86const draft = (w: number, h: number): Draft => Array.from({ length: h }, () => Array<string>(w).fill('.'))
87const put = (g: Draft, x: number, y: number, c: string): void => {
88  const row = g[y]
89  if (row && x >= 0 && x < row.length) row[x] = c
90}
91const fill = (g: Draft, x0: number, y0: number, x1: number, y1: number, c: string): void => {
92  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) put(g, x, y, c)
93}
94const rows = (g: Draft): Grid => g.map((r) => r.join(''))
95
96/** Contorna uma silhueta: o que encosta no vazio vira `d`. */
97function outline(g: Draft): Draft {
98  const h = g.length
99  const w = g[0]?.length ?? 0
100  const solid = (x: number, y: number): boolean => (g[y]?.[x] ?? '.') !== '.'
101  const out = draft(w, h)
102  for (let y = 0; y < h; y++)
103    for (let x = 0; x < w; x++) {
104      if (!solid(x, y)) continue
105      const edge = !solid(x + 1, y) || !solid(x - 1, y) || !solid(x, y + 1) || !solid(x, y - 1)
106      put(out, x, y, edge ? 'd' : (g[y]?.[x] ?? 'W'))
107    }
108  return out
109}
110
111/* ───────────────────────── o rosto da faixa: 12×10 ───────────────────────── */
112
113export const AVATAR_W = 12
114export const AVATAR_H = 10
115
116function avatarBase(stage: 1 | 2 | 3): Draft {
117  const g = draft(AVATAR_W, AVATAR_H)
118  const head: ReadonlyArray<readonly [number, number, number]> = [
119    [1, 3, 8], [2, 1, 10], [3, 0, 11], [4, 0, 11], [5, 0, 11], [6, 0, 11], [7, 0, 11], [8, 1, 10], [9, 3, 8],
120  ]
121  for (const [y, a, b] of head) fill(g, a, y, b, y, 'W')
122  const o = outline(g)
123  put(o, 3, 0, 'd'); put(o, 4, 0, 'd'); put(o, 2, 1, 'd'); put(o, 3, 1, 'W'); put(o, 4, 1, 'd')
124  fill(o, 5, 1, 6, 4, 'R')
125  put(o, 1, 8, 'R'); put(o, 10, 8, 'R')
126  if (stage >= 2) { put(o, 8, 0, 'd'); put(o, 9, 0, 'd'); put(o, 9, 1, 'd'); put(o, 8, 1, 'W') }
127  if (stage >= 3) { put(o, 5, 0, 'd'); put(o, 6, 0, 'd'); put(o, 5, 1, 'R'); put(o, 6, 1, 'R') }
128  return o
129}
130
131const aEye = (g: Draft, x: number, top: number): void => {
132  fill(g, x, top, x + 2, top + 3, 'K'); put(g, x, top + 1, 'H'); put(g, x + 1, top + 1, 'G'); put(g, x + 1, top + 2, 'G'); put(g, x + 2, top + 2, 'O')
133}
134const aEyes = (g: Draft, top = 3): void => { aEye(g, 1, top); aEye(g, 8, top) }
135const aMouth = (g: Draft): void => { fill(g, 3, 8, 8, 8, 'K'); put(g, 4, 8, 'W'); put(g, 6, 8, 'W'); put(g, 8, 8, 'W') }
136const aClosed = (g: Draft): void => { fill(g, 1, 5, 3, 5, 'K'); fill(g, 8, 5, 10, 5, 'K') }
137
138const AVATAR_FACES: Readonly<Record<HollowFace, (g: Draft) => void>> = {
139  idle: (g) => { aEyes(g); aMouth(g) },
140  blink: (g) => { aClosed(g); aMouth(g) },
141  happy: (g) => {
142    for (const x of [1, 8]) { put(g, x + 1, 4, 'K'); put(g, x, 5, 'K'); put(g, x + 2, 5, 'K') }
143    put(g, 1, 6, 'P'); put(g, 10, 6, 'P'); aMouth(g)
144  },
145  worried: (g) => {
146    aEyes(g, 4); put(g, 1, 3, 'K'); put(g, 2, 2, 'K'); put(g, 10, 3, 'K'); put(g, 9, 2, 'K'); aMouth(g); put(g, 11, 1, 'S'); put(g, 11, 2, 'S')
147  },
148  sad: (g) => { aEyes(g, 4); fill(g, 1, 3, 3, 3, 'K'); fill(g, 8, 3, 10, 3, 'K'); aMouth(g); put(g, 0, 8, 'S'); put(g, 0, 9, 'S') },
149  sleep: (g) => {
150    aClosed(g); aMouth(g)
151    for (const [x, y] of [[9, 0], [10, 0], [11, 0], [10, 1], [9, 2], [10, 2], [11, 2]] as const) put(g, x, y, 'Z')
152  },
153  scan: (g) => {
154    aEyes(g)
155    for (let y = 2; y <= 8; y++)
156      for (let x = 7; x <= 11; x++) {
157        const edge = y === 2 || y === 8 || x === 7 || x === 11
158        const corner = (x === 7 || x === 11) && (y === 2 || y === 8)
159        if (edge && !corner) put(g, x, y, 'r')
160      }
161    aMouth(g)
162  },
163  alert: (g) => { aEyes(g, 2); aMouth(g); put(g, 11, 0, 'r'); put(g, 11, 1, 'r'); put(g, 11, 3, 'r') },
164  fire: (g) => { aEyes(g, 4); fill(g, 1, 3, 3, 3, 'K'); fill(g, 8, 3, 10, 3, 'K'); fill(g, 4, 7, 7, 8, 'K'); put(g, 5, 8, 'r'); put(g, 6, 8, 'r') },
165}
166
167export function avatar(face: HollowFace, stage: 1 | 2 | 3 = 1): Grid {
168  const g = avatarBase(stage)
169  AVATAR_FACES[face](g)
170  return rows(g)
171}
172
173/* ───────────────────────── a figura inteira: 16×16 ───────────────────────── */
174
175export const BODY_W = 16
176export const BODY_H = 16
177
178function bodyBase(stage: 1 | 2 | 3): Draft {
179  const g = draft(BODY_W, BODY_H)
180  const head: ReadonlyArray<readonly [number, number, number]> = [
181    [2, 5, 10], [3, 3, 12], [4, 2, 13], [5, 1, 14], [6, 1, 14], [7, 1, 14], [8, 1, 14], [9, 2, 13], [10, 3, 12], [11, 4, 11],
182  ]
183  for (const [y, a, b] of head) fill(g, a, y, b, y, 'W')
184  const o = outline(g)
185  for (let x = 5; x <= 10; x++) if (o[10]?.[x] === 'W') put(o, x, 10, 'w')
186  for (const [x, y, c] of [[4, 0, 'd'], [5, 0, 'd'], [3, 1, 'd'], [4, 1, 'W'], [5, 1, 'd'], [6, 1, 'd'], [3, 2, 'd'], [4, 2, 'W']] as const) put(o, x, y, c)
187  fill(o, 7, 3, 8, 6, 'R')
188  fill(o, 2, 9, 2, 10, 'R'); fill(o, 13, 9, 13, 10, 'R')
189  fill(o, 5, 12, 10, 15, 'B'); fill(o, 3, 12, 4, 14, 'b'); fill(o, 11, 12, 12, 14, 'b')
190  for (const [x, y] of [[3, 12], [12, 12], [5, 12], [10, 12]] as const) put(o, x, y, 'C')
191  for (const [x, y, c] of [[7, 13, 'R'], [8, 13, 'R'], [6, 14, 'R'], [9, 14, 'R'], [7, 14, 'K'], [8, 14, 'K'], [7, 15, 'R'], [8, 15, 'R'], [4, 15, 'W'], [11, 15, 'W']] as const) put(o, x, y, c)
192  if (stage >= 2) { put(o, 10, 1, 'd'); put(o, 11, 1, 'd'); put(o, 11, 2, 'd'); put(o, 10, 2, 'W') }
193  if (stage >= 3) { put(o, 7, 1, 'd'); put(o, 8, 1, 'd'); put(o, 7, 2, 'R'); put(o, 8, 2, 'R') }
194  return o
195}
196
197const fEye = (g: Draft, x: number, top: number): void => {
198  fill(g, x, top, x + 3, top + 3, 'K'); put(g, x + 1, top + 1, 'H'); put(g, x + 2, top + 1, 'G'); put(g, x + 1, top + 2, 'G'); put(g, x + 2, top + 2, 'O')
199}
200const fEyes = (g: Draft, top = 5): void => { fEye(g, 2, top); fEye(g, 10, top) }
201const fMouth = (g: Draft): void => { fill(g, 4, 10, 11, 10, 'K'); for (const x of [5, 7, 9]) put(g, x, 10, 'W'); put(g, 4, 9, 'd'); put(g, 11, 9, 'd') }
202const fClosed = (g: Draft): void => { fill(g, 2, 7, 5, 7, 'K'); fill(g, 10, 7, 13, 7, 'K') }
203
204const BODY_FACES: Readonly<Record<HollowFace, (g: Draft) => void>> = {
205  idle: (g) => { fEyes(g); fMouth(g) },
206  blink: (g) => { fClosed(g); fMouth(g) },
207  happy: (g) => {
208    for (const x of [2, 10]) { put(g, x + 1, 6, 'K'); put(g, x + 2, 6, 'K'); put(g, x, 7, 'K'); put(g, x + 3, 7, 'K') }
209    fill(g, 2, 8, 3, 8, 'P'); fill(g, 12, 8, 13, 8, 'P'); fMouth(g)
210  },
211  worried: (g) => {
212    fEyes(g, 6); put(g, 2, 5, 'K'); put(g, 3, 4, 'K'); put(g, 13, 5, 'K'); put(g, 12, 4, 'K'); fMouth(g); put(g, 6, 10, 'K'); put(g, 9, 10, 'K')
213    for (const y of [2, 3, 4]) put(g, 14, y, 'S')
214    put(g, 13, 3, 'S')
215  },
216  sad: (g) => { fEyes(g, 6); fill(g, 2, 5, 3, 5, 'K'); fill(g, 12, 5, 13, 5, 'K'); fMouth(g); put(g, 1, 9, 'S'); put(g, 1, 10, 'S') },
217  sleep: (g) => {
218    fClosed(g); fMouth(g)
219    for (const [x, y] of [[12, 0], [13, 0], [14, 0], [13, 1], [12, 2], [13, 2], [14, 2]] as const) put(g, x, y, 'Z')
220  },
221  scan: (g) => {
222    fEyes(g)
223    for (let y = 4; y <= 10; y++)
224      for (let x = 9; x <= 14; x++) {
225        const edge = y === 4 || y === 10 || x === 9 || x === 14
226        const corner = (x === 9 || x === 14) && (y === 4 || y === 10)
227        if (edge && !corner) put(g, x, y, 'r')
228      }
229    put(g, 15, 5, 'r'); fMouth(g)
230  },
231  alert: (g) => { fEyes(g, 4); fMouth(g); for (const y of [0, 1, 2, 4]) put(g, 15, y, 'r') },
232  fire: (g) => {
233    fEyes(g, 6); fill(g, 2, 5, 5, 5, 'K'); fill(g, 10, 5, 13, 5, 'K')
234    fill(g, 5, 9, 10, 10, 'K'); put(g, 7, 10, 'r'); put(g, 8, 10, 'r'); put(g, 6, 9, 'W'); put(g, 9, 9, 'W')
235  },
236}
237
238export function body(face: HollowFace, stage: 1 | 2 | 3 = 1): Grid {
239  const g = bodyBase(stage)
240  BODY_FACES[face](g)
241  return rows(g)
242}
243
244/* ───────────────────────── bugs, raio e explosão ───────────────────────── */
245
246export const BUG_W = 8
247export const BUG_H = 6
248
249/** O bug: um besouro vermelho; o chefe é dourado. `legs` alterna as perninhas para ele andar. */
250export function bug(boss: boolean, legs = false): Grid {
251  const g = draft(BUG_W, BUG_H)
252  const shell = boss ? 'A' : 'R'
253  const shine = boss ? 'Y' : 'r'
254  const dark = boss ? 'O' : 'K'
255  for (const [x, y] of [[1, 0], [6, 0], [2, 1], [5, 1]] as const) put(g, x, y, 'K')
256  fill(g, 2, 2, 5, 4, shell); put(g, 1, 3, shell); put(g, 6, 3, shell); put(g, 3, 2, shine); put(g, 4, 2, shine)
257  put(g, 3, 3, dark); put(g, 4, 3, dark); put(g, 2, 4, dark); put(g, 5, 4, dark)
258  for (const x of legs ? [0, 2, 5, 7] : [1, 3, 4, 6]) put(g, x, 5, 'K')
259  put(g, 0, 2, 'K'); put(g, 7, 2, 'K')
260  return rows(g)
261}
262
263/** O bug que levou um raspão pisca em branco. */
264export function bugHit(boss: boolean): Grid {
265  return bug(boss).map((r) => r.replace(/[RrAYO]/g, 'H'))
266}
267
268export const BOOM_SIZE = 7
269
270const BOOM: ReadonlyArray<Grid> = [
271  ['.......', '.......', '..HHH..', '..HHH..', '..HHH..', '.......', '.......'],
272  ['...H...', '.rHYHr.', '.HYAYH.', 'HYAHAYH', '.HYAYH.', '.rHYHr.', '...H...'],
273  ['..r.r..', '.r.E.r.', 'r.....r', '.E...E.', 'r.....r', '.r.E.r.', '..r.r..'],
274  ['.......', '..D.D..', '.......', '.D...D.', '.......', '..D.D..', '.......'],
275]
276
277export function boom(step: number): Grid {
278  return BOOM[Math.min(BOOM.length - 1, Math.max(0, step))] ?? BOOM[0]!
279}
280
281/**
282 * O raio, de `x0` a `x1` (inclusive) na altura `y`, que é o pixel de baixo do miolo. A espessura vai de 1 a 4:
283 * 1 é um fio vermelho, 2 o miolo claro, 3 miolo mais a borda viva (quatro pixels) e 4 acrescenta a borda escura (seis).
284 */
285export function beam(c: Canvas, pal: Readonly<Record<string, number>>, x0: number, x1: number, y: number, level: number): void {
286  if (level <= 0 || x1 < x0) return
287  const L = pal.L ?? 0xffffff
288  const r = pal.r ?? 0xff3b5c
289  const D = pal.D ?? 0xa11236
290  if (level === 1) return c.rect(x0, y, x1, y, r)
291  c.rect(x0, y - 1, x1, y, L)
292  if (level >= 3) { c.rect(x0, y - 2, x1, y - 2, r); c.rect(x0, y + 1, x1, y + 1, r) }
293  if (level >= 4) { c.rect(x0, y - 3, x1, y - 3, D); c.rect(x0, y + 2, x1, y + 2, D) }
294}
295
296/* ───────────────────────── células do terminal ───────────────────────── */
297
298const UPPER_HALF = 0x2580
299const LOWER_HALF = 0x2584
300const FULL = 0x2588
301
302/** Empacota a tela em meias-células: o formato do `Raster` (code point, frente e fundo, em u32 little-endian, base64). */
303export function toCells(c: Canvas): { columns: number; rows: number; cells: string } {
304  const columns = c.width
305  const rowsN = Math.ceil(c.height / 2)
306  const bytes = new Uint8Array(columns * rowsN * 12)
307  const view = new DataView(bytes.buffer)
308  let o = 0
309  for (let cy = 0; cy < rowsN; cy++)
310    for (let x = 0; x < columns; x++) {
311      const top = c.get(x, cy * 2)
312      const bottom = c.get(x, cy * 2 + 1)
313      let glyph = 0x20
314      let fg = DEFAULT
315      let bg = DEFAULT
316      if (top !== CLEAR && bottom !== CLEAR) {
317        if (top === bottom) { glyph = FULL; fg = top } else { glyph = UPPER_HALF; fg = top; bg = bottom }
318      } else if (top !== CLEAR) { glyph = UPPER_HALF; fg = top }
319      else if (bottom !== CLEAR) { glyph = LOWER_HALF; fg = bottom }
320      view.setUint32(o, glyph, true); view.setUint32(o + 4, fg, true); view.setUint32(o + 8, bg, true)
321      o += 12
322    }
323  return { columns, rows: rowsN, cells: base64(bytes) }
324}
325
326const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
327
328/** Base64 padrão, à mão: o ambiente dos plugins não tem `Buffer`. */
329export function base64(bytes: Uint8Array): string {
330  let out = ''
331  for (let i = 0; i < bytes.length; i += 3) {
332    const a = bytes[i] ?? 0
333    const b = bytes[i + 1] ?? 0
334    const d = bytes[i + 2] ?? 0
335    out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
336    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (d >> 6)]! : '='
337    out += i + 2 < bytes.length ? B64[d & 63]! : '='
338  }
339  return out
340}
341
342/** O contrário, para os testes e para a prévia: lê as células de volta como `[glifo, frente, fundo]`. */
343export function fromCells(cells: string): Array<[number, number, number]> {
344  const clean = cells.replace(/=+$/, '')
345  const bytes: number[] = []
346  let bits = 0
347  let acc = 0
348  for (const ch of clean) {
349    acc = (acc << 6) | B64.indexOf(ch)
350    bits += 6
351    if (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 255) }
352  }
353  const view = new DataView(new Uint8Array(bytes).buffer)
354  const out: Array<[number, number, number]> = []
355  for (let o = 0; o + 12 <= bytes.length; o += 12) out.push([view.getUint32(o, true), view.getUint32(o + 4, true), view.getUint32(o + 8, true)])
356  return out
357}
358
hooks/text.ts 215 lines
1// As falas do Hollowzinho no Claude Code, em português e em inglês.
2// A voz é a mesma do pet da página e da extensão do VS Code; aqui as falas falam de ferramentas, turnos e bugs.
3
4export type Lang = 'pt' | 'en'
5
6export type Words = {
7  stages: readonly [string, string, string]
8  hello: readonly string[]
9  back: readonly string[]
10  idle: readonly string[]
11  hungry: readonly string[]
12  prompt: readonly string[]
13  hunt: readonly string[]
14  bugNew: (label: string) => string
15  bugAgain: (label: string, fails: number) => string
16  bugBoss: (label: string) => string
17  bugKill: readonly string[]
18  bossKill: readonly string[]
19  bugsClear: string
20  bugHit: readonly string[]
21  passed: readonly string[]
22  edit: readonly string[]
23  commit: readonly string[]
24  push: readonly string[]
25  pr: readonly string[]
26  branch: readonly string[]
27  turnDone: readonly string[]
28  turnBugs: (n: number) => string
29  aborted: readonly string[]
30  failed: readonly string[]
31  toolFail: readonly string[]
32  sleep: string
33  wake: readonly string[]
34  cero: readonly string[]
35  weak: string
36  pet: readonly string[]
37  feed: readonly string[]
38  full: string
39  levelup: { 2: string; 3: string }
40  reset: string
41  hidden: string
42  shown: string
43  ui: {
44    pane: string
45    pet: string
46    feed: string
47    cero: string
48    sleep: string
49    wake: string
50    hide: string
51    reiatsu: string
52    bond: string
53    hunted: string
54    bugs: string
55    noBugs: string
56    times: (n: number) => string
57    paneHint: string
58    status: (name: string, stage: string, reiatsu: number, bond: number, bugs: number, hunted: number) => string
59    help: string
60    unknown: (arg: string) => string
61    paneOpened: string
62    paneNarrow: string
63  }
64}
65
66const pt: Words = {
67  stages: ['Hollow', 'Adjuchas', 'Vasto Lorde'],
68  hello: ['Cheguei! Tô de olho no seu código.', 'Oi! Posso ficar aqui em cima?'],
69  back: ['Voltou! Senti sua falta.', 'Bem-vindo de volta.', 'Hã? Tô acordado!'],
70  idle: [
71    'Tá quieto demais por aqui…',
72    'Sinto um reiatsu gostoso.',
73    'Cero carregado. É só falar.',
74    'Hueco Mundo é lar, mas aqui tá melhor.',
75    'Me dá um carinho? /hollowzinho pet',
76    'Shhh… tô vigiando os testes.',
77    'Já viu a lua hoje?',
78  ],
79  hungry: ['Meu buraco tá vazio…', 'Fome de reiatsu…', 'Alguém me alimenta? /hollowzinho feed'],
80  prompt: ['Hmm, deixa eu ver…', 'Anotado.', 'Bora!', 'Tô de olho.'],
81  hunt: ['Caçando…', 'Farejando bug…', 'Modo caçador.', 'Atento à saída dos comandos.'],
82  bugNew: (l) => `Opa… falhou: ${l}.`,
83  bugAgain: (l, n) => `${l} de novo (×${n}).`,
84  bugBoss: (l) => `${l} virou um chefe!`,
85  bugKill: ['Bug abatido!', 'Boom, um a menos.', 'Esse não volta.'],
86  bossKill: ['O chefe caiu!', 'Chefe abatido!', 'Mandou bem. Esse era osso duro.'],
87  bugsClear: 'Sem bugs! Respirei.',
88  bugHit: ['Acertei! Mas ele ainda respira.', 'Pegou de raspão. Conserta esse!', 'Ainda tá vivo. Bora corrigir?'],
89  passed: ['Passou!', 'Tudo verde.', 'Cero comemorativo!'],
90  edit: ['Nham, código fresco.', 'Delícia de diff.', 'Mais um arquivo no bucho.'],
91  commit: ['Nham, um commit!', 'Commit fresquinho.', 'Isso alimenta.'],
92  push: ['Enviado!', 'Lá vai ele!', 'No ar!'],
93  pr: ['PR aberto! Que orgulho.', 'Pull request no ar.'],
94  branch: ['Outra branch! Que passeio.', 'Território novo.'],
95  turnDone: ['Pronto!', 'Mandou bem!', 'Acabou? Já?'],
96  turnBugs: (n) => (n === 1 ? 'Ainda tem 1 bug vivo…' : `Ainda tem ${n} bugs vivos…`),
97  aborted: ['Ué, interrompeu?', 'Parei.', 'Tá bom, tá bom.'],
98  failed: ['Deu ruim lá fora.', 'Hmm, isso não parece bom.', 'Poxa…'],
99  toolFail: ['Opa…', 'Hmm.', 'Deu erro, mas segue.'],
100  sleep: 'Zzz…',
101  wake: ['Hã? Tô acordado!', 'Quem chamou?'],
102  cero: ['CERO!', 'Alvo na mira.', 'Boom.'],
103  weak: 'Sem reiatsu pra isso… me alimenta?',
104  pet: ['Hehe!', 'Mais um pouquinho!', 'Bem na máscara.', 'Isso, aí mesmo.', 'Gostei!'],
105  feed: ['Nham! Reiatsu fresquinho.', 'Delícia!', 'Meu buraco tá feliz.', 'Mais, por favor!'],
106  full: 'Tô cheio de reiatsu!',
107  levelup: { 2: 'Evoluí! Agora sou um Adjuchas!', 3: 'Vasto Lorde! Cuidado com o Cero.' },
108  reset: 'Voltei a ser um Hollow novinho.',
109  hidden: 'Escondido. /hollowzinho show chama de volta.',
110  shown: 'Voltei!',
111  ui: {
112    pane: 'Hollowzinho',
113    pet: 'Carinho',
114    feed: 'Alimentar',
115    cero: 'Cero',
116    sleep: 'Dormir',
117    wake: 'Acordar',
118    hide: 'Esconder',
119    reiatsu: 'reiatsu',
120    bond: 'vínculo',
121    hunted: 'abatidos',
122    bugs: 'Bugs vivos',
123    noBugs: 'Nenhum bug vivo. Respirando fundo.',
124    times: (n) => (n > 1 ? `×${n}` : ''),
125    paneHint: 'Um bug nasce quando um comando de build ou teste falha e morre quando o mesmo comando passa.',
126    status: (name, stage, r, b, bugs, h) => `${name} · ${stage} · reiatsu ${r} · vínculo ${b} · bugs vivos ${bugs} · abatidos ${h}`,
127    help: 'Uso: /hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]',
128    unknown: (a) => `Não entendi "${a}". /hollowzinho help lista os comandos.`,
129    paneOpened: 'Painel do Hollowzinho aberto.',
130    paneNarrow: 'Hollowzinho: alargue o terminal para ver o painel.',
131  },
132}
133
134const en: Words = {
135  stages: ['Hollow', 'Adjuchas', 'Vasto Lorde'],
136  hello: ["I'm here! Watching your code.", 'Hi! Can I stay up here?'],
137  back: ['You came back! Missed you.', 'Welcome back.', "Huh? I'm awake!"],
138  idle: [
139    'Too quiet in here…',
140    'Nice reiatsu today.',
141    'Cero charged. Just say the word.',
142    'Hueco Mundo is home, but this is nicer.',
143    'Pet me? /hollowzinho pet',
144    'Shh… watching the tests.',
145    'Seen the moon today?',
146  ],
147  hungry: ['My hole is empty…', 'Hungry for reiatsu…', 'Anyone feeding me? /hollowzinho feed'],
148  prompt: ['Hmm, let me see…', 'Noted.', "Let's go!", 'Watching.'],
149  hunt: ['Hunting…', 'Sniffing for bugs…', 'Hunter mode.', 'Eyes on the command output.'],
150  bugNew: (l) => `Oops… failed: ${l}.`,
151  bugAgain: (l, n) => `${l} again (×${n}).`,
152  bugBoss: (l) => `${l} became a boss!`,
153  bugKill: ['Bug down!', 'Boom, one less.', "That one won't be back."],
154  bossKill: ['The boss fell!', 'Boss down!', 'Well done. That one was tough.'],
155  bugsClear: 'No bugs! Phew.',
156  bugHit: ["Got it! But it's still breathing.", 'Only grazed it. Fix that one!', 'Still alive. Shall we fix it?'],
157  passed: ['Passed!', 'All green.', 'Victory Cero!'],
158  edit: ['Yum, fresh code.', 'Tasty diff.', 'One more file in the belly.'],
159  commit: ['Yum, a commit!', 'Fresh commit.', 'That feeds me.'],
160  push: ['Sent!', 'Off it goes!', 'Live!'],
161  pr: ['PR opened! So proud.', 'Pull request is up.'],
162  branch: ['Another branch! What a walk.', 'New territory.'],
163  turnDone: ['Done!', 'Well done!', 'Already?'],
164  turnBugs: (n) => (n === 1 ? 'There is still 1 live bug…' : `There are still ${n} live bugs…`),
165  aborted: ['Huh, interrupted?', 'Stopped.', 'Okay, okay.'],
166  failed: ['That went wrong out there.', "Hmm, that doesn't look good.", 'Aw…'],
167  toolFail: ['Oops…', 'Hmm.', 'Error, but moving on.'],
168  sleep: 'Zzz…',
169  wake: ["Huh? I'm awake!", 'Who called?'],
170  cero: ['CERO!', 'Target locked.', 'Boom.'],
171  weak: 'Not enough reiatsu… feed me?',
172  pet: ['Hehe!', 'A little more!', 'Right on the mask.', 'Yes, there.', 'Like that!'],
173  feed: ['Yum! Fresh reiatsu.', 'Delicious!', 'My hole is happy.', 'More, please!'],
174  full: "I'm full of reiatsu!",
175  levelup: { 2: 'I evolved! I am an Adjuchas now!', 3: 'Vasto Lorde! Mind the Cero.' },
176  reset: "I'm a brand new Hollow again.",
177  hidden: 'Hidden. /hollowzinho show calls it back.',
178  shown: "I'm back!",
179  ui: {
180    pane: 'Hollowzinho',
181    pet: 'Pet',
182    feed: 'Feed',
183    cero: 'Cero',
184    sleep: 'Sleep',
185    wake: 'Wake',
186    hide: 'Hide',
187    reiatsu: 'reiatsu',
188    bond: 'bond',
189    hunted: 'hunted',
190    bugs: 'Live bugs',
191    noBugs: 'No live bugs. Breathing easy.',
192    times: (n) => (n > 1 ? `×${n}` : ''),
193    paneHint: 'A bug is born when a build or test command fails and dies when the same command passes.',
194    status: (name, stage, r, b, bugs, h) => `${name} · ${stage} · reiatsu ${r} · bond ${b} · live bugs ${bugs} · hunted ${h}`,
195    help: 'Usage: /hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]',
196    unknown: (a) => `I did not get "${a}". /hollowzinho help lists the commands.`,
197    paneOpened: 'Hollowzinho pane opened.',
198    paneNarrow: 'Hollowzinho: widen the terminal to see the pane.',
199  },
200}
201
202export const WORDS: Readonly<Record<Lang, Words>> = { pt, en }
203
204/** `auto` segue o idioma do sistema (`LC_ALL`, `LC_MESSAGES` ou `LANG`): português para `pt*`, inglês no resto. */
205export function pickLang(setting: unknown, env: string | undefined): Lang {
206  if (setting === 'pt' || setting === 'en') return setting
207  return (env || '').toLowerCase().startsWith('pt') ? 'pt' : 'en'
208}
209
210/** Escolhe um item da lista com o sorteador dado (um número em [0, 1)). */
211export function choose<T>(list: readonly T[], roll: number): T {
212  const i = Math.min(list.length - 1, Math.floor(roll * list.length))
213  return list[Math.max(0, i)] as T
214}
215
hooks/view.tsx 299 lines
1// Atenção: `h` e `Fragment` são a fábrica do JSX, globais do ambiente: nada neste arquivo pode se chamar assim.
2// O que o Hollowzinho desenha: a faixa acima do prompt e o painel, no terminal (pixel art em `Raster`) e nas telas
3// remotas (SVG em `Svg`: desktop, celular e VS Code). Aqui só se monta a árvore: o estado vem pronto do pet.ts.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import type { Hollow, HollowBug, HollowFace } from '../types'
8import { LANE_H, LANE_SLOT, laneSvg, petSvg } from './art'
9import { CERO_FRAMES, CERO_HIT, MAX_VISIBLE_BUGS, faceOf, isBoss, stageName, stageOf, type Action } from './pet'
10import { AVATAR_H, AVATAR_W, BODY_H, BODY_W, BUG_H, BUG_W, Canvas, avatar, beam, body, boom, bug, bugHit, palette, toCells, type Tint } from './sprite'
11import { WORDS, type Lang } from './text'
12
13type Term = Elements['terminal']
14type Remote = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
15
16export type Look = { name: string; lang: Lang; tint: Tint }
17export type Handlers = { act: (action: Action) => void }
18
19const ACCENT: Readonly<Record<Tint, string>> = { cero: '#e11d48', reishi: '#16a34a', ouro: '#d97706' }
20const GOLD = '#d97706'
21const OK = '#22c55e'
22const WARN = '#f59e0b'
23
24/** Um rosto de texto para a linha de status, para quem prefere o bicho bem pequeno. */
25const KAOMOJI: Readonly<Record<HollowFace, string>> = {
26  idle: '(•_•)',
27  blink: '(-_-)',
28  happy: '(^‿^)',
29  worried: '(°_°;)',
30  sad: '(;_;)',
31  sleep: '(-_-) zZ',
32  scan: '(⌐■_■)',
33  alert: '(°o°)!',
34  fire: '(>o<)━━',
35}
36
37export function statusText(p: Hollow, look: Look): string {
38  const w = WORDS[look.lang].ui
39  const bugs = p.bugs.length > 0 ? ` · ${p.bugs.length} bug${p.bugs.length > 1 ? 's' : ''}` : ''
40  return `${KAOMOJI[faceOf(p)]} ${look.name} · ♥ ${p.bond} · ${w.reiatsu} ${Math.round(p.reiatsu)}${bugs}`
41}
42
43/** A barra do reiatsu: `█` cheio e `░` vazio. */
44export function meter(value: number, cells = 8): string {
45  const on = Math.max(0, Math.min(cells, Math.round((value / 100) * cells)))
46  return '█'.repeat(on) + '░'.repeat(cells - on)
47}
48
49/* ───────────────────────── a faixa de bugs do terminal ─────────────────────────
50 * Como no VS Code, o bicho fica à direita e os bugs vêm do lado esquerdo, andando até ele: a vaga 0 é a mais perto do pet.
51 * A faixa encosta no pet, então o raio sai da boca dele e cruza a faixa até o alvo, sem nada no meio. */
52
53const SLOT = BUG_W + 2
54
55/** Quantos bugs cabem numa faixa de `cols` colunas. */
56function fits(cols: number): number {
57  return Math.max(0, Math.min(MAX_VISIBLE_BUGS, Math.floor(cols / SLOT)))
58}
59
60/** O que a coluna de texto precisa para a linha do reiatsu não quebrar: `██████░░ reiatsu 100 ♥ 99 ✦ 99`. */
61const TEXT_MIN = 30
62
63/**
64 * A largura da faixa de bugs: zero quando não há o que mostrar, nem raio no ar. Ela ocupa no máximo `share` de `width` e
65 * deixa `reserve` colunas para o resto (o bicho, a coluna de texto), de modo que o texto nunca é espremido por ela.
66 */
67export function laneColumns(p: Pick<Hollow, 'bugs' | 'cero'>, width: number, share = 0.45, reserve = 0): number {
68  const n = Math.min(p.bugs.length, MAX_VISIBLE_BUGS)
69  if (n === 0 && !p.cero) return 0
70  const want = Math.max(n * SLOT, p.cero ? 30 : 0)
71  const cap = Math.max(0, Math.min(Math.floor(width * share), width - reserve))
72  return Math.min(want, cap) >= SLOT ? Math.min(want, cap) : 0
73}
74
75type LaneGeometry = { px: number; bugY: number; beamY: number }
76
77/** O raio fica mais grosso no instante do acerto e afina até sumir. */
78const levelAt = (frame: number): number => (frame < CERO_HIT ? 3 : frame <= CERO_HIT + 1 ? 4 : frame === CERO_HIT + 2 ? 3 : frame === CERO_HIT + 3 ? 2 : 1)
79
80/** A faixa dos bugs: bichinhos andando até o pet, o raio do Cero a caminho do alvo e a explosão no acerto. */
81export function laneCells(p: Pick<Hollow, 'bugs' | 'cero'>, cols: number, tint: Tint, g: LaneGeometry): { columns: number; rows: number; cells: string } {
82  const pal = palette(tint)
83  const c = new Canvas(cols, g.px)
84  const shown = p.bugs.slice(0, fits(cols))
85  const cero = p.cero
86  const left = (i: number): number => cols - 2 - BUG_W - i * SLOT
87  const slot = cero ? (cero.slot ?? -1) : -1
88  shown.forEach((b, i) => {
89    if (cero?.kill && cero.target === b.id && cero.frame > CERO_HIT) return
90    const flash = !!b.hit || (cero?.target === b.id && cero.frame === CERO_HIT)
91    c.blit(flash ? bugHit(isBoss(b)) : bug(isBoss(b), ((cero?.frame ?? 0) + i) % 2 === 1), left(i), g.bugY, pal)
92  })
93  if (cero && cero.frame < CERO_FRAMES - 1) {
94    const aim = slot >= 0 ? left(slot) + BUG_W / 2 : 0
95    const progress = Math.min(1, (cero.frame + 1) / (CERO_HIT + 1))
96    const xa = Math.round(cols - 1 - (cols - 1 - aim) * progress)
97    beam(c, pal, xa, cols - 1, g.beamY, levelAt(cero.frame))
98    if (slot >= 0 && cero.frame >= CERO_HIT && cero.frame <= CERO_HIT + 3) c.blit(boom(cero.frame - CERO_HIT), aim - 3, g.beamY - 3, pal)
99  }
100  return toCells(c)
101}
102
103const BAND_LANE: LaneGeometry = { px: AVATAR_H, bugY: AVATAR_H - BUG_H, beamY: 6 }
104const PANE_LANE: LaneGeometry = { px: BODY_H, bugY: 7, beamY: 10 }
105
106function petCells(p: Hollow, tint: Tint, full: boolean): { columns: number; rows: number; cells: string } {
107  const pal = palette(tint)
108  const face = faceOf(p)
109  const stage = stageOf(p.bond)
110  const c = full ? new Canvas(BODY_W, BODY_H) : new Canvas(AVATAR_W, AVATAR_H)
111  c.blit(full ? body(face, stage) : avatar(face, stage), 0, 0, pal)
112  return toCells(c)
113}
114
115/* ───────────────────────── a faixa acima do prompt ───────────────────────── */
116
117type Plain = Pick<Term, 'Box' | 'Text'>
118
119const statsRow = (t: Plain, p: Hollow, look: Look): RenderElement => {
120  const { Box, Text } = t
121  const w = WORDS[look.lang].ui
122  const hungry = p.reiatsu < 30
123  return (
124    <Box flexDirection="row" gap={1}>
125      <Text color={hungry ? WARN : OK}>{meter(p.reiatsu)}</Text>
126      <Text dimColor>{`${w.reiatsu} ${Math.round(p.reiatsu)}`}</Text>
127      <Text color={ACCENT[look.tint]}>{`♥ ${p.bond}`}</Text>
128      <Text dimColor>{`✦ ${p.hunted}`}</Text>
129    </Box>
130  )
131}
132
133export function terminalBand(t: Term, p: Hollow, look: Look, width: number): RenderElement {
134  const { Box, Text, Raster } = t
135  const pet = petCells(p, look.tint, false)
136  const lane = laneColumns(p, width, 0.45, 2 + AVATAR_W + 2 + TEXT_MIN)
137  const laneOn = lane >= SLOT && width >= 64
138  const textW = Math.max(14, width - 2 - AVATAR_W - 1 - (laneOn ? lane + 1 : 0))
139  const lanePx = laneOn ? laneCells(p, lane, look.tint, BAND_LANE) : null
140  const w = WORDS[look.lang]
141  const extra = p.bugs.length - (laneOn ? fits(lane) : 0)
142  return (
143    <Box flexDirection="row" gap={1} paddingX={1}>
144      <Box flexDirection="column" width={textW} height={pet.rows} justifyContent="center">
145        {p.say !== '' ? (
146          <Box borderStyle="round" borderColor={ACCENT[look.tint]} paddingX={1} width={Math.min(textW, p.say.length + 4)}>
147            <Text wrap="truncate-end">{p.say}</Text>
148          </Box>
149        ) : (
150          <Box flexDirection="row" gap={1}>
151            <Text bold>{look.name}</Text>
152            <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
153          </Box>
154        )}
155        {statsRow(t, p, look)}
156        {extra > 0 ? <Text color={GOLD}>{`+${extra} bugs · ${w.ui.hunted} ${p.hunted}`}</Text> : null}
157      </Box>
158      {lanePx ? <Raster key="lane" columns={lanePx.columns} rows={lanePx.rows} cells={lanePx.cells} /> : null}
159      <Raster key="pet" columns={pet.columns} rows={pet.rows} cells={pet.cells} />
160    </Box>
161  )
162}
163
164/** Quando nem a faixa cabe: uma linha só. */
165export function terminalLine(t: Pick<Term, 'Box' | 'Text'>, p: Hollow, look: Look): RenderElement {
166  const { Box, Text } = t
167  return (
168    <Box paddingX={1}>
169      <Text wrap="truncate-end" dimColor>{p.say !== '' ? `${KAOMOJI[faceOf(p)]} ${p.say}` : statusText(p, look)}</Text>
170    </Box>
171  )
172}
173
174export function remoteBand(r: Remote, p: Hollow, look: Look): RenderElement {
175  const { Box, Text, Svg } = r
176  const face = faceOf(p)
177  const shown = p.bugs.slice(0, MAX_VISIBLE_BUGS)
178  const laneW = shown.length === 0 && !p.cero ? 0 : Math.max(shown.length * LANE_SLOT, p.cero ? 240 : 0)
179  return (
180    <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
181      <Box flexDirection="column" flexGrow={1}>
182        <Text bold>{p.say !== '' ? p.say : `${look.name} · ${stageName(p.bond, look.lang)}`}</Text>
183        {statsRow(r, p, look)}
184      </Box>
185      {laneW > 0 ? <Svg source={laneSvg(p, shown, laneW)} alt={`${p.bugs.length} bugs`} width={Math.round(laneW / 2)} height={LANE_H / 2} isInteractive /> : null}
186      <Svg source={petSvg({ face, stage: stageOf(p.bond), tint: look.tint, crop: 'head' })} alt={`${look.name}: ${face}`} width={88} height={78} isInteractive />
187    </Box>
188  )
189}
190
191/* ───────────────────────── o painel ───────────────────────── */
192
193const BUTTONS: ReadonlyArray<readonly [Action, 'pet' | 'feed' | 'cero' | 'sleep' | 'hide', string]> = [
194  ['pet', 'pet', '1'],
195  ['feed', 'feed', '2'],
196  ['cero', 'cero', '3'],
197  ['sleep', 'sleep', '4'],
198  ['hide', 'hide', '5'],
199]
200
201function buttonLabel(look: Look, p: Hollow, key: 'pet' | 'feed' | 'cero' | 'sleep' | 'hide'): string {
202  const ui = WORDS[look.lang].ui
203  return key === 'sleep' && p.pose === 'sleep' ? ui.wake : ui[key]
204}
205
206function bugRows(t: Plain, p: Hollow, look: Look): RenderElement {
207  const { Box, Text } = t
208  const ui = WORDS[look.lang].ui
209  return (
210    <Box flexDirection="column">
211      <Text bold>{ui.bugs}</Text>
212      {p.bugs.length === 0 ? <Text dimColor>{ui.noBugs}</Text> : null}
213      {p.bugs.map((b: HollowBug) => (
214        <Box key={`bug:${b.id}`} flexDirection="row" gap={1}>
215          <Text color={isBoss(b) ? GOLD : ACCENT[look.tint]}>●</Text>
216          <Text>{b.label}</Text>
217          <Text dimColor>{ui.times(b.fails)}</Text>
218        </Box>
219      ))}
220    </Box>
221  )
222}
223
224/** A partir desta largura o texto cabe ao lado da faixa e do bicho; num painel mais estreito ele sobe para uma fileira própria. */
225const WIDE_PANE = 72
226
227export function terminalPane(t: Term, p: Hollow, look: Look, width: number, handlers: Handlers): RenderElement {
228  const { Box, Text, Raster, Button } = t
229  const w = WORDS[look.lang]
230  const pet = petCells(p, look.tint, true)
231  const stacked = width < WIDE_PANE
232  // O que sobra à esquerda do bicho. No painel estreito ele é todo da faixa, que encosta no bicho como na faixa de cima.
233  const room = Math.max(0, width - 2 - BODY_W - 1)
234  const lane = stacked ? laneColumns(p, room, 1) : laneColumns(p, width, 0.45, 2 + BODY_W + 2 + TEXT_MIN)
235  const laneOn = lane >= SLOT
236  const lanePx = laneOn ? laneCells(p, lane, look.tint, PANE_LANE) : null
237  const side = stacked ? {} : { width: Math.max(16, width - 2 - BODY_W - 1 - (laneOn ? lane + 1 : 0)), height: pet.rows }
238  const info = (
239    <Box flexDirection="column" {...side}>
240      <Box flexDirection="row" gap={1}>
241        <Text bold>{look.name}</Text>
242        <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
243      </Box>
244      <Text wrap="truncate-end">{p.say !== '' ? `“${p.say}”` : ' '}</Text>
245      {statsRow(t, p, look)}
246      <Text dimColor>{`${w.ui.bond} ${p.bond} · ${w.ui.hunted} ${p.hunted}`}</Text>
247    </Box>
248  )
249  return (
250    <Box flexDirection="column" gap={1} paddingX={1}>
251      {stacked ? info : null}
252      <Box flexDirection="row" gap={1} justifyContent={stacked ? 'flex-end' : 'flex-start'}>
253        {stacked ? null : info}
254        {lanePx ? <Raster key="lane" columns={lanePx.columns} rows={lanePx.rows} cells={lanePx.cells} /> : null}
255        <Raster key="pet" columns={pet.columns} rows={pet.rows} cells={pet.cells} />
256      </Box>
257      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
258        {BUTTONS.map(([action, key, hotkey]) => (
259          <Button key={action} label={buttonLabel(look, p, key)} hotkey={hotkey} onPress={() => handlers.act(action === 'sleep' && p.pose === 'sleep' ? 'wake' : action)} />
260        ))}
261      </Box>
262      {bugRows(t, p, look)}
263      <Text dimColor wrap="wrap">{w.ui.paneHint}</Text>
264    </Box>
265  )
266}
267
268export function remotePane(r: Remote, p: Hollow, look: Look, handlers: Handlers): RenderElement {
269  const { Box, Text, Button, Svg } = r
270  const w = WORDS[look.lang]
271  const face = faceOf(p)
272  const shown = p.bugs.slice(0, MAX_VISIBLE_BUGS)
273  const laneW = shown.length === 0 && !p.cero ? 0 : Math.max(shown.length * LANE_SLOT, p.cero ? 240 : 0)
274  return (
275    <Box flexDirection="column" gap={1} paddingX={1}>
276      <Box flexDirection="row" alignItems="center" gap={2}>
277        <Box flexDirection="column" flexGrow={1}>
278          <Box flexDirection="row" gap={1}>
279            <Text bold>{look.name}</Text>
280            <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
281          </Box>
282          <Text>{p.say !== '' ? `“${p.say}”` : ' '}</Text>
283          {statsRow(r, p, look)}
284          <Text dimColor>{`${w.ui.bond} ${p.bond} · ${w.ui.hunted} ${p.hunted}`}</Text>
285        </Box>
286        {laneW > 0 ? <Svg source={laneSvg(p, shown, laneW)} alt={`${p.bugs.length} bugs`} width={Math.round(laneW / 2)} height={LANE_H / 2} isInteractive /> : null}
287        <Svg source={petSvg({ face, stage: stageOf(p.bond), tint: look.tint, crop: 'full' })} alt={`${look.name}: ${face}`} width={150} height={150} isInteractive />
288      </Box>
289      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
290        {BUTTONS.map(([action, key, hotkey]) => (
291          <Button key={action} label={buttonLabel(look, p, key)} hotkey={hotkey} onPress={() => handlers.act(action === 'sleep' && p.pose === 'sleep' ? 'wake' : action)} />
292        ))}
293      </Box>
294      {bugRows(r, p, look)}
295      <Text dimColor>{w.ui.paneHint}</Text>
296    </Box>
297  )
298}
299
hooks/art.ts 261 lines
1// A arte vetorial para o desktop, o celular e o VS Code (o elemento `Svg`): a mesma figura do <hollow-pet> da página,
2// com as expressões escolhidas por atributos `data-*` na raiz do SVG, como o web component faz no shadow DOM.
3// O terminal não tem `Svg`: lá a arte é o pixel art de sprite.ts.
4// Se mudar a figura em pet/hollow-pet.js, mude aqui também (scripts/claude-plugin-test.mjs confere os trechos principais).
5
6import type { Hollow, HollowBug, HollowFace } from '../types'
7import { CERO_FRAMES, CERO_HIT, isBoss } from './pet'
8import type { Tint } from './sprite'
9
10const HEAD = 'M80 22C106 22 128 38 128 62C128 82 118 97 104 103C96 106.5 88 108 80 108C72 108 64 106.5 56 103C42 97 32 82 32 62C32 38 54 22 80 22Z'
11
12const f1 = (n: number): string => n.toFixed(1)
13
14/** Dentes ao longo de uma curva quadrática, cada um girado pela tangente. */
15function teethAlong(n: number, p0: readonly [number, number], p1: readonly [number, number], p2: readonly [number, number], w: number, h: number, from = 0.1, to = 0.9): string {
16  let out = ''
17  for (let i = 0; i < n; i++) {
18    const t = from + ((to - from) * i) / (n - 1)
19    const x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
20    const y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
21    const dx = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0])
22    const dy = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1])
23    const a = (Math.atan2(dy, dx) * 180) / Math.PI
24    const k = 1 - Math.abs(t - 0.5) * 0.9
25    const ww = w * (0.8 + 0.3 * k)
26    const hh = h * (0.8 + 0.3 * k)
27    out += `<rect x="${f1(x - ww / 2)}" y="${f1(y - 0.6)}" width="${f1(ww)}" height="${f1(hh)}" rx="1.5" transform="rotate(${f1(a)} ${f1(x)} ${f1(y)})"/>`
28  }
29  return out
30}
31
32/** Dentes de uma boca aberta (elipse), apontando para o centro. */
33function teethRing(cx: number, cy: number, rx: number, ry: number, a0: number, a1: number, n: number, w: number, h: number): string {
34  let out = ''
35  for (let i = 0; i < n; i++) {
36    const a = a0 + ((a1 - a0) * i) / (n - 1)
37    const r = (a * Math.PI) / 180
38    const x = cx + rx * Math.cos(r)
39    const y = cy + ry * Math.sin(r)
40    out += `<rect x="${f1(x - w / 2)}" y="${f1(y - 0.8)}" width="${w}" height="${h}" rx="1.4" transform="rotate(${f1(a - 90)} ${f1(x)} ${f1(y)})"/>`
41  }
42  return out
43}
44
45const MOUTH_CLOSED = `<g class="mouth mouth-closed"><path d="M52 86Q80 104 108 86Q80 116 52 86Z" fill="#0a0a0e"/><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethAlong(9, [52, 86], [80, 104], [108, 86], 4.6, 6)}</g></g>`
46const MOUTH_OPEN = `<g class="mouth mouth-open"><ellipse cx="80" cy="94" rx="20" ry="11.5" fill="#0a0a0e"/><ellipse class="mouth-glow" cx="80" cy="95" rx="15" ry="8" fill="url(#g-mouth)"/><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethRing(80, 94, 20, 11.5, 196, 344, 8, 4.6, 6.2)}</g><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethRing(80, 94, 19, 10.5, 28, 152, 6, 3.8, 4.6)}</g></g>`
47
48const eyeSocket = (cx: number, rot: number): string => `<ellipse cx="${cx}" cy="66" rx="11.5" ry="14.5" transform="rotate(${rot} ${cx} 66)"/>`
49
50const TINT_VARS: Readonly<Record<Tint, string>> = {
51  cero: '--aura:#e11d48;--mark-hi:#ff3b5c;--mark-lo:#a11236',
52  reishi: '--aura:#4ade80;--mark-hi:#6ff0a0;--mark-lo:#1f9a52',
53  ouro: '--aura:#fbbf24;--mark-hi:#ffd45e;--mark-lo:#b9770e',
54}
55
56// O mesmo CSS do web component, só o que a cena usa. A raiz do SVG leva os atributos `data-*` que escolhem a expressão.
57const CSS = `
58.art{overflow:visible}
59.bob,.tail,.aura,.arm,.head,.eye,.horn,.legs{transform-box:view-box}
60.bob{transform-origin:80px 148px;animation:breathe 3.6s ease-in-out infinite}
61.aura{transform-origin:80px 100px;animation:aura 3.2s ease-in-out infinite}
62.tail{transform-origin:108px 138px;animation:wag 2.8s ease-in-out infinite}
63.arm-l{transform-origin:51px 112px}.arm-r{transform-origin:109px 112px}
64.eye{transform-origin:80px 66px;animation:blink 5.2s infinite}
65.eye-r{animation-delay:.02s}
66.hole-ring{animation:ring 2.6s ease-in-out infinite}
67.hole-glow{opacity:.65;animation:ring 2.6s ease-in-out infinite}
68.eye-alt,.mouth-open,.blush,.mouth-glow,.show-2,.show-3,.sweat{opacity:0}
69.visor{opacity:0}
70[data-stage="2"] .show-2,[data-stage="3"] .show-2,[data-stage="3"] .show-3{opacity:1}
71[data-stage="2"] .horn,[data-stage="3"] .horn{transform-origin:70px 29px;transform:scale(1.08)}
72[data-stage="3"] .horn{transform:scale(1.28)}
73[data-stage="2"] .horn-r,[data-stage="3"] .horn-r{transform-origin:100px 29px}
74[data-stage="3"] .horn-r{transform:scale(1.35)}
75[data-stage="3"] .crack,[data-stage="3"] .crack-2{stroke:#e11d48;filter:drop-shadow(0 0 1.2px #ff3b5c)}
76[data-stage="3"] .aura{animation-duration:2.2s;transform:scale(1.12)}
77[data-eyes="happy"] .eye{opacity:0}[data-eyes="happy"] .eye-happy{opacity:1}[data-eyes="happy"] .blush{opacity:.42}
78[data-eyes="sleep"] .eye{opacity:0}[data-eyes="sleep"] .eye-sleep{opacity:1}
79[data-mouth="open"] .mouth-closed{opacity:0}[data-mouth="open"] .mouth-open{opacity:1}
80[data-glow="1"] .mouth-glow{opacity:1}
81[data-glow="1"] .marks{filter:drop-shadow(0 0 2px var(--mark-hi,#ff3b5c)) drop-shadow(0 0 4px var(--mark-hi,#ff3b5c))}
82[data-glow="1"] .hole-ring{stroke:#fff0f3}
83[data-worried="1"] .sweat{opacity:1;animation:sweat 1.5s ease-in infinite}
84[data-worried="1"] .pupil{transform-box:fill-box;transform-origin:center;transform:scale(.7)}
85[data-worried="1"] .tail{animation-duration:.25s}
86[data-worried="1"] .aura{opacity:.6}
87[data-sleep="1"] .aura{opacity:.5}
88[data-sleep="1"] .bob{animation:breathe-slow 4.8s ease-in-out infinite}
89[data-sleep="1"] .tail{animation-duration:6s}
90[data-pose="scan"] .visor{opacity:1}
91[data-pose="scan"] .aura{animation-duration:1.7s}
92[data-pose="alert"] .pupil{transform-box:fill-box;transform-origin:center;transform:scale(.6)}
93[data-pose="alert"] .aura{opacity:1;transform:scale(1.15);animation:aura .35s ease-in-out infinite}
94[data-pose="alert"] .tail{animation:none;transform:rotate(16deg)}
95[data-pose="alert"] .arm-l{transform:rotate(30deg)}[data-pose="alert"] .arm-r{transform:rotate(-30deg)}
96[data-pose="alert"] .bob{animation:startle .6s cubic-bezier(.2,0,0,1) 1}
97[data-mood="happy"] .arm-l{transform:rotate(42deg)}[data-mood="happy"] .arm-r{transform:rotate(-42deg)}
98[data-mood="happy"] .tail{animation-duration:.5s}
99.scanline{animation:scan 1.6s linear infinite}
100.visor-led{animation:led .9s steps(2) infinite}
101.zz{font:700 20px ui-sans-serif,system-ui,sans-serif;fill:#cfd8ff;animation:zz 2.4s ease-in-out infinite}
102@keyframes scan{0%{transform:translateY(0)}100%{transform:translateY(38px)}}
103@keyframes led{0%{opacity:1}100%{opacity:.25}}
104@keyframes startle{0%{transform:translateY(0) scale(1,1)}16%{transform:translateY(-15px) scale(.93,1.1) rotate(-3deg)}38%{transform:translateY(2px) scale(1.07,.92)}58%{transform:translateY(-4px) scale(.98,1.03)}100%{transform:translateY(0) scale(1,1)}}
105@keyframes sweat{0%{transform:translateY(-3px);opacity:0}20%{opacity:1}80%{opacity:1}100%{transform:translateY(9px);opacity:0}}
106@keyframes breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.014,1.03)}}
107@keyframes breathe-slow{0%,100%{transform:translateY(3px) scale(1.02,.96)}50%{transform:translateY(3px) scale(1.03,.99)}}
108@keyframes blink{0%,92%,100%{transform:scaleY(1)}95.5%{transform:scaleY(.07)}}
109@keyframes wag{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(9deg)}}
110@keyframes aura{0%,100%{opacity:.8}50%{opacity:1}}
111@keyframes ring{0%,100%{opacity:.75}50%{opacity:1}}
112@keyframes zz{0%,100%{transform:translateY(0);opacity:.4}50%{transform:translateY(-6px);opacity:1}}
113`
114
115export type PetArt = {
116  face: HollowFace
117  stage: 1 | 2 | 3
118  tint: Tint
119  /** Só a cabeça (a faixa) ou a figura inteira (o painel). */
120  crop: 'head' | 'full'
121}
122
123/** O Hollowzinho em SVG, na expressão pedida. */
124export function petSvg({ face, stage, tint, crop }: PetArt): string {
125  const eyes = face === 'happy' ? 'happy' : face === 'sleep' || face === 'blink' ? 'sleep' : 'open'
126  const attrs = [
127    `data-stage="${stage}"`,
128    `data-eyes="${eyes}"`,
129    `data-mouth="${face === 'fire' ? 'open' : 'closed'}"`,
130    `data-glow="${face === 'fire' ? 1 : 0}"`,
131    `data-worried="${face === 'worried' || face === 'sad' ? 1 : 0}"`,
132    `data-sleep="${face === 'sleep' ? 1 : 0}"`,
133    `data-pose="${face === 'scan' ? 'scan' : face === 'alert' ? 'alert' : ''}"`,
134    `data-mood="${face === 'happy' ? 'happy' : ''}"`,
135  ].join(' ')
136  const view = crop === 'head' ? '16 0 128 114' : '0 0 160 160'
137  const eyeL = eyeSocket(58, 9)
138  const eyeR = eyeSocket(102, -9)
139  const badge =
140    face === 'alert'
141      ? '<g><circle cx="136" cy="22" r="14" fill="#e11d48" stroke="#fff" stroke-width="1.5"/><rect x="134" y="13" width="4" height="12" rx="2" fill="#fff"/><circle cx="136" cy="30" r="2.4" fill="#fff"/></g>'
142      : face === 'sleep'
143        ? '<g><text class="zz" x="118" y="30">z</text><text class="zz" x="130" y="16" style="animation-delay:.8s">Z</text></g>'
144        : ''
145  return `<svg xmlns="http://www.w3.org/2000/svg" class="art" viewBox="${view}" ${attrs} style="${TINT_VARS[tint]};--gx:0;--gy:0">
146<style>${CSS}</style>
147<defs>
148<radialGradient id="g-bone" gradientUnits="userSpaceOnUse" cx="64" cy="42" r="86"><stop offset="0" stop-color="#fffdf8"/><stop offset=".5" stop-color="#f1ebde"/><stop offset="1" stop-color="#c8bfa9"/></radialGradient>
149<linearGradient id="g-chin" gradientUnits="userSpaceOnUse" x1="0" y1="72" x2="0" y2="110"><stop offset="0" stop-color="#8a8068" stop-opacity="0"/><stop offset="1" stop-color="#8a8068" stop-opacity=".5"/></linearGradient>
150<linearGradient id="g-body" gradientUnits="userSpaceOnUse" x1="0" y1="98" x2="0" y2="152"><stop offset="0" stop-color="#2d2d3a"/><stop offset=".55" stop-color="#15151c"/><stop offset="1" stop-color="#09090d"/></linearGradient>
151<radialGradient id="g-iris" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#fff2b0"/><stop offset=".55" stop-color="#fbbf24"/><stop offset="1" stop-color="#a8650a"/></radialGradient>
152<linearGradient id="g-mark" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--mark-hi)"/><stop offset="1" stop-color="var(--mark-lo)"/></linearGradient>
153<radialGradient id="g-aura" cx=".5" cy=".55" r=".5"><stop offset="0" stop-color="var(--aura)" stop-opacity=".45"/><stop offset=".6" stop-color="var(--aura)" stop-opacity=".13"/><stop offset="1" stop-color="var(--aura)" stop-opacity="0"/></radialGradient>
154<radialGradient id="g-mouth" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#fff0f3"/><stop offset=".45" stop-color="var(--mark-hi)"/><stop offset="1" stop-color="var(--mark-lo)" stop-opacity="0"/></radialGradient>
155<filter id="f-glow" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="2.2"/></filter>
156<filter id="f-soft" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="2.6"/></filter>
157<clipPath id="c-head"><path d="${HEAD}"/></clipPath>
158<clipPath id="c-eye-l">${eyeL}</clipPath>
159<clipPath id="c-eye-r">${eyeR}</clipPath>
160<clipPath id="c-visor"><ellipse cx="102" cy="66" rx="16" ry="18.5"/></clipPath>
161<mask id="m-body"><rect width="160" height="160" fill="#fff"/><circle cx="80" cy="127" r="10.5" fill="#000"/></mask>
162</defs>
163<ellipse class="aura" cx="80" cy="96" rx="76" ry="70" fill="url(#g-aura)"/>
164<ellipse cx="80" cy="153" rx="38" ry="6" fill="#000" opacity=".5" filter="url(#f-soft)"/>
165<g class="tail"><path d="M108 138C128 141 143 129 141 111C140 104 143 98 150 99" fill="none" stroke="#14141b" stroke-width="8.5" stroke-linecap="round"/><path d="M144 96L158 97L148 106Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".6" stroke-linejoin="round"/></g>
166<g class="legs"><ellipse cx="63" cy="148" rx="13" ry="6.5" fill="#101017"/><ellipse cx="97" cy="148" rx="13" ry="6.5" fill="#101017"/><g fill="#e6dfcd"><path d="M53 150l-2 4.5 5.5-2.4zM58 152l-.5 5 4.5-3zM91 152l.5 5 4.5-3zM98 152.5l1 4.5 4-3.4z"/></g></g>
167<g class="bob">
168<g class="arm arm-l"><path d="M51 112C41 114 34 121 33 131" fill="none" stroke="#17171f" stroke-width="10" stroke-linecap="round"/><circle cx="33" cy="132" r="6.6" fill="#17171f"/><path d="M27 135l-1.6 6.4 4.6-3.2zM32 138l0 6.8 3.6-4.2zM37 136.5l2.2 5.6 2.4-4.8z" fill="#e6dfcd"/></g>
169<g class="arm arm-r"><path d="M109 112C119 114 126 121 127 131" fill="none" stroke="#17171f" stroke-width="10" stroke-linecap="round"/><circle cx="127" cy="132" r="6.6" fill="#17171f"/><path d="M133 135l1.6 6.4-4.6-3.2zM128 138l0 6.8-3.6-4.2zM123 136.5l-2.2 5.6-2.4-4.8z" fill="#e6dfcd"/></g>
170<g mask="url(#m-body)"><path d="M47 102C40 124 50 149 80 149C110 149 120 124 113 102Z" fill="url(#g-body)"/></g>
171<circle class="hole-glow" cx="80" cy="127" r="10.5" fill="none" stroke="var(--aura)" stroke-width="3" filter="url(#f-glow)"/>
172<circle class="hole-ring" cx="80" cy="127" r="10.5" fill="none" stroke="var(--mark-hi)" stroke-width="1.5"/>
173<circle cx="80" cy="127" r="9.2" fill="none" stroke="#000" stroke-opacity=".55" stroke-width="2"/>
174<g class="head">
175<g class="show-2 horn-r"><path d="M100 30C102 21 106 15 111 9C101 12 92 19 90 29Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
176<g class="show-3 crest"><path d="M72 27C74 17 77.5 9 80 2C82.5 9 86 17 88 27Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
177<path class="horn" d="M60 30C57 19 57 10 52 2C66 6 77 15 77 29Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".8" stroke-linejoin="round"/>
178<g class="show-2 spikes"><path d="M33 78L21 84L31 91Z M127 78L139 84L129 91Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
179<path d="${HEAD}" fill="url(#g-bone)" stroke="#8d8573" stroke-width="1"/>
180<g clip-path="url(#c-head)"><ellipse cx="62" cy="38" rx="30" ry="12" fill="#fff" opacity=".55" filter="url(#f-glow)"/><rect x="20" y="70" width="120" height="42" fill="url(#g-chin)"/><path class="crack" d="M96 22L90 36L99 44L92 58" fill="none" stroke="#2a2a33" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/><path class="crack crack-2 show-2" d="M44 40L52 47L46 56" fill="none" stroke="#2a2a33" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/></g>
181<g class="marks" fill="url(#g-mark)" clip-path="url(#c-head)"><path d="M49.5 79C46 86 45.5 93 48 101C50.4 94 52.6 87 54.6 80Z"/><path d="M110.5 79C114 86 114.5 93 112 101C109.6 94 107.4 87 105.4 80Z"/><path d="M78.5 23L81.5 23L80.7 48L79.3 48Z"/><path d="M49 36L62 45L60.4 47.2L47.6 38.4Z M111 36L98 45L99.6 47.2L112.4 38.4Z"/></g>
182<path d="M77 81L83 81L80 87Z" fill="#1b1b22" opacity=".8" stroke="#1b1b22" stroke-width="1.6" stroke-linejoin="round"/>
183<ellipse class="blush" cx="44" cy="84" rx="6.5" ry="3.4" fill="#ff3b5c"/><ellipse class="blush" cx="116" cy="84" rx="6.5" ry="3.4" fill="#ff3b5c"/>
184<g class="sweat"><path d="M119 38C115 45 114.5 49.5 118 52C121.5 49.5 122 45 119 38Z" fill="#8ecdf5" stroke="#4d8fb8" stroke-width=".6"/><ellipse cx="117.2" cy="46.5" rx="1" ry="1.8" fill="#fff" opacity=".85"/></g>
185<g class="eye eye-l"><g fill="#07070a">${eyeL}</g><g clip-path="url(#c-eye-l)"><g class="gaze"><circle cx="58" cy="67" r="9.4" fill="url(#g-iris)"/><ellipse class="pupil" cx="58" cy="67" rx="3.7" ry="5.8" fill="#0a0a0e"/><circle cx="54.2" cy="62.4" r="2.7" fill="#fff"/><circle cx="62.2" cy="71.8" r="1.3" fill="#fff" opacity=".85"/></g></g></g>
186<g class="eye eye-r"><g fill="#07070a">${eyeR}</g><g clip-path="url(#c-eye-r)"><g class="gaze"><circle cx="102" cy="67" r="9.4" fill="url(#g-iris)"/><ellipse class="pupil" cx="102" cy="67" rx="3.7" ry="5.8" fill="#0a0a0e"/><circle cx="98.2" cy="62.4" r="2.7" fill="#fff"/><circle cx="106.2" cy="71.8" r="1.3" fill="#fff" opacity=".85"/></g></g></g>
187<g class="eye-alt eye-happy" fill="none" stroke="#0a0a0e" stroke-width="4.6" stroke-linecap="round"><path d="M47 71Q58 57 69 71"/><path d="M91 71Q102 57 113 71"/></g>
188<g class="eye-alt eye-sleep" fill="none" stroke="#0a0a0e" stroke-width="3.6" stroke-linecap="round"><path d="M47 65Q58 74 69 65"/><path d="M91 65Q102 74 113 65"/></g>
189<g class="visor"><path d="M34 53C48 44 112 44 126 53L125.5 59.5C112 51.5 48 51.5 34.5 59.5Z" fill="#17171f" stroke="#050506" stroke-opacity=".6" stroke-width=".8" stroke-linejoin="round"/><ellipse cx="102" cy="66" rx="17.5" ry="20" fill="#e11d48" fill-opacity=".2" stroke="#15151c" stroke-width="3.4"/><ellipse cx="102" cy="66" rx="17.5" ry="20" fill="none" stroke="#ff5a78" stroke-width="1.1" stroke-opacity=".95"/><g clip-path="url(#c-visor)"><rect class="scanline" x="84" y="47" width="36" height="2.6" fill="#ffb3c1" opacity=".9"/></g><path d="M91 57C94 52 100 50 106 51" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.6" stroke-linecap="round"/><circle class="visor-led" cx="122" cy="62" r="2" fill="#ff3b5c"/></g>
190${MOUTH_CLOSED}
191${MOUTH_OPEN}
192</g>
193</g>
194${badge}
195</svg>`
196}
197
198/* ───────────────────────── a faixa dos bugs e do raio ───────────────────────── */
199
200/** Largura de cada vaga de bug na faixa SVG, em unidades do viewBox. */
201export const LANE_SLOT = 54
202export const LANE_H = 96
203
204function bugSvg(x: number, boss: boolean, hit: boolean, delay: number): string {
205  const shell = hit ? '#ffffff' : boss ? '#fbbf24' : '#e11d48'
206  const shine = hit ? '#ffffff' : boss ? '#fff2b0' : '#ff3b5c'
207  const dark = boss ? '#a8650a' : '#0a0a0e'
208  const glow = boss ? '<circle cx="21" cy="60" r="24" fill="#fbbf24" opacity=".22"/>' : ''
209  return `<g transform="translate(${x} 0)">${glow}<g class="crawl" style="animation-delay:${delay}s">
210<path d="M12 40L6 32M30 40L36 32" stroke="#0a0a0e" stroke-width="2.4" stroke-linecap="round"/>
211<path d="M6 62L0 58M6 70L0 74M36 62L42 58M36 70L42 74M10 78L6 86M32 78L36 86" stroke="#0a0a0e" stroke-width="2.4" stroke-linecap="round"/>
212<ellipse cx="21" cy="62" rx="15" ry="17" fill="${shell}" stroke="#0a0a0e" stroke-width="2"/>
213<path d="M21 46V80" stroke="${dark}" stroke-width="2"/>
214<ellipse cx="16" cy="55" rx="4" ry="5" fill="${shine}" opacity=".85"/>
215<circle cx="27" cy="62" r="2.4" fill="${dark}"/><circle cx="15" cy="70" r="2.2" fill="${dark}"/><circle cx="27" cy="72" r="2" fill="${dark}"/>
216<circle cx="21" cy="44" r="7" fill="#0a0a0e"/><circle cx="18.5" cy="42.5" r="1.8" fill="#fff"/><circle cx="24" cy="42.5" r="1.8" fill="#fff"/>
217</g></g>`
218}
219
220function boomSvg(cx: number, cy: number, step: number): string {
221  if (step === 0) return `<circle cx="${cx}" cy="${cy}" r="9" fill="#fff"/>`
222  if (step === 1)
223    return `<g><circle cx="${cx}" cy="${cy}" r="22" fill="#ff3b5c" opacity=".55"/><circle cx="${cx}" cy="${cy}" r="15" fill="#ffd45e"/><circle cx="${cx}" cy="${cy}" r="8" fill="#fff"/><path d="M${cx} ${cy - 30}V${cy + 30}M${cx - 30} ${cy}H${cx + 30}" stroke="#fff" stroke-width="3" stroke-linecap="round"/></g>`
224  if (step === 2) return `<circle cx="${cx}" cy="${cy}" r="26" fill="none" stroke="#ff3b5c" stroke-width="3" stroke-dasharray="4 6"/>`
225  return `<circle cx="${cx}" cy="${cy}" r="30" fill="none" stroke="#a11236" stroke-width="2" stroke-dasharray="2 10" opacity=".7"/>`
226}
227
228/** Onde fica o bug da vaga `i`, contando da direita (o lado do pet): a vaga 0 é a mais perto dele. */
229const bugX = (width: number, i: number): number => width - (i + 1) * LANE_SLOT + 4
230
231/**
232 * A faixa dos bugs: os bichinhos andando até o pet (que fica à direita), o raio do Cero saindo da direita até o alvo
233 * e a explosão quando acerta.
234 */
235export function laneSvg(p: Pick<Hollow, 'bugs' | 'cero'>, visible: readonly HollowBug[], width: number): string {
236  const cero = p.cero
237  const slot = cero && cero.slot !== undefined ? cero.slot : -1
238  let beam = ''
239  let boom = ''
240  if (cero && cero.frame < CERO_FRAMES - 1) {
241    const aim = slot >= 0 ? bugX(width, slot) + 21 : 0
242    const grow = Math.min(1, (cero.frame + 1) / (CERO_HIT + 1))
243    const len = Math.max(8, (width - aim) * grow)
244    const thin = cero.frame <= CERO_HIT + 1 ? 1 : Math.max(0.15, 1 - (cero.frame - CERO_HIT - 1) / (CERO_FRAMES - CERO_HIT - 2))
245    const h = 14 * thin
246    const x = width - len
247    beam = `<g><rect x="${x}" y="${60 - h * 1.4}" width="${len}" height="${h * 2.8}" rx="${h}" fill="#ff3b5c" opacity=".35" filter="url(#bf)"/><rect x="${x}" y="${60 - h / 2}" width="${len}" height="${h}" rx="${h / 2}" fill="url(#bg)"/><rect x="${x}" y="${60 - h / 5}" width="${len}" height="${(h * 2) / 5}" rx="${h / 5}" fill="#fff0f3"/></g>`
248    if (slot >= 0 && cero.frame >= CERO_HIT && cero.frame <= CERO_HIT + 3) boom = boomSvg(aim, 58, cero.frame - CERO_HIT)
249  }
250  const bugs = visible
251    .map((b, i) => ({ b, i }))
252    .filter(({ b }) => !(cero?.kill && cero.target === b.id && cero.frame > CERO_HIT))
253    .map(({ b, i }) => bugSvg(bugX(width, i), isBoss(b), !!b.hit || (cero?.target === b.id && cero.frame === CERO_HIT), i * 0.35))
254    .join('')
255  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${LANE_H}">
256<style>@keyframes crawl{0%,100%{transform:translateX(0)}50%{transform:translateX(-3px)}}.crawl{animation:crawl 1.4s ease-in-out infinite}</style>
257<defs><linearGradient id="bg" x1="1" y1="0" x2="0" y2="0"><stop offset="0" stop-color="#a11236"/><stop offset=".5" stop-color="#ff3b5c"/><stop offset="1" stop-color="#ffb3c1"/></linearGradient><filter id="bf" x="-10%" y="-100%" width="120%" height="300%"><feGaussianBlur stdDeviation="4"/></filter></defs>
258${bugs}${beam}${boom}
259</svg>`
260}
261
types/index.d.ts 89 lines
1/** Como o bicho aparece: cada uma é uma expressão do rosto. */
2export type HollowFace = 'idle' | 'blink' | 'happy' | 'worried' | 'sad' | 'sleep' | 'scan' | 'alert' | 'fire'
3
4/** Humor passageiro; some sozinho quando `moodUntil` passa. */
5export type HollowMood = 'calm' | 'happy' | 'worried' | 'sad'
6
7/** Um comando de build ou teste que falhou e ainda não passou: vira um bichinho na cena. */
8export type HollowBug = {
9  /** Identifica a "família" do comando (`npm test`, `tsc`, `cargo build`). */
10  id: string
11  /** O que aparece na tela: a família do comando, curta. */
12  label: string
13  /** Quantas vezes falhou seguido. Com 3 ou mais ele vira um chefe dourado. */
14  fails: number
15  /** Levou um raspão de um Cero que não o matou: pisca por um instante. */
16  hit?: boolean
17}
18
19/** O Cero em voo: um quadro por passo da animação. */
20export type HollowCero = {
21  /** O passo atual, de 0 ao fim da animação. */
22  frame: number
23  /** Quando o próximo passo é devido (ms desde 1970). */
24  nextAt: number
25  /** O bug mirado (o id dele); sem alvo, o raio vai até a borda. */
26  target?: string
27  /** A vaga do alvo na faixa (0 é a mais perto do pet): fica guardada porque o bug morre no meio do voo. */
28  slot?: number
29  /** O Cero abate o alvo no instante do acerto (é o caso de um comando que passou). */
30  kill: boolean
31}
32
33/** Tudo o que a cena precisa para se desenhar; é o que o plugin guarda como estado da sessão. */
34export type Hollow = {
35  v: 1
36  /** 0 a 100. Cai devagar; teclas, ferramentas e commits alimentam. */
37  reiatsu: number
38  /** Cresce com carinho, comida, commits e bugs abatidos. Com 15 vira Adjuchas e com 40, Vasto Lorde. */
39  bond: number
40  /** Quantos bugs já foram abatidos, desde sempre. */
41  hunted: number
42  mood: HollowMood
43  moodUntil: number
44  /** `scan` não é guardado aqui: sai de `working`. `alert` dura pouco; `sleep` dura até a próxima atividade. */
45  pose: '' | 'alert' | 'sleep'
46  poseUntil: number
47  /** O que está no balão e até quando. */
48  say: string
49  sayUntil: number
50  bugs: HollowBug[]
51  cero: HollowCero | null
52  hidden: boolean
53  /** Um turno do Claude está rodando: o bicho entra no modo caçador. */
54  working: boolean
55  /** Os olhos fechados por um instante. */
56  blink: boolean
57  /** Quando foi o último sinal de vida (prompt, ferramenta, comando). */
58  lastActive: number
59  /** Até onde o tempo já foi contado (fome, sono, piscar). */
60  lastTick: number
61  /** Quando ele falou sozinho pela última vez e quando comemorou (para não ficar repetitivo). */
62  lastChat: number
63  lastCheer: number
64  /** Quando o próximo piscar acontece. */
65  blinkAt: number
66  /** O que a sessão já fez: usado para comemorar o fim do turno só quando houve trabalho. */
67  edits: number
68  /** Já cumprimentou nesta sessão. */
69  greeted: boolean
70}
71
72/** O que fica guardado entre sessões (`$.store`). */
73export type HollowSaved = {
74  v: 1
75  reiatsu: number
76  bond: number
77  hunted: number
78  hidden: boolean
79  seen: boolean
80  /** Quando foi guardado (ms desde 1970): o reiatsu cai pelo tempo que o bicho ficou sozinho. */
81  t: number
82}
83
84declare module 'claude-code' {
85  interface PluginState {
86    hollowzinho: { pet: Hollow }
87  }
88}
89