llm-chess-mcp 0.4.10 → 0.6.0

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 (52) hide show
  1. package/README.md +32 -21
  2. package/dist/chess-copy.js +334 -60
  3. package/dist/cli.js +8 -3
  4. package/dist/domain.d.ts +33 -15
  5. package/dist/domain.js +2 -0
  6. package/dist/engines/stockfish-info.js +8 -2
  7. package/dist/engines/stockfish.d.ts +6 -0
  8. package/dist/engines/stockfish.js +552 -72
  9. package/dist/explorer-retry.d.ts +2 -2
  10. package/dist/explorer-retry.js +15 -6
  11. package/dist/explorer-transport.d.ts +2 -0
  12. package/dist/explorer-transport.js +22 -3
  13. package/dist/explorer.js +30 -7
  14. package/dist/games.d.ts +8 -0
  15. package/dist/games.js +98 -16
  16. package/dist/http-sessions.d.ts +3 -1
  17. package/dist/http-sessions.js +22 -6
  18. package/dist/http.js +19 -28
  19. package/dist/index.d.ts +8 -1
  20. package/dist/index.js +4 -1
  21. package/dist/intents.d.ts +9 -4
  22. package/dist/intents.js +34 -41
  23. package/dist/maia3/inference-worker.d.ts +21 -0
  24. package/dist/maia3/inference-worker.js +57 -0
  25. package/dist/maia3/inference.d.ts +21 -3
  26. package/dist/maia3/inference.js +499 -84
  27. package/dist/maia3/session.d.ts +4 -0
  28. package/dist/maia3/session.js +42 -0
  29. package/dist/pgn-shared.d.ts +8 -0
  30. package/dist/pgn-shared.js +49 -0
  31. package/dist/pgn.d.ts +1 -1
  32. package/dist/pgn.js +116 -125
  33. package/dist/server.js +25 -11
  34. package/dist/services.js +90 -6
  35. package/dist/string-length.d.ts +1 -0
  36. package/dist/string-length.js +6 -0
  37. package/dist/tool-fields.d.ts +3 -0
  38. package/dist/tool-fields.js +15 -0
  39. package/dist/tool-inputs.d.ts +25 -25
  40. package/dist/tool-inputs.js +59 -61
  41. package/dist/tool-result.d.ts +2 -2
  42. package/dist/tool-result.js +32 -10
  43. package/dist/tool-schemas.d.ts +8 -271
  44. package/dist/tool-schemas.js +120 -24
  45. package/dist/tools/analysis.js +57 -6
  46. package/dist/tools/candidates.js +46 -22
  47. package/dist/tools/explorer.js +15 -2
  48. package/dist/tools/game.js +1 -9
  49. package/dist/tools/move-boundary.d.ts +8 -0
  50. package/dist/tools/move-boundary.js +13 -0
  51. package/docs/architecture.md +20 -14
  52. package/package.json +2 -2
package/README.md CHANGED
@@ -13,13 +13,14 @@ judgment; the MCP server handles all the computation.
13
13
  | Engine | Role | Runtime |
14
14
  |---|---|---|
15
15
  | **Stockfish 18** (WASM) | Objective evaluation, best moves, multipv | In-process (npm `stockfish`) |
16
- | **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
16
+ | **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | Dedicated Node child processes (`onnxruntime-node`) |
17
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
18
18
 
19
- Everything runs inside the Node process. No external engine process or Python
20
- runtime is required at deploy time. The published package bundles the Maia3 5M
21
- model; other export variants are not runtime options unless their ONNX files are
22
- provided separately.
19
+ No external engine executable or Python runtime is required at deploy time.
20
+ Stockfish runs in the server process, while Maia inference runs in dedicated
21
+ Node child processes. The published package bundles the Maia3 5M model; other
22
+ export variants are not runtime options unless their ONNX files are provided
23
+ separately.
23
24
 
24
25
  ## Build from source
