SLOPSHOPPER

cooking-puzzles

Play Sudoku or TTS (teka-teki silang) in a pane while Claude is cooking

newpanecommandtoaststatusprompt
v0.1.1no licenseupdated 2026-10-07Tamlica/cooking-puzzles-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cooking-puzzles
│ ┃ Snake ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ✅ Claude is done — Esc to go back │ cooking-puzzles │ │ ┃ ▣ client module ./snake.tsx ⏺ Read(src/auth.ts) │ 🍽️ Claude is done! Esc to return to the │ │ ┃ [ Sudoku ] [ TTS ] ⎿ Read 6 lines │ prompt (your game is saved) │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ⏺ Bash(bun test) │ cooking-puzzles │ │ ⎿ 3 pass, 1 fail │ Claude gagal bikin soal baru, pakai soal │ │ │ bawaan │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sudoku │ ⎿ cooking-puzzles: Sudoku opened. w/a/s/d move, 1-9 fill, 0 erase, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Snake
✅ Claude is done — Esc to go back ▣ client module ./snake.tsx [ Sudoku ] [ TTS ]
README

cooking-puzzles 🍳🧩

A Claude Code mod: play Sudoku, TTS (teka-teki silang) or Nokia-style Snake in a pane while Claude is cooking.

When you send a prompt, a puzzle pane opens. When Claude finishes, you get a toast — press Esc to go back to the prompt. Your game is saved between turns.

Don't want it popping up? /puzzle off — the games then only open when you ask (/sudoku, /tts, /snake).

Install

/plugin install cooking-puzzles --marketplace Tamlica/cooking-puzzles-mod

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

Play

Sudoku — every puzzle has exactly one solution.

KeyAction
w a s dmove
1–9fill
0erase
nnew puzzle
gswitch to TTS
kswitch to Snake

TTS — every new crossword is written by Claude (Haiku) on a random theme (makanan, hewan, teknologi, sejarah…), avoiding words you've seen recently. If that fails or you turn it off, it uses a built-in list of ~50 Indonesian words. Type the answer and press Enter (empty Enter = next clue). Tab to pick a clue, petunjuk reveals a letter.

Snake — like the old Nokia, on a 3310-green screen. Walls wrap around (Snake II style), and the snake speeds up as it grows. Click the board to give it the keyboard first.

KeyAction
arrows / w a s d / h j k lsteer (and start)
space / ppause
rrestart

Your best score is kept across sessions.

Commands

  • /sudoku, /tts, /snake — open a game any time
  • /puzzle on|off — auto-open while Claude works (on by default). Off is fully quiet: no pane, no status line
  • /puzzle sudoku|tts|snake — which game auto-opens
  • /puzzle — show the current settings
  • /puzzle ai on|off — Claude-written TTS words (on by default; each new TTS is one small Haiku call)

Your games in progress, settings and Snake best score are saved, so they carry over to your next session.

To turn the whole mod off, disable or uninstall it from the /plugin menu.

Develop

