Spaces:
Running
Running
File size: 8,076 Bytes
e16348e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | /**
* 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 };
}
|