SLOPSHOPPER

sudoku

Sudoku in a pane. Three levels with one solution each, move with w a s d, type a digit, undo, a hint when stuck, and a clock that pauses when the pane closes…

newpanecommandtoasttimer
v0.1.0MITupdated 2026-10-16MDmubarak786/claude-mods/mods/sudoku
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sudoku
│ ┃ Sudoku ✕ › fix the failing auth test and add an audit log call │ ┃ SUDOKU level normal filled 32/81 time 0:0 │ ┃ Move with w, a, s, d and type a digit. 0 ⏺ Read(src/auth.ts) │ ┃ clears a cell. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 5 9 . | . 4 . | 2 6 . ⎿ Added 2 lines, removed 1 line │ ┃ . 4 1 | . 6 . | 5 . . ⏺ Bash(bun test) │ ┃ . 6 8 | . . 7 | . . 1 ⎿ 3 pass, 1 fail │ ┃ ---------+---------+--------- │ ┃ . . . | . . 3 | 6 . . ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ . . 4 | . 5 . | 7 1 3 │ ┃ 1 . . | . . . | . . 2 ✻ Worked for 42s · done 4:20 PM │ ┃ ---------+---------+--------- │ ┃ . . 2 | . . . | . . . › /sudoku │ ┃ . 3 . | 6 8 . | 1 7 . │ ┃ 8 . . | 7 3 . | 4 . . │ ┃ │ ┃ w: Up a: Left s: Down d: Right │ ┃ 1: 1 2: 2 3: 3 4: 4 5: 5 6: 6 7: 7 8: │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ How to play │ │ ┃ │ 1. Every row, column and 3x3 box must │ │ ┃ │ hold 1 to 9 once. The bold digits are │ │ ┃ │ given; fill the rest. │ │ ┃ │ 2. Move the yellow cursor with w, a, s │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sudoku
SUDOKU level normal filled 32/81 time 0:00 best — · game Move with w, a, s, d and type a digit. 0 clears a cell. 5 9 . | . 4 . | 2 6 . . 4 1 | . 6 . | 5 . . . 6 8 | . . 7 | . . 1 ---------+---------+--------- . . . | . . 3 | 6 . . . . 4 | . 5 . | 7 1 3 1 . . | . . . | . . 2 ---------+---------+--------- . . 2 | . . . | . . . . 3 . | 6 8 . | 1 7 . 8 . . | 7 3 . | 4 . . w: Up a: Left s: Down d: Right 1: 1 2: 2 3: 3 4: 4 5: 5 6: 6 7: 7 8: 8 9: 9 0: Cle ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Every row, column and 3x3 box must hold 1 to 9 once. │ │ The bold digits are given; fill the rest. │ │ 2. Move the yellow cursor with w, a, s and d, then type │ │ a digit. 0 clears a cell. A digit that repeats in its │ │ row, column or box turns red. │ │ 3. u takes the last entry back. g fills the cell you are │ │ on when you are stuck. The clock runs from your first │ │ entry and pauses while the pane is closed; your fastest │ │ time per level is kept. │ │ Keys need the pane focused: click it if nothing happens. │ │ h hides this. │ ╰──────────────────────────────────────────────────────────╯ n: New puzzle u: Undo g: Hint l: Level: normal h: Hi
README

sudoku

Sudoku in a pane beside the conversation. Every puzzle is dealt from a seed and has exactly one solution; three levels leave 40, 32 or 26 givens. Move the cursor with w, a, s and d, type a digit, 0 clears, u takes an entry back, and g fills the cell you're on when you're stuck. The clock runs from your first entry to the last cell and pauses while the pane is closed; your fastest time per level survives restarts.

It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. A puzzle takes longer than most of Claude's turns, so it waits for you.

Sudoku in a pane: the last four cells, a wrong digit in red, then the grid turns green.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.

SUDOKU  level normal  filled 51/81  time 4:12  best 11:40 · game 3
51 of 81 filled. u takes an entry back, g fills the cell you are on.

 5  3  . | .  7  . | .  .  .
 6  .  . | 1  9  5 | .  .  .
 .  9  8 | .  .  . | .  6  .
---------+---------+---------
 8  .  . | .  6  . | .  .  3
 4  .  . | 8  .  3 | .  .  1
 7  .  . | .  2  . | .  .  6
---------+---------+---------
 .  6  . | .  .  . | 2  8  .
 .  .  . | 4  1  9 | .  .  5
 .  .  . | .  8  . | .  7  9

w: Up   a: Left   s: Down   d: Right
1: 1   2: 2   3: 3   4: 4   5: 5   6: 6   7: 7   8: 8   9: 9   0: Clear

n: New puzzle   u: Undo   g: Hint   l: Level: normal   h: Help   Esc closes

