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.
- package/README.md +32 -21
- package/dist/chess-copy.js +334 -60
- package/dist/cli.js +8 -3
- package/dist/domain.d.ts +33 -15
- package/dist/domain.js +2 -0
- package/dist/engines/stockfish-info.js +8 -2
- package/dist/engines/stockfish.d.ts +6 -0
- package/dist/engines/stockfish.js +552 -72
- package/dist/explorer-retry.d.ts +2 -2
- package/dist/explorer-retry.js +15 -6
- package/dist/explorer-transport.d.ts +2 -0
- package/dist/explorer-transport.js +22 -3
- package/dist/explorer.js +30 -7
- package/dist/games.d.ts +8 -0
- package/dist/games.js +98 -16
- package/dist/http-sessions.d.ts +3 -1
- package/dist/http-sessions.js +22 -6
- package/dist/http.js +19 -28
- package/dist/index.d.ts +8 -1
- package/dist/index.js +4 -1
- package/dist/intents.d.ts +9 -4
- package/dist/intents.js +34 -41
- package/dist/maia3/inference-worker.d.ts +21 -0
- package/dist/maia3/inference-worker.js +57 -0
- package/dist/maia3/inference.d.ts +21 -3
- package/dist/maia3/inference.js +499 -84
- package/dist/maia3/session.d.ts +4 -0
- package/dist/maia3/session.js +42 -0
- package/dist/pgn-shared.d.ts +8 -0
- package/dist/pgn-shared.js +49 -0
- package/dist/pgn.d.ts +1 -1
- package/dist/pgn.js +116 -125
- package/dist/server.js +25 -11
- package/dist/services.js +90 -6
- package/dist/string-length.d.ts +1 -0
- package/dist/string-length.js +6 -0
- package/dist/tool-fields.d.ts +3 -0
- package/dist/tool-fields.js +15 -0
- package/dist/tool-inputs.d.ts +25 -25
- package/dist/tool-inputs.js +59 -61
- package/dist/tool-result.d.ts +2 -2
- package/dist/tool-result.js +32 -10
- package/dist/tool-schemas.d.ts +8 -271
- package/dist/tool-schemas.js +120 -24
- package/dist/tools/analysis.js +57 -6
- package/dist/tools/candidates.js +46 -22
- package/dist/tools/explorer.js +15 -2
- package/dist/tools/game.js +1 -9
- package/dist/tools/move-boundary.d.ts +8 -0
- package/dist/tools/move-boundary.js +13 -0
- package/docs/architecture.md +20 -14
- 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 |
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
provided
|
|
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
|
-
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
278
|
-
compute/network jobs process-wide, with two of
|
|
279
|
-
bounded control lane keeps MCP cancellation
|
|
280
|
-
are full. Work keeps its slot after a raw
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
package/dist/chess-copy.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
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].
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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,
|