@g3nomaly/g3n-mods 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ // packages/g3n-mods/mods/mini-game/hooks/mole.ts
2
+ import type { Whack } from '../types'
3
+
4
+ const SIZE = 4
5
+ export const LIVES = 3
6
+ /** How long the first mole stays up. */
7
+ const START_WINDOW_MS = 1400
8
+ /** How long a mole stays up at the fastest. */
9
+ const MIN_WINDOW_MS = 450
10
+ /** What each hit multiplies the window by, so the game speeds up. */
11
+ const SPEEDUP = 0.94
12
+
13
+ /**
14
+ * Creates a game in the `ready` state, with no mole up.
15
+ *
16
+ * @returns The new game, see `Whack`.
17
+ */
18
+ export function newWhack(): Whack {
19
+ return { size: SIZE, mole: null, moleId: 0, score: 0, lives: LIVES, windowMs: START_WINDOW_MS, status: 'ready' }
20
+ }
21
+
22
+ /**
23
+ * Puts the next mole up, in a hole other than the current one.
24
+ *
25
+ * @param game - Game to put the mole up in, see `Whack`.
26
+ * @returns The game with the new mole and its id.
27
+ */
28
+ function nextMole(game: Whack): Whack {
29
+ const holes = game.size * game.size
30
+ const offset = 1 + Math.floor(Math.random() * (holes - 1))
31
+ const mole = game.mole === null ? Math.floor(Math.random() * holes) : (game.mole + offset) % holes
32
+ return { ...game, mole, moleId: game.moleId + 1 }
33
+ }
34
+
35
+ /**
36
+ * Takes a life; the last one ends the game.
37
+ *
38
+ * @param game - Game to take the life from, see `Whack`.
39
+ * @returns The game after the loss.
40
+ */
41
+ function loseLife(game: Whack): Whack {
42
+ const lives = game.lives - 1
43
+ return lives === 0 ? { ...game, lives, mole: null, status: 'over' } : { ...game, lives }
44
+ }
45
+
46
+ /**
47
+ * Starts the game with its first mole up.
48
+ *
49
+ * @param game - Game to start, see `Whack`.
50
+ * @returns The game in the `playing` state, or the same game when it is already on.
51
+ */
52
+ export function startWhack(game: Whack): Whack {
53
+ return game.status === 'playing' ? game : nextMole({ ...newWhack(), status: 'playing' })
54
+ }
55
+
56
+ /**
57
+ * Whacks one hole: the mole scores and speeds the game up, an empty hole costs a life.
58
+ *
59
+ * @param game - Game to whack in, see `Whack`.
60
+ * @param hole - Hole number, counted row by row from `0`.
61
+ * @returns The game after the whack, or the same game when it is not on.
62
+ */
63
+ export function whack(game: Whack, hole: number): Whack {
64
+ if (game.status !== 'playing') return game
65
+ if (hole !== game.mole) return loseLife(game)
66
+ const windowMs = Math.max(MIN_WINDOW_MS, Math.round(game.windowMs * SPEEDUP))
67
+ return nextMole({ ...game, score: game.score + 1, windowMs })
68
+ }
69
+
70
+ /**
71
+ * Lets a mole escape when its time runs out, which costs a life.
72
+ *
73
+ * @param game - Game the mole was up in, see `Whack`.
74
+ * @param moleId - Mole whose time ran out; a mole already whacked or gone is ignored.
75
+ * @returns The game after the escape, or the same game when that mole is no longer up.
76
+ */
77
+ export function escape(game: Whack, moleId: number): Whack {
78
+ if (game.status !== 'playing' || game.moleId !== moleId) return game
79
+ const after = loseLife(game)
80
+ return after.status === 'over' ? after : nextMole(after)
81
+ }
82
+
83
+ /**
84
+ * Ends a round early, keeping its score.
85
+ *
86
+ * @param game - Game to end, see `Whack`.
87
+ * @returns The game in the `over` state, or the same game when it is not on.
88
+ */
89
+ export function quit(game: Whack): Whack {
90
+ return game.status === 'playing' ? { ...game, mole: null, status: 'over' } : game
91
+ }
@@ -1,123 +1,69 @@
1
1
  // packages/g3n-mods/mods/mini-game/hooks/register.tsx
2
2
  import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderChildren } from 'claude-code'
4
4
 
