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.
- package/README.md +26 -15
- package/dist/analysis-boundary.d.ts +2 -0
- package/dist/analysis-boundary.js +49 -0
- package/dist/chess-copy.d.ts +5 -2
- package/dist/chess-copy.js +70 -423
- package/dist/chess-move.d.ts +7 -0
- package/dist/chess-move.js +4 -0
- package/dist/chess.d.ts +2 -1
- package/dist/chess.js +5 -7
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -3
- package/dist/domain.d.ts +4 -0
- package/dist/domain.js +4 -0
- package/dist/engines/stockfish-info.js +5 -5
- package/dist/engines/stockfish.js +107 -106
- package/dist/explorer-limiter.d.ts +1 -1
- package/dist/explorer-limiter.js +1 -1
- package/dist/explorer.js +7 -6
- package/dist/games.d.ts +2 -0
- package/dist/games.js +39 -26
- package/dist/http-body.d.ts +13 -0
- package/dist/http-body.js +103 -0
- package/dist/http-config.d.ts +33 -0
- package/dist/http-config.js +105 -0
- package/dist/http-posts.d.ts +11 -0
- package/dist/http-posts.js +37 -0
- package/dist/http-response.d.ts +3 -0
- package/dist/http-response.js +17 -0
- package/dist/http-runtime.d.ts +15 -0
- package/dist/http-runtime.js +308 -0
- package/dist/http.d.ts +2 -22
- package/dist/http.js +51 -471
- package/dist/human-boundary.d.ts +2 -0
- package/dist/human-boundary.js +34 -0
- package/dist/index.js +15 -15
- package/dist/intents.d.ts +1 -1
- package/dist/intents.js +60 -26
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.js +24 -0
- package/dist/maia3/inference.js +20 -5
- package/dist/pgn-lex.d.ts +14 -0
- package/dist/pgn-lex.js +72 -0
- package/dist/pgn-serialize.d.ts +4 -0
- package/dist/pgn-serialize.js +103 -0
- package/dist/pgn-shared.d.ts +13 -0
- package/dist/pgn-shared.js +43 -0
- package/dist/pgn.d.ts +1 -3
- package/dist/pgn.js +183 -432
- package/dist/position-validation.d.ts +3 -0
- package/dist/position-validation.js +372 -0
- package/dist/server.js +2 -18
- package/dist/services.js +22 -30
- package/dist/tool-fields.d.ts +2 -1
- package/dist/tool-fields.js +2 -1
- package/dist/tool-inputs.js +8 -8
- package/dist/tool-meta.d.ts +1 -1
- package/dist/tool-meta.js +1 -1
- package/dist/tool-result.d.ts +22 -3
- package/dist/tool-result.js +30 -13
- package/dist/tool-schemas.js +24 -11
- package/dist/tools/analysis.js +6 -39
- package/dist/tools/candidates.js +62 -4
- package/dist/tools/explorer.js +1 -1
- package/dist/tools/game.js +8 -8
- package/docs/architecture.md +40 -24
- 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`.
|
|
76
|
-
`dist/`
|
|
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
|
|
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
|
|
277
|
-
mainline and variations together at 32,768
|
|
278
|
-
per lexical token. Every variation is
|
|
279
|
-
the mainline. UTF-8 BOMs and standard
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/chess-copy.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
|
-
export
|
|
3
|
-
export declare function
|
|
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;
|
package/dist/chess-copy.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
}
|