Sala d'attesa: Snake giocabile solo mentre Claude lavora

hooks/register.tsx 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BoardMessage, BoardProps, Game } from '../types'
5
6const PANE = 'snake'
7const TITLE = "Snake · sala d'attesa"
8const COLS = 20
9const ROWS = 12
10const PANE_ROWS = ROWS + 6
11const CLOSE_DELAY_MS = 800
12
13/** Tools that stop the turn until the person answers. */
14const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
15const ASKING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
16
17const phase = atom({ plugin: 'waiting-room', key: 'phase' } as const, 'idle')
18const best = atom({ plugin: 'waiting-room', key: 'best' } as const, 0)
19const isAutoOpen = atom({ plugin: 'waiting-room', key: 'isAutoOpen' } as const, true)
20
21function formatDuration(ms: number): string {
22 const seconds = Math.round(ms / 1000)
23 if (seconds < 60) return `${seconds}s`
24
25 return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
26}
27
28function isMessage(data: unknown): data is BoardMessage {
29 return typeof data === 'object' && data !== null && 'type' in data
30}
31
32// Turns running now, the main loop's and its subagents': the room is open while any runs.
33const running = new Set<string>()
34let pendingClose: Timer | undefined
35
36async function needsYou($: EngineInterface): Promise<void> {
37 if (running.size > 0) await update($, phase, () => 'needs-you')
38}
39
40async function backToWork($: EngineInterface): Promise<void> {
41 if (running.size > 0) await update($, phase, current => (current === 'needs-you' ? 'working' : current))
42}
43
44export const register: Register = on => {
45 on('session.start', async ($, e, next) => {
46 await $.command.register({
47 name: 'snake',
48 description: "Apre Snake, giocabile solo mentre Claude lavora (/snake auto on|off)",
49 })
50 const stored = Number((await $.store.get('best')) ?? 0)
51 const isAuto = (await $.store.get('isAutoOpen')) !== false
52 await update($, best, current => Math.max(current, stored))
53 await update($, isAutoOpen, () => isAuto)
54
55 return next(e)
56 })
57
58 on('prompt.submit', async ($, e, next) => {
59 const result = await next(e)
60 pendingClose?.cancel()
61 pendingClose = undefined
62 if (await read($, isAutoOpen)) {
63 void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
64 }
65
66 return result
67 })
68
69 on('turn.start', async ($, e, next) => {
70 running.add(e.turnId)
71 await update($, phase, () => 'working')
72
73 return next(e)
74 })
75
76 on('turn.complete', async ($, e, next) => {
77 const result = await next(e)
78 running.delete(e.turnId)
79 if (running.size > 0 || e.agentId !== undefined) return result
80
81 await update($, phase, () => 'idle')
82 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
83 if (isOpen) {
84 $.ui.toast(`Claude ha finito dopo ${formatDuration(e.durationMs)}: Snake in pausa fino al prossimo prompt`)
85 if (await read($, isAutoOpen)) {
86 // Leaves the board a moment to save the game before the pane goes.
87 pendingClose = $.clock.after(CLOSE_DELAY_MS, () => {
88 pendingClose = undefined
89 void $.ui.close({ id: PANE })
90 })
91 }
92 }
93
94 return result
95 })
96
97 on('classic.PermissionRequest', async ($, e, next) => {
98 await needsYou($)
99
100 return next(e)
101 })
102
103 on('classic.Notification', async ($, e, next) => {
104 if (ASKING_NOTIFICATIONS.has(e.notification_type)) await needsYou($)
105
106 return next(e)
107 })
108
109 on('tool.call', async ($, e, next) => {
110 if (ASKING_TOOLS.has(String(e.tool))) await needsYou($)
111 const ran = await next(e)
112 await backToWork($)
113
114 return ran
115 })
116
117 on('ui.message', async ($, e, next) => {
118 const data = e.data
119 if (isMessage(data) && data.type === 'save') {
120 await $.store.set('saved', data.game)
121 }
122 if (isMessage(data) && data.type === 'over') {
123 await $.store.delete('saved')
124 const record = await read($, best)
125 if (data.score > record) {
126 await update($, best, () => data.score)
127 await $.store.set('best', data.score)
128 $.ui.toast(`🐍 Nuovo record a Snake: ${data.score} punti`)
129 }
130 }
131
132 return next(e)
133 })
134
135 on('command.run', { command: 'snake' }, async ($, e) => {
136 const args = e.args.trim()
137 if (args === 'auto on' || args === 'auto off') {
138 const isAuto = args === 'auto on'
139 await update($, isAutoOpen, () => isAuto)
140 await $.store.set('isAutoOpen', isAuto)
141
142 return {
143 text: isAuto
144 ? 'Snake si aprirà da solo a ogni prompt e si chiuderà quando Claude finisce.'
145 : 'Snake non si aprirà più da solo: usa /snake quando vuoi giocare.',
146 }
147 }
148
149 await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: PANE_ROWS })
150 const isLocked = (await read($, phase)) !== 'working'
151
152 return {
153 text: isLocked
154 ? 'Snake aperto: si sblocca appena Claude inizia a lavorare.'
155 : 'Snake aperto: buon divertimento!',
156 }
157 })
158
159 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
161 const { Text } = $.ui.resolve(e)
162
163 return <Text dimColor>Snake si gioca nel terminale o nell'app desktop.</Text>
164 }
165
166 const { Client } = $.ui.resolve(e)
167 const room = Math.floor((e.props.bodyColumns - 2) / 2)
168 const props: BoardProps = {
169 phase: await read($, phase),
170 best: await read($, best),
171 saved: ((await $.store.get('saved')) as Game | undefined) ?? null,
172 cols: Math.max(8, Math.min(COLS, room)),
173 rows: ROWS,
174 }
175
176 return <Client key="board" module="./board.tsx" props={props} />
177 })
178}
179hooks/board.tsx 228 lines1import type { ClientKeyEvent, ClientModule, ClientSurface } from 'claude-code'
2
3import type { BoardMessage, BoardProps, Dir, Game } from '../types'
4import { interval, newGame, step, turn } from './snake'
5
6type Local = {
7 game: Game | null
8 isPaused: boolean
9 isSaved: boolean
10 isRecord: boolean
11}
12
13type Surface = ClientSurface<Local>
14
15const TICK_MS = 30
16const SAVE_EVERY_MS = 2000
17
18const KEYS: Record<string, Dir> = {
19 up: 'up',
20 down: 'down',
21 left: 'left',
22 right: 'right',
23 w: 'up',
24 a: 'left',
25 s: 'down',
26 d: 'right',
27 k: 'up',
28 h: 'left',
29 j: 'down',
30 l: 'right',
31}
32
33const COLORS = {
34 head: '#86efac',
35 body: '#22c55e',
36 food: '#ef4444',
37 locked: '#6b7280',
38}
39
40// One board per plugin: the tick and key handlers read the latest props from here.
41let live: BoardProps = { phase: 'idle', best: 0, saved: null, cols: 20, rows: 12 }
42let sinceMove = 0
43let sinceSave = 0
44
45function newSeed(): number {
46 return (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0
47}
48
49function post(surface: Surface, message: BoardMessage): void {
50 surface.post(message)
51}
52
53function tick(surface: Surface): void {
54 const local = surface.state
55 if (!local?.game || local.game.isOver) return
56
57 if (live.phase !== 'working' || local.isPaused) {
58 if (!local.isSaved) {
59 post(surface, { type: 'save', game: local.game })
60 surface.setState({ ...local, isSaved: true })
61 }
62 return
63 }
64
65 sinceMove += TICK_MS
66 sinceSave += TICK_MS
67 if (sinceMove < interval(local.game.score)) return
68 sinceMove = 0
69
70 const game = step(local.game)
71 if (game.isOver) {
72 post(surface, { type: 'over', score: game.score })
73 surface.setState({ ...local, game, isSaved: true, isRecord: game.score > live.best })
74 return
75 }
76
77 if (sinceSave >= SAVE_EVERY_MS) {
78 sinceSave = 0
79 post(surface, { type: 'save', game })
80 surface.setState({ ...local, game, isSaved: true })
81 return
82 }
83
84 surface.setState({ ...local, game, isSaved: false })
85}
86
87function steer(surface: Surface, dir: Dir): void {
88 const local = surface.state
89 if (!local || live.phase !== 'working') return
90
91 if (!local.game || local.game.isOver) {
92 sinceMove = 0
93 sinceSave = 0
94 const game = newGame(live.cols, live.rows, newSeed(), dir)
95 surface.setState({ game, isPaused: false, isSaved: false, isRecord: false })
96 return
97 }
98
99 surface.setState({ ...local, game: turn(local.game, dir), isPaused: false })
100}
101
102function togglePause(surface: Surface): void {
103 const local = surface.state
104 if (!local?.game || local.game.isOver || live.phase !== 'working') return
105
106 surface.setState({ ...local, isPaused: !local.isPaused })
107}
108
109function onKey(surface: Surface, event: ClientKeyEvent): void {
110 if (event.ctrl || event.meta) return
111 if (event.key === ' ' || event.key === 'space' || event.key === 'p') {
112 togglePause(surface)
113 return
114 }
115
116 const dir = KEYS[event.key] ?? KEYS[event.key.toLowerCase()]
117 if (dir) steer(surface, dir)
118}
119
120type Kind = 'empty' | 'body' | 'head' | 'food'
121
122function kindAt(game: Game | null, x: number, y: number): Kind {
123 if (!game) return 'empty'
124 const head = game.snake[0]
125 if (head && head[0] === x && head[1] === y) return 'head'
126 if (game.snake.some(([sx, sy]) => sx === x && sy === y)) return 'body'
127 if (game.food && game.food[0] === x && game.food[1] === y) return 'food'
128
129 return 'empty'
130}
131
132/** One row of the board as runs of same-kind cells, two columns per cell. */
133function runsOf(game: Game | null, cols: number, y: number): { kind: Kind; width: number }[] {
134 const runs: { kind: Kind; width: number }[] = []
135 for (let x = 0; x < cols; x++) {
136 const kind = kindAt(game, x, y)
137 const last = runs[runs.length - 1]
138 if (last && last.kind === kind) last.width += 2
139 else runs.push({ kind, width: 2 })
140 }
141
142 return runs
143}
144
145function messageOf(props: BoardProps, local: Local): string {
146 if (props.phase === 'idle') {
147 return '🔒 Claude ha finito: si gioca solo mentre lavora. La partita ti aspetta al prossimo prompt.'
148 }
149 if (props.phase === 'needs-you') {
150 return '⚠ Claude ha bisogno di te: rispondi alla sua richiesta, poi si riprende.'
151 }
152
153 const game = local.game
154 if (!game) return 'Premi una freccia (o w a s d) per iniziare. Se i tasti non arrivano, clicca sul campo.'
155 if (game.isOver) {
156 const record = local.isRecord ? ' Nuovo record!' : ''
157 return `💥 Game over: ${game.score} punti.${record} Una freccia per rigiocare.`
158 }
159 if (local.isPaused) return '⏸ In pausa. Una freccia per riprendere.'
160
161 return 'Claude sta lavorando: gioca! Spazio per la pausa.'
162}
163
164const Board: ClientModule<BoardProps, Local> = (props, surface) => {
165 live = props
166 const { Box, Text, Button } = surface.elements
167
168 if (surface.state === undefined) {
169 surface.every(TICK_MS, () => tick(surface))
170 surface.onKey(event => onKey(surface, event))
171 surface.setState({ game: props.saved, isPaused: false, isSaved: true, isRecord: false })
172 }
173
174 const local: Local = surface.state ?? { game: props.saved, isPaused: false, isSaved: true, isRecord: false }
175 const game = local.game
176 const cols = game?.cols ?? props.cols
177 const rows = game?.rows ?? props.rows
178 const isLocked = props.phase !== 'working'
179 const score = game?.score ?? 0
180 const best = Math.max(props.best, score)
181
182 const lines = []
183 for (let y = 0; y < rows; y++) {
184 lines.push(
185 <Box key={`row-${y}`}>
186 {runsOf(game, cols, y).map(run => (
187 <Text
188 color={isLocked ? COLORS.locked : run.kind === 'empty' ? undefined : COLORS[run.kind]}
189 dimColor={isLocked}
190 >
191 {(run.kind === 'empty' ? ' ' : '█').repeat(run.width)}
192 </Text>
193 ))}
194 </Box>,
195 )
196 }
197
198 return (
199 <Box flexDirection="column">
200 <Box gap={2}>
201 <Text bold>🐍 Snake</Text>
202 <Text>punti {score}</Text>
203 <Text dimColor>record {best}</Text>
204 </Box>
205 <Box
206 flexDirection="column"
207 borderStyle="round"
208 borderColor={isLocked ? COLORS.locked : COLORS.body}
209 width={cols * 2 + 2}
210 >
211 {lines}
212 </Box>
213 <Text dimColor={isLocked} wrap="wrap">
214 {messageOf(props, local)}
215 </Text>
216 <Box gap={1}>
217 <Button key="up" label="↑" hotkey="w" plain onPress={() => steer(surface, 'up')} />
218 <Button key="left" label="←" hotkey="a" plain onPress={() => steer(surface, 'left')} />
219 <Button key="down" label="↓" hotkey="s" plain onPress={() => steer(surface, 'down')} />
220 <Button key="right" label="→" hotkey="d" plain onPress={() => steer(surface, 'right')} />
221 <Button key="pause" label="pausa" hotkey="p" plain onPress={() => togglePause(surface)} />
222 </Box>
223 </Box>
224 )
225}
226
227export default Board
228hooks/snake.ts 124 lines1import type { Cell, Dir, Game } from '../types'
2
3const DELTA: Record<Dir, Cell> = {
4 up: [0, -1],
5 down: [0, 1],
6 left: [-1, 0],
7 right: [1, 0],
8}
9
10const OPPOSITE: Record<Dir, Dir> = {
11 up: 'down',
12 down: 'up',
13 left: 'right',
14 right: 'left',
15}
16
17const START_LENGTH = 3
18const MAX_QUEUED = 2
19
20function nextSeed(seed: number): number {
21 return (Math.imul(seed, 1664525) + 1013904223) >>> 0
22}
23
24function isOn(cells: readonly Cell[], [x, y]: Cell): boolean {
25 return cells.some(([cx, cy]) => cx === x && cy === y)
26}
27
28function placeFood(
29 snake: readonly Cell[],
30 cols: number,
31 rows: number,
32 seed: number,
33): { food: Cell | null; seed: number } {
34 const free = cols * rows - snake.length
35 if (free <= 0) {
36 return { food: null, seed }
37 }
38
39 const next = nextSeed(seed)
40 let pick = next % free
41 for (let y = 0; y < rows; y++) {
42 for (let x = 0; x < cols; x++) {
43 if (isOn(snake, [x, y])) continue
44 if (pick === 0) return { food: [x, y], seed: next }
45 pick--
46 }
47 }
48
49 return { food: null, seed: next }
50}
51
52export function newGame(cols: number, rows: number, seed: number, dir: Dir = 'right'): Game {
53 const head: Cell = [Math.floor(cols / 2), Math.floor(rows / 2)]
54 const [bx, by] = DELTA[OPPOSITE[dir]]
55 const snake: Cell[] = []
56 for (let i = 0; i < START_LENGTH; i++) {
57 snake.push([head[0] + bx * i, head[1] + by * i])
58 }
59 const placed = placeFood(snake, cols, rows, seed)
60
61 return {
62 cols,
63 rows,
64 snake,
65 dir,
66 queued: [],
67 food: placed.food,
68 score: 0,
69 isOver: false,
70 seed: placed.seed,
71 }
72}
73
74export function turn(game: Game, dir: Dir): Game {
75 const last = game.queued[game.queued.length - 1] ?? game.dir
76 if (game.isOver || dir === last || dir === OPPOSITE[last] || game.queued.length >= MAX_QUEUED) {
77 return game
78 }
79
80 return { ...game, queued: [...game.queued, dir] }
81}
82
83export function step(game: Game): Game {
84 const current = game.snake[0]
85 if (game.isOver || !current) {
86 return game
87 }
88
89 const [dir = game.dir, ...queued] = game.queued
90 const [hx, hy] = current
91 const [dx, dy] = DELTA[dir]
92 const head: Cell = [hx + dx, hy + dy]
93 const isOutside = head[0] < 0 || head[1] < 0 || head[0] >= game.cols || head[1] >= game.rows
94 const eats = game.food !== null && head[0] === game.food[0] && head[1] === game.food[1]
95 const body = eats ? game.snake : game.snake.slice(0, -1)
96
97 if (isOutside || isOn(body, head)) {
98 return { ...game, dir, queued: [], isOver: true }
99 }
100
101 const snake = [head, ...body]
102 if (!eats) {
103 return { ...game, snake, dir, queued }
104 }
105
106 const placed = placeFood(snake, game.cols, game.rows, game.seed)
107
108 return {
109 ...game,
110 snake,
111 dir,
112 queued,
113 food: placed.food,
114 score: game.score + 1,
115 isOver: placed.food === null,
116 seed: placed.seed,
117 }
118}
119
120/** Milliseconds between moves: the snake speeds up as it eats. */
121export function interval(score: number): number {
122 return Math.max(70, 150 - score * 4)
123}
124types/index.d.ts 36 lines1export type Phase = 'idle' | 'working' | 'needs-you'
2
3export type Dir = 'up' | 'down' | 'left' | 'right'
4
5export type Cell = [number, number]
6
7export type Game = {
8 cols: number
9 rows: number
10 snake: Cell[]
11 dir: Dir
12 queued: Dir[]
13 food: Cell | null
14 score: number
15 isOver: boolean
16 seed: number
17}
18
19export type BoardProps = {
20 phase: Phase
21 best: number
22 saved: Game | null
23 cols: number
24 rows: number
25}
26
27export type BoardMessage =
28 | { type: 'save'; game: Game }
29 | { type: 'over'; score: number }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'waiting-room': { phase: Phase; best: number; isAutoOpen: boolean }
34 }
35}
36