SLOPSHOPPER

magpie-sim

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…

newpaneguardcommandprompttool
v0.6.0no licenseupdated 2026-10-19olaugh/claude-mods/magpie-sim
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · magpie-sim
│ ┃ MAGPIE setup ✕ › fix the failing auth test and add an audit log call │ ┃ MAGPIE runs the sims, and it is not │ ┃ installed yet. Pick the language and lexicon ⏺ Read(src/auth.ts) │ ┃ you play in, then press Install: it builds ⎿ Read 6 lines │ ┃ MAGPIE 0.1.3 from its source (about a ⏺ Update(src/auth.ts) │ ┃ minute), then its word lists (about 195 MB), ⎿ Added 2 lines, removed 1 line │ ┃ then builds the word map of your lexicon. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Language language-Englishlanguage-Françaisl │ ┃ Lexicon lexicon-CSW24lexicon-NWL23lexicon- ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Folder /Users/dev/.magpie (change it in… │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Install ] [ Not now ] │ › /magpie │ ⎿ magpie-sim: MAGPIE, which runs the sims, is not installed yet. O │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · MAGPIE setup
MAGPIE runs the sims, and it is not installed yet. Pick the language and lexicon you play in, then press Install: it builds MAGPIE 0.1.3 from its source (about a minute), then its word lists (about 195 MB), then builds the word map of your lexicon. Language language-Englishlanguage-Françaislanguage-Deutschl Lexicon lexicon-CSW24lexicon-NWL23lexicon-CSW21lexicon-NWL Folder /Users/dev/.magpie (change it in /config, magpie… [ Install ] [ Not now ]
Pane · magpie-results
The pane failed to draw: Error: ENOENT: no such file /plugins/magpie-sim/sims/ADOPQRR-CSW24.txt
README

claude-mods

My personal mods for Claude Code. The repository is a plugin marketplace: each folder is one mod.

Install

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.

Mods

turn-copy

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.

  • Terminal: the icons are Nerd Font glyphs (Material 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.
  • Desktop app: shows ⧉ Copy / ✓ Copied.

magpie-sim

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.

  • Installs MAGPIE itself. Installing the mod asks the language you play in (English, Français, Deutsch, Nederlands, Català, Polski) and your lexicon, the one a position is simmed in when it names none. The first session without MAGPIE opens a setup pane with the same choices and an Install button, which gets MAGPIE 0.1.3 (on Windows the published .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.
  • Languages: every set MAGPIE plays. Tiles with accents are tiles of their own (Ä, Ł, Ç), drawn with their own set's scores; Catalan's two-letter tiles are written as MAGPIE writes them, [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 lives in ~/.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.
  • Terminal: the board is an image, drawn by kitty graphics (kitty, Ghostty). Desktop and VS Code get SVG.
  • Phone: the Claude app following a session over Remote Control draws no pane, so a /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.
  • The tiles are Fira Code (the MAGPIE TUI's font, OFL, in assets/), rasterized by the mod itself.

Agents (Claude sessions) installing or writing mods: see AGENTS.md.

Developing

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).

Source 14 files
hooks/register.tsx 2177 lines
1import { 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 lines
1// 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}
152
hooks/board.ts 208 lines
1// 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}
208
hooks/draw.ts 139 lines
1// 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}
139
hooks/layout.ts 39 lines
1// 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}
39
hooks/magpie.ts 1210 lines
1// 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 lines
1// 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}
216
hooks/setup.ts 361 lines
1import 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}
361
hooks/sim.ts 444 lines
1// 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}
444
hooks/svg.ts 71 lines
1// 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}
71
hooks/theme.ts 64 lines
1// 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}
64
hooks/tiles.ts 52 lines
1// 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