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 };
}