25
26
 
@@ -68,6 +69,12 @@ const server = await serveHttp({ port: 3000, bodyTimeoutMs: 15_000 });
68
69
  await server.close();
69
70
  ```
70
71
 
72
+ The root API also exports `buildServer`, `GameStore`, `ChessError`,
73
+ `ExplorerError`, the service/domain types needed to provide custom
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.
77
+
71
78
  `bodyTimeoutMs` limits HTTP body upload time; it is not a whole-tool deadline.
72
79
  The deprecated `requestTimeoutMs` alias remains supported when `bodyTimeoutMs`
73
80
  is omitted.
@@ -200,9 +207,10 @@ human-readable summary and must not be parsed as data.
200
207
  `best / excellent / good / inaccuracy / mistake / blunder`.
201
208
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
202
209
  can still be objectively bad.
203
- - Analysis continuations return `pv` in UCI and the same legal prefix in
204
- `pvSan` as SAN. If an engine line contains an invalid move, `pvSan` stops
205
- before it while the original `pv` remains unchanged.
210
+ - Successful analysis continuations return corresponding `pv` and `pvSan`
211
+ arrays of equal length in UCI and SAN. An invalid engine continuation is
212
+ rejected at the internal tool boundary instead of returning a truncated
213
+ `pvSan`.
206
214
 
207
215
  ## Candidate structure
208
216
 
@@ -264,20 +272,23 @@ rejected:
264
272
 
265
273
  - Up to 1,000 games are retained per process; idle games expire after one hour.
266
274
  - `move_evaluate` accepts at most 10 moves per call.
267
- - Imported PGNs are limited to 1 MiB, 256 headers, and 4,096 plies across the
268
- mainline and variations, plus 32,768 structural elements and 16 KiB per
269
- lexical token. Every variation is legality-checked; game state retains the
270
- mainline. UTF-8 BOMs and standard escaped header values are supported.
275
+ - 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.
271
280
  - Custom FENs reject inconsistent castling/en-passant metadata and impossible
272
281
  pawn or promotion material.
273
- - Stockfish accepts up to 32 active or queued analyses.
282
+ - Stockfish accepts up to 32 active or queued analyses. Maia runs at most two
283
+ inferences concurrently and queues up to 32 more.
274
284
  - Lichess Explorer requests run one at a time and share 429 cooldowns.
275
285
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
276
286
  after 30 minutes. An open GET/SSE stream keeps its session active.
277
- - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
278
- compute/network jobs process-wide, with two of each per session. A separately
279
- bounded control lane keeps MCP cancellation available when normal POST slots
280
- are full. Work keeps its slot after a raw disconnect until it settles. HTTP
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
281
292
  also caps connections at 128 and applies a 15-second body upload deadline
282
293
  plus bounded header, socket, and keep-alive timeouts.
283
294
 
@@ -289,10 +300,10 @@ proxy.
289
300
  MCP cancellation notifications, session deletion, and server shutdown propagate
290
301
  to body uploads and Stockfish, Maia, and Lichess work. Stockfish stops safely at
291
302
  its UCI queue boundary, drains queued work during shutdown, and rejects new
292
- analysis until teardown completes. Lichess fetch and retry waits abort immediately. ONNX
293
- Runtime cannot interrupt an inference already executing, so Maia discards its
294
- result after the native call returns. A raw HTTP disconnect alone is not a
295
- cancellation signal.
303
+ analysis until teardown completes. Lichess fetch and retry waits abort
304
+ immediately. Maia runs native inference in dedicated child processes; cancelling
305
+ active work terminates its child, while queued cancellation is immediate. A raw
306
+ HTTP disconnect alone is not a cancellation signal.
296
307
 
297
308
  ## Intents
298
309
 
@@ -1,68 +1,50 @@
1
1
  import { Chess } from "chess.js";
2
2
  import { ChessError } from "./errors.js";
3
+ import { assertPgnPlyLimit, replacePgnHeaders } from "./pgn-shared.js";
3
4
  const ORIGINAL_PIECES = {
4
5
  q: 1,
5
6
  r: 2,
6
7
  n: 2,
7
8
  };
8
- const CANONICAL_PGN_HEADERS = new Map([
9
- "Event",
10
- "Site",
11
- "Date",
12
- "Round",
13
- "White",
14
- "Black",
15
- "Result",
16
- "SetUp",
17
- "FEN",
18
- ].map((name) => [name.toLowerCase(), name]));
19
- function restoreHeaders(chess, sourceHeaders) {
20
- const sourceNames = new Set(sourceHeaders.map(([key]) => key.toLowerCase()));
21
- for (const key of Object.keys(chess.getHeaders())) {
22
- if (!sourceNames.has(key.toLowerCase()))
23
- chess.removeHeader(key);
24
- }
25
- const names = new Map();
26
- for (const key of Object.keys(chess.getHeaders())) {
27
- const existing = names.get(key.toLowerCase());
28
- if (existing)
29
- existing.push(key);
30
- else
31
- names.set(key.toLowerCase(), [key]);
32
- }
33
- for (const [key, value] of sourceHeaders) {
34
- const lower = key.toLowerCase();
35
- const canonical = CANONICAL_PGN_HEADERS.get(lower) ?? key;
36
- for (const existing of names.get(lower) ?? []) {
37
- if (existing !== canonical)
38
- chess.removeHeader(existing);
39
- }
40
- chess.setHeader(canonical, value);
41
- names.set(lower, [canonical]);
42
- }
43
- }
9
+ const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
44
10
  function squareColor(square) {
45
11
  return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
46
12
  }
47
- function minimumPawnCaptures(chess, color) {
48
- const pawns = chess
13
+ function minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) {
14
+ const requirements = chess
49
15
  .findPiece({ type: "p", color })
50
16
  .map((square) => ({
17
+ kind: "pawn",
51
18
  advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
52
19
  file: square.charCodeAt(0) - 97,
53
- }))
54
- .sort((left, right) => left.file - right.file);
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
+ }
55
29
  let costs = new Map([[0, 0]]);
56
- for (const pawn of pawns) {
30
+ for (const requirement of requirements) {
57
31
  const next = new Map();
58
32
  for (const [mask, cost] of costs) {
59
33
  for (let original = 0; original < 8; original += 1) {
60
34
  const bit = 1 << original;
61
35
  if (mask & bit)
62
36
  continue;
63
- const captures = Math.abs(original - pawn.file);
64
- if (captures > pawn.advances)
65
- 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
+ }
66
48
  const nextMask = mask | bit;
67
49
  next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
68
50
  }
@@ -74,6 +56,95 @@ function minimumPawnCaptures(chess, color) {
74
56
  function nonKingMaterial(chess, color) {
75
57
  return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
76
58
  }
59
+ function clonedChess(chess) {
60
+ const state = Object.create(null);
61
+ for (const key of CHESS_STATE_KEYS) {
62
+ const descriptor = Object.getOwnPropertyDescriptor(chess, key);
63
+ if (!descriptor || !("value" in descriptor)) {
64
+ throw new ChessError("INVALID_FEN", "chess state cannot be cloned");
65
+ }
66
+ Object.defineProperty(state, key, {
67
+ configurable: true,
68
+ enumerable: true,
69
+ value: descriptor.value,
70
+ writable: true,
71
+ });
72
+ }
73
+ try {
74
+ const clone = structuredClone(state);
75
+ Object.setPrototypeOf(clone, Chess.prototype);
76
+ return clone;
77
+ }
78
+ catch {
79
+ throw new ChessError("INVALID_FEN", "chess state cannot be cloned");
80
+ }
81
+ }
82
+ function exactFen(chess) {
83
+ return chess.fen({ forceEnpassantSquare: true });
84
+ }
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
+ }
77
148
  function hasPiece(chess, square, type, color) {
78
149
  const piece = chess.get(square);
79
150
  return piece?.type === type && piece.color === color;
@@ -114,6 +185,35 @@ function assertEnPassantPosition(chess) {
114
185
  (turn === "w" && fields[5] === "1")) {
115
186
  throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
116
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
+ }
117
217
  }
118
218
  function moveDescriptor(move) {
119
219
  const base = { from: move.from, to: move.to };
@@ -124,6 +224,111 @@ function isSafeDecimal(value, minimum) {
124
224
  Number.isSafeInteger(Number(value)) &&
125
225
  Number(value) >= minimum);
126
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
+ }
127
332
  export function assertSafeFenCounters(fen) {
128
333
  const fields = fen.split(/\s+/);
129
334
  if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
@@ -134,6 +339,9 @@ export function assertSafeFenCounters(fen) {
134
339
  }
135
340
  }
136
341
  export function assertLegalPosition(chess) {
342
+ assertLegalPositionInternal(chess, true);
343
+ }
344
+ function assertLegalPositionInternal(chess, validateDoubleCheck) {
137
345
  for (const color of ["w", "b"]) {
138
346
  if (chess.findPiece({ type: "k", color }).length !== 1) {
139
347
  throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
@@ -148,22 +356,18 @@ export function assertLegalPosition(chess) {
148
356
  const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
149
357
  Math.max(0, chess.findPiece({ type: type, color }).length -
150
358
  original), 0);
151
- const promotedBishops = [0, 1].reduce((total, squareColorValue) => total +
152
- Math.max(0, chess
153
- .findPiece({ type: "b", color })
154
- .filter((square) => squareColor(square) === squareColorValue)
155
- .length - 1), 0);
156
- const promoted = promotedPieces + promotedBishops;
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];
157
364
  if (promoted > 8 - pawns.length) {
158
365
  throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
159
366
  }
160
367
  assertCastlingPosition(chess, color);
161
368
  const opponent = color === "w" ? "b" : "w";
162
- const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
163
369
  const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
164
- const possibleOpponentPromotions = 8 - opponentPawns;
165
- if (minimumPawnCaptures(chess, color) >
166
- missingOpponentMaterial + possibleOpponentPromotions) {
370
+ if (minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) > missingOpponentMaterial) {
167
371
  throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
168
372
  }
169
373
  }
@@ -174,19 +378,89 @@ export function assertLegalPosition(chess) {
174
378
  if (previousKing && chess.isAttacked(previousKing, turn)) {
175
379
  throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
176
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
+ 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
+ }
415
+ if (fen === undefined)
416
+ return exactFen(new Chess());
417
+ assertSafeFenCounters(fen);
418
+ let initial;
419
+ try {
420
+ initial = new Chess(fen);
421
+ }
422
+ catch {
423
+ throw new ChessError("INVALID_FEN", "invalid FEN");
424
+ }
425
+ assertLegalPosition(initial);
426
+ return exactFen(initial);
427
+ }
428
+ function validatedHistory(chess) {
429
+ const sourceFen = exactFen(chess);
430
+ const sourceHeaders = Object.entries(chess.getHeaders());
431
+ const shadow = clonedChess(chess);
432
+ const history = Chess.prototype.history.call(shadow, {
433
+ verbose: true,
434
+ });
435
+ if (history.some((move) => move.from === move.to)) {
436
+ throw new ChessError("INVALID_PGN", "null moves are not supported");
437
+ }
438
+ if (exactFen(shadow) !== sourceFen) {
439
+ throw new ChessError("INVALID_FEN", "current position does not match move history");
440
+ }
441
+ const initial = clonedChess(chess);
442
+ while (Chess.prototype.undo.call(initial)) { }
443
+ const initialFen = exactFen(initial);
444
+ if (initialFen !== expectedInitialFen(sourceHeaders)) {
445
+ throw new ChessError("INVALID_PGN", "move history does not match PGN setup headers");
446
+ }
447
+ return { history, initialFen, shadow, sourceHeaders };
177
448
  }
178
449
  export function snapshotChess(chess) {
179
450
  assertLegalPosition(chess);
180
- const history = chess.history({ verbose: true });
181
- const initialFen = history[0]?.before ?? chess.fen();
451
+ const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
452
+ assertPgnPlyLimit(history.length);
182
453
  assertSafeFenCounters(initialFen);
183
454
  const snapshot = new Chess(initialFen);
184
455
  assertLegalPosition(snapshot);
185
- const comments = new Map(chess.getComments().map(({ fen, comment }) => [
456
+ const getComments = chess.getComments;
457
+ const sourceComments = getComments === Chess.prototype.getComments
458
+ ? Chess.prototype.getComments.call(shadow)
459
+ : getComments.call(chess);
460
+ const comments = new Map(sourceComments.map(({ fen, comment }) => [
186
461
  fen,
187
462
  /[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
188
463
  ]));
189
- const sourceHeaders = Object.entries(chess.getHeaders());
190
464
  const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
191
465
  let markerPrefix = "\uE000";
192
466
  if (unsafeComments) {
@@ -214,7 +488,7 @@ export function snapshotChess(chess) {
214
488
  }
215
489
  assertSafeFenCounters(snapshot.fen());
216
490
  if (!unsafeComments) {
217
- restoreHeaders(snapshot, sourceHeaders);
491
+ replacePgnHeaders(snapshot, sourceHeaders, { removeMissing: true });
218
492
  return snapshot;
219
493
  }
220
494
  const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -237,7 +511,7 @@ export function snapshotChess(chess) {
237
511
  restored.move(moveDescriptor(move));
238
512
  restoreSafeComment();
239
513
  }
240
- restoreHeaders(restored, sourceHeaders);
514
+ replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
241
515
  assertSafeFenCounters(restored.fen());
242
516
  return restored;
243
517
  }
package/dist/cli.js CHANGED
@@ -29,6 +29,9 @@ export function parseCli(args) {
29
29
  let help = false;
30
30
  let hasHttpOption = false;
31
31
  const allowedHosts = [];
32
+ if (args.includes("-h") || args.includes("--help")) {
33
+ return { transport, host, port, path, allowedHosts, help: true };
34
+ }
32
35
  for (let index = 0; index < args.length; index += 1) {
33
36
  const arg = args[index];
34
37
  if (arg === undefined)
@@ -93,21 +96,23 @@ export function parseCli(args) {
93
96
  if (!isCanonicalHttpPath(path)) {
94
97
  throw new Error("--path must be an absolute URL path without query or fragment");
95
98
  }
99
+ const canonicalHost = canonicalHttpHostname(host);
96
100
  const canonicalAllowedHosts = allowedHosts.map(canonicalHttpHostname);
97
- if (!host || canonicalAllowedHosts.some((value) => value === null)) {
101
+ if (canonicalHost === null ||
102
+ canonicalAllowedHosts.some((value) => value === null)) {
98
103
  throw new Error("HTTP hostnames must be non-empty hostnames");
99
104
  }
100
105
  if (transport === "stdio" && hasHttpOption) {
101
106
  throw new Error("HTTP options require --transport http");
102
107
  }
103
108
  if (transport === "http" &&
104
- isWildcardHttpBindHost(host) &&
109
+ isWildcardHttpBindHost(canonicalHost) &&
105
110
  canonicalAllowedHosts.length === 0) {
106
111
  throw new Error("wildcard HTTP binding requires at least one --allowed-host");
107
112
  }
108
113
  return {
109
114
  transport,
110
- host,
115
+ host: canonicalHost,
111
116
  port,
112
117
  path,
113
118
  allowedHosts: canonicalAllowedHosts,