llm-chess-mcp 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +26 -15
  2. package/dist/analysis-boundary.d.ts +2 -0
  3. package/dist/analysis-boundary.js +49 -0
  4. package/dist/chess-copy.d.ts +5 -2
  5. package/dist/chess-copy.js +70 -423
  6. package/dist/chess-move.d.ts +7 -0
  7. package/dist/chess-move.js +4 -0
  8. package/dist/chess.d.ts +2 -1
  9. package/dist/chess.js +5 -7
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.js +3 -3
  12. package/dist/domain.d.ts +4 -0
  13. package/dist/domain.js +4 -0
  14. package/dist/engines/stockfish-info.js +5 -5
  15. package/dist/engines/stockfish.js +107 -106
  16. package/dist/explorer-limiter.d.ts +1 -1
  17. package/dist/explorer-limiter.js +1 -1
  18. package/dist/explorer.js +7 -6
  19. package/dist/games.d.ts +2 -0
  20. package/dist/games.js +39 -26
  21. package/dist/http-body.d.ts +13 -0
  22. package/dist/http-body.js +103 -0
  23. package/dist/http-config.d.ts +33 -0
  24. package/dist/http-config.js +105 -0
  25. package/dist/http-posts.d.ts +11 -0
  26. package/dist/http-posts.js +37 -0
  27. package/dist/http-response.d.ts +3 -0
  28. package/dist/http-response.js +17 -0
  29. package/dist/http-runtime.d.ts +15 -0
  30. package/dist/http-runtime.js +308 -0
  31. package/dist/http.d.ts +2 -22
  32. package/dist/http.js +51 -471
  33. package/dist/human-boundary.d.ts +2 -0
  34. package/dist/human-boundary.js +34 -0
  35. package/dist/index.js +15 -15
  36. package/dist/intents.d.ts +1 -1
  37. package/dist/intents.js +60 -26
  38. package/dist/lifecycle.d.ts +2 -0
  39. package/dist/lifecycle.js +24 -0
  40. package/dist/maia3/inference.js +20 -5
  41. package/dist/pgn-lex.d.ts +14 -0
  42. package/dist/pgn-lex.js +72 -0
  43. package/dist/pgn-serialize.d.ts +4 -0
  44. package/dist/pgn-serialize.js +103 -0
  45. package/dist/pgn-shared.d.ts +13 -0
  46. package/dist/pgn-shared.js +43 -0
  47. package/dist/pgn.d.ts +1 -3
  48. package/dist/pgn.js +183 -432
  49. package/dist/position-validation.d.ts +3 -0
  50. package/dist/position-validation.js +372 -0
  51. package/dist/server.js +2 -18
  52. package/dist/services.js +22 -30
  53. package/dist/tool-fields.d.ts +2 -1
  54. package/dist/tool-fields.js +2 -1
  55. package/dist/tool-inputs.js +8 -8
  56. package/dist/tool-meta.d.ts +1 -1
  57. package/dist/tool-meta.js +1 -1
  58. package/dist/tool-result.d.ts +22 -3
  59. package/dist/tool-result.js +30 -13
  60. package/dist/tool-schemas.js +24 -11
  61. package/dist/tools/analysis.js +6 -39
  62. package/dist/tools/candidates.js +62 -4
  63. package/dist/tools/explorer.js +1 -1
  64. package/dist/tools/game.js +8 -8
  65. package/docs/architecture.md +40 -24
  66. package/package.json +1 -2
package/README.md CHANGED
@@ -72,8 +72,11 @@ await server.close();
72
72
  The root API also exports `buildServer`, `GameStore`, `ChessError`,
73
73
  `ExplorerError`, the service/domain types needed to provide custom
74
74
  `AppServices`, and safe chess helpers including `parseImportedPgn`, `pgnOf`,
