SLOPSHOPPER

daily-grind

Daily puzzles to play while Claude works, in two shelves: Developer (word guess, grouping) and Everyone (logic grid).

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · daily-grind
│ ┃ Daily Grind ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ☕ Daily Grind #-83 · Thursday, October 9 │ daily-grind │ │ ┃ ⏺ Read(src/auth.ts) │ ☕ Daily Grind #-83: 9 puzzles ready, │ │ ┃ n: ▸ Originals c: Classics ⎿ Read 6 lines │ /grind │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ DEVELOPER ────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ 1: Git Golf · not started ⏺ Bash(bun test) │ ┃ 2: Heisenbug · not started ⎿ 3 pass, 1 fail │ ┃ 3: Install Order · not started │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ EVERYONE ─────────────────────────────────── │ ┃ 4: Ladder · not started ✻ Worked for 42s · done 4:20 PM │ ┃ 5: Pixel Logic · not started │ ┃ 6: Codebreaker · not started › /grind │ ┃ ⎿ daily-grind: Daily Grind opened. /grind share copies today's res │ ┃ 1-6 or click to play · n/c switch tabs · Esc │ ┃ /grind share │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Daily Grind
☕ Daily Grind #-83 · Thursday, October 9 0/9 solved n: ▸ Originals c: Classics DEVELOPER ────────────────────────────────────────────── 1: Git Golf · not started 2: Heisenbug · not started 3: Install Order · not started EVERYONE ─────────────────────────────────────────────── 4: Ladder · not started 5: Pixel Logic · not started 6: Codebreaker · not started 1-6 or click to play · n/c switch tabs · Esc closes · /grind share
README

claude-mods

Claude Code mods by @sreshtalluri.

session-receipt: a receipt for every Claude Code session

Install

/plugin marketplace add sreshtalluri/claude-mods
/plugin install session-receipt@claude-mods
/plugin install daily-grind@claude-mods
/plugin install next-asks@claude-mods

Mods use Claude Code's function-hooks plugin API, which is early access, so you need a current Claude Code.

Mods

ModWhat it does
session-receipt/receipt prints a shareable receipt of your session. /receipt week totals your week. Copy image gives you a square PNG to post.
daily-grind/grind opens a hub of daily puzzles to play while Claude works. Developer: Git Golf (reshape a commit graph under par), Heisenbug (find the buggy line), Install Order (dependency logic). Everyone: Ladder, Pixel Logic, Codebreaker. A Classics tab holds Lexer, Buckets and Daemons. Streaks, and /grind share for a shareable result.
next-asksAfter each turn, a band above the prompt suggests up to three things worth asking next that you might not think of: a missed edge case, an unrun check, a related file. Press its number to put it in the prompt. One small Haiku call per turn.

session-receipt

session-receipt demo

Command
/receiptThis session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right.
/receipt weekThe last seven days: sessions, busiest day, top project, usual model, total.
/receipt lastThe previous session's receipt.
  • Copy image puts a 1200×1200 PNG on your clipboard on macOS, Windows and Linux. If nothing can draw it, the image is saved and its path is shown.
  • Every session's receipt is saved to ~/.claude/receipts/ when it ends. Nothing leaves your machine.
  • In the terminal it's a text slip; in the desktop app it's drawn as paper.

Adding a mod

  1. Put it in plugins/<name>/ (with .claude-plugin/plugin.json).
  2. Add an entry to .claude-plugin/marketplace.json.
  3. claude plugin validate plugins/<name>, claude plugin test plugins/<name> and claude plugin validate .

The demo clip is a HyperFrames composition in media/receipt-demo: bun build.ts && npm run render.

License

MIT

