Sim a crossword position in MAGPIE and browse the results in a pane, with a board picture of each play: /magpie <rack | CGP | .cgp/.gcg path or URL>. Installs…

My personal mods for Claude Code. The repository is a plugin marketplace: each folder is one mod.
In a terminal Claude Code session, type:
/plugin install turn-copy --marketplace olaugh/claude-mods
Answer y to add the marketplace, then pick the user scope (Enter). The mod is active right away and loads in every session you start on that machine afterwards. Repeat once per machine.
To pick up changes later:
claude plugin update turn-copy
then /reload-plugins in a running session.
A copy button under each finished assistant reply. It copies the whole turn's text (every text block of the reply, joined) and turns into a check mark for 1.5 s, like the Claude desktop app. Beside it, the reply's size: 1,234 chars · 18 lines, which also copies when clicked.
content_copy U+F018F and check U+F012C), so the terminal needs a Nerd Font (kitty and Ghostty draw them built in). Clicks work in fullscreen terminal mode; otherwise use /copy.⧉ Copy / ✓ Copied.Sims a crossword position in MAGPIE and shows the results in a pane: a picture of the board with the selected play on it, beside a table of the sim's top 20 plays (win%, equity, static equity, iterations, the opponent's mean reply and bingo rate), each play spelled in tiles: bright green for the tiles it plays, darker green for the board's own it plays through, blanks' letters grey. Click a row to put its play on the board; click a heading to sort those 20 by it (again to flip); the wheel scrolls the rows.
Say what to sim after /magpie in your own words. A model (Haiku) reads them into a request, knowing what the pane shows and which lexicons MAGPIE has; the rest is MAGPIE's:
/magpie ADOPQRR an opening rack (in your lexicon unless you name another)
/magpie opening rack rucksex nwl NWL23, the newest NWL installed
/magpie AEINRST after 8g vav a rack after the moves before it (all lowercase is plain tiles; VaV has a blank)
/magpie 15/15/.../15 AEINRST/ 0/0 0 lex CSW24; a CGP
/magpie ~/games/round3.gcg turn 12 a GCG (path, URL, woogles.io or cross-tables.com link) at a turn
/magpie <game> the turn where josh played brisant or a rack held, or "after VODKA" (that player's next rack)
/magpie https://www.cross-tables.com/annotated.php?u=62161#13# the turn the viewer link's move 13 was played on
/magpie next turn back, first turn, end of game: through the game the pane shows
/magpie csw the same position again in another lexicon (it says if that is the one in use)
Play on from what the pane shows by saying what happened, as a game goes on:
/magpie inertias was played. opp played zag through the a in inertias. next rack for me is polish?
The model turns that into moves and a rack (polish? is ?HILOPS); each move is then found among the legal plays MAGPIE generates on the board so far (in milliseconds), narrowed by what you said: square, direction, score, the tiles it went through, "to the TWS". Your own move is the sim's best-ranked placement of the word, the one the pane showed; the opponent's, when it fits more than one place, is asked about, and a move that fits nowhere says so and where the word does fit. With no rack given, /magpie asks for it; then the new position is simmed.
Tiles are counted from MAGPIE's letter distribution less the board. A word needing more of a letter than is left takes blanks, and when the blank could be more than one of its tiles /magpie asks which ("the first Z (JAzZ) or the second Z (JAZz)?"; say a blank in lowercase to skip the question). A word the tiles cannot make ("PIZZAZZ needs 4 Zs, and only 1 Z and 2 blanks are left") and a rack the bag cannot hold are refused before anything runs. "Keeping ION" commits your move from the whole rack, and your next rack must hold ION. A short answer to a question ("the second z", "14G") is read with what you typed before it.
A game's position left out is its latest with a player to move. When a turn, rack or word fits more than one position, /magpie lists them and asks which. Asking Claude ("sim AEINRST after 8G VAV") works too: the mod gives the model a simulate tool that takes the same request as fields and reports the top plays back.
The table fills in while a sim runs, its numbers and order updating about twice a second. Each sim is 50 plays × 5,000 iterations at 4 plies, with WMP, and RIT and WIT where the lexicon has them. With the bag empty (seven or fewer tiles unseen) MAGPIE solves the endgame instead, and the table shows its 50 best first moves: Result the final spread (W+20, L-10, T=0), Net what the line nets, Then the rest of the line.
.exe, its SHA-256 checked; on macOS and Linux, where none is published yet, built from its source in about a minute, which needs Apple's command-line tools or make and a C compiler), then has MAGPIE fetch its data (about 195 MB, every language) and build your lexicon's word map. About a minute on a Mac, all told. /magpie setup opens the pane again, to change the language or lexicon; until MAGPIE is installed, /magpie and the simulate tool open it too.[L·L], [NY], [QU], and drawn on one tile. Write /magpie in your own language; with a language other than English chosen, its replies come back in that language (the quick model translates them, the plays and racks left as they are). Polish has no word map, so its sims are slower.~/.magpie (bin/magpie and data/; %LOCALAPPDATA%\MAGPIE on Windows, where MAGPIE itself looks), or wherever magpieDir says (claude plugin configure magpie-sim@olaugh-mods). To use a MAGPIE checkout's own build instead, after make release: mkdir -p ~/magpie/bin && cp bin/magpie ~/magpie/bin/ && cp -cR data ~/magpie/ (-c clones on APFS, so the 5 GB of data costs no space until it changes), and set magpieDir to ~/magpie./magpie sent from it gets a picture instead: a PNG of the board and the top 10 plays with their leaves, which the mod draws once the sim finishes and asks Claude to send (a short turn: a file card reaches the phone only from a SendUserFile call in the conversation). Asking Claude from the phone sends one too. /magpie picture (or "send it to my phone") sends one of what the pane shows.assets/), rasterized by the mod itself.Agents (Claude sessions) installing or writing mods: see AGENTS.md.
Edit a mod's hooks/register.tsx, then check it:
claude plugin validate turn-copy
claude plugin test turn-copy
To run a working copy without installing, start Claude Code with claude --plugin-dir ./turn-copy (or name the folder in CLAUDE_CODE_PLUGIN_DIRS).
hooks/register.tsx 2177 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { SortKey } from '../types'
5import { cardPicture } from './card'
6import { setTileScores } from './board'
7import { boardPicture, tilesColumnPicture, type Picture } from './draw'
8import { TILE_SCORES } from './layout'
9import {
10 cgpUnseen,
11 applyMove,
12 blankText,
13 checkLexicon,
14 choosePosition,
15 distributionName,
16 fitsDescription,
17 generateRun,
18 missingText,
19 missingTiles,
20 parseDistribution,
21 parseGenerated,
22 parseScores,
23 rackLess,
24 racksFor,
25 runLexicon,
26 sameTiles,
27 shortText,
28 tilesFromRack,
29 tilesLeft,
30 withBlanks,
31 type Tiles,
32 type GeneratedPlay,
33 DEFAULT_LEXICON,
34 preferredLexicon,
35 setPreferredLexicon,
36 describePosition,
37 gcgPositions,
38 interpreterPrompt,
39 ITERATIONS_PER_PLAY,
40 letters,
41 magpieRun,
42 modeFor,
43 parseIntent,
44 requestFromIntent,
45 scanRun,
46 SIM_PLAYS,
47 SIM_PLIES,
48 sniffPosition,
49 type Accelerators,
50 type GcgPosition,
51 type Intent,
52 type InterpreterContext,
53 type MagpieRun,
54 type Mode,
55 type MoveDescription,
56 type PlacedMove,
57 type Resolved,
58 type Selector,
59 type SimRequest,
60} from './magpie'
61import { encodePng } from './png'
62import {
63 defaultMagpieDir,
64 englishNameOf,
65 executableIn,
66 firstLexicon,
67 homeOf,
68 hostOf,
69 installMagpie,
70 isPublished,
71 LANGUAGES,
72 languageOf,
73 MAGPIE_VERSION,
74 windowsPath,
75 type Host,
76 type SetupProgress,
77} from './setup'
78import { describePlay, latestTable, parseBoard, parseSim, playName, playTiles, resultText, type Play, type Sim } from './sim'
79import { boardSvg } from './svg'
80import { SELECTED_ROW } from './theme'
81import { packTiles, unpackTiles } from './tiles'
82import { parseFont, type Font } from './truetype'
83
84const PLUGIN = 'magpie-sim'
85const PANE = 'magpie-results'
86// The magpieDir option's default: the folder the mod installs MAGPIE in
87// (setup.ts, defaultMagpieDir).
88const DEFAULT_MAGPIE_DIR = ''
89// The setup pane, which installs MAGPIE and picks the language and lexicon.
90const SETUP_PANE = 'magpie-setup'
91const SETUP_COLUMNS = 100
92const SETUP_ROWS = 24
93// How long after the session starts the setup pane opens, MAGPIE missing.
94const SETUP_DELAY_MS = 1000
95// The store's key for the person's "not now", which keeps the pane from
96// opening by itself again.
97const SETUP_DECLINED = 'setupDeclined'
98// The model that reads what the person typed after /magpie: quick, and
99// a short JSON reply is all it writes.
100const INTERPRETER_MODEL = 'haiku'
101const INTERPRETER_MAX_TOKENS = 400
102const INTERPRETER_TIMEOUT_MS = 30_000
103// The pane's top plays the interpreter is told of.
104const TOP_PLAYS_TOLD = 8
105// The plays the pane lists, the sim's top ones: as many as stand beside the
106// board, its BOARD_ROWS less the table's chrome and the running status.
107const PLAYS_LISTED = 20
108// The most bytes of pictures one tree may hold (Claude Code refuses more
109// than 2 MiB), less a margin.
110const IMAGE_BUDGET = 2_000_000
111// The /magpie lines a question keeps, the one that asked it last.
112const ASKED_KEPT = 3
113// Ten minutes, the most $.process.run allows.
114const RUN_TIMEOUT_MS = 600_000
115// The TUI's font (tui/assets/magpie.ttf), under its own licence beside it.
116const FONT_FILE = 'assets/FiraCodeNerdFontMono-Regular.ttf'
117// The sim the pane shows before any has run.
118const DEFAULT_SIM_FILE = 'sims/ADOPQRR-CSW24.txt'
119// Plays the simulate tool lists for the model; the pane shows them all.
120const PLAYS_REPORTED = 10
121// How long after a command its picture's prompt goes: past the command's own
122// turn.
123const PROMPT_DELAY_MS = 100
124
125// The terminal picture's box in cells. Cells are about twice as tall as they
126// are wide, so 44 x 24 keeps the 596 x 638 picture's tiles near square.
127const BOARD_COLUMNS = 44
128const BOARD_ROWS = 24
129// Rows the table draws above its plays: the summary and the headings.
130const TABLE_CHROME_ROWS = 2
131// About the full table's width (tableLayout measures it per sim), for the
132// pane's opening size: room for the board beside it.
133const TYPICAL_TABLE_COLUMNS = 80
134const PANE_COLUMNS = BOARD_COLUMNS + 2 + TYPICAL_TABLE_COLUMNS
135const PANE_ROWS = 30
136
137const selectedId = atom({ plugin: 'magpie-sim', key: 'selectedId' } as const, '')
138const sortKey = atom({ plugin: 'magpie-sim', key: 'sortKey' } as const, 'win' as SortKey)
139const isDescending = atom({ plugin: 'magpie-sim', key: 'isDescending' } as const, true)
140const windowTop = atom({ plugin: 'magpie-sim', key: 'windowTop' } as const, 0)
141const simFile = atom({ plugin: 'magpie-sim', key: 'simFile' } as const, '')
142const simText = atom({ plugin: 'magpie-sim', key: 'simText' } as const, '')
143const running = atom({ plugin: 'magpie-sim', key: 'running' } as const, '')
144const runError = atom({ plugin: 'magpie-sim', key: 'runError' } as const, '')
145const current = atom({ plugin: 'magpie-sim', key: 'current' } as const, '')
146const setup = atom({ plugin: 'magpie-sim', key: 'setup' } as const, '')
147const setupLanguage = atom({ plugin: 'magpie-sim', key: 'setupLanguage' } as const, '')
148const setupLexicon = atom({ plugin: 'magpie-sim', key: 'setupLexicon' } as const, '')
149
150// How many plays the table showed when last drawn, and how many it has, so
151// the wheel scrolls no further than its end. The drawing clamps it again.
152let lastRoom = 10
153let lastCount = 0
154let lastPictureMs = 0
155// Whether a terminal has drawn the pane, so a move seen only on the other
156// surfaces skips the RGBA picture they would not show.
157let isTerminalDrawing = false
158
159// What the mod did, newest last, for `/magpie why`.
160const log: string[] = []
161const LOG_KEPT = 40
162
163function note(line: string) {
164 log.push(`${new Date().toTimeString().slice(0, 8)} ${line}`)
165 if (log.length > LOG_KEPT) {
166 log.shift()
167 }
168}
169
170let fontLoad: Promise<Font> | null = null
171
172// Reads and parses the font once per load of the module; a failed read is
173// tried again at the next draw.
174export function loadFont($: EngineInterface): Promise<Font> {
175 fontLoad ??= $.fs
176 .read(`${$.plugin.root}/${FONT_FILE}`, { as: 'bytes' })
177 .then(({ base64 }) => parseFont(Uint8Array.fromBase64(base64)))
178 .catch((error: unknown) => {
179 fontLoad = null
180 throw error
181 })
182 return fontLoad
183}
184
185// Pictures by play id, for the sim loaded now: compressed, many fit, the
186// listed plays' and those a sort or scroll reaches.
187const pictures = new Map<string, string>()
188const PICTURES_KEPT = 64
189// Between two pictures drawn ahead, so the pane's events run between them.
190const DRAW_AHEAD_MS = 15
191
192// The last sim parsed, by what it was parsed from: a run's output, or a file.
193let simLoad: { source: string; sim: Promise<Sim> } | null = null
194
195async function simPath($: EngineInterface): Promise<string> {
196 const file = await read($, simFile)
197 return file === '' ? `${$.plugin.root}/${DEFAULT_SIM_FILE}` : file
198}
199
200// The position the pictures kept are of: they hold while a sim streams in
201// and only its numbers change, and go when the board or rack does.
202let picturesOf = ''
203
204// The sim the pane shows: the last run's, else the file the show tool named,
205// else the one the mod ships. Parsed once each; a failed read is tried again
206// at the next draw.
207export async function loadSim($: EngineInterface): Promise<Sim> {
208 const text = await read($, simText)
209 const source = text !== '' ? text : await simPath($)
210 if (simLoad?.source !== source) {
211 const parsed = text !== '' ? Promise.resolve().then(() => parseSim(text)) : $.fs.read(source).then(parseSim)
212 const sim = parsed.then(async loaded => {
213 // Its tiles drawn with their own set's scores.
214 setTileScores(await scoresFor($, loaded.lexicon).catch(() => TILE_SCORES))
215 const position = `${loaded.lexicon}|${loaded.rack}|${(loaded.board ?? []).join('/')}`
216 if (position !== picturesOf) {
217 pictures.clear()
218 columnPictures.clear()
219 drawingAhead = null
220 picturesOf = position
221 }
222 return loaded
223 })
224 simLoad = { source, sim }
225 sim.catch(() => {
226 if (simLoad?.sim === sim) {
227 simLoad = null
228 }
229 })
230 }
231 return simLoad.sim
232}
233
234let threadCount: Promise<number> | null = null
235
236// All the cores but two, for the sim's threads: Windows names its count,
237// getconf counts them elsewhere.
238function threads($: EngineInterface): Promise<number> {
239 threadCount ??= (async () => {
240 const named = await $.env.get('NUMBER_OF_PROCESSORS')
241 const cores = named ?? (await $.process.run(['getconf', '_NPROCESSORS_ONLN'])).stdout
242 return Math.max(1, (Number.parseInt(cores, 10) || 6) - 2)
243 })().catch(() => 4)
244 return threadCount
245}
246
247let home: Promise<string> | null = null
248
249async function expandHome($: EngineInterface, path: string): Promise<string> {
250 if (path !== '~' && !/^~[\\/]/.test(path)) {
251 return path
252 }
253 home ??= homeOf($)
254 return (await home) + path.slice(1)
255}
256
257let host: Promise<Host> | null = null
258
259// The computer the session runs on, read once.
260function hostNow($: EngineInterface): Promise<Host> {
261 host ??= hostOf($)
262 return host
263}
264
265// An absolute path, `~` expanded and a relative one taken from the session's
266// directory; rejects when nothing is there.
267async function absolutePath($: EngineInterface, path: string): Promise<string> {
268 const expanded = await expandHome($, path)
269 const { realPath } = await $.fs.stat(expanded, { resolve: true })
270 return realPath ?? expanded
271}
272
273// The lexicon a GCG names, when its text is at hand.
274function gcgLexicon(text: string): string | undefined {
275 return /^#lexicon\s+(\S+)/m.exec(text)?.[1]?.toUpperCase()
276}
277
278// The MAGPIE install that runs the sims, holding bin/magpie and data/: the
279// magpieDir option, read as the module loads. Empty is the folder the mod
280// installs MAGPIE in (setup.ts, defaultMagpieDir).
281let magpieDir = ''
282
283type Magpie = { binary: string; cwd?: string }
284
285async function findMagpie($: EngineInterface): Promise<Magpie> {
286 const machine = await hostNow($)
287 const dir = magpieDir === '' ? await defaultMagpieDir($, machine) : await expandHome($, magpieDir)
288 return { binary: executableIn(dir, machine), cwd: dir }
289}
290
291// Whichever of the RIT and WIT the lexicon has built. A lexicon or data
292// directory not known before the run (a GCG MAGPIE fetches, the magpie on
293// PATH) tries both, and the run goes again without them if MAGPIE finds them
294// missing.
295async function accelerators($: EngineInterface, magpie: Magpie, lexicon: string | undefined): Promise<Accelerators> {
296 if (lexicon === undefined || magpie.cwd === undefined) {
297 return { rit: true, wit: true }
298 }
299 const magpieDir = magpie.cwd
300 const [rit, wit] = await Promise.all(
301 ['rit', 'wit'].map(kind => $.fs.exists(`${magpieDir}/data/lexica/${lexicon}.${kind}`)),
302 )
303 return { rit: rit === true, wit: wit === true }
304}
305
306// The request with its position in MAGPIE's terms (Resolved), the moves
307// played after it and its rack carried over.
308async function resolveRequest($: EngineInterface, request: SimRequest): Promise<{ resolved: Resolved; lexicon?: string }> {
309 const found = await resolvePosition($, request)
310 const { after, rack } = request.position
311 return {
312 ...found,
313 resolved: {
314 ...found.resolved,
315 ...(after === undefined || after.length === 0 ? {} : { after }),
316 ...(rack === undefined || rack === '' || found.resolved.kind === 'rack' ? {} : { rack }),
317 },
318 }
319}
320
321// The position itself: a URL or file read and told apart, a local path made
322// absolute for MAGPIE's own directory.
323async function resolvePosition($: EngineInterface, request: SimRequest): Promise<{ resolved: Resolved; lexicon?: string }> {
324 const position = request.position
325 let lexicon = request.lexicon
326 if (position.kind === 'rack' || position.kind === 'cgp') {
327 return { resolved: position, lexicon }
328 }
329 if (position.kind === 'gcg') {
330 if (/^https?:\/\//i.test(position.source) || !/[/.~]/.test(position.source)) {
331 return { resolved: position, lexicon }
332 }
333 const path = await absolutePath($, position.source)
334 lexicon ??= gcgLexicon(await $.fs.read(path))
335 return { resolved: { kind: 'gcg' as const, source: path }, lexicon }
336 }
337 if (position.kind === 'url') {
338 const fetched = await $.http.fetch(position.url).catch((error: unknown) => {
339 throw new Error(`could not fetch ${position.url}: ${error instanceof Error ? error.message : String(error)}`)
340 })
341 if (!fetched.ok) {
342 throw new Error(`could not fetch ${position.url}: HTTP ${fetched.status}`)
343 }
344 return sniffPosition(fetched.text) === 'cgp'
345 ? { resolved: { kind: 'cgp' as const, cgp: fetched.text.trim() }, lexicon }
346 : { resolved: { kind: 'gcg' as const, source: position.url }, lexicon: lexicon ?? gcgLexicon(fetched.text) }
347 }
348 const path = await absolutePath($, position.path)
349 const text = await $.fs.read(path)
350 return sniffPosition(text) === 'cgp'
351 ? { resolved: { kind: 'cgp' as const, cgp: text.trim().split('\n')[0] ?? '' }, lexicon }
352 : { resolved: { kind: 'gcg' as const, source: path }, lexicon: lexicon ?? gcgLexicon(text) }
353}
354
355// What MAGPIE said went wrong, when the run printed no sim.
356function magpieErrors(stdout: string, stderr: string, otherwise: string): string {
357 const lines = `${stderr}\n${stdout}`
358 .split('\n')
359 .map(line => line.trim())
360 .filter(line => /error|fatal|not found|invalid|cannot/i.test(line))
361 return lines.slice(0, 4).join('; ') || stderr.trim().slice(0, 300) || otherwise
362}
363
364
365/** A sim ready to run: the position in MAGPIE's terms, and for a GCG the
366 * `goto` that reaches it. */
367type Plan = {
368 request: SimRequest
369 resolved: Resolved
370 lexicon?: string
371 goto?: string
372 label: string
373 where: Magpie
374 threadCount: number
375 /** For a game: the turn chosen, and how many it has. */
376 turn?: number
377 turns?: number
378 /** Simmed, or its endgame solved (the bag empty). */
379 mode: Mode
380}
381
382/** What the pane says while a plan runs. */
383function planStatus(plan: Plan): string {
384 return plan.mode === 'endgame'
385 ? `Solving the endgame of ${plan.label} in ${lexiconText(plan)}`
386 : `Simulating ${plan.label} in ${lexiconText(plan)}: ${SIM_SHAPE}`
387}
388
389/** Which lexicon a plan runs in, as the person is told it. */
390function lexiconText(plan: Plan): string {
391 return runLexicon(plan.resolved, plan.lexicon) ?? "the game's lexicon"
392}
393
394/** What the pane shows, for `/magpie next turn` and `/magpie csw`: the
395 * request that made it (a game's at its turn), its lexicon, and the game's
396 * turn and turns. Kept as JSON in the `current` atom. */
397type Current = { request: SimRequest; label: string; lexicon: string; turn?: number; turns?: number }
398
399async function readCurrent($: EngineInterface): Promise<Current | undefined> {
400 const json = await read($, current)
401 return json === '' ? undefined : (JSON.parse(json) as Current)
402}
403
404const couldNotRun = (where: Magpie, error: unknown) =>
405 new Error(
406 `could not run ${where.binary} (${error instanceof Error ? error.message : String(error)}); install MAGPIE there, or set the mod's MAGPIE directory with \`claude plugin configure magpie-sim@olaugh-mods\``,
407 )
408
409// What MAGPIE printed as the mod reads it: a Windows one's CRLFs as LFs,
410// bracketed tiles packed (tiles.ts).
411const fromMagpie = (text: string) => packTiles(text.replaceAll('\r\n', '\n'))
412
413async function execute($: EngineInterface, where: Magpie, label: string, run: MagpieRun) {
414 note(`running ${label}: ${where.binary} ${run.args.join(' ')} <<< ${run.stdin.trim().split('\n').slice(0, 3).join('; ')}`)
415 let result
416 try {
417 const ran = await $.process.run([where.binary, ...run.args], { cwd: where.cwd, stdin: unpackTiles(run.stdin), timeoutMs: RUN_TIMEOUT_MS })
418 result = { ...ran, stdout: fromMagpie(ran.stdout), stderr: fromMagpie(ran.stderr) }
419 } catch (error) {
420 throw couldNotRun(where, error)
421 }
422 return { result, said: result.stdout + result.stderr }
423}
424
425/** A sim run as `execute` runs one, but streamed: each results table MAGPIE
426 * prints as it goes is handed to `onTable` when it ends, so the pane fills
427 * in while the sim runs. A run past RUN_TIMEOUT_MS is ended. */
428async function executeStreaming(
429 $: EngineInterface,
430 where: Magpie,
431 label: string,
432 run: MagpieRun,
433 onTable: (table: string) => Promise<void>,
434) {
435 note(`streaming ${label}: ${where.binary} ${run.args.join(' ')} <<< ${run.stdin.trim().split('\n').slice(0, 3).join('; ')}`)
436 const started = Date.now()
437 let stdout = ''
438 let stderr = ''
439 let handed = 0
440 try {
441 // Leaving the loop ends the child.
442 for await (const { stream, text } of $.process.spawn({ argv: [where.binary, ...run.args], cwd: where.cwd, input: unpackTiles(run.stdin) })) {
443 if (stream === 'stderr') {
444 stderr = fromMagpie(stderr + text)
445 } else {
446 // Whole: a CRLF may come in two pieces.
447 stdout = fromMagpie(stdout + text)
448 const table = latestTable(stdout)
449 if (table !== undefined && table.count > handed) {
450 handed = table.count
451 await onTable(table.text)
452 }
453 }
454 if (Date.now() - started > RUN_TIMEOUT_MS) {
455 stderr += `\nended after ${RUN_TIMEOUT_MS / 1000} s`
456 break
457 }
458 }
459 } catch (error) {
460 throw couldNotRun(where, error)
461 }
462 return { result: { stdout, stderr }, said: stdout + stderr }
463}
464
465/** Works out what to sim, before anything runs: the position read or
466 * fetched, and for a GCG which of its positions, or the ones that fit when
467 * the person's turn, rack or word fits more than one. */
468async function planSim($: EngineInterface, request: SimRequest): Promise<{ plan: Plan } | { candidates: GcgPosition[] }> {
469 const already = await read($, running)
470 if (already !== '') {
471 throw new Error(`still running: ${already}`)
472 }
473 const { resolved, lexicon: knownLexicon } = await resolveRequest($, request)
474 const where = await findMagpie($)
475 let lexicon = knownLexicon
476 let goto: string | undefined
477 let label = request.label
478 let turn: number | undefined
479 let turns: number | undefined
480 let mode: Mode = 'sim'
481 if (resolved.kind === 'cgp') {
482 // Unseen tiles counted from the lexicon's own set (102 tiles in French).
483 const set = await tileSet($, where, runLexicon(resolved, lexicon))
484 const setSize = set === undefined ? undefined : [...set.values()].reduce((total, count) => total + count, 0)
485 mode = modeFor(cgpUnseen(resolved.cgp, setSize))
486 }
487 if (resolved.kind === 'gcg') {
488 // Every position of the game, to find the one asked for. The scan also
489 // learns whether the GCG names its lexicon (one MAGPIE fetched is
490 // unread here).
491 let scan = await execute($, where, label, scanRun(resolved.source, lexicon))
492 if (lexicon === undefined && /without a specified lexicon/.test(scan.said)) {
493 lexicon = preferredLexicon()
494 scan = await execute($, where, label, scanRun(resolved.source, lexicon))
495 }
496 const positions = gcgPositions(scan.result.stdout)
497 const choice = choosePosition(positions, request.at)
498 if ('error' in choice) {
499 throw new Error(
500 positions.length === 0 ? magpieErrors(scan.result.stdout, scan.result.stderr, choice.error) : choice.error,
501 )
502 }
503 if ('candidates' in choice) {
504 return { candidates: choice.candidates }
505 }
506 turns = positions.length
507 if ('goto' in choice) {
508 goto = choice.goto
509 label = `${label} event ${goto}`
510 } else {
511 goto = String(choice.position.events)
512 turn = choice.position.turn
513 mode = modeFor(choice.position.unseen)
514 label = `${label} turn ${turn}`
515 }
516 }
517 const plan: Plan = { request, resolved, goto, label, where, threadCount: await threads($), mode }
518 if (lexicon !== undefined) {
519 plan.lexicon = lexicon
520 }
521 if (turns !== undefined) {
522 plan.turns = turns
523 }
524 if (turn !== undefined) {
525 plan.turn = turn
526 }
527 return { plan }
528}
529
530/** What the person's turn, rack or word was, for the question. */
531function selectorText(at: Selector | undefined): string {
532 switch (at?.kind) {
533 case 'rack':
534 return `Rack ${at.rack}`
535 case 'word':
536 return `The word ${at.word}`
537 case 'letters':
538 return at.letters
539 default:
540 return 'That'
541 }
542}
543
544function candidateLines(candidates: readonly GcgPosition[]): string[] {
545 return candidates.map(candidate => ` ${describePosition(candidate)}`)
546}
547
548/** Runs a planned sim and makes it the one the pane shows. One at a time. */
549async function runPlan($: EngineInterface, plan: Plan): Promise<Sim> {
550 const already = await read($, running)
551 if (already !== '') {
552 throw new Error(`still running: ${already}`)
553 }
554 await update($, running, () => planStatus(plan))
555 await update($, runError, () => '')
556 try {
557 const started = Date.now()
558 const headerOf = (run: MagpieRun) =>
559 [run.lexicon === '' ? '' : `# lexicon ${run.lexicon}`, `# label ${plan.label}`].filter(line => line !== '').join('\n')
560 // A sim's tables as they come: the pane shows each, from the first on
561 // with its own selection and scroll.
562 let tables = 0
563 const showTable = async (run: MagpieRun, table: string) => {
564 await update($, simText, () => `${headerOf(run)}\n${table}`)
565 if (++tables === 1) {
566 await update($, selectedId, () => '')
567 await update($, windowTop, () => 0)
568 }
569 }
570 const magpie = async (accelerated: Accelerators) => {
571 const run = magpieRun(plan.resolved, { lexicon: plan.lexicon, goto: plan.goto }, plan.threadCount, accelerated, plan.mode)
572 const ran =
573 plan.mode === 'sim'
574 ? await executeStreaming($, plan.where, plan.label, run, table => showTable(run, table))
575 : await execute($, plan.where, plan.label, run)
576 return { run, result: ran.result, said: ran.said }
577 }
578 // The endgame solver has no use for them.
579 const tried = plan.mode === 'endgame' ? { rit: false, wit: false } : await accelerators($, plan.where, plan.lexicon)
580 let { run, result, said } = await magpie(tried)
581 if ((tried.rit || tried.wit) && /not found for data type (rack|word) info table/.test(said)) {
582 ;({ run, result } = await magpie({ rit: false, wit: false }))
583 }
584 // A streamed sim is kept as its last table, the one it finished on.
585 const text = `${headerOf(run)}\n${latestTable(result.stdout)?.text ?? result.stdout}`
586 let sim: Sim
587 try {
588 sim = parseSim(text)
589 } catch {
590 throw new Error(magpieErrors(result.stdout, result.stderr, 'MAGPIE printed no sim results'))
591 }
592 note(`${plan.label}: ${sim.plays.length} plays in ${((Date.now() - started) / 1000).toFixed(1)} s`)
593 await update($, simText, () => text)
594 // A game is kept at its turn, so moving from it and changing its
595 // lexicon start from there.
596 const shown: Current = {
597 request: plan.turn === undefined ? plan.request : { ...plan.request, at: { kind: 'turn', turn: plan.turn } },
598 label: plan.label,
599 lexicon: run.lexicon !== '' ? run.lexicon : (plan.lexicon ?? ''),
600 }
601 if (plan.turn !== undefined && plan.turns !== undefined) {
602 shown.turn = plan.turn
603 shown.turns = plan.turns
604 }
605 await update($, current, () => JSON.stringify(shown))
606 // A row picked while the sim streamed in stays picked.
607 if (tables === 0) {
608 await update($, selectedId, () => '')
609 await update($, windowTop, () => 0)
610 }
611 return sim
612 } catch (error) {
613 const message = error instanceof Error ? error.message : String(error)
614 note(`${plan.label} failed: ${message}`)
615 await update($, runError, () => `${plan.label}: ${message}`)
616 throw error
617 } finally {
618 await update($, running, () => '')
619 }
620}
621
622type Column = {
623 title: string
624 sort?: SortKey
625 isLeft?: true
626 isCompact?: true
627 cell: (play: Play) => string
628}
629
630const signed = (value: number) => (value > 0 ? '+' : '') + value.toFixed(2)
631
632const COLUMNS: readonly Column[] = [
633 // Cells as people read them: a Catalan tile in brackets (tiles.ts).
634 { title: 'Play', sort: 'play', isLeft: true, isCompact: true, cell: play => unpackTiles(playName(play)) },
635 { title: 'Leave', isLeft: true, cell: play => unpackTiles(play.leave) },
636 { title: 'Sc', sort: 'score', isCompact: true, cell: play => String(play.score) },
637 { title: 'Win%', sort: 'win', isCompact: true, cell: play => play.winPct.toFixed(2) },
638 { title: '±', cell: play => play.winSe.toFixed(2) },
639 { title: 'Eq', sort: 'equity', isCompact: true, cell: play => signed(play.equity) },
640 { title: 'StEq', sort: 'static', cell: play => signed(play.staticEquity) },
641 { title: 'Iters', sort: 'iterations', cell: play => String(play.iterations) },
642 // The opponent's reply: its mean score and how often it bingoes.
643 { title: 'OpSc', cell: play => play.plies[0]?.score.toFixed(1) ?? '' },
644 { title: 'OpBn%', cell: play => play.plies[0]?.bingoPct.toFixed(1) ?? '' },
645]
646
647// An endgame's table: the final spread, what the line nets, and the rest of
648// the line.
649const ENDGAME_COLUMNS: readonly Column[] = [
650 { title: 'Play', sort: 'play', isLeft: true, isCompact: true, cell: play => unpackTiles(playName(play)) },
651 { title: 'Sc', sort: 'score', isCompact: true, cell: play => String(play.score) },
652 { title: 'Result', sort: 'result', isCompact: true, cell: play => resultText(play.result) },
653 { title: 'Net', sort: 'net', isCompact: true, cell: play => (play.net === undefined ? '' : `${play.net > 0 ? '+' : ''}${play.net}`) },
654 { title: 'Then', isLeft: true, cell: play => unpackTiles(play.line ?? '') },
655]
656// The selection marker and the sim's rank, before the columns.
657const RANK_WIDTH = 5
658
659// A tile in a row of the table: two terminal columns, one row.
660const TILE_COLUMNS = 2
661
662/** The table's columns, each as wide as its widest cell, or its title with
663 * room for a sort arrow, and the width of a whole row. With `tiles`, the
664 * Play column (the first) spells each play as its square and a picture of
665 * its tiles: the widest square label, and room for the most tiles. */
666export type TableLayout = {
667 columns: readonly { column: Column; width: number }[]
668 width: number
669 tiles?: { labelWidth: number; slots: number }
670}
671
672export function tableLayout(
673 plays: readonly Play[],
674 isCompact: boolean,
675 isTiled = false,
676 kind: Sim['kind'] = 'sim',
677): TableLayout {
678 const kindColumns = kind === 'endgame' ? ENDGAME_COLUMNS : COLUMNS
679 const spelled = plays.map(playTiles)
680 const tiles = isTiled
681 ? {
682 labelWidth: Math.max(0, ...spelled.map(play => play.label.length)),
683 slots: Math.max(1, ...spelled.map(play => play.tiles.length)),
684 }
685 : undefined
686 const columns = (isCompact ? kindColumns.filter(column => column.isCompact) : kindColumns).map(column => ({
687 column,
688 width: Math.max(
689 column.title.length + (column.sort === undefined ? 0 : 1),
690 ...(tiles !== undefined && column.sort === 'play'
691 ? [tiles.labelWidth + 1 + TILE_COLUMNS * tiles.slots]
692 : plays.map(play => column.cell(play).length)),
693 ),
694 }))
695 const width = columns.reduce((total, { width: columnWidth }) => total + 1 + columnWidth, RANK_WIDTH)
696 return tiles === undefined ? { columns, width } : { columns, width, tiles }
697}
698
699function sortValue(play: Play, key: SortKey): number {
700 switch (key) {
701 case 'win':
702 return play.winPct
703 case 'equity':
704 return play.equity
705 case 'score':
706 return play.score
707 case 'static':
708 return play.staticEquity
709 case 'iterations':
710 return play.iterations
711 case 'result':
712 return play.result ?? 0
713 case 'net':
714 return play.net ?? 0
715 case 'play':
716 return 0
717 }
718}
719
720export function sortPlays(plays: readonly Play[], key: SortKey, descending: boolean): Play[] {
721 return [...plays].sort((a, b) => {
722 const order = key === 'play' ? a.tiles.localeCompare(b.tiles) : sortValue(a, key) - sortValue(b, key)
723 return (descending ? -order : order) || a.rank - b.rank
724 })
725}
726
727// The first play the table shows: the stored one, moved just far enough to
728// keep the selected play in view.
729export function windowStart(top: number, index: number, room: number, count: number): number {
730 const start = Math.min(Math.max(top, index - room + 1), index)
731 return Math.min(Math.max(0, start), Math.max(0, count - room))
732}
733
734function pad(text: string, column: Column, width: number): string {
735 return column.isLeft ? text.padEnd(width) : text.padStart(width)
736}
737
738export function headerLine(layout: TableLayout, key: SortKey, descending: boolean): string {
739 const titles = layout.columns.map(({ column, width }) =>
740 pad(column.title + (column.sort === key ? (descending ? '▼' : '▲') : ''), column, width),
741 )
742 return ['#'.padStart(RANK_WIDTH), ...titles].join(' ')
743}
744
745// The rank is the sim's own, by win%, whatever the table's order.
746function rankCell(play: Play, isSelected: boolean): string {
747 return (isSelected ? '▶' : ' ') + String(play.rank).padStart(RANK_WIDTH - 1)
748}
749
750export function rowLine(layout: TableLayout, play: Play, isSelected: boolean): string {
751 const cells = layout.columns.map(({ column, width }) => pad(column.cell(play), column, width))
752 return [rankCell(play, isSelected), ...cells].join(' ')
753}
754
755/** A tiled row's text either side of its tiles' picture: the rank and the
756 * play's square, then the rest of the columns, so the row is as wide as
757 * the header. */
758export function rowParts(layout: TableLayout, play: Play, isSelected: boolean): { before: string; after: string } {
759 const [playColumn, ...rest] = layout.columns
760 if (layout.tiles === undefined || playColumn === undefined) {
761 return { before: rowLine(layout, play, isSelected), after: '' }
762 }
763 const spelled = layout.tiles.labelWidth + 1 + TILE_COLUMNS * layout.tiles.slots
764 return {
765 before: `${rankCell(play, isSelected)} ${playTiles(play).label.padEnd(layout.tiles.labelWidth)} `,
766 after:
767 ' '.repeat(playColumn.width - spelled) +
768 rest.map(({ column, width }) => ` ${pad(column.cell(play), column, width)}`).join(''),
769 }
770}
771
772export function summaryLine(sim: Sim): string {
773 const parts = [
774 sim.label !== '' && sim.label !== sim.rack ? `${sim.label}, rack ${sim.rack}` : sim.rack,
775 sim.lexicon,
776 ...(sim.kind === 'endgame'
777 ? [`endgame solved ${sim.plies} plies deep in ${sim.seconds.toFixed(1)} s`]
778 : [
779 `${sim.plies} ${sim.plies === 1 ? 'ply' : 'plies'}`,
780 `${sim.iterations.toLocaleString('en-US')} iters in ${sim.seconds.toFixed(1)} s`,
781 ]),
782 ]
783 return unpackTiles(parts.filter(part => part !== '').join(' · '))
784}
785
786/** The top plays as the simulate tool reports them to the model. */
787export function reportLines(sim: Sim, count: number): string[] {
788 return reportedLines(sim, count).map(unpackTiles)
789}
790
791function reportedLines(sim: Sim, count: number): string[] {
792 if (sim.kind === 'endgame') {
793 return sim.plays
794 .slice(0, count)
795 .map(
796 play =>
797 `${play.rank}. ${playName(play)}: ${play.score} pts, ${resultText(play.result)}, net ${play.net ?? 0}${play.line === '' || play.line === undefined ? '' : `; then ${play.line}`}`,
798 )
799 }
800 return sim.plays
801 .slice(0, count)
802 .map(
803 play =>
804 `${play.rank}. ${playName(play)}${play.leave === '' ? '' : ` (keeps ${play.leave})`}: ${play.score} pts, win ${play.winPct.toFixed(2)}% ±${play.winSe.toFixed(2)}, equity ${signed(play.equity)}`,
805 )
806}
807
808// A picture as a compressed PNG's base64, a small part of its pixels' size:
809// what crosses to the terminal on each click, and what the tree's budget
810// counts. Alpha where its clear pixels must stay clear.
811function pngSource(picture: Picture, hasAlpha = false): string {
812 return encodePng(picture.width, picture.height, picture.pixels, hasAlpha).toBase64()
813}
814
815// The bytes a picture's base64 decodes to, as the budget counts them.
816const sourceBytes = (base64: string) => Math.floor((base64.length * 3) / 4)
817
818// The selected play's picture as a PNG, the last few dozen kept: each is
819// some 20 KB. The time a click waited on one is shown; one drawn ahead
820// (`isAhead`) kept no one waiting.
821function pictureSource(font: Font, sim: Sim, play: Play, isAhead = false) {
822 let png = pictures.get(play.id)
823 if (png === undefined) {
824 const started = Date.now()
825 png = pngSource(boardPicture(font, sim.board, sim.rack, play))
826 if (!isAhead) {
827 lastPictureMs = Date.now() - started
828 }
829 if (pictures.size >= PICTURES_KEPT) {
830 const oldest = pictures.keys().next().value
831 if (oldest !== undefined) {
832 pictures.delete(oldest)
833 }
834 }
835 } else {
836 pictures.delete(play.id)
837 }
838 pictures.set(play.id, png)
839 return { png }
840}
841
842// The sim whose listed plays' pictures are being drawn ahead.
843let drawingAhead: Sim | null = null
844
845/** Draws the listed plays' pictures ahead of any click, one each timer
846 * tick so the pane's own events run between them: a click then finds its
847 * picture made. Once a sim; a new one stops the last. */
848function drawAhead($: EngineInterface, font: Font, sim: Sim) {
849 if (drawingAhead === sim) {
850 return
851 }
852 drawingAhead = sim
853 const waiting = listedPlays(sim).filter(play => !pictures.has(play.id))
854 const count = waiting.length
855 const next = () => {
856 const play = waiting.shift()
857 if (drawingAhead !== sim) {
858 return
859 }
860 if (play === undefined) {
861 note(`drew ${count} pictures ahead`)
862 return
863 }
864 if (!pictures.has(play.id)) {
865 pictureSource(font, sim, play, true)
866 }
867 $.clock.after(DRAW_AHEAD_MS, next)
868 }
869 $.clock.after(DRAW_AHEAD_MS, next)
870}
871
872/** The plays the pane lists: the sim's top ones, as it ranks them. */
873function listedPlays(sim: Sim): Play[] {
874 return sim.plays.slice(0, PLAYS_LISTED)
875}
876
877async function sortedPlays($: EngineInterface, sim: Sim): Promise<Play[]> {
878 return sortPlays(listedPlays(sim), await read($, sortKey), await read($, isDescending))
879}
880
881// Where the selected play stands in the table: the first play when nothing
882// is selected yet, or the selection is not in this sim.
883function selectedIndex(sorted: readonly Play[], id: string): number {
884 return Math.max(0, sorted.findIndex(play => play.id === id))
885}
886
887/** A row's click: its play on the board. */
888async function selectPlay($: EngineInterface, id: string) {
889 if ((await read($, selectedId)) === id) {
890 return
891 }
892 // Swap the picture at once; the redraw the write below causes draws the
893 // same source, which sends nothing. Refused where no terminal picture is
894 // mounted (the other surfaces draw an Svg), and a failed swap must not
895 // stop the selection: that redraw draws the new picture anyway.
896 const sim = await loadSim($)
897 const play = sim.plays.find(candidate => candidate.id === id)
898 if (play !== undefined && isTerminalDrawing) {
899 try {
900 await $.ui.blit({ requestId: PANE, key: 'board', source: pictureSource(await loadFont($), sim, play) })
901 } catch {
902 // The redraw below shows it.
903 }
904 }
905 await update($, selectedId, () => id)
906}
907
908// The table's tile column as a PNG, for the sim loaded now, by the rows it
909// shows and which is selected: clear around the tiles, so the pane shows
910// through, the selected row on its colour, as its text is. The last few
911// kept, so going back to a row draws nothing.
912const columnPictures = new Map<string, string>()
913const COLUMN_PICTURES_KEPT = 16
914
915function tilesColumnSource(font: Font, plays: readonly Play[], selectedId: string, slots: number) {
916 const key = `${slots}|${selectedId}|${plays.map(play => play.id).join('|')}`
917 let png = columnPictures.get(key)
918 if (png === undefined) {
919 const rows = plays.map(play => ({
920 tiles: playTiles(play).tiles,
921 ...(play.id === selectedId && { background: SELECTED_ROW }),
922 }))
923 png = pngSource(tilesColumnPicture(font, rows, slots), true)
924 if (columnPictures.size >= COLUMN_PICTURES_KEPT) {
925 const oldest = columnPictures.keys().next().value
926 if (oldest !== undefined) {
927 columnPictures.delete(oldest)
928 }
929 }
930 columnPictures.set(key, png)
931 }
932 return { png }
933}
934
935// The selected row's colour as text takes it, and the text on it.
936const SELECTED_BACKGROUND = `#${SELECTED_ROW.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
937const SELECTED_TEXT = '#ffffff'
938
939
940async function sortBy($: EngineInterface, key: SortKey) {
941 if ((await read($, sortKey)) === key) {
942 await update($, isDescending, descending => !descending)
943 } else {
944 await update($, sortKey, () => key)
945 // Numbers sort high first, plays A to Z.
946 await update($, isDescending, () => key !== 'play')
947 }
948 const sorted = await sortedPlays($, await loadSim($))
949 const index = selectedIndex(sorted, await read($, selectedId))
950 await update($, windowTop, top => windowStart(top, index, lastRoom, sorted.length))
951}
952
953async function openPane($: EngineInterface, title: string, focus: boolean): Promise<string> {
954 const opened = await $.ui.open({
955 id: PANE,
956 title,
957 focus: focus ? true : undefined,
958 rows: PANE_ROWS,
959 columns: PANE_COLUMNS,
960 })
961 return opened.isPlaced ? 'Opened the MAGPIE sim pane' : `The pane waits undrawn: ${opened.reason}`
962}
963
964async function currentTitle($: EngineInterface): Promise<string> {
965 try {
966 const sim = await loadSim($)
967 return `MAGPIE · ${sim.label || sim.rack}`
968 } catch {
969 return 'MAGPIE'
970 }
971}
972
973// The language the person plays in, the mod's option; their lexicon is
974// magpie.ts's preferredLexicon.
975let language = 'English'
976
977const NOT_INSTALLED = 'MAGPIE, which runs the sims, is not installed yet'
978// A reply put in the person's language: long enough for any the mod writes.
979const TRANSLATION_MAX_TOKENS = 1000
980
981/** A reply of the mod's in the person's language: its messages are English,
982 * so the quick model writes them again in theirs, the plays, racks and
983 * squares as they are. As it is in English, or when the model does not
984 * answer. */
985async function inLanguage($: EngineInterface, text: string): Promise<string> {
986 const target = englishNameOf(language)
987 if (target === 'English' || text.trim() === '') {
988 return text
989 }
990 const reply = await $.model.complete({
991 model: INTERPRETER_MODEL,
992 system: `You translate the replies of /magpie, a command that analyzes crossword (Scrabble) positions, into ${target}. Keep exactly as written: plays and coordinates (8G VAV, H8 (I)NERTIAS, -EO), racks and words in capitals, tiles in brackets ([NY]), lexicon names, numbers, file paths, URLs, and commands (/magpie ...). Keep the line breaks. Reply with the translation and nothing else.`,
993 prompt: text,
994 maxTokens: TRANSLATION_MAX_TOKENS,
995 timeoutMs: INTERPRETER_TIMEOUT_MS,
996 })
997 if (!reply.isAnswered || reply.text.trim() === '') {
998 note(`could not translate into ${target}: ${reply.isAnswered ? 'empty' : reply.reason}`)
999 return text
1000 }
1001 return reply.text.trim()
1002}
1003
1004// Whether MAGPIE and its data are where the mod runs them.
1005async function isInstalled($: EngineInterface): Promise<boolean> {
1006 const where = await findMagpie($)
1007 return (await $.fs.exists(where.binary)) && (where.cwd === undefined || (await $.fs.exists(`${where.cwd}/data/lexica`)))
1008}
1009
1010async function openSetup($: EngineInterface, focus: boolean): Promise<string> {
1011 const opened = await $.ui.open({
1012 id: SETUP_PANE,
1013 title: 'MAGPIE setup',
1014 focus: focus ? true : undefined,
1015 rows: SETUP_ROWS,
1016 columns: SETUP_COLUMNS,
1017 })
1018 return opened.isPlaced ? 'Opened the MAGPIE setup pane' : `The setup pane waits undrawn: ${opened.reason}`
1019}
1020
1021// Opens the setup pane by itself at the session's start while MAGPIE is
1022// missing, unless the person said not now.
1023async function offerSetup($: EngineInterface) {
1024 if ((await isInstalled($)) || (await $.store.get(SETUP_DECLINED)) === true) {
1025 return
1026 }
1027 note(`MAGPIE is missing: ${await openSetup($, false)}`)
1028}
1029
1030// The language and lexicon the setup pane shows: its picks, else the options.
1031async function setupChoices($: EngineInterface): Promise<{ language: string; lexicon: string }> {
1032 return {
1033 language: (await read($, setupLanguage)) || language,
1034 lexicon: (await read($, setupLexicon)) || preferredLexicon(),
1035 }
1036}
1037
1038let setupRun: Promise<boolean> | null = null
1039
1040async function pickLanguage($: EngineInterface, name: string) {
1041 if (setupRun !== null) {
1042 return
1043 }
1044 const { lexicon } = await setupChoices($)
1045 await update($, setupLanguage, () => name)
1046 if (languageOf(lexicon) !== name) {
1047 await update($, setupLexicon, () => firstLexicon(name))
1048 }
1049}
1050
1051async function pickLexicon($: EngineInterface, name: string) {
1052 if (setupRun === null) {
1053 await update($, setupLexicon, () => name)
1054 }
1055}
1056
1057// Installs what is missing for the choices, then keeps them as the mod's
1058// options, as /config shows them.
1059async function startSetup($: EngineInterface) {
1060 if (setupRun !== null) {
1061 return
1062 }
1063 const choices = await setupChoices($)
1064 const where = await findMagpie($)
1065 const plan = { host: await hostNow($), dir: where.cwd ?? '', lexicon: choices.lexicon, threads: await threads($) }
1066 let progress: SetupProgress | undefined
1067 setupRun = installMagpie($, plan, async shown => {
1068 progress = shown
1069 await update($, setup, () => JSON.stringify(shown))
1070 })
1071 const isDone = await setupRun.finally(() => {
1072 setupRun = null
1073 })
1074 note(`setup ${isDone ? 'done' : `stopped: ${progress?.problem ?? ''}`}`)
1075 if (!isDone || progress === undefined) {
1076 return
1077 }
1078 lexicaList = null
1079 await $.store.delete(SETUP_DECLINED)
1080 const saved = await saveChoices($, choices)
1081 await update($, setup, () => JSON.stringify({ ...progress, saved }))
1082}
1083
1084// The choices as the mod's options, which a change reloads it with; meanwhile
1085// this session uses them. Says what could not be saved.
1086async function saveChoices($: EngineInterface, choices: { language: string; lexicon: string }): Promise<string> {
1087 const unsaved: string[] = []
1088 for (const [field, value, was] of [
1089 ['language', choices.language, language],
1090 ['lexicon', choices.lexicon, preferredLexicon()],
1091 ] as const) {
1092 if (value === was) {
1093 continue
1094 }
1095 const result = await $.config.set({ key: `${PLUGIN}.${field}`, value }).catch((error: unknown) => ({ deny: String(error) }))
1096 if ('deny' in result && result.deny !== undefined) {
1097 note(`could not save the ${field}: ${result.deny}`)
1098 unsaved.push(field)
1099 }
1100 }
1101 language = choices.language
1102 setPreferredLexicon(choices.lexicon)
1103 return unsaved.length === 0
1104 ? ''
1105 : `The ${unsaved.join(' and ')} hold for this session; to keep them, set them in /config (${PLUGIN}).`
1106}
1107
1108async function declineSetup($: EngineInterface) {
1109 await $.store.set(SETUP_DECLINED, true)
1110 await $.ui.close({ id: SETUP_PANE })
1111}
1112
1113// Apple's installer of its command-line tools, which building on a Mac needs.
1114async function getAppleTools($: EngineInterface) {
1115 const started = await $.process.run(['xcode-select', '--install']).catch(() => undefined)
1116 note(`xcode-select --install: ${started === undefined ? 'did not start' : `exit ${started.exitCode}`}`)
1117}
1118
1119const SIM_SHAPE = `${SIM_PLAYS} plays ×${ITERATIONS_PER_PLAY.toLocaleString('en-US')} iterations, ${SIM_PLIES} plies`
1120
1121// Where the latest prompt came from: `bridge` for the phone (or the web) over
1122// Remote Control, where no pane draws, so a sim answers with a picture.
1123let lastPromptOrigin = ''
1124
1125let pictureDirectory: Promise<string> | null = null
1126
1127// Pictures go in the temporary directory, `magpie-sim` in it.
1128function pictureFolder($: EngineInterface): Promise<string> {
1129 pictureDirectory ??= (async () => {
1130 const temporary = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
1131 return `${temporary.replace(/[\\/]$/, '')}/magpie-sim`
1132 })()
1133 return pictureDirectory
1134}
1135
1136function cardSubtitle(sim: Sim): string {
1137 return summaryLine({ ...sim, label: '' })
1138}
1139
1140/** The picture of what the pane shows (or of `sim`) as a PNG file, for a
1141 * phone, which shows a file and no pane: its path and a caption. */
1142async function makePicture($: EngineInterface, given?: Sim): Promise<{ file: string; caption: string }> {
1143 const sim = given ?? (await loadSim($))
1144 const id = await read($, selectedId)
1145 const shown = sim.plays.find(play => play.id === id) ?? sim.plays[0]
1146 if (shown === undefined) {
1147 throw new Error('there are no plays to show')
1148 }
1149 const picture = cardPicture(await loadFont($), sim, shown, sim.label || sim.rack, cardSubtitle(sim))
1150 const directory = await pictureFolder($)
1151 const name = `${(sim.label || sim.rack).replace(/[^\p{L}\p{N}]+/gu, '-').slice(0, 60)}-${Date.now()}`
1152 const raw = `${directory}/${name}.raw.png`
1153 const path = `${directory}/${name}.png`
1154 // The file is binary and $.fs writes text: its base64 decoded on the way,
1155 // on Windows by certutil from a file $.fs writes (folders and all).
1156 const base64 = encodePng(picture.width, picture.height, picture.pixels).toBase64()
1157 const machine = await hostNow($)
1158 let wrote
1159 if (machine.os === 'windows') {
1160 await $.fs.write(`${raw}.b64`, base64)
1161 wrote = await $.process.run(['certutil', '-f', '-decode', windowsPath(`${raw}.b64`), windowsPath(raw)])
1162 } else {
1163 wrote = await $.process.run(['sh', '-c', 'mkdir -p "$1" && base64 --decode > "$2"', 'magpie-sim', directory, raw], {
1164 stdin: base64,
1165 })
1166 }
1167 if (wrote.exitCode !== 0) {
1168 throw new Error(`could not write the picture: ${(wrote.stderr || wrote.stdout).trim()}`)
1169 }
1170 // On a Mac, sips writes it smaller still.
1171 const squeezed =
1172 machine.os === 'macos' ? await $.process.run(['sips', '-s', 'format', 'png', raw, '--out', path]).catch(() => undefined) : undefined
1173 const file = squeezed?.exitCode === 0 ? path : raw
1174 note(`drew ${file}`)
1175 return { file, caption: `${sim.label || sim.rack}: ${sim.kind === 'endgame' ? 'the endgame solved' : 'the top plays'}` }
1176}
1177
1178/** What has Claude send a picture: a file card reaches the phone only as a
1179 * SendUserFile call of the conversation's own, which a plugin's
1180 * $.tool.call is not. */
1181function sendInstruction({ file, caption }: { file: string; caption: string }): string {
1182 return `Send the person this picture with SendUserFile (display "render", caption "${caption}"), as their phone shows no pane: ${file}`
1183}
1184
1185/** The picture sent from outside a turn (a /magpie from the phone): a prompt
1186 * for Claude to send it, its own short turn once the session is idle. The
1187 * engine refuses a prompt from inside a command's hook, which holds the turn
1188 * it would wait on, so it goes from a timer of its own. */
1189async function sendPicture($: EngineInterface, given?: Sim): Promise<void> {
1190 const text = sendInstruction(await makePicture($, given))
1191 $.clock.after(PROMPT_DELAY_MS, () => {
1192 $.prompt.submit({ text }).catch((error: unknown) => note(`could not ask for the picture: ${String(error)}`))
1193 })
1194}
1195
1196let lexicaList: Promise<string[]> | null = null
1197
1198// The question the last /magpie asked, kept for the next one to answer
1199// briefly: "the second z", "14G". A question whose answer the pane already
1200// stands on (the rack still to come) is not kept.hooks/card.ts 152 lines1// A sim's results as one picture for a phone, where no pane draws: a title,
2// the board with a play on it, and the top plays in large rows, each spelled
3// in tiles. Drawn with the board's pixel canvas, sent as a PNG.
4
5import { drawBoard, drawTile, glyphInk, tileColors, type Canvas } from './board'
6import { pixelCanvas, type Picture } from './draw'
7import { playTiles, resultText, type Play, type Sim } from './sim'
8import { BACKGROUND, LABEL, PICTURE_HEIGHT, PICTURE_WIDTH, PREVIEW_BG, SQUARE, TILE_BG, type Rgb } from './theme'
9import type { Font } from './truetype'
10
11// Fira Code's advance: 1200 of its 1950 units to the em, every glyph.
12const ADVANCE_EM = 1200 / 1950
13const MARGIN = 24
14const TITLE_SIZE = 26
15const SUBTITLE_SIZE = 18
16const TEXT_SIZE = 22
17const ROW_HEIGHT = SQUARE + 6
18const TILE_STEP = SQUARE + 3
19const COLUMN_GAP = 16
20// The plays the picture lists; a phone shows no more legibly.
21export const CARD_ROWS = 10
22const MIN_WIDTH = 720
23const TEXT: Rgb = [220, 220, 220]
24// Behind the row whose play the board shows.
25const PICKED_ROW: Rgb = [28, 40, 32]
26
27type CardColumn = { title: string; cell: (play: Play) => string }
28
29const signed = (value: number, digits: number) => (value > 0 ? '+' : '') + value.toFixed(digits)
30
31// The numbers a phone needs: the table's main columns.
32const SIM_COLUMNS: readonly CardColumn[] = [
33 { title: 'Sc', cell: play => String(play.score) },
34 { title: 'Win%', cell: play => play.winPct.toFixed(1) },
35 { title: 'Eq', cell: play => signed(play.equity, 1) },
36]
37const ENDGAME_COLUMNS: readonly CardColumn[] = [
38 { title: 'Sc', cell: play => String(play.score) },
39 { title: 'Result', cell: play => resultText(play.result) },
40 { title: 'Net', cell: play => signed(play.net ?? 0, 0) },
41]
42
43// Each glyph placed by its advance, as a monospace font sets it.
44function drawText(canvas: Canvas, font: Font, text: string, x: number, baseline: number, size: number, color: Rgb, isBold = false) {
45 const advance = size * ADVANCE_EM
46 for (const [index, char] of [...text].entries()) {
47 const ink = glyphInk(font, char, size, isBold)
48 if (ink !== null) {
49 canvas.glyph(char, size, isBold, x + index * advance + Math.floor(ink.minX), baseline, color)
50 }
51 }
52}
53
54const textWidth = (text: string, size: number) => Math.ceil([...text].length * size * ADVANCE_EM)
55
56// Draws onto `canvas` moved right and down.
57function shifted(canvas: Canvas, dx: number, dy: number): Canvas {
58 return {
59 rect: (x, y, width, height, color) => canvas.rect(x + dx, y + dy, width, height, color),
60 diamond: (centerX, centerY, radius, color) => canvas.diamond(centerX + dx, centerY + dy, radius, color),
61 glyph: (char, size, isBold, inkLeft, baseline, color) => canvas.glyph(char, size, isBold, inkLeft + dx, baseline + dy, color),
62 }
63}
64
65/** The picture of `sim`, the board showing `shown`, under `title` and
66 * `subtitle`. */
67export function cardPicture(font: Font, sim: Sim, shown: Play, title: string, subtitle: string): Picture {
68 const plays = sim.plays.slice(0, CARD_ROWS)
69 const columns = sim.kind === 'endgame' ? ENDGAME_COLUMNS : SIM_COLUMNS
70 const spelled = plays.map(playTiles)
71 const rankWidth = textWidth(String(plays.length), TEXT_SIZE) + textWidth(' ', TEXT_SIZE)
72 const labelWidth = Math.max(...spelled.map(play => textWidth(play.label, TEXT_SIZE)), textWidth('Play', TEXT_SIZE))
73 const slots = Math.max(1, ...spelled.map(play => play.tiles.length))
74 const widths = columns.map(column =>
75 Math.max(textWidth(column.title, TEXT_SIZE), ...plays.map(play => textWidth(column.cell(play), TEXT_SIZE))),
76 )
77 // The leave in tiles too, after the play's, as long as the longest.
78 const leaveSlots = Math.max(0, ...plays.map(play => play.leave.length))
79 const tilesAt = MARGIN + rankWidth + labelWidth + COLUMN_GAP
80 const leaveAt = tilesAt + slots * TILE_STEP + COLUMN_GAP
81 const statsAt = leaveSlots === 0 ? leaveAt : leaveAt + leaveSlots * TILE_STEP + COLUMN_GAP
82 const width = Math.max(
83 MIN_WIDTH,
84 statsAt + widths.reduce((total, columnWidth) => total + columnWidth + COLUMN_GAP, 0) + MARGIN,
85 PICTURE_WIDTH + 2 * MARGIN,
86 )
87 const boardAt = MARGIN + TITLE_SIZE + SUBTITLE_SIZE + 2 * MARGIN
88 const tableAt = boardAt + PICTURE_HEIGHT + MARGIN
89 const height = tableAt + ROW_HEIGHT * (plays.length + 1) + MARGIN
90 const picture: Picture = { width, height, pixels: new Uint8Array(width * height * 4) }
91 const canvas = pixelCanvas(picture, font)
92
93 canvas.rect(0, 0, width, height, BACKGROUND)
94 drawText(canvas, font, title, MARGIN, MARGIN + TITLE_SIZE, TITLE_SIZE, TEXT, true)
95 drawText(canvas, font, subtitle, MARGIN, MARGIN + TITLE_SIZE + SUBTITLE_SIZE + 12, SUBTITLE_SIZE, LABEL)
96 drawBoard(shifted(canvas, Math.floor((width - PICTURE_WIDTH) / 2), boardAt), font, sim.board, sim.rack, shown)
97
98 // The headings, then a row per play: rank, square, its tiles, the leave's,
99 // numbers right aligned.
100 const baselineIn = (top: number) => top + Math.floor(ROW_HEIGHT * 0.7)
101 const drawNumbers = (cells: readonly string[], top: number, color: Rgb) => {
102 let right = statsAt
103 for (const [index, cell] of cells.entries()) {
104 right += (widths[index] ?? 0) + COLUMN_GAP
105 drawText(canvas, font, cell, right - COLUMN_GAP - textWidth(cell, TEXT_SIZE), baselineIn(top), TEXT_SIZE, color)
106 }
107 }
108 drawText(canvas, font, '#', MARGIN, baselineIn(tableAt), TEXT_SIZE, LABEL)
109 drawText(canvas, font, 'Play', MARGIN + rankWidth, baselineIn(tableAt), TEXT_SIZE, LABEL)
110 if (leaveSlots > 0) {
111 drawText(canvas, font, 'Leave', leaveAt, baselineIn(tableAt), TEXT_SIZE, LABEL)
112 }
113 drawNumbers(
114 columns.map(column => column.title),
115 tableAt,
116 LABEL,
117 )
118 for (const [index, play] of plays.entries()) {
119 const top = tableAt + ROW_HEIGHT * (index + 1)
120 if (play.id === shown.id) {
121 canvas.rect(MARGIN / 2, top, width - MARGIN, ROW_HEIGHT, PICKED_ROW)
122 }
123 drawText(canvas, font, String(play.rank), MARGIN, baselineIn(top), TEXT_SIZE, LABEL)
124 drawText(canvas, font, spelled[index]?.label ?? '', MARGIN + rankWidth, baselineIn(top), TEXT_SIZE, TEXT)
125 for (const [slot, { letter, isThrough }] of (spelled[index]?.tiles ?? []).entries()) {
126 drawTile(canvas, font, tilesAt + slot * TILE_STEP, top + 3, {
127 letter,
128 ...tileColors(letter, isThrough ? TILE_BG : PREVIEW_BG),
129 isBold: !isThrough,
130 hasScore: false,
131 isLarge: true,
132 })
133 }
134 // The leave as the rack draws its tiles: not bold, a blank grey.
135 for (const [slot, letter] of [...play.leave].entries()) {
136 drawTile(canvas, font, leaveAt + slot * TILE_STEP, top + 3, {
137 letter,
138 ...tileColors(letter, TILE_BG),
139 isBold: false,
140 hasScore: false,
141 isLarge: true,
142 })
143 }
144 drawNumbers(
145 columns.map(column => column.cell(play)),
146 top,
147 TEXT,
148 )
149 }
150 return picture
151}
152hooks/board.ts 208 lines1// Lays out the board, the previewed play and the rack once, in Fira Code
2// with the TUI's tile proportions (tui/pixel_compose.c), for both outputs:
3// the terminal's pixels (draw.ts) and the other surfaces' SVG (svg.ts).
4
5import { BOARD_DIM, COLUMN_LETTERS, premiumAt, TILE_SCORES } from './layout'
6import { placedTiles, rackGhosts, type Play } from './sim'
7import {
8 BACKGROUND,
9 BLANK_FG,
10 BOARD,
11 BOARD_SPAN,
12 EMPTY_SQUARE,
13 GHOST_FG,
14 LABEL,
15 LEFT,
16 PICTURE_HEIGHT,
17 PICTURE_WIDTH,
18 PREMIUM_COLORS,
19 PREVIEW_BG,
20 RACK_Y,
21 rackTileX,
22 SQUARE,
23 squareX,
24 squareY,
25 TILE_BG,
26 TILE_FG,
27 TOP,
28 type Rgb,
29} from './theme'
30import { tileLabel } from './tiles'
31import { inkBox, type Font, type InkBox } from './truetype'
32
33/** What a board is drawn onto. Glyphs are placed by the left edge of their
34 * ink and their baseline, in pixels from the picture's top-left. */
35export type Canvas = {
36 rect: (x: number, y: number, width: number, height: number, color: Rgb) => void
37 diamond: (centerX: number, centerY: number, radius: number, color: Rgb) => void
38 glyph: (char: string, size: number, isBold: boolean, inkLeft: number, baseline: number, color: Rgb) => void
39}
40
41// tui/pixel_compose.c's proportions of a tile with score subscripts.
42const LETTER_SIZE = Math.floor(SQUARE * 0.5)
43// Its letter on a tile without one, as small tiles draw.
44const LARGE_LETTER_SIZE = Math.floor(SQUARE * 0.74)
45const SCORE_SIZE = Math.floor(SQUARE * 0.24)
46const LETTER_BASELINE = Math.floor(SQUARE * 0.72)
47const LETTER_LIFT = Math.floor(SQUARE * 0.08)
48const SCORE_MARGIN_X = Math.floor(SQUARE * 0.12)
49const SCORE_MARGIN_Y = Math.floor(SQUARE * 0.16)
50const LABEL_SIZE = 10
51// A tile of several letters: the part of its width they may span, and the
52// pixels between them.
53const MANY_LETTERS_WIDTH = 0.7
54const LETTER_GAP = 1
55
56/** The ink box of `char` drawn at `size` pixels, y up from the baseline. */
57export function glyphInk(font: Font, char: string, size: number, isBold: boolean): InkBox | null {
58 return inkBox(font.outline(char.codePointAt(0) ?? 0, isBold), size / font.unitsPerEm)
59}
60
61// What each tile scores in the set the pictures show: English's until the
62// mod reads the sim's own set (setTileScores).
63let tileScores: Readonly<Record<string, number>> = TILE_SCORES
64
65/** The scores of the set the pictures show from now on, by tile (a Catalan
66 * one of several letters packed into one, tiles.ts). */
67export function setTileScores(scores: Readonly<Record<string, number>>) {
68 tileScores = scores
69}
70
71/** `isLarge`: a tile without a score drawn small (a row of the table), its
72 * letter at the TUI's size for that. */
73export type Tile = { letter: string; fg: Rgb; bg: Rgb; isBold: boolean; hasScore: boolean; isLarge?: true }
74
75/** A tile's colours on `bg`: a blank's letter grey, as the TUI draws it. */
76export function tileColors(letter: string, bg: Rgb): { fg: Rgb; bg: Rgb } {
77 return { fg: letter === letter.toLowerCase() ? BLANK_FG : TILE_FG, bg }
78}
79
80export function drawTile(canvas: Canvas, font: Font, x: number, y: number, tile: Tile) {
81 canvas.rect(x, y, SQUARE, SQUARE, tile.bg)
82 // A Catalan tile of several letters (L·L) draws them all, as large as
83 // fits across it.
84 const chars = [...tileLabel(tile.letter)]
85 const fullSize = tile.isLarge === true && !tile.hasScore ? LARGE_LETTER_SIZE : LETTER_SIZE
86 const width = (ink: InkBox | null) => (ink === null ? 0 : Math.ceil(ink.maxX) - Math.floor(ink.minX))
87 const inksAt = (size: number) => chars.map(char => ({ char, ink: glyphInk(font, char, size, tile.isBold) }))
88 const widthOf = (inks: ReturnType<typeof inksAt>) =>
89 inks.reduce((total, { ink }) => total + width(ink), 0) + LETTER_GAP * (chars.length - 1)
90 const fullWidth = widthOf(inksAt(fullSize))
91 const room = SQUARE * MANY_LETTERS_WIDTH
92 const size = chars.length === 1 || fullWidth <= room ? fullSize : Math.floor((fullSize * room) / fullWidth)
93 const inks = size === fullSize ? inksAt(fullSize) : inksAt(size)
94 const inkWidth = widthOf(inks)
95 const score = tileScores[tile.letter] ?? 0
96 // Off centre to the left, and raised, to leave the score its corner.
97 const shift = tile.hasScore ? Math.floor(SQUARE * (score >= 10 ? 0.07 : 0.03)) : 0
98 const baseline = LETTER_BASELINE - (tile.hasScore ? LETTER_LIFT : 0)
99 let left = x + Math.floor((SQUARE - inkWidth) / 2) - shift
100 for (const { char, ink } of inks) {
101 if (ink !== null) {
102 canvas.glyph(char, size, tile.isBold, left, y + baseline, tile.fg)
103 left += width(ink) + LETTER_GAP
104 }
105 }
106 if (!tile.hasScore) {
107 return
108 }
109 // Digits right to left from the bottom-right margin, a pixel apart.
110 let right = x + SQUARE - SCORE_MARGIN_X
111 const bottom = y + SQUARE - SCORE_MARGIN_Y
112 for (const digit of [...String(score)].reverse()) {
113 const digitInk = glyphInk(font, digit, SCORE_SIZE, tile.isBold)
114 if (digitInk === null) {
115 continue
116 }
117 const digitWidth = Math.ceil(digitInk.maxX) - Math.floor(digitInk.minX)
118 // Sits the digit's ink bottom on the margin, as the TUI's bitmap does.
119 canvas.glyph(digit, SCORE_SIZE, tile.isBold, right - digitWidth, bottom + Math.floor(digitInk.minY), tile.fg)
120 right -= digitWidth + 1
121 }
122}
123
124// Centres a glyph's ink in a box, its baseline at the TUI's 72%.
125function drawCentered(canvas: Canvas, font: Font, char: string, x: number, y: number, width: number, height: number, size: number, color: Rgb) {
126 const ink = glyphInk(font, char, size, false)
127 if (ink === null) {
128 return
129 }
130 const inkWidth = Math.ceil(ink.maxX) - Math.floor(ink.minX)
131 canvas.glyph(char, size, false, x + Math.floor((width - inkWidth) / 2), y + Math.floor(height * 0.72), color)
132}
133
134// A premium square is its colour alone, unlabelled; the centre keeps its
135// diamond.
136function drawSquare(canvas: Canvas, row: number, col: number) {
137 const x = squareX(col)
138 const y = squareY(row)
139 const premium = premiumAt(row, col)
140 if (premium === null) {
141 canvas.rect(x, y, SQUARE, SQUARE, EMPTY_SQUARE)
142 return
143 }
144 const { bg, fg } = PREMIUM_COLORS[premium]
145 canvas.rect(x, y, SQUARE, SQUARE, bg)
146 if (premium === 'CENTER') {
147 canvas.diamond(x + SQUARE / 2, y + SQUARE / 2, Math.floor(SQUARE * 0.2), fg)
148 }
149}
150
151/** `board` is the position's rows, `.` for an empty square and lowercase
152 * for a blank; null for an empty board. */
153export function drawBoard(canvas: Canvas, font: Font, board: readonly string[] | null, rack: string, play: Play) {
154 canvas.rect(0, 0, PICTURE_WIDTH, PICTURE_HEIGHT, BACKGROUND)
155 canvas.rect(LEFT, TOP, BOARD_SPAN, BOARD_SPAN, BOARD)
156 for (let col = 0; col < BOARD_DIM; col++) {
157 drawCentered(canvas, font, COLUMN_LETTERS[col] ?? '', squareX(col), 0, SQUARE, TOP, LABEL_SIZE, LABEL)
158 }
159 for (let row = 0; row < BOARD_DIM; row++) {
160 const number = String(row + 1)
161 let right = LEFT - 4
162 for (const digit of [...number].reverse()) {
163 const ink = glyphInk(font, digit, LABEL_SIZE, false)
164 if (ink !== null) {
165 const width = Math.ceil(ink.maxX) - Math.floor(ink.minX)
166 canvas.glyph(digit, LABEL_SIZE, false, right - width, squareY(row) + Math.floor(SQUARE / 2 + LABEL_SIZE * 0.35), LABEL)
167 right -= width + 1
168 }
169 }
170 for (let col = 0; col < BOARD_DIM; col++) {
171 const letter = board?.[row]?.[col] ?? '.'
172 if (letter === '.') {
173 drawSquare(canvas, row, col)
174 } else {
175 drawTile(canvas, font, squareX(col), squareY(row), {
176 letter,
177 ...tileColors(letter, TILE_BG),
178 isBold: false,
179 hasScore: letter !== letter.toLowerCase(),
180 })
181 }
182 }
183 }
184 // The previewed play: a muted tile under a bold letter, as the TUI
185 // draws a preview. A blank is its lowercase letter, with no score. An
186 // exchange places nothing: its rack ghosts show what goes back.
187 for (const { row, col, letter } of placedTiles(play)) {
188 drawTile(canvas, font, squareX(col), squareY(row), {
189 letter,
190 ...tileColors(letter, PREVIEW_BG),
191 isBold: true,
192 hasScore: letter !== letter.toLowerCase(),
193 })
194 }
195 // The rack: the tiles the play spends drawn as grey ghosts, no score.
196 const ghosts = rackGhosts(rack, play)
197 for (const [index, letter] of [...rack].entries()) {
198 const isGhost = ghosts[index] === true
199 drawTile(canvas, font, rackTileX(index), RACK_Y, {
200 letter,
201 fg: isGhost ? GHOST_FG : TILE_FG,
202 bg: isGhost ? BACKGROUND : TILE_BG,
203 isBold: false,
204 hasScore: !isGhost,
205 })
206 }
207}
208hooks/draw.ts 139 lines1// Draws the board as RGBA pixels for the terminal's <Image>: the layout of
2// board.ts, its glyphs rasterized from the font and blended in.
3
4import { drawBoard, drawTile, tileColors, type Canvas } from './board'
5import type { Play } from './sim'
6import { PICTURE_HEIGHT, PICTURE_WIDTH, PREVIEW_BG, SQUARE, TILE_BG, type Rgb } from './theme'
7import { rasterize, type Font, type GlyphBitmap } from './truetype'
8
9export type Picture = { width: number; height: number; pixels: Uint8Array }
10
11// Rasterized glyphs by character, size and weight: a board draws the same
12// few dozen many times.
13const bitmaps = new Map<string, GlyphBitmap | null>()
14
15function glyphBitmap(font: Font, char: string, size: number, isBold: boolean): GlyphBitmap | null {
16 const key = `${char}:${size}:${isBold}`
17 let bitmap = bitmaps.get(key)
18 if (bitmap === undefined) {
19 bitmap = rasterize(font.outline(char.codePointAt(0) ?? 0, isBold), size / font.unitsPerEm)
20 bitmaps.set(key, bitmap)
21 }
22 return bitmap
23}
24
25export function pixelCanvas(picture: Picture, font: Font): Canvas {
26 const { width, height, pixels } = picture
27 // Coordinates are rounded: a centred shape can land on a half pixel, and a
28 // fractional index into the pixels writes nothing.
29 const rect = (x: number, y: number, rectWidth: number, rectHeight: number, color: Rgb) => {
30 const left = Math.max(0, Math.round(x))
31 const right = Math.min(width, Math.round(x + rectWidth))
32 const top = Math.max(0, Math.round(y))
33 const bottom = Math.min(height, Math.round(y + rectHeight))
34 for (let row = top; row < bottom; row++) {
35 for (let at = (row * width + left) * 4, col = left; col < right; col++, at += 4) {
36 pixels[at] = color[0]
37 pixels[at + 1] = color[1]
38 pixels[at + 2] = color[2]
39 pixels[at + 3] = 255
40 }
41 }
42 }
43 return {
44 rect,
45 diamond: (centerX, centerY, radius, color) => {
46 for (let dy = -radius; dy <= radius; dy++) {
47 const halfWidth = radius - Math.abs(dy)
48 rect(centerX - halfWidth, centerY + dy, halfWidth * 2 + 1, 1, color)
49 }
50 },
51 glyph: (char, size, isBold, inkLeft, baseline, color) => {
52 const bitmap = glyphBitmap(font, char, size, isBold)
53 if (bitmap === null) {
54 return
55 }
56 // The bitmap's own left edge is its ink's, floored.
57 const x0 = Math.round(inkLeft)
58 const y0 = Math.round(baseline) - bitmap.top
59 for (let row = 0; row < bitmap.height; row++) {
60 const y = y0 + row
61 if (y < 0 || y >= height) {
62 continue
63 }
64 for (let col = 0; col < bitmap.width; col++) {
65 const x = x0 + col
66 const coverage = bitmap.alpha[row * bitmap.width + col] ?? 0
67 if (x < 0 || x >= width || coverage <= 0) {
68 continue
69 }
70 const at = (y * width + x) * 4
71 for (let channel = 0; channel < 3; channel++) {
72 const under = pixels[at + channel] ?? 0
73 pixels[at + channel] = Math.round(under + (color[channel]! - under) * coverage)
74 }
75 }
76 }
77 },
78 }
79}
80
81export function boardPicture(font: Font, board: readonly string[] | null, rack: string, play: Play): Picture {
82 const picture: Picture = {
83 width: PICTURE_WIDTH,
84 height: PICTURE_HEIGHT,
85 pixels: new Uint8Array(PICTURE_WIDTH * PICTURE_HEIGHT * 4),
86 }
87 drawBoard(pixelCanvas(picture, font), font, board, rack, play)
88 return picture
89}
90
91// Between two tiles of a row, in pixels; each tile and its gap fill two
92// terminal columns.
93const ROW_TILE_GAP = 4
94export const ROW_TILE_STEP = SQUARE + ROW_TILE_GAP
95// Above and below the tiles, so a selected row's colour runs round them.
96const ROW_TILE_MARGIN = 3
97export const ROW_TILE_HEIGHT = SQUARE + 2 * ROW_TILE_MARGIN
98
99type RowTiles = readonly { letter: string; isThrough: boolean }[]
100
101// One row's tiles, `top` pixels down: the tiles it plays bright, as the
102// board's preview draws them, the board's own it plays through darker,
103// blanks grey; the rest left clear, or filled with `background`.
104function drawTileRow(canvas: Canvas, font: Font, tiles: RowTiles, top: number, width: number, background?: Rgb) {
105 if (background !== undefined) {
106 canvas.rect(0, top, width, ROW_TILE_HEIGHT, background)
107 }
108 for (const [index, { letter, isThrough }] of tiles.entries()) {
109 drawTile(canvas, font, index * ROW_TILE_STEP + ROW_TILE_GAP / 2, top + ROW_TILE_MARGIN, {
110 letter,
111 ...tileColors(letter, isThrough ? TILE_BG : PREVIEW_BG),
112 isBold: !isThrough,
113 hasScore: false,
114 isLarge: true,
115 })
116 }
117}
118
119/** A play's tiles in a row, `slots` wide so every row of the table lines
120 * up, on `background` (the selected row's) or clear. */
121export function tilesPicture(font: Font, tiles: RowTiles, slots: number, background?: Rgb): Picture {
122 return tilesColumnPicture(font, [{ tiles, background }], slots)
123}
124
125/** The table's tile column: each row's tiles under the last, a terminal
126 * row each, `slots` wide, a row's `background` (the selected one's) behind
127 * it. One picture for the whole column, as the terminal shows one picture
128 * more surely than many small ones. */
129export function tilesColumnPicture(font: Font, rows: readonly { tiles: RowTiles; background?: Rgb }[], slots: number): Picture {
130 const width = slots * ROW_TILE_STEP
131 const height = Math.max(1, rows.length) * ROW_TILE_HEIGHT
132 const picture: Picture = { width, height, pixels: new Uint8Array(width * height * 4) }
133 const canvas = pixelCanvas(picture, font)
134 for (const [index, { tiles, background }] of rows.entries()) {
135 drawTileRow(canvas, font, tiles, index * ROW_TILE_HEIGHT, width, background)
136 }
137 return picture
138}
139hooks/layout.ts 39 lines1// The standard board and the English tile values: what a sim's output
2// leaves out.
3
4export const BOARD_DIM = 15
5export const RACK_SIZE = 7
6
7export type Premium = 'TW' | 'DW' | 'TL' | 'DL' | 'CENTER'
8
9// The standard board's top half: = TW, - DW, " TL, ' DL, * the centre.
10const TOP_HALF = [
11 "=..'...=...'..=",
12 '.-..."..."...-.',
13 "..-...'.'...-..",
14 "'..-...'...-..'",
15 '....-.....-....',
16 '."..."..."...".',
17 "..'...'.'...'..",
18 "=..'...*...'..=",
19]
20const LAYOUT = [...TOP_HALF, ...TOP_HALF.slice(0, TOP_HALF.length - 1).reverse()]
21const PREMIUMS: Readonly<Record<string, Premium>> = {
22 '=': 'TW',
23 '-': 'DW',
24 '"': 'TL',
25 "'": 'DL',
26 '*': 'CENTER',
27}
28
29export const TILE_SCORES: Readonly<Record<string, number>> = {
30 A: 1, B: 3, C: 3, D: 2, E: 1, F: 4, G: 2, H: 4, I: 1, J: 8, K: 5, L: 1, M: 3,
31 N: 1, O: 1, P: 3, Q: 10, R: 1, S: 1, T: 1, U: 1, V: 4, W: 4, X: 8, Y: 4, Z: 10,
32}
33
34export const COLUMN_LETTERS = 'ABCDEFGHIJKLMNO'
35
36export function premiumAt(row: number, col: number): Premium | null {
37 return PREMIUMS[LAYOUT[row]?.[col] ?? '.'] ?? null
38}
39hooks/magpie.ts 1210 lines1// Turns what the person asks for (an opening rack, a CGP, a path or URL of a
2// CGP or GCG, and for a GCG which position in it) into a MAGPIE run:
3// `setoptions` with the sim's settings on the command line, which keeps
4// MAGPIE reading commands, then the position and `gsimulate` on its
5// standard input.
6
7import { BOARD_DIM, COLUMN_LETTERS, premiumAt } from './layout'
8import { hasWordmap } from './setup'
9import { packTiles, tileOf } from './tiles'
10
11export const DEFAULT_LEXICON = 'CSW24'
12// The lexicon for a position that names none: the person's own (the mod's
13// lexicon option), CSW24 until it is read.
14let preferred = DEFAULT_LEXICON
15
16export function setPreferredLexicon(lexicon: string) {
17 preferred = lexicon
18}
19
20export function preferredLexicon(): string {
21 return preferred
22}
23
24// 5,000 iterations for each of the top 50 plays, shared evenly.
25export const SIM_PLAYS = 50
26export const ITERATIONS_PER_PLAY = 5000
27export const SIM_PLIES = 4
28// Long enough never to cut the iterations short.
29const SIM_TIME_LIMIT_SECONDS = 3600
30// How often MAGPIE prints the table as a sim runs, which the pane streams:
31// at this, a table each 14,000 iterations or so, 18 a sim, twice a second.
32const SIM_PRINT_FREQUENCY = 1000
33// More `next`s than any game has events; the ones past its end only say so.
34const SCAN_STEPS = 150
35// The tiles a rack holds.
36const RACK_TILES = 7
37
38/** A play as `commit` takes it: `8G VAV`, lowercase a blank. */
39export type PlacedMove = {
40 coordinate: string
41 word: string
42 /** The tiles its player kept, when known: committed from the whole rack. */
43 leave?: string
44}
45
46export type Position =
47 /** A rack to move with, after the moves played before it, if any. */
48 | { kind: 'rack'; rack: string; after?: PlacedMove[] }
49 // The rest, each with the moves played after it and the rack to move with
50 // then, when moves followed.
51 | { kind: 'cgp'; cgp: string; after?: PlacedMove[]; rack?: string }
52 /** A path, URL or cross-tables or Woogles game id: MAGPIE's `load` fetches it. */
53 | { kind: 'gcg'; source: string; after?: PlacedMove[]; rack?: string }
54 /** A URL of unknown content: fetched, then read as a CGP or a GCG. */
55 | { kind: 'url'; url: string; after?: PlacedMove[]; rack?: string }
56 /** A local file of unknown content: read, then the same. */
57 | { kind: 'file'; path: string; after?: PlacedMove[]; rack?: string }
58
59/** Which position of a GCG to sim. */
60export type Selector =
61 /** The game's turn-th move, from 1: the position it was made from. */
62 | { kind: 'turn'; turn: number }
63 /** The position whose player to move held this rack. */
64 | { kind: 'rack'; rack: string }
65 /** The position this word was played from. */
66 | { kind: 'word'; word: string }
67 /** The rack its player held next, at their following turn, after this
68 * word was played (where, if given). */
69 | { kind: 'after'; word: string; coordinate?: string }
70 /** Letters that could be a rack or a word. */
71 | { kind: 'letters'; letters: string }
72 /** MAGPIE's own `goto` argument: events played, start or end. */
73 | { kind: 'goto'; goto: string }
74 /** The turn the game's event-th event was a move of: cross-tables's
75 * viewer counts moves so (`#13#`), challenge bonuses and end points
76 * among them. */
77 | { kind: 'move'; event: number }
78
79export type SimRequest = {
80 position: Position
81 /** A short name for titles and messages. */
82 label: string
83 /** As the person gave the position, to repeat back to them. */
84 source: string
85 /** Left out: the person's own, or what a CGP's `lex` or a GCG's `#lexicon` says. */
86 lexicon?: string
87 /** For a GCG; left out, its latest position with a player to move. */
88 at?: Selector
89}
90
91const RACK = /^[\p{L}?]{1,7}$/u
92const LEXICON_NAME = /^[A-Z]+\d*$/
93// Pages MAGPIE's `load` knows how to read a game from.
94const GAME_SITES = /(^|\.)(woogles\.io|cross-tables\.com)$/i
95const COORDINATE = /^(?:\d{1,2}[A-O]|[A-O]\d{1,2})$/
96const PLAYED_WORD = /^[\p{L}?.()]+$/u
97
98/** Where `/magpie next turn` and the like move in the game the pane shows. */
99export type Navigation = 'next' | 'previous' | 'first' | 'last'
100
101/** What the person asked for, as the interpreter model reads their words, or
102 * as the simulate tool's caller states it. */
103export type Intent =
104 | {
105 action: 'sim'
106 /** An opening rack, a CGP, or a path or URL of a .cgp or .gcg; left out,
107 * the game or position the pane shows now. */
108 position?: string
109 lexicon?: string
110 /** For a rack: the moves played before it. */
111 after?: PlacedMove[]
112 /** For a GCG, which position: one of these. */
113 turn?: number
114 rack?: string
115 word?: string
116 /** With `word`: the rack it was played `from` (the default), or its
117 * player's next rack, `after` it. */
118 relation?: 'from' | 'after'
119 /** Letters that could be the rack or the word. */
120 letters?: string
121 /** MAGPIE's goto: events played, or start or end. */
122 event?: number | 'start' | 'end'
123 }
124 /** Moves played on from what the pane shows, in order, then the rack to
125 * move with (left out: asked for). */
126 | { action: 'play'; moves: MoveDescription[]; rack?: string }
127 | { action: 'navigate'; to: Navigation }
128 | { action: 'lexicon'; lexicon: string }
129 | { action: 'reopen' }
130 /** A picture of what the pane shows, sent to the person (for a phone). */
131 | { action: 'picture' }
132 /** The setup pane: MAGPIE installed, the language and lexicon chosen. */
133 | { action: 'setup' }
134 | { action: 'unclear'; question: string }
135
136/** What the interpreter knows of the pane: the analysis it shows, if any,
137 * the moves played to reach it, the rack to move with, its top plays; and
138 * the lexicons MAGPIE has. */
139export type InterpreterContext = {
140 current?: {
141 label: string
142 source: string
143 lexicon: string
144 turn?: number
145 turns?: number
146 moves?: readonly string[]
147 rack?: string
148 topPlays?: readonly string[]
149 }
150 lexica: readonly string[]
151 /** A question the last /magpie asked, and what was typed up to it. */
152 asked?: { typed: readonly string[]; question: string }
153}
154
155/** The interpreter's instructions and the person's words, with what the
156 * pane shows now. */
157export function interpreterPrompt(text: string, context: InterpreterContext): { system: string; prompt: string } {
158 const current = context.current
159 const state =
160 current === undefined
161 ? 'The pane shows nothing yet.'
162 : [
163 [
164 `The pane shows ${current.label} (from ${current.source})`,
165 current.lexicon === '' ? '' : `in ${current.lexicon}`,
166 current.turn === undefined ? '' : `at turn ${current.turn} of ${current.turns ?? '?'}`,
167 ]
168 .filter(part => part !== '')
169 .join(' ') + '.',
170 current.moves === undefined || current.moves.length === 0 ? '' : `Moves played to reach it: ${current.moves.join(', ')}.`,
171 current.rack === undefined || current.rack === '' ? 'The rack to move with is not known yet.' : `The player to move (the person) holds ${current.rack}.`,
172 current.topPlays === undefined || current.topPlays.length === 0 ? '' : `Its top plays: ${current.topPlays.join(', ')}.`,
173 ]
174 .filter(part => part !== '')
175 .join('\n')
176 const asked =
177 context.asked === undefined
178 ? ''
179 : `\nTheir last /magpie asked them something. What they typed: "${context.asked.typed.join('", then "')}". It asked: "${context.asked.question}". When what they type now answers it, reply with that whole request again with the answer folded in (a square or score picks a placement; "the second z" names the blank: JAZz); otherwise read it alone.`
180 const lexica =
181 context.lexica.length === 0
182 ? 'The lexicons installed are not known; use the usual names (CSW24, NWL23).'
183 : `The lexicons installed: ${context.lexica.join(', ')}. A family name (csw, nwl, twl) means its newest one here.`
184 const system = `You read what a person typed after /magpie, a command that analyzes crossword (Scrabble) positions with the MAGPIE engine, and reply with one JSON object and nothing else.
185
186The object is one of:
187- {"action":"sim", ...} to analyze a position, with these fields as they apply:
188 "position": an opening rack (up to 7 tiles, ? for a blank, in capitals), a CGP string copied whole, or a path or URL of a .cgp or .gcg file copied exactly (keep any #13# on a cross-tables link). Leave it out to mean the game or position the pane shows now.
189 "lexicon": a lexicon name, e.g. "NWL23", only if the person names one.
190 "after": for a rack, the moves played before it, in order: [{"coordinate":"8G","word":"VAV"}]. A coordinate is a row number then a column letter for across (8G) or a column letter then a row number for down (H8), in capitals. Write a word in capitals when the person typed it all in lowercase; when they mixed cases, keep their case (a lowercase letter is a blank). A tile already on the board is a "." or in parentheses.
191 For a game (a GCG), which position, at most one of:
192 "turn": a turn number, 1 being the game's first move.
193 "rack": letters the player to move held then.
194 "word" with "relation": a word played in the game; "from" (the default) is the turn it was played on, "after" is the next rack of the player who played it.
195 "letters": letters that could be either the rack or the word, when the person does not say which.
196 "event": MAGPIE's goto, a number of events played, or "start" or "end".
197 With none of them, the game's latest position.
198- {"action":"navigate","to":"next"|"previous"|"first"|"last"} to move to another turn of the game the pane shows ("next turn", "back", "first turn", "end of game").
199- {"action":"lexicon","lexicon":"CSW24"} to analyze what the pane shows again in another lexicon, when the person names only a lexicon.
200- {"action":"reopen"} to show the pane again without a new analysis.
201- {"action":"picture"} to send a picture of what the pane shows ("send it to my phone", "picture", "image").
202- {"action":"setup"} to install MAGPIE again, or to change the language or lexicon the person plays in for good ("setup", "install", "I play in French").
203- {"action":"unclear","question":"..."} when you cannot tell what they mean; ask one short question.
204- {"action":"play","moves":[...],"rack":"..."} when the person tells what was played from the position the pane shows, as a game goes on: each move in order, then the rack they now hold.
205 A move: {"word":"INERTIAS"} with whatever they said of it: "by" ("me" for the person, "opponent"), "square" (its first tile's square, 8H or H8 as above), "direction" ("across" or "down"), "score", "through" (tiles on the board it went through: [{"letter":"A","word":"INERTIAS"}], the word on the board each is in), "premium" ("TW", "DW", "TL" or "DL": "to the TWS" is TW). "leave": the tiles they kept ("keeping ion" is "ION"). A pass is {"pass":true}. The word is in capitals, the board's tiles in it included; when they say which tile was a blank, that letter alone in lowercase ("the second z was the blank": JAZz). Moves alternate: the person's own first move is from the rack they hold, often one of the top plays.
206 "rack": the tiles they hold now, in capitals, ? for a blank; a ? after letters is the blank ("polish?" is ?HILOPS, not a question), and a rack is not a word to look up. Leave it out when they have not said.
207
208Words like "sim", "opening", "rack", "the", "turn", "where", "played" only say what the rest is. Words are case-insensitive except as "after" says. The person may write in any language: read "simule le tirage AEINRST" or "ich habe ÄRGER gelegt" as you would the English. A letter with an accent is a tile of its own (Ä, Ł, Ç): keep it. Catalan's tiles of two letters go in brackets, [L·L] (with its dot), [NY] and [QU] ("anys" is A[NY]S, "quatre" is [QU]ATRE, "ral·li" is RA[L·L]I, a blank inside in lowercase: [ny]). A position with no lexicon named is in ${preferred}: leave "lexicon" out for it.
209
210Examples:
211"ADOPQRR" -> {"action":"sim","position":"ADOPQRR"}
212"opening rack rucksex" -> {"action":"sim","position":"RUCKSEX"}
213"rucksex nwl" -> {"action":"sim","position":"RUCKSEX","lexicon":"NWL23"}
214"AEINRST after 8g vav" -> {"action":"sim","position":"AEINRST","after":[{"coordinate":"8G","word":"VAV"}]}
215"~/games/r3.gcg turn 12" -> {"action":"sim","position":"~/games/r3.gcg","turn":12}
216"https://woogles.io/game/abc the turn where I played VODKA" -> {"action":"sim","position":"https://woogles.io/game/abc","word":"VODKA","relation":"from"}
217"game.gcg after silvered" -> {"action":"sim","position":"game.gcg","word":"SILVERED","relation":"after"}
218"game.gcg DEEILRV" -> {"action":"sim","position":"game.gcg","letters":"DEEILRV"}
219"turn 14" -> {"action":"sim","turn":14}
220"inertias was played. opp played zag through the a in inertias. next rack for me is polish?" -> {"action":"play","moves":[{"word":"INERTIAS","by":"me"},{"word":"ZAG","by":"opponent","through":[{"letter":"A","word":"INERTIAS"}]}],"rack":"?HILOPS"}
221"I played qi at 8g for 22, they passed, now I have aeinrst" -> {"action":"play","moves":[{"word":"QI","by":"me","square":"8G","score":22},{"pass":true,"by":"opponent"}],"rack":"AEINRST"}
222"my rack is now deeilrv" -> {"action":"play","moves":[],"rack":"DEEILRV"}
223"I played jazz 8g keeping ion" -> {"action":"play","moves":[{"word":"JAZZ","by":"me","square":"8G","leave":"ION"}]}
224"prev" -> {"action":"navigate","to":"previous"}
225"csw" -> {"action":"lexicon","lexicon":"CSW24"}`
226 return { system, prompt: `${state}\n${lexica}${asked}\n\nWhat they typed: ${text}` }
227}
228
229const NAVIGATIONS: readonly Navigation[] = ['next', 'previous', 'first', 'last']
230
231/** The interpreter's reply as an Intent; throws on anything else. */
232export function parseIntent(reply: string): Intent {
233 // A Catalan tile is one letter here (tiles.ts).
234 const json = /\{[\s\S]*\}/.exec(packTiles(reply))?.[0]
235 if (json === undefined) {
236 throw new Error(`the reply held no JSON: ${reply.slice(0, 200)}`)
237 }
238 const value: unknown = JSON.parse(json)
239 if (typeof value !== 'object' || value === null) {
240 throw new Error('the reply was not an object')
241 }
242 const fields = value as Record<string, unknown>
243 const text = (name: string) => (typeof fields[name] === 'string' && fields[name] !== '' ? (fields[name] as string) : undefined)
244 switch (fields.action) {
245 case 'navigate': {
246 const to = NAVIGATIONS.find(navigation => navigation === fields.to)
247 if (to === undefined) {
248 throw new Error(`no way to move called ${String(fields.to)}`)
249 }
250 return { action: 'navigate', to }
251 }
252 case 'lexicon': {
253 const lexicon = text('lexicon')
254 if (lexicon === undefined) {
255 throw new Error('a lexicon change that names no lexicon')
256 }
257 return { action: 'lexicon', lexicon: lexicon.toUpperCase() }
258 }
259 case 'reopen':
260 return { action: 'reopen' }
261 case 'picture':
262 return { action: 'picture' }
263 case 'setup':
264 return { action: 'setup' }
265 case 'play': {
266 const moves = Array.isArray(fields.moves) ? fields.moves.map(parseMove) : []
267 const rack = text('rack')?.toUpperCase()
268 if (rack !== undefined && !RACK.test(rack)) {
269 throw new Error(`a rack is 1 to 7 tiles, not "${rack}"`)
270 }
271 return rack === undefined ? { action: 'play', moves } : { action: 'play', moves, rack }
272 }
273 case 'unclear':
274 return { action: 'unclear', question: text('question') ?? 'What should I sim?' }
275 case 'sim': {
276 const intent: Extract<Intent, { action: 'sim' }> = { action: 'sim' }
277 const position = text('position')
278 const lexicon = text('lexicon')
279 if (position !== undefined) {
280 intent.position = position
281 }
282 if (lexicon !== undefined) {
283 intent.lexicon = lexicon.toUpperCase()
284 }
285 if (Array.isArray(fields.after)) {
286 intent.after = fields.after.map(move => {
287 const { coordinate, word } = (move ?? {}) as Record<string, unknown>
288 return { coordinate: String(coordinate ?? ''), word: String(word ?? '') }
289 })
290 }
291 if (typeof fields.turn === 'number') {
292 intent.turn = fields.turn
293 }
294 for (const name of ['rack', 'word', 'letters'] as const) {
295 const value = text(name)
296 if (value !== undefined) {
297 intent[name] = value
298 }
299 }
300 if (fields.relation === 'after' || fields.relation === 'from') {
301 intent.relation = fields.relation
302 }
303 if (typeof fields.event === 'number' || fields.event === 'start' || fields.event === 'end') {
304 intent.event = fields.event
305 }
306 return intent
307 }
308 default:
309 throw new Error(`no action called ${String(fields.action)}`)
310 }
311}
312
313/** A lexicon name as MAGPIE spells it, if it has that lexicon (when the
314 * list is known). */
315export function checkLexicon(lexicon: string, lexica: readonly string[]): string {
316 const name = lexicon.toUpperCase()
317 if (!LEXICON_NAME.test(name)) {
318 throw new Error(`"${lexicon}" is not a lexicon name`)
319 }
320 if (lexica.length > 0 && !lexica.includes(name)) {
321 throw new Error(`MAGPIE has no ${name}; it has ${lexica.join(', ')}`)
322 }
323 return name
324}
325
326/** The request a sim intent makes: its position read for its kind, and for
327 * a game which position. Left out, the position is the one the pane shows
328 * (`previous`), in its lexicon unless the intent names another. */
329export function requestFromIntent(intent: Extract<Intent, { action: 'sim' }>, previous?: SimRequest): SimRequest {
330 const source = intent.position?.trim() ?? ''
331 const base = source !== '' ? undefined : previous
332 if (source === '' && base === undefined) {
333 throw new Error('name a position: an opening rack, a CGP, or a .cgp or .gcg path or URL')
334 }
335 const request: SimRequest =
336 base === undefined
337 ? { position: classifyPosition(source), label: positionLabel(source), source }
338 : { position: base.position, label: base.label, source: base.source }
339 const lexicon = intent.lexicon ?? base?.lexicon
340 if (lexicon !== undefined) {
341 request.lexicon = lexicon
342 }
343 const at = selectorOf(intent)
344 const position = request.position
345 if (position.kind === 'rack' || position.kind === 'cgp') {
346 if (at !== undefined) {
347 throw new Error('a turn, rack or word picks a position in a game (a GCG); this position is the whole of what to sim')
348 }
349 if (intent.after !== undefined && intent.after.length > 0) {
350 if (position.kind === 'cgp') {
351 throw new Error('a CGP is the whole position; play the moves into it instead')
352 }
353 const after = intent.after.map(placedMove)
354 request.position = { kind: 'rack', rack: position.rack, after }
355 request.label = `${position.rack} after ${after.map(move => `${move.coordinate} ${move.word}`).join(', ')}`
356 }
357 return request
358 }
359 if (at !== undefined) {
360 request.at = at
361 } else {
362 const move = base === undefined ? gameFragmentMove(source) : undefined
363 if (move !== undefined && move > 0) {
364 request.at = { kind: 'move', event: move }
365 }
366 }
367 return request
368}
369
370function placedMove(move: PlacedMove): PlacedMove {
371 const coordinate = move.coordinate.toUpperCase()
372 if (!COORDINATE.test(coordinate) || !PLAYED_WORD.test(move.word)) {
373 throw new Error(`"${move.coordinate} ${move.word}" is not a move: a square (8G, H8) and the word played there`)
374 }
375 return { coordinate, word: canonicalWord(move.word) }
376}
377
378// Which position of a game the intent names, if any.
379function selectorOf(intent: Extract<Intent, { action: 'sim' }>): Selector | undefined {
380 const asWord = (word: string) => word.replace(/[^\p{L}]/gu, '').toUpperCase()
381 if (intent.turn !== undefined) {
382 if (!Number.isInteger(intent.turn) || intent.turn < 1) {
383 throw new Error(`a turn is a number from 1, not ${intent.turn}`)
384 }
385 return { kind: 'turn', turn: intent.turn }
386 }
387 if (intent.event !== undefined) {
388 return { kind: 'goto', goto: String(intent.event) }
389 }
390 if (intent.word !== undefined) {
391 return intent.relation === 'after' ? { kind: 'after', word: asWord(intent.word) } : { kind: 'word', word: asWord(intent.word) }
392 }
393 if (intent.rack !== undefined) {
394 if (!RACK.test(intent.rack)) {
395 throw new Error(`a rack is 1 to 7 tiles, not "${intent.rack}"`)
396 }
397 return { kind: 'rack', rack: intent.rack.toUpperCase() }
398 }
399 if (intent.letters !== undefined) {
400 return { kind: 'letters', letters: asWord(intent.letters) }
401 }
402 return undefined
403}
404
405/** A word as typed, in MAGPIE's spelling: all lowercase is plain tiles
406 * (`vav` is VAV); in a mixed-case word lowercase letters are blanks. */
407export function canonicalWord(word: string): string {
408 return word === word.toLowerCase() ? word.toUpperCase() : word
409}
410
411/** The tiles a play takes from its player's rack: the board's own (in
412 * parentheses, or a `.` each) left out, a blank as `?`. */
413export function tilesFromRack(word: string): string {
414 return [...word.replace(/\([^)]*\)/g, '').replaceAll('.', '')]
415 .map(letter => (letter === letter.toLowerCase() ? '?' : letter))
416 .join('')
417}
418
419/** Whether `text` opens with a CGP board: its rows split by slashes. */
420export function isCgp(text: string): boolean {
421 return (text.trim().split(/\s+/)[0] ?? '').split('/').length === BOARD_DIM
422}
423
424export function classifyPosition(text: string): Position {
425 const trimmed = text.trim()
426 if (/^https?:\/\//i.test(trimmed)) {
427 const url = new URL(trimmed)
428 // A viewer's `#13#` is for the page (gameFragmentEvents), not the fetch.
429 url.hash = ''
430 return GAME_SITES.test(url.hostname) || /\.gcg$/i.test(url.pathname)
431 ? { kind: 'gcg', source: url.href }
432 : { kind: 'url', url: url.href }
433 }
434 if (isCgp(trimmed)) {
435 return { kind: 'cgp', cgp: trimmed }
436 }
437 if (/^[~/.]/.test(trimmed) || /\.(gcg|cgp|txt)$/i.test(trimmed)) {
438 return /\.gcg$/i.test(trimmed) ? { kind: 'gcg', source: trimmed } : { kind: 'file', path: trimmed }
439 }
440 if (RACK.test(trimmed)) {
441 return { kind: 'rack', rack: trimmed.toUpperCase() }
442 }
443 throw new Error(
444 `cannot tell what "${trimmed}" is: give an opening rack (ADOPQRR), a CGP, or a path or URL of a CGP or GCG`,
445 )
446}
447
448/** A short name for a position: a URL's or path's last part, else as given. */
449export function positionLabel(text: string): string {
450 const trimmed = text.trim()
451 if (isCgp(trimmed)) {
452 return 'CGP'
453 }
454 if (/^https?:\/\//i.test(trimmed)) {
455 const url = new URL(trimmed)
456 const annotated = url.searchParams.get('u')
457 if (/cross-tables\.com$/i.test(url.hostname) && annotated !== null) {
458 return `cross-tables ${annotated}`
459 }
460 return url.pathname.split('/').filter(part => part !== '').pop() ?? url.hostname
461 }
462 return /[/]/.test(trimmed) ? (trimmed.split('/').filter(part => part !== '').pop() ?? trimmed) : trimmed
463}
464
465/** The move a game viewer's link shows: cross-tables's annotated viewer
466 * writes its move counter, which counts the events MAGPIE does, as `#13#`. */
467export function gameFragmentMove(text: string): number | undefined {
468 const move = /^https?:\/\/[^#]*cross-tables\.com\/[^#]*#(\d+)#?$/i.exec(text.trim())?.[1]
469 return move === undefined ? undefined : Number(move)
470}
471
472/** The CGP's four fields, and its `lex` opcode if it has one. */
473export function splitCgp(cgp: string): { fields: string; lexicon?: string } {
474 const tokens = cgp.trim().split(/\s+/)
475 const lexicon = /(?:^|;)\s*lex\s+([A-Za-z0-9_]+)\s*;/.exec(tokens.slice(4).join(' '))?.[1]
476 return lexicon === undefined
477 ? { fields: tokens.slice(0, 4).join(' ') }
478 : { fields: tokens.slice(0, 4).join(' '), lexicon: lexicon.toUpperCase() }
479}
480
481/** A fetched or read file: a CGP if it opens like one, else a GCG. */
482export function sniffPosition(text: string): 'cgp' | 'gcg' {
483 return isCgp(text) ? 'cgp' : 'gcg'
484}
485
486export type Accelerators = { rit: boolean; wit: boolean }
487
488export type MagpieRun = { args: string[]; stdin: string; lexicon: string }
489
490// A plain ASCII board for `shgame` and the scan, which the mod reads.
491const ASCII_BOARD = [
492 '-boardcolor', 'none', '-boardtiles', 'primary', '-boardborder', 'ascii',
493 '-boardcolumns', 'ascii', '-onturnmarker', 'ascii',
494]
495
496// The settings every sim runs with, after `setoptions`.
497export function simOptions(lexicon: string | undefined, threads: number, accelerators: Accelerators): string[] {
498 return [
499 ...(lexicon === undefined ? [] : ['-lex', lexicon]),
500 // Polish has no word map.
501 '-wmp', String(lexicon === undefined || hasWordmap(lexicon)),
502 '-rit', String(accelerators.rit),
503 '-wit', String(accelerators.wit),
504 // Mapped, the 1.9 GB RIT costs no time to load; pages come in as used.
505 '-ritmmap', String(accelerators.rit),
506 '-s1', 'equity', '-s2', 'equity',
507 '-r1', 'all', '-r2', 'all',
508 '-numplays', String(SIM_PLAYS),
509 '-plies', String(SIM_PLIES),
510 '-iterations', String(SIM_PLAYS * ITERATIONS_PER_PLAY),
511 // Round robin with no stopping rule: every play gets its share.
512 '-sr', 'rr', '-threshold', 'none', '-scondition', 'none',
513 '-tlim', String(SIM_TIME_LIMIT_SECONDS),
514 '-pfrequency', String(SIM_PRINT_FREQUENCY),
515 '-threads', String(threads),
516 '-hr', 'false',
517 ...ASCII_BOARD,
518 '-maxnumdplays', String(SIM_PLAYS),
519 ]
520}
521
522/** A position with tiles in the bag is simmed; one with the bag empty has its
523 * endgame solved. */
524export type Mode = 'sim' | 'endgame'
525
526// The endgame solver's best first moves to list, each with its line.
527export const ENDGAME_PLAYS = 50
528// The most tiles the player to move can fail to see with the bag empty: the
529// other rack.
530const ENDGAME_UNSEEN = 7
531// An English set's tiles, which a CGP's unseen tiles are counted from when
532// its set is not known.
533const TILES_IN_SET = 100
534
535export function modeFor(unseen: number | undefined): Mode {
536 return unseen !== undefined && unseen <= ENDGAME_UNSEEN ? 'endgame' : 'sim'
537}
538
539/** The tiles a CGP's player to move cannot see: the set (`setSize` tiles)
540 * less the board's and their rack's. */
541export function cgpUnseen(cgp: string, setSize = TILES_IN_SET): number {
542 const [board = '', racks = ''] = cgp.trim().split(/\s+/)
543 const onBoard = (board.match(/\p{L}/gu) ?? []).length
544 const rack = (racks.split('/')[0] ?? '').length
545 return setSize - onBoard - rack
546}
547
548// The settings an endgame solve runs with, after `setoptions`.
549function endgameOptions(lexicon: string | undefined, threads: number): string[] {
550 return [
551 ...(lexicon === undefined ? [] : ['-lex', lexicon]),
552 '-wmp', String(lexicon === undefined || hasWordmap(lexicon)),
553 '-etopk', String(ENDGAME_PLAYS),
554 '-threads', String(threads),
555 '-hr', 'false',
556 ...ASCII_BOARD,
557 ]
558}
559
560/** The run that prints every position of a GCG: loaded at its start, then
561 * one `next` at a time, each printing the game. */
562export function scanRun(source: string, lexicon: string | undefined): MagpieRun {
563 return {
564 args: ['setoptions', ...(lexicon === undefined ? [] : ['-lex', lexicon]), '-wmp', 'false', '-hr', 'false', ...ASCII_BOARD],
565 stdin: `load ${source}\n${'next\n'.repeat(SCAN_STEPS)}`,
566 lexicon: lexicon ?? '',
567 }
568}
569
570/** A position of a GCG a player had to move from, with a known rack. */
571export type GcgPosition = {
572 /** Events played to reach it: MAGPIE's `goto` argument. */
573 events: number
574 /** Which move of the game was made from it, from 1. */
575 turn: number
576 player: string
577 rack: string
578 /** That move as MAGPIE writes it (`11H (S)ILVERED 74 DEEILRV`); none at
579 * the latest position of a game in progress. */
580 move?: string
581 /** The events played once that move was: one more than `events`, or more
582 * where MAGPIE takes a play and its challenge bonus as one step. */
583 movedTo?: number
584 /** How many tiles the player to move cannot see: the bag and the other
585 * rack; an endgame from seven or fewer. */
586 unseen?: number
587}
588
589// Each printed game opens with the column letters.
590const PRINTED_GAME = /\n(?=\s+A B C D E F G H I J K L M N O)/
591// The header or the top border, whichever row holds the on-turn marker: a
592// name, a rack and a score.
593const ON_TURN = /^\s+(?:A B C D E F G H I J K L M N O|-{30})\s+>\s+(.+?)\s{2,}([\p{L}?]{1,7})\s+-?\d+\s*$/mu
594// `Event N (player):` beside the board, and on the next row what it was.
595const EVENT = /Event (\d+) \((.*?)\):[^\n]*\n[^\n]*\|\s+([^\n]*?)\s*$/m
596
597/** The positions a scan printed that a player moved from (or, at the end of
598 * a game in progress, is to move from). An event is a move when the player
599 * who made it was the one to move: challenge bonuses, phonies taken off and
600 * end-of-game points are events by the other player, or by nobody to move. */
601export function gcgPositions(output: string): GcgPosition[] {
602 const printed = new Map<
603 number,
604 { onTurn?: { player: string; rack: string; unseen?: number }; event?: { player: string; what: string } }
605 >()
606 for (const game of output.split(PRINTED_GAME)) {
607 if (!/A B C D E F G H I J K L M N O/.test(game)) {
608 continue
609 }
610 const event = EVENT.exec(game)
611 const onTurn = ON_TURN.exec(game)
612 const unseen = /Unseen: (\d+)/.exec(game)?.[1]
613 printed.set(event === null ? 0 : Number(event[1]), {
614 onTurn:
615 onTurn === null
616 ? undefined
617 : {
618 player: onTurn[1] ?? '',
619 rack: onTurn[2] ?? '',
620 ...(unseen === undefined ? {} : { unseen: Number(unseen) }),
621 },
622 event: event === null ? undefined : { player: event[2] ?? '', what: event[3] ?? '' },
623 })
624 }
625 // `next` and `goto` step over an event MAGPIE takes with the one before
626 // (a challenge bonus with its play), so the next event printed is the
627 // next step, whatever its number.
628 const printedEvents = [...printed.keys()].sort((a, b) => a - b)
629 const positions: GcgPosition[] = []
630 for (const [index, events] of printedEvents.entries()) {
631 const onTurn = printed.get(events)?.onTurn
632 if (onTurn === undefined) {
633 continue
634 }
635 const movedTo = printedEvents[index + 1]
636 const next = movedTo === undefined ? undefined : printed.get(movedTo)?.event
637 if (movedTo === undefined) {
638 positions.push({ events, turn: positions.length + 1, ...onTurn })
639 } else if (next !== undefined && next.player === onTurn.player) {
640 positions.push({ events, turn: positions.length + 1, ...onTurn, move: next.what, movedTo })
641 }
642 }
643 return positions
644}
645
646/** A move as MAGPIE writes it, without its score and rack: `11H (S)ILVERED`,
647 * `(exch FRV)`, `(pass)`. */
648export function moveText(what: string): string {
649 return what.startsWith('(') ? what.slice(0, what.indexOf(')') + 1) : what.split(/\s+/).slice(0, 2).join(' ')
650}
651
652// A placement's square and the word it formed, the board's tiles in it, in
653// capitals: 11H, SILVERED.
654function placement(what: string | undefined): { coordinate: string; word: string } | undefined {
655 const match = /^(\d+[A-O]|[A-O]\d+)\s+(\S+)/.exec(what ?? '')
656 return match === null ? undefined : { coordinate: match[1] ?? '', word: (match[2] ?? '').replace(/[()]/g, '').toUpperCase() }
657}
658
659function moveWord(what: string | undefined): string | undefined {
660 return placement(what)?.word
661}
662
663const sortedTiles = (tiles: string) => [...tiles.toUpperCase()].sort().join('')
664
665export type Choice =
666 | { position: GcgPosition }
667 | { goto: string }
668 /** More than one position fits: the person picks. */
669 | { candidates: GcgPosition[] }
670 | { error: string }
671
672/** The position a selector names among a GCG's, or what stands in the way. */
673export function choosePosition(positions: readonly GcgPosition[], at: Selector | undefined): Choice {
674 if (at?.kind === 'goto') {
675 // Named by its turn when a player moves from there.
676 const position = positions.find(candidate => String(candidate.events) === at.goto)
677 return position === undefined ? { goto: at.goto } : { position }
678 }
679 if (positions.length === 0) {
680 return { error: 'no position in the game has a player to move with a known rack' }
681 }
682 if (at === undefined) {
683 return { position: positions[positions.length - 1] as GcgPosition }
684 }
685 if (at.kind === 'move') {
686 // The turn whose move took the game past that event.
687 const position = positions.find(
688 candidate => candidate.movedTo !== undefined && candidate.events < at.event && at.event <= candidate.movedTo,
689 )
690 return position === undefined ? { error: `the game's move ${at.event} was no one's turn` } : { position }
691 }
692 if (at.kind === 'turn') {
693 const position = positions.find(candidate => candidate.turn === at.turn)
694 return position === undefined
695 ? { error: `the game has turns 1 to ${positions.length}, not ${at.turn}` }
696 : { position }
697 }
698 const byRack = (rack: string) => positions.filter(position => sortedTiles(position.rack) === sortedTiles(rack))
699 const byWord = (word: string) => positions.filter(position => moveWord(position.move) === word)
700 if (at.kind === 'after') {
701 const played = positions.filter(position => {
702 const move = placement(position.move)
703 return move?.word === at.word && (at.coordinate === undefined || move.coordinate === at.coordinate)
704 })
705 // The rack its player held next: their following turn.
706 const following = played.flatMap(position => {
707 const next = positions.find(later => later.turn > position.turn && later.player === position.player)
708 return next === undefined ? [] : [next]
709 })
710 if (following.length === 0) {
711 return {
712 error:
713 played[0] === undefined
714 ? `no turn of the game played ${at.word}`
715 : `${at.word} was ${played[0].player}'s last turn; no rack came after it`,
716 }
717 }
718 return following.length === 1 ? { position: following[0] as GcgPosition } : { candidates: following }
719 }
720 const matches =
721 at.kind === 'rack'
722 ? byRack(at.rack)
723 : at.kind === 'word'
724 ? byWord(at.word)
725 : [...new Set([...byRack(at.letters), ...byWord(at.letters)])].sort((a, b) => a.events - b.events)
726 const what = at.kind === 'rack' ? `rack ${at.rack}` : at.kind === 'word' ? `the word ${at.word}` : at.letters
727 if (matches.length === 0) {
728 return { error: `no turn of the game ${at.kind === 'letters' ? 'held or played' : 'matches'} ${what}` }
729 }
730 return matches.length === 1 ? { position: matches[0] as GcgPosition } : { candidates: matches }
731}
732
733/** One line per candidate position, for asking which. */
734export function describePosition(position: GcgPosition): string {
735 const move = position.move === undefined ? ', the latest position' : `, who played ${moveText(position.move)}`
736 return `turn ${position.turn}: ${position.player} to move with ${position.rack}${move}`
737}
738
739/** A position already in MAGPIE's terms: a rack, a CGP's text, or a GCG's
740 * source; each with the moves played after it and the rack to move with
741 * then (a rack position's always; a CGP's or GCG's when moves followed). */
742export type Resolved =
743 | { kind: 'rack'; rack: string; after?: PlacedMove[] }
744 | { kind: 'cgp'; cgp: string; after?: PlacedMove[]; rack?: string }
745 | { kind: 'gcg'; source: string; after?: PlacedMove[]; rack?: string }
746
747/** The commands that commit moves, whoever's turn: each from a rack of its
748 * tiles and the leave its player kept, when known. `commit` reads a board
749 * tile as `.`, not in parentheses. */
750export function commitCommands(moves: readonly PlacedMove[]): string[] {
751 return moves.flatMap(move =>
752 move.coordinate === 'pass'
753 ? ['commit pass']
754 : [
755 `rack ${tilesFromRack(move.word)}${move.leave ?? ''}`,
756 `commit ${move.coordinate} ${move.word.replace(/\(([^)]*)\)/g, (_, onBoard: string) => '.'.repeat(onBoard.length))}`,
757 ],
758 )
759}
760
761/** The lexicon a run of `position` uses: the one asked for, else a CGP's
762 * own, else the person's; a game's own, unread here, is undefined. */
763export function runLexicon(position: Resolved, lexicon: string | undefined): string | undefined {
764 if (lexicon !== undefined || position.kind === 'gcg') {
765 return lexicon
766 }
767 return (position.kind === 'cgp' ? splitCgp(position.cgp).lexicon : undefined) ?? preferred
768}
769
770// The commands that reach a position, before what it is for: the board and
771// the moves after it, not the rack to move with.
772function setupCommands(position: Resolved, request: { lexicon?: string; goto?: string }) {
773 const commands: string[] = []
774 const lexicon = runLexicon(position, request.lexicon)
775 if (position.kind === 'cgp') {
776 commands.push(`cgp ${splitCgp(position.cgp).fields}`)
777 } else if (position.kind === 'gcg') {
778 // `load` leaves the game at its start.
779 commands.push(`load ${position.source}`, `goto ${request.goto ?? 'end'}`)
780 }
781 commands.push(...commitCommands(position.after ?? []))
782 return { commands, lexicon }
783}
784
785/** The run for a position: reached, its rack set, then simmed or its
786 * endgame solved. `shgame` prints the board the picture draws. */
787export function magpieRun(
788 position: Resolved,
789 request: { lexicon?: string; goto?: string },
790 threads: number,
791 accelerators: Accelerators,
792 mode: Mode = 'sim',
793): MagpieRun {
794 const { commands, lexicon } = setupCommands(position, request)
795 if (position.rack !== undefined && position.rack !== '') {
796 commands.push(`rack ${position.rack}`)
797 }
798 commands.push('shgame', mode === 'endgame' ? 'endgame' : 'gsimulate')
799 return {
800 args: ['setoptions', ...(mode === 'endgame' ? endgameOptions(lexicon, threads) : simOptions(lexicon, threads, accelerators))],
801 stdin: `${commands.join('\n')}\n`,
802 lexicon: lexicon ?? '',
803 }
804}
805
806// Every legal play listed, however many.
807const ALL_PLAYS = 10000
808
809/** The run that lists every legal play of the player to move from each of
810 * `racks`, on the board the position reaches: a move's legal placements.
811 * No wordmap: the lexicon's DAWG alone, which loads at once. */
812export function generateRun(position: Resolved, request: { lexicon?: string; goto?: string }, racks: readonly string[]): MagpieRun {
813 const { commands, lexicon } = setupCommands(position, request)
814 commands.push('shgame', ...racks.flatMap(rack => [`rack ${rack}`, 'generate']))
815 return {
816 args: [
817 'setoptions',
818 ...(lexicon === undefined ? [] : ['-lex', lexicon]),
819 '-wmp', 'false',
820 '-s1', 'equity', '-s2', 'equity', '-r1', 'all', '-r2', 'all',
821 '-numplays', String(ALL_PLAYS), '-maxnumdplays', String(ALL_PLAYS), '-shwithmoves', 'false',
822 '-hr', 'false',
823 ...ASCII_BOARD,
824 ],
825 stdin: `${commands.join('\n')}\n`,
826 lexicon: lexicon ?? '',
827 }
828}
829
830/** A legal play as `generate` lists it: `H8 (I)NERTIAS 77 77.00`. */
831export type GeneratedPlay = { coordinate: string; word: string; score: number; equity: number }
832
833const GENERATED = /^\s*\d+:\s+(\d+[A-O]|[A-O]\d+)\s+(\S+)\s+(?:\S+\s+)?(-?\d+)\s+(-?\d+(?:\.\d+)?)\s*$/
834
835/** Each `generate`'s placements (exchanges and passes left out), in order,
836 * each list closed by its `Showing N of M plays`. */
837export function parseGenerated(output: string): GeneratedPlay[][] {
838 const lists: GeneratedPlay[][] = []
839 let list: GeneratedPlay[] = []
840 for (const line of output.split('\n')) {
841 const play = GENERATED.exec(line)
842 if (play !== null) {
843 list.push({ coordinate: play[1] ?? '', word: play[2] ?? '', score: Number(play[3]), equity: Number(play[4]) })
844 } else if (/^Showing \d+ of \d+ plays/.test(line)) {
845 lists.push(list)
846 list = []
847 }
848 }
849 return lists
850}
851
852/** A move as the person described it: its word (the board's tiles in it
853 * included) and whatever narrows where it went. */
854export type MoveDescription = {
855 word: string
856 by?: 'me' | 'opponent'
857 /** `8H` across, `H8` down: where its first tile is. */
858 square?: string
859 direction?: 'across' | 'down'
860 score?: number
861 /** Board tiles it went through, each with the word on the board it is in. */
862 through?: { letter: string; word?: string }[]
863 /** A premium square one of its tiles covers: "to the TWS". */
864 premium?: 'TW' | 'DW' | 'TL' | 'DL'
865 /** The tiles its player kept: "keeping ION". */
866 leave?: string
867 /** A pass, which has no word. */
868 pass?: true
869}
870
871/** A word's letters in capitals, whatever marked the board's or the blanks. */
872export const letters = (word: string) => word.replace(/[^\p{L}]/gu, '').toUpperCase()
873
874/** Where each tile of a play goes, the board's own (in parentheses) marked. */
875export function playSquares(coordinate: string, word: string): { row: number; col: number; letter: string; isThrough: boolean }[] {
876 const across = /^(\d+)([A-O])$/.exec(coordinate)
877 const down = /^([A-O])(\d+)$/.exec(coordinate)
878 const row = Number(across?.[1] ?? down?.[2]) - 1
879 const col = COLUMN_LETTERS.indexOf(across?.[2] ?? down?.[1] ?? '')
880 const squares: { row: number; col: number; letter: string; isThrough: boolean }[] = []
881 let isThrough = false
882 for (const char of word) {
883 if (char === '(' || char === ')') {
884 isThrough = char === '('
885 continue
886 }
887 const offset = squares.length
888 squares.push({ row: row + (down !== null ? offset : 0), col: col + (across !== null ? offset : 0), letter: char, isThrough })
889 }
890 return squares
891}
892
893/** The squares of each run of tiles on `board` that spells `word`, across
894 * or down. */
895export function wordOnBoard(board: readonly string[], word: string): { row: number; col: number }[][] {
896 const target = letters(word)
897 const found: { row: number; col: number }[][] = []
898 const scan = (at: (line: number, index: number) => { row: number; col: number }) => {
899 for (let line = 0; line < BOARD_DIM; line++) {
900 let run: { row: number; col: number }[] = []
901 for (let index = 0; index <= BOARD_DIM; index++) {
902 const square = index < BOARD_DIM ? at(line, index) : undefined
903 const tile = square === undefined ? '.' : (board[square.row]?.[square.col] ?? '.')
904 if (tile !== '.' && square !== undefined) {
905 run.push(square)
906 continue
907 }
908 if (run.length > 1 && run.map(({ row, col }) => board[row]?.[col] ?? '').join('').toUpperCase() === target) {
909 found.push(run)
910 }
911 run = []
912 }
913 }
914 }
915 scan((line, index) => ({ row: line, col: index }))
916 scan((line, index) => ({ row: index, col: line }))
917 return found
918}
919
920/** Whether a legal play is the move described. `board` is the board it was
921 * played on, for what it went through. */
922export function fitsDescription(play: GeneratedPlay, description: MoveDescription, board: readonly string[]): boolean {
923 if (letters(play.word) !== letters(description.word)) {
924 return false
925 }
926 // Lowercase in what the person said marks the blanks: exactly those.
927 const said = canonicalWord(spelling(description.word))
928 if (said !== said.toUpperCase() && spelling(play.word) !== said) {
929 return false
930 }
931 if (description.square !== undefined && play.coordinate !== description.square.toUpperCase()) {
932 return false
933 }
934 if (description.direction !== undefined && (description.direction === 'across') !== /^\d/.test(play.coordinate)) {
935 return false
936 }
937 if (description.score !== undefined && play.score !== description.score) {
938 return false
939 }
940 const squares = playSquares(play.coordinate, play.word)
941 const through = squares.filter(square => square.isThrough)
942 for (const { letter, word } of description.through ?? []) {
943 const runs = word === undefined ? undefined : wordOnBoard(board, word)
944 const isThere = through.some(
945 square =>
946 square.letter.toUpperCase() === letter.toUpperCase() &&
947 (runs === undefined || runs.some(run => run.some(({ row, col }) => row === square.row && col === square.col))),
948 )
949 if (!isThere) {
950 return false
951 }
952 }
953 if (description.premium !== undefined) {
954 const premium = description.premium
955 const isOn = squares.some(square => {
956 const there = premiumAt(square.row, square.col)
957 return !square.isThrough && (there === premium || (premium === 'DW' && there === 'CENTER'))
958 })
959 if (!isOn) {
960 return false
961 }
962 }
963 return true
964}
965
966/** The racks to find a described move from, its player's tiles unknown: the
967 * word's letters less the board's it went through; a word of more than a
968 * rack's tiles tries each letter it could have gone through. */
969export function racksFor(description: MoveDescription): string[] {
970 // Case kept: a lowercase letter is a blank the person named.
971 const tiles = [...canonicalWord(spelling(description.word))]
972 for (const { letter } of description.through ?? []) {
973 const at = tiles.findIndex(tile => tile.toUpperCase() === letter.toUpperCase())
974 if (at >= 0) {
975 tiles.splice(at, 1)
976 }
977 }
978 if (tiles.length <= RACK_TILES) {
979 return [tiles.join('')]
980 }
981 if (tiles.length > RACK_TILES + 1) {
982 return []
983 }
984 // One tile was the board's: each distinct letter in turn.
985 return [...new Set(tiles)].map(letter => {
986 const rest = [...tiles]
987 rest.splice(rest.indexOf(letter), 1)
988 return rest.join('')
989 })
990}
991
992const PREMIUMS: readonly NonNullable<MoveDescription['premium']>[] = ['TW', 'DW', 'TL', 'DL']
993
994// A move of a play intent, as the interpreter wrote it.
995function parseMove(value: unknown): MoveDescription {
996 const fields = (typeof value === 'object' && value !== null ? value : {}) as Record<string, unknown>
997 const by: Pick<MoveDescription, 'by'> = fields.by === 'me' || fields.by === 'opponent' ? { by: fields.by } : {}
998 if (fields.pass === true) {
999 return { word: '', pass: true, ...by }
1000 }
1001 if (typeof fields.word !== 'string' || letters(fields.word) === '') {
1002 throw new Error('a move with no word')
1003 }
1004 const move: MoveDescription = { word: canonicalWord(spelling(fields.word)), ...by }
1005 if (typeof fields.leave === 'string' && /^[\p{L}?]{1,7}$/u.test(fields.leave)) {
1006 move.leave = fields.leave.toUpperCase()
1007 }
1008 if (typeof fields.square === 'string' && COORDINATE.test(fields.square.toUpperCase())) {
1009 move.square = fields.square.toUpperCase()
1010 }
1011 if (fields.direction === 'across' || fields.direction === 'down') {
1012 move.direction = fields.direction
1013 }
1014 if (typeof fields.score === 'number') {
1015 move.score = fields.score
1016 }
1017 if (Array.isArray(fields.through)) {
1018 move.through = fields.through.flatMap(item => {
1019 const { letter, word } = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>
1020 return typeof letter === 'string' && /^\p{L}$/u.test(letter)
1021 ? [typeof word === 'string' && word !== '' ? { letter: letter.toUpperCase(), word: letters(word) } : { letter: letter.toUpperCase() }]
1022 : []
1023 })
1024 }
1025 const premium = PREMIUMS.find(name => name === fields.premium)
1026 if (premium !== undefined) {
1027 move.premium = premium
1028 }
1029 return move
1030}
1031
1032/** A count of tiles: letters in capitals, `?` for blanks. */
1033export type Tiles = Map<string, number>
1034
1035// Which letter distribution a lexicon's tiles come from, as MAGPIE picks it
1036// (letter_distribution.h).
1037const DISTRIBUTIONS: readonly [RegExp, string][] = [
1038 [/^(CSW|NWL|OSPD|OSW|TWL|America|CEL)/i, 'english'],
1039 [/^RD/i, 'german'],
1040 [/^NSF/i, 'norwegian'],
1041 [/^DISC/i, 'catalan'],
1042 [/^FRA/i, 'french'],
1043 [/^OSPS/i, 'polish'],
1044 [/^DSW/i, 'dutch'],
1045]
1046
1047export function distributionName(lexicon: string): string | undefined {
1048 return DISTRIBUTIONS.find(([pattern]) => pattern.test(lexicon))?.[1]
1049}
1050
1051// A distribution file's rows (`A,a,9,1,...`): each tile, a Catalan one of
1052// several letters packed into one (tiles.ts), and its count and score.
1053function distributionRows(csv: string): { tile: string; count: number; score: number }[] {
1054 return csv.split('\n').flatMap(line => {
1055 const [name, , count, score] = line.trim().split(',')
1056 return name === undefined || name === '' || count === undefined
1057 ? []
1058 : [{ tile: tileOf(name).toUpperCase(), count: Number(count), score: Number(score ?? 0) }]
1059 })
1060}
1061
1062/** A distribution file: each tile and how many the set has. */
1063export function parseDistribution(csv: string): Tiles {
1064 return new Map(distributionRows(csv).map(({ tile, count }) => [tile, count]))
1065}
1066
1067/** A distribution file: what each tile scores. */
1068export function parseScores(csv: string): Readonly<Record<string, number>> {
1069 return Object.fromEntries(distributionRows(csv).map(({ tile, score }) => [tile, score]))
1070}
1071
1072/** The set less the tiles on `board` (a blank is lowercase) and `less`. */
1073export function tilesLeft(set: Tiles, board: readonly string[], less = ''): Tiles {
1074 const left = new Map(set)
1075 const take = (tile: string) => left.set(tile, (left.get(tile) ?? 0) - 1)
1076 for (const row of board) {
1077 for (const tile of row) {
1078 if (tile !== '.') {
1079 take(tile === tile.toUpperCase() ? tile : '?')
1080 }
1081 }
1082 }
1083 for (const tile of less) {
1084 take(tile === '?' || tile !== tile.toUpperCase() ? '?' : tile)
1085 }
1086 return left
1087}
1088
1089const countOf = (tiles: string) => {
1090 const counts: Tiles = new Map()
1091 for (const tile of tiles) {
1092 counts.set(tile, (counts.get(tile) ?? 0) + 1)
1093 }
1094 return counts
1095}
1096
1097/** The letters of `tiles` (a `?` a blank) that `left` cannot supply. */
1098export function missingTiles(tiles: string, left: Tiles): { tile: string; need: number; have: number }[] {
1099 return [...countOf(tiles.toUpperCase())].flatMap(([tile, need]) => {
1100 const have = Math.max(0, left.get(tile) ?? 0)
1101 return need > have ? [{ tile, need, have }] : []
1102 })
1103}
1104
1105/** A rack for `needed` from what is left, a blank standing in for each
1106 * letter short; or which letter cannot be had even with the blanks. A
1107 * lowercase letter is one the person said was a blank. */
1108export function withBlanks(needed: string, left: Tiles): { rack: string } | { short: { tile: string; need: number; have: number; blanks: number } } {
1109 const blanks = Math.max(0, left.get('?') ?? 0)
1110 const marked = [...needed].filter(tile => tile !== tile.toUpperCase()).length
1111 if (marked > blanks) {
1112 return { short: { tile: '?', need: marked, have: blanks, blanks: 0 } }
1113 }
1114 let used = marked
1115 let rack = '?'.repeat(marked)
1116 const plain = [...needed].filter(tile => tile === tile.toUpperCase()).join('')
1117 for (const [tile, need] of countOf(plain)) {
1118 const have = Math.max(0, left.get(tile) ?? 0)
1119 const excess = Math.max(0, need - have)
1120 if (used + excess > blanks) {
1121 return { short: { tile, need, have, blanks: Math.max(0, blanks - used) } }
1122 }
1123 used += excess
1124 rack += tile.repeat(need - excess) + '?'.repeat(excess)
1125 }
1126 return { rack }
1127}
1128
1129/** A word's letters, case kept where the person said which were blanks. */
1130export function spelling(word: string): string {
1131 return word.replace(/[^\p{L}]/gu, '')
1132}
1133
1134/** `board` with a play's own tiles on it (its board tiles are there). */
1135export function applyMove(board: readonly string[], coordinate: string, word: string): string[] {
1136 const rows = board.map(row => [...row])
1137 for (const square of playSquares(coordinate, word)) {
1138 const row = rows[square.row]
1139 if (!square.isThrough && row !== undefined) {
1140 row[square.col] = square.letter
1141 }
1142 }
1143 return rows.map(row => row.join(''))
1144}
1145
1146/** What stays of `rack` once `tiles` leave it (a blank as `?`), or null when
1147 * it does not hold them. */
1148export function rackLess(rack: string, tiles: string): string | null {
1149 const left = [...rack.toUpperCase()]
1150 for (const tile of tiles.toUpperCase()) {
1151 const at = left.indexOf(tile)
1152 if (at < 0) {
1153 return null
1154 }
1155 left.splice(at, 1)
1156 }
1157 return left.join('')
1158}
1159
1160const sortTiles = (tiles: string) => [...tiles.toUpperCase()].sort().join('')
1161
1162/** Whether two racks hold the same tiles, in any order. */
1163export function sameTiles(a: string, b: string): boolean {
1164 return sortTiles(a) === sortTiles(b)
1165}
1166
1167const plural = (count: number, tile: string) =>
1168 `${count === 0 ? 'no' : count} ${tile === '?' ? 'blank' : tile}${count === 1 ? '' : 's'}`
1169
1170/** Why a word cannot be had from what is left: "PIZZAZZ needs 4 Zs, and
1171 * only 1 Z and 2 blanks are left". */
1172export function shortText(word: string, short: { tile: string; need: number; have: number; blanks: number }): string {
1173 if (short.tile === '?') {
1174 return `${word} needs ${plural(short.need, '?')}, and only ${plural(short.have, '?')} ${short.have === 1 ? 'is' : 'are'} left`
1175 }
1176 return `${word} needs ${plural(short.need, short.tile)}, and only ${plural(short.have, short.tile)} and ${plural(short.blanks, '?')} are left`
1177}
1178
1179/** Why a rack cannot be had: "no blank is left", "only 1 H is left". */
1180export function missingText(missing: readonly { tile: string; need: number; have: number }[]): string {
1181 return missing
1182 .map(({ tile, have }) => (have === 0 ? `no ${tile === '?' ? 'blank' : tile} is left` : `only ${plural(have, tile)} ${have === 1 ? 'is' : 'are'} left`))
1183 .join(', ')
1184}
1185
1186const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth']
1187
1188/** Which of a play's tiles are its blanks, by place among the word's same
1189 * letters: "the second Z", "the A", "the first E and the S". */
1190export function blankText(word: string): string {
1191 const plain = letters(word)
1192 const named: string[] = []
1193 let index = 0
1194 let isThrough = false
1195 for (const char of word) {
1196 if (char === '(' || char === ')') {
1197 isThrough = char === '('
1198 continue
1199 }
1200 const letter = char.toUpperCase()hooks/png.ts 216 lines1// Writes a picture as a PNG, compressed here: the plugin's environment has no
2// deflate, so this is one made for these pictures, whose colours run flat.
3// Each byte is matched against the pixel before it and the row above, the
4// matches and the rest in deflate's fixed Huffman codes: one quick pass, no
5// search. Truecolour, 8 bits a channel, alpha kept when asked.
6
7const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
8const ADLER_MODULUS = 65521
9// The most bytes summed before Adler-32's sums must be reduced.
10const ADLER_BLOCK = 5552
11const MIN_MATCH = 3
12const MAX_MATCH = 258
13const END_OF_BLOCK = 256
14
15const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => {
16 let crc = index
17 for (let bit = 0; bit < 8; bit++) {
18 crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
19 }
20 return crc >>> 0
21})
22
23function crc32(bytes: Uint8Array): number {
24 let crc = 0xffffffff
25 for (let index = 0; index < bytes.length; index++) {
26 crc = CRC_TABLE[(crc ^ bytes[index]!) & 0xff]! ^ (crc >>> 8)
27 }
28 return (crc ^ 0xffffffff) >>> 0
29}
30
31function adler32(bytes: Uint8Array): number {
32 let low = 1
33 let high = 0
34 for (let start = 0; start < bytes.length; start += ADLER_BLOCK) {
35 const end = Math.min(bytes.length, start + ADLER_BLOCK)
36 for (let index = start; index < end; index++) {
37 low += bytes[index]!
38 high += low
39 }
40 low %= ADLER_MODULUS
41 high %= ADLER_MODULUS
42 }
43 return ((high << 16) | low) >>> 0
44}
45
46// A Huffman code goes in most significant bit first, the bits deflate packs
47// least significant first: each code reversed once, here.
48function reversed(code: number, bits: number): number {
49 let out = 0
50 for (let bit = 0; bit < bits; bit++) {
51 out = (out << 1) | ((code >>> bit) & 1)
52 }
53 return out
54}
55
56// The fixed codes of the literal and length symbols, 0 to 287.
57const SYMBOL_BITS = Uint8Array.from({ length: 288 }, (_, symbol) => (symbol < 144 ? 8 : symbol < 256 ? 9 : symbol < 280 ? 7 : 8))
58const SYMBOL_CODES = Uint16Array.from({ length: 288 }, (_, symbol) =>
59 reversed(
60 symbol < 144 ? 0x30 + symbol : symbol < 256 ? 0x190 + symbol - 144 : symbol < 280 ? symbol - 256 : 0xc0 + symbol - 280,
61 SYMBOL_BITS[symbol]!,
62 ),
63)
64
65const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
66const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
67const DISTANCE_BASE = [
68 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289,
69 16385, 24577,
70]
71const DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
72// The farthest back a match may reach.
73const WINDOW = 32768
74
75// Each match length's code, looked up.
76const LENGTH_CODE = new Uint8Array(MAX_MATCH + 1)
77for (const [code, base] of LENGTH_BASE.entries()) {
78 const last = code === LENGTH_BASE.length - 1 ? MAX_MATCH : base + (1 << LENGTH_EXTRA[code]!) - 1
79 for (let length = base; length <= last; length++) {
80 LENGTH_CODE[length] = code
81 }
82}
83
84// A match distance as deflate writes it: its code, reversed, and the extra
85// bits past the code's base.
86function distanceParts(distance: number) {
87 let code = 0
88 while (code < DISTANCE_BASE.length - 1 && DISTANCE_BASE[code + 1]! <= distance) {
89 code++
90 }
91 return { code: reversed(code, 5), extra: distance - DISTANCE_BASE[code]!, extraBits: DISTANCE_EXTRA[code]! }
92}
93
94/** `data` deflated in one block of fixed codes, a match sought only at
95 * `near` and `above` bytes back: a pixel's width and a row's. */
96function deflate(data: Uint8Array, near: number, above: number): Uint8Array {
97 // At worst every byte a 9-bit literal.
98 const out = new Uint8Array(Math.ceil((data.length * 9) / 8) + 64)
99 let length = 0
100 let buffer = 0
101 let count = 0
102 const write = (value: number, bits: number) => {
103 buffer |= value << count
104 count += bits
105 while (count >= 8) {
106 out[length++] = buffer & 0xff
107 buffer >>>= 8
108 count -= 8
109 }
110 }
111 const writeSymbol = (symbol: number) => write(SYMBOL_CODES[symbol]!, SYMBOL_BITS[symbol]!)
112 const candidates = [near, above].filter(distance => distance > 0 && distance <= WINDOW)
113 const parts = candidates.map(distanceParts)
114
115 write(1, 1) // the last block
116 write(1, 2) // in fixed codes
117 let at = 0
118 while (at < data.length) {
119 const limit = Math.min(MAX_MATCH, data.length - at)
120 let best = 0
121 let bestIndex = 0
122 for (let index = 0; index < candidates.length && limit >= MIN_MATCH; index++) {
123 const from = at - candidates[index]!
124 if (from < 0) {
125 continue
126 }
127 let matched = 0
128 while (matched < limit && data[from + matched] === data[at + matched]) {
129 matched++
130 }
131 if (matched > best) {
132 best = matched
133 bestIndex = index
134 }
135 }
136 if (best >= MIN_MATCH) {
137 const code = LENGTH_CODE[best]!
138 writeSymbol(257 + code)
139 write(best - LENGTH_BASE[code]!, LENGTH_EXTRA[code]!)
140 const distance = parts[bestIndex]!
141 write(distance.code, 5)
142 write(distance.extra, distance.extraBits)
143 at += best
144 } else {
145 writeSymbol(data[at]!)
146 at++
147 }
148 }
149 writeSymbol(END_OF_BLOCK)
150 if (count > 0) {
151 out[length++] = buffer & 0xff
152 }
153 return out.subarray(0, length)
154}
155
156function uint32(value: number): number[] {
157 return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]
158}
159
160function chunk(type: string, data: Uint8Array): Uint8Array {
161 const typed = new Uint8Array(4 + data.length)
162 typed.set([...type].map(char => char.charCodeAt(0)))
163 typed.set(data, 4)
164 const out = new Uint8Array(12 + data.length)
165 out.set(uint32(data.length))
166 out.set(typed, 4)
167 out.set(uint32(crc32(typed)), 8 + data.length)
168 return out
169}
170
171// A zlib stream: its header, the deflated data, the data's Adler-32.
172function zlib(data: Uint8Array, near: number, above: number): Uint8Array {
173 const deflated = deflate(data, near, above)
174 const out = new Uint8Array(2 + deflated.length + 4)
175 out.set([0x78, 0x01])
176 out.set(deflated, 2)
177 out.set(uint32(adler32(data)), 2 + deflated.length)
178 return out
179}
180
181/** The PNG of an RGBA picture, its alpha dropped unless `hasAlpha`. */
182export function encodePng(width: number, height: number, rgba: Uint8Array, hasAlpha = false): Uint8Array {
183 const channels = hasAlpha ? 4 : 3
184 const rowBytes = 1 + width * channels
185 const raw = new Uint8Array(rowBytes * height)
186 for (let row = 0; row < height; row++) {
187 // Filter type 0 (none), then the row's pixels.
188 let to = row * rowBytes + 1
189 const end = (row + 1) * width * 4
190 for (let from = row * width * 4; from < end; from += 4) {
191 raw[to] = rgba[from]!
192 raw[to + 1] = rgba[from + 1]!
193 raw[to + 2] = rgba[from + 2]!
194 if (hasAlpha) {
195 raw[to + 3] = rgba[from + 3]!
196 }
197 to += channels
198 }
199 }
200 // Colour type 6 is truecolour with alpha, 2 without.
201 const header = new Uint8Array([...uint32(width), ...uint32(height), 8, hasAlpha ? 6 : 2, 0, 0, 0])
202 const parts = [
203 new Uint8Array(SIGNATURE),
204 chunk('IHDR', header),
205 chunk('IDAT', zlib(raw, channels, rowBytes)),
206 chunk('IEND', new Uint8Array()),
207 ]
208 const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
209 let at = 0
210 for (const part of parts) {
211 out.set(part, at)
212 at += part.length
213 }
214 return out
215}
216hooks/setup.ts 361 lines1import type { EngineInterface } from 'claude-code'
2
3// Installing MAGPIE for the mod: its executable (downloaded where one is
4// published for the computer, else built from its source), then its data,
5// which MAGPIE fetches itself, then the word map of the person's lexicon.
6
7/** The MAGPIE release the mod installs. */
8export const MAGPIE_VERSION = '0.1.3'
9const RELEASES = 'https://github.com/jvc56/MAGPIE/releases/download'
10const SOURCE = `https://github.com/jvc56/MAGPIE/archive/refs/tags/v${MAGPIE_VERSION}.tar.gz`
11// The folder the source tarball unpacks to.
12const SOURCE_FOLDER = `MAGPIE-${MAGPIE_VERSION}`
13
14export type Os = 'macos' | 'linux' | 'windows'
15export type Arch = 'x64' | 'arm64'
16export type Host = { os: Os; arch: Arch }
17
18// The executables published for MAGPIE_VERSION, with the SHA-256 published
19// beside each. A computer with none builds MAGPIE from its source.
20const EXECUTABLES: Partial<Record<`${Os}-${Arch}`, { file: string; sha256: string }>> = {
21 'windows-x64': {
22 file: `magpie-${MAGPIE_VERSION}-windows-x64.exe`,
23 sha256: '2d493ebd0f76b435bf26679d8e4c40d426ebf6906e596b4b69b47d1279b84ee6',
24 },
25 'windows-arm64': {
26 file: `magpie-${MAGPIE_VERSION}-windows-arm64.exe`,
27 sha256: '5aae3aa75c4ac982553e1f44fc5b6312aea444f35b54075f5ebf8761f5cd9610',
28 },
29}
30
31/** Whether an executable is published for the computer; if not, MAGPIE is
32 * built from its source there. */
33export function isPublished(host: Host): boolean {
34 return EXECUTABLES[`${host.os}-${host.arch}`] !== undefined
35}
36
37/** The languages MAGPIE plays in, each by its own name and its English one,
38 * with its lexicons (the ones MAGPIE's data holds), newest and most played
39 * first. */
40export const LANGUAGES: readonly { name: string; english: string; lexica: readonly string[] }[] = [
41 {
42 name: 'English',
43 english: 'English',
44 lexica: ['CSW24', 'NWL23', 'CSW21', 'NWL20', 'TWL14', 'CSW15', 'TWL06', 'CSW12', 'CSW07', 'TWL98', 'OSWI', 'OSW4', 'OSW3', 'OSW2', 'OSW1'],
45 },
46 { name: 'Français', english: 'French', lexica: ['FRA20'] },
47 { name: 'Deutsch', english: 'German', lexica: ['RD29'] },
48 { name: 'Nederlands', english: 'Dutch', lexica: ['DSW25'] },
49 { name: 'Català', english: 'Catalan', lexica: ['DISC2'] },
50 { name: 'Polski', english: 'Polish', lexica: ['OSPS49'] },
51]
52
53/** A language's English name, English for one not listed. */
54export function englishNameOf(language: string): string {
55 return LANGUAGES.find(candidate => candidate.name === language)?.english ?? 'English'
56}
57
58/** The language a lexicon is in, English for one not listed. */
59export function languageOf(lexicon: string): string {
60 return LANGUAGES.find(language => language.lexica.includes(lexicon.toUpperCase()))?.name ?? 'English'
61}
62
63/** A language's first lexicon, CSW24 for one not listed. */
64export function firstLexicon(language: string): string {
65 return LANGUAGES.find(candidate => candidate.name === language)?.lexica[0] ?? 'CSW24'
66}
67
68/** Whether MAGPIE builds a word map for the lexicon: Polish has none. */
69export function hasWordmap(lexicon: string): boolean {
70 return !/^OSPS/i.test(lexicon)
71}
72
73/** The computer the session runs on. */
74export async function hostOf($: EngineInterface): Promise<Host> {
75 if ((await $.env.get('OS')) === 'Windows_NT') {
76 // A 32-bit process on 64-bit Windows sees the machine's in the first.
77 const arch = (await $.env.get('PROCESSOR_ARCHITEW6432')) ?? (await $.env.get('PROCESSOR_ARCHITECTURE')) ?? ''
78 return { os: 'windows', arch: /arm64/i.test(arch) ? 'arm64' : 'x64' }
79 }
80 const { stdout } = await $.process.run(['uname', '-sm'])
81 const [kernel = '', machine = ''] = stdout.trim().split(/\s+/)
82 return { os: kernel === 'Darwin' ? 'macos' : 'linux', arch: /arm64|aarch64/i.test(machine) ? 'arm64' : 'x64' }
83}
84
85/** The person's home folder: HOME, or on Windows USERPROFILE. */
86export async function homeOf($: EngineInterface): Promise<string> {
87 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
88}
89
90/** Where the mod installs MAGPIE unless told otherwise: on Windows the folder
91 * MAGPIE itself looks in (%LOCALAPPDATA%\MAGPIE), elsewhere ~/.magpie. */
92export async function defaultMagpieDir($: EngineInterface, host: Host): Promise<string> {
93 const local = host.os === 'windows' ? await $.env.get('LOCALAPPDATA') : undefined
94 return local === undefined || local === '' ? `${await homeOf($)}/.magpie` : `${local}\\MAGPIE`
95}
96
97/** The executable in a MAGPIE folder. */
98export function executableIn(dir: string, host: Host): string {
99 return `${dir}/bin/magpie${host.os === 'windows' ? '.exe' : ''}`
100}
101
102/** A step of the install, as the setup pane lists it. */
103export type SetupStep = {
104 key: 'magpie' | 'data' | 'wordmap'
105 label: string
106 state: 'waiting' | 'running' | 'done' | 'failed'
107 detail: string
108}
109
110/** How the install goes: its steps, and why it stopped, with what the pane
111 * offers to do about it (`tools`: install Apple's command-line tools); once
112 * done, how saving the choices went. */
113export type SetupProgress = {
114 steps: SetupStep[]
115 problem: string
116 remedy?: 'tools'
117 isRunning: boolean
118 isDone: boolean
119 saved?: string
120}
121
122export type SetupPlan = { host: Host; dir: string; lexicon: string; threads: number }
123
124/** A stop the person can do something about: what, and what the pane offers. */
125class SetupProblem extends Error {
126 readonly remedy?: 'tools'
127
128 constructor(message: string, remedy?: 'tools') {
129 super(message)
130 this.remedy = remedy
131 }
132}
133
134export function setupSteps(plan: SetupPlan): SetupStep[] {
135 return [
136 { key: 'magpie', label: `MAGPIE ${MAGPIE_VERSION}`, state: 'waiting', detail: '' },
137 { key: 'data', label: 'Word lists and strategy files (about 195 MB)', state: 'waiting', detail: '' },
138 { key: 'wordmap', label: `The ${plan.lexicon} word map`, state: 'waiting', detail: '' },
139 ]
140}
141
142/** Installs what the plan's folder lacks, one step after another, telling
143 * `report` each change. Resolves whether it all went. */
144export async function installMagpie(
145 $: EngineInterface,
146 plan: SetupPlan,
147 report: (progress: SetupProgress) => Promise<void>,
148): Promise<boolean> {
149 const progress: SetupProgress = { steps: setupSteps(plan), problem: '', isRunning: true, isDone: false }
150 const tell = () => report({ ...progress, steps: progress.steps.map(step => ({ ...step })) })
151 let current: SetupStep | undefined
152 const step = async (key: SetupStep['key'], work: (say: (detail: string) => Promise<void>) => Promise<void>) => {
153 current = progress.steps.find(candidate => candidate.key === key)
154 if (current === undefined) {
155 return
156 }
157 const shown = current
158 shown.state = 'running'
159 await tell()
160 await work(async detail => {
161 shown.detail = detail
162 await tell()
163 })
164 shown.state = 'done'
165 await tell()
166 }
167 try {
168 await step('magpie', say => getExecutable($, plan, say))
169 await step('data', say => getData($, plan, say))
170 await step('wordmap', say => buildWordmap($, plan, say))
171 progress.isDone = true
172 } catch (error) {
173 if (current !== undefined) {
174 current.state = 'failed'
175 }
176 progress.problem = error instanceof Error ? error.message : String(error)
177 if (error instanceof SetupProblem && error.remedy !== undefined) {
178 progress.remedy = error.remedy
179 }
180 }
181 progress.isRunning = false
182 await tell()
183 return progress.isDone
184}
185
186// A command that must succeed; its error output is the reason when not.
187async function mustRun($: EngineInterface, argv: readonly string[], what: string, cwd?: string): Promise<string> {
188 const result = await $.process.run(argv, { cwd, timeoutMs: 600_000 }).catch((error: unknown) => {
189 throw new Error(`could not ${what}: ${error instanceof Error ? error.message : String(error)}`)
190 })
191 if (result.exitCode !== 0) {
192 const said = `${result.stderr}\n${result.stdout}`.trim().split('\n').slice(-3).join(' ')
193 throw new Error(`could not ${what}${said === '' ? '' : `: ${said}`}`)
194 }
195 return result.stdout
196}
197
198/** A path as Windows' own commands take it. */
199export const windowsPath = (path: string) => path.replaceAll('/', '\\')
200
201async function sha256Of($: EngineInterface, host: Host, path: string): Promise<string> {
202 if (host.os === 'windows') {
203 // certutil prints the hash on a line of its own, spaced on old Windows.
204 const said = await mustRun($, ['certutil', '-hashfile', windowsPath(path), 'SHA256'], 'check the download')
205 return (said.split('\n').map(line => line.replace(/\s/g, '')).find(line => /^[0-9a-f]{64}$/i.test(line)) ?? '').toLowerCase()
206 }
207 const said = await mustRun($, host.os === 'macos' ? ['shasum', '-a', '256', path] : ['sha256sum', path], 'check the download')
208 return said.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
209}
210
211async function remove($: EngineInterface, host: Host, path: string) {
212 await $.process
213 .run(host.os === 'windows' ? ['cmd', '/d', '/c', 'del', '/f', '/q', windowsPath(path)] : ['rm', '-f', path])
214 .catch(() => undefined)
215}
216
217async function getExecutable($: EngineInterface, plan: SetupPlan, say: (detail: string) => Promise<void>) {
218 const executable = executableIn(plan.dir, plan.host)
219 if (await $.fs.exists(executable)) {
220 await say(`already in ${plan.dir}`)
221 return
222 }
223 const published = EXECUTABLES[`${plan.host.os}-${plan.host.arch}`]
224 if (published === undefined) {
225 await buildFromSource($, plan, say)
226 return
227 }
228 await say(`downloading ${published.file}`)
229 await mustRun($, ['curl', '-fsSL', '--create-dirs', '-o', executable, `${RELEASES}/v${MAGPIE_VERSION}/${published.file}`], 'download MAGPIE')
230 const sha256 = await sha256Of($, plan.host, executable)
231 if (sha256 !== published.sha256) {
232 await remove($, plan.host, executable)
233 throw new Error(`the download of ${published.file} is not the one published (SHA-256 ${sha256 || 'unknown'}), so it was deleted`)
234 }
235 await say(`downloaded to ${plan.dir}`)
236}
237
238// What building needs, said plainly when it is missing.
239async function checkTools($: EngineInterface, host: Host) {
240 if (host.os === 'macos') {
241 const tools = await $.process.run(['xcode-select', '-p']).catch(() => undefined)
242 if (tools?.exitCode !== 0) {
243 throw new SetupProblem(
244 "No MAGPIE is published for Macs yet, so it is built here, which needs Apple's command-line tools. Press Get the tools, finish Apple's installer, then press Install again.",
245 'tools',
246 )
247 }
248 return
249 }
250 const found = await $.process
251 .run(['sh', '-c', 'command -v make && command -v cc && command -v curl && command -v tar'])
252 .catch(() => undefined)
253 if (found?.exitCode !== 0) {
254 throw new SetupProblem(
255 'No MAGPIE is published for Linux yet, so it is built here, which needs make, a C compiler, curl and tar. On Debian or Ubuntu: sudo apt install build-essential curl. On Fedora: sudo dnf install make gcc curl tar. Then press Install again.',
256 )
257 }
258}
259
260// One `make` of MAGPIE, telling how many files it has compiled. Resolves
261// whether it built, and the last of what it said.
262async function make(
263 $: EngineInterface,
264 source: string,
265 targets: readonly string[],
266 say: (detail: string) => Promise<void>,
267): Promise<{ isBuilt: boolean; said: string }> {
268 let said = ''
269 let compiled = 0
270 const stream = $.process.spawn({ argv: ['make', ...targets], cwd: source })
271 for await (const { text } of stream) {
272 said = `${said}${text}`.slice(-4000)
273 const count = text.split('\n').filter(line => / -c /.test(line)).length
274 if (count > 0) {
275 compiled += count
276 await say(`building: ${compiled} files compiled`)
277 }
278 if (/-o bin\/magpie\b/.test(text)) {
279 await say(`building: ${compiled} files compiled, now linking (the slow part)`)
280 }
281 }
282 const { code } = await stream.result
283 return { isBuilt: code === 0, said }
284}
285
286async function buildFromSource($: EngineInterface, plan: SetupPlan, say: (detail: string) => Promise<void>) {
287 await checkTools($, plan.host)
288 const work = `${plan.dir}/build`
289 const tarball = `${work}/magpie-${MAGPIE_VERSION}.tar.gz`
290 const source = `${work}/${SOURCE_FOLDER}`
291 await say('downloading the source')
292 await mustRun($, ['curl', '-fsSL', '--create-dirs', '-o', tarball, SOURCE], 'download the source')
293 await mustRun($, ['tar', '-xzf', tarball, '-C', work], 'unpack the source')
294 await say('building (about a minute)')
295 // The build for this very computer, or failing that the portable one the
296 // published executables are.
297 let build = await make($, source, ['magpie', 'BUILD=no_pgo_release'], say)
298 if (!build.isBuilt) {
299 await say('building again, the portable way')
300 const march = plan.host.arch === 'arm64' ? 'armv8-a' : 'nehalem'
301 build = await make($, source, ['magpie', 'BUILD=portable_release', `PORTABLE_MARCH=${march}`], say)
302 }
303 if (!build.isBuilt) {
304 const why = build.said.trim().split('\n').filter(line => /error/i.test(line)).slice(-2).join(' ')
305 throw new Error(`MAGPIE did not build${why === '' ? '' : `: ${why}`}`)
306 }
307 await mustRun($, ['mkdir', '-p', `${plan.dir}/bin`], 'make the bin folder')
308 await mustRun($, ['cp', `${source}/bin/magpie`, executableIn(plan.dir, plan.host)], 'put MAGPIE in place')
309 await $.process.run(['rm', '-rf', work]).catch(() => undefined)
310 await say(`built in ${plan.dir}`)
311}
312
313// MAGPIE's own fetch: every language's word lists, each chunk checked
314// against its SHA-256, unpacked file by file.
315async function getData($: EngineInterface, plan: SetupPlan, say: (detail: string) => Promise<void>) {
316 const kwg = `${plan.dir}/data/lexica/${plan.lexicon}.kwg`
317 if (await $.fs.exists(kwg)) {
318 await say('already there')
319 return
320 }
321 let said = ''
322 const stream = $.process.spawn({ argv: [executableIn(plan.dir, plan.host), 'data', 'fetch'], cwd: plan.dir })
323 for await (const { text } of stream) {
324 said = `${said}${text}`.slice(-4000)
325 const line = said.replaceAll('\r', '\n').trim().split('\n').at(-1)?.trim()
326 if (line !== undefined && line !== '') {
327 await say(line)
328 }
329 }
330 const { code } = await stream.result
331 if (code !== 0) {
332 const why = said.trim().split('\n').slice(-2).join(' ')
333 throw new Error(`the data did not download (exit ${code})${why === '' ? '' : `: ${why}`}`)
334 }
335 if (!(await $.fs.exists(kwg))) {
336 throw new Error(`the data downloaded, but holds no ${plan.lexicon}`)
337 }
338 await say('downloaded')
339}
340
341// MAGPIE builds a lexicon's word map the first time it loads it; loading it
342// here spares the first sim the wait.
343async function buildWordmap($: EngineInterface, plan: SetupPlan, say: (detail: string) => Promise<void>) {
344 if (!hasWordmap(plan.lexicon)) {
345 await say('none for Polish')
346 return
347 }
348 const wordmap = `${plan.dir}/data/lexica/${plan.lexicon}.wmp`
349 if (await $.fs.exists(wordmap)) {
350 await say('already built')
351 return
352 }
353 await say('building (a few seconds)')
354 const args = ['setoptions', '-lex', plan.lexicon, '-wmp', 'true', '-threads', String(plan.threads)]
355 await mustRun($, [executableIn(plan.dir, plan.host), ...args], `build the ${plan.lexicon} word map`, plan.dir)
356 if (!(await $.fs.exists(wordmap))) {
357 throw new Error(`MAGPIE did not build the ${plan.lexicon} word map`)
358 }
359 await say('built')
360}
361hooks/sim.ts 444 lines1// Reads a sim as MAGPIE prints it with `-hr false`: a line per simmed play,
2// then a summary. The columns are src/str/sim_string.c's UCGI ones: rank,
3// move, leave, score, Ig, Wp, WpSE, Eq, EqSE, StEq, It, then each ply's
4// score mean, score stdev and bingo%. Lines starting `# lexicon` name the
5// lexicon, which the output leaves out.
6
7import { BOARD_DIM, COLUMN_LETTERS } from './layout'
8import { packTiles, unpackTiles } from './tiles'
9
10export type PlyStats = { score: number; stdev: number; bingoPct: number }
11
12export type Play = {
13 /** Where the sim ranked it: by win%. */
14 rank: number
15 /** As the sim lists it: `8F DROP`, `exch Q`, `pass`. */
16 id: string
17 kind: 'place' | 'exchange' | 'pass'
18 /** The word, `.` for a tile already on the board and lowercase for a
19 * blank; for an exchange the tiles thrown back; empty for a pass. */
20 tiles: string
21 row: number
22 col: number
23 isDown: boolean
24 leave: string
25 score: number
26 winPct: number
27 winSe: number
28 equity: number
29 equitySe: number
30 staticEquity: number
31 iterations: number
32 plies: PlyStats[]
33 /** An endgame's: the final spread for the player to move, what the line
34 * nets them, and the rest of the line (a going-out play's end points). */
35 result?: number
36 net?: number
37 line?: string
38}
39
40export type Sim = {
41 /** A Monte Carlo sim, or an endgame solved (the bag empty). */
42 kind: 'sim' | 'endgame'
43 /** Sorted, blanks last, as MAGPIE writes a rack. */
44 rack: string
45 lexicon: string
46 /** What the person named: a rack, a file, a URL. */
47 label: string
48 /** The position's rows, `.` empty and lowercase a blank; null when the
49 * output printed no board (an opening rack). */
50 board: string[] | null
51 plays: Play[]
52 /** A sim's plies, or the endgame solver's depth. */
53 plies: number
54 iterations: number
55 seconds: number
56 status: string
57}
58
59const PLAY_LINE = /^\s*(\d+):\s+(.*)$/
60// `shgame`'s header or top border, whichever holds the on-turn marker: a
61// name, a rack and a score.
62const ON_TURN = /^\s+(?:A B C D E F G H I J K L M N O|-{30})\s+>\s+(.+?)\s{2,}([\p{L}?]{1,7})\s+-?\d+\s*$/mu
63const ENDGAME_HEADER = /^Endgame \(depth: (\d+), time: ([\d.]+)s, status: ([^)]*)\)/
64// An endgame row's first move: square, word, score, and a going-out play's
65// end points in parentheses.
66const ENDGAME_MOVE = /^(pass|\S+\s+\S+)\s+(-?\d+)(?:\s+(\(.*\)))?$/
67// The Ig column: X for a play the threshold found too close to the best.
68const IGNORED_MARKS = new Set(['-', 'X'])
69// Wp, WpSE, Eq, EqSE, StEq, It.
70const STAT_COLUMNS = 6
71const PLY_COLUMNS = 3
72
73function numberAt(tokens: readonly string[], at: number, line: string): number {
74 const value = Number(tokens[at])
75 if (tokens[at] === undefined || Number.isNaN(value)) {
76 throw new Error(`no number in column ${at + 1} of "${line.trim()}"`)
77 }
78 return value
79}
80
81// `8F` is across, row first; `H8` is down, column first.
82function parseCoordinates(text: string): { row: number; col: number; isDown: boolean } {
83 const across = /^(\d+)([A-Z])$/.exec(text)
84 const down = /^([A-Z])(\d+)$/.exec(text)
85 const [rowText, colText, isDown] =
86 across !== null ? [across[1], across[2], false] : down !== null ? [down[2], down[1], true] : []
87 const row = Number(rowText) - 1
88 const col = COLUMN_LETTERS.indexOf(colText ?? '')
89 if (isDown === undefined || row < 0 || row >= BOARD_DIM || col < 0) {
90 throw new Error(`no square at ${text}`)
91 }
92 return { row, col, isDown }
93}
94
95// MAGPIE writes the tiles a play goes through in parentheses, `(S)ILVERED`;
96// a `.` stands for each, as in a GCG.
97function boardTilesAsDots(word: string): string {
98 return word.replace(/\(([^)]*)\)/g, (_, onBoard: string) => '.'.repeat(onBoard.length))
99}
100
101export function parsePlayLine(line: string): Play | null {
102 const match = PLAY_LINE.exec(line)
103 if (match === null) {
104 return null
105 }
106 const tokens = (match[2] ?? '').trim().split(/\s+/)
107 let at = 0
108 let play: Pick<Play, 'id' | 'kind' | 'tiles' | 'row' | 'col' | 'isDown'>
109 if (tokens[0] === '(exch') {
110 const tiles = (tokens[1] ?? '').replace(/\)$/, '')
111 play = { id: `exch ${tiles}`, kind: 'exchange', tiles, row: 0, col: 0, isDown: false }
112 at = 2
113 } else if (tokens[0] === 'pass') {
114 play = { id: 'pass', kind: 'pass', tiles: '', row: 0, col: 0, isDown: false }
115 at = 1
116 } else {
117 const word = tokens[1] ?? ''
118 play = { id: `${tokens[0]} ${word}`, kind: 'place', tiles: boardTilesAsDots(word), ...parseCoordinates(tokens[0] ?? '') }
119 at = 2
120 }
121 // A play that spends the whole rack leaves an empty leave cell, which the
122 // split drops: the score is whatever stands before the Ig mark.
123 const ignoredAt = tokens.findIndex((token, index) => index > at && IGNORED_MARKS.has(token))
124 if (ignoredAt < 0) {
125 throw new Error(`no Ig column in "${line.trim()}"`)
126 }
127 const leave = ignoredAt - at === 2 ? (tokens[at] ?? '') : ''
128 const stats = ignoredAt + 1
129 const plyCount = Math.floor((tokens.length - stats - STAT_COLUMNS) / PLY_COLUMNS)
130 const plies: PlyStats[] = []
131 for (let ply = 0; ply < plyCount; ply++) {
132 const first = stats + STAT_COLUMNS + ply * PLY_COLUMNS
133 plies.push({
134 score: numberAt(tokens, first, line),
135 stdev: numberAt(tokens, first + 1, line),
136 bingoPct: numberAt(tokens, first + 2, line),
137 })
138 }
139 return {
140 ...play,
141 rank: Number(match[1]),
142 leave,
143 score: numberAt(tokens, ignoredAt - 1, line),
144 winPct: numberAt(tokens, stats, line),
145 winSe: numberAt(tokens, stats + 1, line),
146 equity: numberAt(tokens, stats + 2, line),
147 equitySe: numberAt(tokens, stats + 3, line),
148 staticEquity: numberAt(tokens, stats + 4, line),
149 iterations: numberAt(tokens, stats + 5, line),
150 plies,
151 }
152}
153
154/** The tiles a play takes from the rack: a blank as `?`. */
155export function spentTiles(play: Play): string {
156 if (play.kind === 'exchange') {
157 return play.tiles
158 }
159 return [...play.tiles]
160 .filter(letter => letter !== '.')
161 .map(letter => (letter === letter.toLowerCase() ? '?' : letter))
162 .join('')
163}
164
165function sortRack(tiles: string): string {
166 // `?` sorts before the letters in ASCII; MAGPIE writes blanks last.
167 return [...tiles].sort((a, b) => (a === '?' ? 1 : b === '?' ? -1 : a.localeCompare(b))).join('')
168}
169
170// A row of `shgame`'s ASCII board (-pretty false): its number, a bar, then
171// two characters a square, the first a letter or a premium mark.
172const BOARD_ROW = new RegExp(`^\\s?(\\d{1,2})\\|(.{${BOARD_DIM * 2}})\\|`)
173
174/** The board rows `shgame` printed, the last board printed winning. */
175export function parseBoard(lines: readonly string[]): string[] | null {
176 let board: string[] | null = null
177 for (const line of lines) {
178 const match = BOARD_ROW.exec(line)
179 if (match === null) {
180 continue
181 }
182 const row = Number(match[1]) - 1
183 if (row === 0) {
184 board = []
185 }
186 if (board !== null && row === board.length) {
187 const squares = match[2] ?? ''
188 board.push(
189 Array.from({ length: BOARD_DIM }, (_, col) => {
190 const char = squares[col * 2] ?? ' '
191 return /\p{L}/u.test(char) ? char : '.'
192 }).join(''),
193 )
194 }
195 }
196 return board?.length === BOARD_DIM ? board : null
197}
198
199/** The rack the player to move holds, as the last game printed shows it. */
200function onTurnRack(text: string): string | undefined {
201 const racks = [...text.matchAll(new RegExp(ON_TURN.source, 'gmu'))]
202 return racks[racks.length - 1]?.[2]
203}
204
205/** An endgame as MAGPIE's solver prints it: a header with its depth and
206 * time, then a row per first move, its columns where the headings start:
207 * the move (and a going-out play's end points), V (what the line nets), S
208 * (the final spread), then each later turn of the line. */
209function parseEndgame(lines: readonly string[], at: number, rack: string): Pick<Sim, 'plays' | 'plies' | 'seconds' | 'status'> {
210 const header = ENDGAME_HEADER.exec(lines[at] ?? '')
211 const headings = lines[at + 1] ?? ''
212 const starts = [...headings.matchAll(/Play|V|S|Turn \d+/g)].map(match => match.index)
213 const [, netAt = 0, resultAt = 0, ...turnsAt] = starts
214 const plays: Play[] = []
215 for (let index = at + 2; index < lines.length && (lines[index] ?? '').trim() !== ''; index++) {
216 const row = lines[index] ?? ''
217 const cell = (from: number, to: number | undefined) => row.slice(from, to).trim()
218 const move = ENDGAME_MOVE.exec(cell(0, netAt))
219 if (move === null) {
220 continue
221 }
222 const what = move[1] ?? ''
223 const isPass = what === 'pass'
224 const [square = '', word = ''] = what.split(/\s+/)
225 const tiles = isPass ? '' : boardTilesAsDots(word)
226 const later = turnsAt.map((from, turn) => cell(from, turnsAt[turn + 1])).filter(turn => turn !== '')
227 const placed = isPass ? { row: 0, col: 0, isDown: false } : parseCoordinates(square)
228 const play: Play = {
229 rank: plays.length + 1,
230 id: isPass ? 'pass' : `${square} ${word}`,
231 kind: isPass ? 'pass' : 'place',
232 tiles,
233 ...placed,
234 leave: '',
235 score: Number(move[2]),
236 winPct: 0,
237 winSe: 0,
238 equity: 0,
239 equitySe: 0,
240 staticEquity: 0,
241 iterations: 0,
242 plies: [],
243 result: Number(cell(resultAt, turnsAt[0])),
244 net: Number(cell(netAt, resultAt)),
245 line: [move[3] ?? '', ...later].filter(part => part !== '').join(' · '),
246 }
247 play.leave = leaveAfter(rack, play)
248 plays.push(play)
249 }
250 return {
251 plays,
252 plies: Number(header?.[1] ?? 0),
253 seconds: Number(header?.[2] ?? 0),
254 status: header?.[3] ?? '',
255 }
256}
257
258// What stays on the rack once the play's tiles leave it.
259function leaveAfter(rack: string, play: Play): string {
260 const ghosts = rackGhosts(rack, play)
261 return [...rack].filter((_, index) => ghosts[index] !== true).join('')
262}
263
264export function parseSim(printed: string): Sim {
265 // A Catalan tile is one letter here (tiles.ts), as MAGPIE's own output
266 // has it by now; a file's still has it in brackets.
267 const text = packTiles(printed)
268 const lines = text.split('\n')
269 const endgameAt = lines.findIndex(line => ENDGAME_HEADER.test(line))
270 if (endgameAt >= 0) {
271 const rack = sortRack(onTurnRack(text) ?? '')
272 const endgame = parseEndgame(lines, endgameAt, rack)
273 if (endgame.plays.length === 0) {
274 throw new Error('the endgame solver printed no plays')
275 }
276 return {
277 kind: 'endgame',
278 rack,
279 lexicon: /^#\s*lexicon\s+(\S+)/m.exec(text)?.[1] ?? '',
280 label: /^#\s*label\s+(.*?)\s*$/m.exec(text)?.[1] ?? '',
281 board: parseBoard(lines),
282 iterations: 0,
283 ...endgame,
284 }
285 }
286 // The sim's table is the run of play lines just before `Showing N of M
287 // plays`; a move list printed earlier (by `shgame`) is another run.
288 let plays: Play[] = []
289 let run: Play[] = []
290 let lastRun: Play[] = []
291 let lexicon = ''
292 let label = ''
293 let plies = 0
294 let iterations = 0
295 let seconds = 0
296 let status = ''
297 for (const line of lines) {
298 const header = /^#\s*(lexicon|label)\s+(.*?)\s*$/.exec(line)
299 const shownPlays = /^Showing \d+ of \d+ plays/.test(line)
300 const shownPlies = /^Showing \d+ of (\d+) plies/.exec(line)
301 const summary = /^(Iters|Time|Status):\s+(.*?)\s*$/.exec(line)
302 const play = header === null ? parsePlayLine(line) : null
303 if (play !== null) {
304 run.push(play)
305 continue
306 }
307 if (run.length > 0) {
308 lastRun = run
309 run = []
310 }
311 if (header !== null) {
312 if (header[1] === 'lexicon') {
313 lexicon = header[2] ?? ''
314 } else {
315 label = header[2] ?? ''
316 }
317 } else if (shownPlays) {
318 plays = lastRun
319 } else if (shownPlies !== null) {
320 plies = Number(shownPlies[1])
321 } else if (summary !== null) {
322 const value = summary[2] ?? ''
323 if (summary[1] === 'Iters') {
324 iterations = Number(value)
325 } else if (summary[1] === 'Time') {
326 seconds = Number.parseFloat(value)
327 } else {
328 status = value
329 }
330 }
331 }
332 const first = plays[0]
333 if (first === undefined) {
334 throw new Error('the output holds no simmed plays: was it printed with -hr false?')
335 }
336 return {
337 kind: 'sim',
338 // The player to move's, as shgame printed it; else every play's spent
339 // tiles and leave make it, the first's as well as any.
340 rack: sortRack(onTurnRack(text) ?? spentTiles(first) + first.leave),
341 lexicon,
342 label,
343 board: parseBoard(lines),
344 plays,
345 plies: plies || first.plies.length,
346 iterations,
347 seconds,
348 status,
349 }
350}
351
352/** Where each placed tile lands, the board's own tiles skipped. */
353export function placedTiles(play: Play): { row: number; col: number; letter: string }[] {
354 if (play.kind !== 'place') {
355 return []
356 }
357 return [...play.tiles].flatMap((letter, offset) =>
358 letter === '.'
359 ? []
360 : [{ row: play.row + (play.isDown ? offset : 0), col: play.col + (play.isDown ? 0 : offset), letter }],
361 )
362}
363
364// Which rack tiles the play spends, in rack order: the first unspent tile of
365// each letter it takes, as the TUI ghosts them.
366export function rackGhosts(rack: string, play: Play): boolean[] {
367 const needed = [...spentTiles(play)]
368 return [...rack].map(letter => {
369 const at = needed.indexOf(letter)
370 if (at < 0) {
371 return false
372 }
373 needed.splice(at, 1)
374 return true
375 })
376}
377
378/** What the picture shows, in words, for the surfaces that cannot see it. */
379export function describePlay(play: Play): string {
380 if (play.kind === 'exchange') {
381 return unpackTiles(`Exchange ${play.tiles}, keeping ${play.leave || 'nothing'}`)
382 }
383 if (play.kind === 'pass') {
384 return 'Pass'
385 }
386 return unpackTiles(`The board with ${play.id} placed, ${play.score} points`)
387}
388
389/** A play as people write it: `8F DROP`, an exchange `-EO`, `pass`. */
390export function playName(play: Play): string {
391 return play.kind === 'exchange' ? `-${play.tiles}` : play.id
392}
393
394/** A play as the table spells it: its square (`-` before an exchange's
395 * tiles, `pass`) and its tiles, the board's own (in parentheses) marked. */
396export function playTiles(play: Play): { label: string; tiles: { letter: string; isThrough: boolean }[] } {
397 if (play.kind === 'exchange') {
398 return { label: '-', tiles: [...play.tiles].map(letter => ({ letter, isThrough: false })) }
399 }
400 if (play.kind === 'pass') {
401 return { label: 'pass', tiles: [] }
402 }
403 const [label = '', word = ''] = play.id.split(' ')
404 const tiles: { letter: string; isThrough: boolean }[] = []
405 let isThrough = false
406 for (const char of word) {
407 if (char === '(' || char === ')') {
408 isThrough = char === '('
409 } else {
410 tiles.push({ letter: char, isThrough })
411 }
412 }
413 return { label, tiles }
414}
415
416/** An endgame's result as players write it: W+20 a win by 20, L-10 a loss
417 * by 10, T=0 a tie. */
418export function resultText(result: number | undefined): string {
419 if (result === undefined) {
420 return ''
421 }
422 return result > 0 ? `W+${result}` : result < 0 ? `L${result}` : 'T=0'
423}
424
425/** The latest whole results table in a sim's output so far, as parseSim
426 * reads it: MAGPIE, asked to print as it goes (`-pfrequency`), prints the
427 * board, then a table each so often, each ending with its `Nps:` line; the
428 * board goes before the latest. `count` is how many tables have ended;
429 * undefined before the first has. */
430export function latestTable(output: string): { text: string; count: number } | undefined {
431 const ends = [...output.matchAll(/^Nps:.*$/gm)].map(match => match.index + match[0].length)
432 const end = ends[ends.length - 1]
433 if (end === undefined) {
434 return undefined
435 }
436 const start = ends[ends.length - 2] ?? 0
437 const table = output.slice(start, end)
438 if (start === 0) {
439 return { text: table, count: ends.length }
440 }
441 const firstRow = output.search(/^\d+:\s/m)
442 return { text: `${firstRow < 0 ? '' : output.slice(0, firstRow)}${table.replace(/^\n/, '')}`, count: ends.length }
443}
444hooks/svg.ts 71 lines1// The board as SVG for the surfaces without an Image: Desktop, VS Code and
2// the mobile app, which draws it in a web view. The layout of board.ts, each
3// glyph its Fira Code outline as a path, defined once and placed with <use>,
4// so no surface needs the font.
5
6import { drawBoard, type Canvas } from './board'
7import type { Play } from './sim'
8import { PICTURE_HEIGHT, PICTURE_WIDTH, type Rgb } from './theme'
9import { inkBox, walkOutline, type Font, type Outline } from './truetype'
10
11function css(color: Rgb): string {
12 return `rgb(${color.map(Math.round).join(',')})`
13}
14
15const round = (value: number) => Math.round(value * 10) / 10
16
17// The outline as path data in font units, y up.
18function pathData(outline: Outline): string {
19 const parts: string[] = []
20 walkOutline(outline, {
21 moveTo: (x, y) => parts.push(`M${round(x)} ${round(y)}`),
22 lineTo: (x, y) => parts.push(`L${round(x)} ${round(y)}`),
23 quadTo: (controlX, controlY, x, y) => parts.push(`Q${round(controlX)} ${round(controlY)} ${round(x)} ${round(y)}`),
24 close: () => parts.push('Z'),
25 })
26 return parts.join('')
27}
28
29function svgCanvas(font: Font, defs: Map<string, string>, body: string[]): Canvas {
30 return {
31 rect: (x, y, width, height, color) => {
32 body.push(`<rect x="${round(x)}" y="${round(y)}" width="${round(width)}" height="${round(height)}" fill="${css(color)}"/>`)
33 },
34 diamond: (centerX, centerY, radius, color) => {
35 const points = [
36 [centerX, centerY - radius],
37 [centerX + radius, centerY],
38 [centerX, centerY + radius],
39 [centerX - radius, centerY],
40 ]
41 body.push(`<polygon points="${points.map(point => point.map(round).join(',')).join(' ')}" fill="${css(color)}"/>`)
42 },
43 glyph: (char, size, isBold, inkLeft, baseline, color) => {
44 const outline = font.outline(char.codePointAt(0) ?? 0, isBold)
45 const scale = size / font.unitsPerEm
46 const ink = inkBox(outline, scale)
47 if (ink === null) {
48 return
49 }
50 const id = `g${char.codePointAt(0)}${isBold ? 'b' : ''}`
51 if (!defs.has(id)) {
52 defs.set(id, `<path id="${id}" d="${pathData(outline)}"/>`)
53 }
54 // The pixel picture puts the ink's floored left edge at inkLeft.
55 const originX = inkLeft - Math.floor(ink.minX)
56 const transform = `translate(${round(originX)} ${round(baseline)}) scale(${scale.toFixed(5)} ${(-scale).toFixed(5)})`
57 body.push(`<use href="#${id}" transform="${transform}" fill="${css(color)}"/>`)
58 },
59 }
60}
61
62export function boardSvg(font: Font, board: readonly string[] | null, rack: string, play: Play): string {
63 const defs = new Map<string, string>()
64 const body: string[] = []
65 drawBoard(svgCanvas(font, defs, body), font, board, rack, play)
66 return (
67 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PICTURE_WIDTH} ${PICTURE_HEIGHT}" width="${PICTURE_WIDTH}" height="${PICTURE_HEIGHT}">` +
68 `<defs>${[...defs.values()].join('')}</defs>${body.join('')}</svg>`
69 )
70}
71hooks/theme.ts 64 lines1// The board's colours (tui/theme.c's THEME_DARK) and geometry, shared by the
2// terminal's pixel picture and the SVG the other surfaces draw.
3
4import { BOARD_DIM, RACK_SIZE, type Premium } from './layout'
5
6export type Rgb = readonly [number, number, number]
7
8export const BACKGROUND: Rgb = [0, 0, 0]
9export const BOARD: Rgb = [10, 14, 18]
10export const EMPTY_SQUARE: Rgb = [18, 22, 20]
11export const LABEL: Rgb = [110, 130, 120]
12export const TILE_FG: Rgb = [130, 230, 160]
13export const TILE_BG: Rgb = [30, 50, 35]
14// A preview tile is the player's hue blended halfway into the background.
15export const PREVIEW_BG: Rgb = [
16 (TILE_FG[0] + BACKGROUND[0]) / 2,
17 (TILE_FG[1] + BACKGROUND[1]) / 2,
18 (TILE_FG[2] + BACKGROUND[2]) / 2,
19]
20// Behind the table's selected row, its text and its tiles' picture alike:
21// slate blue, apart from the tiles' greens.
22export const SELECTED_ROW: Rgb = [56, 82, 120]
23// A spent rack tile: grey on the background.
24export const GHOST_FG: Rgb = [102, 102, 102]
25// A blank's letter, on whatever tile it is (tui/theme.c's blank_tile_fg).
26export const BLANK_FG: Rgb = [170, 170, 170]
27export const PREMIUM_COLORS: Readonly<Record<Premium, { bg: Rgb; fg: Rgb }>> = {
28 TW: { bg: [91, 35, 47], fg: [204, 117, 133] },
29 DW: { bg: [124, 63, 77], fg: [216, 154, 160] },
30 TL: { bg: [31, 68, 88], fg: [88, 139, 168] },
31 DL: { bg: [51, 94, 122], fg: [125, 168, 200] },
32 CENTER: { bg: [124, 63, 77], fg: [216, 154, 160] },
33}
34
35export const SQUARE = 36
36export const GAP = 2
37// Room for the row numbers at the left and the column letters on top.
38export const LEFT = 20
39export const TOP = 14
40const RIGHT = 8
41const BOTTOM = 8
42const RACK_GAP = 12
43const RACK_TILE_GAP = 4
44
45export const BOARD_SPAN = BOARD_DIM * SQUARE + (BOARD_DIM - 1) * GAP
46export const PICTURE_WIDTH = LEFT + BOARD_SPAN + RIGHT
47export const PICTURE_HEIGHT = TOP + BOARD_SPAN + RACK_GAP + SQUARE + BOTTOM
48export const RACK_Y = TOP + BOARD_SPAN + RACK_GAP
49
50// Centred for a full rack, so a shorter one sits where a full one would.
51const RACK_SPAN = RACK_SIZE * SQUARE + (RACK_SIZE - 1) * RACK_TILE_GAP
52
53export function squareX(col: number): number {
54 return LEFT + col * (SQUARE + GAP)
55}
56
57export function squareY(row: number): number {
58 return TOP + row * (SQUARE + GAP)
59}
60
61export function rackTileX(index: number): number {
62 return LEFT + (BOARD_SPAN - RACK_SPAN) / 2 + index * (SQUARE + RACK_TILE_GAP)
63}
64hooks/tiles.ts 52 lines1// Tiles of more than one letter, Catalan's, which MAGPIE writes in brackets:
2// [L·L], [NY], [QU], a blank's in lowercase ([ny]). Inside the mod each is
3// one letter, a cased one no set MAGPIE plays has, so that a rack or a word
4// is a string with a letter per tile and a blank is its lowercase, as for
5// every other set. MAGPIE's output is packed so as it comes in, what goes to
6// MAGPIE or is shown is unpacked.
7
8const MULTI_LETTER: readonly { name: string; tile: string }[] = [
9 { name: 'L·L', tile: 'Ŀ' },
10 { name: 'NY', tile: 'Ñ' },
11 { name: 'QU', tile: 'Ꝗ' },
12]
13
14// L·L also as a person (or the model reading them) may write it in
15// brackets, its dot left out or a full stop: [LL] is no other tile.
16const BRACKETED = /\[(L[·.]?L|NY|QU)\]/giu
17const PACKED = /[ĿŀÑñꝖꝗ]/gu
18
19/** The one letter standing for a tile named by its letters (`L·L`), as a
20 * letter distribution names it; a single letter is itself. */
21export function tileOf(written: string): string {
22 const name = /^l[·.]?l$/iu.test(written) ? written.replace(/^(.)[·.]?(.)$/u, '$1·$2') : written
23 const upper = name.toUpperCase()
24 const tile = MULTI_LETTER.find(multi => multi.name === upper)?.tile
25 if (tile === undefined) {
26 return name
27 }
28 return name === upper ? tile : tile.toLowerCase()
29}
30
31/** A tile's letters, as it is drawn: `L·L` for Catalan's, a blank's in
32 * lowercase. */
33export function tileLabel(tile: string): string {
34 const upper = tile.toUpperCase()
35 const name = MULTI_LETTER.find(multi => multi.tile === upper)?.name
36 if (name === undefined) {
37 return tile
38 }
39 return tile === upper ? name : name.toLowerCase()
40}
41
42/** Text with each bracketed tile packed into its one letter. */
43export function packTiles(text: string): string {
44 return text.replace(BRACKETED, (_, name: string) => tileOf(name))
45}
46
47/** Text with each packed tile written back in brackets, as MAGPIE takes it
48 * and people read it. */
49export function unpackTiles(text: string): string {
50 return text.replace(PACKED, tile => `[${tileLabel(tile)}]`)
51}
52