75
- and `snapshotChess`. New integrations should use the package root. Legacy
76
- `dist/` subpath imports remain available for compatibility.
75
+ and `snapshotChess`. The package root is the supported public API. Deep imports
76
+ under `dist/` are intentionally not exported and will fail with
77
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`; use named root exports instead.
78
+ This removes the previous `dist/*` compatibility exports and is a breaking
79
+ change for integrations that imported internal modules.
77
80
 
78
81
  `bodyTimeoutMs` limits HTTP body upload time; it is not a whole-tool deadline.
79
82
  The deprecated `requestTimeoutMs` alias remains supported when `bodyTimeoutMs`
@@ -173,7 +176,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
173
176
  | Tool | Description |
174
177
  |---|---|
175
178
  | `create_game` | Create a game (optionally from a FEN), returns `game_id` |
176
- | `delete_game` | Delete a game and free its session |
179
+ | `delete_game` | Delete a process-shared game and free game capacity |
177
180
  | `game_state` | Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
178
181
  | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
179
182
  | `game_legal_moves` | All legal moves with metadata |
@@ -273,10 +276,11 @@ rejected:
273
276
  - Up to 1,000 games are retained per process; idle games expire after one hour.
274
277
  - `move_evaluate` accepts at most 10 moves per call.
275
278
  - Imported and exported PGNs are limited to 1 MiB, 256 headers, and 4,096
276
- plies; stored game histories use the same ply limit. Imports also cap the
277
- mainline and variations together at 32,768 structural elements and 16 KiB
278
- per lexical token. Every variation is legality-checked; game state retains
279
- the mainline. UTF-8 BOMs and standard escaped header values are supported.
279
+ plies; stored snapshots enforce the same byte, header, token, and ply resource
280
+ bounds. Imports also cap the mainline and variations together at 32,768
281
+ structural elements and 16 KiB per lexical token. Every variation is
282
+ legality-checked; game state retains the mainline. UTF-8 BOMs and standard
283
+ escaped header values are supported.
280
284
  - Custom FENs reject inconsistent castling/en-passant metadata and impossible
281
285
  pawn or promotion material.
282
286
  - Stockfish accepts up to 32 active or queued analyses. Maia runs at most two
@@ -284,13 +288,18 @@ rejected:
284
288
  - Lichess Explorer requests run one at a time and share 429 cooldowns.
285
289
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
286
290
  after 30 minutes. An open GET/SSE stream keeps its session active.
287
- - HTTP accepts bodies up to 2 MiB. After body parsing, it permits 16 concurrent
288
- POST dispatches and downstream compute/network jobs process-wide, with two of
289
- each per session. A separate bounded control lane keeps MCP cancellation
290
- available when normal POST slots are full. Work keeps its slot after a raw
291
- disconnect until it settles. HTTP
292
- also caps connections at 128 and applies a 15-second body upload deadline
293
- plus bounded header, socket, and keep-alive timeouts.
291
+ - HTTP accepts bodies up to 2 MiB under normal body-parser capacity. Once those
292
+ parsers are full, an overflow request receives only a small, up-to-8 KiB
293
+ probe; only a complete MCP cancellation notification can proceed, and no
294
+ accepted parser is preempted. The listener's connection limit bounds overflow
295
+ probes. After body parsing, it permits 16 concurrent POST dispatches and
296
+ downstream compute/network jobs process-wide, with two of each per session.
297
+ A separate bounded control lane prioritizes MCP cancellation when normal
298
+ dispatch capacity is full. If an existing-session POST response closes before
299
+ it finishes, its session is closed and its work is aborted; an uncooperative
300
+ downstream operation still holds capacity until it settles. HTTP also caps
301
+ connections at 128 and applies a 15-second body upload deadline plus bounded
302
+ header, socket, and keep-alive timeouts.
294
303
 
295
304
  Programmatic users can override the HTTP limits through `HttpServerOptions`.
296
305
  These safeguards do not replace public-edge quotas: a public deployment must
@@ -303,7 +312,9 @@ its UCI queue boundary, drains queued work during shutdown, and rejects new
303
312
  analysis until teardown completes. Lichess fetch and retry waits abort
304
313
  immediately. Maia runs native inference in dedicated child processes; cancelling
305
314
  active work terminates its child, while queued cancellation is immediate. A raw
306
- HTTP disconnect alone is not a cancellation signal.
315
+ response disconnect for an existing-session POST closes that session and aborts
316
+ its work. Reconnect with a new session, then re-read the process-shared game
317
+ state before retrying a move.
307
318
 
308
319
  ## Intents
309
320
 
@@ -0,0 +1,2 @@
1
+ import type { SfLine } from "./domain.js";
2
+ export declare function validateAnalysisLines(lines: readonly SfLine[], requestedMultipv: number): void;
@@ -0,0 +1,49 @@
1
+ import { MAX_MULTIPV, WDL_TOTAL } from "./domain.js";
2
+ function validScore(value) {
3
+ return value === null || (typeof value === "number" && Number.isFinite(value));
4
+ }
5
+ function validWdl(value) {
6
+ return value === null ||
7
+ (Array.isArray(value) &&
8
+ value.length === 3 &&
9
+ value.every((count) => typeof count === "number" &&
10
+ Number.isSafeInteger(count) &&
11
+ count >= 0 &&
12
+ count <= WDL_TOTAL) &&
13
+ value[0] + value[1] + value[2] === WDL_TOTAL);
14
+ }
15
+ export function validateAnalysisLines(lines, requestedMultipv) {
16
+ if (!Number.isSafeInteger(requestedMultipv) ||
17
+ requestedMultipv < 1 ||
18
+ requestedMultipv > MAX_MULTIPV) {
19
+ throw new RangeError("invalid requested analysis multipv");
20
+ }
21
+ if (!Array.isArray(lines) || lines.length > requestedMultipv) {
22
+ throw new RangeError("analysis returned too many lines");
23
+ }
24
+ const ranks = new Set();
25
+ for (const line of lines) {
26
+ if (typeof line !== "object" ||
27
+ line === null ||
28
+ Array.isArray(line) ||
29
+ !Number.isSafeInteger(line.multipv) ||
30
+ line.multipv < 1 ||
31
+ line.multipv > requestedMultipv ||
32
+ ranks.has(line.multipv)) {
33
+ throw new RangeError("invalid analysis multipv rank");
34
+ }
35
+ ranks.add(line.multipv);
36
+ if (!validScore(line.scoreCp) || !validScore(line.scoreMate)) {
37
+ throw new RangeError("analysis line has an invalid score");
38
+ }
39
+ if (line.scoreCp !== null && line.scoreMate !== null) {
40
+ throw new RangeError("analysis line has conflicting scores");
41
+ }
42
+ if (!validWdl(line.wdl))
43
+ throw new RangeError("analysis line has invalid WDL");
44
+ if (!Array.isArray(line.pv) ||
45
+ !Array.from(line.pv).every((move) => typeof move === "string" && move.length > 0)) {
46
+ throw new RangeError("analysis line has invalid PV");
47
+ }
48
+ }
49
+ }
@@ -1,4 +1,7 @@
1
1
  import { Chess } from "chess.js";
2
- export declare function assertSafeFenCounters(fen: string): void;
3
- export declare function assertLegalPosition(chess: Chess): void;
2
+ export { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
3
+ export declare function snapshotChessWithPgn(chess: Chess): {
4
+ chess: Chess;
5
+ pgn: string;
6
+ };
4
7
  export declare function snapshotChess(chess: Chess): Chess;
@@ -1,61 +1,11 @@
1
1
  import { Chess } from "chess.js";
2
+ import { materializeMove } from "./chess-move.js";
2
3
  import { ChessError } from "./errors.js";
3
- import { assertPgnPlyLimit, replacePgnHeaders } from "./pgn-shared.js";
4
- const ORIGINAL_PIECES = {
5
- q: 1,
6
- r: 2,
7
- n: 2,
8
- };
4
+ import { assertPgnPlyLimit, pgnHeaderIndex, pgnSetupHeaders, replacePgnHeaders, terminalPgnResult, } from "./pgn-shared.js";
5
+ import { serializePgn } from "./pgn-serialize.js";
6
+ import { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
7
+ export { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
9
8
  const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
10
- function squareColor(square) {
11
- return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
12
- }
13
- function minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) {
14
- const requirements = chess
15
- .findPiece({ type: "p", color })
16
- .map((square) => ({
17
- kind: "pawn",
18
- advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
19
- file: square.charCodeAt(0) - 97,
20
- }));
21
- for (const bishopColor of [0, 1]) {
22
- for (let count = 0; count < promotedBishops[bishopColor]; count += 1) {
23
- requirements.push({ kind: "bishop", color: bishopColor });
24
- }
25
- }
26
- for (let count = 0; count < promotedPieces; count += 1) {
27
- requirements.push({ kind: "promotion" });
28
- }
29
- let costs = new Map([[0, 0]]);
30
- for (const requirement of requirements) {
31
- const next = new Map();
32
- for (const [mask, cost] of costs) {
33
- for (let original = 0; original < 8; original += 1) {
34
- const bit = 1 << original;
35
- if (mask & bit)
36
- continue;
37
- let captures = 0;
38
- if (requirement.kind === "pawn") {
39
- captures = Math.abs(original - requirement.file);
40
- if (captures > requirement.advances)
41
- continue;
42
- }
43
- else if (requirement.kind === "bishop") {
44
- const promotionRank = color === "w" ? 8 : 1;
45
- const promotionColor = ((original + promotionRank) % 2);
46
- captures = promotionColor === requirement.color ? 0 : 1;
47
- }
48
- const nextMask = mask | bit;
49
- next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
50
- }
51
- }
52
- costs = next;
53
- }
54
- return Math.min(...costs.values());
55
- }
56
- function nonKingMaterial(chess, color) {
57
- return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
58
- }
59
9
  function clonedChess(chess) {
60
10
  const state = Object.create(null);
61
11
  for (const key of CHESS_STATE_KEYS) {
@@ -82,336 +32,8 @@ function clonedChess(chess) {
82
32
  function exactFen(chess) {
83
33
  return chess.fen({ forceEnpassantSquare: true });
84
34
  }
85
- const FILES = "abcdefgh";
86
- function squareAt(file, rank) {
87
- return `${FILES[file]}${rank}`;
88
- }
89
- function squareCoordinates(square) {
90
- return [square.charCodeAt(0) - 97, Number(square[1])];
91
- }
92
- function squaresBetween(from, to) {
93
- const [fromFile, fromRank] = squareCoordinates(from);
94
- const [toFile, toRank] = squareCoordinates(to);
95
- const fileDistance = toFile - fromFile;
96
- const rankDistance = toRank - fromRank;
97
- if (fileDistance !== 0 &&
98
- rankDistance !== 0 &&
99
- Math.abs(fileDistance) !== Math.abs(rankDistance)) {
100
- return [];
101
- }
102
- const fileStep = Math.sign(fileDistance);
103
- const rankStep = Math.sign(rankDistance);
104
- const squares = [];
105
- let file = fromFile + fileStep;
106
- let rank = fromRank + rankStep;
107
- while (file !== toFile || rank !== toRank) {
108
- squares.push(squareAt(file, rank));
109
- file += fileStep;
110
- rank += rankStep;
111
- }
112
- return squares;
113
- }
114
- function priorFullmove(fields, previous) {
115
- const fullmove = fields[5] ?? "";
116
- if (!isSafeDecimal(fullmove, 1))
117
- return null;
118
- const value = Number(fullmove) - (previous === "b" ? 1 : 0);
119
- return value >= 1 ? value : null;
120
- }
121
- function priorChess(setup, previous, castling, enPassant, halfmove, fullmove) {
122
- try {
123
- return new Chess([
124
- setup.fen().split(" ")[0],
125
- previous,
126
- castling,
127
- enPassant,
128
- String(halfmove),
129
- String(fullmove),
130
- ].join(" "));
131
- }
132
- catch {
133
- return null;
134
- }
135
- }
136
- function reachesPosition(prior, move, currentFen) {
137
- if (!prior)
138
- return false;
139
- try {
140
- assertLegalPositionInternal(prior, false);
141
- prior.move(move);
142
- return exactFen(prior) === currentFen;
143
- }
144
- catch {
145
- return false;
146
- }
147
- }
148
- function hasPiece(chess, square, type, color) {
149
- const piece = chess.get(square);
150
- return piece?.type === type && piece.color === color;
151
- }
152
- function assertCastlingPosition(chess, color) {
153
- const rank = color === "w" ? "1" : "8";
154
- const rights = chess.getCastlingRights(color);
155
- if ((rights.k || rights.q) &&
156
- !hasPiece(chess, `e${rank}`, "k", color)) {
157
- throw new ChessError("INVALID_FEN", "FEN castling rights require a home king");
158
- }
159
- if (rights.k && !hasPiece(chess, `h${rank}`, "r", color)) {
160
- throw new ChessError("INVALID_FEN", "FEN kingside castling rights require a home rook");
161
- }
162
- if (rights.q && !hasPiece(chess, `a${rank}`, "r", color)) {
163
- throw new ChessError("INVALID_FEN", "FEN queenside castling rights require a home rook");
164
- }
165
- }
166
- function assertEnPassantPosition(chess) {
167
- const fields = chess.fen({ forceEnpassantSquare: true }).split(" ");
168
- const target = fields[3];
169
- if (!target || target === "-")
170
- return;
171
- const turn = chess.turn();
172
- const file = target[0];
173
- const targetRank = turn === "w" ? "6" : "3";
174
- const pawnRank = turn === "w" ? "5" : "4";
175
- const originRank = turn === "w" ? "7" : "2";
176
- const pawnColor = turn === "w" ? "b" : "w";
177
- const targetSquare = target;
178
- const pawnSquare = `${file}${pawnRank}`;
179
- const originSquare = `${file}${originRank}`;
180
- if (target[1] !== targetRank ||
181
- chess.get(targetSquare) !== undefined ||
182
- !hasPiece(chess, pawnSquare, "p", pawnColor) ||
183
- chess.get(originSquare) !== undefined ||
184
- fields[4] !== "0" ||
185
- (turn === "w" && fields[5] === "1")) {
186
- throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
187
- }
188
- const fullmove = fields[5] ?? "";
189
- if (!isSafeDecimal(fullmove, 1)) {
190
- throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
191
- }
192
- const setup = new Chess(fields.join(" "));
193
- setup.remove(pawnSquare);
194
- setup.put({ type: "p", color: pawnColor }, originSquare);
195
- const priorFullmove = turn === "w" ? Number(fullmove) - 1 : Number(fullmove);
196
- const priorFen = [
197
- setup.fen().split(" ")[0],
198
- pawnColor,
199
- fields[2],
200
- "-",
201
- "0",
202
- String(priorFullmove),
203
- ].join(" ");
204
- const prior = new Chess(priorFen);
205
- assertLegalPosition(prior);
206
- try {
207
- prior.move({ from: originSquare, to: pawnSquare });
208
- }
209
- catch {
210
- throw new ChessError("INVALID_FEN", "FEN en passant target does not follow a legal double pawn move");
211
- }
212
- const transitioned = exactFen(prior).split(" ");
213
- transitioned[3] = target;
214
- if (transitioned.join(" ") !== fields.join(" ")) {
215
- throw new ChessError("INVALID_FEN", "FEN en passant target does not match the previous position");
216
- }
217
- }
218
- function moveDescriptor(move) {
219
- const base = { from: move.from, to: move.to };
220
- return move.promotion ? { ...base, promotion: move.promotion } : base;
221
- }
222
- function isSafeDecimal(value, minimum) {
223
- return (/^(?:0|[1-9]\d*)$/.test(value) &&
224
- Number.isSafeInteger(Number(value)) &&
225
- Number(value) >= minimum);
226
- }
227
- const CAPTURED_PIECES = ["p", "n", "b", "r", "q"];
228
- function ordinaryDoubleCheckPredecessor(chess, king, checkers) {
229
- const currentFen = exactFen(chess);
230
- const fields = currentFen.split(" ");
231
- const previous = chess.turn() === "w" ? "b" : "w";
232
- const active = chess.turn();
233
- const fullmove = priorFullmove(fields, previous);
234
- const currentHalfmove = Number(fields[4]);
235
- if (fullmove === null || !Number.isSafeInteger(currentHalfmove))
236
- return false;
237
- for (let movedIndex = 0; movedIndex < 2; movedIndex += 1) {
238
- const to = checkers[movedIndex];
239
- const other = checkers[1 - movedIndex];
240
- const moved = chess.get(to);
241
- const otherType = chess.get(other)?.type;
242
- if (!moved ||
243
- moved.color !== previous ||
244
- (otherType !== "b" && otherType !== "r" && otherType !== "q")) {
245
- continue;
246
- }
247
- for (const from of squaresBetween(other, king)) {
248
- if (chess.get(from) !== undefined)
249
- continue;
250
- for (const captured of [undefined, ...CAPTURED_PIECES]) {
251
- if (captured === "p" &&
252
- (to[1] === "1" || to[1] === "8")) {
253
- continue;
254
- }
255
- const halfmove = moved.type === "p" || captured ? 0 : currentHalfmove - 1;
256
- if (halfmove < 0 || (moved.type === "p" || captured) && currentHalfmove !== 0) {
257
- continue;
258
- }
259
- const setup = new Chess(currentFen);
260
- setup.remove(to);
261
- setup.put(moved, from);
262
- if (captured)
263
- setup.put({ type: captured, color: active }, to);
264
- const prior = priorChess(setup, previous, fields[2], "-", halfmove, fullmove);
265
- if (reachesPosition(prior, { from, to }, currentFen))
266
- return true;
267
- const promotionRank = previous === "w" ? "8" : "1";
268
- const pawnRank = previous === "w" ? "7" : "2";
269
- if (moved.type !== "p" &&
270
- moved.type !== "k" &&
271
- to[1] === promotionRank &&
272
- from[1] === pawnRank) {
273
- const promotedSetup = new Chess(currentFen);
274
- promotedSetup.remove(to);
275
- promotedSetup.put({ type: "p", color: previous }, from);
276
- if (captured) {
277
- promotedSetup.put({ type: captured, color: active }, to);
278
- }
279
- const promotionPrior = priorChess(promotedSetup, previous, fields[2], "-", 0, fullmove);
280
- if (currentHalfmove === 0 &&
281
- reachesPosition(promotionPrior, { from, to, promotion: moved.type }, currentFen)) {
282
- return true;
283
- }
284
- }
285
- }
286
- }
287
- }
288
- return false;
289
- }
290
- function enPassantDoubleCheckPredecessor(chess) {
291
- const currentFen = exactFen(chess);
292
- const fields = currentFen.split(" ");
293
- if (fields[3] !== "-" || fields[4] !== "0")
294
- return false;
295
- const previous = chess.turn() === "w" ? "b" : "w";
296
- const active = chess.turn();
297
- const fullmove = priorFullmove(fields, previous);
298
- if (fullmove === null)
299
- return false;
300
- const destinationRank = previous === "w" ? 6 : 3;
301
- const originRank = previous === "w" ? 5 : 4;
302
- for (const to of chess.findPiece({ type: "p", color: previous })) {
303
- if (Number(to[1]) !== destinationRank)
304
- continue;
305
- const [toFile] = squareCoordinates(to);
306
- const capturedSquare = squareAt(toFile, originRank);
307
- if (chess.get(capturedSquare) !== undefined)
308
- continue;
309
- for (const originFile of [toFile - 1, toFile + 1]) {
310
- if (originFile < 0 || originFile > 7)
311
- continue;
312
- const from = squareAt(originFile, originRank);
313
- if (chess.get(from) !== undefined)
314
- continue;
315
- const setup = new Chess(currentFen);
316
- setup.remove(to);
317
- setup.put({ type: "p", color: previous }, from);
318
- setup.put({ type: "p", color: active }, capturedSquare);
319
- const prior = priorChess(setup, previous, fields[2], to, 0, fullmove);
320
- if (reachesPosition(prior, { from, to }, currentFen))
321
- return true;
322
- }
323
- }
324
- return false;
325
- }
326
- function hasDoubleCheckPredecessor(chess, king, checkers) {
327
- if (exactFen(chess).split(" ")[3] !== "-")
328
- return true;
329
- return (ordinaryDoubleCheckPredecessor(chess, king, checkers) ||
330
- enPassantDoubleCheckPredecessor(chess));
331
- }
332
- export function assertSafeFenCounters(fen) {
333
- const fields = fen.split(/\s+/);
334
- if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
335
- throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
336
- }
337
- if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
338
- throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
339
- }
340
- }
341
- export function assertLegalPosition(chess) {
342
- assertLegalPositionInternal(chess, true);
343
- }
344
- function assertLegalPositionInternal(chess, validateDoubleCheck) {
345
- for (const color of ["w", "b"]) {
346
- if (chess.findPiece({ type: "k", color }).length !== 1) {
347
- throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
348
- }
349
- const pawns = chess.findPiece({ type: "p", color });
350
- if (pawns.some((square) => square[1] === "1" || square[1] === "8")) {
351
- throw new ChessError("INVALID_FEN", "FEN pawns cannot occupy the first or eighth rank");
352
- }
353
- if (pawns.length > 8) {
354
- throw new ChessError("INVALID_FEN", "FEN cannot contain more than eight pawns per side");
355
- }
356
- const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
357
- Math.max(0, chess.findPiece({ type: type, color }).length -
358
- original), 0);
359
- const promotedBishops = [0, 1].map((squareColorValue) => Math.max(0, chess
360
- .findPiece({ type: "b", color })
361
- .filter((square) => squareColor(square) === squareColorValue)
362
- .length - 1));
363
- const promoted = promotedPieces + promotedBishops[0] + promotedBishops[1];
364
- if (promoted > 8 - pawns.length) {
365
- throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
366
- }
367
- assertCastlingPosition(chess, color);
368
- const opponent = color === "w" ? "b" : "w";
369
- const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
370
- if (minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) > missingOpponentMaterial) {
371
- throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
372
- }
373
- }
374
- assertEnPassantPosition(chess);
375
- const turn = chess.turn();
376
- const previous = turn === "w" ? "b" : "w";
377
- const previousKing = chess.findPiece({ type: "k", color: previous })[0];
378
- if (previousKing && chess.isAttacked(previousKing, turn)) {
379
- throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
380
- }
381
- const king = chess.findPiece({ type: "k", color: turn })[0];
382
- if (king) {
383
- const checkers = chess.attackers(king, previous);
384
- const leapers = checkers.filter((square) => {
385
- const type = chess.get(square)?.type;
386
- return type === "k" || type === "n" || type === "p";
387
- });
388
- if (checkers.length > 2 || leapers.length > 1) {
389
- throw new ChessError("INVALID_FEN", "FEN contains an impossible check topology");
390
- }
391
- if (validateDoubleCheck &&
392
- checkers.length === 2 &&
393
- !hasDoubleCheckPredecessor(chess, king, checkers)) {
394
- throw new ChessError("INVALID_FEN", "FEN double check has no legal previous move");
395
- }
396
- }
397
- }
398
35
  function expectedInitialFen(headers) {
399
- const values = new Map();
400
- for (const [name, value] of headers) {
401
- const key = name.toLowerCase();
402
- if (values.has(key)) {
403
- throw new ChessError("INVALID_PGN", `PGN must not repeat ${name} headers`);
404
- }
405
- values.set(key, value);
406
- }
407
- const setup = values.get("setup");
408
- const fen = values.get("fen");
409
- if (setup !== undefined && setup !== "0" && setup !== "1") {
410
- throw new ChessError("INVALID_PGN", "PGN SetUp must be 0 or 1");
411
- }
412
- if ((setup === "1") !== (fen !== undefined)) {
413
- throw new ChessError("INVALID_PGN", "PGN SetUp 1 and FEN headers must appear together");
414
- }
36
+ const { fen } = pgnSetupHeaders(pgnHeaderIndex(headers));
415
37
  if (fen === undefined)
416
38
  return exactFen(new Chess());
417
39
  assertSafeFenCounters(fen);
@@ -446,21 +68,69 @@ function validatedHistory(chess) {
446
68
  }
447
69
  return { history, initialFen, shadow, sourceHeaders };
448
70
  }
449
- export function snapshotChess(chess) {
450
- assertLegalPosition(chess);
451
- const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
452
- assertPgnPlyLimit(history.length);
453
- assertSafeFenCounters(initialFen);
454
- const snapshot = new Chess(initialFen);
455
- assertLegalPosition(snapshot);
71
+ function commentsByFen(chess, shadow) {
456
72
  const getComments = chess.getComments;
457
73
  const sourceComments = getComments === Chess.prototype.getComments
458
74
  ? Chess.prototype.getComments.call(shadow)
459
75
  : getComments.call(chess);
460
- const comments = new Map(sourceComments.map(({ fen, comment }) => [
76
+ return new Map(sourceComments.map(({ fen, comment }) => [
461
77
  fen,
462
78
  /[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
463
79
  ]));
80
+ }
81
+ function replayHistory(chess, history, restoreComment) {
82
+ restoreComment();
83
+ for (const move of history) {
84
+ chess.move(materializeMove(move));
85
+ restoreComment();
86
+ }
87
+ }
88
+ function snapshotPgn(chess) {
89
+ const result = terminalPgnResult(chess);
90
+ const originalResult = chess.getHeaders().Result;
91
+ if (result !== undefined)
92
+ chess.setHeader("Result", result);
93
+ try {
94
+ const headers = Object.entries(chess.getHeaders());
95
+ return serializePgn(() => Chess.prototype.pgn.call(chess), headers, Chess.prototype.getComments.call(chess).map(({ comment }) => comment));
96
+ }
97
+ finally {
98
+ if (result !== undefined) {
99
+ if (originalResult === undefined)
100
+ chess.removeHeader("Result");
101
+ else
102
+ chess.setHeader("Result", originalResult);
103
+ }
104
+ }
105
+ }
106
+ function restoreUnsafeComments(snapshot, comments, markerPrefix, markerComments, sourceHeaders) {
107
+ const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
109
+ const pgn = snapshot.pgn().replace(marker, (_match, index) => {
110
+ return `;${markerComments[Number(index)]}\n`;
111
+ });
112
+ const restored = new Chess();
113
+ restored.loadPgn(pgn);
114
+ const restoredHistory = restored.history({ verbose: true });
115
+ while (restored.undo()) { }
116
+ replayHistory(restored, restoredHistory, () => {
117
+ const comment = comments.get(restored.fen());
118
+ if (comment !== undefined && !/[{}]/.test(comment)) {
119
+ restored.setComment(comment);
120
+ }
121
+ });
122
+ replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
123
+ assertSafeFenCounters(restored.fen());
124
+ return restored;
125
+ }
126
+ export function snapshotChessWithPgn(chess) {
127
+ assertLegalPosition(chess);
128
+ const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
129
+ assertPgnPlyLimit(history.length);
130
+ assertSafeFenCounters(initialFen);
131
+ const snapshot = new Chess(initialFen);
132
+ assertLegalPosition(snapshot);
133
+ const comments = commentsByFen(chess, shadow);
464
134
  const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
465
135
  let markerPrefix = "\uE000";
466
136
  if (unsafeComments) {
@@ -469,7 +139,7 @@ export function snapshotChess(chess) {
469
139
  markerPrefix += "\uE001";
470
140
  }
471
141
  const markerComments = [];
472
- const restoreComment = () => {
142
+ replayHistory(snapshot, history, () => {
473
143
  const comment = comments.get(snapshot.fen());
474
144
  if (comment === undefined)
475
145
  return;
@@ -480,38 +150,15 @@ export function snapshotChess(chess) {
480
150
  const marker = `${markerPrefix}${markerComments.length}${markerPrefix}`;
481
151
  markerComments.push(comment);
482
152
  snapshot.setComment(marker);
483
- };
484
- restoreComment();
485
- for (const move of history) {
486
- snapshot.move(moveDescriptor(move));
487
- restoreComment();
488
- }
153
+ });
489
154
  assertSafeFenCounters(snapshot.fen());
490
155
  if (!unsafeComments) {
491
156
  replacePgnHeaders(snapshot, sourceHeaders, { removeMissing: true });
492
- return snapshot;
157
+ return { chess: snapshot, pgn: snapshotPgn(snapshot) };
493
158
  }
494
- const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
495
- const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
496
- const pgn = snapshot.pgn().replace(marker, (_match, index) => {
497
- return `;${markerComments[Number(index)]}\n`;
498
- });
499
- const restored = new Chess();
500
- restored.loadPgn(pgn);
501
- const restoredHistory = restored.history({ verbose: true });
502
- while (restored.undo()) { }
503
- const restoreSafeComment = () => {
504
- const comment = comments.get(restored.fen());
505
- if (comment !== undefined && !/[{}]/.test(comment)) {
506
- restored.setComment(comment);
507
- }
508
- };
509
- restoreSafeComment();
510
- for (const move of restoredHistory) {
511
- restored.move(moveDescriptor(move));
512
- restoreSafeComment();
513
- }
514
- replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
515
- assertSafeFenCounters(restored.fen());
516
- return restored;
159
+ const restored = restoreUnsafeComments(snapshot, comments, markerPrefix, markerComments, sourceHeaders);
160
+ return { chess: restored, pgn: snapshotPgn(restored) };
161
+ }
162
+ export function snapshotChess(chess) {
163
+ return snapshotChessWithPgn(chess).chess;
517
164
  }