SLOPSHOPPER

chessus

Play chess against Claude inside Claude Code: pick the model and effort, review every game move by move

newpanecommandtoastmodelnetwork
v0.1.2MITupdated 2026-10-08walangstudio/chessus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chessus
│ ┃ Chessus ✕ › fix the failing auth test and add an audit log call │ ┃ 1: board 2: [new game] 3: replays 4: sett │ ┃ ⏺ Read(src/auth.ts) │ ┃ Start a: ‹ Casual game › ⎿ Read 6 lines │ ┃ White: ‹ You › ⏺ Update(src/auth.ts) │ ┃ Black: ‹ Sonnet 5.5 › Effort: ‹ medium › ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Game options ⎿ 3 pass, 1 fail │ ┃ │ ┃ Time control ‹ Untimed › ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Rules ‹ FIDE standard › │ ┃ Claude clock ‹ Thinking time counts › ✻ Worked for 42s · done 4:20 PM │ ┃ Opponent mid-game ‹ Fixed for the game › │ ┃ x: swap colors s: start game › /chess │ ⎿ chessus: Chessus opened. Start a game from "new" (key 1), or /ch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chessus
1: board 2: [new game] 3: replays 4: settings 0: close Start a: ‹ Casual game › White: ‹ You › Black: ‹ Sonnet 5.5 › Effort: ‹ medium › Game options Time control ‹ Untimed › Rules ‹ FIDE standard › Claude clock ‹ Thinking time counts › Opponent mid-game ‹ Fixed for the game › x: swap colors s: start game
README

chessus!

tests version

chessus! is chess inside Claude Code. Play Claude, watch two Claude models play each other, run a tournament between them, or replay famous games. Every side is either you or a Claude model at the effort you pick.

It's a mod: a plugin built on Claude Code's function hooks. The board lives in a pane next to your session, so you can play while Claude works on something else.

Requirements

  • Claude Code 2.1.291 or later. Older builds are untested.
  • Mods turned on. They're still early access. Add this to the env block of ~/.claude/settings.json:
  "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  • The board draws in the terminal and in Claude Desktop's Code tab. Mobile and VS Code get the move list only.

Cost

Experimental. Use at your own risk. Every Claude move is a model call billed to your account. The spend chessus! shows is an estimate at Anthropic's API list prices (or your managed modelPricing rates). It is not your bill and can differ from it. On a Pro or Max subscription, calls may count against your usage limits or extra usage. The tournament cap pauses on the estimate, not on real charges. Check actual cost and usage in your Anthropic Console or claude.ai account. walangstudio is not responsible for charges or usage incurred.

Install

/plugin marketplace add walangstudio/marketplace
/plugin install chessus@walangstudio

Restart Claude Code, then /chess.

Working from a checkout instead: claude --plugin-dir path/to/chessus, or put the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. That second way also reloads the mod every time you save a file.

Quick start

/chess haiku low

That opens a game against Haiku at low effort, you as White. Click a piece, then a dotted square. Claude answers on the board, and as a toast if the pane is hidden.

You can also type moves from the chat: /chess move e4.

Moving pieces

Moves use standard chess notation (SAN):

  • No letter means a pawn: e4, exd5, e8=Q.
  • Pieces are capitals: N knight, B bishop, R rook, Q queen, K king. So Nf3 means "the knight that can reach f3".
  • Castling is O-O (short) or O-O-O (long). 0-0 and lowercase work too.
  • If two of the same piece can reach a square, say which one: Nbd2, R1e2. chessus! tells you when that's needed and lists the options.
  • From-to also works: e2e4, e2-e4, e7e8q.

With the board focused (click it), the arrow keys move a cursor and Enter or Space picks.

The pane

The top row has four places, plus standings while a tournament runs:

  • board: the game. The board is on the left. The sidebar shows the mode (CASUAL GAME, TOURNAMENT, or REPLAY), both players and their clocks, » next to whoever is to move, and the moves so far. The move list keeps its full height from the first move, so nothing below it shifts as the game goes on. Under it sits STATUS: whose turn it is, and when a game ends, a GAME OVER banner with who won and how. Claude's spend shows under the game keys, with the budget cap in a tournament.
  • new game: switch between Casual game and Tournament, set up the sides or players, and adjust this game's options (time control, rules, Claude's clock, switching the opponent mid-game, tournament format). They start at your default settings. Starting over a game in progress asks what happens to it: abandon it (it stays under replays to watch, not to continue), keep it to resume later from replays, or cancel.
  • replays: famous games, online search, and everything you've played or imported. Open a saved game and press x to delete it, or delete them all from the bottom of the list. Both ask first.
  • settings: your defaults for new games: time control, rules, Claude's clock, pace for Claude vs Claude, tournament format and budget cap.

Pickers are arrow buttons, ‹ value ›. Click them, or Tab to one and press Enter.

Keys

With the pane focused. If your keys keep going to Claude Code's prompt instead, press ctrl+x then Tab to move focus to the pane; Esc moves it back. The key row under the board only shows what fits the moment.

During a game:

KeyDoes
ppause or resume
ssave to resume later (it goes under replays)
ccopy the game as PGN, to share
doffer Claude a draw
mclaim a draw (threefold repetition or 50-move rule), when you can
xresign (asks first)
rretry, if Claude couldn't be reached
vflip the board

Replays, and a game that just ended:

KeyDoes
a b f estart, back, forward, end
rresume, for a game you saved mid-play
nnew game, after a game ends
ttournament standings
xdelete a saved game (asks first)
qclose the replay

Anywhere: 1 board, 2 new game, 3 replays, 4 settings, 5 standings while a tournament runs, 0 close (mid-game it offers to pause and save first). Esc hands the keyboard back to the prompt.

Commands

/chess help prints this list in the chat. /chess runs at once, even while Claude is mid-reply, so a game never waits on the chat.

Play

CommandDoes
/chessOpen the pane
/chess <model> [effort]New game, you (White) vs Claude: /chess opus high. Models: fable, opus, sonnet, haiku. Efforts: low, medium, high, xhigh, max; medium when left out
/chess move <move>Play a move: e4, Nf3, O-O, exd5, e8=Q or e2e4
/chess pauseFreeze both clocks and Claude
/chess resumeCarry on a paused game
/chess saveSave the game in progress to resume later; open it under replays and press r
/chess resignResign, after a yes/no question in Claude Code's own picker
/chess closeClose the pane. Mid-game it pauses and saves first

Drive the pane from the chat

CommandDoes
/chess keysList every key, picker and field the pane shows right now
/chess key <key>Press one of the pane's keys: /chess key 2 opens new game. A key that asks first asks in Claude Code's yes/no picker
/chess set <name> <value>Change a picker or fill a field: /chess set tc 5+3

Screens

CommandDoes
/chess replaysFamous games, online search, saved games. /chess library does the same
/chess tournamentTournament setup, or standings if one is running
/chess settingsYour defaults for new games

Games and files

CommandDoes
/chess lastReplay your last finished game
/chess search <words>Find official over-the-board events on Lichess and download their games: /chess search candidates
/chess player <username>Load a Chess.com player's latest month of games
/chess import <url or file>Load PGN from a Lichess game or study link, any PGN URL, or a local .pgn file
/chess export <path>Write every saved game to one PGN file
/chess helpThis list

An unfinished casual game survives a restart. It comes back paused, and the time you were away doesn't count.

Rules

FIDE Laws of Chess, using chess.js for move generation, plus the parts it doesn't cover:

  • Fivefold repetition and the 75-move rule end the game on their own. Threefold repetition and the 50-move rule have to be claimed (m).
  • Running out of time loses, unless the other side can't possibly mate. Then it's a draw.
  • If Claude's reply has no move in it at all, it's asked again, up to three times, without a penalty. That's a formatting slip, not a chess mistake.
  • If Claude names a move that isn't legal under FIDE standard rules: in rapid and classical the opponent gets 2 extra minutes and a second one loses; in blitz and bullet the first one loses; untimed, the first one is a warning. The casual preset lets Claude retry 3 times, then plays a random legal move.
  • Claude's thinking time counts on its clock by default. Turn that off in settings if you'd rather it didn't.

Tournaments

2 to 8 players, any mix of you and Claude models. Single or double round-robin (double plays each pairing twice, colours swapped), with colours balanced. Standings score 1, ½ and 0, broken by Sonneborn-Berger, then head-to-head, then wins.

Claude costs money, so tournaments have a budget cap ($5 by default). The tournament pauses once Claude's spend reaches it. Spend is checked before each Claude call, moves and draw offers alike, so only a call already under way can take it past the cap. Raise the cap in settings and resume. Before you start, you get an estimate based on what each model and effort has cost you so far.

Replays

chessus! comes with five famous games: Légal's Mate (1750), the Immortal Game (1851), the Evergreen Game (1852), the Opera Game (1858), and Rodolfo Tan Cardoso's win over a 14-year-old Bobby Fischer in New York, 1957. That last one is the only game Fischer lost to a Filipino. Its moves come from chessgames.com, and the tests replay every bundled game to its real ending.

You can also search for games online:

  • Lichess broadcasts cover official over-the-board events like the Candidates, Olympiads and national championships. Search, pick an event, and its games download.
  • Chess.com gives you a player's most recent month of games.

One import adds at most 30 games and never the same game twice. Chess960 and games set up from a custom position are skipped, since a replay always starts from the normal opening position.

Known issues

  • herdr: board clicks don't register. A click never reaches the board; the likely cause, unconfirmed, is that Claude Code switches to pixel mouse reporting while the board is shown and herdr doesn't pass those reports through. Double-clicks select text instead, and since the pane never takes the keyboard, hotkeys land in Claude's prompt. Run chessus outside herdr, or drive it from the chat: /chess keys lists what the pane shows, /chess key and /chess set work it, and /chess move e4 plays.

Develop

claude plugin validate .
claude plugin test .

For type-checking, load the mod once (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS) so Claude Code writes .claude-plugin/types/ and a tsconfig.json, then run npx -p typescript@5 tsc -p ..

How it's laid out:

  • hooks/register.tsx: commands, state, the move loop, the screens. The only file that uses $.
  • hooks/game.ts: rules, clocks, PGN.
  • hooks/claude.ts: prompts and reading Claude's moves.
  • hooks/tournament.ts: pairings and standings.
  • hooks/board.tsx: the board.
  • hooks/classics.ts: the bundled famous games.

License

MIT, © walangstudio. That covers the code of chessus! itself.

hooks/vendor/chess.js is chess.js 1.4.0 by Jeff Hlywa, used unmodified under the BSD 2-Clause License. Its copyright notice and license text stay with it, in the file's header and in hooks/vendor/LICENSE-chess.js. Game scores (the moves of famous games) are historical facts, not code.

