/** * pgnFormat.js: move-tree helpers, PGN export text, and PGN import parsing * * The tree-walking helpers (childrenOf/collectMainPath) are shared by the * move-list panel and the PGN exporter so the two can't disagree about * where a variation forks. */ import { Board, VARIANTS } from './board.js'; import { Chess } from '../vendor/chessops/chess.js'; import { parseFen, makeFen } from '../vendor/chessops/fen.js'; import { parsePgnStrict, PgnError } from '../vendor/chessops/pgn.js'; import { parseSan, makeSan } from '../vendor/chessops/san.js'; import { makeSquare } from '../vendor/chessops/util.js'; export const ROOT_ID = -1; export function childrenOf(nodes, rootChildren, id) { return id === ROOT_ID ? rootChildren : nodes[id].children; } // Follows children[0] repeatedly from startId - one line's worth of node // ids (its own "main" continuation). export function collectMainPath(nodes, startId) { const ids = []; let cur = startId; while (cur !== null && cur !== undefined) { ids.push(cur); const kids = nodes[cur].children; cur = kids.length ? kids[0] : null; } return ids; } // Derives {turn, fullmove} from a FEN's 2nd/6th fields, for numbering a // move line that doesn't start from a fresh game. export function parseFenMeta(fen) { const parts = String(fen || '').trim().split(/\s+/); const turn = parts[1] === 'b' ? 'b' : 'w'; const fullmove = parseInt(parts[5], 10); return { turn, fullmove: Number.isFinite(fullmove) && fullmove > 0 ? fullmove : 1 }; } // Same recursive shape as the move-list panel's own rendering - builds PGN // movetext tokens (including variations, in "(...)" notation) instead of // DOM/JSX nodes. function buildMoveTokens(nodes, rootChildren, nodeIds, parentId, whiteToMove, moveNum, skipFirstFork) { const tokens = []; nodeIds.forEach((id, i) => { const node = nodes[id]; if (whiteToMove) { tokens.push(`${moveNum}.`, node.san); } else if (i === 0) { tokens.push(`${moveNum}...`, node.san); } else { tokens.push(node.san); } if (!(i === 0 && skipFirstFork)) { const parentForThis = i === 0 ? parentId : nodeIds[i - 1]; const kids = childrenOf(nodes, rootChildren, parentForThis); for (let v = 1; v < kids.length; v++) { const varPath = collectMainPath(nodes, kids[v]); const varTokens = buildMoveTokens(nodes, rootChildren, varPath, parentForThis, whiteToMove, moveNum, true); tokens.push(`(${varTokens.join(' ')})`); } } if (!whiteToMove) moveNum++; whiteToMove = !whiteToMove; }); return tokens; } // Builds a PGN export: the standard tag-roster header block (plus // FEN/SetUp, since the explorer's starting position is rarely the default // array) followed by movetext ending in a result token. export function buildPgnText() { const { nodes, rootChildren } = Board.getMoveHistory(); if (!rootChildren || rootChildren.length === 0) return ''; const startFen = Board.getStartFen(); const mainPath = collectMainPath(nodes, rootChildren[0]); const finalFen = nodes[mainPath[mainPath.length - 1]].fen; const result = Board.outcomeFromFen(finalFen); const today = new Date(); const pad = n => String(n).padStart(2, '0'); const dateStr = `${today.getFullYear()}.${pad(today.getMonth() + 1)}.${pad(today.getDate())}`; const headers = [ ['Event', 'ChessTB Explorer'], ['Site', '?'], ['Date', dateStr], ['Round', '?'], ['White', '?'], ['Black', '?'], ['Result', result], ]; if (Board.isChess960()) headers.push(['Variant', 'Chess960']); headers.push(['FEN', startFen], ['SetUp', '1']); const headerBlock = headers.map(([k, v]) => `[${k} "${v}"]`).join('\n'); const { turn, fullmove } = parseFenMeta(startFen); const tokens = buildMoveTokens(nodes, rootChildren, mainPath, ROOT_ID, turn === 'w', fullmove, false); tokens.push(result); return `${headerBlock}\n\n${tokens.join(' ')}`; } // Case-insensitive PGN tag lookup - chessops preserves whatever case the // source PGN used. function pgnHeaderValue(headers, name) { const wanted = name.toLowerCase(); for (const key of headers.keys()) { if (key.toLowerCase() === wanted) return headers.get(key); } return null; } // chessops's own PGN Variant parsing maps every Chess960-like name to the // same generic rules Standard uses, so Chess960 identity is read from the // raw header text instead. function isChess960VariantHeader(value) { return /chess\s*960|fischer\s*random/i.test(value || ''); } // Parses and validates the entire game tree, including variations, into // Board's move-history format and the variant declared by its header. // Returns { ok: false, error } on any rejection. export function parsePgn(text) { const trimmed = text.trim(); if (!trimmed) return { ok: false, error: '' }; let games = []; try { games = parsePgnStrict(trimmed); } catch (error) { const msg = (error instanceof PgnError && error.message === 'ERR_PGN_MULTIPLE_GAMES') ? 'PGN contains multiple games. Paste one game at a time.' : 'Could not parse PGN. Check the format and try again.'; return { ok: false, error: msg }; } const game = games[0]; const moves = game ? [...game.moves.mainline()] : []; if (!game || (game.headers.size === 0 && moves.length === 0)) { return { ok: false, error: 'Could not parse PGN. Check the format and try again.' }; } const variant = isChess960VariantHeader(pgnHeaderValue(game.headers, 'Variant')) ? VARIANTS.CHESS960 : VARIANTS.STANDARD; const fenHeader = pgnHeaderValue(game.headers, 'FEN'); if (variant === VARIANTS.CHESS960 && !fenHeader) { return { ok: false, error: 'Chess960 PGN requires a FEN starting position.' }; } let pos = null; if (fenHeader) { const parsed = parseFen(fenHeader); const built = parsed.isErr ? null : Chess.fromSetup(parsed.value); pos = (built && !built.isErr) ? built.value : null; if (!pos) return { ok: false, error: 'Invalid FEN starting position in PGN.' }; } if (!pos) pos = Chess.default(); const startFen = makeFen(pos.toSetup()); const tree = { startFen, nodes: [], rootChildren: [], rootActiveChild: null, currentId: ROOT_ID }; const pending = [{ source: game.moves, parentId: ROOT_ID, pos }]; // An explicit stack handles long lines without recursion limits. while (pending.length) { const frame = pending.pop(); const children = frame.source.children; const next = []; for (const child of children) { const childPos = frame.pos.clone(); const move = parseSan(childPos, child.data.san); if (!move) return { ok: false, error: `Invalid move in PGN: ${child.data.san}` }; const san = makeSan(childPos, move); childPos.play(move); const id = tree.nodes.length; tree.nodes.push({ id, san, fen: makeFen(childPos.toSetup()), from: makeSquare(move.from), to: makeSquare(move.to), parentId: frame.parentId, children: [], activeChild: null, }); if (frame.parentId === ROOT_ID) { tree.rootChildren.push(id); if (tree.rootActiveChild === null) tree.rootActiveChild = id; } else { const parent = tree.nodes[frame.parentId]; parent.children.push(id); if (parent.activeChild === null) parent.activeChild = id; } next.push({ source: child, parentId: id, pos: childPos }); } // Visit the main continuation first, preserving source branch order. pending.push(...next.reverse()); } return { ok: true, tree, variant }; }