claude --plugin-dir .          # run it from this folder
claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 444 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Game, Sudoku, Tts } from '../types'
5import {
6  answerTts,
7  hintTts,
8  isSudokuSolved,
9  moveCursor,
10  newSudoku,
11  newTts,
12  nextUnsolved,
13  parseEntries,
14  pickTheme,
15  ttsPrompt,
16  wordCells,
17} from './games'
18
19const PANE = 'puzzle'
20
21const game = atom({ plugin: 'cooking-puzzles', key: 'game' } as const, 'sudoku' as Game)
22const sudoku = atom({ plugin: 'cooking-puzzles', key: 'sudoku' } as const, null as Sudoku | null)
23const tts = atom({ plugin: 'cooking-puzzles', key: 'tts' } as const, null as Tts | null)
24const auto = atom({ plugin: 'cooking-puzzles', key: 'auto' } as const, true)
25const cooking = atom({ plugin: 'cooking-puzzles', key: 'cooking' } as const, false)
26const draft = atom({ plugin: 'cooking-puzzles', key: 'draft' } as const, '')
27const snakeBest = atom({ plugin: 'cooking-puzzles', key: 'snakeBest' } as const, 0)
28const ttsAi = atom({ plugin: 'cooking-puzzles', key: 'ttsAi' } as const, true)
29const ttsLoading = atom({ plugin: 'cooking-puzzles', key: 'ttsLoading' } as const, false)
30
31// Writes of the values saved to the plugin's store, so they outlive the session
32type $ = EngineInterface
33const setGame = async ($: $, fn: (v: Game) => Game) => $.store.set('game', await update($, game, fn))
34const setAuto = async ($: $, fn: (v: boolean) => boolean) => $.store.set('auto', await update($, auto, fn))
35const setAi = async ($: $, fn: (v: boolean) => boolean) => $.store.set('ttsAi', await update($, ttsAi, fn))
36const setBest = async ($: $, fn: (v: number) => number) => $.store.set('snakeBest', await update($, snakeBest, fn))
37const setSudoku = async ($: $, fn: (v: Sudoku | null) => Sudoku | null) =>
38  $.store.set('sudoku', await update($, sudoku, fn))
39const setTts = async ($: $, fn: (v: Tts | null) => Tts | null) => $.store.set('tts', await update($, tts, fn))
40
41/** A new TTS: words Claude writes on a random theme, else the built-in list. */
42const startTts = async ($: EngineInterface) => {
43  if (await read($, ttsLoading)) return
44  await update($, ttsLoading, () => true)
45  try {
46    const recent = ((await $.store.get('ttsRecent')) as string[] | undefined) ?? []
47    let puzzle: Tts | null = null
48    if (await read($, ttsAi)) {
49      const theme = pickTheme()
50      try {
51        const reply = await $.model.complete({
52          model: 'haiku',
53          prompt: ttsPrompt(theme, recent.slice(-40)),
54          maxTokens: 2000,
55          timeoutMs: 45000,
56        })
57        const entries = reply.isAnswered ? parseEntries(reply.text) : []
58        if (entries.length >= 8) {
59          const made = newTts(8, entries)
60          if (made.words.length >= 5) puzzle = { ...made, theme }
61        }
62      } catch {}
63      if (!puzzle) $.ui.toast('Claude gagal bikin soal baru, pakai soal bawaan')
64    }
65    const t = puzzle ?? newTts()
66    await setTts($, () => t)
67    await $.store.set('ttsRecent', [...recent, ...t.words.map(w => w.answer)].slice(-60))
68  } finally {
69    await update($, ttsLoading, () => false)
70  }
71}
72
73const TITLE: Record<Game, string> = { sudoku: 'Sudoku', tts: 'TTS', snake: 'Snake' }
74const ROWS: Record<Game, number> = { sudoku: 18, tts: 22, snake: 17 }
75const isGame = (x: string): x is Game => x in TITLE
76
77const openPane = async ($: EngineInterface, which?: Game) => {
78  if (which) await setGame($, () => which)
79  const g = await read($, game)
80  if (g === 'sudoku' && !(await read($, sudoku))) await setSudoku($, () => newSudoku())
81  if (g === 'tts' && !(await read($, tts))) void startTts($).catch(() => {})
82  return $.ui.open({ id: PANE, title: TITLE[g], focus: true, closeOnEscape: true, rows: ROWS[g] })
83}
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    await $.command.register({ name: 'sudoku', description: 'Play Sudoku in a pane' })
88    await $.command.register({ name: 'tts', description: 'Main teka-teki silang (TTS) di pane' })
89    await $.command.register({ name: 'snake', description: 'Play Nokia-style Snake in a pane' })
90    await $.command.register({
91      name: 'puzzle',
92      description: 'Puzzle while Claude cooks: on | off | sudoku | tts | snake | ai on | ai off',
93      argumentHint: 'on | off | sudoku | tts | snake | ai on | ai off',
94    })
95    await update($, cooking, () => false)
96    await update($, ttsLoading, () => false)
97    // put back what an earlier session saved
98    const was = async (key: string) => $.store.get(key).catch(() => undefined)
99    const savedGame = await was('game')
100    if (typeof savedGame === 'string' && isGame(savedGame)) await update($, game, () => savedGame)
101    const savedAuto = await was('auto')
102    if (typeof savedAuto === 'boolean') await update($, auto, () => savedAuto)
103    const savedAi = await was('ttsAi')
104    if (typeof savedAi === 'boolean') await update($, ttsAi, () => savedAi)
105    const savedBest = await was('snakeBest')
106    if (typeof savedBest === 'number') await update($, snakeBest, cur => Math.max(cur, savedBest))
107    const savedSudoku = await was('sudoku')
108    if (savedSudoku && !(await read($, sudoku))) await update($, sudoku, () => savedSudoku as Sudoku)
109    const savedTts = await was('tts')
110    if (savedTts && !(await read($, tts))) await update($, tts, () => savedTts as Tts)
111
112    return next(e)
113  })
114
115  on('command.run', { command: 'sudoku' }, async $ => {
116    await openPane($, 'sudoku')
117    return { text: 'Sudoku opened. w/a/s/d move, 1-9 fill, 0 erase, Esc to leave.' }
118  })
119
120  on('command.run', { command: 'tts' }, async $ => {
121    await openPane($, 'tts')
122    return { text: 'TTS dibuka. Ketik jawaban lalu Enter; Tab untuk pilih soal.' }
123  })
124
125  on('command.run', { command: 'snake' }, async $ => {
126    await openPane($, 'snake')
127    return { text: 'Snake opened. Click the board, then arrows/WASD. Space pauses, Esc leaves.' }
128  })
129
130  // the snake board posts a new best score when a game ends
131  on('ui.message', { requestId: PANE }, async ($, e, next) => {
132    const best = (e.data as { best?: unknown } | null)?.best
133    if (typeof best === 'number') await setBest($, cur => Math.max(cur, best))
134    return next(e)
135  })
136
137  on('command.run', { command: 'puzzle' }, async ($, e) => {
138    const arg = e.args.trim().toLowerCase()
139    if (arg === 'on' || arg === 'off') {
140      await setAuto($, () => arg === 'on')
141      return { text: `Auto-open while Claude cooks: ${arg}` }
142    }
143    if (arg === 'ai on' || arg === 'ai off') {
144      await setAi($, () => arg === 'ai on')
145      return { text: `TTS words written by Claude: ${arg.slice(3)}` }
146    }
147    if (isGame(arg)) {
148      await setGame($, () => arg)
149      return { text: `Auto-open game: ${TITLE[arg]}` }
150    }
151    const [isOn, g] = [await read($, auto), await read($, game)]
152    return { text: `Auto-open: ${isOn ? 'on' : 'off'}, game: ${TITLE[g]}. TTS by Claude: ${(await read($, ttsAi)) ? 'on' : 'off'}. Usage: /puzzle on|off|sudoku|tts|snake|ai on|ai off` }
153  })
154
155  // Claude starts cooking: open the puzzle.
156  on('prompt.submit', async ($, e, next) => {
157    const result = await next(e)
158    if (e.origin.kind === 'composer' && !e.text.startsWith('/') && !('drop' in result)) {
159      // never let the game get in the way of the prompt
160      try {
161        await update($, cooking, () => true)
162        // auto off: stay quiet; the games still open by command
163        if (await read($, auto)) {
164          $.ui.status('🍳 Claude is cooking… puzzle time')
165          void openPane($).catch(() => {})
166        }
167      } catch {}
168    }
169    return result
170  })
171
172  // Claude is done: tell the player.
173  on('turn.complete', async ($, e, next) => {
174    if (await read($, cooking)) {
175      await update($, cooking, () => false)
176      $.ui.status(undefined)
177      const isUp = (await $.ui.panes()).some(p => p.id === PANE)
178      if (isUp) $.ui.toast('🍽️  Claude is done! Esc to return to the prompt (your game is saved)')
179    }
180    return next(e)
181  })
182
183  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
184    const els = $.ui.resolve(e)
185    const { Box, Text, Button } = els
186    const Input = 'Input' in els ? els.Input : undefined
187    const g = await read($, game)
188    const isCooking = await read($, cooking)
189
190    const banner = (
191      <Text color={isCooking ? 'warning' : 'success'}>
192        {isCooking ? '🍳 Claude is cooking…' : '✅ Claude is done — Esc to go back'}
193      </Text>
194    )
195    const switcher = (
196      <Button
197        key="switch"
198        plain
199        hotkey="g"
200        label={g === 'sudoku' ? 'switch to TTS' : 'switch to Sudoku'}
201        onPress={() => openPane($, g === 'sudoku' ? 'tts' : 'sudoku')}
202      />
203    )
204
205    if (g === 'snake') {
206      const Client = 'Client' in els ? els.Client : undefined
207      return (
208        <Box flexDirection="column">
209          {banner}
210          {Client ? (
211            <Client key="snake" module="./snake.tsx" props={{ best: await read($, snakeBest) }} />
212          ) : (
213            <Text dimColor>Snake needs the terminal or the desktop app.</Text>
214          )}
215          <Box flexDirection="row">
216            <Button key="tosudoku" label="Sudoku" onPress={() => openPane($, 'sudoku')} />
217            <Text> </Text>
218            <Button key="totts" label="TTS" onPress={() => openPane($, 'tts')} />
219          </Box>
220        </Box>
221      )
222    }
223
224    if (g === 'sudoku') {
225      const s = await read($, sudoku)
226      if (!s) {
227        return (
228          <Box flexDirection="column">
229            {banner}
230            <Button key="start" autoFocus hotkey="n" label="Start Sudoku" onPress={() => setSudoku($, () => newSudoku())} />
231          </Box>
232        )
233      }
234      const set = (fn: (s: Sudoku) => Sudoku) => setSudoku($, cur => (cur ? fn(cur) : cur))
235      const move = (dr: number, dc: number) => set(x => ({ ...x, cursor: moveCursor(x.cursor, dr, dc) }))
236      const put = (d: number) =>
237        set(x => {
238          if (x.puzzle[x.cursor] !== 0) return x
239          const board = [...x.board]
240          board[x.cursor] = d
241          return { ...x, board }
242        })
243      const isDone = isSudokuSolved(s)
244      const filled = s.board.filter(Boolean).length
245
246      const rule = (l: string, m: string, r: string) => (
247        <Text dimColor>{l + '───────' + m + '───────' + m + '───────' + r}</Text>
248      )
249      const row = (r: number) => (
250        <Box key={`r${r}`} flexDirection="row">
251          {[...Array(9).keys()].flatMap(c => {
252            const i = r * 9 + c
253            const d = s.board[i]
254            const isGiven = s.puzzle[i] !== 0
255            const isWrong = !isGiven && d !== 0 && d !== s.solution[i]
256            const parts = []
257            if (c % 3 === 0) parts.push(<Text dimColor>{c === 0 ? '│' : ' │'}</Text>)
258            parts.push(<Text> </Text>)
259            parts.push(
260              <Text
261                bold={isGiven}
262                color={isWrong ? 'error' : isGiven ? undefined : 'suggestion'}
263                inverse={i === s.cursor && !isDone}
264              >
265                {d === 0 ? '·' : String(d)}
266              </Text>,
267            )
268            if (c === 8) parts.push(<Text dimColor> │</Text>)
269            return parts
270          })}
271        </Box>
272      )
273
274      return (
275        <Box flexDirection="column">
276          {banner}
277          <Box flexDirection="row">
278            <Box flexDirection="column" marginRight={2}>
279              {rule('┌', '┬', '┐')}
280              {row(0)}{row(1)}{row(2)}
281              {rule('├', '┼', '┤')}
282              {row(3)}{row(4)}{row(5)}
283              {rule('├', '┼', '┤')}
284              {row(6)}{row(7)}{row(8)}
285              {rule('└', '┴', '┘')}
286            </Box>
287            <Box flexDirection="column">
288              {isDone ? <Text color="success" bold>🎉 Solved!</Text> : <Text dimColor>{filled}/81 filled</Text>}
289              <Text> </Text>
290              <Box flexDirection="row">
291                <Button key="up" plain hotkey="w" label="↑" onPress={() => move(-1, 0)} />
292                <Text> </Text>
293                <Button key="left" plain hotkey="a" label="←" onPress={() => move(0, -1)} />
294                <Text> </Text>
295                <Button key="down" plain hotkey="s" label="↓" onPress={() => move(1, 0)} />
296                <Text> </Text>
297                <Button key="right" plain hotkey="d" label="→" onPress={() => move(0, 1)} />
298              </Box>
299              <Box flexDirection="row">
300                {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(d => (
301                  <Button key={`n${d}`} hotkey={String(d)} label={String(d)} onPress={() => put(d)} />
302                ))}
303              </Box>
304              <Button key="erase" plain hotkey="0" label="erase" onPress={() => put(0)} />
305              <Button key="new" plain hotkey="n" label="new puzzle" onPress={() => setSudoku($, () => newSudoku())} />
306              {switcher}
307              <Button key="tosnake" plain hotkey="k" label="play Snake" onPress={() => openPane($, 'snake')} />
308              <Text> </Text>
309              <Text dimColor>Press the keys shown.</Text>
310              <Text dimColor>Ctrl+X Tab focuses the pane.</Text>
311            </Box>
312          </Box>
313        </Box>
314      )
315    }
316
317    // ---- TTS ----
318    const t = await read($, tts)
319    if (!t) {
320      return (
321        <Box flexDirection="column">
322          {banner}
323          {(await read($, ttsLoading)) ? (
324            <Text color="claude">✍️  Claude lagi bikin TTS baru…</Text>
325          ) : (
326            <Button key="start" autoFocus hotkey="n" label="Mulai TTS" onPress={() => startTts($)} />
327          )}
328        </Box>
329      )
330    }
331    const text = await read($, draft)
332    const sel = t.words[t.sel]
333
334    const shown = new Map<string, string>()
335    const starts = new Map<string, number>()
336    const inSel = new Set<string>()
337    for (const w of t.words) {
338      wordCells(w).forEach((x, k) => {
339        const id = `${x.r},${x.c}`
340        if (w.solved || k < w.hints) shown.set(id, x.ch)
341        if (!shown.has(id)) shown.set(id, '')
342        if (k === 0) starts.set(id, w.n)
343        if (w === sel) inSel.add(id)
344      })
345    }
346    const isAllDone = t.words.every(w => w.solved)
347    const isMaking = await read($, ttsLoading)
348
349    const grid = [...Array(t.rows).keys()].map(r => (
350      <Box key={`g${r}`} flexDirection="row">
351        {[...Array(t.cols).keys()].map(c => {
352          const id = `${r},${c}`
353          if (!shown.has(id)) return <Text>{'  '}</Text>
354          const letter = shown.get(id)
355          const n = starts.get(id)
356          const glyph = letter || (n !== undefined && n < 10 ? String(n) : '·')
357          return (
358            <Text
359              color={letter ? 'success' : undefined}
360              dimColor={!letter && n === undefined}
361              inverse={inSel.has(id) && !isAllDone}
362            >
363              {' ' + glyph}
364            </Text>
365          )
366        })}
367      </Box>
368    ))
369
370    const clueButton = (i: number) => {
371      const w = t.words[i]
372      return (
373        <Button
374          key={`clue${i}`}
375          plain
376          dimColor={w.solved}
377          label={`${i === t.sel ? '▶' : ' '} ${w.n}. ${w.clue} (${w.answer.length})${w.solved ? ' ✓' : ''}`}
378          onPress={() => setTts($, cur => (cur ? { ...cur, sel: i } : cur))}
379        />
380      )
381    }
382    const across = t.words.map((w, i) => (w.dir === 'across' ? i : -1)).filter(i => i >= 0)
383    const down = t.words.map((w, i) => (w.dir === 'down' ? i : -1)).filter(i => i >= 0)
384
385    const submit = async (value: string) => {
386      await update($, draft, () => '')
387      const cur = await read($, tts)
388      if (!cur) return
389      if (value.trim() === '') {
390        const next = nextUnsolved(cur, cur.sel)
391        if (next >= 0) await setTts($, () => ({ ...cur, sel: next }))
392        return
393      }
394      const { tts: after, isRight } = answerTts(cur, value)
395      if (isRight) await setTts($, () => after)
396      else $.ui.toast(`❌ "${value}" belum tepat, coba lagi`)
397    }
398
399    return (
400      <Box flexDirection="column">
401        {banner}
402        <Text dimColor>{t.theme ? `Tema: ${t.theme} (dibuat Claude)` : 'Soal bawaan'}</Text>
403        {isMaking && <Text color="claude">✍️  Claude lagi bikin TTS baru…</Text>}
404        <Box flexDirection="row">
405          <Box flexDirection="column" marginRight={3}>
406            {grid}
407          </Box>
408          <Box flexDirection="column" flexGrow={1}>
409            <Text bold>Mendatar</Text>
410            {across.map(clueButton)}
411            <Text bold>Menurun</Text>
412            {down.map(clueButton)}
413          </Box>
414        </Box>
415        {isAllDone ? (
416          <Text color="success" bold>🎉 Semua terjawab!</Text>
417        ) : !Input ? (
418          <Text dimColor>Typing answers needs the terminal or desktop.</Text>
419        ) : (
420          <Input
421            key="answer"
422            autoFocus
423            label={`${sel.n} ${sel.dir === 'across' ? 'mendatar' : 'menurun'}: `}
424            placeholder="ketik jawaban, Enter (kosong = soal berikutnya)"
425            value={text}
426            submitLabel="jawab"
427            onInput={(value: string) => update($, draft, () => value)}
428            onSubmit={(value: string) => void submit(value)}
429          />
430        )}
431        <Box flexDirection="row">
432          <Button key="hint" label="petunjuk" onPress={() => setTts($, cur => (cur ? hintTts(cur) : cur))} />
433          <Text> </Text>
434          <Button key="newtts" label="TTS baru" onPress={() => startTts($)} />
435          <Text> </Text>
436          <Button key="switch" label="Sudoku" onPress={() => openPane($, 'sudoku')} />
437          <Text> </Text>
438          <Button key="tosnake" label="Snake" onPress={() => openPane($, 'snake')} />
439        </Box>
440      </Box>
441    )
442  })
443}
444
hooks/games.ts 306 lines
1import type { Sudoku, Tts, Word } from '../types'
2
3const shuffle = <T>(list: T[]): T[] => {
4  const out = [...list]
5  for (let i = out.length - 1; i > 0; i--) {
6    const j = Math.floor(Math.random() * (i + 1))
7    ;[out[i], out[j]] = [out[j], out[i]]
8  }
9  return out
10}
11
12// ---------- Sudoku ----------
13
14const canPlace = (b: number[], i: number, d: number): boolean => {
15  const r = Math.floor(i / 9)
16  const c = i % 9
17  const br = r - (r % 3)
18  const bc = c - (c % 3)
19  for (let k = 0; k < 9; k++) {
20    if (b[r * 9 + k] === d || b[k * 9 + c] === d) return false
21    if (b[(br + Math.floor(k / 3)) * 9 + bc + (k % 3)] === d) return false
22  }
23  return true
24}
25
26const fill = (b: number[]): boolean => {
27  const i = b.indexOf(0)
28  if (i < 0) return true
29  for (const d of shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9])) {
30    if (canPlace(b, i, d)) {
31      b[i] = d
32      if (fill(b)) return true
33    }
34  }
35  b[i] = 0
36  return false
37}
38
39export const countSolutions = (b: number[], limit = 2): number => {
40  const i = b.indexOf(0)
41  if (i < 0) return 1
42  let count = 0
43  for (let d = 1; d <= 9 && count < limit; d++) {
44    if (canPlace(b, i, d)) {
45      b[i] = d
46      count += countSolutions(b, limit - count)
47    }
48  }
49  b[i] = 0
50  return count
51}
52
53/** A puzzle with exactly one solution and up to `holes` empty cells. */
54export const newSudoku = (holes = 46): Sudoku => {
55  const solution = Array<number>(81).fill(0)
56  fill(solution)
57  const puzzle = [...solution]
58  let removed = 0
59  for (const i of shuffle([...Array(81).keys()])) {
60    if (removed >= holes) break
61    const keep = puzzle[i]
62    puzzle[i] = 0
63    if (countSolutions([...puzzle]) === 1) removed++
64    else puzzle[i] = keep
65  }
66  return { puzzle, solution, board: [...puzzle], cursor: puzzle.indexOf(0) }
67}
68
69export const isSudokuSolved = (s: Sudoku): boolean =>
70  s.board.every((d, i) => d === s.solution[i])
71
72export const moveCursor = (cursor: number, dr: number, dc: number): number => {
73  const r = (Math.floor(cursor / 9) + dr + 9) % 9
74  const c = ((cursor % 9) + dc + 9) % 9
75  return r * 9 + c
76}
77
78// ---------- TTS (teka-teki silang) ----------
79
80export type Entry = { answer: string; clue: string }
81
82export const BANK: Entry[] = [
83  { answer: 'KUCING', clue: 'Hewan peliharaan yang suka mengeong' },
84  { answer: 'NASI', clue: 'Makanan pokok orang Indonesia' },
85  { answer: 'GORENG', clue: 'Cara memasak dengan minyak panas' },
86  { answer: 'SAMBAL', clue: 'Pelengkap pedas dari cabai' },
87  { answer: 'RENDANG', clue: 'Masakan daging khas Minang' },
88  { answer: 'SATE', clue: 'Daging tusuk yang dibakar' },
89  { answer: 'BAKSO', clue: 'Bola daging berkuah' },
90  { answer: 'TEMPE', clue: 'Olahan kedelai fermentasi' },
91  { answer: 'KOPI', clue: 'Minuman hitam berkafein' },
92  { answer: 'TEH', clue: 'Minuman dari daun yang diseduh' },
93  { answer: 'GARAM', clue: 'Bumbu asin dari laut' },
94  { answer: 'GULA', clue: 'Pemanis dari tebu' },
95  { answer: 'WAJAN', clue: 'Alat untuk menggoreng' },
96  { answer: 'DAPUR', clue: 'Ruang tempat memasak' },
97  { answer: 'KOKI', clue: 'Juru masak profesional' },
98  { answer: 'JAKARTA', clue: 'Ibu kota Indonesia (lama)' },
99  { answer: 'BALI', clue: 'Pulau Dewata' },
100  { answer: 'MONAS', clue: 'Tugu ikonik di Jakarta' },
101  { answer: 'GARUDA', clue: 'Lambang negara Indonesia' },
102  { answer: 'BATIK', clue: 'Kain bermotif warisan budaya' },
103  { answer: 'ANGKLUNG', clue: 'Alat musik bambu dari Jawa Barat' },
104  { answer: 'GAMELAN', clue: 'Ansambel musik tradisional Jawa' },
105  { answer: 'WAYANG', clue: 'Pertunjukan boneka kulit' },
106  { answer: 'KOMODO', clue: 'Kadal raksasa dari NTT' },
107  { answer: 'GAJAH', clue: 'Hewan berbelalai' },
108  { answer: 'HARIMAU', clue: 'Kucing besar belang dari Sumatra' },
109  { answer: 'BURUNG', clue: 'Hewan bersayap dan berbulu' },
110  { answer: 'IKAN', clue: 'Hewan yang bernapas dengan insang' },
111  { answer: 'LAUT', clue: 'Perairan luas dan asin' },
112  { answer: 'GUNUNG', clue: 'Tanah yang menjulang tinggi' },
113  { answer: 'HUJAN', clue: 'Air yang turun dari langit' },
114  { answer: 'MATAHARI', clue: 'Bintang pusat tata surya' },
115  { answer: 'BULAN', clue: 'Satelit alami bumi' },
116  { answer: 'BINTANG', clue: 'Berkelip di langit malam' },
117  { answer: 'PELANGI', clue: 'Lengkung warna-warni setelah hujan' },
118  { answer: 'SEKOLAH', clue: 'Tempat belajar murid' },
119  { answer: 'GURU', clue: 'Pahlawan tanpa tanda jasa' },
120  { answer: 'BUKU', clue: 'Jendela dunia' },
121  { answer: 'PENSIL', clue: 'Alat tulis dari grafit' },
122  { answer: 'KOMPUTER', clue: 'Mesin hitung elektronik' },
123  { answer: 'KODE', clue: 'Yang ditulis programmer' },
124  { answer: 'BUG', clue: 'Kesalahan dalam program' },
125  { answer: 'MERDEKA', clue: 'Bebas dari penjajahan' },
126  { answer: 'RUPIAH', clue: 'Mata uang Indonesia' },
127  { answer: 'SEPEDA', clue: 'Kendaraan roda dua dikayuh' },
128  { answer: 'KERETA', clue: 'Kendaraan di atas rel' },
129  { answer: 'PANTAI', clue: 'Tepi laut berpasir' },
130  { answer: 'DURIAN', clue: 'Raja buah berduri' },
131  { answer: 'MANGGA', clue: 'Buah manis, harum manis salah satunya' },
132  { answer: 'PISANG', clue: 'Buah kuning kesukaan monyet' },
133]
134
135type Placed = { r: number; c: number; dir: Word['dir']; answer: string; clue: string }
136
137const MAX = 15
138
139const cellsOf = (p: Placed) =>
140  [...p.answer].map((ch, k) => ({
141    r: p.r + (p.dir === 'down' ? k : 0),
142    c: p.c + (p.dir === 'across' ? k : 0),
143    ch,
144  }))
145
146/** Letter at each cell, keyed "r,c". */
147const gridOf = (placed: Placed[]) => {
148  const grid = new Map<string, string>()
149  for (const p of placed) for (const x of cellsOf(p)) grid.set(`${x.r},${x.c}`, x.ch)
150  return grid
151}
152
153const fits = (placed: Placed[], grid: Map<string, string>, p: Placed): number => {
154  const at = (r: number, c: number) => grid.get(`${r},${c}`)
155  const [dr, dc] = p.dir === 'across' ? [0, 1] : [1, 0]
156  const len = p.answer.length
157  if (at(p.r - dr, p.c - dc) || at(p.r + dr * len, p.c + dc * len)) return -1
158  let crossings = 0
159  for (const x of cellsOf(p)) {
160    const here = at(x.r, x.c)
161    if (here) {
162      if (here !== x.ch) return -1
163      crossings++
164    } else if (at(x.r + dc, x.c + dr) || at(x.r - dc, x.c - dr)) {
165      return -1
166    }
167  }
168  if (crossings === 0 || crossings === len) return -1
169  // a crossing must cross a word of the other direction
170  for (const q of placed) {
171    if (q.dir !== p.dir) continue
172    for (const x of cellsOf(q)) if (cellsOf(p).some(y => y.r === x.r && y.c === x.c)) return -1
173  }
174  // keep within MAX×MAX
175  const all = [...grid.keys()].map(k => k.split(',').map(Number))
176  for (const x of cellsOf(p)) all.push([x.r, x.c])
177  const rs = all.map(a => a[0])
178  const cs = all.map(a => a[1])
179  if (Math.max(...rs) - Math.min(...rs) >= MAX || Math.max(...cs) - Math.min(...cs) >= MAX) return -1
180  return crossings
181}
182
183export const newTts = (count = 8, bank: Entry[] = BANK): Tts => {
184  for (let attempt = 0; ; attempt++) {
185    const pool = shuffle(bank)
186    const first = pool.shift()!
187    const placed: Placed[] = [{ r: 0, c: 0, dir: 'across', ...first }]
188    for (const w of pool) {
189      if (placed.length >= count) break
190      const grid = gridOf(placed)
191      const options: { p: Placed; score: number }[] = []
192      for (const [key, ch] of grid) {
193        const [r, c] = key.split(',').map(Number)
194        for (let k = 0; k < w.answer.length; k++) {
195          if (w.answer[k] !== ch) continue
196          for (const dir of ['across', 'down'] as const) {
197            const p: Placed = {
198              r: dir === 'down' ? r - k : r,
199              c: dir === 'across' ? c - k : c,
200              dir,
201              ...w,
202            }
203            const score = fits(placed, grid, p)
204            if (score > 0) options.push({ p, score })
205          }
206        }
207      }
208      if (options.length === 0) continue
209      const best = Math.max(...options.map(o => o.score))
210      const top = options.filter(o => o.score === best)
211      placed.push(top[Math.floor(Math.random() * top.length)].p)
212    }
213    if (placed.length < Math.min(count, 5) && attempt < 20) continue
214    return layout(placed)
215  }
216}
217
218const layout = (placed: Placed[]): Tts => {
219  const minR = Math.min(...placed.flatMap(p => cellsOf(p).map(x => x.r)))
220  const minC = Math.min(...placed.flatMap(p => cellsOf(p).map(x => x.c)))
221  const moved = placed.map(p => ({ ...p, r: p.r - minR, c: p.c - minC }))
222  const starts = [...new Set(moved.map(p => p.r * 100 + p.c))].sort((a, b) => a - b)
223  const words: Word[] = moved
224    .map(p => ({ ...p, n: starts.indexOf(p.r * 100 + p.c) + 1, solved: false, hints: 0 }))
225    .sort((a, b) => a.n - b.n || (a.dir === 'across' ? -1 : 1))
226  const rows = Math.max(...words.flatMap(w => cellsOf(w).map(x => x.r))) + 1
227  const cols = Math.max(...words.flatMap(w => cellsOf(w).map(x => x.c))) + 1
228  return { rows, cols, words, sel: 0 }
229}
230
231export const wordCells = (w: Word) => cellsOf(w)
232
233export const normalize = (text: string) => text.toUpperCase().replace(/[^A-Z]/g, '')
234
235/** Index of the next unsolved word after `from`, or -1 when all are solved. */
236export const nextUnsolved = (t: Tts, from: number): number => {
237  for (let k = 1; k <= t.words.length; k++) {
238    const i = (from + k) % t.words.length
239    if (!t.words[i].solved) return i
240  }
241  return -1
242}
243
244/** Answer the selected word; returns the new state and whether it was right. */
245export const answerTts = (t: Tts, text: string): { tts: Tts; isRight: boolean } => {
246  const w = t.words[t.sel]
247  if (!w || normalize(text) !== w.answer) return { tts: t, isRight: false }
248  const words = t.words.map((x, i) => (i === t.sel ? { ...x, solved: true } : x))
249  const solved = { ...t, words }
250  const next = nextUnsolved(solved, t.sel)
251  return { tts: { ...solved, sel: next < 0 ? t.sel : next }, isRight: true }
252}
253
254export const hintTts = (t: Tts): Tts => ({
255  ...t,
256  words: t.words.map((w, i) => {
257    if (i !== t.sel || w.solved) return w
258    const hints = w.hints + 1
259    return { ...w, hints, solved: hints >= w.answer.length }
260  }),
261})
262
263// ---------- TTS words written by Claude ----------
264
265const THEMES = [
266  'makanan & minuman', 'hewan', 'alam & cuaca', 'budaya Indonesia', 'kota & tempat di Indonesia',
267  'teknologi & pemrograman', 'olahraga', 'pekerjaan', 'rumah & benda sehari-hari', 'musik & film',
268  'sains', 'tubuh & kesehatan', 'transportasi', 'sekolah', 'buah & sayur', 'sejarah Indonesia',
269]
270
271export const pickTheme = () => THEMES[Math.floor(Math.random() * THEMES.length)]!
272
273export const ttsPrompt = (theme: string, avoid: string[]) =>
274  `Buat 16 kata untuk teka-teki silang (TTS) berbahasa Indonesia dengan tema "${theme}".
275Aturan:
276- Jawaban: satu kata bahasa Indonesia, huruf A-Z saja, 3 sampai 9 huruf, tanpa spasi atau tanda hubung.
277- Petunjuk: singkat (maksimal 8 kata), jelas, tidak menyebut jawabannya.
278- Campur kata pendek dan panjang, dengan banyak huruf vokal yang umum agar mudah disilangkan.
279${avoid.length ? `- Jangan pakai kata-kata ini: ${avoid.join(', ')}\n` : ''}Balas HANYA dengan array JSON, tanpa teks lain:
280[{"answer":"KUCING","clue":"Hewan peliharaan yang suka mengeong"}]`
281
282/** The entries a model reply holds, the bad ones dropped. */
283export const parseEntries = (text: string): Entry[] => {
284  const start = text.indexOf('[')
285  const end = text.lastIndexOf(']')
286  if (start < 0 || end <= start) return []
287  let raw: unknown
288  try {
289    raw = JSON.parse(text.slice(start, end + 1))
290  } catch {
291    return []
292  }
293  if (!Array.isArray(raw)) return []
294  const seen = new Set<string>()
295  const out: Entry[] = []
296  for (const item of raw) {
297    const answer = typeof item?.answer === 'string' ? item.answer.trim().toUpperCase() : ''
298    const clue = typeof item?.clue === 'string' ? item.clue.trim().slice(0, 80) : ''
299    if (!/^[A-Z]{3,9}$/.test(answer) || !clue || seen.has(answer)) continue
300    if (clue.toUpperCase().includes(answer)) continue
301    seen.add(answer)
302    out.push({ answer, clue })
303  }
304  return out
305}
306
hooks/snake.tsx 161 lines
1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3// Nokia 3310 screen colors
4const LCD = '#c7f0d8'
5const INK = '#43523d'
6
7export const W = 20
8export const H = 10
9const TICK = 40 // ms per frame; the snake moves every few frames
10
11type Dir = 'up' | 'down' | 'left' | 'right'
12type Cell = { x: number; y: number }
13
14export type SnakeState = {
15  snake: Cell[] // head first
16  dir: Dir
17  queue: Dir[] // turns typed between moves
18  food: Cell
19  score: number
20  mode: 'ready' | 'playing' | 'paused' | 'over'
21  live: { frames: number; best: number } // mutated in place: no redraw needed
22}
23
24export type SnakeProps = { best: number }
25
26const STEP: Record<Dir, Cell> = {
27  up: { x: 0, y: -1 },
28  down: { x: 0, y: 1 },
29  left: { x: -1, y: 0 },
30  right: { x: 1, y: 0 },
31}
32const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
33const KEYS: Record<string, Dir> = {
34  up: 'up', down: 'down', left: 'left', right: 'right',
35  w: 'up', s: 'down', a: 'left', d: 'right',
36  k: 'up', j: 'down', h: 'left', l: 'right',
37}
38
39const randomFood = (snake: Cell[]): Cell => {
40  for (;;) {
41    const cell = { x: Math.floor(Math.random() * W), y: Math.floor(Math.random() * H) }
42    if (!snake.some(s => s.x === cell.x && s.y === cell.y)) return cell
43  }
44}
45
46export const newGame = (): SnakeState => {
47  const snake = [{ x: 6, y: 5 }, { x: 5, y: 5 }, { x: 4, y: 5 }]
48  return { snake, dir: 'right', queue: [], food: randomFood(snake), score: 0, mode: 'ready', live: { frames: 0, best: 0 } }
49}
50
51/** Frames between moves: 4 (160ms) at the start, 2 (80ms) at full speed. */
52const framesPerMove = (score: number) => Math.max(2, 4 - Math.floor(score / 5))
53
54/** One move of the snake. Walls wrap around, as in Snake II. */
55export const step = (s: SnakeState): SnakeState => {
56  const [turn, ...queue] = s.queue
57  const dir = turn ?? s.dir
58  const head = s.snake[0]!
59  const next = {
60    x: (head.x + STEP[dir].x + W) % W,
61    y: (head.y + STEP[dir].y + H) % H,
62  }
63  const eats = next.x === s.food.x && next.y === s.food.y
64  const body = eats ? s.snake : s.snake.slice(0, -1)
65  if (body.some(c => c.x === next.x && c.y === next.y)) {
66    return { ...s, dir, queue: [], mode: 'over' }
67  }
68  const snake = [next, ...body]
69  return {
70    ...s,
71    snake,
72    dir,
73    queue,
74    food: eats ? randomFood(snake) : s.food,
75    score: eats ? s.score + 1 : s.score,
76  }
77}
78
79/** What a key does to the game. */
80export const press = (s: SnakeState, e: ClientKeyEvent): SnakeState => {
81  const key = e.key.toLowerCase()
82  if (key === 'r' || ((key === 'space' || key === ' ' || key === 'return') && s.mode === 'over')) {
83    return { ...newGame(), mode: 'playing', live: s.live }
84  }
85  if (key === 'space' || key === ' ' || key === 'p' || key === 'return') {
86    return { ...s, mode: s.mode === 'playing' ? 'paused' : s.mode === 'over' ? s.mode : 'playing' }
87  }
88  const dir = KEYS[key]
89  if (!dir || s.mode === 'over') return s
90  const last = s.queue[s.queue.length - 1] ?? s.dir
91  if (dir === last || dir === OPPOSITE[last] || s.queue.length >= 2) {
92    return s.mode === 'playing' ? s : { ...s, mode: 'playing' }
93  }
94  return { ...s, queue: [...s.queue, dir], mode: 'playing' }
95}
96
97const Snake: ClientModule<SnakeProps, SnakeState> = (props, surface) => {
98  const { Box, Text } = surface.elements
99  const s = surface.state ?? newGame()
100  s.live.best = props.best
101
102  if (surface.state === undefined) {
103    surface.setState(s)
104    surface.onKey(e => {
105      const cur = surface.state ?? s
106      const after = press(cur, e)
107      if (after !== cur) surface.setState(after)
108    })
109    surface.onPointer(e => {
110      const cur = surface.state ?? s
111      if (e.type === 'down' && cur.mode !== 'playing') {
112        surface.setState(cur.mode === 'over' ? { ...newGame(), mode: 'playing', live: cur.live } : { ...cur, mode: 'playing' })
113      }
114    })
115    surface.every(TICK, () => {
116      const cur = surface.state
117      if (!cur || cur.mode !== 'playing') return
118      cur.live.frames += 1
119      if (cur.live.frames % framesPerMove(cur.score) !== 0) return
120      const after = step(cur)
121      surface.setState(after)
122      if (after.mode === 'over' && after.score > cur.live.best) surface.post({ best: after.score })
123    })
124  }
125
126  const rows: string[] = []
127  for (let y = 0; y < H; y++) {
128    let line = ''
129    for (let x = 0; x < W; x++) {
130      const isHead = s.snake[0]!.x === x && s.snake[0]!.y === y
131      const isBody = s.snake.some(c => c.x === x && c.y === y)
132      const isFood = s.food.x === x && s.food.y === y
133      line += isHead ? '▓▓' : isBody ? '██' : isFood ? '◆ ' : '  '
134    }
135    rows.push(line)
136  }
137
138  const status =
139    s.mode === 'ready' ? 'Arrows/WASD to start · click here first'
140    : s.mode === 'paused' ? 'Paused · space to resume'
141    : s.mode === 'over' ? `Game over! Score ${s.score} · space to restart`
142    : 'Space pauses · r restarts'
143
144  return (
145    <Box flexDirection="column">
146      <Box flexDirection="row" width={W * 2 + 2} justifyContent="space-between">
147        <Text bold>{String(s.score).padStart(4, '0')}</Text>
148        <Text dimColor>best {Math.max(props.best, s.mode === 'over' ? s.score : 0)}</Text>
149      </Box>
150      <Box flexDirection="column" borderStyle="bold" borderColor={INK} width={W * 2 + 2}>
151        {rows.map(line => (
152          <Text color={INK} backgroundColor={LCD}>{line}</Text>
153        ))}
154      </Box>
155      <Text dimColor={s.mode !== 'over'} color={s.mode === 'over' ? 'error' : undefined}>{status}</Text>
156    </Box>
157  )
158}
159
160export default Snake
161
types/index.d.ts 38 lines
1export type Game = 'sudoku' | 'tts' | 'snake'
2
3export type Sudoku = {
4  puzzle: number[]
5  solution: number[]
6  board: number[]
7  cursor: number
8}
9
10export type Word = {
11  n: number
12  r: number
13  c: number
14  dir: 'across' | 'down'
15  answer: string
16  clue: string
17  solved: boolean
18  hints: number
19}
20
21export type Tts = { rows: number; cols: number; words: Word[]; sel: number; theme?: string }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'cooking-puzzles': {
26      game: Game
27      sudoku: Sudoku | null
28      tts: Tts | null
29      auto: boolean
30      cooking: boolean
31      draft: string
32      snakeBest: number
33      ttsAi: boolean
34      ttsLoading: boolean
35    }
36  }
37}
38