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

Claude Code mods by @sreshtalluri.

/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.
| Mod | What 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-asks | After 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. |

| Command | |
|---|---|
/receipt | This session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right. |
/receipt week | The last seven days: sessions, busiest day, top project, usual model, total. |
/receipt last | The previous session's receipt. |
~/.claude/receipts/ when it ends. Nothing leaves your machine.plugins/<name>/ (with .claude-plugin/plugin.json)..claude-plugin/marketplace.json.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.
MIT
hooks/register.tsx 301 lines1import { 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}
301hooks/games/index.ts 20 lines1import { 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]
20hooks/games/types.ts 42 lines1import 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}
42hooks/shared.ts 85 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
82
83/** The font desktop SVGs draw with. */
84export const FONT = `font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"`
85hooks/games/buckets/view.tsx 101 lines1import 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}
101hooks/games/gitgolf/view.tsx 212 lines1import { 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}
212hooks/games/heisenbug/view.tsx 208 lines1import 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}
208hooks/games/installorder/view.tsx 181 lines1import { 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}
181hooks/games/ladder/view.tsx 160 lines1import { 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}
160hooks/games/pixellogic/view.tsx 230 lines1import 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}
230hooks/games/codebreaker/view.tsx 227 lines1import { 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}
227hooks/games/daemons/view.tsx 107 lines1import 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