The classic mine-clearing puzzle in a Claude Code pane: click or use the keys to clear the board while Claude works. A timer, a best time, and a face that…


Minesweeper in a Claude Code pane. A mod, shipped as a plugin: /mines opens a board you play with the mouse or the keys while Claude works. It reads and changes nothing the model sees: claude plugin validate ./minefield lists every event it hooks and every call it makes, before any of its code runs.
❯ ./register.ts hooks: session.start, command.run{command=mines}, ui.render{component=Pane}, ui.message
❯ ./register.ts calls: $.clock.after (via focusSoon), $.clock.now (via deal), $.command.register, $.session.surfaces, $.store.get, $.store.set (via recordWin), $.ui.invalidate, $.ui.open (via openPane), $.ui.panes (via refocus), $.ui.resolve
❯ ./register.ts surface modules: hooks/board.ts
No prompt, tool or attachment hook, and no network call.
1 ▆▆ ▆▆ ▆▆
2 ⚑ ▆▆ ▆▆
2 ▆▆ ▆▆ ▆▆
1 1 ▆▆ ▆▆
1 1 1 1 ▆▆ ⚑
▆▆ ▆▆ 2 2 1 1 1 ▆▆ ▆▆
▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆
▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆
▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆ ▆▆
▄▄▄▄▄▄▄
● ● Mines 8 · Time 0:17 · Best 0:42 · By Reporails
ω 34 safe cells left.
▀▀▀▀▀▀▀
In a Claude Code session:
/plugin marketplace add reporails/arcade
/plugin install minefield@reporails-arcade
Or from your shell, then /reload-plugins in any session already open:
claude plugin marketplace add reporails/arcade
claude plugin install minefield@reporails-arcade
Then run /mines. To play from a checkout instead: claude --plugin-dir ./minefield.
Mods need Claude Code 2.1.287 or later, where they are on by default. For the side pane and the mouse, start Claude Code with CLAUDE_CODE_NO_FLICKER=1: that is the fullscreen layout, the one where Claude Code reports the mouse, and the pane docks on the right beside the transcript and takes clicks. Without it the pane sits above the prompt and the game is played with the keys alone.
| Input | What it does |
|---|---|
/mines | Opens the pane. If a game is in play, it stays as it is. |
One board, 9×9 with 10 mines: a game for the few minutes Claude works.
/mines is an immediate command, so it works while Claude is mid-turn.
| Input | What it does |
|---|---|
| Left click | Reveals the cell. On an open number with all its flags set, opens the cells around it. |
| Right click, or ctrl/alt + left click | Sets or clears a flag. |
r | Reveals the cell under the cursor. |
f | Flags the cell under the cursor. |
w a s d | Move the cursor. |
n | New game. |
Arrow keys, h j k l, space, return | Move and reveal, once a click has given the board the keyboard. |
Esc | Hands the keyboard back to the prompt; the game stays. ctrl+x tab gives the pane the keys again. Close the pane with its ✕ or ctrl+x x. |
The letter keys work as soon as /mines has opened the pane; nothing needs a click first. The mouse needs the fullscreen layout (/tui fullscreen turns it on); the line under the buttons says which you have.
The board grows into the room the pane has. Docked beside the transcript, it is drawn as big square tiles, three rows a cell where the pane is tall enough, two where it is not, and one in a short terminal. Above the prompt, where rows are few, a cell is one row, its hidden tile a smaller box, and the face and the status move beside the board instead of under it, so nothing is cut off.
The first reveal is always safe and opens an area: the mines are laid after it, away from that cell and the eight around it. The clock runs from the first reveal to the last. The best time is kept between sessions in the plugin's own store.
Closing the pane ends the game in play.
The status line ends with a By Reporails link to the Reporails CLI on GitHub. Your terminal opens it; the mod itself makes no network call.
A face under the board shows how the game is going, drawn in Claude Code's own orange (the theme's claude colour, so it follows your theme). It is this mod's own drawing; the Claude logo at the top of a session is not something a mod can redraw.
| When | The face |
|---|---|
| You are playing | Follows the cursor to its side of the board. |
| Two seconds without a move | Looks around: up at you, at the cursor's side, or left at the transcript beside the pane. |
| Six seconds without a move | Reads the transcript, as far as a face can: eyes half shut, following a few lines along in short steps and jumping back to each line's start. It only looks; the mod sees nothing of the transcript. |
| Always | Blinks every few seconds, sometimes twice, often as its gaze jumps. The mouth leans after the eyes. |
| A reveal | Wide eyes and a round mouth; delight when a reveal opens ten cells or more. |
| A flag | A wink. |
| Stepped on a mine | x x and a flat mouth. |
| Board cleared | Delight, then sunglasses and a wide grin. |
minefield/
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json points at register.ts
│ ├── register.ts the hooks: the command, the pane, the buttons, the best time
│ ├── board.ts the surface module: draws the board and the face, takes the pointer and keys
│ └── lib.ts pure functions: the game rules, the cell styles, parsing
├── tests/minefield.test.ts runs with `claude plugin test`
└── README.md
The game lives in the board's local state. The hooks module deals the game, its mines laid from the time it is dealt, hands down the best time, and draws the buttons; the board posts back a win.
Four things a live session showed that the test kit did not:
/mines opens the pane, then asks for the keyboard again, up to three times over the next two seconds, only while the pane is open and still without it.register.ts and hand each move to the board as a numbered act prop.The face runs on the board's own timer, five beats a second, as pure functions in lib.ts (nextMood, attend, react, looks) driven by a die seeded from the deal, so the tests replay it exactly. The board's size is one pure function too (fitFor), from the pane's columns and rows.
claude plugin validate --strict passes and claude plugin test passes 31 of 31, from a logged-out config, the way CI runs them.tsc passes on the code and the tests against that build's typings, and the same 31 tests pass. CI runs both builds. The tests cover the rules (safe first reveal, numbers, flags, opening around a number, winning, losing, the clock), the face's gaze, reading, blinks and reactions, the board's fit to the pane and the pane's size, and the mod driven through its pane on the terminal and desktop surfaces: pointer, keys, buttons, /mines bringing back the game in play, a win stored as the best time.claude --version must be 2.1.287 or later./plugin shows a dim mod active line naming the mods that loaded. If minefield is not on it, run claude plugin test in an empty folder: no hooks module to load means mods load for you; hooks modules are turned off here means a setting blocks them (disableAllHooks in your settings, or your organization's policy); hooks modules are turned off in this process means Anthropic has them off for your account, and no local setting changes that. An organization can also limit which mods load (allowManagedModsOnly).claude -p there is nothing to draw a board on; /mines says so.Made by Reporails, diagnostics for the instructions that steer Claude Code. MIT.
hooks/register.ts 231 lines1// minefield: Minesweeper in a pane.
2//
3// The hooks here deal the game, keep the best time and open the pane;
4// board.ts, a surface module, plays it. No hook touches what the model
5// reads: there is no prompt, tool or attachment hook in this mod.
6//
7// The host reads on(...) and $.noun.method(...) from source, so they are
8// spelled literally, and helpers that take $ are top-level functions here.
9
10import type { EngineInterface, Register, RenderSurface } from 'claude-code'
11
12import type { BoardProps } from './board'
13import { CHROME_ROWS, betterBest, bestOf, mergeBest, messageOf, paneSize } from './lib'
14import type { Act, Best, MoveType, Win } from './lib'
15
16const PANE = 'minefield'
17const BOARD = 'board'
18const NO_BOARD = 'Minefield draws its board in the terminal and the desktop app only. In VS Code, run claude in the integrated terminal.'
19
20// A pane takes the keyboard only over an empty prompt, and a command's own
21// text stays in the prompt while the command runs. These are the waits
22// after /mines returns before asking for the keyboard again, each tried only
23// while the pane is open and still without it.
24const FOCUS_RETRIES_MS = [250, 500, 1000] as const
25
26// The moves a control button asks the board for, at its cursor. A pane's
27// hotkeys reach only buttons drawn here, not ones a surface module draws.
28const CONTROLS: readonly { type: MoveType; label: string; hotkey: string; isMove: boolean }[] = [
29 { type: 'new', label: 'New', hotkey: 'n', isMove: false },
30 { type: 'reveal', label: 'Reveal', hotkey: 'r', isMove: false },
31 { type: 'flag', label: 'Flag', hotkey: 'f', isMove: false },
32 { type: 'up', label: '↑', hotkey: 'w', isMove: true },
33 { type: 'left', label: '←', hotkey: 'a', isMove: true },
34 { type: 'down', label: '↓', hotkey: 's', isMove: true },
35 { type: 'right', label: '→', hotkey: 'd', isMove: true },
36]
37
38// Moves kept for the board, so presses faster than its redraws all arrive.
39const MAX_ACTS = 32
40
41// The game the board is asked to play, the room the pane has for it, and
42// whether the screen is the fullscreen layout. In memory; the best time is
43// also stored.
44const state: {
45 seed: number
46 id: number
47 best: Best
48 columns: number
49 rows: number
50 terminalColumns: number
51 isFullscreen: boolean
52 acts: readonly Act[]
53} = {
54 seed: 0,
55 id: 0,
56 best: null,
57 columns: 80,
58 rows: 0,
59 terminalColumns: 80,
60 isFullscreen: false,
61 acts: [],
62}
63
64// One line under the controls, for the screen the pane is on: the mouse
65// exists only in the fullscreen layout, and a pane without the keyboard
66// takes them back with ctrl+x tab.
67function hintFor(isFocused: boolean, isFullscreen: boolean): string {
68 if (!isFocused) return 'ctrl+x tab gives the pane the keys again.'
69 if (isFullscreen) return 'Click reveals, right-click flags; click a fully flagged number to open around it.'
70 return 'Keys only on this screen: /tui fullscreen adds the mouse.'
71}
72
73export const register: Register = (on) => {
74 on('session.start', async ($, e, next) => {
75 const result = await next(e)
76 try {
77 state.best = bestOf(await $.store.get('best'))
78 } catch {
79 // Not readable now; a win reads the store again before writing to it.
80 }
81 try {
82 await $.command.register({
83 name: 'mines',
84 description: 'Play Minefield in a pane',
85 immediate: true,
86 })
87 } catch {
88 // The name is taken; nothing else opens the pane.
89 }
90 return result
91 })
92
93 // `/mines` deals the first game and brings back the one in play after it.
94 on('command.run', { command: 'mines' }, async ($, e) => {
95 const surfaces = await $.session.surfaces()
96 if (!surfaces.some(drawsBoard)) return { text: NO_BOARD }
97 state.isFullscreen = e.presentation.isFullscreen
98 state.terminalColumns = e.presentation.columns
99 if (state.id === 0) await deal($)
100 await openPane($)
101 focusSoon($, 0)
102 return {}
103 })
104
105 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
106 if (e.requestId !== PANE) return next(e)
107 const elements = $.ui.resolve(e)
108 // The surface decides; the `in` check only tells the types what it decided.
109 if (!drawsBoard(e.surface) || !('Client' in elements)) return elements.Text({ children: [NO_BOARD] })
110 const { Box, Text, Button, Client } = elements
111 state.columns = e.props.bodyColumns
112 state.rows = Math.max(0, e.props.scroll.bodyRows - CHROME_ROWS)
113 if (e.viewport?.isFullscreen !== undefined) state.isFullscreen = e.viewport.isFullscreen
114 if (e.viewport !== undefined) state.terminalColumns = e.viewport.columns
115 const controls = CONTROLS.map((control) =>
116 Button({
117 key: control.type,
118 label: control.label,
119 hotkey: control.hotkey,
120 plain: true,
121 dimColor: control.isMove,
122 onPress: () => {
123 ask(control.type)
124 $.ui.invalidate('ui.render')
125 },
126 }),
127 )
128 return Box({
129 flexDirection: 'column',
130 children: [
131 Text({ children: [' '] }),
132 Client({ key: BOARD, module: './board.ts', props: boardProps() }),
133 Box({ flexDirection: 'row', columnGap: 2, children: controls }),
134 Text({ dimColor: true, wrap: 'truncate-end', children: [hintFor(e.props.isFocused, state.isFullscreen)] }),
135 ],
136 })
137 })
138
139 // The board reports a win.
140 on('ui.message', async ($, e, next) => {
141 const result = await next(e)
142 if (e.requestId !== PANE || e.element !== BOARD) return result
143 const message = messageOf(e.data)
144 if (message === null) return result
145 await recordWin($, message.won)
146 return { props: boardProps() }
147 })
148}
149
150// The surfaces that draw a surface module and hand it the pointer.
151function drawsBoard(surface: RenderSurface): boolean {
152 return surface === 'terminal' || surface === 'desktop'
153}
154
155// A new game, its mines laid from the time it is dealt. Without a clock,
156// the next game in line.
157async function deal($: EngineInterface): Promise<void> {
158 try {
159 state.seed = await $.clock.now()
160 } catch {
161 state.seed += 1
162 }
163 state.id += 1
164}
165
166function ask(type: MoveType): void {
167 const n = (state.acts.at(-1)?.n ?? 0) + 1
168 state.acts = [...state.acts, { n, type }].slice(-MAX_ACTS)
169}
170
171function boardProps(): BoardProps {
172 return {
173 id: state.id,
174 seed: state.seed,
175 best: state.best,
176 columns: state.columns,
177 rows: state.rows,
178 acts: state.acts,
179 }
180}
181
182// Keep a win's time when it beats the record. The store is read again first,
183// so a read that failed at session start never costs a stored record; when
184// it cannot be read now, the time stands for this session only.
185async function recordWin($: EngineInterface, win: Win): Promise<void> {
186 const best = betterBest(state.best, win.seconds)
187 if (best === state.best) return
188 state.best = best
189 try {
190 const merged = mergeBest(bestOf(await $.store.get('best')), best)
191 state.best = merged
192 await $.store.set('best', merged)
193 } catch {
194 // Not stored; the time still stands for this session.
195 }
196}
197
198// Open the pane, or resize the open one, to fit the board.
199async function openPane($: EngineInterface): Promise<void> {
200 const size = paneSize(state.terminalColumns)
201 try {
202 await $.ui.open({ id: PANE, title: 'Minefield', focus: true, rows: size.rows, columns: size.columns })
203 } catch {
204 // Refused; the pane stays as it was.
205 }
206 $.ui.invalidate('ui.render')
207}
208
209// Ask for the keyboard again once /mines has returned, while the pane is open
210// and still without it; never reopen a pane the person has closed.
211function focusSoon($: EngineInterface, attempt: number): void {
212 const wait = FOCUS_RETRIES_MS[attempt]
213 if (wait === undefined) return
214 try {
215 $.clock.after(wait, () => void refocus($, attempt))
216 } catch {
217 // No timer; a click on the pane gives it the keyboard.
218 }
219}
220
221async function refocus($: EngineInterface, attempt: number): Promise<void> {
222 try {
223 const pane = (await $.ui.panes()).find((p) => p.id === PANE)
224 if (pane === undefined || pane.isFocused) return
225 await openPane($)
226 focusSoon($, attempt + 1)
227 } catch {
228 // The panes cannot be listed; a click on the pane gives it the keyboard.
229 }
230}
231hooks/board.ts 266 lines1// minefield: the board, a surface module. It keeps the game in its local
2// state, takes the pointer and the keys, and reports wins to the hooks module.
3//
4// The board is drawn first, so a pointer position in the region is a board
5// position: nothing above it can push the rows down.
6
7import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
8
9import {
10 FACE_WIDTH,
11 SEPARATOR,
12 act,
13 applyActs,
14 attend,
15 boardRows,
16 cellAt,
17 faceRows,
18 fitFor,
19 firstMood,
20 looks,
21 moveTo,
22 newGame,
23 nextMood,
24 outcomeLine,
25 randomOf,
26 rowsFor,
27 react,
28 reactionTo,
29 reveal,
30 statusLine,
31 statusParts,
32 tick,
33 toggleFlag,
34} from './lib'
35import type { Act, Best, Fit, Game, Mood, MoveType } from './lib'
36
37// What the hooks module hands the board: which game to deal and the number
38// its mines are laid from, the best time, the room the pane has for it, and
39// the moves the control buttons asked for, oldest first.
40export type BoardProps = {
41 id: number
42 seed: number
43 best: Best
44 columns: number
45 rows: number
46 acts: readonly Act[]
47}
48
49type Surface = ClientSurface<Game>
50
51// The keys the board takes once a click has given it the keyboard.
52const KEYS: Readonly<Record<string, MoveType>> = {
53 up: 'up',
54 down: 'down',
55 left: 'left',
56 right: 'right',
57 w: 'up',
58 s: 'down',
59 a: 'left',
60 d: 'right',
61 k: 'up',
62 j: 'down',
63 h: 'left',
64 l: 'right',
65 return: 'reveal',
66 space: 'reveal',
67 ' ': 'reveal',
68 r: 'reveal',
69 f: 'flag',
70 n: 'new',
71}
72
73const OUTCOME_COLORS: Readonly<Partial<Record<Game['status'], string>>> = { won: 'green', lost: 'red' }
74
75const BLANK = ' '
76
77// The maker's credit, after the status.
78const CREDIT = { href: 'https://github.com/reporails/cli', label: 'By Reporails' }
79
80// The board's one timer: five beats a second. The clock ticks every fifth
81// beat counted from the first reveal; the face's clock runs on every beat.
82const BEAT_MS = 200
83const BEATS_PER_SECOND = 5
84
85// Per instance, outside the game: none of these alone is worth a redraw.
86// `latest` is the newest game, so two events in one frame build on each
87// other; `beats` counts the timer; `started` is the beat of the first reveal;
88// `moods` is the face's clock, `heard` the beat of the last move, and
89// `randoms` the face's dice, seeded from the first deal so tests can replay it.
90const latest = new WeakMap<Surface, Game>()
91const beats = new WeakMap<Surface, number>()
92const started = new WeakMap<Surface, number>()
93const moods = new WeakMap<Surface, Mood>()
94const heard = new WeakMap<Surface, number>()
95const randoms = new WeakMap<Surface, () => number>()
96
97function randomFor(surface: Surface, seed: number): () => number {
98 const known = randoms.get(surface)
99 if (known !== undefined) return known
100 const random = randomOf(seed ^ 0x9e3779b9)
101 randoms.set(surface, random)
102 return random
103}
104
105function moodOf(surface: Surface, n: number, random: () => number): Mood {
106 return moods.get(surface) ?? firstMood(n, random)
107}
108
109// Keep the game a change led to; start the clock on the first reveal, and
110// report a win.
111function settle(surface: Surface, before: Game, after: Game): void {
112 latest.set(surface, after)
113 surface.setState(after)
114 if (before.status === 'ready' && after.status === 'playing') started.set(surface, beats.get(surface) ?? 0)
115 if (after.status === 'won' && before.status !== 'won') {
116 surface.post({ won: { seconds: after.seconds } })
117 }
118}
119
120function step(surface: Surface, change: (game: Game) => Game): void {
121 const before = latest.get(surface) ?? surface.state
122 if (before === undefined) return
123 const after = change(before)
124 if (after !== before) settle(surface, before, after)
125}
126
127// A move by the player: play it, turn the face to the cursor, and let the
128// face react to what the move did.
129function moved(surface: Surface, change: (game: Game) => Game): void {
130 const n = beats.get(surface) ?? 0
131 step(surface, (game) => {
132 const after = change(game)
133 const reaction = reactionTo(game, after)
134 const watching = attend(moodOf(surface, n, randomFor(surface, game.seed)), n)
135 const mood = reaction === null ? watching : react(watching, n, reaction)
136 moods.set(surface, mood)
137 heard.set(surface, n)
138 return looks(after, mood, n)
139 })
140}
141
142// Left reveals, and opens around a number; right flags, as does a left click
143// with ctrl or alt for a pointer with one button. The face holds its breath
144// from a reveal's press to its release.
145function pressed(game: Game, e: ClientPointerEvent, fit: Fit): Game {
146 if (e.type === 'up') return game.isPressing ? { ...game, isPressing: false } : game
147 if (e.type !== 'down') return game
148 const i = cellAt(game, e.x, e.y, fit.width, fit.height)
149 if (i === -1) return game
150 const at = moveTo(game, i)
151 if (e.button === 'right' || (e.button === 'left' && (e.ctrl || e.alt))) return toggleFlag(at, i)
152 if (e.button !== 'left' && e.button !== 'middle') return at
153 const after = reveal(at, i)
154 return after.status === 'playing' ? { ...after, isPressing: true } : after
155}
156
157function typed(surface: Surface, e: ClientKeyEvent): void {
158 if (e.ctrl || e.meta) return
159 const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
160 const type = KEYS[key]
161 if (type !== undefined) moved(surface, (game) => act(game, type))
162}
163
164function beat(surface: Surface): void {
165 const n = (beats.get(surface) ?? 0) + 1
166 beats.set(surface, n)
167 step(surface, (game) => {
168 const since = n - (started.get(surface) ?? n)
169 const ticked = since > 0 && since % BEATS_PER_SECOND === 0 ? tick(game) : game
170 const random = randomFor(surface, game.seed)
171 const mood = nextMood(moodOf(surface, n, random), n, heard.get(surface) ?? 0, random)
172 moods.set(surface, mood)
173 return looks(ticked, mood, n)
174 })
175}
176
177// The newest move number the props carry, or 0.
178function lastActOf(acts: readonly Act[]): number {
179 return acts.at(-1)?.n ?? 0
180}
181
182const Minefield: ClientModule<BoardProps, Game> = (props, surface) => {
183 const { Box, Text, Link } = surface.elements
184
185 // A new deal from the hooks module starts over; each move a control button
186 // asked for is played once, in order.
187 let game = latest.get(surface) ?? surface.state
188 if (game === undefined || game.id !== props.id) {
189 const isFirst = game === undefined
190 game = { ...newGame(props.seed, props.id), acted: lastActOf(props.acts) }
191 latest.set(surface, game)
192 started.delete(surface)
193 surface.setState(game)
194 if (isFirst) surface.every(BEAT_MS, () => beat(surface))
195 } else if (lastActOf(props.acts) !== game.acted) {
196 moved(surface, (g) => applyActs(g, props.acts))
197 game = latest.get(surface) ?? game
198 }
199
200 // The board grows into the room it has; the face and the status go under it
201 // or, where rows are short, beside it.
202 const fit = fitFor(game.cols, game.rows, { columns: props.columns, rows: props.rows })
203 // Only a press on a cell is a move: a click on the face or the status
204 // leaves the face where it was looking.
205 surface.onPointer((e) => {
206 const isMove = e.type === 'down' && cellAt(game, e.x, e.y, fit.width, fit.height) !== -1
207 ;(isMove ? moved : step)(surface, (g) => pressed(g, e, fit))
208 })
209 surface.onKey((e) => typed(surface, e))
210
211 const board = boardRows(game, fit).map((runs) =>
212 Box({ flexDirection: 'row', flexShrink: 0, children: runs.map((run) => Text({ ...run.style, children: [run.text] })) }),
213 )
214
215 const outcomeColor = OUTCOME_COLORS[game.status]
216 const outcomeStyle = outcomeColor === undefined ? { dimColor: true } : { color: outcomeColor }
217
218 // The face's column keeps its width, so long text beside it is cut and the
219 // face is not.
220 const face = faceRows(game).map((row) => Text({ ...row.style, wrap: 'truncate-end', children: [row.text] }))
221 const faceColumn = Box({ flexDirection: 'column', width: FACE_WIDTH, flexShrink: 0, children: face })
222
223 if (fit.side) {
224 return Box({
225 flexDirection: 'row',
226 columnGap: 2,
227 minHeight: rowsFor(fit, game.rows),
228 children: [
229 Box({ flexDirection: 'column', flexShrink: 0, children: board }),
230 Box({
231 flexDirection: 'column',
232 flexShrink: 1,
233 children: [
234 faceColumn,
235 Text({ children: [BLANK] }),
236 ...statusParts(game, props.best).map((part) => Text({ bold: true, wrap: 'truncate-end', children: [part] })),
237 Text({ wrap: 'truncate-end', children: [Link(CREDIT)] }),
238 Text({ ...outcomeStyle, wrap: 'wrap', children: [outcomeLine(game, props.best)] }),
239 ],
240 }),
241 ],
242 })
243 }
244
245 const lines = [
246 Text({ children: [BLANK] }),
247 Text({ bold: true, wrap: 'truncate-end', children: [statusLine(game, props.best) + SEPARATOR, Link(CREDIT)] }),
248 Text({ ...outcomeStyle, wrap: 'truncate-end', children: [outcomeLine(game, props.best)] }),
249 ]
250
251 return Box({
252 flexDirection: 'column',
253 children: [
254 ...board,
255 Text({ children: [BLANK] }),
256 Box({
257 flexDirection: 'row',
258 columnGap: 2,
259 children: [faceColumn, Box({ flexDirection: 'column', flexShrink: 1, children: lines })],
260 }),
261 ],
262 })
263}
264
265export default Minefield
266hooks/lib.ts 823 lines1// minefield: the game itself. Pure functions over plain data, so the hooks
2// module, the board and the tests share them. A game is JSON all the way down.
3
4// One board, 9×9 with 10 mines: a game for the few minutes Claude works.
5const COLS = 9
6const ROWS = 9
7const MINES = 10
8
9export const HIDDEN = 0
10export const OPEN = 1
11export const FLAG = 2
12export const MINE = -1
13
14export type Mark = typeof HIDDEN | typeof OPEN | typeof FLAG
15export type Status = 'ready' | 'playing' | 'won' | 'lost'
16
17export type Game = {
18 id: number
19 cols: number
20 rows: number
21 mines: number
22 seed: number
23 // Per cell, the mines around it, or MINE; null until the first reveal.
24 // Laid once and never changed, so every later game shares the same array.
25 adj: readonly number[] | null
26 marks: Mark[]
27 status: Status
28 seconds: number
29 opened: number
30 flags: number
31 boom: number
32 cursor: { x: number; y: number }
33 // The numbered move from the hooks module this game last played.
34 acted: number
35 isPressing: boolean
36 isBlinking: boolean
37 // What the face shows between moves: where it looks, where its eyes sit (in
38 // half cells), where its mouth sits, and what it is reacting to.
39 gaze: Gaze
40 eyesAt: number
41 mouthAt: number
42 reaction: Reaction | null
43}
44
45// Following the cursor, straight out at the player, glancing left at the
46// transcript beside the pane, or reading it line by line.
47export type Gaze = 'cursor' | 'player' | 'left' | 'reading'
48
49// A moment's expression: a gasp at a reveal, delight at a big opening or a
50// win, a wink at a flag.
51export type Reaction = 'gasp' | 'delight' | 'wink'
52
53// The face's own clock, kept beside the game: the gaze from one beat until
54// another, the eyes' position on each beat of a read, the beats its eyes are
55// shut on next, and a reaction until a beat.
56export type Mood = {
57 gaze: Gaze
58 since: number
59 until: number
60 path: readonly number[]
61 blinks: readonly number[]
62 reaction: Reaction | null
63 reactUntil: number
64}
65
66// The best time in seconds, or null before the first win.
67export type Best = number | null
68
69export type MoveType = 'up' | 'down' | 'left' | 'right' | 'reveal' | 'flag' | 'new'
70
71// A move a control button asked for, numbered so each is played once.
72export type Act = { n: number; type: MoveType }
73
74export type Style = {
75 color?: string
76 backgroundColor?: string
77 bold?: boolean
78 dimColor?: boolean
79}
80
81export type Run = { text: string; style: Style }
82
83export type Win = { seconds: number }
84
85// What the board reports to the hooks module: a win.
86export type Message = { won: Win }
87
88const MAX_SECONDS = 5999
89// Seven and eight in the theme's own text and inactive shades, so they show
90// on a light theme as on a dark one.
91const NUMBER_COLORS = [undefined, 'blue', 'green', 'red', 'magenta', 'yellow', 'cyan', 'text', 'inactive']
92// Claude Code's own theme key for its orange, so the face follows the theme.
93const FACE_COLOR = 'claude'
94const FACE_INK = 'black'
95export const FACE_WIDTH = 9
96
97// A small seeded generator (mulberry32): the same number lays the same board.
98export function randomOf(seed: number): () => number {
99 let a = seed >>> 0
100 return () => {
101 a = (a + 0x6d2b79f5) >>> 0
102 let t = a
103 t = Math.imul(t ^ (t >>> 15), t | 1)
104 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
105 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
106 }
107}
108
109// A board with no mines yet: they are laid at the first reveal, around it.
110export function newGame(seed: number, id = 0): Game {
111 return {
112 id,
113 cols: COLS,
114 rows: ROWS,
115 mines: MINES,
116 seed: seed >>> 0,
117 adj: null,
118 marks: new Array<Mark>(COLS * ROWS).fill(HIDDEN),
119 status: 'ready',
120 seconds: 0,
121 opened: 0,
122 flags: 0,
123 boom: -1,
124 cursor: { x: COLS >> 1, y: ROWS >> 1 },
125 acted: 0,
126 isPressing: false,
127 isBlinking: false,
128 gaze: 'player',
129 eyesAt: EYES.centre,
130 mouthAt: MOUTH,
131 reaction: null,
132 }
133}
134
135// A new board, laid from the next number.
136export function again(game: Game): Game {
137 return { ...newGame(game.seed + 1, game.id), acted: game.acted }
138}
139
140export function neighbours(cols: number, rows: number, i: number): number[] {
141 const x = i % cols
142 const y = (i - x) / cols
143 const out: number[] = []
144 for (let dy = -1; dy <= 1; dy++) {
145 for (let dx = -1; dx <= 1; dx++) {
146 const nx = x + dx
147 const ny = y + dy
148 if ((dx !== 0 || dy !== 0) && nx >= 0 && nx < cols && ny >= 0 && ny < rows) out.push(ny * cols + nx)
149 }
150 }
151 return out
152}
153
154function swap(items: number[], i: number, j: number): void {
155 const a = items[i]
156 const b = items[j]
157 if (a === undefined || b === undefined) return
158 items[i] = b
159 items[j] = a
160}
161
162// Per cell, the mines around it, or MINE. The first cell and the cells
163// around it stay clear, so the first reveal always opens an area.
164export function layout(cols: number, rows: number, mines: number, seed: number, first: number): number[] {
165 const clear = new Set([first, ...neighbours(cols, rows, first)])
166 const free: number[] = []
167 for (let i = 0; i < cols * rows; i++) if (!clear.has(i)) free.push(i)
168 const random = randomOf(seed)
169 const count = Math.min(mines, free.length)
170 for (let i = 0; i < count; i++) swap(free, i, i + Math.floor(random() * (free.length - i)))
171 const placed = free.slice(0, count)
172 const adj = new Array<number>(cols * rows).fill(0)
173 for (const mine of placed) adj[mine] = MINE
174 for (const mine of placed) {
175 for (const n of neighbours(cols, rows, mine)) {
176 const around = adj[n]
177 if (around !== undefined && around !== MINE) adj[n] = around + 1
178 }
179 }
180 return adj
181}
182
183function isOver(game: Game): boolean {
184 return game.status === 'won' || game.status === 'lost'
185}
186
187function isInside(game: Game, i: number): boolean {
188 return Number.isInteger(i) && i >= 0 && i < game.cols * game.rows
189}
190
191// Open the cells and everything an empty one leads to; a mine ends the game.
192// `adj` is the game's laid mines, passed apart so a caller can lay them here.
193function openCells(game: Game, adj: readonly number[], starts: readonly number[]): Game {
194 const marks = game.marks.slice()
195 const queue = [...starts]
196 let opened = game.opened
197 let boom = -1
198 for (let i = queue.pop(); i !== undefined; i = queue.pop()) {
199 if (marks[i] !== HIDDEN) continue
200 marks[i] = OPEN
201 if (adj[i] === MINE) {
202 if (boom === -1) boom = i
203 continue
204 }
205 opened += 1
206 if (adj[i] === 0) queue.push(...neighbours(game.cols, game.rows, i))
207 }
208 const dealt = { ...game, adj, marks, opened, status: 'playing' as const }
209 if (boom !== -1) return { ...dealt, boom, status: 'lost' }
210 if (opened < game.cols * game.rows - game.mines) return dealt
211 for (let i = 0; i < marks.length; i++) if (adj[i] === MINE) marks[i] = FLAG
212 return { ...dealt, flags: game.mines, status: 'won' }
213}
214
215// On an open number with as many flags around it as it says: open the rest.
216// The same game back when there is nothing to open.
217function chord(game: Game, adj: readonly number[], i: number): Game {
218 const count = adj[i] ?? 0
219 const around = neighbours(game.cols, game.rows, i)
220 const flagged = around.filter((n) => game.marks[n] === FLAG).length
221 if (count <= 0 || flagged !== count) return game
222 const hidden = around.filter((n) => game.marks[n] === HIDDEN)
223 return hidden.length === 0 ? game : openCells(game, adj, hidden)
224}
225
226export function reveal(game: Game, i: number): Game {
227 if (isOver(game) || !isInside(game, i) || game.marks[i] === FLAG) return game
228 if (game.marks[i] === OPEN) return game.adj === null ? game : chord(game, game.adj, i)
229 return openCells(game, game.adj ?? layout(game.cols, game.rows, game.mines, game.seed, i), [i])
230}
231
232export function toggleFlag(game: Game, i: number): Game {
233 if (isOver(game) || !isInside(game, i) || game.marks[i] === OPEN) return game
234 const marks = game.marks.slice()
235 const isSet = marks[i] !== FLAG
236 marks[i] = isSet ? FLAG : HIDDEN
237 return { ...game, marks, flags: game.flags + (isSet ? 1 : -1) }
238}
239
240export function cursorIndex(game: Game): number {
241 return game.cursor.y * game.cols + game.cursor.x
242}
243
244export function moveTo(game: Game, i: number): Game {
245 const x = i % game.cols
246 const y = (i - x) / game.cols
247 return game.cursor.x === x && game.cursor.y === y ? game : { ...game, cursor: { x, y } }
248}
249
250export function move(game: Game, dx: number, dy: number): Game {
251 const x = Math.max(0, Math.min(game.cols - 1, game.cursor.x + dx))
252 const y = Math.max(0, Math.min(game.rows - 1, game.cursor.y + dy))
253 return moveTo(game, y * game.cols + x)
254}
255
256const STEPS: Readonly<Record<'up' | 'down' | 'left' | 'right', readonly [number, number]>> = {
257 up: [0, -1],
258 down: [0, 1],
259 left: [-1, 0],
260 right: [1, 0],
261}
262
263// One move by name, at the cursor: what a key or a control button asks for.
264export function act(game: Game, type: MoveType): Game {
265 switch (type) {
266 case 'up':
267 case 'down':
268 case 'left':
269 case 'right':
270 return move(game, STEPS[type][0], STEPS[type][1])
271 case 'reveal':
272 return reveal(game, cursorIndex(game))
273 case 'flag':
274 return toggleFlag(game, cursorIndex(game))
275 case 'new':
276 return again(game)
277 }
278}
279
280// Every move newer than the last one played, in order. Presses can outrun
281// the board's redraws, so one redraw may bring several. Numbers lower than
282// the last one played mean the hooks module started counting again, as it
283// does when the plugin reloads, so every move it lists is new. The same game
284// back when there is nothing new.
285export function applyActs(game: Game, acts: readonly Act[]): Game {
286 const last = acts.at(-1)?.n ?? 0
287 let next = last < game.acted ? { ...game, acted: 0 } : game
288 for (const move of acts) {
289 if (move.n > next.acted) next = { ...act(next, move.type), acted: move.n }
290 }
291 return next
292}
293
294// One second of play. The clock runs from the first reveal to the last.
295export function tick(game: Game): Game {
296 if (game.status !== 'playing' || game.seconds >= MAX_SECONDS) return game
297 return { ...game, seconds: game.seconds + 1 }
298}
299
300// Two columns per cell where the pane has the room, so cells look square.
301export function cellWidthFor(cols: number, columns: number): 1 | 2 {
302 return columns >= cols * 2 ? 2 : 1
303}
304
305// The cell under a pointer position given in the board's own cells, or -1.
306export function cellAt(game: Game, x: number, y: number, cellWidth: number, cellHeight = 1): number {
307 const cx = Math.floor(x / cellWidth)
308 const cy = Math.floor(y / cellHeight)
309 if (x < 0 || cy < 0 || cx >= game.cols || cy >= game.rows) return -1
310 return cy * game.cols + cx
311}
312
313// What a cell shows, decided once for every way the board is drawn.
314// A lost board shows every mine: the one stepped on in red, the ones left
315// unflagged, and each flag that found one as a red flag on an open cell. A
316// flag that was wrong stays, faint, on an open cell, so it never reads as a
317// mine.
318type Shown =
319 | { kind: 'boom' | 'mine' | 'found' | 'wrong' | 'hidden' | 'empty' }
320 | { kind: 'flag'; isWon: boolean }
321 | { kind: 'number'; count: number }
322
323function shownAt(game: Game, i: number): Shown {
324 const mark = game.marks[i]
325 const count = game.adj === null ? 0 : (game.adj[i] ?? 0)
326 const isMine = count === MINE
327 if (game.status === 'lost') {
328 if (i === game.boom) return { kind: 'boom' }
329 if (isMine) return { kind: mark === FLAG ? 'found' : 'mine' }
330 if (mark === FLAG) return { kind: 'wrong' }
331 }
332 if (mark === FLAG) return { kind: 'flag', isWon: game.status === 'won' }
333 if (mark === HIDDEN) return { kind: 'hidden' }
334 return count === 0 ? { kind: 'empty' } : { kind: 'number', count }
335}
336
337function numberInk(count: number): Style {
338 const color = NUMBER_COLORS[count]
339 return color === undefined ? { bold: true } : { color, bold: true }
340}
341
342function lookOf(shown: Shown): Style & { glyph: string } {
343 switch (shown.kind) {
344 case 'boom':
345 return { glyph: '*', color: 'white', backgroundColor: 'red', bold: true }
346 case 'mine':
347 return { glyph: '*', color: 'red' }
348 case 'found':
349 return { glyph: '⚑', color: 'red', bold: true }
350 case 'wrong':
351 return { glyph: '⚑', dimColor: true }
352 case 'flag':
353 return { glyph: '⚑', color: shown.isWon ? 'green' : 'red', bold: true }
354 case 'hidden':
355 return { glyph: '■' }
356 case 'empty':
357 return { glyph: '·', dimColor: true }
358 case 'number':
359 return { glyph: String(shown.count), ...numberInk(shown.count) }
360 }
361}
362
363const STYLE_KEYS = ['color', 'backgroundColor', 'bold', 'dimColor'] as const
364
365// The cursor lights its glyph alone, in the face's colours, so it stays one
366// cell wide: lighting the cell's trailing space too drew a white block beside it.
367const CURSOR_STYLE: Style = { color: FACE_INK, backgroundColor: FACE_COLOR, bold: true }
368
369// The cursor shows only while the game can take a move, so the mine that
370// ended a game is drawn as that mine.
371function isCursorAt(game: Game, x: number, y: number): boolean {
372 return !isOver(game) && game.cursor.x === x && game.cursor.y === y
373}
374
375function pushRun(runs: Run[], text: string, style: Style): void {
376 const last = runs.at(-1)
377 if (last && STYLE_KEYS.every((key) => last.style[key] === style[key])) last.text += text
378 else runs.push({ text, style })
379}
380
381// One board row a column a cell, as runs of text that share a style.
382export function rowRuns(game: Game, y: number): Run[] {
383 const runs: Run[] = []
384 for (let x = 0; x < game.cols; x++) {
385 const { glyph, ...style } = lookOf(shownAt(game, y * game.cols + x))
386 pushRun(runs, glyph, isCursorAt(game, x, y) ? CURSOR_STYLE : style)
387 }
388 return runs
389}
390
391// How the board fits the room the pane gives it: each cell `width` columns
392// by `height` rows, and the face and the status `side` by side with the board
393// or under it. One column by one row is the classic board; from two columns
394// a hidden tile is a short box, and from two rows a cell is a tile, square on
395// screen, so the board grows into a big pane.
396export type Fit = { width: number; height: number; side: boolean }
397
398const SCALES = [3, 2] as const
399const SIDE_COLUMNS = 26
400// Beside the board, a column of the face's four rows, a blank row, the three
401// status parts, the credit, and the outcome in up to two lines.
402const SIDE_ROWS = 4 + 1 + 3 + 1 + 2
403// The columns an inline pane's frame takes from the terminal's width.
404const INLINE_FRAME = 4
405const UNDER_ROWS = 5
406// Claude Code's own theme keys, so the tiles follow the theme: a hidden tile
407// in the shade of its inactive text, an open one in the shade it puts behind
408// the person's own messages.
409const TILE = 'inactive'
410const OPEN_TILE = 'userMessageBackground'
411
412// The rows a fit takes in the board's own region: the board and, under it,
413// a blank row and the face's four; or, beside it, the face's column. The
414// board draws every one of them: a pane above the prompt shrinks to what is
415// drawn, and a shorter drawing would leave too little room for this fit.
416export function rowsFor(fit: Fit, rows: number): number {
417 return fit.side ? Math.max(rows * fit.height, SIDE_ROWS) : rows * fit.height + UNDER_ROWS
418}
419
420function columnsFor(fit: Fit, cols: number): number {
421 return cols * fit.width + (fit.side ? SIDE_COLUMNS : 0)
422}
423
424// Every fit, biggest cells first; under the board before beside it.
425const FITS: readonly Fit[] = [
426 ...SCALES.flatMap((k) => [
427 { width: 2 * k, height: k, side: false },
428 { width: 2 * k, height: k, side: true },
429 ]),
430 { width: 3, height: 1, side: false },
431 { width: 3, height: 1, side: true },
432 { width: 2, height: 1, side: false },
433 { width: 2, height: 1, side: true },
434 { width: 1, height: 1, side: false },
435 { width: 1, height: 1, side: true },
436]
437
438// The biggest fit the room holds; with the room unknown, or too small for
439// any, a row a cell, two columns wide where the room has them, the face under
440// the board.
441export function fitFor(cols: number, rows: number, room: { columns: number; rows: number }): Fit {
442 const fit = FITS.find((f) => columnsFor(f, cols) <= room.columns && rowsFor(f, rows) <= room.rows)
443 return fit ?? { width: cellWidthFor(cols, room.columns), height: 1, side: false }
444}
445
446// The fit with the fewest rows a width holds: the one a pane above the
447// prompt asks for, so it takes as little of the transcript as it can.
448function compactFit(cols: number, columns: number): Fit {
449 const fits = FITS.filter((f) => f.height === 1 && columnsFor(f, cols) <= columns)
450 return fits.find((f) => f.side) ?? fits[0] ?? { width: 1, height: 1, side: false }
451}
452
453type Tile = { glyph: string; ink: Style; fill: string }
454
455function tileOf(game: Game, x: number, y: number): Tile {
456 const shown = shownAt(game, y * game.cols + x)
457 if (isCursorAt(game, x, y)) {
458 const glyph = shown.kind === 'flag' ? '⚑' : shown.kind === 'number' ? String(shown.count) : ' '
459 return { glyph, ink: { color: FACE_INK, bold: true }, fill: FACE_COLOR }
460 }
461 switch (shown.kind) {
462 case 'boom':
463 return { glyph: '*', ink: { color: 'white', bold: true }, fill: 'red' }
464 case 'mine':
465 return { glyph: '*', ink: { color: 'black', bold: true }, fill: TILE }
466 case 'found':
467 return { glyph: '⚑', ink: { color: 'red', bold: true }, fill: OPEN_TILE }
468 case 'wrong':
469 return { glyph: '⚑', ink: { dimColor: true }, fill: OPEN_TILE }
470 case 'flag':
471 return { glyph: '⚑', ink: { color: 'white', bold: true }, fill: shown.isWon ? 'green' : 'red' }
472 case 'hidden':
473 return { glyph: ' ', ink: {}, fill: TILE }
474 case 'empty':
475 return { glyph: ' ', ink: {}, fill: OPEN_TILE }
476 case 'number':
477 return { glyph: String(shown.count), ink: numberInk(shown.count), fill: OPEN_TILE }
478 }
479}
480
481// At a row a cell, a hidden tile is a box drawn short of its row, so a gap
482// shows between rows as one does between columns: two columns of three
483// quarter blocks and a gap, or a square of two quarter blocks.
484const SHORT_TILES: Readonly<Record<number, string>> = { 2: '▗▖', 3: '▆▆ ' }
485
486// A glyph fills its row top to bottom, so a tile that shows one cannot be
487// a short box. A number, a flag or a mine stands on the bare board, as on
488// the classic one, and an open empty cell is bare. The cursor lights an open
489// cell as wide as a box, its number or its dot on the face's colour, so it
490// never looks like the box it lights on a hidden cell; the mine that went off
491// keeps its colour the same way.
492function shortRow(game: Game, y: number, width: number): Run[] {
493 const runs: Run[] = []
494 const tile = SHORT_TILES[width] ?? ' '.repeat(width)
495 const across = Math.max(1, width - 1)
496 for (let x = 0; x < game.cols; x++) {
497 const shown = shownAt(game, y * game.cols + x)
498 const isCursor = isCursorAt(game, x, y)
499 if (shown.kind === 'hidden') pushRun(runs, tile, { color: isCursor ? FACE_COLOR : TILE })
500 else if (shown.kind === 'empty' && !isCursor) pushRun(runs, ' '.repeat(width), {})
501 else {
502 const { glyph, ...style } = lookOf(shown)
503 pushRun(runs, glyph + ' '.repeat(across - 1), isCursor ? CURSOR_STYLE : style)
504 pushRun(runs, ' '.repeat(width - across), {})
505 }
506 }
507 return runs
508}
509
510// The board's screen rows as runs of text that share a style. A tile is
511// `width - 1` columns of colour with a gap after it, its last row a half
512// block so the gap between rows is half a row, as the gap between columns is.
513export function boardRows(game: Game, fit: Fit): Run[][] {
514 const out: Run[][] = []
515 if (fit.height === 1) {
516 for (let y = 0; y < game.rows; y++) out.push(fit.width === 1 ? rowRuns(game, y) : shortRow(game, y, fit.width))
517 return out
518 }
519 const across = fit.width - 1
520 const glyphRow = Math.floor((fit.height - 1) / 2)
521 const glyphAt = Math.floor(across / 2)
522 for (let y = 0; y < game.rows; y++) {
523 const tiles: Tile[] = []
524 for (let x = 0; x < game.cols; x++) tiles.push(tileOf(game, x, y))
525 for (let r = 0; r < fit.height; r++) {
526 const runs: Run[] = []
527 for (const { glyph, ink, fill } of tiles) {
528 if (r === fit.height - 1) pushRun(runs, '▀'.repeat(across), { color: fill })
529 else if (r === glyphRow) pushRun(runs, ' '.repeat(glyphAt) + glyph + ' '.repeat(across - glyphAt - 1), { ...ink, backgroundColor: fill })
530 else pushRun(runs, ' '.repeat(across), { backgroundColor: fill })
531 pushRun(runs, ' ', {})
532 }
533 out.push(runs)
534 }
535 }
536 return out
537}
538
539// The face's clock runs on the board's beats, five a second. A move turns the
540// face to the cursor, and it follows the cursor while the player is busy. Two
541// seconds after the last move it looks around: straight out, at the cursor's
542// side, or left at the transcript beside the pane. After six it reads that
543// transcript, as far as a face can: a few lines, each followed along in three
544// short steps and a jump back. It only looks; the mod sees nothing of the
545// transcript. It blinks every few seconds, less often while it reads,
546// sometimes twice, often as its gaze jumps.
547const TRACK_BEATS = 10
548const READ_AFTER = 30
549const WANDER_BEATS: readonly [number, number] = [8, 20]
550const STEP_BEATS: readonly [number, number] = [1, 2]
551const LINES: readonly [number, number] = [2, 4]
552const BLINK_GAPS: Readonly<Record<'track' | 'wander' | 'read', readonly [number, number]>> = {
553 track: [20, 40],
554 wander: [12, 30],
555 read: [30, 50],
556}
557const DOUBLE_BLINK = 0.2
558const BLINK_ON_JUMP = 0.3
559const BLINK_ON_RETURN = 0.3
560const REACT_BEATS: Readonly<Record<Reaction, number>> = { gasp: 2, delight: 3, wink: 2 }
561const BIG_OPENING = 10
562
563// Where the eyes sit, in half cells from the face's left edge: the left eye
564// there, the right one four cells on. The mouth's column, and how far it
565// leans after the eyes.
566const EYES = { left: 2, centre: 4, right: 6 } as const
567const MOUTH = 4
568
569function between(random: () => number, [lo, hi]: readonly [number, number]): number {
570 return lo + Math.floor(random() * (hi - lo + 1))
571}
572
573function blinksFrom(at: number, random: () => number): readonly number[] {
574 return random() < DOUBLE_BLINK ? [at, at + 2] : [at]
575}
576
577// The face as the board appears: looking at the player.
578export function firstMood(n: number, random: () => number): Mood {
579 return {
580 gaze: 'player',
581 since: n,
582 until: n + TRACK_BEATS,
583 path: [],
584 blinks: [n + between(random, BLINK_GAPS.wander)],
585 reaction: null,
586 reactUntil: n,
587 }
588}
589
590function wander(mood: Mood, n: number, random: () => number): Mood {
591 const r = random()
592 const gaze: Gaze = r < 0.5 ? 'player' : r < 0.7 ? 'cursor' : 'left'
593 return { ...mood, gaze, since: n, until: n + between(random, WANDER_BEATS), path: [] }
594}
595
596// A few lines, each looked along in three steps of a beat or two, then a
597// jump back to the next line's start, now and then with a blink.
598function read(mood: Mood, n: number, random: () => number): Mood {
599 const path: number[] = []
600 const blinks: number[] = []
601 const lines = between(random, LINES)
602 for (let line = 0; line < lines; line++) {
603 for (const at of [EYES.left, EYES.left + 1, EYES.left + 2]) {
604 for (let beat = between(random, STEP_BEATS); beat > 0; beat--) path.push(at)
605 }
606 if (random() < BLINK_ON_RETURN) blinks.push(n + path.length)
607 path.push(EYES.left)
608 }
609 return { ...mood, gaze: 'reading', since: n, until: n + path.length, path, blinks: [...mood.blinks.filter((b) => b >= n), ...blinks].sort((a, b) => a - b) }
610}
611
612function blinkGap(mood: Mood, idle: number): readonly [number, number] {
613 if (mood.gaze === 'reading') return BLINK_GAPS.read
614 return idle < TRACK_BEATS ? BLINK_GAPS.track : BLINK_GAPS.wander
615}
616
617// One beat of the face's clock; `heard` is the beat of the last move. The
618// same mood back when nothing is due.
619export function nextMood(mood: Mood, n: number, heard: number, random: () => number): Mood {
620 let next = mood
621 if (next.reaction !== null && n >= next.reactUntil) next = { ...next, reaction: null }
622 const idle = n - heard
623 if (n >= next.until) {
624 const before = next.gaze
625 if (idle < TRACK_BEATS) next = { ...next, gaze: 'cursor', since: before === 'cursor' ? next.since : n, until: heard + TRACK_BEATS, path: [] }
626 else if (idle >= READ_AFTER && before !== 'reading') next = read(next, n, random)
627 else next = wander(next, n, random)
628 if (next.gaze !== before && random() < BLINK_ON_JUMP) next = { ...next, blinks: [n, ...next.blinks.filter((b) => b > n)] }
629 }
630 const last = next.blinks.at(-1)
631 if (last === undefined || n > last) next = { ...next, blinks: blinksFrom(n + between(random, blinkGap(next, idle)), random) }
632 return next
633}
634
635// A move: the face turns to the cursor and follows it.
636export function attend(mood: Mood, n: number): Mood {
637 if (mood.gaze === 'cursor' && mood.until >= n + TRACK_BEATS) return mood
638 return { ...mood, gaze: 'cursor', since: mood.gaze === 'cursor' ? mood.since : n, until: n + TRACK_BEATS, path: [] }
639}
640
641export function react(mood: Mood, n: number, reaction: Reaction): Mood {
642 return { ...mood, reaction, reactUntil: n + REACT_BEATS[reaction] }
643}
644
645// What a move deserves: delight at a win or a big opening, a gasp at any
646// other reveal, a wink at a new flag.
647export function reactionTo(before: Game, after: Game): Reaction | null {
648 if (after.status === 'won' && before.status !== 'won') return 'delight'
649 if (after.id !== before.id || after.seed !== before.seed) return null
650 if (after.opened - before.opened >= BIG_OPENING) return 'delight'
651 if (after.opened > before.opened) return 'gasp'
652 if (after.flags > before.flags) return 'wink'
653 return null
654}
655
656function eyesFor(game: Game, mood: Mood, n: number): number {
657 switch (mood.gaze) {
658 case 'player':
659 return EYES.centre
660 case 'left':
661 return EYES.left
662 case 'reading':
663 return mood.path[n - mood.since] ?? EYES.left
664 case 'cursor': {
665 const third = game.cols / 3
666 if (game.cursor.x < third) return EYES.left
667 return game.cursor.x >= game.cols - third ? EYES.right : EYES.centre
668 }
669 }
670}
671
672// What the game shows of the mood on beat n: the same game when nothing shows.
673// The mouth leans after the eyes a beat later, as a head follows a glance.
674export function looks(game: Game, mood: Mood, n: number): Game {
675 const isBlinking = mood.blinks.includes(n)
676 const eyesAt = eyesFor(game, mood, n)
677 const leaning = mood.gaze === 'reading' || eyesAt < EYES.centre ? MOUTH - 1 : eyesAt > EYES.centre ? MOUTH + 1 : MOUTH
678 const mouthAt = eyesAt === game.eyesAt ? leaning : game.mouthAt
679 const reaction = mood.reaction !== null && n < mood.reactUntil ? mood.reaction : null
680 if (game.gaze === mood.gaze && game.eyesAt === eyesAt && game.mouthAt === mouthAt && game.isBlinking === isBlinking && game.reaction === reaction) return game
681 return { ...game, gaze: mood.gaze, eyesAt, mouthAt, isBlinking, reaction }
682}
683
684function placed(glyph: string, at: number): string {
685 return (' '.repeat(at) + glyph).padEnd(FACE_WIDTH)
686}
687
688// Two eyes four cells apart, the left one on the cell `at` half cells in.
689function eyePair(at: number, left: string, right: string): string {
690 return placed(left + ' ' + right, Math.floor(at / 2))
691}
692
693// Half-shut eyes looking down along a line: a lower half block on a whole
694// cell, or two quarter blocks halfway between two cells.
695function lidded(at: number): string {
696 const cells = new Array<string>(FACE_WIDTH).fill(' ')
697 for (const eye of [at, at + 8]) {
698 const cell = Math.floor(eye / 2)
699 if (eye % 2 === 0) cells[cell] = '▄'
700 else {
701 cells[cell] = '▗'
702 cells[cell + 1] = '▖'
703 }
704 }
705 return cells.join('')
706}
707
708// The face's two inner rows, each FACE_WIDTH wide: crosses and a flat
709// mouth for a lost game, delight and then sunglasses for a won one, wide eyes
710// at a reveal. Otherwise dot eyes where the gaze puts them, half shut while
711// reading, shut on a blink.
712export function faceOf(game: Game): { eyes: string; mouth: string } {
713 if (game.status === 'lost') return { eyes: ' x x ', mouth: ' — ' }
714 if (game.status === 'won') {
715 return game.reaction === 'delight' ? { eyes: ' ^ ^ ', mouth: placed('ω', MOUTH) } : { eyes: ' ▀██▀██▀ ', mouth: ' ╰───╯ ' }
716 }
717 if (game.isPressing || game.reaction === 'gasp') return { eyes: ' O O ', mouth: ' o ' }
718 if (game.reaction === 'delight') return { eyes: ' ^ ^ ', mouth: placed('ω', MOUTH) }
719 const mouth = placed('ω', game.mouthAt)
720 if (game.reaction === 'wink') return { eyes: eyePair(game.eyesAt, '─', '●'), mouth }
721 if (game.isBlinking) return { eyes: eyePair(game.eyesAt, '─', '─'), mouth }
722 if (game.gaze === 'reading') return { eyes: lidded(game.eyesAt), mouth }
723 return { eyes: eyePair(game.eyesAt, '●', '●'), mouth }
724}
725
726// The face as four rows of styled text, like a board row's runs.
727export function faceRows(game: Game): Run[] {
728 const { eyes, mouth } = faceOf(game)
729 const edge: Style = { color: FACE_COLOR }
730 const inner: Style = { color: FACE_INK, backgroundColor: FACE_COLOR, bold: true }
731 return [
732 { text: ' ▄▄▄▄▄▄▄ ', style: edge },
733 { text: eyes, style: inner },
734 { text: mouth, style: inner },
735 { text: ' ▀▀▀▀▀▀▀ ', style: edge },
736 ]
737}
738
739export function clock(seconds: number): string {
740 return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0')
741}
742
743export function statusParts(game: Game, best: Best): string[] {
744 const parts = [`Mines ${game.mines - game.flags}`, `Time ${clock(game.seconds)}`]
745 if (best !== null) parts.push(`Best ${clock(best)}`)
746 return parts
747}
748
749// Between the parts of the status line, and before the credit after them.
750export const SEPARATOR = ' · '
751
752export function statusLine(game: Game, best: Best): string {
753 return statusParts(game, best).join(SEPARATOR)
754}
755
756export function outcomeLine(game: Game, best: Best): string {
757 switch (game.status) {
758 case 'ready':
759 return 'Reveal a cell to start; the first is safe.'
760 case 'lost':
761 return 'Boom! Press n to try again.'
762 case 'playing':
763 return `${game.cols * game.rows - game.mines - game.opened} safe cells left.`
764 case 'won': {
765 const isBest = best === null || game.seconds <= best
766 return `Cleared in ${clock(game.seconds)}.${isBest ? ' Best time!' : ''}`
767 }
768 }
769}
770
771function isSeconds(value: unknown): value is number {
772 return Number.isInteger(value) && typeof value === 'number' && value >= 0 && value <= MAX_SECONDS
773}
774
775function isRecord(value: unknown): value is Record<string, unknown> {
776 return value !== null && typeof value === 'object' && !Array.isArray(value)
777}
778
779// The best time as the store may hand it back: whole seconds, or null.
780export function bestOf(value: unknown): Best {
781 return isSeconds(value) ? value : null
782}
783
784// The same time back when the new one is no better, so a caller can tell.
785export function betterBest(best: Best, seconds: number): Best {
786 return best !== null && best <= seconds ? best : seconds
787}
788
789// The better of two records, so a write never loses one.
790export function mergeBest(a: Best, b: Best): Best {
791 return b === null ? a : betterBest(a, b)
792}
793
794function winOf(value: unknown): Win | null {
795 if (!isRecord(value) || !isSeconds(value.seconds)) return null
796 return { seconds: value.seconds }
797}
798
799// What the board may post to the hooks module. It comes from code, so it is
800// checked here: a post without a valid win is null.
801export function messageOf(data: unknown): Message | null {
802 if (!isRecord(data)) return null
803 const won = winOf(data.won)
804 return won === null ? null : { won }
805}
806
807// The pane's own rows: a blank row above the board, the control buttons and
808// a line of hints below it.
809export const CHROME_ROWS = 3
810
811// The columns a docked pane asks for: room to grow the board into big tiles.
812const DOCKED_COLUMNS = 60
813
814// A docked pane opens to the columns it asks for, floor to ceiling, and a
815// pane above the prompt to the rows it asks for, across the terminal; each
816// ignores the other. Above the prompt it asks for the rows of the most
817// compact fit the terminal's width holds, the face beside the board where it
818// can be.
819export function paneSize(terminalColumns: number): { rows: number; columns: number } {
820 const fit = compactFit(COLS, terminalColumns - INLINE_FRAME)
821 return { rows: rowsFor(fit, ROWS) + CHROME_ROWS, columns: DOCKED_COLUMNS }
822}
823