Source 30 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { GrindData, GrindStats } from '../types'
5import { CATEGORIES, GAMES } from './games'
6import type { Game, Move, ViewProps } from './games/types'
7import { dayIndex, emptyData, esc, FONT, forDay, liveStreak, progressOf, saveOf, settle, shareText, statsOf } from './shared'
8import type { Progress } from './shared'
9
10const PANE = 'daily-grind'
11const data = atom({ plugin: 'daily-grind', key: 'data' } as const, emptyData())
12
13const ACCENT = '#a78bfa'
14const CATEGORY_COLOR = { Developer: '#7c3aed', Everyone: '#0d9488', Classics: '#d97706' } as const
15const PROGRESS: Record<Progress, { text: string; color: string }> = {
16  new: { text: '· not started', color: '#71717a' },
17  playing: { text: '◐ in progress', color: '#f59e0b' },
18  won: { text: '✓ solved', color: '#22c55e' },
19  lost: { text: '✗ failed', color: '#ef4444' },
20}
21const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
22const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
23
24/** "Saturday, October 3" for a day index. */
25const dateOf = (day: number) => {
26  const d = new Date(Date.UTC(2026, 0, 1 + day))
27  return `${DAYS[d.getUTCDay()]}, ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`
28}
29
30const today = async ($: EngineInterface) => dayIndex(await $.clock.now())
31
32type Stored = { saves: GrindData['saves']; stats: GrindData['stats'] }
33
34/** What every session has stored for `day`. The store is shared by all open sessions. */
35const stored = async ($: EngineInterface, day: number): Promise<Stored> => {
36  const t = (await $.store.get('today')) as Pick<GrindData, 'day' | 'saves'> | undefined
37  return { saves: t?.day === day ? t.saves : {}, stats: ((await $.store.get('stats')) as GrindData['stats'] | undefined) ?? {} }
38}
39
40/** Pulls in progress other sessions made, so this one draws and builds on the latest. */
41const load = async ($: EngineInterface) => {
42  const day = await today($)
43  const s = await stored($, day)
44  await update($, data, d => {
45    const cur = forDay(d, day)
46    return { ...cur, saves: { ...cur.saves, ...s.saves }, stats: { ...cur.stats, ...s.stats } }
47  })
48}
49
50/** Every change goes through here: state for the drawing; the touched game alone goes to the store, merged over what other sessions wrote. */
51const act = async ($: EngineInterface, fn: (d: GrindData) => GrindData, touched?: string) => {
52  const day = await today($)
53  const next = await update($, data, d => fn(forDay(d, day)))
54  if (touched === undefined) return
55  // ponytail: read-merge-write isn't atomic; two sessions moving in the same instant can still race on one game.
56  const s = await stored($, day)
57  await $.store.set('today', { day, saves: { ...s.saves, [touched]: next.saves[touched] } })
58  await $.store.set('stats', { ...s.stats, ...(next.stats[touched] ? { [touched]: next.stats[touched] } : {}) })
59}
60
61/** A move in game `g`, applied to the latest save. */
62const play = ($: EngineInterface, g: Game, move: (save: any) => Move<any>) =>
63  act($, d => {
64    const m = move(saveOf(g, d))
65    return { ...(m.save === undefined ? d : settle(d, g, m.save)), note: m.note ?? '' }
66  }, g.id)
67
68const show = ($: EngineInterface, open: string | null) =>
69  act($, d => {
70    const g = GAMES.find(x => x.id === open)
71    // Back from a game lands on that game's tab.
72    return { ...d, open, note: '', ...(g ? { tab: g.category === 'Classics' ? ('classics' as const) : ('new' as const) } : {}) }
73  })
74const showTab = ($: EngineInterface, tab: 'new' | 'classics') => act($, d => ({ ...d, tab }))
75
76const openPane = ($: EngineInterface) =>
77  $.ui.open({ id: PANE, title: 'Daily Grind', focus: true, closeOnEscape: true, rows: 32 })
78
79const streakText = (st: GrindStats, day: number) => {
80  const n = liveStreak(st, day)
81  return n > 0 ? `🔥${n}` : ''
82}
83
84/** A rounded badge with the game's initials, tinted by category: the desktop card's icon. */
85const badgeSvg = (g: Game) => {
86  const initials = g.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase()
87  const c = CATEGORY_COLOR[g.category]
88  return `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" ${FONT}><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${c}"/><stop offset="1" stop-color="${c}99"/></linearGradient></defs><rect width="48" height="48" rx="12" fill="url(#g)"/><text x="24" y="31" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">${esc(initials)}</text></svg>`
89}
90
91/** The desktop hub's banner: title, date, today's tally. */
92const bannerSvg = (day: number, solved: number, total: number) =>
93  `<svg xmlns="http://www.w3.org/2000/svg" width="560" height="96" viewBox="0 0 560 96" ${FONT}><defs><linearGradient id="b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4c1d95"/><stop offset="0.6" stop-color="#7c3aed"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs><rect width="560" height="96" rx="16" fill="url(#b)"/><text x="28" y="48" font-size="30" font-weight="800" fill="#fff">☕ Daily Grind</text><text x="30" y="74" font-size="14" fill="#ede9fe">#${day + 1} · ${esc(dateOf(day))} · new puzzles at midnight</text><text x="532" y="52" text-anchor="end" font-size="34" font-weight="800" fill="#fff">${solved}/${total}</text><text x="532" y="74" text-anchor="end" font-size="13" fill="#ede9fe">solved today</text></svg>`
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    await $.command.register({
98      name: 'grind',
99      description: "Open today's daily puzzles (or: share, or a game's name)",
100      argumentHint: '[share | game]',
101    })
102    await load($)
103    const day = await today($)
104    const d = forDay(await read($, data), day)
105    const left = GAMES.filter(g => ['new', 'playing'].includes(progressOf(g, d))).length
106    if (left > 0 && (await $.store.get('nudged')) !== day) {
107      await $.store.set('nudged', day)
108      $.ui.toast(`☕ Daily Grind #${day + 1}: ${left} puzzle${left === 1 ? '' : 's'} ready, /grind`, { timeoutMs: 6000 })
109    }
110    return next(e)
111  })
112
113  on('command.run', { command: 'grind' }, async ($, e) => {
114    const arg = e.args.trim().toLowerCase()
115    if (arg === 'share') {
116      await load($)
117      const d = forDay(await read($, data), await today($))
118      const texts = GAMES.map(g => shareText(g, d)).filter(t => t !== null)
119      if (texts.length === 0) return { text: 'Nothing finished today yet: /grind to play.' }
120      return { text: '```\n' + texts.join('\n\n') + '\n```' }
121    }
122    const pick = GAMES.find(g => arg && (g.id === arg || g.name.toLowerCase() === arg))
123    await load($)
124    await show($, pick ? pick.id : null)
125    await openPane($)
126    return { text: `Daily Grind opened${pick ? ` on ${pick.name}` : ''}. /grind share copies today's results.` }
127  })
128
129  // Esc (or the close mark) inside a game goes back to the menu; on the menu it closes the pane.
130  on('ui.close', { id: PANE }, async ($, e, next) => {
131    if (e.origin.kind !== 'person' || (await read($, data)).open === null) return next(e)
132    await show($, null)
133    void openPane($)
134    return { value: undefined }
135  })
136
137  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138    const els = $.ui.resolve(e)
139    const { Box, Text, Button } = els
140    const day = await today($)
141    const d = forDay(await read($, data), day)
142    const width = e.props.bodyColumns
143    const isRich = e.surface !== 'terminal'
144    const game = GAMES.find(g => g.id === d.open)
145
146    // ---- The menu ----
147    if (!game) {
148      const solved = GAMES.filter(g => progressOf(g, d) === 'won').length
149      // Two tabs: the originals, and the classics (the familiar formats) on their own.
150      const tab = d.tab ?? 'new'
151      const cats = CATEGORIES.filter(c => (c.name === 'Classics') === (tab === 'classics'))
152      const order = cats.flatMap(c => GAMES.filter(g => g.category === c.name))
153      const hotkey = (g: Game) => String(order.indexOf(g) + 1)
154      const tabs = (
155        <Box key="tabs" flexDirection="row" gap={2}>
156          <Button key="tab-new" hotkey="n" {...(isRich ? { variant: tab === 'new' ? 'primary' : 'secondary' } : { plain: true })} label={!isRich && tab === 'new' ? '▸ Originals' : 'Originals'} onPress={() => showTab($, 'new')} />
157          <Button key="tab-classics" hotkey="c" {...(isRich ? { variant: tab === 'classics' ? 'primary' : 'secondary' } : { plain: true })} label={!isRich && tab === 'classics' ? '▸ Classics' : 'Classics'} onPress={() => showTab($, 'classics')} />
158        </Box>
159      )
160      if (e.surface !== 'terminal') {
161        const { Svg } = els as Elements['desktop']
162        return (
163          <Box flexDirection="column" gap={1}>
164            <Svg source={bannerSvg(day, solved, GAMES.length)} alt={`Daily Grind #${day + 1}: ${solved} of ${GAMES.length} solved today`} />
165            {tabs}
166            {cats.map(c => (
167              <Box key={`cat-${c.name}`} flexDirection="column" gap={1}>
168                <Text>
169                  <Text bold color={CATEGORY_COLOR[c.name]}>{c.name.toUpperCase()}</Text>
170                  <Text dimColor>  {c.blurb}</Text>
171                </Text>
172                {GAMES.filter(g => g.category === c.name).map(g => {
173                  const p = progressOf(g, d)
174                  const streak = streakText(statsOf(g, d), day)
175                  return (
176                    <Box key={`card-${g.id}`} flexDirection="row" gap={2} alignItems="center" borderStyle="round" borderColor="#52525b" paddingX={2} paddingY={1} hover={{ borderColor: CATEGORY_COLOR[g.category] }}>
177                      <Svg source={badgeSvg(g)} alt="" width={48} height={48} />
178                      <Box flexDirection="column" flexGrow={1}>
179                        <Text>
180                          <Text bold>{g.name}</Text>
181                          {streak && <Text>  {streak}</Text>}
182                        </Text>
183                        <Text dimColor wrap="truncate">{g.tagline}</Text>
184                        <Text color={PROGRESS[p].color}>{PROGRESS[p].text}</Text>
185                      </Box>
186                      <Button key={`game-${g.id}`} hotkey={hotkey(g)} variant={p === 'won' || p === 'lost' ? 'secondary' : 'primary'} label={p === 'new' ? 'Play' : p === 'playing' ? 'Continue' : 'Result'} onPress={() => show($, g.id)} />
187                    </Box>
188                  )
189                })}
190              </Box>
191            ))}
192          </Box>
193        )
194      }
195
196      const w = Math.min(width, 78)
197      const nameW = Math.max(...GAMES.map(g => g.name.length)) + 6
198      const showTagline = w >= 64
199      return (
200        <Box flexDirection="column" width={w}>
201          <Box flexDirection="row" justifyContent="space-between">
202            <Text>
203              <Text bold color={ACCENT}>☕ Daily Grind</Text>
204              <Text dimColor>  #{day + 1} · {dateOf(day)}</Text>
205            </Text>
206            <Text dimColor>{solved}/{GAMES.length} solved</Text>
207          </Box>
208          <Box marginTop={1}>{tabs}</Box>
209          {cats.map(c => (
210            <Box key={`cat-${c.name}`} flexDirection="column" marginTop={1}>
211              <Text>
212                <Text bold color={CATEGORY_COLOR[c.name]}>{c.name.toUpperCase()}</Text>
213                <Text dimColor> {'─'.repeat(Math.max(2, w - c.name.length - 1))}</Text>
214              </Text>
215              {GAMES.filter(g => g.category === c.name).map(g => {
216                const p = progressOf(g, d)
217                return (
218                  <Box key={`row-${g.id}`} flexDirection="row">
219                    <Box width={nameW} flexShrink={0}>
220                      <Text> </Text>
221                      <Button key={`game-${g.id}`} plain hotkey={hotkey(g)} label={g.name} onPress={() => show($, g.id)} />
222                    </Box>
223                    {showTagline && (
224                      <Box flexGrow={1} flexShrink={1} paddingRight={2}>
225                        <Text dimColor wrap="truncate">{g.tagline}</Text>
226                      </Box>
227                    )}
228                    <Box width={14} flexShrink={0}>
229                      <Text color={PROGRESS[p].color}>{PROGRESS[p].text}</Text>
230                    </Box>
231                    <Box width={5} flexShrink={0} justifyContent="flex-end">
232                      <Text>{streakText(statsOf(g, d), day)}</Text>
233                    </Box>
234                  </Box>
235                )
236              })}
237            </Box>
238          ))}
239          <Box marginTop={1}>
240            <Text dimColor>1-{order.length} or click to play · n/c switch tabs · Esc closes · /grind share</Text>
241          </Box>
242        </Box>
243      )
244    }
245
246    // ---- A game ----
247    const puzzle = game.puzzle(day)
248    const save = saveOf(game, d)
249    const status = game.status(puzzle, save)
250    const st = statsOf(game, d)
251    const board = game.view({
252      surface: e.surface,
253      els,
254      width,
255      day,
256      puzzle,
257      save,
258      status,
259      play: move => void play($, game, move),
260    } as ViewProps<unknown, unknown>)
261
262    const text = shareText(game, d)
263    const flat = isRich ? {} : { plain: true as const }
264    const share: RenderElement | null = text ? (
265      <Box key="share" flexDirection="column" marginTop={1} {...(isRich ? { borderStyle: 'round', borderColor: status === 'won' ? '#22c55e' : '#ef4444', paddingX: 2, paddingY: 1 } : {})}>
266        {/* The terminal shows the score line alone: the board is above, and emoji widths vary between terminals. */}
267        {(isRich ? text.split('\n') : text.split('\n').slice(0, 4)).map((line, i) => <Text bold={!isRich && i === 0}>{line}</Text>)}
268        <Box flexDirection="row" gap={2}>
269          <Text dimColor>
270            streak {liveStreak(st, day)} · best {st.best} · won {st.won}/{st.played}
271          </Text>
272          <Button key="copy" {...flat} hotkey="y" label="copy result" onPress={p => void $.ui.copy({ text, surface: p.surface })} />
273        </Box>
274      </Box>
275    ) : null
276    const note = d.note ? (
277      <Box key="note" marginTop={isRich ? 0 : 1}>
278        <Text color="#f59e0b">{d.note}</Text>
279      </Box>
280    ) : null
281
282    return (
283      <Box flexDirection="column" {...(isRich ? { gap: 1 } : { width: Math.min(width, 78) })}>
284        <Box flexDirection="row" justifyContent="space-between" marginBottom={isRich ? 0 : 1}>
285          <Box flexDirection="row" gap={2}>
286            <Button key="back" {...flat} hotkey="q" label={isRich ? '‹ All games' : 'menu'} onPress={() => show($, null)} />
287            <Text>
288              <Text bold color={ACCENT}>{game.name}</Text>
289              <Text color={CATEGORY_COLOR[game.category]}> · {game.category}</Text>
290            </Text>
291          </Box>
292          <Text dimColor>#{day + 1}{streakText(st, day) && ` · ${streakText(st, day)}`}</Text>
293        </Box>
294        {board}
295        {note}
296        {share}
297      </Box>
298    )
299  })
300}
301
hooks/games/index.ts 20 lines
1import { buckets } from './buckets/view'
2import { gitgolf } from './gitgolf/view'
3import { heisenbug } from './heisenbug/view'
4import { installorder } from './installorder/view'
5import { ladder } from './ladder/view'
6import { pixellogic } from './pixellogic/view'
7import { codebreaker } from './codebreaker/view'
8import { daemons } from './daemons/view'
9import { lexer } from './lexer/view'
10import type { Category, Game } from './types'
11
12/** Every game, in menu order within its category. Adding one is a folder and a line here. */
13export const GAMES: Game[] = [lexer, buckets, gitgolf, heisenbug, installorder, daemons, ladder, pixellogic, codebreaker]
14
15export const CATEGORIES: { name: Category; blurb: string }[] = [
16  { name: 'Developer', blurb: 'for people who live in a terminal' },
17  { name: 'Everyone', blurb: 'pure logic, no jargon' },
18  { name: 'Classics', blurb: 'familiar daily formats, our own twist' },
19]
20
hooks/games/types.ts 42 lines
1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3export type Category = 'Developer' | 'Everyone' | 'Classics'
4export type Status = 'playing' | 'won' | 'lost'
5
6/** What a move returns: the new save, a feedback line, or both. A missing note clears the old one. */
7export type Move<S> = { save?: S; note?: string }
8
9/**
10 * What a game's view draws with. `surface` narrows `els` (the surface's element
11 * table): `Raster` only on 'terminal', `Svg` everywhere else, no `Input` on 'mobile'. Branch on `surface`, never on what `els` holds: every table answers every name (a missing one draws nothing).
12 */
13export type ViewProps<P, S> = { [K in RenderSurface]: { surface: K; els: Elements[K] } }[RenderSurface] & {
14  /** Body columns the view has; size boards to it. */
15  width: number
16  day: number
17  puzzle: P
18  save: S
19  status: Status
20  /** Applies a move to the latest save: persisted, stats recorded when it ends the game, redrawn. */
21  play: (move: (save: S) => Move<S>) => void
22}
23
24/** One daily game. Add one: a folder under games/ exporting this, plus a line in games/index.ts. */
25export type Game<P = any, S = any> = {
26  /** Stable id: the key of its save and stats. */
27  id: string
28  name: string
29  category: Category
30  /** One line for the menu. */
31  tagline: string
32  /** The day's puzzle; the same `day` must give the same puzzle everywhere. */
33  puzzle: (day: number) => P
34  /** An untouched save (plain JSON). The menu calls a game "not started" while its save equals this. */
35  fresh: (puzzle: P) => S
36  status: (puzzle: P, save: S) => Status
37  /** The result after `Daily Grind · <name> #<n>  `: a score, then optional emoji rows ("3/6\n🟣🟣⚫⚫🟣"). Called once finished. */
38  share: (puzzle: P, save: S) => string
39  /** The board and its controls. The shell draws the header, back button, note and share card around it. */
40  view: (v: ViewProps<P, S>) => RenderElement
41}
42
hooks/shared.ts 85 lines
1import type { GrindData, GrindStats } from '../types'
2import type { Game } from './games/types'
3
4const DAY = 86_400_000
5const EPOCH = Date.UTC(2026, 0, 1)
6
7/** Days since 2026-01-01 by the local calendar, so everyone's puzzle flips at their own midnight. */
8export const dayIndex = (now: number): number => {
9  const d = new Date(now)
10  return Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - EPOCH) / DAY)
11}
12
13/** The day's item of a list that repeats once it runs out. */
14export const cycle = <T>(list: readonly T[], day: number): T => list[((day % list.length) + list.length) % list.length]!
15
16/** A small seeded PRNG (mulberry32): the same day shuffles the same way everywhere. */
17export const rng = (seed: number) => () => {
18  seed = (seed + 0x6d2b79f5) | 0
19  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
20  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
21  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
22}
23
24// ---- Day state, streaks, sharing ----
25
26const freshStats = (): GrindStats => ({ played: 0, won: 0, streak: 0, best: 0, lastWin: -2 })
27
28export const emptyData = (): GrindData => ({ day: -1, saves: {}, stats: {}, open: null, note: '' })
29
30/** The data as of `day`: yesterday's saves and note are dropped. */
31export const forDay = (d: GrindData, day: number): GrindData =>
32  d.day === day ? d : { ...d, day, saves: {}, note: '' }
33
34/** A game's save for today, fresh when it has none yet. */
35export const saveOf = (g: Game, d: GrindData) => (g.id in d.saves ? d.saves[g.id] : g.fresh(g.puzzle(d.day)))
36
37export const statsOf = (g: Game, d: GrindData) => d.stats[g.id] ?? freshStats()
38
39export type Progress = 'new' | 'playing' | 'won' | 'lost'
40
41/** Where a game stands today: untouched, under way, or finished. */
42export const progressOf = (g: Game, d: GrindData): Progress => {
43  const p = g.puzzle(d.day)
44  const s = saveOf(g, d)
45  const st = g.status(p, s)
46  return st !== 'playing' ? st : JSON.stringify(s) === JSON.stringify(g.fresh(p)) ? 'new' : 'playing'
47}
48
49export const record = (s: GrindStats, day: number, won: boolean): GrindStats => {
50  const streak = won ? (s.lastWin === day - 1 ? s.streak + 1 : 1) : 0
51  return {
52    played: s.played + 1,
53    won: s.won + (won ? 1 : 0),
54    streak,
55    best: Math.max(s.best, streak),
56    lastWin: won ? day : s.lastWin,
57  }
58}
59
60/** The streak as it stands today: one not extended yesterday or today has lapsed. */
61export const liveStreak = (s: GrindStats, day: number) => (s.lastWin >= day - 1 ? s.streak : 0)
62
63/** Applies a move to game `g`: stores the new save and records the result the first time the game ends. */
64export const settle = (d: GrindData, g: Game, save: unknown): GrindData => {
65  const p = g.puzzle(d.day)
66  const was = g.status(p, saveOf(g, d))
67  const now = g.status(p, save)
68  const next = { ...d, saves: { ...d.saves, [g.id]: save } }
69  if (was !== 'playing' || now === 'playing') return next
70  return { ...next, stats: { ...next.stats, [g.id]: record(statsOf(g, d), d.day, now === 'won') } }
71}
72
73/** The share card of a finished game, or null while it is still being played. */
74export const shareText = (g: Game, d: GrindData): string | null => {
75  const p = g.puzzle(d.day)
76  const s = saveOf(g, d)
77  return g.status(p, s) === 'playing' ? null : `Daily Grind · ${g.name} #${d.day + 1}  ${g.share(p, s)}`
78}
79
80/** Text made safe for SVG markup. */
81export const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
82
83/** The font desktop SVGs draw with. */
84export const FONT = `font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"`
85
hooks/games/buckets/view.tsx 101 lines
1import type { Game } from '../types'
2import { bucketsFor, bucketsProgress, bucketsShare, GROUP_MARK, MISTAKES, pick, submit } from './logic'
3import type { Buckets, BucketsSave } from './logic'
4
5const GROUP_BG = ['#e5c07b', '#61afef', '#98c379', '#c678dd']
6const HOTKEYS = 'abcdefghijklmnop'
7
8export const buckets: Game<Buckets, BucketsSave> = {
9  id: 'buckets',
10  name: 'Buckets',
11  category: 'Classics',
12  tagline: 'Sort 16 dev terms into four groups',
13  puzzle: bucketsFor,
14  fresh: () => ({ tries: [], picked: [] }),
15  status: (b, s) => bucketsProgress(b, s.tries).status,
16  share: bucketsShare,
17  view: v => {
18    const { Box, Text, Button } = v.els
19    const b = v.puzzle
20    const { solved, mistakes } = bucketsProgress(b, v.save.tries)
21    const shown = v.status === 'lost' ? [...solved, ...[0, 1, 2, 3].filter(i => !solved.includes(i))] : solved
22    const left = b.terms.filter(t => !shown.some(i => b.groups[i]!.terms.includes(t)))
23    const picked = v.save.picked
24    const toggle = (t: string) => v.play(s => ({ save: pick(s, t) }))
25    const misses = (
26      <Text>
27        <Text dimColor>misses </Text>
28        <Text color="#e06c75">{'●'.repeat(MISTAKES - mistakes)}</Text>
29        <Text dimColor>{'○'.repeat(mistakes)}</Text>
30      </Text>
31    )
32    const flat = v.surface === 'terminal' ? { plain: true as const } : {}
33    const controls = v.status === 'playing' && [
34      <Button key="submit" variant="primary" {...flat} hotkey="s" label={`submit ${picked.length}/4`} onPress={() => v.play(s => submit(b, s))} />,
35      <Button key="clear" {...flat} hotkey="x" label="clear" onPress={() => v.play(s => ({ save: { ...s, picked: [] } }))} />,
36    ]
37
38    if (v.surface !== 'terminal') {
39      return (
40        <Box flexDirection="column" gap={1}>
41          {shown.map(i => (
42            <Box key={`solved-${i}`} flexDirection="column" alignItems="center" backgroundColor={GROUP_BG[i]} paddingY={1} borderStyle="round" borderColor={GROUP_BG[i]}>
43              <Text bold color="#1f2328">{b.groups[i]!.name}</Text>
44              <Text color="#1f2328">{b.groups[i]!.terms.join(' · ')}</Text>
45            </Box>
46          ))}
47          {Array.from({ length: Math.ceil(left.length / 4) }, (_, r) => (
48            <Box key={`terms-${r}`} flexDirection="row" gap={1}>
49              {left.slice(r * 4, r * 4 + 4).map(t => (
50                <Box width="25%" flexGrow={1} justifyContent="center">
51                  <Button key={`term-${t}`} variant={picked.includes(t) ? 'primary' : 'secondary'} label={t} onPress={() => toggle(t)} />
52                </Box>
53              ))}
54            </Box>
55          ))}
56          {v.status === 'playing' && (
57            <Box key="controls" flexDirection="row" gap={2} alignItems="center" justifyContent="center">
58              {controls}
59              {misses}
60            </Box>
61          )}
62        </Box>
63      )
64    }
65
66    const cols = v.width >= 72 ? 4 : 2
67    const colWidth = Math.min(22, Math.floor(v.width / cols))
68    return (
69      <Box flexDirection="column">
70        {shown.map(i => (
71          <Box key={`solved-${i}`} flexDirection="row">
72            <Text backgroundColor={GROUP_BG[i]} color="#1f2328" bold>{` ${GROUP_MARK[i]} ${b.groups[i]!.name} `}</Text>
73            <Text color={GROUP_BG[i]} wrap="truncate">{`  ${b.groups[i]!.terms.join(' · ')}`}</Text>
74          </Box>
75        ))}
76        {shown.length > 0 && left.length > 0 && <Text> </Text>}
77        {Array.from({ length: Math.ceil(left.length / cols) }, (_, r) => (
78          <Box key={`terms-${r}`} flexDirection="row">
79            {left.slice(r * cols, r * cols + cols).map((t, j) => {
80              const isPicked = picked.includes(t)
81              return (
82                <Box width={colWidth} paddingRight={1}>
83                  <Box {...(isPicked ? { backgroundColor: '#5b21b6' } : {})}>
84                    <Button key={`term-${t}`} plain hotkey={HOTKEYS[r * cols + j]} label={isPicked ? `${t} ✓` : t} onPress={() => toggle(t)} />
85                  </Box>
86                </Box>
87              )
88            })}
89          </Box>
90        ))}
91        {v.status === 'playing' && (
92          <Box key="controls" flexDirection="row" gap={3} marginTop={1}>
93            {controls}
94            {misses}
95          </Box>
96        )}
97      </Box>
98    )
99  },
100}
101
hooks/games/gitgolf/view.tsx 212 lines
1import { esc, FONT } from '../../shared'
2import type { Game } from '../types'
3import { current, format, fresh, meta, move, moves, puzzle, reachable, render, share, status, undo } from './logic'
4import type { Puzzle, Repo, Save } from './logic'
5
6const PALETTE = ['#22c55e', '#3b82f6', '#f59e0b', '#ec4899', '#a855f7', '#06b6d4', '#ef4444']
7const CHEATS = [
8  'commit · branch <name> · checkout <name> · merge <name>',
9  'rebase <name> · cherry-pick <ref> · reset <ref>   (ref: main, C2, HEAD~1)',
10]
11
12const byMain = (a: string, b: string) => Number(b === 'main') - Number(a === 'main') || a.localeCompare(b)
13
14/** One color per branch name, shared by both graphs so `feature` looks the same in each. */
15const colorsFor = (p: Puzzle): Record<string, string> =>
16  Object.fromEntries(
17    [...new Set([...Object.keys(p.start.branches), ...Object.keys(p.target.branches)])].sort(byMain).map((b, i) => [b, PALETTE[i % PALETTE.length]!]),
18  )
19
20/** render() lines with `main*` spelled git's way: `HEAD → main`. */
21const lines = (repo: Repo) => render(repo).map(l => l.replace(/([\w-]+)\*/, 'HEAD → $1'))
22
23// ---- Desktop: the graph as an SVG ----
24
25const [DX, DY, R, PAD] = [58, 54, 13, 22]
26
27function graphSvg(repo: Repo, colors: Record<string, string>): string {
28  const live = [...reachable(repo)]
29  const depth = new Map<string, number>()
30  const d = (c: string): number => depth.get(c) ?? (depth.set(c, Math.max(-1, ...repo.commits[c]!.map(d)) + 1), depth.get(c)!)
31  live.forEach(d)
32
33  // Lanes along first parents, branches first (main on top), as render() does.
34  const lane = new Map<string, number>()
35  const laneColor: string[] = []
36  const names = Object.keys(repo.branches).sort(byMain)
37  const claim = (tip: string, color: string) => {
38    let n = 0
39    for (let c: string | undefined = tip; c !== undefined && !lane.has(c); c = repo.commits[c]?.[0], n++) lane.set(c, laneColor.length)
40    if (n) laneColor.push(color)
41  }
42  for (const b of names) claim(repo.branches[b]!, colors[b] ?? '#8a8a8a')
43  for (const c of live.sort((a, b) => d(b) - d(a))) claim(c, '#8a8a8a')
44
45  const at = (c: string) => [PAD + d(c) * DX, PAD + 14 + lane.get(c)! * DY] as const
46  const edges = live.flatMap(c =>
47    repo.commits[c]!.map(p => {
48      const [[x1, y1], [x2, y2]] = [at(p), at(c)]
49      const mid = x1 + DX / 2
50      const path = y1 === y2 ? `M${x1} ${y1}H${x2}` : `M${x1} ${y1}C${mid} ${y1} ${mid} ${y2} ${x2} ${y2}`
51      return `<path d="${path}" fill="none" stroke="${laneColor[lane.get(c)!]}" stroke-width="3" stroke-opacity="0.75"/>`
52    }),
53  )
54  const nodes = live.map(c => {
55    const [x, y] = at(c)
56    const isHead = repo.branches[repo.head] === c
57    const ring = isHead ? `<circle cx="${x}" cy="${y}" r="${R + 4}" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/>` : ''
58    return `${ring}<circle cx="${x}" cy="${y}" r="${R}" fill="${laneColor[lane.get(c)!]}"/><text x="${x}" y="${y + 4}" text-anchor="middle" font-size="10" font-weight="700" fill="#fff">${esc(c)}</text>`
59  })
60
61  // Branch pills: right of a lane's last commit, above one that has a child on its lane.
62  let right = 0
63  const byTip = new Map<string, string[]>()
64  for (const b of names) byTip.set(repo.branches[b]!, [...(byTip.get(repo.branches[b]!) ?? []), b])
65  const pills = [...byTip].flatMap(([c, bs]) => {
66    const [cx, cy] = at(c)
67    const hasNext = live.some(k => repo.commits[k]![0] === c && lane.get(k) === lane.get(c))
68    let x = hasNext ? cx - R : cx + R + 8
69    const y = hasNext ? cy - R - 22 : cy - 10
70    return bs.map(b => {
71      const text = b === repo.head ? `HEAD → ${b}` : b
72      const w = text.length * 7 + 14
73      const out = `<rect x="${x}" y="${y}" width="${w}" height="20" rx="10" fill="${colors[b]}"/><text x="${x + w / 2}" y="${y + 14}" text-anchor="middle" font-size="12" font-weight="${b === repo.head ? 700 : 500}" fill="#fff">${esc(text)}</text>`
74      x += w + 4
75      right = Math.max(right, x)
76      return out
77    })
78  })
79  const w = Math.max(right, ...live.map(c => at(c)[0] + R)) + PAD
80  const h = PAD + 14 + (laneColor.length - 1) * DY + R + PAD
81  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ${FONT}>${edges.join('')}${nodes.join('')}${pills.join('')}</svg>`
82}
83
84export const gitgolf: Game<Puzzle, Save> = {
85  ...meta,
86  tagline: 'Match the commit graph, under par',
87  puzzle,
88  fresh,
89  status,
90  share,
91  view: v => {
92    const { Box, Text, Button } = v.els
93    const Input = v.surface === 'mobile' ? undefined : v.els.Input
94    const p = v.puzzle
95    const repo = current(p, v.save)
96    const colors = colorsFor(p)
97    const strokes = v.save.moves.length
98    const isPlaying = v.status === 'playing'
99    const isRich = v.surface !== 'terminal'
100    const run = (text: string) => v.play(s => (text.trim() ? move(p, s, text) : { note: 'type a git command, e.g. commit' }))
101
102    const other = Object.keys(repo.branches).find(b => b !== repo.head)
103    const hint = other ? `e.g. checkout ${other}` : 'e.g. commit'
104    const field = isPlaying && Input && (
105      <Input key="cmd" autoFocus label="git" placeholder={hint} submitLabel="run" value="" onSubmit={run} />
106    )
107    const flat = isRich ? {} : { plain: true as const }
108    const controls = isPlaying && (
109      <Box key="controls" flexDirection="row" gap={2}>
110        <Button key="undo" {...flat} label={isRich ? 'Undo' : '↶ undo'} onPress={() => v.play(s => ({ save: undo(s) }))} />
111        <Button key="reset" {...flat} label={isRich ? 'Start over' : '⟲ reset'} onPress={() => v.play(() => ({ save: fresh() }))} />
112      </Box>
113    )
114    const over = strokes - p.par
115    const score = (
116      <Box key="score"><Text>
117        <Text bold>{strokes}</Text>
118        <Text dimColor>{` stroke${strokes === 1 ? '' : 's'} · par `}</Text>
119        <Text bold>{p.par}</Text>
120        {over > 0 && <Text color="#f87171">{`  +${over}`}</Text>}
121      </Text></Box>
122    )
123    const log = strokes > 0 && <Text key="log" dimColor wrap="truncate-start">{'$ ' + v.save.moves.join(' → ')}</Text>
124    const cheats = isPlaying && <Box key="cheats" flexDirection="column">{CHEATS.map(c => <Text dimColor>{c}</Text>)}</Box>
125
126    if (isRich) {
127      const { Svg } = v.els
128      const card = (title: string, r: Repo, k: string) => (
129        <Box key={k} flexDirection="column" borderStyle="round" borderColor={k === 'target' && !isPlaying ? '#22c55e' : '#57534e'} paddingX={1}>
130          <Text bold dimColor={k !== 'target'}>{title}</Text>
131          <Svg source={graphSvg(r, colors)} alt={`${title}: ${render(r).join('; ')}`} />
132        </Box>
133      )
134      // Mobile has no text field: offer every command worth trying as a button.
135      const pad = isPlaying && !Input && (
136        <Box key="pad" flexDirection="row" flexWrap="wrap" gap={1}>
137          {moves(repo, p.target).map(c => (
138            <Button key={`do-${format(c)}`} label={format(c)} onPress={() => run(format(c))} />
139          ))}
140        </Box>
141      )
142      return (
143        <Box flexDirection="column" gap={1}>
144          <Text><Text bold>{p.title}</Text><Text dimColor>  ·  make your repo match the target</Text></Text>
145          <Box flexDirection="row" flexWrap="wrap" gap={2}>
146            {card(isPlaying ? 'Your repo' : 'Holed out', repo, 'now')}
147            {card('Target', p.target, 'target')}
148          </Box>
149          {score}
150          {log}
151          {field}
152          {pad}
153          {controls}
154          {cheats}
155        </Box>
156      )
157    }
158
159    // Terminal: the ASCII graphs, branch names in their colors, HEAD spelled out.
160    const graph = (title: string, r: Repo, k: string) => {
161      const ls = lines(r)
162      return (
163        <Box key={k} flexDirection="column" flexShrink={0}>
164          <Text bold dimColor={k !== 'target'}>{title}</Text>
165          {ls.map((l, i) => (
166            <Text key={`${k}-${i}`}>
167              {l.split(/(\([^)]*\))/).map((part, j) =>
168                j % 2 === 0 ? (
169                  part.split(/(C\d+'*)/).map((s, n) => (n % 2 ? <Text>{s}</Text> : <Text dimColor>{s}</Text>))
170                ) : (
171                  <Text>
172                    <Text dimColor>(</Text>
173                    {part.slice(1, -1).split(', ').map((b, n) => {
174                      const name = b.replace('HEAD → ', '')
175                      return (
176                        <Text>
177                          {n > 0 && <Text dimColor>, </Text>}
178                          {name !== b && <Text><Text bold>HEAD</Text><Text dimColor> → </Text></Text>}
179                          <Text color={colors[name]} bold={name !== b}>{name}</Text>
180                        </Text>
181                      )
182                    })}
183                    <Text dimColor>)</Text>
184                  </Text>
185                ),
186              )}
187            </Text>
188          ))}
189        </Box>
190      )
191    }
192    const width = (r: Repo) => Math.max(8, ...lines(r).map(l => l.length))
193    const isWide = width(repo) + width(p.target) + 6 <= v.width
194    return (
195      <Box flexDirection="column">
196        <Text><Text bold>{p.title}</Text><Text dimColor>  make your repo match the target</Text></Text>
197        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 6 : 1} marginTop={1}>
198          {graph(isPlaying ? 'your repo' : 'holed out', repo, 'now')}
199          {graph('target', p.target, 'target')}
200        </Box>
201        <Box flexDirection="row" gap={3} marginTop={1}>
202          {score}
203          {controls}
204        </Box>
205        {log}
206        {field && <Box marginTop={1}>{field}</Box>}
207        {cheats && <Box marginTop={1}>{cheats}</Box>}
208      </Box>
209    )
210  },
211}
212
hooks/games/heisenbug/view.tsx 208 lines
1import type { Game } from '../types'
2import { PUZZLES } from './data'
3import { cluesShown, guess, isDone, isWon, MAX_GUESSES, meta, puzzle, shareText, wrongGuesses } from './logic'
4import type { Clue, Puzzle, State } from './logic'
5
6type Save = { guesses: number[] }
7
8// Line pickers: a, b, c… skipping the shell's q (back) and y (copy).
9const HOTKEYS = 'abcdefghijklmnoprstuvwxz'
10const EXT = { javascript: 'js', typescript: 'ts', python: 'py' } as const
11const C = { kw: '#c678dd', str: '#98c379', num: '#d19a66', fn: '#61afef', com: '#5c6370', red: '#e06c75', green: '#98c379' }
12const KEYWORDS = new Set(
13  ('function return if else for while const let var of in new class extends import export from async await try catch finally throw ' +
14    'typeof instanceof break continue switch case default do def elif lambda not and or is pass with as yield raise except global nonlocal')
15    .split(' '),
16)
17const CONSTANTS = new Set('true false null undefined this None True False self NaN Infinity'.split(' '))
18const TOKEN = /("""[^]*?"""|'''[^]*?'''|"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`(?:\\.|[^`\\])*`?)|\b(\d+(?:\.\d+)?)\b|\b([A-Za-z_$][\w$]*)\b(\s*\()?/g
19
20/** One line split into colored runs: keywords, strings, numbers, calls, a trailing comment. */
21const colorize = (line: string, lang: Puzzle['lang']): { text: string; color?: string }[] => {
22  const ci = line.search(lang === 'python' ? /#(?=(?:[^'"]|'[^']*'|"[^"]*")*$)/ : /\/\/(?=(?:[^'"`]|'[^']*'|"[^"]*"|`[^`]*`)*$)/)
23  const [code, comment] = ci < 0 ? [line, ''] : [line.slice(0, ci), line.slice(ci)]
24  const out: { text: string; color?: string }[] = []
25  let last = 0
26  for (const m of code.matchAll(TOKEN)) {
27    const [all, str, num, id, call] = m
28    if (m.index > last) out.push({ text: code.slice(last, m.index) })
29    if (str) out.push({ text: str, color: C.str })
30    else if (num) out.push({ text: num, color: C.num })
31    else if (id) {
32      const color = KEYWORDS.has(id) ? C.kw : CONSTANTS.has(id) ? C.num : call ? C.fn : undefined
33      out.push({ text: id, color }, ...(call ? [{ text: call }] : []))
34    }
35    last = m.index + all.length
36  }
37  if (last < code.length) out.push({ text: code.slice(last) })
38  if (comment) out.push({ text: comment, color: C.com })
39  return out
40}
41
42/** The logic keys everything by day; the shell hands us the puzzle, which is the day's entry of PUZZLES. */
43const state = (p: Puzzle, s: Save): State => ({ day: PUZZLES.indexOf(p), guesses: s.guesses })
44
45export const heisenbug: Game<Puzzle, Save> = {
46  ...meta,
47  puzzle,
48  fresh: () => ({ guesses: [] }),
49  status: (p, s) => {
50    const st = state(p, s)
51    return isWon(st) ? 'won' : isDone(st) ? 'lost' : 'playing'
52  },
53  share: (p, s) => shareText(state(p, s))?.replace(/^Heisenbug #\d+ /, '') ?? '',
54  view: v => {
55    const { Box, Text, Button, Code } = v.els
56    const p = v.puzzle
57    const st = state(p, v.save)
58    const isPlaying = v.status === 'playing'
59    const clues = cluesShown(st)
60    const left = MAX_GUESSES - st.guesses.length
61    const file = `${p.title}.${EXT[p.lang]}`
62    const pick = (i: number) =>
63      v.play(s => {
64        const before = state(p, s)
65        const after = guess(before, i)
66        if (after === before) return { note: isDone(before) ? undefined : `Line ${i + 1} is already ruled out.` }
67        return {
68          save: { guesses: after.guesses },
69          note: isDone(after) ? undefined : `Line ${i + 1} checks out. A new test is failing.`,
70        }
71      })
72    const bugs = (
73      <Box key="guesses">
74        <Text>
75          <Text dimColor>guesses left </Text>
76          <Text>{'🐞'.repeat(left)}</Text>
77        </Text>
78      </Box>
79    )
80    const fixedAt = `line ${p.bugLine + 1}`
81
82    if (v.surface !== 'terminal') {
83      const clueCard = (c: Clue, k: number) => (
84        <Box key={`clue-${k}`} flexDirection="column" borderStyle="round" borderColor="#e06c7566" paddingX={1} flexGrow={1}>
85          <Text bold>{c.input}</Text>
86          <Text color={C.green}>expected {c.expected}</Text>
87          <Text color={C.red}>actual   {c.actual}</Text>
88        </Box>
89      )
90      const mark = (i: number) => (i === p.bugLine && !isPlaying ? (isWon(st) ? '✓ ' : '🐞 ') : st.guesses.includes(i) ? '✗ ' : '')
91      return (
92        <Box flexDirection="column" gap={1}>
93          <Text dimColor>One line is wrong. Read the code, then pick the line you think is the bug.</Text>
94          <Box key="listing" flexDirection="column" borderStyle="round" borderColor="#5c6370" paddingX={1}>
95            <Text bold>{file}</Text>
96            <Code source={p.lines.join('\n')} language={p.lang} startLine={1} />
97          </Box>
98          <Box key="picker" flexDirection="column" gap={1}>
99            <Text dimColor>{isPlaying ? 'Which line is the bug?' : 'Lines'}</Text>
100            <Box flexDirection="row" flexWrap="wrap" gap={1}>
101              {p.lines.map((_, i) => (
102                <Button key={`line-${i}`} hotkey={isPlaying ? HOTKEYS[i] : undefined}
103                  variant={!isPlaying && i === p.bugLine ? 'primary' : 'secondary'} dimColor={st.guesses.includes(i) && i !== p.bugLine}
104                  label={`${mark(i)}${i + 1}`} onPress={() => pick(i)} />
105              ))}
106            </Box>
107            {isPlaying && bugs}
108          </Box>
109          {clues.length > 0 && (
110            <Box key="clues" flexDirection="column" gap={1}>
111              <Text bold color={C.red}>Failing tests</Text>
112              <Box flexDirection="row" flexWrap="wrap" gap={1}>{clues.map(clueCard)}</Box>
113            </Box>
114          )}
115          {!isPlaying && (
116            <Box key="diff" flexDirection="column" borderStyle="round" borderColor={isWon(st) ? C.green : C.red} paddingX={1} gap={1}>
117              <Text bold>{isWon(st) ? `Found it: ${fixedAt}` : `The bug was ${fixedAt}`}</Text>
118              <Code format="diff" language={p.lang}
119                source={`@@ -${p.bugLine + 1},1 +${p.bugLine + 1},1 @@\n-${p.lines[p.bugLine]}\n+${p.fix}`} />
120              <Text>{p.why}</Text>
121            </Box>
122          )}
123        </Box>
124      )
125    }
126
127    // Terminal: an editor gutter. [mark][hotkey: n] │ code
128    const numW = String(p.lines.length).length
129    const rows = p.lines.map((line, i) => {
130      const n = String(i + 1).padStart(numW)
131      const tried = st.guesses.includes(i)
132      const isBug = !isPlaying && i === p.bugLine
133      const markGlyph = isBug ? (isWon(st) ? '✓' : '▶') : tried ? '✗' : ' '
134      const markColor = isBug ? (isWon(st) ? C.green : C.red) : C.red
135      const bg = isBug ? (isWon(st) ? '#1f3a26' : '#4a1f24') : undefined
136      return (
137        <Box key={`row-${i}`} flexDirection="row">
138          <Text color={markColor} bold>{`${markGlyph} `}</Text>
139          {isPlaying && !tried ? (
140            <Button key={`line-${i}`} plain hotkey={HOTKEYS[i]} label={n} onPress={() => pick(i)} />
141          ) : (
142            <Text dimColor={!isBug} bold={isBug}>{`   ${n}`}</Text>
143          )}
144          <Text dimColor>{' │ '}</Text>
145          <Box flexShrink={1} {...(bg ? { backgroundColor: bg, flexGrow: 1 } : {})}>
146            <Text wrap="truncate">
147              {tried && !isBug
148                ? <Text dimColor strikethrough>{line}</Text>
149                : colorize(line, p.lang).map(t => (t.color ? <Text color={t.color}>{t.text}</Text> : <Text>{t.text}</Text>))}
150            </Text>
151          </Box>
152        </Box>
153      )
154    })
155
156    // Clues as a test table; one row each when it fits, else input over expected/actual.
157    const [inW, exW] = [Math.max(13, ...clues.map(c => c.input.length)), Math.max(8, ...clues.map(c => c.expected.length))]
158    const actW = Math.max(6, ...clues.map(c => c.actual.length))
159    const oneRow = 4 + inW + 2 + exW + 2 + actW <= v.width
160    const table = clues.length > 0 && (
161      <Box key="clues" flexDirection="column" marginTop={1}>
162        <Text>
163          <Text color={C.red} bold>{'✗ failing tests'}</Text>
164          {oneRow && <Text dimColor>{`  ${''.padEnd(inW - 11)}expected${''.padEnd(exW - 6)}actual`}</Text>}
165        </Text>
166        {clues.map((c, k) =>
167          oneRow ? (
168            <Text key={`clue-${k}`} wrap="truncate">
169              <Text dimColor>{'  ✗ '}</Text>
170              <Text>{c.input.padEnd(inW + 2)}</Text>
171              <Text color={C.green}>{c.expected.padEnd(exW + 2)}</Text>
172              <Text color={C.red}>{c.actual}</Text>
173            </Text>
174          ) : (
175            <Box key={`clue-${k}`} flexDirection="column">
176              <Text wrap="truncate"><Text dimColor>{'  ✗ '}</Text>{c.input}</Text>
177              <Text wrap="truncate">
178                <Text dimColor>{'    expected '}</Text><Text color={C.green}>{c.expected}</Text>
179                <Text dimColor>{'  actual '}</Text><Text color={C.red}>{c.actual}</Text>
180              </Text>
181            </Box>
182          ),
183        )}
184      </Box>
185    )
186    const diff = !isPlaying && (
187      <Box key="diff" flexDirection="column" width={v.width}>
188        <Text bold color={isWon(st) ? C.green : C.red}>{isWon(st) ? `✓ found it: ${fixedAt}` : `▶ the bug was ${fixedAt}`}</Text>
189        <Text wrap="truncate" backgroundColor="#4a1f24" color="#ffb3b8">{`- ${p.lines[p.bugLine]!.trim()} `}</Text>
190        <Text wrap="truncate" backgroundColor="#1f3a26" color="#b5f5c0">{`+ ${p.fix.trim()} `}</Text>
191        <Text dimColor wrap="wrap">{p.why}</Text>
192      </Box>
193    )
194    return (
195      <Box flexDirection="column">
196        <Box flexDirection="row" gap={2}>
197          <Text backgroundColor="#2c313a" color="#abb2bf">{` ${file} `}</Text>
198          {isPlaying ? bugs : <Text dimColor>{`${wrongGuesses(st)} wrong`}</Text>}
199          {isPlaying && v.width >= 70 && <Text dimColor>one line lies: press its letter</Text>}
200        </Box>
201        <Box flexDirection="column">{rows}</Box>
202        {table}
203        {diff}
204      </Box>
205    )
206  },
207}
208
hooks/games/installorder/view.tsx 181 lines
1import { esc, FONT } from '../../shared'
2import type { Game } from '../types'
3import { clueText, judge, meta, move, puzzle, shareText, SOLVED_WITHIN } from './logic'
4import type { Clue, Puzzle } from './logic'
5
6/** order: the queue as arranged; history: violated clue indexes per submit; sel: selected slot. */
7export type InstallSave = { order: string[]; history: number[][]; sel: number | null }
8
9// Package hotkeys skip the shell's q/y and this view's j/k/s.
10const KEYS = 'abcdefgh'
11const [OK, BAD, DIM, ACC] = ['#98c379', '#e06c75', '#7f848e', '#61afef']
12
13/** A stable fake semver per package name, for the npm vibe. */
14const ver = (name: string) => {
15  const h = [...name].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7)
16  return `${h % 4}.${(h >> 3) % 12}.${(h >> 7) % 9}`
17}
18
19const isWon = (s: InstallSave) => s.history.length > 0 && s.history[s.history.length - 1]!.length === 0
20
21/** Two-package edges a clue draws in the diagram: [from, to] in install order. */
22const edges = (c: Clue): [string, string][] =>
23  c.kind === 'needs' ? [[c.b, c.a]]
24    : c.kind === 'between' ? [[c.lo, c.a], [c.a, c.hi]]
25    : c.kind === 'adjacent' || c.kind === 'gap' ? [[c.a, c.b]]
26    : []
27
28/** The queue as chips in a row, with an arc per two-package clue, colored by the last submit. */
29const diagramSvg = (p: Puzzle, order: string[], marks: (boolean | null)[]) => {
30  const [W, H, G, top] = [96, 30, 12, 64]
31  const w = order.length * (W + G) - G
32  const cx = (x: string) => order.indexOf(x) * (W + G) + W / 2
33  const arcs = p.clues.flatMap((c, i) =>
34    edges(c).map(([a, b]) => {
35      const [x1, x2] = [cx(a), cx(b)]
36      const lift = Math.min(top - 6, 14 + Math.abs(x2 - x1) / 7)
37      const color = marks[i] === null ? '#8888' : marks[i] ? OK : BAD
38      const arrow = c.kind === 'needs' || c.kind === 'between' ? `<path d="M${x2} ${top} l-4 -7 h8 z" fill="${color}"/>` : ''
39      return `<path d="M${x1} ${top} C${x1} ${top - lift} ${x2} ${top - lift} ${x2} ${top}" stroke="${color}" stroke-width="2" fill="none"${c.kind === 'gap' ? ' stroke-dasharray="4 3"' : ''}/>${arrow}`
40    }),
41  )
42  const chips = order.map((name, i) => {
43    const x = i * (W + G)
44    return `<rect x="${x}" y="${top + 2}" width="${W}" height="${H}" rx="6" fill="#8882" stroke="#8886"/><text x="${x + W / 2}" y="${top + 2 + H / 2 + 5}" text-anchor="middle" font-size="13" font-weight="600" fill="currentColor">${esc(name)}</text><text x="${x + 6}" y="${top + H + 16}" font-size="10" fill="#888">${i + 1}</text>`
45  })
46  const h = top + H + 20
47  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" color="#999" ${FONT}>${chips.join('')}${arcs.join('')}</svg>`
48}
49
50export const installorder: Game<Puzzle, InstallSave> = {
51  ...meta,
52  puzzle,
53  fresh: p => ({ order: [...p.packages], history: [], sel: null }),
54  status: (_, s) => (isWon(s) ? 'won' : 'playing'),
55  share: (p, s) => {
56    const [head, ...rows] = shareText(p, s.history).split('\n')
57    return [head!.slice(head!.indexOf('📦')), ...rows].join('\n')
58  },
59  view: v => {
60    const { Box, Text, Button } = v.els
61    const p = v.puzzle
62    const { order, history, sel } = v.save
63    const playing = v.status === 'playing'
64    const last = history[history.length - 1]
65    const marks = p.clues.map((_, i) => (last ? !last.includes(i) : null))
66    const tries = history.length
67
68    const select = (i: number) => v.play(s => ({ save: { ...s, sel: s.sel === i ? null : i } }))
69    const shift = (d: -1 | 1) =>
70      v.play(s => {
71        if (s.sel === null) return { note: 'Pick a package first.' }
72        const to = s.sel + d
73        if (to < 0 || to >= s.order.length) return {}
74        return { save: { ...s, order: move(s.order, s.sel, to), sel: to } }
75      })
76    const submit = () =>
77      v.play(s => {
78        const { solved, violated } = judge(p, s.order)
79        const n = s.history.length + 1
80        const note = solved
81          ? `added ${s.order.length} packages in ${n} ${n === 1 ? 'try' : 'tries'}${n <= SOLVED_WITHIN ? '' : ` (par is ${SOLVED_WITHIN})`}`
82          : `npm ERR! ERESOLVE: ${violated.length} of ${p.clues.length} constraints broken`
83        return { save: { ...s, history: [...s.history, violated], sel: null }, note }
84      })
85
86    const isRich = v.surface !== 'terminal'
87    const flat = isRich ? {} : { plain: true as const }
88    const triesText = (
89      <Text key="tries">
90        <Text dimColor>tries </Text>
91        <Text bold color={tries > SOLVED_WITHIN ? BAD : undefined}>{tries}</Text>
92        <Text dimColor>{` · par ${SOLVED_WITHIN}`}</Text>
93      </Text>
94    )
95    const controls = playing && [
96      <Button key="up" {...flat} hotkey="k" label="▲ up" onPress={() => shift(-1)} />,
97      <Button key="down" {...flat} hotkey="j" label="▼ down" onPress={() => shift(1)} />,
98      <Button key="submit" {...flat} variant="primary" hotkey="s" label="npm install" onPress={submit} />,
99    ]
100    const clueRow = (c: Clue, i: number) => {
101      const m = marks[i]
102      return (
103        <Box key={`clue-${i}`} flexDirection="row">
104          <Text color={m === null ? DIM : m ? OK : BAD}>{m === null ? '· ' : m ? '✓ ' : '✗ '}</Text>
105          <Text color={m === false ? BAD : undefined} wrap="wrap">{clueText(c)}</Text>
106        </Box>
107      )
108    }
109
110    if (v.surface !== 'terminal') {
111      const { Svg } = v.els
112      const card = { flexDirection: 'column' as const, borderStyle: 'round' as const, borderColor: '#57534e', paddingX: 2, paddingY: 1, gap: 1 }
113      return (
114        <Box flexDirection="column" gap={1}>
115          <Text dimColor>Order the queue so every constraint holds. Pick a package, move it, then install.</Text>
116          <Box flexDirection="row" flexWrap="wrap" gap={2}>
117            <Box key="queue" {...card} flexGrow={1}>
118              <Text bold>$ npm install</Text>
119              {order.map((name, i) => (
120                <Box key={`slot-${i}`} flexDirection="row" gap={1} alignItems="center">
121                  <Text dimColor>{`${i + 1}.`}</Text>
122                  <Button key={`pkg-${name}`} variant={sel === i ? 'primary' : 'secondary'} label={`+ ${name}@${ver(name)}`} onPress={() => playing && select(i)} />
123                </Box>
124              ))}
125              {playing && <Box flexDirection="row" gap={1}>{controls}</Box>}
126              {triesText}
127            </Box>
128            <Box key="clues" {...card} flexGrow={1}>
129              <Text bold>package.json constraints</Text>
130              {p.clues.map(clueRow)}
131            </Box>
132          </Box>
133          <Svg source={diagramSvg(p, order, marks)} alt={`Install queue: ${order.join(', ')}`} />
134        </Box>
135      )
136    }
137
138    // Terminal: queue left, clues right; stacked when narrow.
139    const isWide = v.width >= 76
140    const queue = (
141      <Box key="queue" flexDirection="column" flexShrink={0}>
142        <Text color={ACC} bold>$ npm install</Text>
143        {order.map((name, i) => {
144          const isSel = sel === i
145          const label = `+ ${name}@${ver(name)}`
146          return (
147            <Box key={`slot-${i}`} flexDirection="row">
148              <Text dimColor>{`${String(i + 1).padStart(2)} `}</Text>
149              {playing ? (
150                <Box {...(isSel ? { backgroundColor: '#3e4451' } : {})}>
151                  <Button key={`pkg-${name}`} plain hotkey={KEYS[order.indexOf(name)]} label={isSel ? `${label} ◂` : label} onPress={() => select(i)} />
152                </Box>
153              ) : (
154                <Text color={OK}>{label}</Text>
155              )}
156            </Box>
157          )
158        })}
159      </Box>
160    )
161    const clues = (
162      <Box key="clues" flexDirection="column" flexShrink={1} flexGrow={1}>
163        <Text dimColor bold>constraints</Text>
164        {p.clues.map(clueRow)}
165      </Box>
166    )
167    return (
168      <Box flexDirection="column">
169        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 4 : 1}>
170          {queue}
171          {clues}
172        </Box>
173        <Box key="controls" flexDirection="row" gap={3} marginTop={1}>
174          {controls}
175          {triesText}
176        </Box>
177      </Box>
178    )
179  },
180}
181
hooks/games/ladder/view.tsx 160 lines
1import { esc, FONT } from '../../shared'
2import type { Game } from '../types'
3import { hint, isDone, marks, meta, moves, puzzle, shareText, step, undo } from './logic'
4import type { LadderPuzzle, LadderState, Mark } from './logic'
5
6export type LadderSave = { path: string[] }
7
8const C = { start: '#2563eb', step: '#3f3f46', changed: '#d97706', target: '#16a34a', ghost: '#27272a', rail: '#a16207' }
9const MARK_COLOR: Record<Mark, string> = { '▲': '#4ade80', '■': '#a1a1aa', '▼': '#f87171' }
10
11/** One rung of the drawing: its word (blank for the next slot), tile colors, and a side label. */
12type Rung = { key: string; word: string; fills: string[]; label: string; mark?: Mark; ghost?: boolean }
13
14const changedAt = (a: string, b: string) => [...b].findIndex((c, i) => c !== a[i])
15
16/** Start on top, the player's rungs, then (unsolved) a blank slot, a gap marker and the target. */
17const rungsOf = (st: LadderState): Rung[] => {
18  const ms = marks(st)
19  const done = isDone(st)
20  const rungs: Rung[] = st.path.map((w, i) => {
21    const at = i ? changedAt(st.path[i - 1]!, w) : -1
22    const isTarget = done && i === st.path.length - 1
23    const base = i === 0 ? C.start : isTarget ? C.target : C.step
24    return { key: `rung-${i}`, word: w, fills: [...w].map((_, j) => (j === at ? C.changed : base)), label: i === 0 ? 'start' : `${i}`, mark: ms[i - 1] }
25  })
26  if (done) return rungs
27  const cur = st.path[st.path.length - 1]!
28  return [
29    ...rungs,
30    { key: 'rung-next', word: '    ', fills: Array(4).fill(C.ghost), label: 'next', ghost: true },
31    // Target letters already in place glow green; the rest wait in gray.
32    { key: 'rung-target', word: st.target, fills: [...st.target].map((c, j) => (cur[j] === c ? C.target : C.step)), label: 'target' },
33  ]
34}
35
36const ladderSvg = (rungs: Rung[], done: boolean) => {
37  const [T, G, PAD, RH] = [46, 8, 26, 62]
38  const inner = 4 * T + 3 * G
39  const w = inner + 2 * PAD + 70
40  const h = rungs.length * RH + 20
41  const [l, r] = [PAD / 2, PAD + inner + PAD / 2]
42  const parts = [
43    `<rect x="${l - 5}" y="0" width="10" height="${h}" rx="5" fill="${C.rail}"/>`,
44    `<rect x="${r - 5}" y="0" width="10" height="${h}" rx="5" fill="${C.rail}"/>`,
45  ]
46  rungs.forEach((g, i) => {
47    const y = 10 + i * RH
48    const gap = !done && g.key === 'rung-target'
49    parts.push(`<rect x="${l}" y="${y + T + 4}" width="${r - l}" height="6" rx="3" fill="${C.rail}" opacity="0.8"/>`)
50    if (gap) parts.push(`<text x="${(l + r) / 2}" y="${y - 2}" text-anchor="middle" font-size="14" fill="#a1a1aa">⋮</text>`)
51    ;[...g.word].forEach((ch, j) => {
52      const x = PAD + j * (T + G)
53      parts.push(
54        g.ghost
55          ? `<rect x="${x + 1}" y="${y + 1}" width="${T - 2}" height="${T - 2}" rx="8" fill="#8881" stroke="#8886" stroke-width="2" stroke-dasharray="5 4"/>`
56          : `<rect x="${x}" y="${y}" width="${T}" height="${T}" rx="8" fill="${g.fills[j]}"/><text x="${x + T / 2}" y="${y + T / 2 + 8}" text-anchor="middle" font-size="24" font-weight="700" fill="#fff">${esc(ch.toUpperCase())}</text>`,
57      )
58    })
59    const lx = r + 14
60    parts.push(`<text x="${lx}" y="${y + T / 2 + 5}" font-size="13" fill="${g.mark ? MARK_COLOR[g.mark] : '#a1a1aa'}">${esc(g.mark ? `${g.mark} ${g.label}` : g.label)}</text>`)
61  })
62  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ${FONT}>${parts.join('')}</svg>`
63}
64
65export const ladder: Game<LadderPuzzle, LadderSave> = {
66  ...meta,
67  puzzle,
68  fresh: p => ({ path: [p.start] }),
69  status: (p, s) => (isDone({ ...p, ...s }) ? 'won' : 'playing'),
70  share: (p, s) => shareText({ ...p, ...s }).replace(/^Ladder #\d+ /, ''),
71  view: v => {
72    const { Box, Text, Button } = v.els
73    const Input = v.surface === 'mobile' ? undefined : v.els.Input
74    const p = v.puzzle
75    const st: LadderState = { ...p, ...v.save }
76    const playing = v.status === 'playing'
77    const rungs = rungsOf(st)
78    const n = moves(st)
79
80    const submit = (value: string) =>
81      v.play(s => {
82        const r = step({ ...p, ...s }, value)
83        return 'error' in r ? { note: r.error } : { save: { path: r.path } }
84      })
85    const takeBack = () => v.play(s => ({ save: { path: undo({ ...p, ...s }).path } }))
86    const askHint = () =>
87      v.play(s => {
88        const h = hint({ ...p, ...s })
89        return { note: h ? `Try ${h.toUpperCase()}.` : 'No route from here: undo a rung.' }
90      })
91
92    const isRich = v.surface !== 'terminal'
93    const btn = isRich ? {} : { plain: true as const }
94    const controls = playing && (
95      <Box key="controls" flexDirection="row" gap={2}>
96        <Button key="undo" {...btn} label={isRich ? 'Undo' : '[undo]'} onPress={takeBack} />
97        <Button key="hint" {...btn} label={isRich ? 'Hint' : '[hint]'} onPress={askHint} />
98      </Box>
99    )
100    const field = !playing ? null : Input ? (
101      <Input key="word" autoFocus label="next rung" placeholder="four letters, Enter" submitLabel="climb" value="" onSubmit={submit} />
102    ) : (
103      <Text dimColor>Ladder needs a keyboard: play it in the terminal or desktop app.</Text>
104    )
105    const over = n - p.par
106    const score = (
107      <Box key="score"><Text>
108        <Text bold>{p.start.toUpperCase()}</Text>
109        <Text dimColor>{' → '}</Text>
110        <Text bold>{p.target.toUpperCase()}</Text>
111        <Text dimColor>{`  ·  ${n} ${n === 1 ? 'move' : 'moves'}  ·  par ${p.par}`}</Text>
112        {!playing && <Text color={over === 0 ? '#4ade80' : '#fbbf24'}>{over === 0 ? '  ⛳ on par' : `  +${over}`}</Text>}
113      </Text></Box>
114    )
115
116    if (isRich) {
117      const { Svg } = v.els
118      return (
119        <Box flexDirection="column" alignItems="center" gap={1}>
120          <Box flexDirection="column" alignItems="center" gap={1} borderStyle="round" borderColor="#57534e" paddingX={2} paddingY={1}>
121            {score}
122            <Svg source={ladderSvg(rungs, !playing)} alt={`Ladder from ${p.start} to ${p.target}: ${st.path.join(', ')} (${n} of par ${p.par})`} />
123            <Text dimColor>Change one letter per rung; every rung must be a word.</Text>
124          </Box>
125          {field}
126          {controls}
127        </Box>
128      )
129    }
130
131    const rail = <Text color={C.rail}>┃</Text>
132    const ladderRows = rungs.map(g => (
133      <Box key={g.key} flexDirection="row">
134        {rail}
135        <Text> </Text>
136        {[...g.word].map((ch, j) =>
137          g.ghost ? (
138            <Text backgroundColor="#34343a" color="#71717a">{' _ '}</Text>
139          ) : (
140            <Text backgroundColor={g.fills[j]} color="#ffffff" bold>{` ${ch.toUpperCase()} `}</Text>
141          ),
142        ).flatMap((t, j) => (j ? [<Text> </Text>, t] : [t]))}
143        <Text> </Text>
144        {rail}
145        <Text color={g.mark ? MARK_COLOR[g.mark] : undefined} dimColor={!g.mark}>{` ${g.mark ? `${g.mark} ` : ''}${g.label}`}</Text>
146      </Box>
147    ))
148    if (playing) ladderRows.splice(rungs.length - 1, 0, <Box key="gap" flexDirection="row">{rail}<Text dimColor>{'        ⋮        '}</Text>{rail}</Box>)
149    return (
150      <Box flexDirection="column">
151        {score}
152        <Box flexDirection="column" marginTop={1}>{ladderRows}</Box>
153        <Text dimColor>▲ closer  ■ same  ▼ further · change one letter per rung</Text>
154        {field && <Box marginTop={1}>{field}</Box>}
155        {controls && <Box marginTop={field ? 0 : 1}>{controls}</Box>}
156      </Box>
157    )
158  },
159}
160
hooks/games/pixellogic/view.tsx 230 lines
1import type { Game } from '../types'
2import { clues, emptyBoard, isMistake, isSolved, meta, puzzle, shareText, wrongCells } from './logic'
3import type { Cell, Puzzle } from './logic'
4
5export type PixelSave = { board: Cell[]; mode: 1 | 2; hints: number; mistakes: number; wrong: number[] }
6
7const LETTERS = 'abcdefghij'
8const FILL = '#3b82f6'
9const SOLVED = '#f59e0b'
10const WRONG = '#dc2626'
11const BLOCK = ['#3f3f46', undefined] // 5×5 blocks shaded alternately so cells are easy to count (no bg survives any color depth)
12
13const cellName = (size: number, i: number) => `${LETTERS[i % size]}${Math.floor(i / size) + 1}`
14const clueText = (c: number[]) => (c.length ? c : [0]).join(' ')
15
16/**
17 * "c4" fills, "xc4" marks, "a1-e1" covers a run within one row or column. Several separated by spaces or commas.
18 * Returns the cells and what to set them to, or null if anything does not parse.
19 */
20export const parseMove = (size: number, text: string): { cells: number[]; to: 1 | 2 } | null => {
21  const tokens = text.trim().toLowerCase().split(/[\s,]+/).filter(Boolean)
22  if (!tokens.length) return null
23  const cells: number[] = []
24  let to: 1 | 2 = 1
25  for (const t of tokens) {
26    const m = /^(x?)([a-j])(\d{1,2})(?:-([a-j])(\d{1,2}))?$/.exec(t)
27    if (!m) return null
28    if (m[1]) to = 2
29    const [c1, r1] = [LETTERS.indexOf(m[2]!), Number(m[3]) - 1]
30    const [c2, r2] = m[4] ? [LETTERS.indexOf(m[4]), Number(m[5]) - 1] : [c1, r1]
31    if ([c1, r1, c2, r2].some(n => n < 0 || n >= size) || (c1 !== c2 && r1 !== r2)) return null
32    for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++)
33      for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) cells.push(r * size + c)
34  }
35  return { cells, to }
36}
37
38/** Sets `cells` to `to`, or clears them all if they all already are; counts cells set wrongly. */
39const apply = (p: Puzzle, s: PixelSave, cells: number[], to: 1 | 2): PixelSave => {
40  const isClear = cells.every(i => s.board[i] === to)
41  const board = [...s.board]
42  let mistakes = s.mistakes
43  for (const i of cells) {
44    if (!isClear && board[i] !== to && isMistake(p, i, to)) mistakes++
45    board[i] = isClear ? 0 : to
46  }
47  return { ...s, board, mistakes, wrong: [] }
48}
49
50const pictureSvg = (p: Puzzle) => {
51  const [T, G] = [22, 3]
52  const n = p.size
53  const side = n * T + (n - 1) * G
54  const rects = p.rows.flatMap((row, r) =>
55    [...row].map((ch, c) => `<rect x="${c * (T + G)}" y="${r * (T + G)}" width="${T}" height="${T}" rx="4" fill="${ch === '#' ? SOLVED : '#8882'}"/>`),
56  )
57  return `<svg xmlns="http://www.w3.org/2000/svg" width="${side}" height="${side}" viewBox="0 0 ${side} ${side}">${rects.join('')}</svg>`
58}
59
60export const pixellogic: Game<Puzzle, PixelSave> = {
61  ...meta,
62  puzzle,
63  fresh: p => ({ board: emptyBoard(p), mode: 1, hints: 0, mistakes: 0, wrong: [] }),
64  status: (p, s) => (isSolved(p, s.board) ? 'won' : 'playing'),
65  share: (p, s) => shareText(p, { hints: s.hints, mistakes: s.mistakes }).replace(/^Pixel Logic #\d+ /, ''),
66  view: v => {
67    const { Box, Text, Button } = v.els
68    const Input = v.surface === 'mobile' ? undefined : v.els.Input
69    const p = v.puzzle
70    const n = p.size
71    const { board, mode, hints, mistakes, wrong } = v.save
72    const isPlaying = v.status === 'playing'
73    const isRich = v.surface !== 'terminal'
74    const bad = new Set(wrong)
75
76    // A line is satisfied when the runs drawn so far equal its clue: its clue goes dim.
77    const drawn = clues(Array.from({ length: n }, (_, r) => board.slice(r * n, r * n + n).map(c => (c === 1 ? '#' : '.')).join('')))
78    const rowDone = p.clues.rows.map((c, r) => !isPlaying || clueText(c) === clueText(drawn.rows[r]!))
79    const colDone = p.clues.cols.map((c, k) => !isPlaying || clueText(c) === clueText(drawn.cols[k]!))
80
81    const play = (cells: number[], to: 1 | 2) => v.play(s => (isPlaying ? { save: apply(p, s, cells, to) } : {}))
82    const check = () =>
83      v.play(s => {
84        const w = wrongCells(p, s.board)
85        return { save: { ...s, hints: s.hints + 1, wrong: w }, note: w.length ? `${w.length} wrong cell${w.length === 1 ? '' : 's'}, shown in red.` : 'Nothing wrong so far.' }
86      })
87    const toggleMode = () => v.play(s => ({ save: { ...s, mode: s.mode === 1 ? 2 : 1 } }))
88
89    // Terminal cells are 2 columns by 1 row; elsewhere they get room to breathe.
90    const [cw, ch] = !isRich ? [2, 1] : v.surface === 'mobile' ? [3, 1] : [4, 2]
91    const rowW = Math.max(...p.clues.rows.map(c => clueText(c).length))
92    const colH = Math.max(...p.clues.cols.map(c => Math.max(1, c.length)))
93
94    const cellBg = (i: number) => {
95      const c = board[i]
96      if (bad.has(i)) return WRONG
97      if (!isPlaying || c === 1) return isRich && c === 1 ? (isPlaying ? FILL : SOLVED) : undefined
98      return BLOCK[(Math.floor(i / n / 5) + Math.floor((i % n) / 5)) % 2]
99    }
100    const cellLabel = (i: number) => {
101      const c = board[i]
102      if (isRich) return c === 1 ? '■' : c === 2 ? '×' : '·'
103      // Solid blocks in the text color read in any theme and color depth.
104      return c === 1 ? (bad.has(i) ? '▒▒' : '██') : c === 2 ? '× ' : '  '
105    }
106
107    const colClues = Array.from({ length: colH }, (_, line) => (
108      <Box key={`colclue-${line}`} flexDirection="row">
109        <Box width={rowW + 1} flexShrink={0} />
110        {p.clues.cols.map((c, k) => {
111          const list = c.length ? c : [0]
112          const at = line - (colH - list.length)
113          return (
114            <Box width={cw} flexShrink={0} justifyContent={isRich ? 'center' : 'flex-end'}>
115              <Text dimColor={colDone[k]} bold={!colDone[k]}>{at >= 0 ? String(list[at]) : ''}</Text>
116            </Box>
117          )
118        })}
119      </Box>
120    ))
121    const letters = (
122      <Box flexDirection="row">
123        <Box width={rowW + 1} flexShrink={0} />
124        {[...LETTERS.slice(0, n)].map(l => (
125          <Box width={cw} flexShrink={0} justifyContent={isRich ? 'center' : 'flex-end'}>
126            <Text color="#71717a">{l}</Text>
127          </Box>
128        ))}
129      </Box>
130    )
131    const rows = Array.from({ length: n }, (_, r) => (
132      <Box key={`row-${r}`} flexDirection="row">
133        <Box width={rowW + 1} height={ch} flexShrink={0} justifyContent="flex-end" alignItems="center" paddingRight={1}>
134          <Text dimColor={rowDone[r]} bold={!rowDone[r]}>{clueText(p.clues.rows[r]!)}</Text>
135        </Box>
136        {Array.from({ length: n }, (_, c) => {
137          const i = r * n + c
138          return (
139            <Box width={cw} height={ch} flexShrink={0} backgroundColor={cellBg(i)} justifyContent="center" alignItems="center">
140              {isPlaying ? (
141                <Button key={`cell-${cellName(n, i)}`} plain label={cellLabel(i)} onPress={() => play([i], v.save.mode)} />
142              ) : (
143                <Text color={SOLVED}>{!isRich && board[i] === 1 ? '██' : '  '}</Text>
144              )}
145            </Box>
146          )
147        })}
148        <Box width={3} height={ch} flexShrink={0} alignItems="center">
149          <Text color="#71717a">{` ${r + 1}`}</Text>
150        </Box>
151      </Box>
152    ))
153    const grid = (
154      <Box key="grid" flexDirection="column" flexShrink={0} {...(isRich ? { borderStyle: 'round', borderColor: '#57534e', paddingX: 1 } : {})}>
155        {colClues}
156        {letters}
157        {rows}
158      </Box>
159    )
160
161    const tally = (
162      <Box key="tally"><Text>
163        <Text dimColor>hints </Text>
164        <Text>{hints}</Text>
165        <Text dimColor>{'  ·  mistakes '}</Text>
166        <Text color={mistakes ? '#f87171' : undefined}>{mistakes}</Text>
167      </Text></Box>
168    )
169    const title = !isPlaying && (
170      <Box key="title"><Text>
171        <Text dimColor>It's </Text>
172        <Text bold color={SOLVED}>{p.title}</Text>
173        <Text dimColor>{` · ${n}×${n}`}</Text>
174      </Text></Box>
175    )
176    const rules = <Text dimColor>Numbers are runs of filled cells, in order. Fill them all to reveal the picture.</Text>
177    const field = isPlaying && Input && (
178      <Input key="cell" autoFocus label="cell" placeholder="c4 fills, xc4 marks, a1-e1 a run" submitLabel="go" value=""
179        onSubmit={text => {
180          const m = parseMove(n, text)
181          if (!m) return void v.play(() => ({ note: `Cells look like c4 (column a-${LETTERS[n - 1]}, row 1-${n}); xc4 marks; a1-e1 a run.` }))
182          play(m.cells, m.to)
183        }}
184      />
185    )
186
187    if (isRich) {
188      const controls = isPlaying && (
189        <Box key="controls" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap" justifyContent="center">
190          <Button key="mode-fill" {...(mode === 1 ? { variant: 'primary' as const } : {})} label="■ Fill" onPress={() => mode !== 1 && toggleMode()} />
191          <Button key="mode-mark" {...(mode === 2 ? { variant: 'primary' as const } : {})} label="× Mark" onPress={() => mode !== 2 && toggleMode()} />
192          <Button key="check" label="Check (hint)" onPress={check} />
193        </Box>
194      )
195      return (
196        <Box flexDirection="column" alignItems="center" gap={1}>
197          {isPlaying ? rules : title}
198          {isPlaying ? grid : <v.els.Svg source={pictureSvg(p)} alt={`The finished picture: ${p.title}`} />}
199          {controls}
200          {tally}
201          {field}
202        </Box>
203      )
204    }
205
206    const side = (
207      <Box key="controls" flexDirection="column" flexShrink={1} marginTop={1} gap={1}>
208        {isPlaying ? rules : title}
209        {isPlaying && (
210          <Box flexDirection="row" gap={2}>
211            <Button key="mode" plain hotkey="m" label={mode === 1 ? 'filling ■' : 'marking ×'} onPress={toggleMode} />
212            <Button key="check" plain hotkey="c" label="check (hint)" onPress={check} />
213          </Box>
214        )}
215        {tally}
216      </Box>
217    )
218    const isWide = v.width >= 60
219    return (
220      <Box flexDirection="column">
221        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 3 : 0}>
222          {grid}
223          {side}
224        </Box>
225        {field && <Box marginTop={1}>{field}</Box>}
226      </Box>
227    )
228  },
229}
230
hooks/games/codebreaker/view.tsx 227 lines
1import { FONT } from '../../shared'
2import type { Game } from '../types'
3import { guess, isDone, isWon, LEN, LETTERS, meta, puzzle, remaining, score, share, SYMBOLS, TRIES } from './logic'
4import type { Letter, State } from './logic'
5
6/** Guesses made, the row being composed, and hard mode (only settable before the first guess). */
7export type CodebreakerSave = { guesses: string[]; draft: string; hard: boolean }
8
9const PEG: Record<Letter, string> = { R: '#ef4444', O: '#f97316', T: '#14b8a6', B: '#3b82f6', V: '#8b5cf6', P: '#ec4899' }
10const cap = (s: string) => s[0]!.toUpperCase() + s.slice(1)
11
12const stateOf = (code: string, s: CodebreakerSave): State => ({
13  day: 0,
14  code,
15  hard: s.hard,
16  rows: s.guesses.map(g => ({ guess: g, ...score(code, g) })),
17})
18
19/** Submits `g` (or the draft): a refused guess keeps the draft and says why. */
20const submit = (code: string, s: CodebreakerSave, g = s.draft) => {
21  const next = guess(stateOf(code, s), g)
22  if ('error' in next) return { note: g.length < LEN ? `Pick ${LEN} colors first.` : next.error }
23  return { save: { ...s, guesses: next.rows.map(r => r.guess), draft: '' } }
24}
25
26/** The board as an SVG: a code cover on top, eight rows of round pegs, each with its 2×2 feedback pins. */
27const boardSvg = (st: State, draft: string, done: boolean) => {
28  const [R, G, PAD, HEAD] = [15, 8, 14, 52]
29  const pinX = PAD + 28 + LEN * (2 * R + G) + 6
30  const w = pinX + 40 + PAD
31  const rowH = 2 * R + 8
32  const h = HEAD + TRIES * rowH + PAD
33  const cx = (i: number) => PAD + 28 + R + i * (2 * R + G)
34  const peg = (x: number, y: number, c?: string, ghost = false) =>
35    c
36      ? `<circle cx="${x}" cy="${y}" r="${R}" fill="${PEG[c as Letter]}"/><circle cx="${x - 5}" cy="${y - 5}" r="${R / 3}" fill="#fff" opacity=".28"/>`
37      : `<circle cx="${x}" cy="${y}" r="${R - 4}" fill="#0005" stroke="${ghost ? '#a1a1aa' : '#3f3f46'}" stroke-width="2"${ghost ? ' stroke-dasharray="3 3"' : ''}/>`
38  const out: string[] = [`<rect width="${w}" height="${h}" rx="16" fill="#27272a"/>`]
39  // The code, under a cover until the game ends.
40  const cy0 = PAD + R + 2
41  if (done) for (let i = 0; i < LEN; i++) out.push(peg(cx(i), cy0, st.code[i]))
42  else {
43    out.push(`<rect x="${PAD + 28 - 4}" y="${cy0 - R - 4}" width="${LEN * (2 * R + G) - G + 8}" height="${2 * R + 8}" rx="${R + 4}" fill="#52525b"/>`)
44    for (let i = 0; i < LEN; i++) out.push(`<text x="${cx(i)}" y="${cy0 + 6}" text-anchor="middle" font-size="18" font-weight="700" fill="#a1a1aa">?</text>`)
45  }
46  out.push(`<line x1="${PAD}" x2="${w - PAD}" y1="${HEAD - 6}" y2="${HEAD - 6}" stroke="#3f3f46" stroke-width="2"/>`)
47  for (let r = 0; r < TRIES; r++) {
48    const y = HEAD + r * rowH + rowH / 2
49    const row = st.rows[r]
50    const isNext = r === st.rows.length && !done
51    if (isNext) out.push(`<rect x="${PAD - 4}" y="${y - rowH / 2 + 1}" width="${w - 2 * PAD + 8}" height="${rowH - 2}" rx="10" fill="#3f3f46"/>`)
52    out.push(`<text x="${PAD + 10}" y="${y + 5}" text-anchor="middle" font-size="13" fill="#71717a">${r + 1}</text>`)
53    const letters = row?.guess ?? (isNext ? draft : '')
54    for (let i = 0; i < LEN; i++) out.push(peg(cx(i), y, letters[i], isNext && i === letters.length))
55    const pins = row ? [...Array(row.exact).fill('#fafafa'), ...Array(row.near).fill('near')] : []
56    for (let i = 0; i < LEN; i++) {
57      const [px, py] = [pinX + 8 + (i % 2) * 16, y - 8 + Math.floor(i / 2) * 16]
58      const p = pins[i]
59      out.push(p === 'near'
60        ? `<circle cx="${px}" cy="${py}" r="5" fill="none" stroke="#fafafa" stroke-width="2"/>`
61        : `<circle cx="${px}" cy="${py}" r="${p ? 6 : 3}" fill="${p ?? '#52525b'}"/>`)
62    }
63  }
64  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ${FONT}>${out.join('')}</svg>`
65}
66
67const swatchSvg = (c: string) =>
68  `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="${c}"/><circle cx="11" cy="11" r="4" fill="#fff" opacity=".3"/></svg>`
69
70export const codebreaker: Game<string, CodebreakerSave> = {
71  ...meta,
72  puzzle,
73  fresh: () => ({ guesses: [], draft: '', hard: false }),
74  status: (code, s) => {
75    const st = stateOf(code, s)
76    return isWon(st) ? 'won' : isDone(st) ? 'lost' : 'playing'
77  },
78  // The shell already prints "Daily Grind · Codebreaker #n  ": keep only the score and dot rows.
79  share: (code, s) => share(stateOf(code, s)).replace(/^Codebreaker #\S+ /, ''),
80  view: v => {
81    const { Box, Text, Button } = v.els
82    const Input = v.surface === 'mobile' ? undefined : v.els.Input
83    const code = v.puzzle
84    const { draft, hard } = v.save
85    const st = stateOf(code, v.save)
86    const isPlaying = v.status === 'playing'
87    const isRich = v.surface !== 'terminal'
88    const left = remaining(st)
89
90    const add = (c: string) => v.play(s => (s.draft.length < LEN ? { save: { ...s, draft: s.draft + c } } : { note: 'Row full: guess it, or ⌫.' }))
91    const typed = (text: string) =>
92      v.play(s => {
93        const t = text.toUpperCase().replace(/[\s,]+/g, '')
94        if ([...t].some(c => !LETTERS.includes(c))) return { note: `Colors are ${[...LETTERS].join(' ')}.` }
95        const g = s.draft + t
96        if (g.length > LEN) return { note: `Only ${LEN} pegs a row.` }
97        return g.length === LEN ? submit(code, s, g) : { save: { ...s, draft: g } }
98      })
99    const edit = (f: (d: string) => string) => () => v.play(s => ({ save: { ...s, draft: f(s.draft) } }))
100    const flat = isRich ? {} : { plain: true as const }
101
102    const palette = [...LETTERS].map(c =>
103      isRich ? (
104        <Box key={`pal-${c}`} flexDirection="column" alignItems="center" gap={1}>
105          {v.surface !== 'terminal' && <v.els.Svg source={swatchSvg(PEG[c as Letter])} alt={SYMBOLS[c as Letter]} width={32} height={32} />}
106          <Button key={`peg-${c}`} label={cap(SYMBOLS[c as Letter])} onPress={() => add(c)} />
107        </Box>
108      ) : (
109        <Box backgroundColor={PEG[c as Letter]}>
110          <Button key={`peg-${c}`} plain label={` ${c} `} onPress={() => add(c)} />
111        </Box>
112      ),
113    )
114    const controls = (
115      <Box key="controls" flexDirection="row" gap={isRich ? 1 : 2} alignItems="center">
116        <Button key="undo" {...flat} label="⌫" onPress={edit(d => d.slice(0, -1))} />
117        <Button key="clear" {...flat} label="clear" onPress={edit(() => '')} />
118        <Button key="guess" {...flat} variant={draft.length === LEN ? 'primary' : undefined} label={`guess ${draft.length}/${LEN}`} onPress={() => v.play(s => submit(code, s))} />
119      </Box>
120    )
121    // Always drawn while playing: removing a focusable mid-game moves the focus off the Input.
122    const hardToggle = isPlaying && (
123      <Button key="hard" {...flat} dimColor={!isRich} label={`hard mode: ${hard ? 'on' : 'off'}${st.rows.length ? ' (locked)' : ''}`}
124        onPress={() => v.play(s => (s.guesses.length ? { note: 'Hard mode is set before the first guess.' } : { save: { ...s, hard: !s.hard } }))} />
125    )
126    const stat = (
127      <Box key="stat">
128        <Text>
129          <Text dimColor>codes still possible: </Text>
130          <Text bold color={left === 1 ? '#22c55e' : undefined}>{left}</Text>
131          {hard && <Text color="#f59e0b">  hard</Text>}
132        </Text>
133      </Box>
134    )
135    const field = isPlaying && Input && (
136      <Input key="code" autoFocus label="colors" placeholder={`e.g. ROTB (${LETTERS}), Enter`} submitLabel="guess" value="" onSubmit={typed} />
137    )
138    const outcome = !isPlaying && (
139      <Box key="outcome">
140        <Text>
141          {v.status === 'won' ? `Cracked in ${st.rows.length}.` : 'Out of guesses.'} The code was{' '}
142          {[...code].map(c => <Text bold color={PEG[c as Letter]}>{c}</Text>)}
143          <Text dimColor> ({[...code].map(c => SYMBOLS[c as Letter]).join(', ')})</Text>
144        </Text>
145      </Box>
146    )
147
148    if (isRich) {
149      const { Svg } = v.els
150      return (
151        <Box flexDirection="column" alignItems="center" gap={1}>
152          <Text dimColor>Four pegs, six colors, repeats allowed. ● right color and spot · ○ right color, wrong spot.</Text>
153          <Svg source={boardSvg(st, draft, !isPlaying)} alt={`Codebreaker board: ${st.rows.length} of ${TRIES} guesses${isPlaying ? '' : `, code ${code}`}`} />
154          {stat}
155          {isPlaying && <Box key="palette" flexDirection="row" gap={2} flexWrap="wrap" justifyContent="center">{palette}</Box>}
156          {isPlaying && controls}
157          {hardToggle}
158          {field}
159          {outcome}
160        </Box>
161      )
162    }
163
164    // Terminal: each peg a 3-cell colored block with its letter, feedback in an aligned column.
165    const block = (c: string | undefined, bg: string, text: string) =>
166      c ? <Text backgroundColor={PEG[c as Letter]} color="#ffffff" bold>{` ${c} `}</Text> : <Text backgroundColor={bg} color="#71717a">{text}</Text>
167    const pegs = (letters: string, bg: string, text = ' · ') => (
168      <Box flexDirection="row" gap={1}>{Array.from({ length: LEN }, (_, i) => block(letters[i], bg, text))}</Box>
169    )
170    const dots = (r?: { exact: number; near: number }) =>
171      r ? (
172        <Text>
173          <Text color="#fafafa">{'●'.repeat(r.exact)}</Text>
174          <Text color="#a1a1aa">{'○'.repeat(r.near)}</Text>
175          <Text color="#52525b">{'·'.repeat(LEN - r.exact - r.near)}</Text>
176        </Text>
177      ) : <Text> </Text>
178    const rows = Array.from({ length: TRIES }, (_, r) => {
179      const row = st.rows[r]
180      const isNext = r === st.rows.length && isPlaying
181      return (
182        <Box key={`row-${r}`} flexDirection="row" gap={2}>
183          <Text color={isNext ? '#f59e0b' : '#52525b'}>{isNext ? '›' : String(r + 1)}</Text>
184          {pegs(row?.guess ?? (isNext ? draft : ''), isNext ? '#3f3f46' : '#1f1f23', isNext ? ' _ ' : ' · ')}
185          {dots(row)}
186        </Box>
187      )
188    })
189    const secret = (
190      <Box key="secret" flexDirection="row" gap={2}>
191        <Text> </Text>
192        {isPlaying ? pegs('', '#52525b', ' ? ') : pegs(code, '')}
193        <Text dimColor>code</Text>
194      </Box>
195    )
196    const board = (
197      <Box flexDirection="column" flexShrink={0}>
198        {secret}
199        <Text color="#3f3f46">{'─'.repeat(25)}</Text>
200        {rows}
201      </Box>
202    )
203    const side = (
204      <Box flexDirection="column" gap={1} flexShrink={1}>
205        <Box flexDirection="column">
206          <Text dimColor>4 pegs, 6 colors, repeats allowed.</Text>
207          <Text dimColor>● right color, right spot  ○ wrong spot</Text>
208        </Box>
209        {isPlaying && <Box key="palette" flexDirection="row" gap={1}>{palette}</Box>}
210        {isPlaying && controls}
211        {stat}
212        {hardToggle}
213      </Box>
214    )
215    const isWide = v.width >= 60
216    return (
217      <Box flexDirection="column">
218        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 4 : 1}>
219          {board}
220          {side}
221        </Box>
222        {(field || outcome) && <Box marginTop={1}>{field || outcome}</Box>}
223      </Box>
224    )
225  },
226}
227
hooks/games/daemons/view.tsx 107 lines
1import type { Game } from '../types'
2import { boardFor, cellName, clashes, isSolved, N, parseCell, toggle } from './logic'
3import type { DaemonsSave } from './logic'
4
5const REGION_BG = ['#9f1239', '#1d4ed8', '#15803d', '#a16207', '#7e22ce', '#0e7490', '#57534e']
6const COLS = 'abcdefg'
7
8export const daemons: Game<number[], DaemonsSave> = {
9  id: 'daemons',
10  name: 'Daemons',
11  category: 'Classics',
12  tagline: 'One per row, column and color',
13  puzzle: boardFor,
14  fresh: () => ({ cells: [], moves: 0 }),
15  status: (board, s) => (isSolved(board, s.cells) ? 'won' : 'playing'),
16  share: (_, s) => `◆ ${s.moves} moves`,
17  view: v => {
18    const { Box, Text, Button } = v.els
19    const Input = v.surface === 'mobile' ? undefined : v.els.Input
20    const board = v.puzzle
21    const { cells } = v.save
22    const bad = clashes(board, cells)
23    const isPlaying = v.status === 'playing'
24    const flip = (list: number[]) => v.play(s => (isPlaying ? { save: toggle(s, list) } : {}))
25    const isRich = v.surface !== 'terminal'
26    // Terminal cells are 3 columns by 1 row (about square); desktop cells get room to breathe.
27    const [cw, ch] = isRich ? [5, 2] : [3, 1]
28    const glyph = (i: number) => (cells.includes(i) ? (bad.has(i) ? '✖' : '◆') : isRich ? '' : ' ')
29
30    const grid = (
31      <Box flexDirection="column" flexShrink={0} {...(isRich ? { borderStyle: 'round', borderColor: '#57534e' } : {})}>
32        <Box flexDirection="row">
33          <Text dimColor>{'   '}</Text>
34          {[...COLS].map(c => (
35            <Box width={cw} justifyContent="center">
36              <Text dimColor>{c}</Text>
37            </Box>
38          ))}
39        </Box>
40        {Array.from({ length: N }, (_, r) => (
41          <Box key={`grid-${r}`} flexDirection="row">
42            <Box width={3} height={ch} alignItems="center">
43              <Text dimColor>{` ${r + 1}`}</Text>
44            </Box>
45            {Array.from({ length: N }, (_, c) => {
46              const i = r * N + c
47              return (
48                <Box width={cw} height={ch} backgroundColor={REGION_BG[board[i]!]} justifyContent="center" alignItems="center">
49                  <Button key={`cell-${cellName(i)}`} plain label={isRich ? glyph(i) || '·' : ` ${glyph(i)} `} onPress={() => flip([i])} />
50                </Box>
51              )
52            })}
53          </Box>
54        ))}
55      </Box>
56    )
57    const placed = cells.length - bad.size
58    const status = (
59      <Text>
60        <Text color={bad.size ? '#f87171' : '#a3e635'}>{'◆'.repeat(Math.max(0, placed))}</Text>
61        <Text color="#f87171">{'✖'.repeat(bad.size)}</Text>
62        <Text dimColor>{'·'.repeat(Math.max(0, N - cells.length))} {cells.length}/{N}</Text>
63      </Text>
64    )
65    const field = isPlaying && Input && (
66      <Input key="cell" autoFocus label="cell" placeholder="e.g. c4 (toggles)" submitLabel="toggle" value=""
67        onSubmit={value => {
68          const list = value.split(/[\s,]+/).filter(Boolean).map(parseCell)
69          if (list.length === 0 || list.some(c => c === null)) return void v.play(() => ({ note: 'Cells look like c4: column a-g, row 1-7.' }))
70          flip(list as number[])
71        }}
72      />
73    )
74    const reset = isPlaying && (
75      <Button key="reset" {...(isRich ? {} : { plain: true as const })} hotkey="r" label="reset" onPress={() => v.play(s => ({ save: { ...s, cells: [] } }))} />
76    )
77    const rules = <Text dimColor>Place 7 ◆: one per row, column and color. None may touch, even diagonally.</Text>
78
79    if (isRich)
80      return (
81        <Box flexDirection="column" alignItems="center" gap={1}>
82          {rules}
83          {grid}
84          <Box key="controls" flexDirection="row" gap={2} alignItems="center">
85            {status}
86            {reset}
87          </Box>
88          {field}
89        </Box>
90      )
91    const isWide = v.width >= 60
92    return (
93      <Box flexDirection="column">
94        <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 3 : 0}>
95          {grid}
96          <Box key="controls" flexDirection="column" flexShrink={1} marginTop={1} gap={1}>
97            {rules}
98            {status}
99            {reset}
100          </Box>
101        </Box>
102        {field && <Box marginTop={1}>{field}</Box>}
103      </Box>
104    )
105  },
106}
107