The grid is colored, which the text above can't show: the givens are bold, your entries take the accent color, the cell the cursor is on sits on yellow, a digit that repeats in its row, column or box sits on red, every cell holding the cursor's digit is marked, and the whole grid turns green when it's solved. Colors follow your theme.

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install sudoku@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/sudoku

How to play

  1. Run /sudoku. The pane opens with a fresh puzzle at the normal level and the cursor on the first blank cell. An untouched board shows a short how-to; h hides or shows it any time.
  2. Every row, column and 3×3 box must hold 1 to 9 once. Move the cursor with w, a, s and d (it wraps at the edges) and type a digit. 0 clears the cell. A given can't be changed.
  3. A digit that repeats in its row, column or box turns red, along with the cell it clashes with. The puzzle is solved when all 81 cells are filled with nothing red.
  4. u takes the last entry back and puts the cursor there. g fills the cell you're on from the solution and counts a hint; the status line says how many you took.
  5. The clock starts at your first entry and stops at the last cell. It pauses while the pane is closed, so stepping away costs nothing. Your fastest time at each level is kept.
  6. n deals a new puzzle at the same level. l deals at the next level, but only while the board is untouched, so one key press never throws away your entries; with entries on the board it tells you to press n first or use the command.

The keys work while the pane has the focus. If typing a digit goes into the prompt instead, click the pane first.

CommandWhat it does
/sudokuOpen the pane. Where no pane can be drawn, print how far along the puzzle is.
/sudoku newDeal a new puzzle at the current level. The records are kept.
/sudoku level easy, normal, or hardDeal a new puzzle at that level: 40, 32 or 26 givens.
/sudoku statsGames, solved, fastest time per level.

What it touches

From claude plugin validate ./mods/sudoku:

hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=sudoku}, ui.close{id=sudoku}, ui.render{component=Pane}
calls: $.clock.every (via startTicking), $.clock.now, $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
  • $.store keeps the puzzle, your entries, the undo list and the records. Every entry is saved, so a restart loses nothing but the stretch of clock since the pane last redrew. A loaded puzzle's clock waits for the next entry. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.
  • $.clock seeds each deal, times the game, and redraws the clock once a second while a game is running in an open pane. The ticker stops on a new deal, when the puzzle is solved, and when the pane closes.
  • ui.close is observed to stop the clock.
  • The generator runs in the hooks module: it deals a full grid from the seed, then blanks cells while the puzzle keeps one solution. It takes a few milliseconds.
  • No tool calls are observed, no files, processes, model calls, or network.

Tested with

  • Claude Code 2.1.301, claude plugin validate --strict and claude plugin test passing: the generator (the givens count at each level, one solution, a valid grid, the same seed dealing the same puzzle), the solver, entries, a refused given, clashes, clearing, undo and its cap, solving with the fastest time kept per level, hints, the cursor wrapping, the clock pausing and formatting, the guard, and mounted pane tests on the mock clock for the keys, the running clock, undo, a hint, solving with the toast, the level key on a touched and an untouched board, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so pausing on close is covered by the load path.
  • Not yet looked at in the Desktop app. The grid is built from fixed-width Boxes with single-character rules, the same way bingo and connect-four are, which draw correctly there.

Limitations

  • One puzzle at a time per machine. Two sessions playing at once overwrite each other; the last write wins.
  • No pencil marks. Keep candidates in your head, or take a hint.
  • The levels are set by how many givens are left, not by which solving techniques a puzzle needs, so one hard puzzle can be harder than another.
  • The digits are hotkeys, so they only reach the puzzle while the pane has the focus.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 329 lines