Source 8 files
hooks/register.tsx 1555 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Color, CostStat, Effort, Game, Prefs, Review, SavedGame, Screen, Side, Tournament, View } from '../types'
5import { CLASSICS, classicPgn, classicTermination } from './classics'
6import { drawPrompt, isAcceptance, movePrompt, namesAMove, pickMove } from './claude'
7import type { Ask } from './claude'
8import {
9  EFFORTS, MODELS, TIME_CONTROLS, agreeDraw, cleanText, importGames, pgnUrl, applyMove, claimDraw, claimable, flagIfOut, freeze, fromPgn, illegalMove,
10  isClockRunning, isTimed, nameOf, newGame, other, priceUsd, remaining, replay, resign, sideOf, toPgn, turnOf, unfreeze,
11} from './game'
12import type { NewGame } from './game'
13import { estimateUsd, nextPairing, roundRobin, standings } from './tournament'
14
15const PANE = 'chessus'
16const MAX_SAVED = 200
17const MAX_ENTRANTS = 8
18const LIBRARY_ROWS = 15
19const MAX_FOUND = 10
20// Settings labels sit in one column this wide, so every value and hint lines up.
21const SETTINGS_LABEL_WIDTH = 24
22const OPTION_LABEL_WIDTH = 20
23// One import adds at most this many games (the newest in the file) and reads at most this much text.
24const MAX_IMPORT_ONCE = 30
25const MAX_IMPORT_CHARS = 2_000_000
26const TICK_MS = 500
27const HELP = `Chessus commands
28
29Play
30  /chess                      open the pane
31  /chess <model> [effort]     new game, you (White) vs Claude: /chess opus high
32                              models: fable, opus, sonnet, haiku · efforts: low, medium, high, xhigh, max (default medium)
33  /chess move <move>          play a move: e4, Nf3, O-O, exd5, e8=Q or e2e4
34  /chess pause                freeze both clocks and Claude
35  /chess resume               carry on a paused game
36  /chess save                 save the game to resume later from replays
37  /chess resign               resign, after a yes/no question
38  /chess close                close the pane; mid-game it pauses and saves first
39
40Drive the pane from the chat
41  /chess keys                 list every key, picker and field on screen now
42  /chess key <key>            press one: /chess key 2 opens new game
43  /chess set <name> <value>   change a picker or fill a field: /chess set tc 5+3
44
45Screens
46  /chess replays              famous games, online search, saved games (also /chess library)
47  /chess tournament           tournament setup, or standings while one runs
48  /chess settings             defaults for new games
49
50Games and files
51  /chess last                 replay your last finished game
52  /chess search <words>       find official events on Lichess: /chess search candidates
53  /chess player <username>    load a Chess.com player's latest month of games
54  /chess import <url|file>    load PGN from a Lichess link, any PGN URL, or a .pgn file
55  /chess export <path>        write every saved game to one PGN file
56
57/chess runs at once, even while Claude is replying.`
58
59// Every hotkey, picker and field the pane last drew, so /chess can drive the pane from the chat when clicks and keys never reach it.
60type Control = { kind: 'key' | 'set'; label: string; options?: readonly string[]; run: (value: string) => unknown }
61const controls = new Map<string, Control>()
62const MAX_API_ERRORS = 3
63const CASUAL_RETRIES = 3
64const UNREADABLE_RETRIES = 3
65
66const SONNET: Side = { kind: 'claude', model: 'claude-sonnet-5-5', effort: 'medium' }
67const DEFAULT_PREFS: Prefs = { tcId: 'untimed', rules: 'standard', isClaudeClocked: true, isDouble: false, budgetUsd: 5, spectateMs: 1000, canSwitchOpponent: false }
68const DEFAULT_VIEW: View = {
69  screen: 'setup', ply: null, isFlipped: false,
70  draft: { white: { kind: 'human' }, black: SONNET },
71  entrants: [], pick: { model: SONNET.model, effort: 'medium' }, review: null,
72}
73
74const gameA = atom({ plugin: 'chessus', key: 'game' } as const, null)
75const tourA = atom({ plugin: 'chessus', key: 'tournament' } as const, null)
76const viewA = atom({ plugin: 'chessus', key: 'view' } as const, DEFAULT_VIEW)
77const settingsA = atom({ plugin: 'chessus', key: 'settings' } as const, DEFAULT_PREFS)
78const nowA = atom({ plugin: 'chessus', key: 'now' } as const, 0)
79
80export const tcOf = (id: string) => TIME_CONTROLS.find(t => t.id === id) ?? TIME_CONTROLS[0]!
81const eventOf = (t: Tournament) => `Chessus tournament #${t.id}`
82const sideFrom = (value: string, effort: Effort): Side => (value === 'human' ? { kind: 'human' } : { kind: 'claude', model: value, effort })
83const PLAYER_OPTIONS = [{ value: 'human', label: 'You' }, ...MODELS]
84const costKey = (side: Side) => (side.kind === 'claude' ? `${side.model}|${side.effort}` : '')
85
86// Sidebar beside the board: players, clocks, moves, status. Lichess-style.
87const SIDEBAR = 26
88// Width beside the 8 squares: rank labels 2, gap 2, sidebar. Rows besides the squares: nav, the gap under it, files row, promo row, two key rows (replay and game over), Claude's spend.
89const BESIDE_BOARD = 4 + SIDEBAR
90const CHROME_ROWS = 8
91// What the pane asks for when it opens: room for the 7x3 board and the sidebar. The person's own resize wins.
92const PANE_ROWS = 8 * 3 + CHROME_ROWS
93const PANE_COLUMNS = 8 * 7 + BESIDE_BOARD
94// Sidebar rows that are not moves, worst case: mode header, 2 player lines, 2 rules, gap, STATUS label, status,
95// then two rows: a note during play, or the outcome and its follow-up line once it ends.
96const SIDEBAR_FIXED_ROWS = 10
97const SWITCH_ROWS = 2
98// The store holds 4 MiB in all; the library keeps well under it so settings, costs and the live game always fit.
99const LIBRARY_BYTES = 3_000_000
100
101// Squares are 7x3 cells when the pane has room, else 5x2, else 3x1 (a terminal cell is about twice as tall as wide).
102// Below 3x1's needs the board still draws at 3x1 and the pane scrolls.
103export const boardCell = (columns: number, rows: number) => {
104  const sizes = [{ w: 7, h: 3 }, { w: 5, h: 2 }, { w: 3, h: 1 }]
105  return sizes.find(s => BESIDE_BOARD + 8 * s.w <= columns && 8 * s.h + CHROME_ROWS <= rows) ?? sizes[2]!
106}
107
108export const formatClock = (ms: number) => {
109  const t = Math.max(0, ms)
110  if (t < 10_000) return `${(t / 1000).toFixed(1)}s`
111  const s = Math.floor(t / 1000)
112  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
113}
114
115// Why no Claude call may be made for this game now: it is paused or over, or its tournament is paused or at its cap.
116const heldBy = async ($: EngineInterface, gameId: number) => {
117  const g = await read($, gameA)
118  if (!g || g.id !== gameId || g.result !== '*') return 'The game is over.'
119  if (g.isPaused) return 'The game is paused.'
120  const t = await read($, tourA)
121  if (!isTournamentGame(g, t)) return ''
122  if (t.spentUsd >= t.budgetUsd) return `Budget of $${t.budgetUsd} reached.`
123  return t.status === 'running' ? '' : 'The tournament is paused.'
124}
125
126// Every Claude call goes through here, so nothing is spent while the game is held. The check sits right before the call.
127const complete = async ($: EngineInterface, g: Game, c: Color, prompt: string, timeoutMs: number) => {
128  const side = sideOf(g, c)
129  if (side.kind !== 'claude') throw new Error('not a Claude side')
130  // Claude Code prices at the managed modelPricing only; a project's settings cannot change what it bills.
131  const pricing = await $.settings.read({ source: 'policy' }).then(p => p.modelPricing, () => undefined)
132  const held = await heldBy($, g.id)
133  if (held) return { held }
134  const r = await $.model.complete({ model: side.model, effort: side.effort, prompt, maxTokens: 16_000, timeoutMs })
135  return { r, usd: priceUsd(side.model, r.usage, pricing) }
136}
137
138const askMove = async ($: EngineInterface, g: Game, c: Color, retry: string, timeoutMs: number) => {
139  const res = await complete($, g, c, movePrompt(g, c, retry), timeoutMs)
140  if (!res.r) return { held: res.held ?? '' }
141  const { r, usd } = res
142  if (!r.isAnswered) return { ask: { kind: 'error', reason: r.reason } as Ask, usd }
143  if (r.text.trim() === '') return { ask: { kind: 'error', reason: 'empty reply' } as Ask, usd }
144  const san = pickMove(r.text, replay(g.history).moves())
145  const text = cleanText(r.text, 60)
146  const ask: Ask = san ? { kind: 'move', san } : namesAMove(r.text) ? { kind: 'illegal', text } : { kind: 'unreadable', text }
147  return { ask, usd }
148}
149
150const askDraw = async ($: EngineInterface, g: Game, c: Color) => {
151  const res = await complete($, g, c, drawPrompt(g, c), 120_000)
152  if (!res.r) return { held: res.held ?? '' }
153  return { isAccepted: res.r.isAnswered && isAcceptance(res.r.text), usd: res.usd }
154}
155
156const loadGames = async ($: EngineInterface) => ((await $.store.get('games')) as SavedGame[] | undefined) ?? []
157// 0.1.1's table ('costs') counted moves at $0 once Claude Code 2.1.292 froze the session cost, so 0.1.2 starts a new one.
158const COSTS_KEY = 'costs-v2'
159const loadCosts = async ($: EngineInterface) => ((await $.store.get(COSTS_KEY)) as Record<string, CostStat> | undefined) ?? {}
160
161const patchTour = ($: EngineInterface, fn: (t: Tournament) => Partial<Tournament>) => update($, tourA, t => t && { ...t, ...fn(t) })
162
163const setView = ($: EngineInterface, patch: Partial<View>) =>
164  update($, viewA, v => ({
165    ...v,
166    ...('screen' in patch || 'review' in patch ? { confirm: null } : {}),
167    ...('screen' in patch && patch.screen !== 'library' && v.screen === 'library' ? { found: [], searchNote: '' } : {}),
168    ...patch,
169  }))
170
171// M4: the library is capped by count and by size, oldest out first; a full store never blocks the caller.
172const saveGames = async ($: EngineInterface, added: SavedGame[]) => {
173  const before = await loadGames($)
174  let games = [...before, ...added].slice(-MAX_SAVED)
175  while (games.length > added.length && JSON.stringify(games).length > LIBRARY_BYTES) games = games.slice(1)
176  const evicted = Math.max(0, before.length - (games.length - added.length))
177  try {
178    await $.store.set('games', games)
179    return { isSaved: true, evicted }
180  } catch {
181    return { isSaved: false, evicted: 0 }
182  }
183}
184const setSettings = async ($: EngineInterface, patch: Partial<Prefs>) => {
185  await update($, settingsA, s => ({ ...s, ...patch }))
186  await $.store.set('settings', await read($, settingsA))
187}
188
189const isTournamentGame = (g: Game, t: Tournament | null): t is Tournament =>
190  t !== null && t.current !== null && t.status !== 'done' && g.event === eventOf(t)
191
192const spend = async ($: EngineInterface, gameId: number, side: Side, usd: number, isMove: boolean) => {
193  const g = await read($, gameA)
194  await update($, gameA, x => (x && x.id === gameId ? { ...x, spentUsd: x.spentUsd + usd } : x))
195  if (g && g.id === gameId) await update($, tourA, t => (t && isTournamentGame(g, t) ? { ...t, spentUsd: t.spentUsd + usd } : t))
196  // ponytail: get-then-set on the cost table; two overlapping calls can drop one sample of an average
197  const costs = await loadCosts($)
198  const was = costs[costKey(side)] ?? { usd: 0, moves: 0 }
199  await $.store.set(COSTS_KEY, { ...costs, [costKey(side)]: { usd: was.usd + usd, moves: was.moves + (isMove ? 1 : 0) } })
200}
201
202// An unfinished casual game outlives the session; a tournament game ends with its tournament.
203const LIVE_KEY = 'live'
204const CASUAL = 'Casual game'
205
206const saveLive = async ($: EngineInterface) => {
207  const g = await read($, gameA)
208  if (g && g.result === '*' && g.event === CASUAL) await $.store.set(LIVE_KEY, { ...g, thinking: '' })
209  else await $.store.delete(LIVE_KEY)
210}
211
212// A restored game comes back paused: no clock runs and Claude spends nothing until the person resumes.
213// Time away is not charged.
214const restoreLive = async ($: EngineInterface) => {
215  if (await read($, gameA)) return
216  const live = (await $.store.get(LIVE_KEY)) as Game | undefined
217  if (!live || live.result !== '*') return
218  const now = await $.clock.now()
219  await update($, gameA, () => freeze({ ...live, thinking: '', isHalted: false, isPaused: false, turnStartedAt: now, nextMoveAt: 0 }, now))
220  await update($, nowA, () => now)
221  await setView($, { screen: 'game', ply: null, review: null, confirm: null, isFlipped: live.white.kind !== 'human' && live.black.kind === 'human' })
222}
223
224const isLiveCasual = (g: Game | null): g is Game => !!g && g.result === '*' && g.event === CASUAL
225
226// Pause freezes both clocks and Claude; the game is already saved, so a paused game survives a restart too.
227const pauseGame = async ($: EngineInterface) => {
228  const now = await $.clock.now()
229  await update($, gameA, g => (isLiveCasual(g) ? freeze(g, now) : g))
230  await saveLive($)
231}
232
233const resumeGame = async ($: EngineInterface) => {
234  const now = await $.clock.now()
235  await update($, gameA, g => (isLiveCasual(g) ? unfreeze(g, now) : g))
236  await saveLive($)
237  kick($, 0)
238}
239
240const resignGame = async ($: EngineInterface) => {
241  const cur = await read($, gameA)
242  if (cur && cur.result === '*') await finish($, resign(cur, humanColor(cur) ?? turnOf(cur)))
243}
244
245const closePane = async ($: EngineInterface, isPausing: boolean) => {
246  if (isPausing) await pauseGame($)
247  await setView($, { confirm: null })
248  await $.ui.close({ id: PANE })
249}
250
251// The one writer of a finished game. It claims the game inside the update, so a flag racing a move or a resign lands once.
252const finish = async ($: EngineInterface, g: Game) => {
253  let isClaimed = false
254  await update($, gameA, prev => {
255    isClaimed = !!prev && prev.id === g.id && prev.result === '*' && g.result !== '*' && g.history.length >= prev.history.length
256    return isClaimed ? { ...g, thinking: '' } : prev
257  })
258  if (!isClaimed) return
259  await saveLive($)
260  const t = await read($, tourA)
261  const pairing = isTournamentGame(g, t) ? t.current : null
262  const saved: SavedGame = {
263    pgn: toPgn(g, new Date()), white: nameOf(g.white), black: nameOf(g.black),
264    result: g.result, termination: g.termination, at: new Date().toISOString(),
265    ...(t && pairing !== null ? { tournament: t.id, pairing } : {}),
266  }
267  const { isSaved } = await saveGames($, [saved])
268  if (!isSaved) $.ui.toast('Chessus could not save the finished game: the store is full. /chess export, then clear old games.')
269  await setView($, { confirm: null })
270  if (pairing === null) return
271  await update($, tourA, x => {
272    if (!x || x.current !== pairing) return x
273    const pairings = x.pairings.map((p, i) => (i === pairing ? { ...p, result: g.result } : p))
274    return { ...x, pairings, current: null, status: nextPairing(pairings) === null ? 'done' : x.status }
275  })
276}
277
278const kick = ($: EngineInterface, ms: number) => void $.clock.after(ms, () => void pump($))
279
280const settle = async ($: EngineInterface, g: Game) => {
281  if (g.result !== '*') return finish($, g)
282  const isSpectating = g.white.kind === 'claude' && g.black.kind === 'claude'
283  const delay = isSpectating ? (await read($, settingsA)).spectateMs : 0
284  const now = await $.clock.now()
285  await update($, gameA, x => {
286    if (!x || x.id !== g.id || x.result !== '*') return x
287    const moved = { ...g, nextMoveAt: now + delay }
288    return x.isPaused ? freeze({ ...moved, isPaused: false }, now) : moved
289  })
290  await saveLive($)
291  kick($, delay)
292}
293
294const halt = async ($: EngineInterface, gameId: number, note: string) => {
295  await update($, gameA, g => (g && g.id === gameId ? { ...g, thinking: '', isHalted: true, note } : g))
296  const g = await read($, gameA)
297  if (g && g.id === gameId) await update($, tourA, t => (t && isTournamentGame(g, t) ? { ...t, note: `Game halted: ${note}` } : t))
298}
299
300// No module-level busy flag: an old call still waiting (the opponent was switched, or a hot reload) must not
301// block the next ask. The thinking token in claudeTurn is what keeps two asks from both moving.
302const pump = async ($: EngineInterface) => {
303  await claudeTurn($).catch(() => undefined)
304}
305
306const pauseTournament = async ($: EngineInterface, note: string) => {
307  const now = await $.clock.now()
308  await patchTour($, () => ({ status: 'paused', note }))
309  const t = await read($, tourA)
310  await update($, gameA, g => (g && g.result === '*' && isTournamentGame(g, t) ? freeze(g, now) : g))
311}
312
313const resumeTournament = async ($: EngineInterface) => {
314  const now = await $.clock.now()
315  await patchTour($, () => ({ status: 'running', note: '' }))
316  await update($, gameA, g => (g ? unfreeze(g, now) : g))
317  kick($, 0)
318}
319
320// The live game plays on as a casual game; it is no longer scored.
321const endTournament = async ($: EngineInterface) => {
322  const now = await $.clock.now()
323  const t = await read($, tourA)
324  await update($, gameA, g => (g && g.result === '*' && isTournamentGame(g, t) ? { ...unfreeze(g, now), event: CASUAL } : g))
325  await patchTour($, () => ({ status: 'done', current: null, note: 'Ended early.' }))
326  await saveLive($)
327  kick($, 0)
328}
329
330// M2: a casual game in progress is never silently replaced: it goes to the library as abandoned first.
331const archiveCasual = async ($: EngineInterface) => {
332  const live = await read($, gameA)
333  if (!isLiveCasual(live) || live.history.length === 0) return
334  const saved: SavedGame = {
335    pgn: toPgn(live, new Date()), white: nameOf(live.white), black: nameOf(live.black),
336    result: '*', termination: 'abandoned', at: new Date().toISOString(),
337  }
338  const { isSaved } = await saveGames($, [saved])
339  $.ui.toast(isSaved ? 'The unfinished game went to "saved" as abandoned.' : 'The unfinished game could not be saved: the store is full.')
340}
341
342const claudeTurn = async ($: EngineInterface) => {
343  const g = await read($, gameA)
344  if (!g || g.result !== '*' || g.thinking || g.isHalted || g.isPaused) return
345  if ((await $.clock.now()) < g.nextMoveAt) return
346  const c = turnOf(g)
347  const side = sideOf(g, c)
348  if (side.kind !== 'claude') return
349  const token = `${g.id}:${g.history.length}:${Math.random()}`
350  await update($, gameA, s => (s && s.id === g.id && !s.thinking && s.history.length === g.history.length ? { ...s, thinking: token } : s))
351
352  const isMine = (s: Game | null): s is Game => !!s && s.id === g.id && s.thinking === token && s.result === '*'
353  try {
354    await askUntilMoved($, g, c, side, token, isMine)
355  } catch (err) {
356    const why = `Claude's move failed (${err instanceof Error ? err.message : String(err)}). Press retry.`
357    await update($, gameA, s => (isMine(s) ? { ...s, thinking: '', isHalted: true, note: why } : s))
358  }
359}
360
361const askUntilMoved = async (
362  $: EngineInterface, g: Game, c: Color, side: Side & { kind: 'claude' }, token: string, isMine: (s: Game | null) => s is Game,
363) => {
364  let retry = ''
365  let errors = 0
366  let casualMisses = 0
367  let unreadable = 0
368  const release = () => update($, gameA, s => (isMine(s) ? { ...s, thinking: '' } : s))
369  for (;;) {
370    const before = await read($, gameA)
371    if (!isMine(before)) return
372    const now = await $.clock.now()
373    const timeoutMs = isClockRunning(before, c) ? Math.max(1000, remaining(before, c, now) + 500) : 600_000
374    const asked = await askMove($, before, c, retry, timeoutMs)
375    if (!asked.ask) {
376      // A move at the cap pauses the tournament, freezing the game before the turn is let go so the ticker starts no other
377      // call. Only moves pause it: a draw offer pausing would throw away a paid move call already under way.
378      const t = await read($, tourA)
379      if (isTournamentGame(before, t) && t.status === 'running' && t.spentUsd >= t.budgetUsd) {
380        await pauseTournament($, `${asked.held} Raise it in Settings, then resume.`)
381      }
382      return release()
383    }
384    const { ask, usd } = asked
385    await spend($, g.id, side, usd, ask.kind === 'move')
386    const cur = await read($, gameA)
387    if (!isMine(cur)) return
388    if (cur.isPaused) return release()
389    const at = await $.clock.now()
390
391    if (ask.kind === 'move') {
392      if (sideOf(cur, other(c)).kind === 'human') $.ui.toast(`${nameOf(side)} played ${ask.san}`)
393      return settle($, { ...applyMove(cur, ask.san, at)!, thinking: '' })
394    }
395    if (ask.kind === 'error') {
396      if (++errors >= MAX_API_ERRORS) return halt($, g.id, `${nameOf(side)} is unavailable (${ask.reason}). Press retry.`)
397      await $.clock.sleep(2000)
398      continue
399    }
400    // A reply with no move in it is asked again for free; only a named move that is not legal costs a penalty.
401    if (ask.kind === 'unreadable' && ++unreadable <= UNREADABLE_RETRIES) {
402      retry = `Your previous reply "${ask.text}" did not contain a move. Reply with exactly one move from the legal list, like ${replay(cur.history).moves()[0]}.`
403      continue
404    }
405    if (cur.rules === 'casual') {
406      if (++casualMisses <= CASUAL_RETRIES) {
407        retry = `Your previous reply "${ask.text}" was not a legal move.`
408        continue
409      }
410      const legal = replay(cur.history).moves()
411      const moved = applyMove(cur, legal[Math.floor(Math.random() * legal.length)]!, at)!
412      return settle($, { ...moved, thinking: '', note: `${nameOf(side)} gave no legal move after ${CASUAL_RETRIES} retries; a random legal move was played.` })
413    }
414    const penalized = illegalMove(cur, c, ask.text)
415    if (penalized.result !== '*') return finish($, penalized)
416    await update($, gameA, s => (isMine(s) ? penalized : s))
417    retry = `Your previous reply "${ask.text}" was an illegal move and was penalized. Another illegal move loses the game.`
418  }
419}
420
421const startGame = async ($: EngineInterface, o: Omit<NewGame, 'id'>, screen: Screen | null = 'game', history: string[] = []) => {
422  const live = await read($, gameA)
423  if (screen !== null && live && live.result === '*' && isTournamentGame(live, await read($, tourA))) {
424    $.ui.toast('A tournament game is in progress. End the tournament first.')
425    return false
426  }
427  await archiveCasual($)
428  const now = await $.clock.now()
429  const g = { ...newGame({ ...o, id: Math.max((live?.id ?? 0) + 1, Math.floor(now)) }, now), history }
430  await update($, gameA, () => g)
431  await update($, nowA, () => now)
432  await saveLive($)
433  if (screen !== null) await setView($, { screen, ply: null, review: null, isFlipped: g.white.kind !== 'human' && g.black.kind === 'human' })
434  else await update($, viewA, v => (v.review ? v : { ...v, ply: null }))
435  kick($, 0)
436  return true
437}
438
439let isStarting = false
440
441const startNext = async ($: EngineInterface) => {
442  if (isStarting) return
443  isStarting = true
444  try {
445    await startNextGame($)
446  } finally {
447    isStarting = false
448  }
449}
450
451const startNextGame = async ($: EngineInterface) => {
452  const t = await read($, tourA)
453  if (!t || t.status !== 'running' || t.current !== null) return
454  const live = await read($, gameA)
455  if (live && live.result === '*' && (!live.isPaused || live.event === CASUAL)) {
456    const waiting = 'Waiting: a casual game is open. Finish or resign it to continue.'
457    if (live.event === CASUAL && t.note !== waiting) await patchTour($, () => ({ note: waiting }))
458    return
459  }
460  const i = nextPairing(t.pairings)
461  if (i === null) {
462    await patchTour($, () => ({ status: 'done' }))
463    return
464  }
465  const p = t.pairings[i]!
466  await patchTour($, () => ({ current: i }))
467  await startGame($, {
468    white: t.entrants[p.white]!, black: t.entrants[p.black]!, tc: t.tc, rules: t.rules,
469    isClaudeClocked: t.isClaudeClocked, round: String(p.round), event: eventOf(t),
470  }, null)
471  if ((await read($, tourA))?.status === 'paused') {
472    const now = await $.clock.now()
473    await update($, gameA, g => (g && g.result === '*' ? freeze(g, now) : g))
474  }
475}
476
477const tick = async ($: EngineInterface) => {
478  const g = await read($, gameA)
479  const now = await $.clock.now()
480  if (g && g.result === '*' && isTimed(g.tc) && !g.isPaused) {
481    const flagged = flagIfOut(g, now)
482    if (flagged !== g) await finish($, flagged)
483    else await update($, nowA, () => now)
484  }
485  const isClaudeDue = !!g && g.result === '*' && !g.thinking && !g.isHalted && !g.isPaused && now >= g.nextMoveAt && sideOf(g, turnOf(g)).kind === 'claude'
486  if (isClaudeDue) kick($, 0)
487  await startNext($)
488}
489
490const humanColor = (g: Game): Color | null => {
491  const isW = g.white.kind === 'human'
492  const isB = g.black.kind === 'human'
493  if (isW && isB) return turnOf(g)
494  return isW ? 'w' : isB ? 'b' : null
495}
496
497const humanMove = async ($: EngineInterface, uci: string, isFromChat = false) => {
498  const g = await read($, gameA)
499  const v = await read($, viewA)
500  if (!g || g.result !== '*' || g.isPaused || sideOf(g, turnOf(g)).kind !== 'human') return false
501  if (!isFromChat && (v.ply !== null || v.review)) return false
502  const promotion = uci.slice(4, 5)
503  const moved = applyMove(g, { from: uci.slice(0, 2), to: uci.slice(2, 4), ...(promotion ? { promotion } : {}) }, await $.clock.now())
504  if (!moved) return false
505  await settle($, moved)
506  return true
507}
508
509// From-square to-square, with an optional - x or : between and an optional promotion: e2e4, e2-e4, e7e8q, e7-e8=Q.
510const COORDINATE = /^([a-h][1-8])\s*[-x:]?\s*([a-h][1-8])\s*=?([qrbn])?$/i
511
512// One move as a player writes it: SAN (e4, Nf3, O-O, exd5, e8=Q, case and 0-0 forgiven) or coordinates.
513// Returns the UCI of the legal move it names, or undefined.
514export const parseMove = (text: string, history: readonly string[]) => {
515  const t = text.trim()
516  const moves = replay(history).moves({ verbose: true })
517  const uciOf = (m: (typeof moves)[number]) => m.from + m.to + (m.promotion ?? '')
518  const coord = COORDINATE.exec(t)
519  if (coord) {
520    const uci = (coord[1]! + coord[2]! + (coord[3] ?? '')).toLowerCase()
521    return moves.some(m => uciOf(m) === uci) ? uci : undefined
522  }
523  if (/\s/.test(t)) return undefined
524  const castle = t.replace(/[0o]/gi, 'O')
525  const typed = /^O-O(-O)?[+#]?$/.test(castle) ? castle : t
526  const san = pickMove(typed, moves.map(m => m.san))
527  const move = moves.find(m => m.san === san)
528  if (move) return uciOf(move)
529  // Over-specified but unique (Ngf3 with one knight that reaches f3) is still that move.
530  const spelled = /^([NBRQKnrqk])([a-h]?[1-8]?)(x?[a-h][1-8])$/.exec(typed)
531  if (!spelled) return undefined
532  const piece = spelled[1]!.toUpperCase()
533  const from = spelled[2]!.toLowerCase()
534  const to = spelled[3]!.toLowerCase().replace('x', '')
535  const candidates = moves.filter(m => m.piece === piece.toLowerCase() && m.to === to && m.from.includes(from))
536  return candidates.length === 1 ? uciOf(candidates[0]!) : undefined
537}
538
539// Why a typed move did not play. SAN requires naming which piece when two can reach a square (Nbd2, R1e2),
540// so a bare Nd2 that two knights reach is answered with the moves it could mean.
541export const whyNotMove = (text: string, history: readonly string[]) => {
542  const coord = COORDINATE.exec(text.trim())
543  if (coord && !coord[3]) {
544    const fromTo = (coord[1]! + coord[2]!).toLowerCase()
545    if (replay(history).moves({ verbose: true }).some(m => m.from + m.to === fromTo && m.promotion)) {
546      return `"${text.trim()}" promotes a pawn: add the piece, e.g. ${fromTo}q (queen), ${fromTo}r, ${fromTo}b or ${fromTo}n.`
547    }
548  }
549  const t = text.trim().replace(/[+#?!]/g, '')
550  const asked = [t, t.charAt(0).toUpperCase() + t.slice(1)]
551  const undisambiguated = (san: string) => san.replace(/[+#]/g, '').replace(/^([NBRQK])[a-h]?[1-8]?(x?[a-h][1-8])$/, '$1$2')
552  const meant = replay(history).moves().filter(san => asked.includes(undisambiguated(san)) && !asked.includes(san.replace(/[+#]/g, '')))
553  return meant.length > 1 ? `"${text.trim()}" is ambiguous: more than one piece can go there. Write ${[...meant].sort().join(" or ")}.` : `"${text.trim()}" is not a legal move here.`
554}
555
556const typedMove = async ($: EngineInterface, text: string) => {
557  const g = await read($, gameA)
558  const uci = g && parseMove(text, g.history)
559  return uci ? humanMove($, uci, true) : false
560}
561
562const offerDraw = async ($: EngineInterface) => {
563  const g = await read($, gameA)
564  const me = g && humanColor(g)
565  if (!g || !me || g.result !== '*') return
566  const opponent = sideOf(g, other(me))
567  if (opponent.kind !== 'claude') return
568  const decline = (why: string) => update($, gameA, s => (s && s.id === g.id ? { ...s, note: `No draw offer sent. ${why}` } : s))
569  const held = await heldBy($, g.id)
570  if (held) return decline(held)
571  await update($, gameA, s => (s && s.id === g.id ? { ...s, note: `Offered a draw to ${nameOf(opponent)}...` } : s))
572  const res = await askDraw($, g, other(me))
573  if (res.held !== undefined) return decline(res.held)
574  const { isAccepted, usd } = res
575  await spend($, g.id, opponent, usd, false)
576  const cur = await read($, gameA)
577  if (!cur || cur.id !== g.id || cur.result !== '*') return
578  if (isAccepted) return finish($, agreeDraw(cur))
579  await update($, gameA, s => (s && s.id === g.id ? { ...s, note: `${nameOf(opponent)} declined the draw.` } : s))
580}
581
582// Imported text becomes saved games, the first opens as a replay. One message says what happened.
583const importText = async ($: EngineInterface, text: string, from: string) => {
584  const { games: parsed, failed } = importGames(text.slice(0, MAX_IMPORT_CHARS), new Date().toISOString())
585  if (parsed.length === 0) return `No replayable games in ${from}${failed > 0 ? ` (${failed} skipped: Chess960, custom start or unreadable)` : ''}.`
586  const known = new Set((await loadGames($)).map(g => g.pgn))
587  const fresh = parsed.filter(g => !known.has(g.pgn))
588  const games = fresh.slice(-MAX_IMPORT_ONCE)
589  if (games.length === 0) {
590    await openReview($, parsed.at(-1)!)
591    return `Every game from ${from} is already under replays. Replaying ${parsed.at(-1)!.white} vs ${parsed.at(-1)!.black}.`
592  }
593  const { isSaved, evicted } = await saveGames($, games)
594  if (!isSaved) return 'The store is full. /chess export your games, then try again.'
595  const first = games.at(-1)!
596  await openReview($, first)
597  const notes = [
598    failed > 0 ? `skipped ${failed} (Chess960, custom start or unreadable)` : '',
599    fresh.length > games.length ? `kept the newest ${games.length} of ${fresh.length}` : '',
600    parsed.length > fresh.length ? `${parsed.length - fresh.length} already saved` : '',
601  ].filter(Boolean)
602  const dropped = evicted > 0 ? ` To make room, your ${evicted} oldest saved game${evicted === 1 ? ' was' : 's were'} dropped.` : ''
603  return `Imported ${games.length} game${games.length === 1 ? '' : 's'} from ${from}${notes.length ? ` (${notes.join('; ')})` : ''}. Replaying ${first.white} vs ${first.black}; the rest are under replays.${dropped}`
604}
605
606const fetchText = async ($: EngineInterface, url: string, headers: Record<string, string> = {}) => {
607  const res = await $.http.fetch(url, { headers })
608  if (!res.ok) throw new Error(`HTTP ${res.status}`)
609  return res.text
610}
611
612const fetchJson = async ($: EngineInterface, url: string, headers: Record<string, string> = {}): Promise<unknown> => {
613  const text = await fetchText($, url, headers)
614  try {
615    return JSON.parse(text)
616  } catch {
617    throw new Error('the reply was not JSON')
618  }
619}
620
621const isRecord = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null
622
623const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
624
625// Lichess broadcasts: official over-the-board events (Candidates, Olympiad, national championships).
626const searchEvents = async ($: EngineInterface, query: string) => {
627  try {
628    const body = await fetchJson($, `https://lichess.org/api/broadcast/search?q=${encodeURIComponent(query)}`)
629    const results = isRecord(body) && Array.isArray(body.currentPageResults) ? body.currentPageResults : []
630    const found = results
631      .map(r => (isRecord(r) && isRecord(r.tour) ? r.tour : {}))
632      .map(tour => ({ id: tour.id, name: tour.name }))
633      .filter((r): r is { id: string; name: string } => typeof r.id === 'string' && /^[A-Za-z0-9]{8}$/.test(r.id) && typeof r.name === 'string')
634      .map(r => ({ id: r.id, name: cleanText(r.name) }))
635      .filter(r => r.name)
636      .slice(0, MAX_FOUND)
637    await setView($, { found, searchNote: found.length ? `${found.length} events for "${query}". Pick one to download its games.` : `No events found for "${query}".` })
638  } catch (err) {
639    await setView($, { found: [], searchNote: `Search failed: ${errorText(err)}.` })
640  }
641}
642
643const downloadEvent = async ($: EngineInterface, id: string, name: string) => {
644  await setView($, { searchNote: `Downloading ${name}...` })
645  try {
646    const note = await importText($, await fetchText($, `https://lichess.org/api/broadcast/${id}.pgn`), name)
647    await setView($, { found: [], searchNote: note })
648  } catch (err) {
649    await setView($, { searchNote: `Download failed: ${errorText(err)}.` })
650  }
651}
652
653// Chess.com's public API: a player's games, one month per archive; the latest month is fetched.
654const playerGames = async ($: EngineInterface, username: string) => {
655  const user = username.trim().toLowerCase()
656  if (!/^[a-z0-9_-]{1,40}$/.test(user)) {
657    await setView($, { searchNote: `"${cleanText(username, 40)}" is not a Chess.com username.` })
658    return
659  }
660  const headers = { 'User-Agent': 'chessus (Claude Code chess mod)' }
661  await setView($, { searchNote: `Fetching ${user}'s games from Chess.com...` })
662  try {
663    const index = await fetchJson($, `https://api.chess.com/pub/player/${user}/games/archives`, headers)
664    const archives = isRecord(index) && Array.isArray(index.archives) ? index.archives.filter((a): a is string => typeof a === 'string') : []
665    const prefix = `https://api.chess.com/pub/player/${user}/games/`
666    const latest = archives.filter(a => a.startsWith(prefix) && /^\d{4}\/\d{2}$/.test(a.slice(prefix.length))).at(-1)
667    if (!latest) {
668      await setView($, { searchNote: `${user} has no games on Chess.com.` })
669      return
670    }
671    const month = await fetchJson($, latest, headers)
672    const list = isRecord(month) && Array.isArray(month.games) ? month.games : []
673    const pgns = list.map(g => (isRecord(g) && typeof g.pgn === 'string' ? g.pgn : '')).filter(Boolean)
674    await setView($, { searchNote: await importText($, pgns.join('\n\n'), `${user} on Chess.com (${latest.slice(prefix.length).replace('/', '-')})`) })
675  } catch (err) {
676    await setView($, { searchNote: `Chess.com fetch failed: ${errorText(err)}.` })
677  }
678}
679
680// "Black (Haiku 4.5) wins · checkmate", "Draw by agreement", "Draw · stalemate".
681export const outcome = (result: string, termination: string, white: string, black: string) => {
682  if (result === '1/2-1/2') return /^draw /i.test(termination) ? termination.charAt(0).toUpperCase() + termination.slice(1) : `Draw · ${termination}`
683  const winner = result === '1-0' ? `White (${white})` : `Black (${black})`
684  return `${winner} wins · ${termination}`
685}
686
687// A casual game in progress goes to replays with what it needs to carry on later. Clocks restart on resume.
688const saveToResume = async ($: EngineInterface) => {
689  const g = await read($, gameA)
690  if (!isLiveCasual(g)) return { isSaved: false, text: 'Only a casual game in progress can be saved to resume.' }
691  if (g.history.length === 0) return { isSaved: false, text: 'Nothing to save yet: no moves have been played.' }
692  const saved: SavedGame = {
693    pgn: toPgn(g, new Date()), white: nameOf(g.white), black: nameOf(g.black), result: '*', termination: 'saved to resume',
694    at: new Date().toISOString(), resume: { white: g.white, black: g.black, tcId: g.tc.id, rules: g.rules, isClaudeClocked: g.isClaudeClocked, gameId: g.id },
695  }
696  const isOld = (s: SavedGame) => s.result === '*' && s.resume?.gameId === g.id
697  const old = (await loadGames($)).filter(isOld)
698  await dropSaved($, isOld)
699  const { isSaved } = await saveGames($, [saved])
700  if (!isSaved && old.length) await saveGames($, old)
701  return isSaved
702    ? { isSaved, text: 'Saved under replays: open it there and press r to resume. c copies the PGN to share.' }
703    : { isSaved, text: 'The store is full. /chess export your games, then try again.' }
704}
705
706// Keeps the live casual game resumable from replays, then clears it so whatever starts next does not archive it again.
707// Only clears once the snapshot is really in the store, so a full store never loses the game: false, and the caller stops.
708const keepLive = async ($: EngineInterface) => {
709  const live = await read($, gameA)
710  if (!isLiveCasual(live) || live.history.length === 0) return true
711  const { isSaved, text } = await saveToResume($)
712  $.ui.toast(text)
713  if (!isSaved) return false
714  await update($, gameA, x => (isLiveCasual(x) ? null : x))
715  return true
716}
717
718const deleteSaved = async ($: EngineInterface, review: Review) => {
719  if (!review.savedAt) return
720  const removed = await dropSaved($, s => s.at === review.savedAt && s.pgn === review.pgn)
721  await setView($, { review: null, ply: null, screen: 'library' })
722  $.ui.toast(removed > 0 ? `Deleted ${review.white} vs ${review.black}.` : 'That game was already gone.')
723}
724
725// Removes the saved games that match, reading the list at the moment of the call. Returns how many went.
726const dropSaved = async ($: EngineInterface, isGone: (s: SavedGame) => boolean) => {
727  const games = await loadGames($)
728  const kept = games.filter(s => !isGone(s))
729  if (kept.length !== games.length) await $.store.set('games', kept)
730  return games.length - kept.length
731}
732
733// Resuming consumes the snapshot: the game carries on, and is saved again when it ends or is saved anew.
734const resumeReview = async ($: EngineInterface, review: Review) => {
735  const r = review.resume
736  if (!r || review.result !== '*') return
737  await setView($, { confirm: null })
738  await startGame($, { white: r.white, black: r.black, tc: tcOf(r.tcId), rules: r.rules, isClaudeClocked: r.isClaudeClocked }, 'game', review.history)
739  if (review.savedAt) await dropSaved($, s => s.result === '*' && s.at === review.savedAt && s.pgn === review.pgn)
740}
741
742const openReview = async ($: EngineInterface, saved: SavedGame) => {
743  try {
744    const { history } = fromPgn(saved.pgn)
745    replay(history)
746    const review: Review = {
747      history, white: saved.white, black: saved.black, result: saved.result, termination: saved.termination, pgn: saved.pgn,
748      ...(saved.at ? { savedAt: saved.at } : {}),
749      ...(saved.resume ? { resume: saved.resume } : {}),
750    }
751    await setView($, { screen: 'game', review, ply: 0, isFlipped: false })
752    return true
753  } catch {
754    $.ui.toast(`Chessus cannot replay ${saved.white} vs ${saved.black}: it does not start from the standard position.`)
755    return false
756  }
757}
758
759// A key that opens a yes/no question in the pane asks it in Claude Code's own picker too, once the pane has drawn it.
760const confirmFromChat = async ($: EngineInterface) => {
761  if (!(await read($, viewA)).confirm) return ''
762  for (const k of ['y', 'n', 'k', 'p']) controls.delete(k)
763  $.ui.invalidate('ui.render')
764  for (let i = 0; i < 20 && !controls.has('y'); i++) await $.clock.sleep(50)
765  const yes = controls.get('y')
766  const no = controls.get('n')
767  if (!yes || !no) return 'its question is waiting in the pane'
768  const third = controls.get('k') ?? controls.get('p')
769  const choices = [yes, third, no].filter((c): c is Control => !!c)
770  const question = third?.label.startsWith('pause') ? 'Leave the game?' : third ? 'What happens to the game in progress?' : `${yes.label}?`
771  const answer = await $.ui.ask(question, choices.map(c => c.label)).catch(() => no.label)
772  const picked = choices.find(c => c.label === answer) ?? no
773  await picked.run('')
774  return picked.label
775}
776
777export const register: Register = on => {
778  on('session.start', async ($, e, next) => {
779    await $.command.register({
780      name: 'chess',
781      description: 'Chess against Claude, Claude vs Claude, tournaments',
782      immediate: true,
783      argumentHint: 'help | [model [effort]] | move <move> | pause | resume | save | resign | close | keys | key <key> | set <name> <value> | replays | tournament | settings | last | search <words> | player <user> | import <url|file> | export <path>',
784    })
785    const stored = (await $.store.get('settings')) as Partial<Prefs> | undefined
786    if (stored) await update($, settingsA, s => ({ ...s, ...stored }))
787    await update($, gameA, g => (g && g.thinking ? { ...g, thinking: '' } : g))
788    await restoreLive($)
789    await $.store.delete('costs')
790    $.clock.every(TICK_MS, () => void tick($))
791    return next(e)
792  })
793
794  on('command.run', { command: 'chess' }, async ($, e) => {
795    const args = e.args.trim()
796    const [first = '', second] = args.split(/\s+/)
797    const word = first.toLowerCase()
798    const open = async (text: string) => {
799      await $.ui.open({ id: PANE, title: 'Chessus', focus: true, rows: PANE_ROWS, columns: PANE_COLUMNS })
800      return { text }
801    }
802    if (word === 'help') return { text: HELP }
803    if (['keys', 'key', 'set'].includes(word) && !(await $.ui.panes()).some(p => p.id === PANE && p.isShown)) controls.clear()
804    if (word === 'keys') {
805      if (!controls.size) return open('Chessus opened. Run /chess keys again to list what it shows.')
806      const rows = [...controls].map(([k, c]) => (c.kind === 'key' ? `key ${k}: ${c.label}` : `set ${k}: ${c.label}${c.options ? ` (${c.options.join(', ')})` : ' <text>'}`))
807      return { text: `On screen now:\n${rows.join('\n')}\n\n/chess key <key> presses one; /chess set <name> <value> picks or fills one.` }
808    }
809    if (word === 'key' || word === 'set') {
810      const value = args.replace(/^\S+(\s+\S+)?\s*/, '')
811      const c = second ? (controls.get(second) ?? [...controls].find(([k]) => k.toLowerCase() === second.toLowerCase())?.[1]) : undefined
812      if (!c || c.kind !== word) return { text: `Nothing "${second ?? ''}" to ${word === 'key' ? 'press' : 'set'} on screen. /chess keys lists what is.` }
813      if (word === 'set' && c.options && !c.options.includes(value)) return { text: `${second} takes: ${c.options.join(', ')}.` }
814      if (word === 'set' && !value) return { text: `Usage: /chess set ${second} <value>` }
815      await c.run(value)
816      const answer = await confirmFromChat($)
817      return { text: `${word === 'key' ? 'Pressed' : 'Set'} ${c.label.split(':')[0]}${word === 'set' ? ` to ${value}` : ''}${answer ? `, then ${answer}` : ''}. /chess keys lists what is on screen now.` }
818    }
819    if (word === 'export') {
820      const path = args.slice(first.length).trim()
821      if (!path) return { text: 'Usage: /chess export <path>' }
822      const games = await loadGames($)
823      await $.fs.write(path, games.map(g => g.pgn).join('\n\n') + '\n')
824      return { text: `Wrote ${games.length} games to ${path}.` }
825    }
826    if (word === 'close') {
827      const g = await read($, gameA)
828      const isPausing = isLiveCasual(g) && !g.isPaused
829      await closePane($, isPausing)
830      return { text: isPausing ? 'Game paused and saved, Chessus closed. /chess resume continues it.' : 'Chessus closed. /chess opens it again.' }
831    }
832    if (word === 'save') return { text: (await saveToResume($)).text }
833    if (word === 'pause' || word === 'resume') {
834      const g = await read($, gameA)
835      if (!isLiveCasual(g)) return { text: 'No casual game in progress. Tournament games pause from the tournament screen.' }
836      if (word === 'pause') await pauseGame($)
837      else await resumeGame($)
838      return { text: word === 'pause' ? 'Game paused and saved. /chess resume continues it.' : 'Game resumed.' }
839    }
840    if (word === 'resign') {
841      const g = await read($, gameA)
842      if (!g || g.result !== '*' || !humanColor(g)) return { text: 'No game of yours in progress.' }
843      const answer = await $.ui.ask(`Resign this game against ${nameOf(sideOf(g, other(humanColor(g)!)))}?`, ['Yes, resign', 'No, keep playing']).catch(() => '')
844      if (answer !== 'Yes, resign') return { text: 'Kept playing.' }
845      await resignGame($)
846      const after = (await read($, gameA))!
847      return { text: `You resigned. ${after.result} (${after.termination}). The game is in /chess library.` }
848    }
849    if (word === 'move') {
850      const text = args.slice(first.length).trim()
851      if (!text) return { text: 'Usage: /chess move <move>, e.g. e4, Nf3, O-O, exd5, e8=Q or e2e4.' }
852      const g = await read($, gameA)
853      if (!g || g.result !== '*') return { text: 'No game in progress. Start one with /chess <model>, e.g. /chess haiku low.' }
854      if (g.isPaused) return { text: 'The game is paused.' }
855      if (sideOf(g, turnOf(g)).kind !== 'human') return { text: `Not your move: ${nameOf(sideOf(g, turnOf(g)))} is to move.` }
856      if (!(await typedMove($, text))) {
857        return { text: `${whyNotMove(text, g.history)} Legal moves: ${replay(g.history).moves().join(' ')}` }
858      }
859      await setView($, { ply: null, review: null })
860      const after = (await read($, gameA))!
861      const played = after.history.at(-1)
862      if (after.result !== '*') return { text: `You played ${played}. Game over: ${after.result} (${after.termination}).` }
863      const next = sideOf(after, turnOf(after))
864      return { text: `You played ${played}.${next.kind === 'claude' ? ` ${nameOf(next)} is thinking; its move shows as a toast and in the pane.` : ''}` }
865    }
866    if (word === 'import') {
867      const source = args.slice(first.length).trim()
868      if (!source) return { text: 'Usage: /chess import <url or file>. A Lichess game or study link, any URL serving PGN, or a .pgn file.' }
869      let text: string
870      try {
871        text = /^https?:\/\//i.test(source) ? await fetchText($, pgnUrl(source)) : await $.fs.read(source)
872      } catch (err) {
873        return { text: `Could not ${/^https?:/i.test(source) ? 'fetch' : 'read'} ${source}: ${errorText(err)}.` }
874      }
875      return open(await importText($, text, source))
876    }
877    if (word === 'replays') {
878      await setView($, { screen: 'library' })
879      return open('Replays opened: classics, online search and your saved games.')
880    }
881    if (word === 'search') {
882      const query = args.slice(first.length).trim()
883      if (!query) return { text: 'Usage: /chess search <words>, e.g. /chess search candidates. Searches official events on Lichess.' }
884      await setView($, { screen: 'library' })
885      await searchEvents($, query)
886      return open((await read($, viewA)).searchNote ?? 'Searched.')
887    }
888    if (word === 'player') {
889      const user = args.slice(first.length).trim()
890      if (!user) return { text: 'Usage: /chess player <chess.com username>. Loads their latest month of games.' }
891      await setView($, { screen: 'library' })
892      await playerGames($, user)
893      return open((await read($, viewA)).searchNote ?? 'Done.')
894    }
895    if (word === 'last') {
896      const last = (await loadGames($)).at(-1)
897      if (!last) return { text: 'No finished games yet.' }
898      if (!(await openReview($, last))) return { text: 'That game cannot be replayed.' }
899      return open('Reviewing the last game.')
900    }
901    if (word === 'tournament') {
902      const t = await read($, tourA)
903      const isActive = !!t && (t.status === 'running' || t.status === 'paused')
904      await setView($, isActive ? { screen: 'tournament' } : { screen: 'setup', newMode: 'tournament' })
905      return open(isActive ? 'Tournament standings opened.' : 'Tournament setup opened: add players, then start.')
906    }
907    if (word === 'library' || word === 'settings') {
908      await setView($, { screen: word })
909      return open(`Chessus ${word} opened.`)
910    }
911    const model = word ? MODELS.find(m => m.value === word || m.label.toLowerCase().startsWith(word)) : undefined
912    if (word && !model) return { text: `Unknown command or model "${first}". Models: ${MODELS.map(m => m.label.split(' ')[0]!.toLowerCase()).join(', ')}. /chess help lists every command.` }
913    if (model) {
914      const effort = EFFORTS.find(x => x === second) ?? 'medium'
915      const live = await read($, gameA)
916      if (isLiveCasual(live) && live.history.length > 0) {
917        const answer = await $.ui.ask('Start a new game? What happens to the game in progress?', ['Abandon it', 'Keep it to resume', 'Cancel']).catch(() => 'Cancel')
918        if (answer === 'Keep it to resume') {
919          if (!(await keepLive($))) return { text: 'The store is full, so the game in progress stays. /chess export your games, then try again.' }
920        } else if (answer !== 'Abandon it') return { text: 'Kept the game in progress.' }
921      }
922      const s: Prefs = { ...(await read($, settingsA)), ...(await read($, viewA)).opts }
923      const isStarted = await startGame($, { white: { kind: 'human' }, black: { kind: 'claude', model: model.value, effort }, tc: tcOf(s.tcId), rules: s.rules, isClaudeClocked: s.isClaudeClocked, canSwitchOpponent: s.canSwitchOpponent })
924      if (isStarted) await setView($, { opts: {} })
925      if (!isStarted) return { text: 'A tournament game is in progress. End the tournament first.' }
926      return open(`New game: you (White) vs ${model.label} (${effort}). Click a piece, then its square, or use /chess move e4.`)
927    }
928    const live = await read($, gameA)
929    if (live && live.result === '*') {
930      return open(live.isPaused ? 'Chessus opened. Your game is paused: /chess resume continues it.' : 'Chessus opened. Click a piece, then its square, or /chess move e4.')
931    }
932    return open('Chessus opened. Start a game from "new" (key 1), or /chess haiku low.')
933  })
934
935  on('ui.message', { module: 'hooks/board.tsx' }, async ($, e) => {
936    const { uci } = (e.data ?? {}) as { uci?: unknown }
937    if (typeof uci === 'string' && /^[a-h][1-8][a-h][1-8][qrbn]?$/.test(uci)) await humanMove($, uci)
938    return {}
939  })
940
941  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
942    const view = await read($, viewA)
943    const settings = await read($, settingsA)
944    const ui = $.ui.resolve(e)
945    const { Box, Text } = ui
946    const hasBoard = e.surface === 'terminal' || e.surface === 'desktop'
947    if (hasBoard) controls.clear()
948    const arg = (key: string) => ({ plugin: 'chessus', element: key, component: 'Pane', requestId: PANE, surface: e.surface }) as const
949    const Button: typeof ui.Button = props => {
950      const key = props.key ?? props.hotkey ?? ''
951      if (hasBoard && props.hotkey) controls.set(props.hotkey, { kind: 'key', label: (props.label ?? key).trim(), run: () => props.onPress(arg(key)) })
952      return ui.Button(props)
953    }
954    type Fields = Extract<typeof ui, { Input: unknown }>
955    const Input: Fields['Input'] = props => {
956      if (hasBoard) controls.set(props.key, { kind: 'set', label: props.label ?? props.placeholder ?? props.key, run: v => props.onSubmit(v, arg(props.key) as never) })
957      return (ui as Fields).Input(props)
958    }
959    const hasFields = e.surface !== 'mobile'
960
961    const t = await read($, tourA)
962    const isTourActive = !!t && (t.status === 'running' || t.status === 'paused')
963    const g = await read($, gameA)
964    const isGameLive = !!g && g.result === '*'
965
966    // Four places: the board, starting something new, replays, settings. Standings appear while a tournament runs.
967    const tabs: { screen: Screen; label: string }[] = [
968      { screen: 'game', label: 'board' },
969      { screen: 'setup', label: 'new game' },
970      { screen: 'library', label: 'replays' },
971      { screen: 'settings', label: 'settings' },
972      ...(isTourActive ? [{ screen: 'tournament' as const, label: 'standings' }] : []),
973    ]
974    const tourGame = g && t && g.event === eventOf(t) ? t : null
975    // The last game of a finished tournament still shows that tournament's total against its cap.
976    const hasClaude = !!g && (g.white.kind === 'claude' || g.black.kind === 'claude')
977    const spend = view.screen !== 'game' || view.review || !g || !hasClaude ? null
978      : tourGame ? `Claude est. $${tourGame.spentUsd.toFixed(2)} / $${tourGame.budgetUsd} cap`
979        : `Claude est. $${g.spentUsd.toFixed(3)} · no cap`
980    const nav = (
981      <Box width="100%">
982        {tabs.map((tab, i) => (
983          <Button
984            key={`nav-${tab.screen}`}
985            plain
986            hotkey={String(i + 1)}
987            label={view.screen === tab.screen ? `[${tab.label}]  ` : `${tab.label}  `}
988            onPress={() => setView($, { screen: tab.screen })}
989          />
990        ))}
991        <Button key="close" plain hotkey="0" label="close" role="dismiss" onPress={async () => {
992          const live = await read($, gameA)
993          if (isLiveCasual(live) && !live.isPaused) await setView($, { screen: 'game', review: null, ply: null, confirm: 'close' })
994          else await closePane($, false)
995        }} />
996      </Box>
997    )
998    const page = (...children: RenderChildren[]) => (
999      <Box flexDirection="column">
1000        {nav}
1001        <Box flexDirection="column" marginTop={1}>{children}</Box>
1002      </Box>
1003    )
1004    // Arrow buttons instead of dropdowns: one click or Enter changes the value at once, no menu to open.
1005    // Arrow-button picker. With a label width the labels line up in a column; a hint sits under the value.
1006    const select = (
1007      key: string, label: string, value: string, options: readonly { value: string; label?: string }[],
1008      onSelect: (v: string) => unknown, layout: { labelWidth?: number; hint?: string } = {},
1009    ) => {
1010      const at = Math.max(0, options.findIndex(o => o.value === value))
1011      const shown = options[at]?.label ?? options[at]?.value ?? value
1012      const go = (by: number) => void onSelect(options[(at + by + options.length) % options.length]!.value)
1013      if (hasBoard) controls.set(key, { kind: 'set', label: `${label}: ${shown}`, options: options.map(o => o.value), run: onSelect })
1014      const width = layout.labelWidth ?? 0
1015      const row = (
1016        <Box key={key}>
1017          <Text>{width ? label.padEnd(width) : `${label}: `}</Text>
1018          <Button key={`${key}-prev`} plain label="‹" onPress={() => go(-1)} />
1019          <Button key={`${key}-value`} plain label={` ${shown} `} onPress={() => go(1)} />
1020          <Button key={`${key}-next`} plain label="›" onPress={() => go(1)} />
1021        </Box>
1022      )
1023      if (!layout.hint) return row
1024      return (
1025        <Box key={`${key}-row`} flexDirection="column" marginBottom={1}>
1026          {row}
1027          <Text dimColor>{' '.repeat(width) + layout.hint}</Text>
1028        </Box>
1029      )
1030    }
1031    const section = (title: string, ...rows: RenderChildren[]) => (
1032      <Box flexDirection="column" marginTop={1}>
1033        <Text bold underline>{title}</Text>
1034        <Box flexDirection="column" marginTop={1}>{rows}</Box>
1035      </Box>
1036    )
1037
1038    if (view.screen === 'settings') {
1039      const field = { labelWidth: SETTINGS_LABEL_WIDTH }
1040      return page(
1041        <Text bold>Default settings</Text>,
1042        <Text dimColor>Every new game and tournament starts with these. Change them for one game on the new game screen.</Text>,
1043        section('Game',
1044          select('tc', 'Time control', settings.tcId, TIME_CONTROLS.map(tc => ({ value: tc.id, label: tc.label })), v => setSettings($, { tcId: v }),
1045            { ...field, hint: 'Minutes per side, plus seconds added after each move.' }),
1046          select('rules', 'Rules', settings.rules, [{ value: 'standard', label: 'FIDE standard' }, { value: 'casual', label: `Casual (${CASUAL_RETRIES} retries, then random)` }],
1047            v => setSettings($, { rules: v === 'casual' ? 'casual' : 'standard' }), { ...field, hint: 'What happens when Claude plays an illegal move.' }),
1048          select('switch', 'Opponent mid-game', settings.canSwitchOpponent ? 'on' : 'off', [{ value: 'off', label: 'Fixed for the game' }, { value: 'on', label: 'Switchable from the board' }],
1049            v => setSettings($, { canSwitchOpponent: v === 'on' }), { ...field, hint: "Change Claude's model and effort during a casual game." }),
1050        ),
1051        section('Claude',
1052          select('clock', 'Claude clock', settings.isClaudeClocked ? 'on' : 'off', [{ value: 'on', label: 'Thinking time counts' }, { value: 'off', label: 'Paused while Claude thinks' }],
1053            v => setSettings($, { isClaudeClocked: v === 'on' }), { ...field, hint: "Whether Claude's thinking time runs down its clock." }),
1054          select('spectate', 'Claude vs Claude pace', String(settings.spectateMs), [0, 500, 1000, 2000, 5000].map(ms => ({ value: String(ms), label: ms === 0 ? 'No delay' : `${ms / 1000}s between moves` })),
1055            v => setSettings($, { spectateMs: Number(v) }), { ...field, hint: 'A pause between moves so you can follow the game.' }),
1056        ),
1057        section('Tournament',
1058          select('format', 'Format', settings.isDouble ? 'double' : 'single', [{ value: 'single', label: 'Single round-robin' }, { value: 'double', label: 'Double round-robin' }],
1059            v => setSettings($, { isDouble: v === 'double' }), { ...field, hint: 'Double plays every pairing twice, with colours swapped.' }),
1060          <Box key="budget-row" flexDirection="column">
1061            <Box>
1062              <Text>{'Budget cap'.padEnd(SETTINGS_LABEL_WIDTH)}</Text>
1063              <Text bold>{`$${settings.budgetUsd}`}</Text>
1064            </Box>
1065            {hasFields && 'Input' in ui && (
1066              <Box marginLeft={SETTINGS_LABEL_WIDTH}>
1067                <Input
1068                  key="budget"
1069                  placeholder="new cap in dollars, e.g. 5"
1070                  submitLabel="set"
1071                  onSubmit={async v => {
1072                    const usd = Number(v)
1073                    if (!Number.isFinite(usd) || usd <= 0) return
1074                    await setSettings($, { budgetUsd: usd })
1075                    await update($, tourA, x => (x && x.status !== 'done' ? { ...x, budgetUsd: usd } : x))
1076                  }}
1077                />
1078              </Box>
1079            )}
1080            <Text dimColor>{' '.repeat(SETTINGS_LABEL_WIDTH) + "Pauses the tournament when Claude's spend reaches it. Also applies to one already running."}</Text>
1081          </Box>,
1082        ),
1083      )
1084    }
1085
1086    if (view.screen === 'library') {
1087      const all = await loadGames($)
1088      const resumable = all.filter(s => s.resume).length
1089      const clearable = all.length - resumable
1090      const games = all.slice(-LIBRARY_ROWS).reverse()
1091      return page(
1092        <Text bold>Replays</Text>,
1093        <Text dimColor>Pick a game to watch it move by move: b and f step, a and e jump to the start and end.</Text>,
1094        section('Famous games',
1095          ...CLASSICS.map(c => (
1096            <Button key={`classic-${c.id}`} plain label={`${c.title}  ·  ${c.white} vs ${c.black}, ${c.site} ${c.date.slice(0, 4)}`} onPress={() => openReview($, {
1097              pgn: classicPgn(c), white: c.white, black: c.black, result: c.result,
1098              termination: classicTermination(c), at: '',
1099            })} />
1100          )),
1101        ),
1102        section('Find games online',
1103          hasFields && 'Input' in ui && (
1104            <Input key="search-events" label="Official events (Lichess)" placeholder="e.g. candidates, olympiad, world championship" submitLabel="search"
1105              onSubmit={v => v.trim() && searchEvents($, v.trim())} />
1106          ),
1107          ...(view.found ?? []).map((f, i) => (
1108            <Button key={`event-${i}`} plain label={`   ↓ ${f.name}`} onPress={() => downloadEvent($, f.id, f.name)} />
1109          )),
1110          <Box key="player-gap" marginTop={1}>
1111            {hasFields && 'Input' in ui && (
1112              <Input key="search-player" label="A player's games (Chess.com)" placeholder="chess.com username" submitLabel="load"
1113                onSubmit={v => v.trim() && playerGames($, v.trim())} />
1114            )}
1115          </Box>,
1116          view.searchNote && <Box key="note-gap" marginTop={1}><Text color="cyan">{view.searchNote}</Text></Box>,
1117        ),
1118        section(`Your saved games${games.length ? ` (newest ${games.length})` : ''}`,
1119          games.length === 0 && <Text dimColor>Finished and imported games land here.</Text>,
1120          ...games.map((sg, i) => (
1121            <Button key={`lib-${i}`} plain label={`${sg.at.slice(0, 10)}  ${sg.white} vs ${sg.black}  ${sg.result}  ${sg.termination}`} onPress={() => openReview($, sg)} />
1122          )),
1123          clearable > 0 && (
1124            <Box key="clear-row" marginTop={1}>
1125              {view.confirm === 'clear' ? (
1126                <Box>
1127                  <Text color="yellow">{`Delete ${clearable} saved game${clearable === 1 ? '' : 's'}? This cannot be undone.${resumable ? ` Games saved to resume (${resumable}) are kept.` : ''}  `}</Text>
1128                  <Button key="clear-yes" plain hotkey="y" label="yes, delete  " onPress={async () => {
1129                    const removed = await dropSaved($, s => !s.resume)
1130                    await setView($, { confirm: null })
1131                    $.ui.toast(`Deleted ${removed} saved game${removed === 1 ? '' : 's'}.`)
1132                  }} />
1133                  <Button key="clear-no" plain hotkey="n" label="no" onPress={() => setView($, { confirm: null })} />
1134                </Box>
1135              ) : (
1136                <Button key="clear-all" plain label="delete all saved games" onPress={() => setView($, { confirm: 'clear' })} />
1137              )}
1138            </Box>
1139          ),
1140        ),
1141        <Box marginTop={1}><Text dimColor>From chat: /chess import &lt;url or .pgn file&gt; · /chess export &lt;path&gt;</Text></Box>,
1142      )
1143    }
1144
1145    if (view.screen === 'tournament' && t && isTourActive) {
1146      const table = standings(t.entrants.length, t.pairings)
1147      const done = t.pairings.filter(p => p.result !== null).length
1148      const saved = (await loadGames($)).filter(sg => sg.tournament === t.id)
1149      const current = t.current !== null ? t.pairings[t.current] : undefined
1150      return page(
1151        <Text bold color="yellow">{`TOURNAMENT #${t.id} · ${done}/${t.pairings.length} games played · ${t.status === 'paused' ? 'PAUSED' : 'RUNNING'}`}</Text>,
1152        <Text dimColor>{`Claude spend est. $${t.spentUsd.toFixed(2)} of $${t.budgetUsd} cap`}</Text>,
1153        <Text dimColor>{'#  Player                  P   W  D  L  Pts   SB'}</Text>,
1154        ...table.map((r, i) => (
1155          <Text key={`row-${r.entrant}`}>{`${String(i + 1).padEnd(3)}${nameOf(t.entrants[r.entrant]!).padEnd(24)}${String(r.played).padEnd(4)}${String(r.wins).padEnd(3)}${String(r.draws).padEnd(3)}${String(r.losses).padEnd(3)}${String(r.points).padEnd(6)}${r.sb.toFixed(2)}`}</Text>
1156        )),
1157        current && <Text color="cyan">{`Now playing: round ${current.round}, ${nameOf(t.entrants[current.white]!)} vs ${nameOf(t.entrants[current.black]!)}`}</Text>,
1158        t.note !== '' && <Text color="red">{t.note}</Text>,
1159        <Box>
1160          <Button key="watch" plain hotkey="w" label="watch  " onPress={() => setView($, { screen: 'game', review: null, ply: null })} />
1161          {t.status === 'running'
1162            ? <Button key="pause" plain hotkey="p" label="pause  " onPress={() => pauseTournament($, 'Paused.')} />
1163            : <Button key="resume" plain hotkey="r" label="resume  " onPress={() => resumeTournament($)} />}
1164          <Button key="stop" plain label="end tournament" onPress={() => endTournament($)} />
1165        </Box>,
1166        saved.length > 0 && <Text dimColor>Played</Text>,
1167        ...saved.slice(-LIBRARY_ROWS).map((sg, i) => (
1168          <Button key={`tg-${i}`} plain label={`R${t.pairings[sg.pairing ?? 0]?.round ?? '?'}  ${sg.white} - ${sg.black}  ${sg.result}`} onPress={() => openReview($, sg)} />
1169        )),
1170      )
1171    }
1172
1173    if (view.screen === 'setup' || (!g && !view.review)) {
1174      const mode = view.newMode ?? 'casual'
1175      const modeSwitch = select('new-mode', 'Start a', mode, [{ value: 'casual', label: 'Casual game' }, { value: 'tournament', label: 'Tournament' }], v =>
1176        setView($, { newMode: v === 'tournament' ? 'tournament' : 'casual', confirm: null }))
1177      const opts: Prefs = { ...settings, ...view.opts }
1178      const setOpt = (patch: Partial<Prefs>) => update($, viewA, v => ({ ...v, opts: { ...v.opts, ...patch } }))
1179      const optField = { labelWidth: OPTION_LABEL_WIDTH }
1180      const changed = (Object.keys(view.opts ?? {}) as (keyof Prefs)[]).some(k => view.opts?.[k] !== settings[k])
1181      const optionRows = (isTournament: boolean) => section(isTournament ? 'Tournament options' : 'Game options',
1182        select('opt-tc', 'Time control', opts.tcId, TIME_CONTROLS.map(tc => ({ value: tc.id, label: tc.label })), v => setOpt({ tcId: v }), optField),
1183        select('opt-rules', 'Rules', opts.rules, [{ value: 'standard', label: 'FIDE standard' }, { value: 'casual', label: `Casual (${CASUAL_RETRIES} retries, then random)` }],
1184          v => setOpt({ rules: v === 'casual' ? 'casual' : 'standard' }), optField),
1185        select('opt-clock', 'Claude clock', opts.isClaudeClocked ? 'on' : 'off', [{ value: 'on', label: 'Thinking time counts' }, { value: 'off', label: 'Paused while Claude thinks' }],
1186          v => setOpt({ isClaudeClocked: v === 'on' }), optField),
1187        isTournament
1188          ? select('opt-format', 'Format', opts.isDouble ? 'double' : 'single', [{ value: 'single', label: 'Single round-robin' }, { value: 'double', label: 'Double round-robin' }],
1189            v => setOpt({ isDouble: v === 'double' }), optField)
1190          : select('opt-switch', 'Opponent mid-game', opts.canSwitchOpponent ? 'on' : 'off', [{ value: 'off', label: 'Fixed for the game' }, { value: 'on', label: 'Switchable from the board' }],
1191            v => setOpt({ canSwitchOpponent: v === 'on' }), optField),
1192        isTournament && <Text key="opt-budget">{`${'Budget cap'.padEnd(OPTION_LABEL_WIDTH)}$${opts.budgetUsd}  `}<Text dimColor>(set in default settings)</Text></Text>,
1193        changed && (
1194          <Box key="opt-reset" marginTop={1}>
1195            <Button key="opts-reset" plain label="reset to default settings" onPress={() => setView($, { opts: {} })} />
1196          </Box>
1197        ),
1198      )
1199      const busy = isGameLive && g
1200        ? <Text color="yellow">{`A game is in progress: ${nameOf(g.white)} vs ${nameOf(g.black)}, ${g.history.length} moves${g.isPaused ? ' (paused)' : ''}.`}</Text>
hooks/classics.ts 41 lines
1// Famous games kept with the mod. Each replays legally to the ending it claims; tests/classics.test.ts holds that.
2export type Classic = {
3  id: string; title: string; white: string; black: string; site: string; date: string; moves: string
4  result: '1-0' | '0-1'; ending: 'mate' | 'resigned'; source?: string
5}
6
7export const CLASSICS: readonly Classic[] = [
8  {
9    id: 'legal', title: "Légal's Mate", white: 'Legall de Kermeur', black: 'Saint Brie', site: 'Paris', date: '1750.??.??', result: '1-0', ending: 'mate',
10    moves: '1. e4 e5 2. Nf3 d6 3. Bc4 Bg4 4. Nc3 g6 5. Nxe5 Bxd1 6. Bxf7+ Ke7 7. Nd5#',
11  },
12  {
13    id: 'immortal', title: 'The Immortal Game', white: 'Adolf Anderssen', black: 'Lionel Kieseritzky', site: 'London', date: '1851.06.21', result: '1-0', ending: 'mate',
14    moves: '1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 Ng8 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2 Na6 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7#',
15  },
16  {
17    id: 'evergreen', title: 'The Evergreen Game', white: 'Adolf Anderssen', black: 'Jean Dufresne', site: 'Berlin', date: '1852.??.??', result: '1-0', ending: 'mate',
18    moves: '1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. b4 Bxb4 5. c3 Ba5 6. d4 exd4 7. O-O d3 8. Qb3 Qf6 9. e5 Qg6 10. Re1 Nge7 11. Ba3 b5 12. Qxb5 Rb8 13. Qa4 Bb6 14. Nbd2 Bb7 15. Ne4 Qf5 16. Bxd3 Qh5 17. Nf6+ gxf6 18. exf6 Rg8 19. Rad1 Qxf3 20. Rxe7+ Nxe7 21. Qxd7+ Kxd7 22. Bf5+ Ke8 23. Bd7+ Kf8 24. Bxe7#',
19  },
20  {
21    id: 'opera', title: 'The Opera Game', white: 'Paul Morphy', black: 'Duke Karl / Count Isouard', site: 'Paris', date: '1858.??.??', result: '1-0', ending: 'mate',
22    moves: '1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7 14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8#',
23  },
24  {
25    id: 'cardoso-fischer', title: 'Cardoso beats Fischer', white: 'Rodolfo Tan Cardoso', black: 'Robert James Fischer',
26    site: 'New York', date: '1957.09.08', result: '1-0', ending: 'resigned',
27    source: 'chessgames.com game 1044430: Fischer-Cardoso match, round 3. The only game Fischer ever lost to a Filipino.',
28    moves: '1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6 6. g3 e5 7. Nde2 Be7 8. Bg2 O-O 9. O-O Nbd7 10. h3 b5 11. a4 b4 12. Nd5 Nxd5 13. Qxd5 Qc7 14. c3 Bb7 15. Qd1 Nc5 16. f3 a5 17. Be3 Ba6 18. Rc1 Rab8 19. f4 bxc3 20. Rxc3 Rxb2 21. Rf2 Qb6 22. Rc1 Qb3 23. Nc3 exf4 24. Rxb2 Qxb2 25. Bxc5 dxc5 26. gxf4 c4 27. Nd5 Bc5+ 28. Kh2 Bb4 29. Rc2 Qb3 30. e5 Qxa4 31. Be4 g6 32. Qg4 Bb7 33. Nf6+ Kg7 34. Qh4 Rc8 35. Qxh7+ Kf8 36. e6 Rc7 37. Qg8+ Ke7 38. Qxf7+ Kd8 39. Rd2+ Bd5 40. Rxd5+',
29  },
30]
31
32export const classicTermination = (c: Classic) => (c.ending === 'mate' ? 'checkmate' : `${c.result === '1-0' ? 'Black' : 'White'} resigned`)
33
34export const classicPgn = (c: Classic) =>
35  [
36    `[Event "${c.title}"]`, `[Site "${c.site}"]`, `[Date "${c.date}"]`,
37    `[White "${c.white}"]`, `[Black "${c.black}"]`, `[Result "${c.result}"]`,
38    `[Termination "${classicTermination(c)}"]`,
39    '', `${c.moves} ${c.result}`, '',
40  ].join('\n')
41
hooks/claude.ts 67 lines
1import type { Color, Game } from '../types'
2import { replay } from './game'
3
4const bare = (san: string) => san.replace(/[+#?!]/g, '').replace(/0/g, 'O').replace(/^([a-h][18])=?([QRBNqrbn])$/, (_, sq: string, p: string) => `${sq}=${p.toUpperCase()}`)
5
6const spellings = (token: string) => {
7  const t = bare(token.replace(/^\d+\.+/, '').replace(/\.$/, ''))
8  return /^[nbrqk][a-h1-8x]/.test(t) ? [t, t[0]!.toUpperCase() + t.slice(1)] : [t]
9}
10
11// Markdown and punctuation around a move are not part of it: **Nf3**, `e4`, "O-O".
12const tokens = (reply: string) => reply.split(/[\s,;:()"'`*]+/).filter(Boolean)
13
14// The whole reply as one move first; otherwise the last legal move named, since models reason before answering.
15export const pickMove = (reply: string, legal: readonly string[]) => {
16  const byBare = new Map(legal.map(m => [bare(m), m]))
17  const find = (token: string) => spellings(token).map(s => byBare.get(s)).find(Boolean)
18  const whole = find(reply.trim())
19  if (whole) return whole
20  let last: string | undefined
21  for (const token of tokens(reply)) last = find(token) ?? last
22  return last
23}
24
25export const movePrompt = (g: Game, c: Color, retry: string) => {
26  const board = replay(g.history)
27  return [
28    `You are playing ${c === 'w' ? 'White' : 'Black'} in a chess game under FIDE rules.`,
29    `Position (FEN): ${board.fen()}`,
30    `Moves so far: ${board.pgn() || '(none)'}`,
31    `Legal moves: ${board.moves().join(' ')}`,
32    retry,
33    'Reply with exactly one move from the legal list, in SAN, and nothing else.',
34  ]
35    .filter(Boolean)
36    .join('\n')
37}
38
39export type Ask =
40  | { kind: 'move'; san: string }
41  | { kind: 'illegal'; text: string }
42  | { kind: 'unreadable'; text: string }
43  | { kind: 'error'; reason: string }
44
45// A reply names a move when its answer is shaped like one: the whole reply, or its last word, the way the
46// prompt asks Claude to end. "a3 is interesting" mentions a square but answers nothing, so it is unreadable,
47// a formatting slip asked again for free; "**Ke6**" is an answer, and if it is not legal it is an illegal move.
48const MOVE = /^([NBRQK][a-h]?[1-8]?x?[a-h][1-8]|[a-h](x[a-h])?[1-8](=?[QRBN])?|[a-h][1-8][a-h][1-8][qrbn]?|[O0]-[O0](-[O0])?)[+#!?]*\.?$/
49
50export const namesAMove = (reply: string) => {
51  const words = tokens(reply)
52  const whole = reply.trim().replace(/^[*`"']+|[*`"'.]+$/g, '')
53  return MOVE.test(whole) || (words.length > 0 && MOVE.test(words.at(-1)!))
54}
55
56export const drawPrompt = (g: Game, c: Color) => {
57  const board = replay(g.history)
58  return [
59    `You are playing ${c === 'w' ? 'White' : 'Black'} in a chess game. Your opponent offers a draw.`,
60    `Position (FEN): ${board.fen()}`,
61    `Moves so far: ${board.pgn() || '(none)'}`,
62    'Reply ACCEPT or DECLINE and nothing else.',
63  ].join('\n')
64}
65
66export const isAcceptance = (text: string) => /\baccept\b/i.test(text) && !/\bdecline\b/i.test(text)
67
hooks/game.ts 272 lines
1import type { Color, Effort, Game, Result, RulesPreset, SavedGame, Side, TimeControl } from '../types'
2import { Chess, DEFAULT_POSITION } from './vendor/chess.js'
3
4// price: first-party API $/MTok for input, output, cache read, 5-minute cache write. Update when Anthropic reprices.
5export const MODELS = [
6  { value: 'claude-fable-5-1', label: 'Fable 5.1', price: [10, 50, 0.25, 12.5] },
7  { value: 'claude-opus-5-5', label: 'Opus 5.5', price: [4, 20, 0.2, 5] },
8  { value: 'claude-sonnet-5-5', label: 'Sonnet 5.5', price: [2, 10, 0.2, 2.5] },
9  { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', price: [1, 5, 0.1, 1.25] },
10] as const
11export const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
12
13
14type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
15
16type Rates = { input: number; output: number; cacheRead: number; cacheWrite: number }
17
18// Claude Code's `modelPricing` setting, as $.settings.read() hands it: contracted rates per model ID, and a flat multiplier.
19// An override named by a bare ID (claude-haiku-4-5) also covers its dated spelling (claude-haiku-4-5-20251001).
20// ponytail: provider spellings (Bedrock, Vertex ARNs) of a built-in ID are not matched; chessus only asks first-party IDs.
21export const contractedRates = (setting: unknown, model: string): { rates?: Rates; multiplier: number } => {
22  const s = (setting ?? {}) as { multiplier?: unknown; overrides?: Record<string, unknown> }
23  const m = typeof s.multiplier === 'number' && s.multiplier > 0 && s.multiplier <= 1 ? s.multiplier : 1
24  const id = model.toLowerCase()
25  const row = Object.entries(s.overrides ?? {}).find(([k]) => id === k.toLowerCase() || id === `${k.toLowerCase()}-${id.slice(-8)}` && /^\d{8}$/.test(id.slice(-8)))?.[1] as Partial<Rates> | undefined
26  const isValid = !!row && (['input', 'output', 'cacheRead', 'cacheWrite'] as const).every(k => typeof row[k] === 'number' && row[k] >= 0 && row[k] <= 10_000)
27  return { rates: isValid ? (row as Rates) : undefined, multiplier: m }
28}
29
30export const priceUsd = (model: string, u: Usage, pricing?: unknown) => {
31  const { rates, multiplier } = contractedRates(pricing, model)
32  const [input, output, read, write] = rates ? [rates.input, rates.output, rates.cacheRead, rates.cacheWrite] : MODELS.find(m => m.value === model)?.price ?? [0, 0, 0, 0]
33  return (multiplier * (u.input_tokens * input + u.output_tokens * output + u.cache_read_input_tokens * read + u.cache_creation_input_tokens * write)) / 1e6
34}
35
36const MIN = 60_000
37export const TIME_CONTROLS: TimeControl[] = [
38  { id: 'untimed', label: 'Untimed', baseMs: 0, incMs: 0 },
39  { id: '1+0', label: 'Bullet 1+0', baseMs: 1 * MIN, incMs: 0 },
40  { id: '3+2', label: 'Blitz 3+2', baseMs: 3 * MIN, incMs: 2000 },
41  { id: '5+3', label: 'Blitz 5+3', baseMs: 5 * MIN, incMs: 3000 },
42  { id: '10+5', label: 'Rapid 10+5', baseMs: 10 * MIN, incMs: 5000 },
43  { id: '15+10', label: 'Rapid 15+10', baseMs: 15 * MIN, incMs: 10_000 },
44  { id: '90+30', label: 'Classical 90+30', baseMs: 90 * MIN, incMs: 30_000 },
45]
46const ILLEGAL_BONUS_MS = 2 * MIN
47
48export const isTimed = (tc: TimeControl) => tc.baseMs > 0
49// FIDE Appendix A/B: blitz is at most 10 minutes per player, counting 60 moves of increment
50export const isBlitz = (tc: TimeControl) => isTimed(tc) && tc.baseMs + 60 * tc.incMs <= 10 * MIN
51
52export const other = (c: Color): Color => (c === 'w' ? 'b' : 'w')
53export const turnOf = (g: Pick<Game, 'history'>): Color => (g.history.length % 2 === 0 ? 'w' : 'b')
54export const sideOf = (g: Pick<Game, 'white' | 'black'>, c: Color) => (c === 'w' ? g.white : g.black)
55export const winFor = (c: Color): Result => (c === 'w' ? '1-0' : '0-1')
56
57export const nameOf = (side: Side) =>
58  side.kind === 'human' ? 'You' : `${MODELS.find(m => m.value === side.model)?.label ?? side.model} (${side.effort})`
59
60export const replay = (history: readonly string[], upto = history.length) => {
61  const board = new Chess()
62  for (const san of history.slice(0, upto)) board.move(san)
63  return board
64}
65
66export type NewGame = {
67  id: number
68  white: Side
69  black: Side
70  tc: TimeControl
71  rules: RulesPreset
72  isClaudeClocked: boolean
73  canSwitchOpponent?: boolean
74  round?: string
75  event?: string
76}
77
78export const newGame = (o: NewGame, now: number): Game => ({
79  ...o,
80  round: o.round ?? '-',
81  event: o.event ?? 'Casual game',
82  history: [],
83  clock: { w: o.tc.baseMs, b: o.tc.baseMs },
84  turnStartedAt: now,
85  illegal: { w: 0, b: 0 },
86  result: '*',
87  termination: '',
88  note: '',
89  thinking: '',
90  isPaused: false,
91  isHalted: false,
92  nextMoveAt: 0,
93  spentUsd: 0,
94})
95
96export const isClockRunning = (g: Game, c: Color) =>
97  isTimed(g.tc) && g.result === '*' && !g.isPaused && turnOf(g) === c && (sideOf(g, c).kind === 'human' || g.isClaudeClocked)
98
99export const remaining = (g: Game, c: Color, now: number) =>
100  isClockRunning(g, c) ? g.clock[c] - (now - g.turnStartedAt) : g.clock[c]
101
102const end = (g: Game, result: Result, termination: string): Game => ({ ...g, result, termination, thinking: '' })
103
104export const freeze = (g: Game, now: number): Game => {
105  if (g.isPaused) return g
106  const c = turnOf(g)
107  return { ...g, clock: { ...g.clock, [c]: remaining(g, c, now) }, turnStartedAt: now, isPaused: true }
108}
109
110export const unfreeze = (g: Game, now: number): Game => (g.isPaused ? { ...g, turnStartedAt: now, isPaused: false } : g)
111
112const positionKey = (fen: string) => fen.split(' ').slice(0, 4).join(' ')
113
114export const repetitions = (history: readonly string[]) => {
115  const board = new Chess()
116  const counts = new Map<string, number>()
117  const bump = () => counts.set(positionKey(board.fen()), (counts.get(positionKey(board.fen())) ?? 0) + 1)
118  bump()
119  for (const san of history) {
120    board.move(san)
121    bump()
122  }
123  return counts.get(positionKey(board.fen())) ?? 1
124}
125
126const halfmoves = (board: Chess) => Number(board.fen().split(' ')[4])
127
128// FIDE 6.9: c can mate by some legal series (a helpmate counts). A lone king never can; a lone minor
129// only when the other side has material to block its own king.
130// ponytail: misses locked-pawn and same-colored-bishop dead positions
131export const canMate = (board: Chess, c: Color) => {
132  const pieces = (color: Color) => board.board().flat().filter(p => p !== null && p.color === color && p.type !== 'k')
133  const mine = pieces(c)
134  if (mine.length === 0) return false
135  const isLoneMinor = mine.length === 1 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')
136  return !(isLoneMinor && pieces(c === 'w' ? 'b' : 'w').length === 0)
137}
138
139const afterMove = (g: Game): Game => {
140  const board = replay(g.history)
141  if (board.isCheckmate()) return end(g, winFor(other(turnOf(g))), 'checkmate')
142  if (board.isStalemate()) return end(g, '1/2-1/2', 'stalemate')
143  if (board.isInsufficientMaterial()) return end(g, '1/2-1/2', 'insufficient material')
144  if (repetitions(g.history) >= 5) return end(g, '1/2-1/2', 'fivefold repetition')
145  if (halfmoves(board) >= 150) return end(g, '1/2-1/2', '75-move rule')
146  return g
147}
148
149export const claimable = (g: Game) => {
150  if (g.result !== '*') return ''
151  if (repetitions(g.history) >= 3) return 'threefold repetition'
152  if (halfmoves(replay(g.history)) >= 100) return '50-move rule'
153  return ''
154}
155
156export const flag = (g: Game, c: Color): Game => {
157  const opponent = other(c)
158  const flagged = { ...g, clock: { ...g.clock, [c]: 0 } }
159  return canMate(replay(g.history), opponent)
160    ? end(flagged, winFor(opponent), 'time forfeit')
161    : end(flagged, '1/2-1/2', 'time forfeit, opponent cannot mate')
162}
163
164export const flagIfOut = (g: Game, now: number) => {
165  const c = turnOf(g)
166  return isClockRunning(g, c) && remaining(g, c, now) <= 0 ? flag(g, c) : g
167}
168
169export type MoveInput = string | { from: string; to: string; promotion?: string }
170
171export const applyMove = (g: Game, move: MoveInput, now: number): Game | null => {
172  if (g.result !== '*') return null
173  const c = turnOf(g)
174  const board = replay(g.history)
175  let san: string
176  try {
177    san = board.move(move).san
178  } catch {
179    return null
180  }
181  const spent = isClockRunning(g, c) ? now - g.turnStartedAt : 0
182  if (isTimed(g.tc) && g.clock[c] - spent <= 0) return flag(g, c)
183  const clock = isTimed(g.tc) ? { ...g.clock, [c]: g.clock[c] - spent + g.tc.incMs } : g.clock
184  return afterMove({ ...g, history: [...g.history, san], clock, turnStartedAt: now, note: '' })
185}
186
187export const illegalMove = (g: Game, c: Color, reply = ''): Game => {
188  const count = g.illegal[c] + 1
189  const counted = { ...g, illegal: { ...g.illegal, [c]: count } }
190  if (isBlitz(g.tc) || count >= 2) return end(counted, winFor(other(c)), 'illegal move')
191  const opponent = other(c)
192  const clock = isTimed(g.tc) ? { ...g.clock, [opponent]: g.clock[opponent] + ILLEGAL_BONUS_MS } : g.clock
193  const penalty = isTimed(g.tc) ? 'opponent gets 2 extra minutes' : 'warning; a second one loses'
194  const said = reply ? ` ("${reply}")` : ''
195  return { ...counted, clock, note: `${nameOf(sideOf(g, c))} played an illegal move${said}: ${penalty}.` }
196}
197
198export const resign = (g: Game, c: Color) => (g.result === '*' ? end(g, winFor(other(c)), `${c === 'w' ? 'White' : 'Black'} resigned`) : g)
199
200export const agreeDraw = (g: Game) => (g.result === '*' ? end(g, '1/2-1/2', 'draw by agreement') : g)
201
202export const claimDraw = (g: Game) => {
203  const reason = claimable(g)
204  return reason ? end(g, '1/2-1/2', `draw claimed: ${reason}`) : g
205}
206
207const tcHeader = (tc: TimeControl) => (isTimed(tc) ? `${tc.baseMs / 1000}+${tc.incMs / 1000}` : '-')
208
209export const toPgn = (g: Game, date: Date) => {
210  const board = replay(g.history)
211  const headers: [string, string][] = [
212    ['Event', g.event],
213    ['Site', 'Claude Code (chessus)'],
214    ['Date', `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`],
215    ['Round', g.round],
216    ['White', nameOf(g.white)],
217    ['Black', nameOf(g.black)],
218    ['Result', g.result],
219    ['TimeControl', tcHeader(g.tc)],
220    ['Termination', g.termination || 'unterminated'],
221  ]
222  for (const [k, v] of headers) board.setHeader(k, v)
223  return board.pgn()
224}
225
226export const fromPgn = (pgn: string) => {
227  const board = new Chess()
228  board.loadPgn(pgn)
229  return { history: board.history(), headers: board.getHeaders() }
230}
231
232const RESULTS: readonly Result[] = ['1-0', '0-1', '1/2-1/2', '*']
233
234// Text from PGN files and APIs is shown in the pane: strip control characters (escape sequences) and keep it short.
235export const cleanText = (text: string, max = 80) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim().slice(0, max)
236const MAX_IMPORT = 200
237
238// A Lichess page links to its PGN export: a game (/abcdEFGH, maybe with a colour suffix) or a study (/study/ID).
239export const pgnUrl = (url: string) => {
240  const u = new URL(url)
241  if (!/(^|\.)lichess\.org$/.test(u.hostname) || u.pathname.startsWith('/api/') || u.pathname.startsWith('/game/export/')) return url
242  const study = /^\/study\/([A-Za-z0-9]{8})/.exec(u.pathname)
243  if (study) return `https://lichess.org/api/study/${study[1]}.pgn`
244  const game = /^\/([A-Za-z0-9]{8})(?:[A-Za-z0-9]{4})?(?:\/(?:white|black))?\/?$/.exec(u.pathname)
245  return game ? `https://lichess.org/game/export/${game[1]}` : url
246}
247
248// One PGN file may hold many games: a new game starts at a tag block after a blank line.
249export const importGames = (text: string, at: string) => {
250  const chunks = text.replace(/\r\n?/g, '\n').split(/\n\s*\n(?=\[)/).map(s => s.trim()).filter(Boolean)
251  const games: SavedGame[] = []
252  let failed = 0
253  for (const pgn of chunks.slice(0, MAX_IMPORT)) {
254    try {
255      const { history, headers } = fromPgn(pgn)
256      if (history.length === 0) throw new Error('no moves')
257      // Replays start from the standard position, so a game set up from a FEN or another variant cannot be shown.
258      const isCustomStart = headers.FEN !== undefined && headers.FEN !== DEFAULT_POSITION
259      if (isCustomStart || (headers.Variant && headers.Variant !== 'Standard')) throw new Error('custom start')
260      const result = (RESULTS as readonly string[]).includes(headers.Result ?? '') ? (headers.Result as Result) : '*'
261      const event = [headers.Event, headers.Date?.slice(0, 4)].filter(s => s && !s.includes('?')).join(' ')
262      const board = replay(history)
263      for (const tag of ['Event', 'Site', 'Date', 'Round', 'White', 'Black']) if (headers[tag]) board.setHeader(tag, headers[tag]!)
264      board.setHeader('Result', result)
265      games.push({ pgn: board.pgn(), white: cleanText(headers.White ?? '?'), black: cleanText(headers.Black ?? '?'), result, termination: cleanText(event) || 'imported', at })
266    } catch {
267      failed++
268    }
269  }
270  return { games, failed: failed + Math.max(0, chunks.length - MAX_IMPORT) }
271}
272
hooks/tournament.ts 84 lines
1import type { Pairing, Result } from '../types'
2
3// Circle method for who meets whom; colors go to whoever has had fewer whites (Berger-style balance).
4export const roundRobin = (n: number, isDouble: boolean): Pairing[] => {
5  const seats = Array.from({ length: n }, (_, i) => i)
6  if (n % 2 === 1) seats.push(-1)
7  const m = seats.length
8  const balance = Array<number>(n).fill(0)
9  const single: Pairing[] = []
10  for (let r = 0; r < m - 1; r++) {
11    for (let i = 0; i < m / 2; i++) {
12      const a = seats[i]!
13      const b = seats[m - 1 - i]!
14      if (a < 0 || b < 0) continue
15      const isAWhite = balance[a]! < balance[b]! || (balance[a] === balance[b] && (r + i) % 2 === 0)
16      const [white, black] = isAWhite ? [a, b] : [b, a]
17      balance[white]!++
18      balance[black]!--
19      single.push({ round: r + 1, white, black, result: null })
20    }
21    seats.splice(1, 0, seats.pop()!)
22  }
23  if (!isDouble) return single
24  return [...single, ...single.map(p => ({ round: p.round + m - 1, white: p.black, black: p.white, result: null }))]
25}
26
27const scoreOf = (result: Result, isWhite: boolean) =>
28  result === '1/2-1/2' ? 0.5 : (result === '1-0') === isWhite ? 1 : 0
29
30export type Standing = { entrant: number; played: number; wins: number; draws: number; losses: number; points: number; sb: number }
31
32export const standings = (n: number, pairings: readonly Pairing[]): Standing[] => {
33  const rows: Standing[] = Array.from({ length: n }, (_, entrant) => ({ entrant, played: 0, wins: 0, draws: 0, losses: 0, points: 0, sb: 0 }))
34  const done = pairings.filter((p): p is Pairing & { result: Result } => p.result !== null && p.result !== '*')
35  for (const p of done) {
36    for (const [me, isWhite] of [[p.white, true], [p.black, false]] as const) {
37      const s = scoreOf(p.result, isWhite)
38      const row = rows[me]!
39      row.played++
40      row.points += s
41      if (s === 1) row.wins++
42      else if (s === 0.5) row.draws++
43      else row.losses++
44    }
45  }
46  for (const p of done) {
47    rows[p.white]!.sb += scoreOf(p.result, true) * rows[p.black]!.points
48    rows[p.black]!.sb += scoreOf(p.result, false) * rows[p.white]!.points
49  }
50  const headToHead = (a: number, b: number) =>
51    done.reduce((sum, p) => sum + (p.white === a && p.black === b ? scoreOf(p.result, true) : p.white === b && p.black === a ? scoreOf(p.result, false) : 0), 0)
52  return rows.sort(
53    (x, y) =>
54      y.points - x.points ||
55      y.sb - x.sb ||
56      headToHead(y.entrant, x.entrant) - headToHead(x.entrant, y.entrant) ||
57      y.wins - x.wins,
58  )
59}
60
61export const nextPairing = (pairings: readonly Pairing[]) => {
62  const i = pairings.findIndex(p => p.result === null)
63  return i < 0 ? null : i
64}
65
66const AVG_MOVES_PER_SIDE = 40
67
68// Cost estimate from measured averages per model+effort; unknown sides make it a lower bound.
69export const estimateUsd = (
70  pairings: readonly Pairing[],
71  perMove: readonly (number | undefined)[],
72) => {
73  let usd = 0
74  let isComplete = true
75  for (const p of pairings) {
76    for (const e of [p.white, p.black]) {
77      const each = perMove[e]
78      if (each === undefined) isComplete = false
79      else usd += each * AVG_MOVES_PER_SIDE
80    }
81  }
82  return { usd, isComplete }
83}
84
hooks/board.tsx 168 lines
1import type { ClientModule } from 'claude-code'
2
3import type { BoardProps } from '../types'
4
5const FILES = 'abcdefgh'
6const GLYPH: Record<string, string> = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }
7const PROMOTIONS = ['q', 'r', 'b', 'n'] as const
8const LABEL = 2
9
10const COLORS = {
11  light: '#f0d9b5', dark: '#b58863',
12  lastLight: '#cdd26a', lastDark: '#aaa23a',
13  from: '#7fa650', target: '#8fb8d8', cursor: '#5d9cec',
14}
15
16const PROMO_LEFT = 11
17const PROMO_STEP = 4
18
19const latest = new WeakMap<object, BoardProps>()
20
21type State = { cursor: [number, number]; from: string | null; promo: { from: string; to: string } | null }
22
23export const parseFen = (fen: string) => {
24  const grid: (string | null)[][] = []
25  for (const row of fen.split(' ')[0]!.split('/')) {
26    const cells: (string | null)[] = []
27    for (const ch of row) {
28      if (/\d/.test(ch)) cells.push(...Array<null>(Number(ch)).fill(null))
29      else cells.push(ch)
30    }
31    grid.push(cells)
32  }
33  return (square: string) => grid[8 - Number(square[1])]![FILES.indexOf(square[0]!)] ?? null
34}
35
36const squareAt = (file: number, rank: number) => `${FILES[file]}${rank + 1}`
37
38const liveFrom = (props: BoardProps, from: string | null) => (from && props.legal.some(m => m.startsWith(from)) ? from : null)
39
40export const act = (props: BoardProps, cur: State, square: string, post: (uci: string) => void): State => {
41  const at = { ...cur, cursor: [FILES.indexOf(square[0]!), Number(square[1]) - 1] as [number, number] }
42  if (props.isLocked) return { ...at, from: null, promo: null }
43  const from = liveFrom(props, cur.from)
44  if (from && from !== square) {
45    const moves = props.legal.filter(m => m.startsWith(from + square))
46    if (moves.length > 1 || moves[0]?.length === 5) return { ...at, from: null, promo: { from, to: square } }
47    if (moves.length === 1) {
48      post(moves[0]!)
49      return { ...at, from: null, promo: null }
50    }
51  }
52  const canPick = from !== square && props.legal.some(m => m.startsWith(square))
53  return { ...at, from: canPick ? square : null, promo: null }
54}
55
56export type Hit = { kind: 'square'; square: string } | { kind: 'promo'; piece: string } | null
57
58// Region cells to what was clicked: a square (orientation-aware), a promotion piece, or nothing.
59export const hitTest = (props: Pick<BoardProps, 'cell' | 'orientation'>, x: number, y: number): Hit => {
60  const { w: cellW, h: cellH } = props.cell
61  if (y === 8 * cellH + 1) {
62    const piece = PROMOTIONS[Math.floor((x - PROMO_LEFT) / PROMO_STEP)]
63    return x >= PROMO_LEFT && piece ? { kind: 'promo', piece } : null
64  }
65  const col = Math.floor((x - LABEL) / cellW)
66  const row = Math.floor(y / cellH)
67  if (x < LABEL || y < 0 || row > 7 || col < 0 || col > 7) return null
68  const isUp = props.orientation === 'w'
69  return { kind: 'square', square: squareAt(isUp ? col : 7 - col, isUp ? 7 - row : row) }
70}
71
72const isLivePromo = (props: BoardProps, promo: State['promo']) =>
73  !!promo && props.legal.some(m => m.startsWith(promo.from + promo.to))
74
75const Board: ClientModule<BoardProps, State> = (props, surface) => {
76  const { Box, Text } = surface.elements
77  latest.set(surface, props)
78  const isWhiteUp = props.orientation === 'w'
79  const s: State = surface.state ?? { cursor: [4, 1], from: null, promo: null }
80  const from = liveFrom(props, s.from)
81  const post = (uci: string) => surface.post({ uci })
82  const promo = isLivePromo(props, s.promo) ? s.promo : null
83
84  const promote = (cur: State, piece: string) => {
85    if (!cur.promo) return cur
86    post(cur.promo.from + cur.promo.to + piece)
87    return { ...cur, promo: null }
88  }
89
90  if (surface.state === undefined) {
91    surface.onKey(({ key }) => {
92      const now = latest.get(surface)!
93      const cur = surface.state!
94      if (isLivePromo(now, cur.promo) && (PROMOTIONS as readonly string[]).includes(key)) return surface.setState(promote(cur, key))
95      const [f, r] = cur.cursor
96      const up = now.orientation === 'w' ? 1 : -1
97      const moved: Record<string, [number, number]> = {
98        up: [f, r + up], down: [f, r - up], left: [f - up, r], right: [f + up, r],
99      }
100      const next = moved[key]
101      if (next) {
102        const clamp = (n: number) => Math.min(7, Math.max(0, n))
103        return surface.setState({ ...cur, cursor: [clamp(next[0]), clamp(next[1])] })
104      }
105      if (key === 'return' || key === ' ') surface.setState(act(now, cur, squareAt(cur.cursor[0], cur.cursor[1]), post))
106    })
107    surface.onPointer(({ type, x, y }) => {
108      if (type !== 'down') return
109      const now = latest.get(surface)!
110      const cur = surface.state!
111      const hit = hitTest(now, x, y)
112      if (hit?.kind === 'promo' && isLivePromo(now, cur.promo)) surface.setState(promote(cur, hit.piece))
113      if (hit?.kind === 'square') surface.setState(act(now, cur, hit.square, post))
114    })
115    surface.setState(s)
116  }
117
118  const piece = parseFen(props.fen)
119  const targets = new Set(from ? props.legal.filter(m => m.startsWith(from)).map(m => m.slice(2, 4)) : [])
120  const last = [props.last.slice(0, 2), props.last.slice(2, 4)]
121  const cursor = squareAt(s.cursor[0], s.cursor[1])
122
123  const { w: cellW, h: cellH } = props.cell
124  const mid = Math.floor(cellH / 2)
125  const left = Math.floor((cellW - 1) / 2)
126  const pad = (glyph: string) => ' '.repeat(left) + glyph + ' '.repeat(cellW - 1 - left)
127  const rows = Array.from({ length: 8 * cellH }, (_, line) => {
128    const row = Math.floor(line / cellH)
129    const isMid = line % cellH === mid
130    const rank = isWhiteUp ? 7 - row : row
131    return (
132      <Box>
133        <Text dimColor>{isMid ? `${rank + 1} ` : '  '}</Text>
134        {Array.from({ length: 8 }, (_, col) => {
135          const file = isWhiteUp ? col : 7 - col
136          const sq = squareAt(file, rank)
137          const isLight = (file + rank) % 2 === 1
138          const p = piece(sq)
139          const bg =
140            sq === cursor && !props.isLocked ? COLORS.cursor
141            : sq === from ? COLORS.from
142            : targets.has(sq) ? COLORS.target
143            : last.includes(sq) ? (isLight ? COLORS.lastLight : COLORS.lastDark)
144            : isLight ? COLORS.light : COLORS.dark
145          const glyph = !isMid ? ' ' : p ? GLYPH[p.toLowerCase()]! : targets.has(sq) ? '●' : ' '
146          const fg = p ? (p === p.toUpperCase() ? '#ffffff' : '#000000') : '#2f4f6f'
147          return <Text backgroundColor={bg} color={fg} bold>{pad(glyph)}</Text>
148        })}
149      </Box>
150    )
151  })
152  const files = Array.from({ length: 8 }, (_, col) => pad(FILES[isWhiteUp ? col : 7 - col]!)).join('')
153
154  return (
155    <Box flexDirection="column">
156      {rows}
157      <Text dimColor>{`  ${files}`}</Text>
158      {promo ? (
159        <Text bold>{`Promote to:${['♛', '♜', '♝', '♞'].map(g => g.padStart(PROMO_STEP - 1).padEnd(PROMO_STEP)).join('')}  (or press q r b n)`}</Text>
160      ) : (
161        <Text> </Text>
162      )}
163    </Box>
164  )
165}
166
167export default Board
168
hooks/vendor/chess.js 3375 lines
1/*
2 * chess.js 1.4.0, https://github.com/jhlywa/chess.js
3 * Copyright (c) 2025, Jeff Hlywa (jhlywa@gmail.com). All rights reserved.
4 * Licensed under the BSD 2-Clause License; the full text, with its conditions and
5 * disclaimer, is in LICENSE-chess.js in this folder. Vendored unmodified from the npm
6 * package's dist/esm/chess.js; only this header was added and the source map line removed.
7 */
8// @generated by Peggy 4.2.0.
9//
10// https://peggyjs.org/
11
12
13
14  function rootNode(comment) {
15  	return comment !== null ? { comment, variations: [] } : { variations: []}
16  }
17
18  function node(move, suffix, nag, comment, variations) {
19  	const node = { move, variations };
20
21    if (suffix) {
22    	node.suffix = suffix;
23    }
24
25    if (nag) {
26    	node.nag = nag;
27    }
28
29    if (comment !== null) {
30    	node.comment = comment;
31    }
32
33    return node
34  }
35
36  function lineToTree(...nodes) {
37  	const [root, ...rest] = nodes;
38
39    let parent = root;
40
41    for (const child of rest) {
42    	if (child !== null) {
43        	parent.variations = [child, ...child.variations];
44            child.variations = [];
45            parent = child;
46        }
47    }
48
49  	return root
50  }
51
52  function pgn(headers, game) {
53  	if (game.marker && game.marker.comment) {
54    	let node = game.root;
55        while (true) {
56        	const next = node.variations[0];
57            if (!next) {
58            	node.comment = game.marker.comment;
59            	break
60            }
61            node = next;
62        }
63    }
64
65  	return {
66    	headers,
67        root: game.root,
68        result: (game.marker && game.marker.result) ?? undefined
69    }
70  }
71
72function peg$subclass(child, parent) {
73  function C() { this.constructor = child; }
74  C.prototype = parent.prototype;
75  child.prototype = new C();
76}
77
78function peg$SyntaxError(message, expected, found, location) {
79  var self = Error.call(this, message);
80  // istanbul ignore next Check is a necessary evil to support older environments
81  if (Object.setPrototypeOf) {
82    Object.setPrototypeOf(self, peg$SyntaxError.prototype);
83  }
84  self.expected = expected;
85  self.found = found;
86  self.location = location;
87  self.name = "SyntaxError";
88  return self;
89}
90
91peg$subclass(peg$SyntaxError, Error);
92
93function peg$padEnd(str, targetLength, padString) {
94  padString = padString || " ";
95  if (str.length > targetLength) { return str; }
96  targetLength -= str.length;
97  padString += padString.repeat(targetLength);
98  return str + padString.slice(0, targetLength);
99}
100
101peg$SyntaxError.prototype.format = function(sources) {
102  var str = "Error: " + this.message;
103  if (this.location) {
104    var src = null;
105    var k;
106    for (k = 0; k < sources.length; k++) {
107      if (sources[k].source === this.location.source) {
108        src = sources[k].text.split(/\r\n|\n|\r/g);
109        break;
110      }
111    }
112    var s = this.location.start;
113    var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
114      ? this.location.source.offset(s)
115      : s;
116    var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
117    if (src) {
118      var e = this.location.end;
119      var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
120      var line = src[s.line - 1];
121      var last = s.line === e.line ? e.column : line.length + 1;
122      var hatLen = (last - s.column) || 1;
123      str += "\n --> " + loc + "\n"
124          + filler + " |\n"
125          + offset_s.line + " | " + line + "\n"
126          + filler + " | " + peg$padEnd("", s.column - 1, ' ')
127          + peg$padEnd("", hatLen, "^");
128    } else {
129      str += "\n at " + loc;
130    }
131  }
132  return str;
133};
134
135peg$SyntaxError.buildMessage = function(expected, found) {
136  var DESCRIBE_EXPECTATION_FNS = {
137    literal: function(expectation) {
138      return "\"" + literalEscape(expectation.text) + "\"";
139    },
140
141    class: function(expectation) {
142      var escapedParts = expectation.parts.map(function(part) {
143        return Array.isArray(part)
144          ? classEscape(part[0]) + "-" + classEscape(part[1])
145          : classEscape(part);
146      });
147
148      return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
149    },
150
151    any: function() {
152      return "any character";
153    },
154
155    end: function() {
156      return "end of input";
157    },
158
159    other: function(expectation) {
160      return expectation.description;
161    }
162  };
163
164  function hex(ch) {
165    return ch.charCodeAt(0).toString(16).toUpperCase();
166  }
167
168  function literalEscape(s) {
169    return s
170      .replace(/\\/g, "\\\\")
171      .replace(/"/g,  "\\\"")
172      .replace(/\0/g, "\\0")
173      .replace(/\t/g, "\\t")
174      .replace(/\n/g, "\\n")
175      .replace(/\r/g, "\\r")
176      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
177      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
178  }
179
180  function classEscape(s) {
181    return s
182      .replace(/\\/g, "\\\\")
183      .replace(/\]/g, "\\]")
184      .replace(/\^/g, "\\^")
185      .replace(/-/g,  "\\-")
186      .replace(/\0/g, "\\0")
187      .replace(/\t/g, "\\t")
188      .replace(/\n/g, "\\n")
189      .replace(/\r/g, "\\r")
190      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
191      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
192  }
193
194  function describeExpectation(expectation) {
195    return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
196  }
197
198  function describeExpected(expected) {
199    var descriptions = expected.map(describeExpectation);
200    var i, j;
201
202    descriptions.sort();
203
204    if (descriptions.length > 0) {
205      for (i = 1, j = 1; i < descriptions.length; i++) {
206        if (descriptions[i - 1] !== descriptions[i]) {
207          descriptions[j] = descriptions[i];
208          j++;
209        }
210      }
211      descriptions.length = j;
212    }
213
214    switch (descriptions.length) {
215      case 1:
216        return descriptions[0];
217
218      case 2:
219        return descriptions[0] + " or " + descriptions[1];
220
221      default:
222        return descriptions.slice(0, -1).join(", ")
223          + ", or "
224          + descriptions[descriptions.length - 1];
225    }
226  }
227
228  function describeFound(found) {
229    return found ? "\"" + literalEscape(found) + "\"" : "end of input";
230  }
231
232  return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
233};
234
235function peg$parse(input, options) {
236  options = options !== undefined ? options : {};
237
238  var peg$FAILED = {};
239  var peg$source = options.grammarSource;
240
241  var peg$startRuleFunctions = { pgn: peg$parsepgn };
242  var peg$startRuleFunction = peg$parsepgn;
243
244  var peg$c0 = "[";
245  var peg$c1 = "\"";
246  var peg$c2 = "]";
247  var peg$c3 = ".";
248  var peg$c4 = "O-O-O";
249  var peg$c5 = "O-O";
250  var peg$c6 = "0-0-0";
251  var peg$c7 = "0-0";
252  var peg$c8 = "$";
253  var peg$c9 = "{";
254  var peg$c10 = "}";
255  var peg$c11 = ";";
256  var peg$c12 = "(";
257  var peg$c13 = ")";
258  var peg$c14 = "1-0";
259  var peg$c15 = "0-1";
260  var peg$c16 = "1/2-1/2";
261  var peg$c17 = "*";
262
263  var peg$r0 = /^[a-zA-Z]/;
264  var peg$r1 = /^[^"]/;
265  var peg$r2 = /^[0-9]/;
266  var peg$r3 = /^[.]/;
267  var peg$r4 = /^[a-zA-Z1-8\-=]/;
268  var peg$r5 = /^[+#]/;
269  var peg$r6 = /^[!?]/;
270  var peg$r7 = /^[^}]/;
271  var peg$r8 = /^[^\r\n]/;
272  var peg$r9 = /^[ \t\r\n]/;
273
274  var peg$e0 = peg$otherExpectation("tag pair");
275  var peg$e1 = peg$literalExpectation("[", false);
276  var peg$e2 = peg$literalExpectation("\"", false);
277  var peg$e3 = peg$literalExpectation("]", false);
278  var peg$e4 = peg$otherExpectation("tag name");
279  var peg$e5 = peg$classExpectation([["a", "z"], ["A", "Z"]], false, false);
280  var peg$e6 = peg$otherExpectation("tag value");
281  var peg$e7 = peg$classExpectation(["\""], true, false);
282  var peg$e8 = peg$otherExpectation("move number");
283  var peg$e9 = peg$classExpectation([["0", "9"]], false, false);
284  var peg$e10 = peg$literalExpectation(".", false);
285  var peg$e11 = peg$classExpectation(["."], false, false);
286  var peg$e12 = peg$otherExpectation("standard algebraic notation");
287  var peg$e13 = peg$literalExpectation("O-O-O", false);
288  var peg$e14 = peg$literalExpectation("O-O", false);
289  var peg$e15 = peg$literalExpectation("0-0-0", false);
290  var peg$e16 = peg$literalExpectation("0-0", false);
291  var peg$e17 = peg$classExpectation([["a", "z"], ["A", "Z"], ["1", "8"], "-", "="], false, false);
292  var peg$e18 = peg$classExpectation(["+", "#"], false, false);
293  var peg$e19 = peg$otherExpectation("suffix annotation");
294  var peg$e20 = peg$classExpectation(["!", "?"], false, false);
295  var peg$e21 = peg$otherExpectation("NAG");
296  var peg$e22 = peg$literalExpectation("$", false);
297  var peg$e23 = peg$otherExpectation("brace comment");
298  var peg$e24 = peg$literalExpectation("{", false);
299  var peg$e25 = peg$classExpectation(["}"], true, false);
300  var peg$e26 = peg$literalExpectation("}", false);
301  var peg$e27 = peg$otherExpectation("rest of line comment");
302  var peg$e28 = peg$literalExpectation(";", false);
303  var peg$e29 = peg$classExpectation(["\r", "\n"], true, false);
304  var peg$e30 = peg$otherExpectation("variation");
305  var peg$e31 = peg$literalExpectation("(", false);
306  var peg$e32 = peg$literalExpectation(")", false);
307  var peg$e33 = peg$otherExpectation("game termination marker");
308  var peg$e34 = peg$literalExpectation("1-0", false);
309  var peg$e35 = peg$literalExpectation("0-1", false);
310  var peg$e36 = peg$literalExpectation("1/2-1/2", false);
311  var peg$e37 = peg$literalExpectation("*", false);
312  var peg$e38 = peg$otherExpectation("whitespace");
313  var peg$e39 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
314
315  var peg$f0 = function(headers, game) { return pgn(headers, game) };
316  var peg$f1 = function(tagPairs) { return Object.fromEntries(tagPairs) };
317  var peg$f2 = function(tagName, tagValue) { return [tagName, tagValue] };
318  var peg$f3 = function(root, marker) { return { root, marker} };
319  var peg$f4 = function(comment, moves) { return lineToTree(rootNode(comment), ...moves.flat()) };
320  var peg$f5 = function(san, suffix, nag, comment, variations) { return node(san, suffix, nag, comment, variations) };
321  var peg$f6 = function(nag) { return nag };
322  var peg$f7 = function(comment) { return comment.replace(/[\r\n]+/g, " ") };
323  var peg$f8 = function(comment) { return comment.trim() };
324  var peg$f9 = function(line) { return line };
325  var peg$f10 = function(result, comment) { return { result, comment } };
326  var peg$currPos = options.peg$currPos | 0;
327  var peg$posDetailsCache = [{ line: 1, column: 1 }];
328  var peg$maxFailPos = peg$currPos;
329  var peg$maxFailExpected = options.peg$maxFailExpected || [];
330  var peg$silentFails = options.peg$silentFails | 0;
331
332  var peg$result;
333
334  if (options.startRule) {
335    if (!(options.startRule in peg$startRuleFunctions)) {
336      throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
337    }
338
339    peg$startRuleFunction = peg$startRuleFunctions[options.startRule];
340  }
341
342  function peg$literalExpectation(text, ignoreCase) {
343    return { type: "literal", text: text, ignoreCase: ignoreCase };
344  }
345
346  function peg$classExpectation(parts, inverted, ignoreCase) {
347    return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
348  }
349
350  function peg$endExpectation() {
351    return { type: "end" };
352  }
353
354  function peg$otherExpectation(description) {
355    return { type: "other", description: description };
356  }
357
358  function peg$computePosDetails(pos) {
359    var details = peg$posDetailsCache[pos];
360    var p;
361
362    if (details) {
363      return details;
364    } else {
365      if (pos >= peg$posDetailsCache.length) {
366        p = peg$posDetailsCache.length - 1;
367      } else {
368        p = pos;
369        while (!peg$posDetailsCache[--p]) {}
370      }
371
372      details = peg$posDetailsCache[p];
373      details = {
374        line: details.line,
375        column: details.column
376      };
377
378      while (p < pos) {
379        if (input.charCodeAt(p) === 10) {
380          details.line++;
381          details.column = 1;
382        } else {
383          details.column++;
384        }
385
386        p++;
387      }
388
389      peg$posDetailsCache[pos] = details;
390
391      return details;
392    }
393  }
394
395  function peg$computeLocation(startPos, endPos, offset) {
396    var startPosDetails = peg$computePosDetails(startPos);
397    var endPosDetails = peg$computePosDetails(endPos);
398
399    var res = {
400      source: peg$source,
401      start: {
402        offset: startPos,
403        line: startPosDetails.line,
404        column: startPosDetails.column
405      },
406      end: {
407        offset: endPos,
408        line: endPosDetails.line,
409        column: endPosDetails.column
410      }
411    };
412    return res;
413  }
414
415  function peg$fail(expected) {
416    if (peg$currPos < peg$maxFailPos) { return; }
417
418    if (peg$currPos > peg$maxFailPos) {
419      peg$maxFailPos = peg$currPos;
420      peg$maxFailExpected = [];
421    }
422
423    peg$maxFailExpected.push(expected);
424  }
425
426  function peg$buildStructuredError(expected, found, location) {
427    return new peg$SyntaxError(
428      peg$SyntaxError.buildMessage(expected, found),
429      expected,
430      found,
431      location
432    );
433  }
434
435  function peg$parsepgn() {
436    var s0, s1, s2;
437
438    s0 = peg$currPos;
439    s1 = peg$parsetagPairSection();
440    s2 = peg$parsemoveTextSection();
441    s0 = peg$f0(s1, s2);
442
443    return s0;
444  }
445
446  function peg$parsetagPairSection() {
447    var s0, s1, s2;
448
449    s0 = peg$currPos;
450    s1 = [];
451    s2 = peg$parsetagPair();
452    while (s2 !== peg$FAILED) {
453      s1.push(s2);
454      s2 = peg$parsetagPair();
455    }
456    s2 = peg$parse_();
457    s0 = peg$f1(s1);
458
459    return s0;
460  }
461
462  function peg$parsetagPair() {
463    var s0, s2, s4, s6, s7, s8, s10;
464
465    peg$silentFails++;
466    s0 = peg$currPos;
467    peg$parse_();
468    if (input.charCodeAt(peg$currPos) === 91) {
469      s2 = peg$c0;
470      peg$currPos++;
471    } else {
472      s2 = peg$FAILED;
473      if (peg$silentFails === 0) { peg$fail(peg$e1); }
474    }
475    if (s2 !== peg$FAILED) {
476      peg$parse_();
477      s4 = peg$parsetagName();
478      if (s4 !== peg$FAILED) {
479        peg$parse_();
480        if (input.charCodeAt(peg$currPos) === 34) {
481          s6 = peg$c1;
482          peg$currPos++;
483        } else {
484          s6 = peg$FAILED;
485          if (peg$silentFails === 0) { peg$fail(peg$e2); }
486        }
487        if (s6 !== peg$FAILED) {
488          s7 = peg$parsetagValue();
489          if (input.charCodeAt(peg$currPos) === 34) {
490            s8 = peg$c1;
491            peg$currPos++;
492          } else {
493            s8 = peg$FAILED;
494            if (peg$silentFails === 0) { peg$fail(peg$e2); }
495          }
496          if (s8 !== peg$FAILED) {
497            peg$parse_();
498            if (input.charCodeAt(peg$currPos) === 93) {
499              s10 = peg$c2;
500              peg$currPos++;
501            } else {
502              s10 = peg$FAILED;
503              if (peg$silentFails === 0) { peg$fail(peg$e3); }
504            }
505            if (s10 !== peg$FAILED) {
506              s0 = peg$f2(s4, s7);
507            } else {
508              peg$currPos = s0;
509              s0 = peg$FAILED;
510            }
511          } else {
512            peg$currPos = s0;
513            s0 = peg$FAILED;
514          }
515        } else {
516          peg$currPos = s0;
517          s0 = peg$FAILED;
518        }
519      } else {
520        peg$currPos = s0;
521        s0 = peg$FAILED;
522      }
523    } else {
524      peg$currPos = s0;
525      s0 = peg$FAILED;
526    }
527    peg$silentFails--;
528    if (s0 === peg$FAILED) {
529      if (peg$silentFails === 0) { peg$fail(peg$e0); }
530    }
531
532    return s0;
533  }
534
535  function peg$parsetagName() {
536    var s0, s1, s2;
537
538    peg$silentFails++;
539    s0 = peg$currPos;
540    s1 = [];
541    s2 = input.charAt(peg$currPos);
542    if (peg$r0.test(s2)) {
543      peg$currPos++;
544    } else {
545      s2 = peg$FAILED;
546      if (peg$silentFails === 0) { peg$fail(peg$e5); }
547    }
548    if (s2 !== peg$FAILED) {
549      while (s2 !== peg$FAILED) {
550        s1.push(s2);
551        s2 = input.charAt(peg$currPos);
552        if (peg$r0.test(s2)) {
553          peg$currPos++;
554        } else {
555          s2 = peg$FAILED;
556          if (peg$silentFails === 0) { peg$fail(peg$e5); }
557        }
558      }
559    } else {
560      s1 = peg$FAILED;
561    }
562    if (s1 !== peg$FAILED) {
563      s0 = input.substring(s0, peg$currPos);
564    } else {
565      s0 = s1;
566    }
567    peg$silentFails--;
568    if (s0 === peg$FAILED) {
569      s1 = peg$FAILED;
570      if (peg$silentFails === 0) { peg$fail(peg$e4); }
571    }
572
573    return s0;
574  }
575
576  function peg$parsetagValue() {
577    var s0, s1, s2;
578
579    peg$silentFails++;
580    s0 = peg$currPos;
581    s1 = [];
582    s2 = input.charAt(peg$currPos);
583    if (peg$r1.test(s2)) {
584      peg$currPos++;
585    } else {
586      s2 = peg$FAILED;
587      if (peg$silentFails === 0) { peg$fail(peg$e7); }
588    }
589    while (s2 !== peg$FAILED) {
590      s1.push(s2);
591      s2 = input.charAt(peg$currPos);
592      if (peg$r1.test(s2)) {
593        peg$currPos++;
594      } else {
595        s2 = peg$FAILED;
596        if (peg$silentFails === 0) { peg$fail(peg$e7); }
597      }
598    }
599    s0 = input.substring(s0, peg$currPos);
600    peg$silentFails--;
601    s1 = peg$FAILED;
602    if (peg$silentFails === 0) { peg$fail(peg$e6); }
603
604    return s0;
605  }
606
607  function peg$parsemoveTextSection() {
608    var s0, s1, s3;
609
610    s0 = peg$currPos;
611    s1 = peg$parseline();
612    peg$parse_();
613    s3 = peg$parsegameTerminationMarker();
614    if (s3 === peg$FAILED) {
615      s3 = null;
616    }
617    peg$parse_();
618    s0 = peg$f3(s1, s3);
619
620    return s0;
621  }
622
623  function peg$parseline() {
624    var s0, s1, s2, s3;
625
626    s0 = peg$currPos;
627    s1 = peg$parsecomment();
628    if (s1 === peg$FAILED) {
629      s1 = null;
630    }
631    s2 = [];
632    s3 = peg$parsemove();
633    while (s3 !== peg$FAILED) {
634      s2.push(s3);
635      s3 = peg$parsemove();
636    }
637    s0 = peg$f4(s1, s2);
638
639    return s0;
640  }
641
642  function peg$parsemove() {
643    var s0, s4, s5, s6, s7, s8, s9, s10;
644
645    s0 = peg$currPos;
646    peg$parse_();
647    peg$parsemoveNumber();
648    peg$parse_();
649    s4 = peg$parsesan();
650    if (s4 !== peg$FAILED) {
651      s5 = peg$parsesuffixAnnotation();
652      if (s5 === peg$FAILED) {
653        s5 = null;
654      }
655      s6 = [];
656      s7 = peg$parsenag();
657      while (s7 !== peg$FAILED) {
658        s6.push(s7);
659        s7 = peg$parsenag();
660      }
661      s7 = peg$parse_();
662      s8 = peg$parsecomment();
663      if (s8 === peg$FAILED) {
664        s8 = null;
665      }
666      s9 = [];
667      s10 = peg$parsevariation();
668      while (s10 !== peg$FAILED) {
669        s9.push(s10);
670        s10 = peg$parsevariation();
671      }
672      s0 = peg$f5(s4, s5, s6, s8, s9);
673    } else {
674      peg$currPos = s0;
675      s0 = peg$FAILED;
676    }
677
678    return s0;
679  }
680
681  function peg$parsemoveNumber() {
682    var s0, s1, s2, s3, s4, s5;
683
684    peg$silentFails++;
685    s0 = peg$currPos;
686    s1 = [];
687    s2 = input.charAt(peg$currPos);
688    if (peg$r2.test(s2)) {
689      peg$currPos++;
690    } else {
691      s2 = peg$FAILED;
692      if (peg$silentFails === 0) { peg$fail(peg$e9); }
693    }
694    while (s2 !== peg$FAILED) {
695      s1.push(s2);
696      s2 = input.charAt(peg$currPos);
697      if (peg$r2.test(s2)) {
698        peg$currPos++;
699      } else {
700        s2 = peg$FAILED;
701        if (peg$silentFails === 0) { peg$fail(peg$e9); }
702      }
703    }
704    if (input.charCodeAt(peg$currPos) === 46) {
705      s2 = peg$c3;
706      peg$currPos++;
707    } else {
708      s2 = peg$FAILED;
709      if (peg$silentFails === 0) { peg$fail(peg$e10); }
710    }
711    if (s2 !== peg$FAILED) {
712      s3 = peg$parse_();
713      s4 = [];
714      s5 = input.charAt(peg$currPos);
715      if (peg$r3.test(s5)) {
716        peg$currPos++;
717      } else {
718        s5 = peg$FAILED;
719        if (peg$silentFails === 0) { peg$fail(peg$e11); }
720      }
721      while (s5 !== peg$FAILED) {
722        s4.push(s5);
723        s5 = input.charAt(peg$currPos);
724        if (peg$r3.test(s5)) {
725          peg$currPos++;
726        } else {
727          s5 = peg$FAILED;
728          if (peg$silentFails === 0) { peg$fail(peg$e11); }
729        }
730      }
731      s1 = [s1, s2, s3, s4];
732      s0 = s1;
733    } else {
734      peg$currPos = s0;
735      s0 = peg$FAILED;
736    }
737    peg$silentFails--;
738    if (s0 === peg$FAILED) {
739      s1 = peg$FAILED;
740      if (peg$silentFails === 0) { peg$fail(peg$e8); }
741    }
742
743    return s0;
744  }
745
746  function peg$parsesan() {
747    var s0, s1, s2, s3, s4, s5;
748
749    peg$silentFails++;
750    s0 = peg$currPos;
751    s1 = peg$currPos;
752    if (input.substr(peg$currPos, 5) === peg$c4) {
753      s2 = peg$c4;
754      peg$currPos += 5;
755    } else {
756      s2 = peg$FAILED;
757      if (peg$silentFails === 0) { peg$fail(peg$e13); }
758    }
759    if (s2 === peg$FAILED) {
760      if (input.substr(peg$currPos, 3) === peg$c5) {
761        s2 = peg$c5;
762        peg$currPos += 3;
763      } else {
764        s2 = peg$FAILED;
765        if (peg$silentFails === 0) { peg$fail(peg$e14); }
766      }
767      if (s2 === peg$FAILED) {
768        if (input.substr(peg$currPos, 5) === peg$c6) {
769          s2 = peg$c6;
770          peg$currPos += 5;
771        } else {
772          s2 = peg$FAILED;
773          if (peg$silentFails === 0) { peg$fail(peg$e15); }
774        }
775        if (s2 === peg$FAILED) {
776          if (input.substr(peg$currPos, 3) === peg$c7) {
777            s2 = peg$c7;
778            peg$currPos += 3;
779          } else {
780            s2 = peg$FAILED;
781            if (peg$silentFails === 0) { peg$fail(peg$e16); }
782          }
783          if (s2 === peg$FAILED) {
784            s2 = peg$currPos;
785            s3 = input.charAt(peg$currPos);
786            if (peg$r0.test(s3)) {
787              peg$currPos++;
788            } else {
789              s3 = peg$FAILED;
790              if (peg$silentFails === 0) { peg$fail(peg$e5); }
791            }
792            if (s3 !== peg$FAILED) {
793              s4 = [];
794              s5 = input.charAt(peg$currPos);
795              if (peg$r4.test(s5)) {
796                peg$currPos++;
797              } else {
798                s5 = peg$FAILED;
799                if (peg$silentFails === 0) { peg$fail(peg$e17); }
800              }
801              if (s5 !== peg$FAILED) {
802                while (s5 !== peg$FAILED) {
803                  s4.push(s5);
804                  s5 = input.charAt(peg$currPos);
805                  if (peg$r4.test(s5)) {
806                    peg$currPos++;
807                  } else {
808                    s5 = peg$FAILED;
809                    if (peg$silentFails === 0) { peg$fail(peg$e17); }
810                  }
811                }
812              } else {
813                s4 = peg$FAILED;
814              }
815              if (s4 !== peg$FAILED) {
816                s3 = [s3, s4];
817                s2 = s3;
818              } else {
819                peg$currPos = s2;
820                s2 = peg$FAILED;
821              }
822            } else {
823              peg$currPos = s2;
824              s2 = peg$FAILED;
825            }
826          }
827        }
828      }
829    }
830    if (s2 !== peg$FAILED) {
831      s3 = input.charAt(peg$currPos);
832      if (peg$r5.test(s3)) {
833        peg$currPos++;
834      } else {
835        s3 = peg$FAILED;
836        if (peg$silentFails === 0) { peg$fail(peg$e18); }
837      }
838      if (s3 === peg$FAILED) {
839        s3 = null;
840      }
841      s2 = [s2, s3];
842      s1 = s2;
843    } else {
844      peg$currPos = s1;
845      s1 = peg$FAILED;
846    }
847    if (s1 !== peg$FAILED) {
848      s0 = input.substring(s0, peg$currPos);
849    } else {
850      s0 = s1;
851    }
852    peg$silentFails--;
853    if (s0 === peg$FAILED) {
854      s1 = peg$FAILED;
855      if (peg$silentFails === 0) { peg$fail(peg$e12); }
856    }
857
858    return s0;
859  }
860
861  function peg$parsesuffixAnnotation() {
862    var s0, s1, s2;
863
864    peg$silentFails++;
865    s0 = peg$currPos;
866    s1 = [];
867    s2 = input.charAt(peg$currPos);
868    if (peg$r6.test(s2)) {
869      peg$currPos++;
870    } else {
871      s2 = peg$FAILED;
872      if (peg$silentFails === 0) { peg$fail(peg$e20); }
873    }
874    while (s2 !== peg$FAILED) {
875      s1.push(s2);
876      if (s1.length >= 2) {
877        s2 = peg$FAILED;
878      } else {
879        s2 = input.charAt(peg$currPos);
880        if (peg$r6.test(s2)) {
881          peg$currPos++;
882        } else {
883          s2 = peg$FAILED;
884          if (peg$silentFails === 0) { peg$fail(peg$e20); }
885        }
886      }
887    }
888    if (s1.length < 1) {
889      peg$currPos = s0;
890      s0 = peg$FAILED;
891    } else {
892      s0 = s1;
893    }
894    peg$silentFails--;
895    if (s0 === peg$FAILED) {
896      s1 = peg$FAILED;
897      if (peg$silentFails === 0) { peg$fail(peg$e19); }
898    }
899
900    return s0;
901  }
902
903  function peg$parsenag() {
904    var s0, s2, s3, s4, s5;
905
906    peg$silentFails++;
907    s0 = peg$currPos;
908    peg$parse_();
909    if (input.charCodeAt(peg$currPos) === 36) {
910      s2 = peg$c8;
911      peg$currPos++;
912    } else {
913      s2 = peg$FAILED;
914      if (peg$silentFails === 0) { peg$fail(peg$e22); }
915    }
916    if (s2 !== peg$FAILED) {
917      s3 = peg$currPos;
918      s4 = [];
919      s5 = input.charAt(peg$currPos);
920      if (peg$r2.test(s5)) {
921        peg$currPos++;
922      } else {
923        s5 = peg$FAILED;
924        if (peg$silentFails === 0) { peg$fail(peg$e9); }
925      }
926      if (s5 !== peg$FAILED) {
927        while (s5 !== peg$FAILED) {
928          s4.push(s5);
929          s5 = input.charAt(peg$currPos);
930          if (peg$r2.test(s5)) {
931            peg$currPos++;
932          } else {
933            s5 = peg$FAILED;
934            if (peg$silentFails === 0) { peg$fail(peg$e9); }
935          }
936        }
937      } else {
938        s4 = peg$FAILED;
939      }
940      if (s4 !== peg$FAILED) {
941        s3 = input.substring(s3, peg$currPos);
942      } else {
943        s3 = s4;
944      }
945      if (s3 !== peg$FAILED) {
946        s0 = peg$f6(s3);
947      } else {
948        peg$currPos = s0;
949        s0 = peg$FAILED;
950      }
951    } else {
952      peg$currPos = s0;
953      s0 = peg$FAILED;
954    }
955    peg$silentFails--;
956    if (s0 === peg$FAILED) {
957      if (peg$silentFails === 0) { peg$fail(peg$e21); }
958    }
959
960    return s0;
961  }
962
963  function peg$parsecomment() {
964    var s0;
965
966    s0 = peg$parsebraceComment();
967    if (s0 === peg$FAILED) {
968      s0 = peg$parserestOfLineComment();
969    }
970
971    return s0;
972  }
973
974  function peg$parsebraceComment() {
975    var s0, s1, s2, s3, s4;
976
977    peg$silentFails++;
978    s0 = peg$currPos;
979    if (input.charCodeAt(peg$currPos) === 123) {
980      s1 = peg$c9;
981      peg$currPos++;
982    } else {
983      s1 = peg$FAILED;
984      if (peg$silentFails === 0) { peg$fail(peg$e24); }
985    }
986    if (s1 !== peg$FAILED) {
987      s2 = peg$currPos;
988      s3 = [];
989      s4 = input.charAt(peg$currPos);
990      if (peg$r7.test(s4)) {
991        peg$currPos++;
992      } else {
993        s4 = peg$FAILED;
994        if (peg$silentFails === 0) { peg$fail(peg$e25); }
995      }
996      while (s4 !== peg$FAILED) {
997        s3.push(s4);
998        s4 = input.charAt(peg$currPos);
999        if (peg$r7.test(s4)) {
1000          peg$currPos++;
1001        } else {
1002          s4 = peg$FAILED;
1003          if (peg$silentFails === 0) { peg$fail(peg$e25); }
1004        }
1005      }
1006      s2 = input.substring(s2, peg$currPos);
1007      if (input.charCodeAt(peg$currPos) === 125) {
1008        s3 = peg$c10;
1009        peg$currPos++;
1010      } else {
1011        s3 = peg$FAILED;
1012        if (peg$silentFails === 0) { peg$fail(peg$e26); }
1013      }
1014      if (s3 !== peg$FAILED) {
1015        s0 = peg$f7(s2);
1016      } else {
1017        peg$currPos = s0;
1018        s0 = peg$FAILED;
1019      }
1020    } else {
1021      peg$currPos = s0;
1022      s0 = peg$FAILED;
1023    }
1024    peg$silentFails--;
1025    if (s0 === peg$FAILED) {
1026      s1 = peg$FAILED;
1027      if (peg$silentFails === 0) { peg$fail(peg$e23); }
1028    }
1029
1030    return s0;
1031  }
1032
1033  function peg$parserestOfLineComment() {
1034    var s0, s1, s2, s3, s4;
1035
1036    peg$silentFails++;
1037    s0 = peg$currPos;
1038    if (input.charCodeAt(peg$currPos) === 59) {
1039      s1 = peg$c11;
1040      peg$currPos++;
1041    } else {
1042      s1 = peg$FAILED;
1043      if (peg$silentFails === 0) { peg$fail(peg$e28); }
1044    }
1045    if (s1 !== peg$FAILED) {
1046      s2 = peg$currPos;
1047      s3 = [];
1048      s4 = input.charAt(peg$currPos);
1049      if (peg$r8.test(s4)) {
1050        peg$currPos++;
1051      } else {
1052        s4 = peg$FAILED;
1053        if (peg$silentFails === 0) { peg$fail(peg$e29); }
1054      }
1055      while (s4 !== peg$FAILED) {
1056        s3.push(s4);
1057        s4 = input.charAt(peg$currPos);
1058        if (peg$r8.test(s4)) {
1059          peg$currPos++;
1060        } else {
1061          s4 = peg$FAILED;
1062          if (peg$silentFails === 0) { peg$fail(peg$e29); }
1063        }
1064      }
1065      s2 = input.substring(s2, peg$currPos);
1066      s0 = peg$f8(s2);
1067    } else {
1068      peg$currPos = s0;
1069      s0 = peg$FAILED;
1070    }
1071    peg$silentFails--;
1072    if (s0 === peg$FAILED) {
1073      s1 = peg$FAILED;
1074      if (peg$silentFails === 0) { peg$fail(peg$e27); }
1075    }
1076
1077    return s0;
1078  }
1079
1080  function peg$parsevariation() {
1081    var s0, s2, s3, s5;
1082
1083    peg$silentFails++;
1084    s0 = peg$currPos;
1085    peg$parse_();
1086    if (input.charCodeAt(peg$currPos) === 40) {
1087      s2 = peg$c12;
1088      peg$currPos++;
1089    } else {
1090      s2 = peg$FAILED;
1091      if (peg$silentFails === 0) { peg$fail(peg$e31); }
1092    }
1093    if (s2 !== peg$FAILED) {
1094      s3 = peg$parseline();
1095      if (s3 !== peg$FAILED) {
1096        peg$parse_();
1097        if (input.charCodeAt(peg$currPos) === 41) {
1098          s5 = peg$c13;
1099          peg$currPos++;
1100        } else {
1101          s5 = peg$FAILED;
1102          if (peg$silentFails === 0) { peg$fail(peg$e32); }
1103        }
1104        if (s5 !== peg$FAILED) {
1105          s0 = peg$f9(s3);
1106        } else {
1107          peg$currPos = s0;
1108          s0 = peg$FAILED;
1109        }
1110      } else {
1111        peg$currPos = s0;
1112        s0 = peg$FAILED;
1113      }
1114    } else {
1115      peg$currPos = s0;
1116      s0 = peg$FAILED;
1117    }
1118    peg$silentFails--;
1119    if (s0 === peg$FAILED) {
1120      if (peg$silentFails === 0) { peg$fail(peg$e30); }
1121    }
1122
1123    return s0;
1124  }
1125
1126  function peg$parsegameTerminationMarker() {
1127    var s0, s1, s3;
1128
1129    peg$silentFails++;
1130    s0 = peg$currPos;
1131    if (input.substr(peg$currPos, 3) === peg$c14) {
1132      s1 = peg$c14;
1133      peg$currPos += 3;
1134    } else {
1135      s1 = peg$FAILED;
1136      if (peg$silentFails === 0) { peg$fail(peg$e34); }
1137    }
1138    if (s1 === peg$FAILED) {
1139      if (input.substr(peg$currPos, 3) === peg$c15) {
1140        s1 = peg$c15;
1141        peg$currPos += 3;
1142      } else {
1143        s1 = peg$FAILED;
1144        if (peg$silentFails === 0) { peg$fail(peg$e35); }
1145      }
1146      if (s1 === peg$FAILED) {
1147        if (input.substr(peg$currPos, 7) === peg$c16) {
1148          s1 = peg$c16;
1149          peg$currPos += 7;
1150        } else {
1151          s1 = peg$FAILED;
1152          if (peg$silentFails === 0) { peg$fail(peg$e36); }
1153        }
1154        if (s1 === peg$FAILED) {
1155          if (input.charCodeAt(peg$currPos) === 42) {
1156            s1 = peg$c17;
1157            peg$currPos++;
1158          } else {
1159            s1 = peg$FAILED;
1160            if (peg$silentFails === 0) { peg$fail(peg$e37); }
1161          }
1162        }
1163      }
1164    }
1165    if (s1 !== peg$FAILED) {
1166      peg$parse_();
1167      s3 = peg$parsecomment();
1168      if (s3 === peg$FAILED) {
1169        s3 = null;
1170      }
1171      s0 = peg$f10(s1, s3);
1172    } else {
1173      peg$currPos = s0;
1174      s0 = peg$FAILED;
1175    }
1176    peg$silentFails--;
1177    if (s0 === peg$FAILED) {
1178      s1 = peg$FAILED;
1179      if (peg$silentFails === 0) { peg$fail(peg$e33); }
1180    }
1181
1182    return s0;
1183  }
1184
1185  function peg$parse_() {
1186    var s0, s1;
1187
1188    peg$silentFails++;
1189    s0 = [];
1190    s1 = input.charAt(peg$currPos);
1191    if (peg$r9.test(s1)) {
1192      peg$currPos++;
1193    } else {
1194      s1 = peg$FAILED;
1195      if (peg$silentFails === 0) { peg$fail(peg$e39); }
1196    }
1197    while (s1 !== peg$FAILED) {
1198      s0.push(s1);
1199      s1 = input.charAt(peg$currPos);
1200      if (peg$r9.test(s1)) {
types/index.d.ts 103 lines
1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2export type Color = 'w' | 'b'
3export type Side = { kind: 'human' } | { kind: 'claude'; model: string; effort: Effort }
4export type RulesPreset = 'standard' | 'casual'
5export type TimeControl = { id: string; label: string; baseMs: number; incMs: number }
6export type Result = '1-0' | '0-1' | '1/2-1/2' | '*'
7
8export type Game = {
9  id: number
10  white: Side
11  black: Side
12  tc: TimeControl
13  rules: RulesPreset
14  isClaudeClocked: boolean
15  history: string[]
16  clock: { w: number; b: number }
17  turnStartedAt: number
18  illegal: { w: number; b: number }
19  result: Result
20  termination: string
21  note: string
22  thinking: string
23  isPaused: boolean
24  isHalted: boolean
25  nextMoveAt: number
26  spentUsd: number
27  canSwitchOpponent?: boolean
28  round: string
29  event: string
30}
31
32export type Pairing = { round: number; white: number; black: number; result: Result | null }
33
34export type Tournament = {
35  id: number
36  entrants: Side[]
37  isDouble: boolean
38  tc: TimeControl
39  rules: RulesPreset
40  isClaudeClocked: boolean
41  budgetUsd: number
42  spentUsd: number
43  pairings: Pairing[]
44  current: number | null
45  status: 'setup' | 'running' | 'paused' | 'done'
46  note: string
47}
48
49export type Screen = 'setup' | 'game' | 'tournament' | 'library' | 'settings'
50
51export type Prefs = {
52  tcId: string
53  rules: RulesPreset
54  isClaudeClocked: boolean
55  isDouble: boolean
56  budgetUsd: number
57  spectateMs: number
58  canSwitchOpponent: boolean
59}
60
61// What a game saved mid-play needs to carry on: who plays each side and under which rules.
62export type Resume = { white: Side; black: Side; tcId: string; rules: RulesPreset; isClaudeClocked: boolean; gameId?: number }
63
64export type Review = { history: string[]; white: string; black: string; result: Result; termination: string; resume?: Resume; pgn?: string; savedAt?: string }
65
66export type View = {
67  screen: Screen
68  ply: number | null
69  isFlipped: boolean
70  draft: { white: Side; black: Side }
71  entrants: Side[]
72  pick: { model: string; effort: Effort }
73  review: Review | null
74  confirm?: 'resign' | 'close' | 'replace' | 'resume' | 'delete' | 'clear' | null
75  newMode?: 'casual' | 'tournament'
76  found?: { id: string; name: string }[]
77  searchNote?: string
78  // This game's or tournament's options; whatever is not set here comes from the default settings.
79  opts?: Partial<Prefs>
80}
81
82export type SavedGame = {
83  pgn: string; white: string; black: string; result: Result; termination: string; at: string
84  tournament?: number; pairing?: number; resume?: Resume
85}
86
87export type CostStat = { usd: number; moves: number }
88
89export type BoardProps = {
90  fen: string
91  legal: string[]
92  last: string
93  isLocked: boolean
94  orientation: Color
95  cell: { w: number; h: number }
96}
97
98declare module 'claude-code' {
99  interface PluginState {
100    chessus: { game: Game | null; tournament: Tournament | null; view: View; settings: Prefs; now: number }
101  }
102}
103