5
- import type { Game } from '../types'
5
+ import type { Game, View, Whack } from '../types'
6
+ import { adjacentMines, newGame, reveal, toggleFlag } from './minesweeper'
7
+ import { LIVES, escape, newWhack, quit, startWhack, whack as whackHole } from './mole'
6
8
 
7
9
  const PANE = 'mini-game'
8
- const TITLE = 'Minesweeper'
10
+ const TITLES: Record<View, string> = { mines: 'Minesweeper', mole: 'Whack-a-mole' }
11
+ /** What `/mini-game` takes after its name, each mapped to the game it opens. */
12
+ const VIEW_ARGS: Record<string, View> = { mines: 'mines', minesweeper: 'mines', mole: 'mole', whack: 'mole' }
13
+
14
+ const view = atom({ plugin: 'mini-game', key: 'view' } as const, 'mines')
9
15
  const game = atom({ plugin: 'mini-game', key: 'game' } as const, null)
10
16
  const best = atom({ plugin: 'mini-game', key: 'best' } as const, null)
17
+ const whack = atom({ plugin: 'mini-game', key: 'whack' } as const, null)
18
+ const whackBest = atom({ plugin: 'mini-game', key: 'whackBest' } as const, null)
11
19
 
12
- const SIZE = 9
13
- const MINES = 10
14
20
  const BEST_KEY = 'best-seconds'
21
+ const WHACK_BEST_KEY = 'whack-best'
15
22
  /** How long a turn runs before the mod suggests a game, once per session. */
16
23
  const HINT_AFTER_MS = 60_000
17
- /** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`) on any surface. */
24
+ /** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`, `🐹`) on any surface. */
18
25
  const CELL_WIDTH = 2
19
26
 
20
27
  const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
21
28
 
22
29
  /**
23
- * Lists the cells around `cell` on a `size` × `size` board.
24
- *
25
- * @param cell - Cell number, counted row by row from `0`.
26
- * @param size - Board width and height in cells.
27
- * @returns The neighboring cell numbers, from one to eight of them.
28
- */
29
- export function neighbors(cell: number, size: number): number[] {
30
- const row = Math.floor(cell / size)
31
- const col = cell % size
32
- const found: number[] = []
33
- for (let dr = -1; dr <= 1; dr++) {
34
- for (let dc = -1; dc <= 1; dc++) {
35
- const r = row + dr
36
- const c = col + dc
37
- if ((dr !== 0 || dc !== 0) && r >= 0 && r < size && c >= 0 && c < size) found.push(r * size + c)
38
- }
39
- }
40
- return found
41
- }
42
-
43
- /**
44
- * Counts the mines in the cells around `cell`.
45
- *
46
- * @param board - Board to read, see `Game`.
47
- * @param cell - Cell number to count around.
48
- * @returns Number of neighboring mines, from `0` to `8`.
49
- */
50
- function adjacentMines(board: Game, cell: number): number {
51
- return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
52
- }
53
-
54
- /**
55
- * Picks random cells for the mines, leaving `safe` and the cells around it free.
56
- *
57
- * @param board - Board to place mines on, see `Game`.
58
- * @param safe - Cell the first reveal hit, which must not be a mine.
59
- * @returns Mine cell numbers, `board.mineCount` of them.
60
- */
61
- function placeMines(board: Game, safe: number): number[] {
62
- const banned = new Set([safe, ...neighbors(safe, board.size)])
63
- const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
64
- for (let i = free.length - 1; i > 0; i--) {
65
- const j = Math.floor(Math.random() * (i + 1))
66
- ;[free[i], free[j]] = [free[j] ?? 0, free[i] ?? 0]
67
- }
68
- return free.slice(0, board.mineCount)
69
- }
70
-
71
- /**
72
- * @brief Reveals one cell: the first reveal places the mines, a zero opens its
73
- * neighbours, a mine ends the game, and the last safe cell wins it.
30
+ * Shows the pane, titled after the game it holds.
74
31
  *
75
- * @param board - Board to reveal on, see `Game`.
76
- * @param cell - Cell number to reveal.
77
- * @param now - Milliseconds since the epoch, stamped as the start or end time.
78
- * @returns The board after the reveal, or the same board when the cell cannot be revealed.
32
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
79
33
  */