1// sudoku: a puzzle with one solution, in a pane.
2//
3//   /sudoku                 open the game
4//   /sudoku new             deal a new puzzle at the current level
5//   /sudoku level hard      deal a new puzzle at easy, normal (default), or hard
6//   /sudoku stats           games, solved, fastest time per level
7//
8// In the pane: w, a, s and d move the cursor, 1 to 9 write a digit, 0 clears,
9// u takes the last entry back, g fills the cursor's cell from the solution,
10// l deals at the next level (only while the board is untouched), n deals
11// again, h shows the how-to. The clock runs from the first entry to the last
12// cell and stops while the pane is closed. Nothing here calls a model, reads
13// what Claude is doing, or leaves the machine.
14
15import { atom, read, update } from 'claude-code'
16import type { Game, Level, Stats } from '../types'
17import { CELLS, DEFAULT_LEVEL, LEVELS, SIZE, at, conflicts, elapsed, filled, formatMs, hint, isGame, isLevel, moveCursor, newGame, pause, setCell, undo, type Dir } from './game'
18
19const PANE = 'sudoku'
20const EMPTY_STATS: Stats = { games: 0, solved: 0, bestMs: { easy: 0, normal: 0, hard: 0 } }
21const EMPTY: Game = { seed: 0, level: DEFAULT_LEVEL, givens: [], cells: [], solution: [], cursor: 0, moves: [], hints: 0, done: false, elapsedMs: 0, startedAt: null, stats: EMPTY_STATS }
22const game = atom({ plugin: 'sudoku', key: 'game' } as const, EMPTY)
23
24// Redraws the running clock once a second while the pane is open and a puzzle is on.
25let ticking: { cancel: () => void } | null = null
26// The how-to block: shown on an untouched board until h has set it either way for the session.
27let help: boolean | null = null
28// False after the store could not be read: then nothing is written over the
29// saved record until a later load reads it.
30let storeOk = true
31
32async function save($, g: Game) {
33  if (!storeOk) return
34  try {
35    await $.store.set('game', g)
36  } catch {
37    // The drawing still updates; the store catches up on the next change.
38  }
39}
40
41async function change($, fn: (cur: Game) => Game): Promise<Game> {
42  let out: Game | null = null
43  await update($, game, (cur) => {
44    out = fn(cur)
45    return out
46  })
47  // The empty default is never written over a saved puzzle.
48  if (out && (out as Game).seed) await save($, out)
49  return out ?? (await read($, game))
50}
51
52async function load($) {
53  let saved: unknown = undefined
54  let readable = true
55  try {
56    saved = await $.store.get('game')
57  } catch {
58    readable = false
59  }
60  storeOk = readable
61  if (isGame(saved)) {
62    // The clock waits for the next entry: a stretch cut off by a restart isn't counted.
63    await update($, game, () => ({ ...(saved as Game), startedAt: null }))
64    return
65  }
66  if (!readable) {
67    const cur = await read($, game)
68    if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }, DEFAULT_LEVEL))
69    $.ui.log('sudoku could not read its saved game; playing without saving')
70    return
71  }
72  const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 }, DEFAULT_LEVEL)
73  await update($, game, () => fresh)
74  await save($, fresh)
75}
76
77function stopTicking() {
78  if (ticking) ticking.cancel()
79  ticking = null
80}
81
82// The clock in the header only changes once a second, so a tick asks for a
83// redraw rather than changing the game; it stops itself when nothing is running.
84function startTicking($) {
85  if (ticking) return
86  ticking = $.clock.every(1000, async () => {
87    try {
88      const cur = await read($, game)
89      if (cur.done || cur.startedAt === null) {
90        stopTicking()
91        return
92      }
93      $.ui.invalidate('ui.render')
94    } catch {
95      // A missed tick just leaves the clock a second behind.
96    }
97  })
98}
99
100async function startNew($, level?: Level) {
101  stopTicking()
102  help = null
103  const now = await $.clock.now()
104  return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }, level ?? cur.level))
105}
106
107const untouched = (g: Game) => g.moves.length === 0 && g.hints === 0 && !g.done
108
109function summary(g: Game, now: number): string {
110  if (!g.seed) return 'No game yet.'
111  const time = formatMs(elapsed(g, now))
112  const state = g.done ? 'Solved on ' + g.level + ' in ' + time + '.' : filled(g.cells) + '/' + CELLS + ' filled, level ' + g.level + ', ' + time + '.'
113  return 'Sudoku: ' + state + ' /sudoku opens the pane.'
114}
115
116export function register(on) {
117  on('session.start', async ($, e, next) => {
118    help = null
119    try {
120      await load($)
121    } catch (error) {
122      $.ui.log('sudoku could not load its game: ' + error)
123    }
124    try {
125      await $.command.register({ name: 'sudoku', description: 'Play sudoku in a pane', argumentHint: '[new | level easy|normal|hard | stats]', immediate: true })
126    } catch (error) {
127      $.ui.log('could not register /sudoku: ' + error)
128    }
129    return next(e)
130  })
131
132  // /clear and /resume reset $.state; the game lives on in the store.
133  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
134    stopTicking()
135    await load($)
136    return next(e)
137  }).catch(async ($, e, next) => next(e))
138
139  on('command.run', { command: 'sudoku' }, async ($, e) => {
140    const args = e.args.trim().toLowerCase()
141    if (args === 'new') return { text: 'New puzzle. ' + summary(await startNew($), await $.clock.now()) }
142    const level = /^level\s+(\w+)$/.exec(args)
143    if (level) {
144      if (!isLevel(level[1])) return { text: 'Levels: easy, normal, hard.' }
145      return { text: 'New puzzle. ' + summary(await startNew($, level[1]), await $.clock.now()) }
146    }
147    if (args === 'stats') {
148      const s = (await read($, game)).stats
149      const best = LEVELS.map((l) => l + ' ' + (s.bestMs[l] ? formatMs(s.bestMs[l]) : '—')).join(', ')
150      return { text: 'Games ' + s.games + ' · solved ' + s.solved + ' · fastest: ' + best }
151    }
152    if (args) return { text: 'Usage: /sudoku, /sudoku new, /sudoku level easy|normal|hard, or /sudoku stats' }
153    // Under claude -p nothing draws, and ui.open still reports the pane as placed:
154    // the surfaces list is the reliable sign.
155    let surfaces = []
156    try {
157      surfaces = await $.session.surfaces()
158    } catch {
159      surfaces = []
160    }
161    if (!surfaces.length) return { text: summary(await read($, game), await $.clock.now()) }
162    const placed = await $.ui.open({ id: PANE, title: 'Sudoku', focus: true, closeOnEscape: true, rows: 30 })
163    if (!placed.isPlaced) return { text: summary(await read($, game), await $.clock.now()) }
164    // A puzzle paused by closing the pane picks its clock up at the next entry.
165    return {}
166  }).catch(async () => ({ text: 'sudoku: the command failed.' }))
167
168  // Closing the pane stops the clock.
169  on('ui.close', { id: PANE }, async ($, e, next) => {
170    stopTicking()
171    const now = await $.clock.now()
172    await change($, (cur) => pause(cur, now))
173    return next(e)
174  }).catch(async ($, e, next) => next(e))
175
176  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
177    if (e.requestId !== PANE) return next(e)
178    const g = await read($, game)
179    const { Box, Text, Button } = $.ui.resolve(e)
180    if (!g.seed) return Text({ children: ['No game loaded. /sudoku new starts one.'] })
181    const columns = e.props.bodyColumns || 60
182    const wide = columns >= 50
183    const desktop = e.surface === 'desktop'
184    const showHelp = help ?? (wide && untouched(g))
185    const now = await $.clock.now()
186    const time = formatMs(elapsed(g, now))
187    const clashes = new Set(conflicts(g.cells))
188    const count = filled(g.cells)
189    const best = g.stats.bestMs[g.level]
190    if (!g.done && g.startedAt !== null) startTicking($)
191
192    const status = g.done ? { text: 'Solved in ' + time + (best === g.elapsedMs ? ', your fastest on ' + g.level + '. ' : '. ') + (g.hints ? g.hints + ' hint' + (g.hints === 1 ? '' : 's') + '. ' : '') + 'Press n for a new puzzle.', color: 'success', bold: true }
193      : clashes.size ? { text: clashes.size + ' cell' + (clashes.size === 1 ? ' clashes' : 's clash') + ' with another, in red.', color: 'error', bold: true }
194      : untouched(g) ? { text: 'Move with w, a, s, d and type a digit. 0 clears a cell.', color: 'suggestion' }
195      : { text: count + ' of ' + CELLS + ' filled. u takes an entry back, g fills the cell you are on.', color: 'suggestion' }
196
197    const header = Box({ flexDirection: 'column', children: [
198      Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
199        Text({ bold: true, children: ['SUDOKU'] }),
200        Text({ children: ['level ' + g.level] }),
201        Text({ children: ['filled ', Text({ bold: true, children: [count + '/' + CELLS] })] }),
202        Text({ bold: true, color: g.done ? 'success' : g.startedAt !== null ? 'warning' : undefined, dimColor: g.startedAt === null && !g.done, children: ['time ' + time] }),
203        Text({ dimColor: true, children: ['best ' + (best ? formatMs(best) : '—') + (g.hints ? ' · hints ' + g.hints : '') + ' · game ' + g.stats.games] }),
204      ] }),
205      Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
206    ] })
207
208    // Every cell is a three-column Box so the grid lines up on any surface;
209    // the box lines are a single character wide and the rules between boxes
210    // are one string with no spaces. A given is bold, your entry takes the
211    // accent color, the cursor sits on yellow, a clash on red, and every cell
212    // holding the cursor's digit is marked so the same number is easy to find.
213    const same = g.cells[g.cursor]
214    const cell = (i: number) => {
215      const v = g.cells[i]
216      const given = g.givens[i] > 0
217      const isCursor = i === g.cursor
218      const backgroundColor = g.done ? 'success' : clashes.has(i) ? 'error' : isCursor ? 'warning' : same && v === same ? 'inactive' : undefined
219      return Box({ width: 3, justifyContent: 'center', backgroundColor, children: [
220        Text({ bold: given || isCursor, color: backgroundColor ? 'inverseText' : given ? undefined : 'suggestion', dimColor: !v && !backgroundColor, children: [v ? String(v) : '.'] }),
221      ] })
222    }
223    const rule = Box({ width: 1, children: [Text({ dimColor: true, children: ['|'] })] })
224    const gridRow = (r: number) => {
225      const children = []
226      for (let c = 0; c < SIZE; c++) {
227        children.push(cell(at(r, c)))
228        if (c === 2 || c === 5) children.push(rule)
229      }
230      return Box({ flexDirection: 'row', children })
231    }
232    const bar = Text({ dimColor: true, children: ['---------+---------+---------'] })
233    const rows = []
234    for (let r = 0; r < SIZE; r++) {
235      rows.push(gridRow(r))
236      if (r === 2 || r === 5) rows.push(bar)
237    }
238    const grid = Box({ flexDirection: 'column', children: rows })
239
240    const enter = async (i: number, v: number) => {
241      let error: string | undefined
242      const pressedAt = await $.clock.now()
243      const after = await change($, (cur) => {
244        const r = setCell(cur, i, v, pressedAt)
245        error = r.error
246        return r.game
247      })
248      if (error) {
249        $.ui.toast(error)
250        return
251      }
252      if (after.done) {
253        stopTicking()
254        $.ui.toast('Solved in ' + formatMs(after.elapsedMs) + (after.stats.bestMs[after.level] === after.elapsedMs ? ', your fastest on ' + after.level : '') + '!', { timeoutMs: 5000 })
255      }
256    }
257    const moveButton = (key: string, dir: Dir, label: string) =>
258      Button({ key, label, hotkey: key, plain: true, onPress: () => change($, (cur) => moveCursor(cur, dir)) })
259    // A digit is its own hotkey; the desktop draws a hotkey as a key badge, so
260    // the label is blank there and the digit in a terminal.
261    const digitButton = (d: number) =>
262      Button({ key: 'd' + d, label: desktop ? ' ' : String(d), hotkey: String(d), plain: true, onPress: () => enter(g.cursor, d) })
263    const controls = Box({ flexDirection: 'column', children: [
264      Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
265        moveButton('w', 'up', 'Up'),
266        moveButton('a', 'left', 'Left'),
267        moveButton('s', 'down', 'Down'),
268        moveButton('d', 'right', 'Right'),
269      ] }),
270      Box({ flexDirection: 'row', columnGap: desktop ? 1 : 2, flexWrap: 'wrap', children: [
271        ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(digitButton),
272        Button({ key: 'd0', label: 'Clear', hotkey: '0', plain: true, onPress: () => enter(g.cursor, 0) }),
273      ] }),
274    ] })
275
276    const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
277      Text({ bold: true, children: ['How to play'] }),
278      Text({ wrap: 'wrap', children: ['1. Every row, column and 3x3 box must hold 1 to 9 once. The bold digits are given; fill the rest.'] }),
279      Text({ wrap: 'wrap', children: ['2. Move the yellow cursor with w, a, s and d, then type a digit. 0 clears a cell. A digit that repeats in its row, column or box turns red.'] }),
280      Text({ wrap: 'wrap', children: ['3. u takes the last entry back. g fills the cell you are on when you are stuck. The clock runs from your first entry and pauses while the pane is closed; your fastest time per level is kept.'] }),
281      Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. h hides this.'] }),
282    ] })
283
284    const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
285      Button({ key: 'new', label: 'New puzzle', hotkey: 'n', plain: true, onPress: () => startNew($) }),
286      Button({ key: 'undo', label: 'Undo', hotkey: 'u', plain: true, onPress: async () => {
287        let error: string | undefined
288        await change($, (cur) => {
289          const r = undo(cur)
290          error = r.error
291          return r.game
292        })
293        if (error) $.ui.toast(error)
294      } }),
295      Button({ key: 'hint', label: 'Hint', hotkey: 'g', plain: true, onPress: async () => {
296        let error: string | undefined
297        const pressedAt = await $.clock.now()
298        const after = await change($, (cur) => {
299          const r = hint(cur, pressedAt)
300          error = r.error
301          return r.game
302        })
303        if (error) $.ui.toast(error)
304        else if (after.done) {
305          stopTicking()
306          $.ui.toast('Solved in ' + formatMs(after.elapsedMs) + ' with ' + after.hints + ' hint' + (after.hints === 1 ? '' : 's') + '.', { timeoutMs: 5000 })
307        }
308      } }),
309      Button({ key: 'level', label: 'Level: ' + g.level, hotkey: 'l', plain: true, onPress: async () => {
310        const cur = await read($, game)
311        const nextLevel = LEVELS[(LEVELS.indexOf(cur.level) + 1) % LEVELS.length]
312        // A board with entries is never thrown away by one key press.
313        if (!untouched(cur)) {
314          $.ui.toast('This puzzle has entries. Press n for a fresh one first, or /sudoku level ' + nextLevel + '.')
315          return
316        }
317        await startNew($, nextLevel)
318      } }),
319      Button({ key: 'help', label: showHelp ? 'Hide help' : 'Help', hotkey: 'h', plain: true, dimColor: true, onPress: () => {
320        help = !showHelp
321        $.ui.invalidate('ui.render')
322      } }),
323      Text({ dimColor: true, children: ['Esc closes'] }),
324    ] })
325
326    return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, controls, howTo, footer] : [header, grid, controls, footer] })
327  })
328}
329
hooks/game.ts 291 lines
1// The rules of sudoku, pure: no `$` here, so the tests and the hooks module
2// share one implementation. The generator deals a full grid from the seed,
3// then blanks cells while the puzzle keeps exactly one solution.
4
5import type { Game, Level, Move, Stats } from '../types'
6
7export const SIZE = 9
8export const CELLS = SIZE * SIZE
9export const LEVELS: Level[] = ['easy', 'normal', 'hard']
10export const DEFAULT_LEVEL: Level = 'normal'
11/** Cells left filled per level. */
12export const GIVENS: Record<Level, number> = { easy: 40, normal: 32, hard: 26 }
13/** Entries kept for undo; the oldest are dropped past this. */
14export const MAX_UNDO = 300
15/** Deals tried before settling for the one with the fewest givens. */
16const ATTEMPTS = 12
17
18function hash(s: string): number {
19  let h = 2166136261
20  for (let i = 0; i < s.length; i++) {
21    h ^= s.charCodeAt(i)
22    h = Math.imul(h, 16777619)
23  }
24  return h >>> 0
25}
26
27function rng(seed: number): () => number {
28  let a = seed
29  return () => {
30    a = (a + 0x6d2b79f5) | 0
31    let t = Math.imul(a ^ (a >>> 15), 1 | a)
32    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
33    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
34  }
35}
36
37function shuffled<T>(xs: T[], r: () => number): T[] {
38  const a = xs.slice()
39  for (let i = a.length - 1; i > 0; i--) {
40    const j = Math.floor(r() * (i + 1))
41    ;[a[i], a[j]] = [a[j], a[i]]
42  }
43  return a
44}
45
46export const at = (row: number, col: number) => row * SIZE + col
47export const rowOf = (i: number) => Math.floor(i / SIZE)
48export const colOf = (i: number) => i % SIZE
49export const boxOf = (i: number) => Math.floor(rowOf(i) / 3) * 3 + Math.floor(colOf(i) / 3)
50
51const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9]
52const ALL = 0x3fe // bits 1 to 9
53
54function popcount(x: number): number {
55  let n = 0
56  while (x) {
57    x &= x - 1
58    n++
59  }
60  return n
61}
62
63// Backtracking over the cell with the fewest candidates. Counts solutions up
64// to `limit`; the first one found is copied into `first` when given. A grid
65// that already breaks a rule has no solutions.
66function search(cells: number[], limit: number, first?: number[], order: number[] = DIGITS): number {
67  const rows = new Array<number>(SIZE).fill(0)
68  const cols = new Array<number>(SIZE).fill(0)
69  const boxes = new Array<number>(SIZE).fill(0)
70  const grid = cells.slice()
71  for (let i = 0; i < CELLS; i++) {
72    const v = grid[i]
73    if (!v) continue
74    const bit = 1 << v
75    const r = rowOf(i)
76    const c = colOf(i)
77    const b = boxOf(i)
78    if (rows[r] & bit || cols[c] & bit || boxes[b] & bit) return 0
79    rows[r] |= bit
80    cols[c] |= bit
81    boxes[b] |= bit
82  }
83  let count = 0
84  const step = (): boolean => {
85    let best = -1
86    let bestMask = 0
87    let bestN = 10
88    for (let i = 0; i < CELLS; i++) {
89      if (grid[i]) continue
90      const mask = ~(rows[rowOf(i)] | cols[colOf(i)] | boxes[boxOf(i)]) & ALL
91      const n = popcount(mask)
92      if (n === 0) return false
93      if (n < bestN) {
94        best = i
95        bestMask = mask
96        bestN = n
97        if (n === 1) break
98      }
99    }
100    if (best < 0) {
101      count++
102      if (first && count === 1) first.push(...grid)
103      return count >= limit
104    }
105    const r = rowOf(best)
106    const c = colOf(best)
107    const b = boxOf(best)
108    for (const d of order) {
109      const bit = 1 << d
110      if (!(bestMask & bit)) continue
111      grid[best] = d
112      rows[r] |= bit
113      cols[c] |= bit
114      boxes[b] |= bit
115      const stop = step()
116      grid[best] = 0
117      rows[r] &= ~bit
118      cols[c] &= ~bit
119      boxes[b] &= ~bit
120      if (stop) return true
121    }
122    return false
123  }
124  step()
125  return count
126}
127
128/** How many solutions the grid has, counted up to `limit` (default 2: one, or more than one). */
129export function countSolutions(cells: number[], limit = 2): number {
130  return search(cells, limit)
131}
132
133/** The first solution, or null when there is none. */
134export function solve(cells: number[]): number[] | null {
135  const first: number[] = []
136  return search(cells, 1, first) ? first : null
137}
138
139// A full valid grid, dealt from the seed: a search over an empty grid that
140// tries the digits in a shuffled order.
141export function fullGrid(seed: number): number[] {
142  const first: number[] = []
143  search(new Array<number>(CELLS).fill(0), 1, first, shuffled(DIGITS, rng(hash(seed + ':fill'))))
144  return first
145}
146
147// Blank cells of the full grid in a shuffled order, keeping each blank only
148// while the puzzle still has one solution, until the level's count of givens
149// is reached. A deal that can't get there is retried with a fresh order; the
150// best of a dozen is kept.
151export function makePuzzle(seed: number, level: Level): { givens: number[]; solution: number[] } {
152  const solution = fullGrid(seed)
153  const target = GIVENS[level]
154  let best: number[] | null = null
155  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
156    const order = shuffled(Array.from({ length: CELLS }, (_, i) => i), rng(hash(seed + ':remove:' + attempt)))
157    const puzzle = solution.slice()
158    let left = CELLS
159    for (const i of order) {
160      if (left <= target) break
161      const v = puzzle[i]
162      puzzle[i] = 0
163      if (countSolutions(puzzle, 2) === 1) left--
164      else puzzle[i] = v
165    }
166    if (!best || left < best.filter(Boolean).length) best = puzzle
167    if (left <= target) break
168  }
169  return { givens: best as number[], solution }
170}
171
172export function newGame(seed: number, stats: Stats, level: Level): Game {
173  const { givens, solution } = makePuzzle(seed, level)
174  const cursor = givens.findIndex((v) => !v)
175  return { seed, level, givens, cells: givens.slice(), solution, cursor: cursor < 0 ? 0 : cursor, moves: [], hints: 0, done: false, elapsedMs: 0, startedAt: null, stats }
176}
177
178/** Cells whose value also appears in their row, column or box. */
179export function conflicts(cells: number[]): number[] {
180  const out: number[] = []
181  for (let i = 0; i < CELLS; i++) {
182    const v = cells[i]
183    if (!v) continue
184    const r = rowOf(i)
185    const c = colOf(i)
186    const b = boxOf(i)
187    let clash = false
188    for (let j = 0; j < CELLS && !clash; j++) {
189      if (j === i || cells[j] !== v) continue
190      clash = rowOf(j) === r || colOf(j) === c || boxOf(j) === b
191    }
192    if (clash) out.push(i)
193  }
194  return out
195}
196
197export const filled = (cells: number[]) => cells.filter(Boolean).length
198
199/** Every cell filled and no rule broken. */
200export function isSolved(cells: number[]): boolean {
201  return filled(cells) === CELLS && conflicts(cells).length === 0
202}
203
204export function elapsed(g: Game, now: number): number {
205  return g.elapsedMs + (g.startedAt !== null && !g.done ? Math.max(0, now - g.startedAt) : 0)
206}
207
208/** Stop the clock, keeping the time so far. */
209export function pause(g: Game, now: number): Game {
210  if (g.startedAt === null || g.done) return g
211  return { ...g, elapsedMs: elapsed(g, now), startedAt: null }
212}
213
214export function formatMs(ms: number): string {
215  const s = Math.floor(ms / 1000)
216  const m = Math.floor(s / 60)
217  return m + ':' + String(s % 60).padStart(2, '0')
218}
219
220export type MoveResult = { game: Game; error?: string }
221
222// Write a digit, or 0 to clear, into a cell. A given can't be changed. The
223// clock starts at the first entry. Filling the last cell with no rule broken
224// solves the puzzle, stops the clock, and records the time.
225export function setCell(g: Game, i: number, v: number, now: number): MoveResult {
226  if (g.done) return { game: g, error: 'The puzzle is solved. Press n for a new one.' }
227  if (i < 0 || i >= CELLS || !Number.isInteger(v) || v < 0 || v > 9) return { game: g, error: 'Not a cell or a digit.' }
228  if (g.givens[i]) return { game: g, error: 'That number is part of the puzzle.' }
229  if (g.cells[i] === v) return { game: g }
230  const cells = g.cells.slice()
231  cells[i] = v
232  const moves = [...g.moves, { i, from: g.cells[i] }].slice(-MAX_UNDO)
233  const startedAt = g.startedAt ?? now
234  if (!isSolved(cells)) return { game: { ...g, cells, moves, cursor: i, startedAt } }
235  const ms = Math.max(1, elapsed({ ...g, startedAt }, now))
236  const prev = g.stats.bestMs[g.level]
237  const stats: Stats = { ...g.stats, solved: g.stats.solved + 1, bestMs: { ...g.stats.bestMs, [g.level]: prev ? Math.min(prev, ms) : ms } }
238  return { game: { ...g, cells, moves, cursor: i, done: true, elapsedMs: ms, startedAt: null, stats } }
239}
240
241/** Take back the last entry and move the cursor there. */
242export function undo(g: Game): MoveResult {
243  if (g.done) return { game: g, error: 'The puzzle is solved. Press n for a new one.' }
244  if (!g.moves.length) return { game: g, error: 'Nothing to undo.' }
245  const last = g.moves[g.moves.length - 1]
246  const cells = g.cells.slice()
247  cells[last.i] = last.from
248  return { game: { ...g, cells, moves: g.moves.slice(0, -1), cursor: last.i } }
249}
250
251/** Fill the cursor's cell from the solution, counting a hint. */
252export function hint(g: Game, now: number): MoveResult {
253  if (g.done) return { game: g, error: 'The puzzle is solved. Press n for a new one.' }
254  const i = g.cursor
255  if (g.givens[i]) return { game: g, error: 'That number is part of the puzzle.' }
256  if (g.cells[i] === g.solution[i]) return { game: g, error: 'That cell is already right.' }
257  const r = setCell(g, i, g.solution[i], now)
258  return r.error ? r : { game: { ...r.game, hints: g.hints + 1 } }
259}
260
261export type Dir = 'up' | 'down' | 'left' | 'right'
262
263/** Move the cursor one cell, wrapping at the edges. */
264export function moveCursor(g: Game, d: Dir): Game {
265  let r = rowOf(g.cursor)
266  let c = colOf(g.cursor)
267  if (d === 'up') r = (r + SIZE - 1) % SIZE
268  if (d === 'down') r = (r + 1) % SIZE
269  if (d === 'left') c = (c + SIZE - 1) % SIZE
270  if (d === 'right') c = (c + 1) % SIZE
271  return { ...g, cursor: at(r, c) }
272}
273
274export function isLevel(x: unknown): x is Level {
275  return x === 'easy' || x === 'normal' || x === 'hard'
276}
277
278export function isGame(x: unknown): x is Game {
279  if (!x || typeof x !== 'object') return false
280  const g = x as Record<string, unknown>
281  const grid = (a: unknown) => Array.isArray(a) && a.length === CELLS && a.every((v) => Number.isInteger(v) && v >= 0 && v <= 9)
282  const move = (m: unknown) => !!m && typeof m === 'object' && Number.isInteger((m as Move).i) && Number.isInteger((m as Move).from)
283  const stats = g.stats as Record<string, unknown> | undefined
284  const best = stats?.bestMs as Record<string, unknown> | undefined
285  return typeof g.seed === 'number' && isLevel(g.level) && grid(g.givens) && grid(g.cells) && grid(g.solution) &&
286    Number.isInteger(g.cursor) && (g.cursor as number) >= 0 && (g.cursor as number) < CELLS && Array.isArray(g.moves) && g.moves.every(move) &&
287    Number.isInteger(g.hints) && typeof g.done === 'boolean' && Number.isInteger(g.elapsedMs) && (g.startedAt === null || typeof g.startedAt === 'number') &&
288    !!stats && typeof stats === 'object' && Number.isInteger(stats.games) && Number.isInteger(stats.solved) &&
289    !!best && typeof best === 'object' && LEVELS.every((l) => Number.isInteger(best[l]))
290}
291
types/index.d.ts 39 lines
1export type Level = 'easy' | 'normal' | 'hard'
2/** One entry you made, for undo: the cell and what it held before. */
3export type Move = { i: number; from: number }
4export type Stats = {
5  games: number
6  solved: number
7  /** Fastest solve per level in ms; 0 until one is solved. */
8  bestMs: Record<Level, number>
9}
10export type Game = {
11  /** Seed of this puzzle; 0 until a game exists. */
12  seed: number
13  level: Level
14  /** The puzzle as dealt, 81 cells in row order, 0 for a blank. */
15  givens: number[]
16  /** The grid as it stands now: the givens plus your entries. */
17  cells: number[]
18  /** The one solution, for hints. */
19  solution: number[]
20  /** The cell the keys act on, 0 to 80. */
21  cursor: number
22  /** Your entries, oldest first, for undo. */
23  moves: Move[]
24  /** Hints taken this puzzle. */
25  hints: number
26  done: boolean
27  /** Time played before the current stretch, in ms. */
28  elapsedMs: number
29  /** When the current stretch of play began; null while the pane is closed or before the first entry. */
30  startedAt: number | null
31  stats: Stats
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    sudoku: { game: Game }
37  }
38}
39