SLOPSHOPPER

pairs

Memory pairs in a pane. Sixteen face-down cards, eight matching pairs; press a card's letter to turn it, find its twin, finish in as few turns and as little…

newpanecommandtoasttimer
v0.1.1MITupdated 2026-10-13MDmubarak786/claude-mods/mods/pairs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pairs
│ ┃ Pairs ✕ › fix the failing auth test and add an audit log call │ ┃ PAIRS turns 0 found 0/8 time 0:00 best — │ ┃ Press a card's letter to turn it over. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ a ] [ b ] [ c ] [ d ] ⏺ Update(src/auth.ts) │ ┃ [ e ] [ f ] [ g ] [ h ] ⎿ Added 2 lines, removed 1 line │ ┃ [ i ] [ j ] [ k ] [ l ] ⏺ Bash(bun test) │ ┃ [ m ] [ n ] [ o ] [ p ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ How to play │ │ ┃ │ 1. Sixteen cards lie face down, eight │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ matching pairs. Press a card's letter, a │ │ ┃ │ to p, or click it, to turn it over. │ › /pairs │ ┃ │ 2. Turn a second card. A pair stays up; │ │ ┃ │ two different cards turn back after a │ │ ┃ │ moment. Remember where they were. │ │ ┃ │ 3. Find all eight pairs in as few turns, │ │ ┃ │ and as little time, as you can. The │ │ ┃ │ clock runs from your first card and │ │ ┃ │ pauses while the pane is closed. Your │ │ ┃ │ best of each is kept. │ │ ┃ │ Keys need the pane focused: click it if │ │ ┃ │ nothing happens. t hides this. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pairs
PAIRS turns 0 found 0/8 time 0:00 best — · game 1 Press a card's letter to turn it over. [ a ] [ b ] [ c ] [ d ] [ e ] [ f ] [ g ] [ h ] [ i ] [ j ] [ k ] [ l ] [ m ] [ n ] [ o ] [ p ] ╭──────────────────────────────────────────────────────────╮ │ How to play │ │ 1. Sixteen cards lie face down, eight matching pairs. │ │ Press a card's letter, a to p, or click it, to turn it │ │ over. │ │ 2. Turn a second card. A pair stays up; two different │ │ cards turn back after a moment. Remember where they │ │ were. │ │ 3. Find all eight pairs in as few turns, and as little │ │ time, as you can. The clock runs from your first card │ │ and pauses while the pane is closed. Your best of each │ │ is kept. │ │ Keys need the pane focused: click it if nothing happens. │ │ t hides this. │ ╰──────────────────────────────────────────────────────────╯ r: New deal t: Hide help Esc closes
README

pairs

Memory pairs in a pane beside the conversation. Sixteen cards lie face down, eight matching pairs. Press a card's letter to turn it, find its twin, and clear the board in as few turns, and as little time, as you can. Your fewest turns and fastest time are kept.

It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. A good one for a child at the keyboard while a long turn runs.

Memory pairs in a pane: two misses, then every pair, with the clock running in the header.

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

PAIRS  turns 5  found 2/8  time 0:41  best 11 turns, 1:12 · game 3
Not a pair. They turn back in a moment.

  @    b    c    #
  e    *    g    h
  @    j    k    #
  m    +    o    p

r: New deal   t: How to play   Esc closes

The cards are colored, which the text above can't show: a card that is up sits on yellow, a found pair on green, and face-down cards show their key dim. On the Desktop app each face-down card is a button with its letter as a key badge; in a terminal the letter is the label.

Install

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

Try it for one session without installing:

claude --plugin-dir ./mods/pairs

How to play

  1. Run /pairs. The pane opens with sixteen face-down cards. A fresh deal shows a short how-to until your first card; t hides or shows it any time.
  2. Press a card's letter, a to p in reading order, or click it. The card turns over and shows one of eight symbols.
  3. Turn a second card. A pair stays up on green. Two different cards stay up for a moment so you can remember them, then turn back; turning a third card turns them back at once.
  4. Every second card is a turn. The clock in the header starts at your first card and runs until the last pair; it pauses while the pane is closed, so stepping away costs nothing. Find all eight pairs and the status line says the turns and the time, and whether either is your best.
  5. r deals a new game. Esc closes the pane; the deal is saved on every turn.

