SLOPSHOPPER

minefield

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…

newpanecommandtimer
★ 2v0.2.0MITupdated 2026-10-05reporails/arcade/minefield
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · minefield
│ ┃ Minefield ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▣ client module ./board.ts ⏺ Read(src/auth.ts) │ ┃ n: New r: Reveal f: Flag w: ↑ a: ← s: ↓ ⎿ Read 6 lines │ ┃ ctrl+x tab gives the pane the keys again. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mines │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Minefield
▣ client module ./board.ts n: New r: Reveal f: Flag w: ↑ a: ← s: ↓ d: → ctrl+x tab gives the pane the keys again.
README

Minefield

Minefield docked beside a Claude Code session: the board in big tiles, the face looking over at Claude's answer

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.
 ▀▀▀▀▀▀▀

Install

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.

Commands

InputWhat it does
/minesOpens 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.

Playing

InputWhat it does
Left clickReveals the cell. On an open number with all its flags set, opens the cells around it.
Right click, or ctrl/alt + left clickSets or clears a flag.
rReveals the cell under the cursor.
fFlags the cell under the cursor.
w a s dMove the cursor.
nNew game.
Arrow keys, h j k l, space, returnMove and reveal, once a click has given the board the keyboard.
EscHands 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.

The face

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.

WhenThe face
You are playingFollows the cursor to its side of the board.
Two seconds without a moveLooks around: up at you, at the cursor's side, or left at the transcript beside the pane.
Six seconds without a moveReads 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.
AlwaysBlinks every few seconds, sometimes twice, often as its gaze jumps. The mouth leans after the eyes.
A revealWide eyes and a round mouth; delight when a reveal opens ten cells or more.
A flagA wink.
Stepped on a minex x and a flat mouth.
Board clearedDelight, then sunglasses and a wide grin.

Files

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:

  • A pane takes the keyboard only over an empty prompt, and a command's own text is still there while it runs. So /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.
  • A pane's hotkeys reach only buttons the hooks module draws, not buttons a surface module draws. So the control buttons live in register.ts and hand each move to the board as a numbered act prop.
  • A row of boxes shrinks every box to fit, and a face one column short wraps each of its rows in two. So the face's column has a fixed width and the text beside it is the part that gets cut.
  • The mouse exists only in the fullscreen layout: on the main screen Claude Code asks the terminal for no pointer reporting at all. So every action has a key, and the letter keys work the moment the pane opens.

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.

Tested on

  • Claude Code 2.1.288, where mods are on by default: claude plugin validate --strict passes and claude plugin test passes 31 of 31, from a logged-out config, the way CI runs them.
  • Claude Code 2.1.285: strict 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.
  • Played in the terminal in both layouts, through a loss; the big tiles, the face's reading and its blinks watched live in a docked pane. A win and its stored best time are covered by the tests, since a live win writes to the real store.
  • Not yet tried: the desktop app outside the test kit.

If it does not start

  • 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).
  • In VS Code's chat panel and 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.

Source 3 files
hooks/register.ts 231 lines
1// 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}
231
hooks/board.ts 266 lines
1// 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
266
hooks/lib.ts 823 lines
1// 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