80
- export function reveal(board: Game, cell: number, now: number): Game {
81
- if (board.status === 'won' || board.status === 'lost') return board
82
- if (board.revealed.includes(cell) || board.flagged.includes(cell)) return board
83
-
84
- const mines = board.status === 'ready' ? placeMines(board, cell) : board.mines
85
- const started: Game = board.status === 'ready' ? { ...board, mines, status: 'playing', startedAt: now } : board
86
- if (mines.includes(cell)) return { ...started, revealed: [...started.revealed, cell], status: 'lost', endedAt: now }
87
-
88
- const open = new Set(started.revealed)
89
- const queue = [cell]
90
- while (queue.length > 0) {
91
- const next = queue.pop() ?? cell
92
- if (open.has(next) || started.flagged.includes(next)) continue
93
- open.add(next)
94
- if (adjacentMines(started, next) === 0) queue.push(...neighbors(next, started.size).filter(n => !open.has(n)))
95
- }
96
-
97
- const revealed = [...open]
98
- const isWon = revealed.length === started.size * started.size - started.mineCount
99
- return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
34
+ async function openPane($: EngineInterface): Promise<void> {
35
+ await $.ui.open({ id: PANE, title: TITLES[await read($, view)] })
100
36
  }
101
37
 
102
38
  /**
103
- * Creates a board in the `ready` state, with no mines placed yet.
39
+ * Starts a new Minesweeper board and shows the pane.
104
40
  *
105
- * @param now - Milliseconds since the epoch, stamped as the start time.
106
- * @returns The new board, see `Game`.
41
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
107
42
  */
108
- function newGame(now: number): Game {
109
- return { size: SIZE, mineCount: MINES, mines: [], revealed: [], flagged: [], status: 'ready', isFlagMode: false, startedAt: now }
43
+ async function startGame($: EngineInterface): Promise<void> {
44
+ const now = await $.clock.now()
45
+ await update($, game, () => newGame(now))
46
+ await openPane($)
110
47
  }
111
48
 
112
49
  /**
113
- * Starts a new board and shows the pane.
50
+ * Switches the pane to `next`, setting up its game when there is none to play.
51
+ * Leaving whack-a-mole mid-round ends the round, so moles do not escape unseen.
114
52
  *
115
53
  * @param $ - Engine handle the hook received, see `EngineInterface`.
54
+ * @param next - Game to show.
116
55
  */
117
- async function startGame($: EngineInterface): Promise<void> {
118
- const now = await $.clock.now()
119
- await update($, game, () => newGame(now))
120
- await $.ui.open({ id: PANE, title: TITLE })
56
+ async function showView($: EngineInterface, next: View): Promise<void> {
57
+ await update($, view, () => next)
58
+ if (next === 'mole') {
59
+ await update($, whack, current => current ?? newWhack())
60
+ await openPane($)
61
+ return
62
+ }
63
+ await changeWhack($, quit)
64
+ const current = await read($, game)
65
+ if (current === null || current.status === 'won' || current.status === 'lost') await startGame($)
66
+ else await openPane($)
121
67
  }
122
68
 
123
69
  /**
@@ -130,10 +76,7 @@ async function pressCell($: EngineInterface, cell: number): Promise<void> {
130
76
  const now = await $.clock.now()
131
77
  const after = await update($, game, board => {
132
78
  if (board === null) return board
133
- if (!board.isFlagMode) return reveal(board, cell, now)
134
- if (board.revealed.includes(cell) || board.status === 'won' || board.status === 'lost') return board
135
- const flagged = board.flagged.includes(cell) ? board.flagged.filter(c => c !== cell) : [...board.flagged, cell]
136
- return { ...board, flagged }
79
+ return board.isFlagMode ? toggleFlag(board, cell) : reveal(board, cell, now)
137
80
  })
138
81
  if (after?.status === 'won' && after.endedAt === now) {
139
82
  const seconds = Math.round((now - after.startedAt) / 1000)
@@ -156,12 +99,46 @@ async function toggleFlagMode($: EngineInterface): Promise<void> {
156
99
  }
157
100
 
158
101
  /**
159
- * Redraws the timer once a second while a game is on.
102
+ * Applies `change` to the whack-a-mole game, times each new mole's escape, and
103
+ * records the score when the round ends.
104
+ *
105
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
106
+ * @param change - Pure step from `mole.ts` to apply.
107
+ */
108
+ async function changeWhack($: EngineInterface, change: (current: Whack) => Whack): Promise<void> {
109
+ let wasPlaying = false
110
+ let moleIdBefore = 0
111
+ const after = await update($, whack, current => {
112
+ if (current === null) return current
113
+ wasPlaying = current.status === 'playing'
114
+ moleIdBefore = current.moleId
115
+ return change(current)
116
+ })
117
+ if (after === null) return
118
+
119
+ if (after.status === 'playing' && after.moleId !== moleIdBefore) {
120
+ const moleId = after.moleId
121
+ $.clock.after(after.windowMs, () => void changeWhack($, current => escape(current, moleId)))
122
+ }
123
+
124
+ if (wasPlaying && after.status === 'over') {
125
+ const previous = await read($, whackBest)
126
+ const isBest = after.score > 0 && (previous === null || after.score > previous)
127
+ if (isBest) {
128
+ await update($, whackBest, () => after.score)
129
+ await $.store.set(WHACK_BEST_KEY, after.score)
130
+ }
131
+ $.ui.toast(`Game over: ${after.score} whacked${isBest ? ', a new high score' : ''}`)
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Redraws the timer once a second while a Minesweeper game is on.
160
137
  *
161
138
  * @param $ - Engine handle the hook received, see `EngineInterface`.
162
139
  */
163
140
  async function tick($: EngineInterface): Promise<void> {
164
- if ((await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
141
+ if ((await read($, view)) === 'mines' && (await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
165
142
  }
166
143
 
167
144
  let hintTurn: string | undefined
@@ -177,7 +154,7 @@ async function hint($: EngineInterface, turnId: string): Promise<void> {
177
154
  if (isHinted || hintTurn !== turnId) return
178
155
  if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
179
156
  isHinted = true
180
- $.ui.toast('Waiting on Claude? /mini-game opens Minesweeper.')
157
+ $.ui.toast('Waiting on Claude? /mini-game opens Minesweeper, /mini-game mole opens whack-a-mole.')
181
158
  }
182
159
 
183
160
  /**
@@ -187,19 +164,26 @@ async function hint($: EngineInterface, turnId: string): Promise<void> {
187
164
  */
188
165
  export const register: Register = on => {
189
166
  on('session.start', async ($, e, next) => {
190
- await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
191
- const stored = await $.store.get(BEST_KEY)
192
- if (typeof stored === 'number') await update($, best, () => stored)
193
- void $.ui.open({ id: PANE, title: TITLE })
167
+ await $.command.register({ name: 'mini-game', description: 'Play Minesweeper or whack-a-mole while Claude works' })
168
+ const storedBest = await $.store.get(BEST_KEY)
169
+ if (typeof storedBest === 'number') await update($, best, () => storedBest)
170
+ const storedWhackBest = await $.store.get(WHACK_BEST_KEY)
171
+ if (typeof storedWhackBest === 'number') await update($, whackBest, () => storedWhackBest)
172
+ void openPane($)
194
173
  $.clock.every(1000, () => void tick($))
195
174
  return next(e)
196
175
  })
197
176
 
198
- on('command.run', { command: 'mini-game' }, async $ => {
199
- const current = await read($, game)
200
- if (current === null || current.status === 'won' || current.status === 'lost') await startGame($)
201
- else await $.ui.open({ id: PANE, title: TITLE })
202
- return { text: 'Minesweeper is open. Clear the board without hitting a mine.' }
177
+ on('command.run', { command: 'mini-game' }, async ($, e) => {
178
+ const arg = e.args.trim().toLowerCase()
179
+ const next = arg === '' ? await read($, view) : VIEW_ARGS[arg]
180
+ if (next === undefined) return { text: `Unknown game "${arg}". Use /mini-game mines or /mini-game mole.`, exitCode: 1 }
181
+ await showView($, next)
182
+ return {
183
+ text: next === 'mines'
184
+ ? 'Minesweeper is open. Clear the board without hitting a mine.'
185
+ : 'Whack-a-mole is open. Hit the mole before it hides; three misses and the round ends.',
186
+ }
203
187
  })
204
188
 
205
189
  on('turn.start', async ($, e, next) => {
@@ -216,66 +200,126 @@ export const register: Register = on => {
216
200
 
217
201
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
218
202
  const { Box, Text, Button } = $.ui.resolve(e)
219
- const board = await read($, game)
220
- if (board === null) {
221
- return <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} />
222
- }
203
+ const current = await read($, view)
223
204
 
224
- const now = await $.clock.now()
225
- const elapsed = board.status === 'ready' ? 0 : Math.round(((board.endedAt ?? now) - board.startedAt) / 1000)
226
- const record = await read($, best)
227
- const isOver = board.status === 'won' || board.status === 'lost'
205
+ const tabs = (
206
+ <Box flexDirection="row" columnGap={1}>
207
+ {(Object.keys(TITLES) as View[]).map(v => (
208
+ <Button key={`view:${v}`} label={TITLES[v]} variant={v === current ? 'primary' : 'secondary'} onPress={() => showView($, v)} />
209
+ ))}
210
+ </Box>
211
+ )
212
+
213
+ // Every cell sits in a box of one fixed width, so changing a cell never moves the others: glyphs differ in width.
214
+ const grid = (size: number, face: (cell: number) => RenderChildren) => (
215
+ <Box flexDirection="column">
216
+ {Array.from({ length: size }, (_, r) => (
217
+ <Box flexDirection="row" columnGap={1}>
218
+ {Array.from({ length: size }, (_, c) => (
219
+ <Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{face(r * size + c)}</Box>
220
+ ))}
221
+ </Box>
222
+ ))}
223
+ </Box>
224
+ )
228
225
 
229
226
  /**
230
- * Draws what a cell shows: a number, a flag, a mine, or a button that reveals or flags it.
227
+ * Draws whack-a-mole: lives, score and high score, and the holes, each a button while a round is on.
231
228
  *
232
- * @param cell - Cell number to draw.
233
- * @returns The cell's content.
229
+ * @param round - Game to draw, see `Whack`.
230
+ * @returns The status line, the controls and the holes.
234
231
  */
235
- const cellFace = (cell: number) => {
236
- const isRevealed = board.revealed.includes(cell)
237
- const isMine = board.mines.includes(cell)
238
- if (isRevealed || (isOver && isMine)) {
239
- if (isMine) return <Text color="#e06c6c">✹</Text>
240
- const n = adjacentMines(board, cell)
241
- return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
232
+ const moleView = async (round: Whack) => {
233
+ const record = await read($, whackBest)
234
+ const isPlaying = round.status === 'playing'
235
+
236
+ const holeFace = (hole: number) => {
237
+ if (!isPlaying) return <Text dimColor>○</Text>
238
+ return (
239
+ <Button key={`hole:${hole}`} plain onPress={() => changeWhack($, current => whackHole(current, hole))}>
240
+ {hole === round.mole ? <Text>🐹</Text> : <Text color="#6b7280">○</Text>}
241
+ </Button>
242
+ )
242
243
  }
243
- const label = board.flagged.includes(cell) ? '⚑' : '■'
244
- if (isOver) return <Text dimColor>{label}</Text>
244
+
245
+ const hearts = '♥'.repeat(round.lives) + '♡'.repeat(LIVES - round.lives)
246
+ const recordText = record === null ? '' : ` · best ${record}`
247
+ const status = round.status === 'over'
248
+ ? <Text color="#e06c6c" bold>Game over. {round.score} whacked{recordText}</Text>
249
+ : round.status === 'ready'
250
+ ? <Text dimColor>Hit the mole before it hides{recordText}</Text>
251
+ : <Text><Text color="#e06c6c">{hearts}</Text><Text dimColor> · {round.score} whacked{recordText}</Text></Text>
252
+
245
253
  return (
246
- <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
247
- <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
248
- </Button>
254
+ <Box flexDirection="column" gap={1}>
255
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
256
+ {status}
257
+ {isPlaying ? '' : <Button key="play" label={round.status === 'over' ? 'play again' : 'start'} variant="primary" onPress={() => changeWhack($, startWhack)} />}
258
+ </Box>
259
+ {grid(round.size, holeFace)}
260
+ </Box>
249
261
  )
250
262
  }
251
263
 
252
- // Every cell sits in a box of one fixed width, so revealing a cell never moves the others: glyphs differ in width.
253
- const cellView = (cell: number) => (
254
- <Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{cellFace(cell)}</Box>
255
- )
264
+ /**
265
+ * Draws the Minesweeper board: numbers, flags, mines, and buttons for hidden cells.
266
+ *
267
+ * @param board - Board to draw, see `Game`.
268
+ * @returns The status line, the controls and the board.
269
+ */
270
+ const minesView = async (board: Game) => {
271
+ const now = await $.clock.now()
272
+ const elapsed = board.status === 'ready' ? 0 : Math.round(((board.endedAt ?? now) - board.startedAt) / 1000)
273
+ const record = await read($, best)
274
+ const isOver = board.status === 'won' || board.status === 'lost'
256
275
 
257
- const rows = Array.from({ length: board.size }, (_, r) => (
258
- <Box flexDirection="row" columnGap={1}>
259
- {Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
260
- </Box>
261
- ))
276
+ const cellFace = (cell: number) => {
277
+ const isRevealed = board.revealed.includes(cell)
278
+ const isMine = board.mines.includes(cell)
279
+ if (isRevealed || (isOver && isMine)) {
280
+ if (isMine) return <Text color="#e06c6c">✹</Text>
281
+ const n = adjacentMines(board, cell)
282
+ return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
283
+ }
284
+ const label = board.flagged.includes(cell) ? '⚑' : '■'
285
+ if (isOver) return <Text dimColor>{label}</Text>
286
+ return (
287
+ <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
288
+ <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
289
+ </Button>
290
+ )
291
+ }
262
292
 
263
- const status = board.status === 'won'
264
- ? <Text color="#7cc47f" bold>Cleared in {elapsed}s</Text>
265
- : board.status === 'lost'
266
- ? <Text color="#e06c6c" bold>Boom. {board.revealed.length} cells cleared</Text>
267
- : <Text dimColor>💣 {board.mineCount - board.flagged.length} · ⏱ {elapsed}s{record === null ? '' : ` · best ${record}s`}</Text>
293
+ const status = board.status === 'won'
294
+ ? <Text color="#7cc47f" bold>Cleared in {elapsed}s</Text>
295
+ : board.status === 'lost'
296
+ ? <Text color="#e06c6c" bold>Boom. {board.revealed.length} cells cleared</Text>
297
+ : <Text dimColor>💣 {board.mineCount - board.flagged.length} · ⏱ {elapsed}s{record === null ? '' : ` · best ${record}s`}</Text>
268
298
 
269
- return (
270
- <Box flexDirection="column" gap={1}>
271
- <Box flexDirection="row" justifyContent="space-between" alignItems="center">
272
- {status}
273
- <Box flexDirection="row" columnGap={1}>
274
- {isOver ? '' : <Button key="flag" label={board.isFlagMode ? '⚑ flagging' : '⚑ flag'} variant={board.isFlagMode ? 'primary' : 'secondary'} onPress={() => toggleFlagMode($)} />}
275
- <Button key="new" label="new game" onPress={() => startGame($)} />
299
+ return (
300
+ <Box flexDirection="column" gap={1}>
301
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
302
+ {status}
303
+ <Box flexDirection="row" columnGap={1}>
304
+ {isOver ? '' : <Button key="flag" label={board.isFlagMode ? '⚑ flagging' : '⚑ flag'} variant={board.isFlagMode ? 'primary' : 'secondary'} onPress={() => toggleFlagMode($)} />}
305
+ <Button key="new" label="new game" onPress={() => startGame($)} />
306
+ </Box>
276
307
  </Box>
308
+ {grid(board.size, cellFace)}
277
309
  </Box>
278
- <Box flexDirection="column">{rows}</Box>
310
+ )
311
+ }
312
+
313
+ const board = await read($, game)
314
+ const round = await read($, whack)
315
+ const body = current === 'mole'
316
+ ? await moleView(round ?? newWhack())
317
+ : (board === null ? <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} /> : await minesView(board))
318
+
319
+ return (
320
+ <Box flexDirection="column" gap={1}>
321
+ {tabs}
322
+ {body}
279
323
  </Box>
280
324
  )
281
325
  })
@@ -14,8 +14,25 @@ export type Game = {
14
14
  endedAt?: number
15
15
  }
16
16
 
17
+ /** A whack-a-mole game; holes are numbered row by row from 0. */
18
+ export type Whack = {
19
+ size: number
20
+ /** Hole the mole is up in; `null` before the start and after the end */
21
+ mole: number | null
22
+ /** Counts the moles put up, so a timer knows whether its mole is still the one up */
23
+ moleId: number
24
+ score: number
25
+ lives: number
26
+ /** How long the current mole stays up; shrinks with each hit */
27
+ windowMs: number
28
+ status: 'ready' | 'playing' | 'over'
29
+ }
30
+
31
+ /** The game the pane shows. */
32
+ export type View = 'mines' | 'mole'
33
+
17
34
  declare module 'claude-code' {
18
35
  interface PluginState {
19
- 'mini-game': { game: Game | null; best: number | null }
36
+ 'mini-game': { view: View; game: Game | null; best: number | null; whack: Whack | null; whackBest: number | null }
20
37
  }
21
38
  }