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…

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.

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.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install sudoku@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/sudoku
/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.The keys work while the pane has the focus. If typing a digit goes into the prompt instead, click the pane first.
| Command | What it does |
|---|---|
/sudoku | Open the pane. Where no pane can be drawn, print how far along the puzzle is. |
/sudoku new | Deal a new puzzle at the current level. The records are kept. |
/sudoku level easy, normal, or hard | Deal a new puzzle at that level: 40, 32 or 26 givens. |
/sudoku stats | Games, solved, fastest time per level. |
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.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.Boxes with single-character rules, the same way bingo and connect-four are, which draw correctly there.MIT, see the repository root.
hooks/register.ts 329 lines1// 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}
329hooks/game.ts 291 lines1// 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}
291types/index.d.ts 39 lines1export 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