UNPKG

chessops

Version:

Chess and chess variant rules and operations

854 lines (780 loc) 25.6 kB
/** * Parse, transform and write PGN. * * ## Parser * * The parser will interpret any input as a PGN, creating a tree of * syntactically valid (but not necessarily legal) moves, skipping any invalid * tokens. * * ```ts * import { parsePgn, startingPosition } from 'chessops/pgn'; * import { parseSan } from 'chessops/san'; * * const pgn = '1. d4 d5 *'; * const games = parsePgn(pgn); * for (const game of games) { * const pos = startingPosition(game.headers).unwrap(); * for (const node of game.moves.mainline()) { * const move = parseSan(pos, node.san); * if (!move) break; // Illegal move * pos.play(move); * } * } * ``` * * ## Streaming parser * * The module also provides a denial-of-service resistant streaming parser. * It can be configured with a budget for reasonable complexity of a single * game, fed with chunks of text, and will yield parsed games as they are * completed. * * ```ts * * import { createReadStream } from 'fs'; * import { PgnParser } from 'chessops/pgn'; * * const stream = createReadStream('games.pgn', { encoding: 'utf-8' }); * * const parser = new PgnParser((game, err) => { * if (err) { * // Budget exceeded. * stream.destroy(err); * } * * // Use game ... * }); * * await new Promise<void>(resolve => * stream * .on('data', (chunk: string) => parser.parse(chunk, { stream: true })) * .on('close', () => { * parser.parse(''); * resolve(); * }) * ); * ``` * * ## Augmenting the game tree * * You can use `walk` to visit all nodes in the game tree, or `transform` * to augment it with user data. * * Both allow you to provide context. You update the context inside the * callback, and it is automatically `clone()`-ed at each fork. * In the example below, the current position `pos` is provided as context. * * ```ts * import { transform } from 'chessops/pgn'; * import { makeFen } from 'chessops/fen'; * import { parseSan, makeSanAndPlay } from 'chessops/san'; * * const pos = startingPosition(game.headers).unwrap(); * game.moves = transform(game.moves, pos, (pos, node) => { * const move = parseSan(pos, node.san); * if (!move) { * // Illegal move. Returning undefined cuts off the tree here. * return; * } * * const san = makeSanAndPlay(pos, move); // Mutating pos! * * return { * ...node, // Keep comments and annotation glyphs * san, // Normalized SAN * fen: makeFen(pos.toSetup()), // Add arbitrary user data to node * }; * }); * ``` * * ## Writing * * Requires each node to at least have a `san` property. * * ``` * import { makePgn } from 'chessops/pgn'; * * const rewrittenPgn = makePgn(game); * ``` * * @packageDocumentation */ import { Result } from '@badrap/result'; import { IllegalSetup, Position, PositionError } from './chess.js'; import { FenError, makeFen, parseFen } from './fen.js'; import { Outcome, Rules, Square } from './types.js'; import { defined, makeSquare, parseSquare } from './util.js'; import { defaultPosition, setupPosition } from './variant.js'; export interface Game<T> { headers: Map<string, string>; comments?: string[]; moves: Node<T>; } export const defaultGame = <T>(initHeaders: () => Map<string, string> = defaultHeaders): Game<T> => ({ headers: initHeaders(), moves: new Node(), }); export class Node<T> { children: ChildNode<T>[] = []; *mainlineNodes(): Iterable<ChildNode<T>> { let node: Node<T> = this; while (node.children.length) { const child = node.children[0]; yield child; node = child; } } *mainline(): Iterable<T> { for (const child of this.mainlineNodes()) yield child.data; } end(): Node<T> { let node: Node<T> = this; while (node.children.length) node = node.children[0]; return node; } } export class ChildNode<T> extends Node<T> { constructor(public data: T) { super(); } } export const isChildNode = <T>(node: Node<T>): node is ChildNode<T> => node instanceof ChildNode; export const extend = <T>(node: Node<T>, data: T[]): Node<T> => { for (const d of data) { const child = new ChildNode(d); node.children.push(child); node = child; } return node; }; export class Box<T> { constructor(public value: T) {} clone(): Box<T> { return new Box(this.value); } } export const transform = <T, U, C extends { clone(): C }>( node: Node<T>, ctx: C, f: (ctx: C, data: T, childIndex: number) => U | undefined, ): Node<U> => { const root = new Node<U>(); const stack = [ { before: node, after: root, ctx, }, ]; let frame; while ((frame = stack.pop())) { for (let childIndex = 0; childIndex < frame.before.children.length; childIndex++) { const ctx = childIndex < frame.before.children.length - 1 ? frame.ctx.clone() : frame.ctx; const childBefore = frame.before.children[childIndex]; const data = f(ctx, childBefore.data, childIndex); if (defined(data)) { const childAfter = new ChildNode(data); frame.after.children.push(childAfter); stack.push({ before: childBefore, after: childAfter, ctx, }); } } } return root; }; export const walk = <T, C extends { clone(): C }>( node: Node<T>, ctx: C, f: (ctx: C, data: T, childIndex: number) => boolean | void, ) => { const stack = [{ node, ctx }]; let frame; while ((frame = stack.pop())) { for (let childIndex = 0; childIndex < frame.node.children.length; childIndex++) { const ctx = childIndex < frame.node.children.length - 1 ? frame.ctx.clone() : frame.ctx; const child = frame.node.children[childIndex]; if (f(ctx, child.data, childIndex) !== false) stack.push({ node: child, ctx }); } } }; export interface PgnNodeData { san: string; startingComments?: string[]; comments?: string[]; nags?: number[]; } export const makeOutcome = (outcome: Outcome | undefined): string => { if (!outcome) return '*'; else if (outcome.winner === 'white') return '1-0'; else if (outcome.winner === 'black') return '0-1'; else return '1/2-1/2'; }; export const parseOutcome = (s: string | undefined): Outcome | undefined => { if (s === '1-0' || s === '1–0' || s === '1—0') return { winner: 'white' }; else if (s === '0-1' || s === '0–1' || s === '0—1') return { winner: 'black' }; else if (s === '1/2-1/2' || s === '1/2–1/2' || s === '1/2—1/2') return { winner: undefined }; else return; }; const escapeHeader = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safeComment = (comment: string): string => comment.replace(/\}/g, ''); const enum MakePgnState { Pre = 0, Sidelines = 1, End = 2, } interface MakePgnFrame { state: MakePgnState; ply: number; node: ChildNode<PgnNodeData>; sidelines: Iterator<ChildNode<PgnNodeData>>; startsVariation: boolean; inVariation: boolean; } export const makePgn = (game: Game<PgnNodeData>): string => { const builder = [], tokens = []; if (game.headers.size) { for (const [key, value] of game.headers.entries()) { builder.push('[', key, ' "', escapeHeader(value), '"]\n'); } builder.push('\n'); } for (const comment of game.comments || []) tokens.push('{', safeComment(comment), '}'); const fen = game.headers.get('FEN'); const initialPly = fen ? parseFen(fen).unwrap( setup => (setup.fullmoves - 1) * 2 + (setup.turn === 'white' ? 0 : 1), _ => 0, ) : 0; const stack: MakePgnFrame[] = []; const variations = game.moves.children[Symbol.iterator](); const firstVariation = variations.next(); if (!firstVariation.done) { stack.push({ state: MakePgnState.Pre, ply: initialPly, node: firstVariation.value, sidelines: variations, startsVariation: false, inVariation: false, }); } let forceMoveNumber = true; while (stack.length) { const frame = stack[stack.length - 1]; if (frame.inVariation) { tokens.push(')'); frame.inVariation = false; forceMoveNumber = true; } switch (frame.state) { case MakePgnState.Pre: for (const comment of frame.node.data.startingComments || []) { tokens.push('{', safeComment(comment), '}'); forceMoveNumber = true; } if (forceMoveNumber || frame.ply % 2 === 0) { tokens.push(Math.floor(frame.ply / 2) + 1 + (frame.ply % 2 ? '...' : '.')); forceMoveNumber = false; } tokens.push(frame.node.data.san); for (const nag of frame.node.data.nags || []) { tokens.push('$' + nag); forceMoveNumber = true; } for (const comment of frame.node.data.comments || []) { tokens.push('{', safeComment(comment), '}'); } frame.state = MakePgnState.Sidelines; // fall through case MakePgnState.Sidelines: { const child = frame.sidelines.next(); if (child.done) { const variations = frame.node.children[Symbol.iterator](); const firstVariation = variations.next(); if (!firstVariation.done) { stack.push({ state: MakePgnState.Pre, ply: frame.ply + 1, node: firstVariation.value, sidelines: variations, startsVariation: false, inVariation: false, }); } frame.state = MakePgnState.End; } else { tokens.push('('); forceMoveNumber = true; stack.push({ state: MakePgnState.Pre, ply: frame.ply, node: child.value, sidelines: [][Symbol.iterator](), startsVariation: true, inVariation: false, }); frame.inVariation = true; } break; } case MakePgnState.End: stack.pop(); } } tokens.push(makeOutcome(parseOutcome(game.headers.get('Result')))); builder.push(tokens.join(' '), '\n'); return builder.join(''); }; export const defaultHeaders = (): Map<string, string> => new Map([ ['Event', '?'], ['Site', '?'], ['Date', '????.??.??'], ['Round', '?'], ['White', '?'], ['Black', '?'], ['Result', '*'], ]); export const emptyHeaders = (): Map<string, string> => new Map(); const BOM = '\ufeff'; const isWhitespace = (line: string): boolean => /^\s*$/.test(line); const isCommentLine = (line: string): boolean => line.startsWith('%'); export interface ParseOptions { stream: boolean; } interface ParserFrame { parent: Node<PgnNodeData>; root: boolean; node?: ChildNode<PgnNodeData>; startingComments?: string[]; } const enum ParserState { Bom = 0, Pre = 1, Headers = 2, Moves = 3, Comment = 4, } export class PgnError extends Error {} export class PgnParser { private lineBuf: string[] = []; private budget: number; private found: boolean; private state: ParserState; private game: Game<PgnNodeData>; private stack: ParserFrame[]; private commentBuf: string[]; constructor( private emitGame: (game: Game<PgnNodeData>, err: PgnError | undefined) => void, private initHeaders: () => Map<string, string> = defaultHeaders, private maxBudget = 1_000_000, ) { this.resetGame(); this.state = ParserState.Bom; } private resetGame() { this.budget = this.maxBudget; this.found = false; this.state = ParserState.Pre; this.game = defaultGame(this.initHeaders); this.stack = [{ parent: this.game.moves, root: true }]; this.commentBuf = []; } private consumeBudget(cost: number) { this.budget -= cost; if (this.budget < 0) throw new PgnError('ERR_PGN_BUDGET'); } parse(data: string, options?: ParseOptions): void { if (this.budget < 0) return; try { let idx = 0; for (;;) { const nlIdx = data.indexOf('\n', idx); if (nlIdx === -1) { break; } const crIdx = nlIdx > idx && data[nlIdx - 1] === '\r' ? nlIdx - 1 : nlIdx; this.consumeBudget(nlIdx - idx); this.lineBuf.push(data.slice(idx, crIdx)); idx = nlIdx + 1; this.handleLine(); } this.consumeBudget(data.length - idx); this.lineBuf.push(data.slice(idx)); if (!options?.stream) { this.handleLine(); this.emit(undefined); } } catch (err: unknown) { this.emit(err as PgnError); } } private handleLine() { let freshLine = true; let line = this.lineBuf.join(''); this.lineBuf = []; continuedLine: for (;;) { switch (this.state) { case ParserState.Bom: if (line.startsWith(BOM)) line = line.slice(BOM.length); this.state = ParserState.Pre; // fall through case ParserState.Pre: if (isWhitespace(line) || isCommentLine(line)) return; this.found = true; this.state = ParserState.Headers; // fall through case ParserState.Headers: { if (isCommentLine(line)) return; let moreHeaders = true; while (moreHeaders) { moreHeaders = false; line = line.replace( /^\s*\[([A-Za-z0-9][A-Za-z0-9_+#=:-]*)\s+"((?:[^"\\]|\\"|\\\\)*)"\]/, (_match, headerName, headerValue) => { this.consumeBudget(200); this.handleHeader(headerName, headerValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\')); moreHeaders = true; freshLine = false; return ''; }, ); } if (isWhitespace(line)) return; this.state = ParserState.Moves; // fall through } case ParserState.Moves: { if (freshLine) { if (isCommentLine(line)) return; if (isWhitespace(line)) return this.emit(undefined); } const tokenRegex = /(?:[NBKRQ]?[a-h]?[1-8]?[-x]?[a-h][1-8](?:=?[nbrqkNBRQK])?|[pnbrqkPNBRQK]?@[a-h][1-8]|[O0o][-–—][O0o](?:[-–—][O0o])?)[+#]?|--|Z0|0000|@@@@|{|;|\$\d{1,4}|[?!]{1,2}|\(|\)|\*|1[-–—]0|0[-–—]1|1\/2[-–—]1\/2/g; let match; while ((match = tokenRegex.exec(line))) { const frame = this.stack[this.stack.length - 1]; let token = match[0]; if (token === ';') return; else if (token.startsWith('$')) this.handleNag(parseInt(token.slice(1), 10)); else if (token === '!') this.handleNag(1); else if (token === '?') this.handleNag(2); else if (token === '!!') this.handleNag(3); else if (token === '??') this.handleNag(4); else if (token === '!?') this.handleNag(5); else if (token === '?!') this.handleNag(6); else if ( token === '1-0' || token === '1–0' || token === '1—0' || token === '0-1' || token === '0–1' || token === '0—1' || token === '1/2-1/2' || token === '1/2–1/2' || token === '1/2—1/2' || token === '*' ) { if (this.stack.length === 1 && token !== '*') this.handleHeader('Result', token); } else if (token === '(') { this.consumeBudget(100); this.stack.push({ parent: frame.parent, root: false }); } else if (token === ')') { if (this.stack.length > 1) this.stack.pop(); } else if (token === '{') { const openIndex = tokenRegex.lastIndex; const beginIndex = line[openIndex] === ' ' ? openIndex + 1 : openIndex; line = line.slice(beginIndex); this.state = ParserState.Comment; continue continuedLine; } else { this.consumeBudget(100); if (token.startsWith('O') || token.startsWith('0') || token.startsWith('o')) { token = token.replace(/[0o]/g, 'O').replace(/[–—]/g, '-'); } else if (token === 'Z0' || token === '0000' || token === '@@@@') token = '--'; if (frame.node) frame.parent = frame.node; frame.node = new ChildNode({ san: token, startingComments: frame.startingComments, }); frame.startingComments = undefined; frame.root = false; frame.parent.children.push(frame.node); } } return; } case ParserState.Comment: { const closeIndex = line.indexOf('}'); if (closeIndex === -1) { this.commentBuf.push(line); return; } else { const endIndex = closeIndex > 0 && line[closeIndex - 1] === ' ' ? closeIndex - 1 : closeIndex; this.commentBuf.push(line.slice(0, endIndex)); this.handleComment(); line = line.slice(closeIndex); this.state = ParserState.Moves; freshLine = false; } } } } } private handleHeader(name: string, value: string) { this.game.headers.set(name, name === 'Result' ? makeOutcome(parseOutcome(value)) : value); } private handleNag(nag: number) { this.consumeBudget(50); const frame = this.stack[this.stack.length - 1]; if (frame.node) { frame.node.data.nags ||= []; frame.node.data.nags.push(nag); } } private handleComment() { this.consumeBudget(100); const frame = this.stack[this.stack.length - 1]; const comment = this.commentBuf.join('\n'); this.commentBuf = []; if (frame.node) { frame.node.data.comments ||= []; frame.node.data.comments.push(comment); } else if (frame.root) { this.game.comments ||= []; this.game.comments.push(comment); } else { frame.startingComments ||= []; frame.startingComments.push(comment); } } private emit(err: PgnError | undefined) { if (this.state === ParserState.Comment) this.handleComment(); if (err) return this.emitGame(this.game, err); if (this.found) this.emitGame(this.game, undefined); this.resetGame(); } } export const parsePgn = (pgn: string, initHeaders: () => Map<string, string> = defaultHeaders): Game<PgnNodeData>[] => { const games: Game<PgnNodeData>[] = []; new PgnParser(game => games.push(game), initHeaders, NaN).parse(pgn); return games; }; export const parseVariant = (variant: string | undefined): Rules | undefined => { switch ((variant || 'chess').toLowerCase()) { case 'chess': case 'chess960': case 'chess 960': case 'standard': case 'from position': case 'classical': case 'normal': case 'fischerandom': // Cute Chess case 'fischerrandom': case 'fischer random': case 'wild/0': case 'wild/1': case 'wild/2': case 'wild/3': case 'wild/4': case 'wild/5': case 'wild/6': case 'wild/7': case 'wild/8': case 'wild/8a': return 'chess'; case 'crazyhouse': case 'crazy house': case 'house': case 'zh': return 'crazyhouse'; case 'king of the hill': case 'koth': case 'kingofthehill': return 'kingofthehill'; case 'three-check': case 'three check': case 'threecheck': case 'three check chess': case '3-check': case '3 check': case '3check': return '3check'; case 'antichess': case 'anti chess': case 'anti': return 'antichess'; case 'atomic': case 'atom': case 'atomic chess': return 'atomic'; case 'horde': case 'horde chess': return 'horde'; case 'racing kings': case 'racingkings': case 'racing': case 'race': return 'racingkings'; default: return; } }; export const makeVariant = (rules: Rules): string | undefined => { switch (rules) { case 'chess': return; case 'crazyhouse': return 'Crazyhouse'; case 'racingkings': return 'Racing Kings'; case 'horde': return 'Horde'; case 'atomic': return 'Atomic'; case 'antichess': return 'Antichess'; case '3check': return 'Three-check'; case 'kingofthehill': return 'King of the Hill'; } }; export const startingPosition = (headers: Map<string, string>): Result<Position, FenError | PositionError> => { const rules = parseVariant(headers.get('Variant')); if (!rules) return Result.err(new PositionError(IllegalSetup.Variant)); const fen = headers.get('FEN'); if (fen) return parseFen(fen).chain(setup => setupPosition(rules, setup)); else return Result.ok(defaultPosition(rules)); }; export const setStartingPosition = (headers: Map<string, string>, pos: Position) => { const variant = makeVariant(pos.rules); if (variant) headers.set('Variant', variant); else headers.delete('Variant'); const fen = makeFen(pos.toSetup()); const defaultFen = makeFen(defaultPosition(pos.rules).toSetup()); if (fen !== defaultFen) headers.set('FEN', fen); else headers.delete('FEN'); }; export type CommentShapeColor = 'green' | 'red' | 'yellow' | 'blue'; export interface CommentShape { color: CommentShapeColor; from: Square; to: Square; } export type EvaluationPawns = { pawns: number; depth?: number }; export type EvaluationMate = { mate: number; depth?: number }; export type Evaluation = EvaluationPawns | EvaluationMate; export const isPawns = (ev: Evaluation): ev is EvaluationPawns => 'pawns' in ev; export const isMate = (ev: Evaluation): ev is EvaluationMate => 'mate' in ev; export interface Comment { text: string; shapes: CommentShape[]; clock?: number; emt?: number; evaluation?: Evaluation; } const makeClk = (seconds: number): string => { seconds = Math.max(0, seconds); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); seconds = (seconds % 3600) % 60; return `${hours}:${minutes.toString().padStart(2, '0')}:${ seconds.toLocaleString('en', { minimumIntegerDigits: 2, maximumFractionDigits: 3, }) }`; }; const makeCommentShapeColor = (color: CommentShapeColor): 'G' | 'R' | 'Y' | 'B' => { switch (color) { case 'green': return 'G'; case 'red': return 'R'; case 'yellow': return 'Y'; case 'blue': return 'B'; } }; function parseCommentShapeColor(str: 'G' | 'R' | 'Y' | 'B'): CommentShapeColor; function parseCommentShapeColor(str: string): CommentShapeColor | undefined; function parseCommentShapeColor(str: string): CommentShapeColor | undefined { switch (str) { case 'G': return 'green'; case 'R': return 'red'; case 'Y': return 'yellow'; case 'B': return 'blue'; default: return; } } const makeCommentShape = (shape: CommentShape): string => shape.to === shape.from ? `${makeCommentShapeColor(shape.color)}${makeSquare(shape.to)}` : `${makeCommentShapeColor(shape.color)}${makeSquare(shape.from)}${makeSquare(shape.to)}`; const parseCommentShape = (str: string): CommentShape | undefined => { const color = parseCommentShapeColor(str.slice(0, 1)); const from = parseSquare(str.slice(1, 3)); const to = parseSquare(str.slice(3, 5)); if (!color || !defined(from)) return; if (str.length === 3) return { color, from, to: from }; if (str.length === 5 && defined(to)) return { color, from, to }; return; }; const makeEval = (ev: Evaluation): string => { const str = isMate(ev) ? '#' + ev.mate : ev.pawns.toFixed(2); return defined(ev.depth) ? str + ',' + ev.depth : str; }; export const makeComment = (comment: Partial<Comment>): string => { const builder = []; if (defined(comment.text)) builder.push(comment.text); const circles = (comment.shapes || []).filter(shape => shape.to === shape.from).map(makeCommentShape); if (circles.length) builder.push(`[%csl ${circles.join(',')}]`); const arrows = (comment.shapes || []).filter(shape => shape.to !== shape.from).map(makeCommentShape); if (arrows.length) builder.push(`[%cal ${arrows.join(',')}]`); if (comment.evaluation) builder.push(`[%eval ${makeEval(comment.evaluation)}]`); if (defined(comment.emt)) builder.push(`[%emt ${makeClk(comment.emt)}]`); if (defined(comment.clock)) builder.push(`[%clk ${makeClk(comment.clock)}]`); return builder.join(' '); }; export const parseComment = (comment: string): Comment => { let emt, clock, evaluation; const shapes: CommentShape[] = []; const text = comment .replace( /\s?\[%(emt|clk)\s(\d{1,5}):(\d{1,2}):(\d{1,2}(?:\.\d{0,3})?)\]\s?/g, (_, annotation, hours, minutes, seconds) => { const value = parseInt(hours, 10) * 3600 + parseInt(minutes, 10) * 60 + parseFloat(seconds); if (annotation === 'emt') emt = value; else if (annotation === 'clk') clock = value; return ' '; }, ) .replace( /\s?\[%(?:csl|cal)\s([RGYB][a-h][1-8](?:[a-h][1-8])?(?:,[RGYB][a-h][1-8](?:[a-h][1-8])?)*)\]\s?/g, (_, arrows) => { for (const arrow of arrows.split(',')) { shapes.push(parseCommentShape(arrow)!); } return ' '; }, ) .replace( /\s?\[%eval\s(?:#([+-]?\d{1,5})|([+-]?(?:\d{1,5}|\d{0,5}\.\d{1,2})))(?:,(\d{1,5}))?\]\s?/g, (_, mate, pawns, d) => { const depth = d && parseInt(d, 10); evaluation = mate ? { mate: parseInt(mate, 10), depth } : { pawns: parseFloat(pawns), depth }; return ' '; }, ) .trim(); return { text, shapes, emt, clock, evaluation, }; };