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

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.
env block of ~/.claude/settings.json: "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
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
modelPricingrates). 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.
/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.
/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.
Moves use standard chess notation (SAN):
e4, exd5, e8=Q.N knight, B bishop, R rook, Q queen, K king. So Nf3 means "the knight that can reach f3".O-O (short) or O-O-O (long). 0-0 and lowercase work too.Nbd2, R1e2. chessus! tells you when that's needed and lists the options.e2e4, e2-e4, e7e8q.With the board focused (click it), the arrow keys move a cursor and Enter or Space picks.
The top row has four places, plus standings while a tournament runs:
» 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.x to delete it, or delete them all from the bottom of the list. Both ask first.Pickers are arrow buttons, ‹ value ›. Click them, or Tab to one and press Enter.
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:
| Key | Does |
|---|---|
p | pause or resume |
s | save to resume later (it goes under replays) |
c | copy the game as PGN, to share |
d | offer Claude a draw |
m | claim a draw (threefold repetition or 50-move rule), when you can |
x | resign (asks first) |
r | retry, if Claude couldn't be reached |
v | flip the board |
Replays, and a game that just ended:
| Key | Does |
|---|---|
a b f e | start, back, forward, end |
r | resume, for a game you saved mid-play |
n | new game, after a game ends |
t | tournament standings |
x | delete a saved game (asks first) |
q | close 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.
/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.
| Command | Does |
|---|---|
/chess | Open 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 pause | Freeze both clocks and Claude |
/chess resume | Carry on a paused game |
/chess save | Save the game in progress to resume later; open it under replays and press r |
/chess resign | Resign, after a yes/no question in Claude Code's own picker |
/chess close | Close the pane. Mid-game it pauses and saves first |
| Command | Does |
|---|---|
/chess keys | List 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 |
| Command | Does |
|---|---|
/chess replays | Famous games, online search, saved games. /chess library does the same |
/chess tournament | Tournament setup, or standings if one is running |
/chess settings | Your defaults for new games |
| Command | Does |
|---|---|
/chess last | Replay 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 help | This list |
An unfinished casual game survives a restart. It comes back paused, and the time you were away doesn't count.
FIDE Laws of Chess, using chess.js for move generation, plus the parts it doesn't cover:
m).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.
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:
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.
/chess keys lists what the pane shows, /chess key and /chess set work it, and /chess move e4 plays.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.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.
hooks/register.tsx 1555 lines1import { 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 <url or .pgn file> · /chess export <path></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 lines1// 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')
41hooks/claude.ts 67 lines1import 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)
67hooks/game.ts 272 lines1import 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}
272hooks/tournament.ts 84 lines1import 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}
84hooks/board.tsx 168 lines1import 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
168hooks/vendor/chess.js 3375 lines1/*
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 lines1export 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