The keys work while the pane has the focus. If pressing a letter types into the prompt instead, click the pane first.

CommandWhat it does
/pairsOpen the pane. Where no pane can be drawn, print how far the deal has got.
/pairs newDeal a new game.
/pairs statsGames dealt, games finished, fewest turns, fastest time.

What it touches

From claude plugin validate ./mods/pairs:

hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=pairs}, ui.close{id=pairs}, ui.render{component=Pane}
calls: $.clock.after (via scheduleHide), $.clock.every (via startTicking), $.clock.now, $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
  • $.store keeps the deal, which cards are up, and the record, so a game survives a restart. A mismatched pair left up across a restart turns back down on load. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.
  • $.clock seeds each deal, times the game, turns a mismatched pair back down after 0.9 seconds, and redraws the clock once a second while a game is running in an open pane. Both timers stop on a new deal and when the pane closes; the clock also stops at the last pair.
  • ui.close is observed to stop both timers, turn the pair down, and pause the clock.
  • No tool calls are observed, no files, processes, model calls, or network.

Tested with

  • Claude Code 2.1.295, claude plugin validate --strict and claude plugin test passing: the shuffle, turning, a miss and a match, finishing with the fewest-turns record, the clock starting, pausing and stopping, mounted pane tests for the keys, the hide pause and the running clock on the mock clock, a third card cutting the pause short, the status lines, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so the pair turning down on close is covered by the load path.
  • Played in the Desktop app, 2.1.293, through hot reloading.

Limitations

  • One game at a time per machine. Two sessions playing at once overwrite each other; the last write wins.
  • The keys n and h belong to cards here, so a new deal is r and the how-to is t, unlike the other game mods.
  • The clock counts time while the pane is open with a game on, even if you're not looking at it. Close the pane, or finish, to stop it.

License

MIT, see the repository root.

Source 3 files
hooks/register.ts 287 lines
1// pairs: memory pairs in a pane.
2//
3//   /pairs           open the game
4//   /pairs new       deal a new game
5//   /pairs stats     games, finished, fewest turns, fastest time
6//
7// In the pane: each face-down card shows a letter, a to p; press it to turn
8// the card. Turn two the same and they stay up; two different turn back after
9// a moment. The clock runs from the first card to the last pair and stops while
10// the pane is closed. r deals again, t shows the how-to. Nothing here calls a model,
11// reads what Claude is doing, or leaves the machine.
12
13import { atom, read, update } from 'claude-code'
14import type { Game, Stats } from '../types'
15import { CARDS, FACES, KEYS, SIZE, elapsed, formatMs, hideMiss, isGame, newGame, pairsFound, pause, turn } from './game'
16
17const PANE = 'pairs'
18const HIDE_MS = 900
19const EMPTY_STATS: Stats = { games: 0, finished: 0, bestTurns: 0, bestMs: 0 }
20const EMPTY: Game = { seed: 0, faces: [], matched: [], up: [], turns: 0, done: false, elapsedMs: 0, startedAt: null, stats: EMPTY_STATS }
21const game = atom({ plugin: 'pairs', key: 'game' } as const, EMPTY)
22
23// Turns a mismatched pair back down after a moment. Dies with the module on reload.
24let hiding: { cancel: () => void } | null = null
25// Redraws the running clock once a second while the pane is open and a game is on.
26let ticking: { cancel: () => void } | null = null
27// The how-to block: shown on a fresh deal until the first card, or until t
28// has set it either way for the session.
29let help: boolean | null = null
30// False after the store could not be read: then nothing is written over the
31// saved record until a later load reads it.
32let storeOk = true
33
34async function save($, g: Game) {
35  if (!storeOk) return
36  try {
37    await $.store.set('game', g)
38  } catch {
39    // The drawing still updates; the store catches up on the next change.
40  }
41}
42
43async function change($, fn: (cur: Game) => Game): Promise<Game> {
44  let out: Game | null = null
45  await update($, game, (cur) => {
46    out = fn(cur)
47    return out
48  })
49  // The empty default is never written over a saved deal.
50  if (out && (out as Game).seed) await save($, out)
51  return out ?? (await read($, game))
52}
53
54async function load($) {
55  let saved: unknown = undefined
56  let readable = true
57  try {
58    saved = await $.store.get('game')
59  } catch {
60    readable = false
61  }
62  storeOk = readable
63  // A deal saved by 0.1.0 has no clock fields; give it a stopped clock and no
64  // fastest time rather than throwing the record away.
65  let migrated = false
66  if (saved && typeof saved === 'object') {
67    const s = saved as Record<string, unknown>
68    const stats = s.stats as Record<string, unknown> | undefined
69    migrated = !('elapsedMs' in s) || !(stats && typeof stats === 'object' && 'bestMs' in stats)
70    saved = { elapsedMs: 0, startedAt: null, ...s, stats: stats && typeof stats === 'object' ? { bestMs: 0, ...stats } : stats }
71  }
72  if (isGame(saved)) {
73    // A pair left up across a restart turns back down, and the clock waits for
74    // the next card: a stretch cut off by a restart isn't counted.
75    const g = hideMiss({ ...(saved as Game), startedAt: null })
76    await update($, game, () => g)
77    if (migrated) await save($, g)
78    return
79  }
80  if (!readable) {
81    const cur = await read($, game)
82    if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }))
83    $.ui.log('pairs could not read its saved game; playing without saving')
84    return
85  }
86  const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 })
87  await update($, game, () => fresh)
88  await save($, fresh)
89}
90
91function stopHiding() {
92  if (hiding) hiding.cancel()
93  hiding = null
94}
95
96function stopTicking() {
97  if (ticking) ticking.cancel()
98  ticking = null
99}
100
101// The clock in the header only changes once a second, so a tick asks for a
102// redraw rather than changing the game; it stops itself when nothing is running.
103function startTicking($) {
104  if (ticking) return
105  ticking = $.clock.every(1000, async () => {
106    try {
107      const cur = await read($, game)
108      if (cur.done || cur.startedAt === null) {
109        stopTicking()
110        return
111      }
112      $.ui.invalidate('ui.render')
113    } catch {
114      // A missed tick just leaves the clock a second behind.
115    }
116  })
117}
118
119function scheduleHide($) {
120  stopHiding()
121  hiding = $.clock.after(HIDE_MS, async () => {
122    hiding = null
123    try {
124      await change($, hideMiss)
125    } catch {
126      // The pair stays up until the next turn hides it.
127    }
128  })
129}
130
131async function startNew($) {
132  stopHiding()
133  stopTicking()
134  const now = await $.clock.now()
135  return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }))
136}
137
138function summary(g: Game, now: number): string {
139  if (!g.seed) return 'No game yet.'
140  const time = formatMs(elapsed(g, now))
141  const state = g.done ? 'All found in ' + g.turns + ' turns and ' + time + '.' : pairsFound(g) + ' of ' + CARDS / 2 + ' pairs found, ' + g.turns + ' turns, ' + time + '.'
142  return 'Pairs: ' + state + (g.stats.bestTurns ? ' Best ' + g.stats.bestTurns + ' turns, ' + formatMs(g.stats.bestMs) + '.' : '') + ' /pairs opens the pane.'
143}
144
145export function register(on) {
146  on('session.start', async ($, e, next) => {
147    help = null
148    try {
149      await load($)
150    } catch (error) {
151      $.ui.log('pairs could not load its game: ' + error)
152    }
153    try {
154      await $.command.register({ name: 'pairs', description: 'Play memory pairs in a pane', argumentHint: '[new | stats]', immediate: true })
155    } catch (error) {
156      $.ui.log('could not register /pairs: ' + error)
157    }
158    return next(e)
159  })
160
161  // /clear and /resume reset $.state; the game lives on in the store.
162  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
163    stopHiding()
164    stopTicking()
165    await load($)
166    return next(e)
167  }).catch(async ($, e, next) => next(e))
168
169  on('command.run', { command: 'pairs' }, async ($, e) => {
170    const args = e.args.trim().toLowerCase()
171    if (args === 'new') return { text: 'New deal. ' + summary(await startNew($), await $.clock.now()) }
172    if (args === 'stats') {
173      const s = (await read($, game)).stats
174      return { text: 'Games ' + s.games + ' · finished ' + s.finished + ' · fewest turns ' + (s.bestTurns || '—') + ' · fastest ' + (s.bestMs ? formatMs(s.bestMs) : '—') }
175    }
176    if (args) return { text: 'Usage: /pairs, /pairs new, or /pairs stats' }
177    // Under claude -p nothing draws, and ui.open still reports the pane as placed:
178    // the surfaces list is the reliable sign.
179    let surfaces = []
180    try {
181      surfaces = await $.session.surfaces()
182    } catch {
183      surfaces = []
184    }
185    if (!surfaces.length) return { text: summary(await read($, game), await $.clock.now()) }
186    const placed = await $.ui.open({ id: PANE, title: 'Pairs', focus: true, closeOnEscape: true, rows: 22 })
187    if (!placed.isPlaced) return { text: summary(await read($, game), await $.clock.now()) }
188    // A game paused by closing the pane picks its clock up at the next card.
189    return {}
190  }).catch(async () => ({ text: 'pairs: the command failed.' }))
191
192  // Closing the pane turns a mismatched pair down at once and stops the clock.
193  on('ui.close', { id: PANE }, async ($, e, next) => {
194    stopHiding()
195    stopTicking()
196    const now = await $.clock.now()
197    await change($, (cur) => pause(hideMiss(cur), now))
198    return next(e)
199  }).catch(async ($, e, next) => next(e))
200
201  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
202    if (e.requestId !== PANE) return next(e)
203    const g = await read($, game)
204    const { Box, Text, Button } = $.ui.resolve(e)
205    if (!g.seed) return Text({ children: ['No game loaded. /pairs new starts one.'] })
206    const columns = e.props.bodyColumns || 60
207    const wide = columns >= 50
208    const showHelp = help ?? (wide && g.turns === 0 && !g.up.length)
209    const desktop = e.surface === 'desktop'
210    const found = pairsFound(g)
211    const now = await $.clock.now()
212    const time = formatMs(elapsed(g, now))
213    if (!g.done && g.startedAt !== null) startTicking($)
214
215    const status = g.done ? { text: 'All eight pairs in ' + g.turns + ' turns and ' + time + (g.stats.bestTurns === g.turns && g.stats.bestMs === g.elapsedMs ? ', your best on both. ' : g.stats.bestMs === g.elapsedMs ? ', your fastest. ' : g.stats.bestTurns === g.turns ? ', your fewest turns. ' : '. ') + 'Press r for a new deal.', color: 'success', bold: true }
216      : g.up.length === 2 ? { text: 'Not a pair. They turn back in a moment.', color: 'warning' }
217      : g.up.length === 1 ? { text: 'Now find its twin.', color: 'suggestion' }
218      : g.turns === 0 ? { text: 'Press a card\'s letter to turn it over.', color: 'suggestion' }
219      : { text: found + ' of 8 pairs found. Turn a card.', color: 'suggestion' }
220
221    const header = Box({ flexDirection: 'column', children: [
222      Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
223        Text({ bold: true, children: ['PAIRS'] }),
224        Text({ children: ['turns ', Text({ bold: true, children: [String(g.turns)] })] }),
225        Text({ children: ['found ' + found + '/8'] }),
226        Text({ bold: true, color: g.done ? 'success' : g.startedAt !== null ? 'warning' : undefined, dimColor: g.startedAt === null && !g.done, children: ['time ' + time] }),
227        Text({ dimColor: true, children: ['best ' + (g.stats.bestTurns ? g.stats.bestTurns + ' turns, ' + formatMs(g.stats.bestMs) : '—') + ' · game ' + g.stats.games] }),
228      ] }),
229      Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
230    ] })
231
232    // Every card is a fixed-width Box so the grid lines up on any surface. A
233    // face-down card shows its key (the desktop draws the key as a badge, so the
234    // label is blank there); a card that is up shows its face on yellow, a found
235    // pair its face on green.
236    const card = (i: number) => {
237      const isUp = g.up.includes(i)
238      const shown = g.matched[i] || isUp
239      const backgroundColor = g.matched[i] ? 'success' : isUp ? 'warning' : undefined
240      const label = shown ? FACES[g.faces[i]] : desktop ? ' ' : KEYS[i]
241      return Box({ width: 5, justifyContent: 'center', backgroundColor, children: [
242        Button({ key: 'card-' + i, label, hotkey: shown ? undefined : KEYS[i], dimColor: !shown, onPress: async () => {
243          let error: string | undefined
244          let outcome: string | undefined
245          const pressedAt = await $.clock.now()
246          const after = await change($, (cur) => {
247            const r = turn(cur, i, pressedAt)
248            error = r.error
249            outcome = r.outcome
250            return r.game
251          })
252          if (error) {
253            $.ui.toast(error)
254            return
255          }
256          // A stale timer is harmless: hideMiss only acts on a mismatched pair.
257          if (outcome === 'miss') scheduleHide($)
258          if (after.done) {
259            stopTicking()
260            $.ui.toast('All pairs found in ' + after.turns + ' turns and ' + formatMs(after.elapsedMs) + '!', { timeoutMs: 5000 })
261          }
262        } }),
263      ] })
264    }
265    const grid = Box({ flexDirection: 'column', children: Array.from({ length: SIZE }, (_, r) => Box({ flexDirection: 'row', columnGap: 1, children: Array.from({ length: SIZE }, (_, c) => card(r * SIZE + c)) })) })
266
267    const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
268      Text({ bold: true, children: ['How to play'] }),
269      Text({ wrap: 'wrap', children: ['1. Sixteen cards lie face down, eight matching pairs. Press a card\'s letter, a to p, or click it, to turn it over.'] }),
270      Text({ wrap: 'wrap', children: ['2. Turn a second card. A pair stays up; two different cards turn back after a moment. Remember where they were.'] }),
271      Text({ wrap: 'wrap', children: ['3. Find all eight pairs in as few turns, and as little time, as you can. The clock runs from your first card and pauses while the pane is closed. Your best of each is kept.'] }),
272      Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. t hides this.'] }),
273    ] })
274
275    const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
276      Button({ key: 'new', label: 'New deal', hotkey: 'r', plain: true, onPress: () => startNew($) }),
277      Button({ key: 'help', label: showHelp ? 'Hide help' : 'How to play', hotkey: 't', plain: true, dimColor: true, onPress: () => {
278        help = !showHelp
279        $.ui.invalidate('ui.render')
280      } }),
281      Text({ dimColor: true, children: ['Esc closes'] }),
282    ] })
283
284    return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, howTo, footer] : [header, grid, footer] })
285  })
286}
287
hooks/game.ts 114 lines
1// The rules of memory pairs, pure: no `$` here, so the tests and the hooks
2// module share one implementation.
3
4import type { Game, Stats } from '../types'
5
6export const SIZE = 4
7export const CARDS = SIZE * SIZE
8// Eight faces, plain ASCII so they draw one cell wide everywhere.
9export const FACES = ['@', '#', '$', '%', '&', '*', '+', '=']
10// The key that turns each card, in reading order.
11export const KEYS = 'abcdefghijklmnop'.split('')
12
13function hash(s: string): number {
14  let h = 2166136261
15  for (let i = 0; i < s.length; i++) {
16    h ^= s.charCodeAt(i)
17    h = Math.imul(h, 16777619)
18  }
19  return h >>> 0
20}
21
22function rng(seed: number): () => number {
23  let a = seed
24  return () => {
25    a = (a + 0x6d2b79f5) | 0
26    let t = Math.imul(a ^ (a >>> 15), 1 | a)
27    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
28    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29  }
30}
31
32export function shuffle(seed: number): number[] {
33  const faces = Array.from({ length: CARDS }, (_, i) => i % FACES.length)
34  const r = rng(hash(seed + ':deal'))
35  for (let i = faces.length - 1; i > 0; i--) {
36    const j = Math.floor(r() * (i + 1))
37    ;[faces[i], faces[j]] = [faces[j], faces[i]]
38  }
39  return faces
40}
41
42export function newGame(seed: number, stats: Stats): Game {
43  return { seed, faces: shuffle(seed), matched: new Array(CARDS).fill(false), up: [], turns: 0, done: false, elapsedMs: 0, startedAt: null, stats }
44}
45
46export type TurnResult = { game: Game; error?: string; outcome?: 'first' | 'match' | 'miss' }
47
48// Time played so far: earlier stretches plus the running one.
49export function elapsed(g: Game, now: number): number {
50  return g.elapsedMs + (g.startedAt !== null && !g.done ? Math.max(0, now - g.startedAt) : 0)
51}
52
53// Stop the clock, folding the running stretch into the total: for a closed pane.
54export function pause(g: Game, now: number): Game {
55  if (g.startedAt === null || g.done) return g
56  return { ...g, elapsedMs: elapsed(g, now), startedAt: null }
57}
58
59export function formatMs(ms: number): string {
60  const s = Math.floor(ms / 1000)
61  return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
62}
63
64// Turn a card. Two mismatched cards left up are turned back by this call first.
65// The clock starts at the first card and stops at the last pair.
66export function turn(g: Game, card: number, now: number): TurnResult {
67  if (g.done) return { game: g, error: 'All pairs found. Press r for a new game.' }
68  if (!Number.isInteger(card) || card < 0 || card >= CARDS) return { game: g, error: 'No such card.' }
69  if (g.matched[card]) return { game: g, error: 'That pair is already found.' }
70  let up = g.up.length === 2 ? [] : g.up
71  if (up.includes(card)) return { game: g, error: 'That card is already up.' }
72  const startedAt = g.startedAt ?? now
73  if (!up.length) return { game: { ...g, up: [card], startedAt }, outcome: 'first' }
74  const first = up[0]
75  const turns = g.turns + 1
76  if (g.faces[first] === g.faces[card]) {
77    const matched = g.matched.slice()
78    matched[first] = true
79    matched[card] = true
80    const done = matched.every(Boolean)
81    if (!done) return { game: { ...g, matched, up: [], turns, startedAt }, outcome: 'match' }
82    // At least a millisecond, since 0 means no time recorded.
83    const ms = Math.max(1, elapsed({ ...g, startedAt }, now))
84    const stats: Stats = {
85      ...g.stats,
86      finished: g.stats.finished + 1,
87      bestTurns: g.stats.bestTurns ? Math.min(g.stats.bestTurns, turns) : turns,
88      bestMs: g.stats.bestMs ? Math.min(g.stats.bestMs, ms) : ms,
89    }
90    return { game: { ...g, matched, up: [], turns, done: true, elapsedMs: ms, startedAt: null, stats }, outcome: 'match' }
91  }
92  return { game: { ...g, up: [first, card], turns, startedAt }, outcome: 'miss' }
93}
94
95// Turn a mismatched pair back down.
96export function hideMiss(g: Game): Game {
97  return g.up.length === 2 ? { ...g, up: [] } : g
98}
99
100export function pairsFound(g: Game): number {
101  return g.matched.filter(Boolean).length / 2
102}
103
104export function isGame(x: unknown): x is Game {
105  if (!x || typeof x !== 'object') return false
106  const g = x as Record<string, unknown>
107  const stats = g.stats as Record<string, unknown> | undefined
108  return typeof g.seed === 'number' && Array.isArray(g.faces) && g.faces.length === CARDS && g.faces.every((f) => Number.isInteger(f) && f >= 0 && f < FACES.length) &&
109    Array.isArray(g.matched) && g.matched.length === CARDS && g.matched.every((m) => typeof m === 'boolean') &&
110    Array.isArray(g.up) && g.up.length <= 2 && g.up.every((c) => Number.isInteger(c) && c >= 0 && c < CARDS) &&
111    Number.isInteger(g.turns) && typeof g.done === 'boolean' && Number.isInteger(g.elapsedMs) && (g.startedAt === null || typeof g.startedAt === 'number') &&
112    !!stats && typeof stats === 'object' && ['games', 'finished', 'bestTurns', 'bestMs'].every((k) => Number.isInteger(stats[k]))
113}
114
types/index.d.ts 26 lines
1export type Stats = { games: number; finished: number; bestTurns: number; bestMs: number }
2export type Game = {
3  /** Seed of this game's shuffle; 0 until a game exists. */
4  seed: number
5  /** The face under each of the 16 cards, 0 to 7, each twice. */
6  faces: number[]
7  /** Cards whose pair has been found. */
8  matched: boolean[]
9  /** Cards face up and not yet matched: none, one, or a mismatched two. */
10  up: number[]
11  /** Pairs of cards turned so far. */
12  turns: number
13  done: boolean
14  /** Time played before the current stretch, in ms. */
15  elapsedMs: number
16  /** When the current stretch of play began; null while the pane is closed or before the first card. */
17  startedAt: number | null
18  stats: Stats
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    pairs: { game: Game }
24  }
25}
26