llm-chess-mcp 0.4.5 → 0.4.9
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/.env.example +1 -1
- package/README.md +39 -137
- package/dist/chess-copy.d.ts +4 -0
- package/dist/chess-copy.js +165 -0
- package/dist/chess.d.ts +6 -8
- package/dist/chess.js +9 -171
- package/dist/cli.js +16 -21
- package/dist/domain.d.ts +114 -0
- package/dist/domain.js +43 -0
- package/dist/engines/stockfish-info.d.ts +14 -0
- package/dist/engines/stockfish-info.js +68 -0
- package/dist/engines/stockfish.d.ts +16 -6
- package/dist/engines/stockfish.js +273 -100
- package/dist/eval.d.ts +4 -3
- package/dist/eval.js +1 -0
- package/dist/explorer-core.d.ts +34 -0
- package/dist/explorer-core.js +66 -0
- package/dist/explorer-limiter.d.ts +14 -0
- package/dist/explorer-limiter.js +125 -0
- package/dist/explorer-response.d.ts +8 -0
- package/dist/explorer-response.js +148 -0
- package/dist/explorer-retry.d.ts +20 -0
- package/dist/explorer-retry.js +140 -0
- package/dist/explorer-transport.d.ts +21 -0
- package/dist/explorer-transport.js +68 -0
- package/dist/explorer.d.ts +7 -38
- package/dist/explorer.js +111 -368
- package/dist/games.js +4 -2
- package/dist/http-config.d.ts +8 -0
- package/dist/http-config.js +62 -0
- package/dist/http-posts.d.ts +13 -0
- package/dist/http-posts.js +29 -0
- package/dist/http-sessions.d.ts +23 -0
- package/dist/http-sessions.js +122 -0
- package/dist/http-work.d.ts +5 -2
- package/dist/http-work.js +31 -12
- package/dist/http.js +213 -240
- package/dist/index.d.ts +2 -2
- package/dist/index.js +23 -6
- package/dist/intent-ranking.d.ts +2 -0
- package/dist/intent-ranking.js +72 -0
- package/dist/intents.d.ts +3 -3
- package/dist/intents.js +137 -106
- package/dist/maia3/inference.d.ts +1 -1
- package/dist/pgn.d.ts +7 -0
- package/dist/pgn.js +930 -0
- package/dist/server.js +6 -4
- package/dist/services.d.ts +17 -3
- package/dist/services.js +31 -12
- package/dist/tool-inputs.d.ts +22 -0
- package/dist/tool-inputs.js +74 -37
- package/dist/tool-result.d.ts +6 -4
- package/dist/tool-result.js +3 -3
- package/dist/tool-schemas.d.ts +174 -60
- package/dist/tool-schemas.js +119 -55
- package/dist/tools/analysis.js +8 -12
- package/dist/tools/candidates.js +10 -10
- package/dist/tools/explorer.js +5 -4
- package/dist/tools/game.js +21 -16
- package/dist/types.d.ts +1 -44
- package/dist/types.js +1 -8
- package/docs/architecture.md +57 -12
- package/package.json +11 -8
package/.env.example
CHANGED
|
@@ -5,5 +5,5 @@ LICHESS_TOKEN=
|
|
|
5
5
|
# The published package bundles only Maia3 5m (~20 MB). Leave at 5m.
|
|
6
6
|
MAIA3_MODEL=5m
|
|
7
7
|
|
|
8
|
-
# Stockfish flavor: lite-single (default, ~7MB) | lite | single | full
|
|
8
|
+
# Stockfish flavor: lite-single (default, ~7MB) | single-lite | lite | single | full | asm
|
|
9
9
|
STOCKFISH_FLAVOR=lite-single
|
package/README.md
CHANGED
|
@@ -16,31 +16,15 @@ judgment; the MCP server handles all the computation.
|
|
|
16
16
|
| **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
|
|
17
17
|
| **Lichess explorer** | Real human game statistics | HTTP (needs token) |
|
|
18
18
|
|
|
19
|
-
Everything runs inside the Node process
|
|
19
|
+
Everything runs inside the Node process. No external engine process or Python
|
|
20
20
|
runtime is required at deploy time. The published package bundles the Maia3 5M
|
|
21
21
|
model; other export variants are not runtime options unless their ONNX files are
|
|
22
22
|
provided separately.
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## Build from source
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
No install needed — run it directly with `npx`:
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
npx -y llm-chess-mcp
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
The Maia3 model is already bundled, so there's no Python, torch, or engine
|
|
35
|
-
binaries to install. `npx` fetches the package on first run and caches it.
|
|
36
|
-
|
|
37
|
-
To install it permanently instead:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
npm install -g llm-chess-mcp
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
### Build from source
|
|
26
|
+
The published runtime supports Node.js 20.3 and newer. Repository maintenance
|
|
27
|
+
uses Node.js 22.13 or newer because pnpm 11 and the coverage gate require it.
|
|
44
28
|
|
|
45
29
|
```bash
|
|
46
30
|
pnpm install
|
|
@@ -52,24 +36,6 @@ pnpm test
|
|
|
52
36
|
the MCP transport tests. `pnpm check` runs the full local gate; use
|
|
53
37
|
`pnpm release:check` before publishing.
|
|
54
38
|
|
|
55
|
-
### Maintainers
|
|
56
|
-
|
|
57
|
-
[Architecture](docs/architecture.md) describes runtime and service boundaries.
|
|
58
|
-
|
|
59
|
-
Local quality commands:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
pnpm typecheck
|
|
63
|
-
pnpm test:coverage
|
|
64
|
-
pnpm contract:check
|
|
65
|
-
pnpm check
|
|
66
|
-
pnpm test:package
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`pnpm test:stress` runs the short real-engine concurrency check.
|
|
70
|
-
`pnpm test:live` queries Lichess only when `LICHESS_TOKEN` is set; otherwise it
|
|
71
|
-
skips without making a network request.
|
|
72
|
-
|
|
73
39
|
## Transports
|
|
74
40
|
|
|
75
41
|
stdio remains the default transport and requires no flags. To expose a local
|
|
@@ -111,95 +77,7 @@ does not provide authentication or TLS; use a trusted network or an
|
|
|
111
77
|
authenticated reverse proxy when exposing it beyond localhost. Origin values
|
|
112
78
|
are validated when present, but the server does not emit browser CORS headers.
|
|
113
79
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
The HTTP server is intended to run behind a reverse proxy for any non-local
|
|
117
|
-
deployment. The proxy owns TLS termination, client authentication, external
|
|
118
|
-
rate/connection limits, and any future CORS policy. Bind this process to
|
|
119
|
-
localhost only; never expose its port directly through a firewall, container
|
|
120
|
-
port mapping, or load balancer.
|
|
121
|
-
|
|
122
|
-
For example, start the backend with the public hostname that Nginx will pass
|
|
123
|
-
through as `Host`:
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
node dist/index.js --transport http --host 127.0.0.1 --port 3000 \
|
|
127
|
-
--allowed-host chess-mcp.example.com
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
This is a minimal Nginx layout. It assumes an identity-aware auth service is
|
|
131
|
-
available only on localhost at `127.0.0.1:4180`; configure that service and
|
|
132
|
-
the certificate paths for the deployment. The limits are examples, not a
|
|
133
|
-
substitute for capacity planning.
|
|
134
|
-
|
|
135
|
-
```nginx
|
|
136
|
-
limit_req_zone $binary_remote_addr zone=mcp_req:10m rate=5r/s;
|
|
137
|
-
limit_conn_zone $binary_remote_addr zone=mcp_conn:10m;
|
|
138
|
-
|
|
139
|
-
server {
|
|
140
|
-
listen 443 ssl;
|
|
141
|
-
server_name chess-mcp.example.com;
|
|
142
|
-
ssl_certificate /etc/ssl/certs/chess-mcp.pem;
|
|
143
|
-
ssl_certificate_key /etc/ssl/private/chess-mcp.key;
|
|
144
|
-
|
|
145
|
-
location = /_mcp_auth {
|
|
146
|
-
internal;
|
|
147
|
-
proxy_pass http://127.0.0.1:4180/auth;
|
|
148
|
-
proxy_pass_request_body off;
|
|
149
|
-
proxy_set_header Content-Length "";
|
|
150
|
-
proxy_set_header X-Original-Method $request_method;
|
|
151
|
-
proxy_set_header X-Original-URI $request_uri;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
location = /mcp {
|
|
155
|
-
auth_request /_mcp_auth;
|
|
156
|
-
limit_req zone=mcp_req burst=20 nodelay;
|
|
157
|
-
limit_conn mcp_conn 10;
|
|
158
|
-
client_max_body_size 2m;
|
|
159
|
-
|
|
160
|
-
proxy_pass http://127.0.0.1:3000;
|
|
161
|
-
proxy_http_version 1.1;
|
|
162
|
-
proxy_set_header Connection "";
|
|
163
|
-
proxy_set_header Host $host;
|
|
164
|
-
proxy_set_header X-Forwarded-Proto https;
|
|
165
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
166
|
-
proxy_set_header X-Forwarded-User "";
|
|
167
|
-
proxy_set_header X-Forwarded-Email "";
|
|
168
|
-
proxy_buffering off;
|
|
169
|
-
proxy_read_timeout 90s;
|
|
170
|
-
|
|
171
|
-
# Intentionally no Access-Control-Allow-* headers: browser CORS is unsupported.
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
The application does not trust forwarded identity headers and does not assign
|
|
177
|
-
games to authenticated users. All games in one process share one `GameStore`;
|
|
178
|
-
the opaque `game_id` is the capability to operate a game within the trusted
|
|
179
|
-
deployment, not an OAuth token or user identity. Do not disclose it across
|
|
180
|
-
trust boundaries.
|
|
181
|
-
|
|
182
|
-
This server does not implement MCP OAuth discovery, bearer-token validation,
|
|
183
|
-
or browser CORS. A proxy may authenticate access to the endpoint, but that is
|
|
184
|
-
deployment policy rather than an application-level identity or ownership
|
|
185
|
-
model. Browser clients are unsupported unless a proxy deliberately adds and
|
|
186
|
-
maintains the required CORS policy.
|
|
187
|
-
|
|
188
|
-
### Export Maia3 to ONNX (build-time only)
|
|
189
|
-
|
|
190
|
-
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
191
|
-
the reimplementation against the original, and exports `models/maia3-5m.onnx`.
|
|
192
|
-
|
|
193
|
-
```bash
|
|
194
|
-
uv venv .venv-maia3 --python 3.13
|
|
195
|
-
uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
|
|
196
|
-
uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
|
|
197
|
-
pnpm export:maia3 # -> models/maia3-5m.onnx
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
The resulting `.onnx` is committed/bundled; end users never need Python or torch.
|
|
201
|
-
|
|
202
|
-
### Lichess token (optional)
|
|
80
|
+
## Lichess token (optional)
|
|
203
81
|
|
|
204
82
|
The opening explorer now requires authentication. Generate a personal access token
|
|
205
83
|
at <https://lichess.org/account/oauth/token/create> and set it in `.env`:
|
|
@@ -216,7 +94,9 @@ Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
|
|
|
216
94
|
`1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
|
|
217
95
|
neither filter. Invalid filters fail locally. Transient failures (network,
|
|
218
96
|
timeout, 429, and 5xx) are retried once within a 12-second total budget;
|
|
219
|
-
invalid requests and other 4xx responses are not retried.
|
|
97
|
+
invalid requests and other 4xx responses are not retried. Responses must be
|
|
98
|
+
valid UTF-8 JSON and are limited to 1 MiB, 256 moves, and 256 characters per
|
|
99
|
+
move or opening string.
|
|
220
100
|
|
|
221
101
|
## Configure in your MCP client
|
|
222
102
|
|
|
@@ -384,16 +264,22 @@ rejected:
|
|
|
384
264
|
|
|
385
265
|
- Up to 1,000 games are retained per process; idle games expire after one hour.
|
|
386
266
|
- `move_evaluate` accepts at most 10 moves per call.
|
|
387
|
-
- Imported PGNs are limited to 1 MiB and 4,096 plies
|
|
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.
|
|
271
|
+
- Custom FENs reject inconsistent castling/en-passant metadata and impossible
|
|
272
|
+
pawn or promotion material.
|
|
388
273
|
- Stockfish accepts up to 32 active or queued analyses.
|
|
389
274
|
- Lichess Explorer requests run one at a time and share 429 cooldowns.
|
|
390
275
|
- HTTP retains at most 64 MCP sessions; sessions with no active request expire
|
|
391
276
|
after 30 minutes. An open GET/SSE stream keeps its session active.
|
|
392
277
|
- HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
|
|
393
|
-
compute/network jobs process-wide, with two of each per session.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
281
|
+
also caps connections at 128 and applies a 15-second body upload deadline
|
|
282
|
+
plus bounded header, socket, and keep-alive timeouts.
|
|
397
283
|
|
|
398
284
|
Programmatic users can override the HTTP limits through `HttpServerOptions`.
|
|
399
285
|
These safeguards do not replace public-edge quotas: a public deployment must
|
|
@@ -401,10 +287,12 @@ still enforce request, connection, and authentication limits at the reverse
|
|
|
401
287
|
proxy.
|
|
402
288
|
|
|
403
289
|
MCP cancellation notifications, session deletion, and server shutdown propagate
|
|
404
|
-
to Stockfish, Maia, and Lichess work. Stockfish stops safely at
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
290
|
+
to body uploads and Stockfish, Maia, and Lichess work. Stockfish stops safely at
|
|
291
|
+
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.
|
|
408
296
|
|
|
409
297
|
## Intents
|
|
410
298
|
|
|
@@ -440,6 +328,20 @@ Go deeper only when you need to:
|
|
|
440
328
|
- `opening_explorer` — real-game statistics
|
|
441
329
|
- `move_evaluate` — score a specific move (or compare several)
|
|
442
330
|
|
|
331
|
+
## Export Maia3 to ONNX
|
|
332
|
+
|
|
333
|
+
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
334
|
+
the reimplementation against the original, and exports `models/maia3-5m.onnx`.
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
uv venv .venv-maia3 --python 3.13
|
|
338
|
+
uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
|
|
339
|
+
uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
|
|
340
|
+
pnpm export:maia3 # -> models/maia3-5m.onnx
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The resulting `.onnx` is committed/bundled.
|
|
344
|
+
|
|
443
345
|
## Maia3 ONNX verification
|
|
444
346
|
|
|
445
347
|
The exported ONNX model is regression-tested against the upstream Maia3
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Chess } from "chess.js";
|
|
2
|
+
import { ChessError } from "./errors.js";
|
|
3
|
+
const ORIGINAL_PIECES = {
|
|
4
|
+
q: 1,
|
|
5
|
+
r: 2,
|
|
6
|
+
n: 2,
|
|
7
|
+
};
|
|
8
|
+
function squareColor(square) {
|
|
9
|
+
return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
|
|
10
|
+
}
|
|
11
|
+
function minimumPawnCaptures(chess, color) {
|
|
12
|
+
const pawns = chess
|
|
13
|
+
.findPiece({ type: "p", color })
|
|
14
|
+
.map((square) => ({
|
|
15
|
+
advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
|
|
16
|
+
file: square.charCodeAt(0) - 97,
|
|
17
|
+
}))
|
|
18
|
+
.sort((left, right) => left.file - right.file);
|
|
19
|
+
let costs = new Map([[0, 0]]);
|
|
20
|
+
for (const pawn of pawns) {
|
|
21
|
+
const next = new Map();
|
|
22
|
+
for (const [mask, cost] of costs) {
|
|
23
|
+
for (let original = 0; original < 8; original += 1) {
|
|
24
|
+
const bit = 1 << original;
|
|
25
|
+
if (mask & bit)
|
|
26
|
+
continue;
|
|
27
|
+
const captures = Math.abs(original - pawn.file);
|
|
28
|
+
if (captures > pawn.advances)
|
|
29
|
+
continue;
|
|
30
|
+
const nextMask = mask | bit;
|
|
31
|
+
next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
costs = next;
|
|
35
|
+
}
|
|
36
|
+
return Math.min(...costs.values());
|
|
37
|
+
}
|
|
38
|
+
function nonKingMaterial(chess, color) {
|
|
39
|
+
return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
|
|
40
|
+
}
|
|
41
|
+
function hasPiece(chess, square, type, color) {
|
|
42
|
+
const piece = chess.get(square);
|
|
43
|
+
return piece?.type === type && piece.color === color;
|
|
44
|
+
}
|
|
45
|
+
function assertCastlingPosition(chess, color) {
|
|
46
|
+
const rank = color === "w" ? "1" : "8";
|
|
47
|
+
const rights = chess.getCastlingRights(color);
|
|
48
|
+
if ((rights.k || rights.q) &&
|
|
49
|
+
!hasPiece(chess, `e${rank}`, "k", color)) {
|
|
50
|
+
throw new ChessError("INVALID_FEN", "FEN castling rights require a home king");
|
|
51
|
+
}
|
|
52
|
+
if (rights.k && !hasPiece(chess, `h${rank}`, "r", color)) {
|
|
53
|
+
throw new ChessError("INVALID_FEN", "FEN kingside castling rights require a home rook");
|
|
54
|
+
}
|
|
55
|
+
if (rights.q && !hasPiece(chess, `a${rank}`, "r", color)) {
|
|
56
|
+
throw new ChessError("INVALID_FEN", "FEN queenside castling rights require a home rook");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function assertEnPassantPosition(chess) {
|
|
60
|
+
const fields = chess.fen({ forceEnpassantSquare: true }).split(" ");
|
|
61
|
+
const target = fields[3];
|
|
62
|
+
if (!target || target === "-")
|
|
63
|
+
return;
|
|
64
|
+
const turn = chess.turn();
|
|
65
|
+
const file = target[0];
|
|
66
|
+
const targetRank = turn === "w" ? "6" : "3";
|
|
67
|
+
const pawnRank = turn === "w" ? "5" : "4";
|
|
68
|
+
const originRank = turn === "w" ? "7" : "2";
|
|
69
|
+
const pawnColor = turn === "w" ? "b" : "w";
|
|
70
|
+
const targetSquare = target;
|
|
71
|
+
const pawnSquare = `${file}${pawnRank}`;
|
|
72
|
+
const originSquare = `${file}${originRank}`;
|
|
73
|
+
if (target[1] !== targetRank ||
|
|
74
|
+
chess.get(targetSquare) !== undefined ||
|
|
75
|
+
!hasPiece(chess, pawnSquare, "p", pawnColor) ||
|
|
76
|
+
chess.get(originSquare) !== undefined ||
|
|
77
|
+
fields[4] !== "0" ||
|
|
78
|
+
(turn === "w" && fields[5] === "1")) {
|
|
79
|
+
throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function moveDescriptor(move) {
|
|
83
|
+
const base = { from: move.from, to: move.to };
|
|
84
|
+
return move.promotion ? { ...base, promotion: move.promotion } : base;
|
|
85
|
+
}
|
|
86
|
+
function isSafeDecimal(value, minimum) {
|
|
87
|
+
return (/^(?:0|[1-9]\d*)$/.test(value) &&
|
|
88
|
+
Number.isSafeInteger(Number(value)) &&
|
|
89
|
+
Number(value) >= minimum);
|
|
90
|
+
}
|
|
91
|
+
export function assertSafeFenCounters(fen) {
|
|
92
|
+
const fields = fen.split(/\s+/);
|
|
93
|
+
if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
|
|
94
|
+
throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
|
|
95
|
+
}
|
|
96
|
+
if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
|
|
97
|
+
throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function assertLegalPosition(chess) {
|
|
101
|
+
for (const color of ["w", "b"]) {
|
|
102
|
+
if (chess.findPiece({ type: "k", color }).length !== 1) {
|
|
103
|
+
throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
|
|
104
|
+
}
|
|
105
|
+
const pawns = chess.findPiece({ type: "p", color });
|
|
106
|
+
if (pawns.some((square) => square[1] === "1" || square[1] === "8")) {
|
|
107
|
+
throw new ChessError("INVALID_FEN", "FEN pawns cannot occupy the first or eighth rank");
|
|
108
|
+
}
|
|
109
|
+
if (pawns.length > 8) {
|
|
110
|
+
throw new ChessError("INVALID_FEN", "FEN cannot contain more than eight pawns per side");
|
|
111
|
+
}
|
|
112
|
+
const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
|
|
113
|
+
Math.max(0, chess.findPiece({ type: type, color }).length -
|
|
114
|
+
original), 0);
|
|
115
|
+
const promotedBishops = [0, 1].reduce((total, squareColorValue) => total +
|
|
116
|
+
Math.max(0, chess
|
|
117
|
+
.findPiece({ type: "b", color })
|
|
118
|
+
.filter((square) => squareColor(square) === squareColorValue)
|
|
119
|
+
.length - 1), 0);
|
|
120
|
+
const promoted = promotedPieces + promotedBishops;
|
|
121
|
+
if (promoted > 8 - pawns.length) {
|
|
122
|
+
throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
|
|
123
|
+
}
|
|
124
|
+
assertCastlingPosition(chess, color);
|
|
125
|
+
const opponent = color === "w" ? "b" : "w";
|
|
126
|
+
const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
|
|
127
|
+
const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
|
|
128
|
+
const possibleOpponentPromotions = 8 - opponentPawns;
|
|
129
|
+
if (minimumPawnCaptures(chess, color) >
|
|
130
|
+
missingOpponentMaterial + possibleOpponentPromotions) {
|
|
131
|
+
throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assertEnPassantPosition(chess);
|
|
135
|
+
const turn = chess.turn();
|
|
136
|
+
const previous = turn === "w" ? "b" : "w";
|
|
137
|
+
const previousKing = chess.findPiece({ type: "k", color: previous })[0];
|
|
138
|
+
if (previousKing && chess.isAttacked(previousKing, turn)) {
|
|
139
|
+
throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function snapshotChess(chess) {
|
|
143
|
+
assertLegalPosition(chess);
|
|
144
|
+
const history = chess.history({ verbose: true });
|
|
145
|
+
const initialFen = history[0]?.before ?? chess.fen();
|
|
146
|
+
assertSafeFenCounters(initialFen);
|
|
147
|
+
const snapshot = new Chess(initialFen);
|
|
148
|
+
assertLegalPosition(snapshot);
|
|
149
|
+
const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
|
|
150
|
+
for (const [key, value] of Object.entries(chess.getHeaders())) {
|
|
151
|
+
snapshot.setHeader(key, value);
|
|
152
|
+
}
|
|
153
|
+
const restoreComment = () => {
|
|
154
|
+
const comment = comments.get(snapshot.fen());
|
|
155
|
+
if (comment !== undefined)
|
|
156
|
+
snapshot.setComment(comment);
|
|
157
|
+
};
|
|
158
|
+
restoreComment();
|
|
159
|
+
for (const move of history) {
|
|
160
|
+
snapshot.move(moveDescriptor(move));
|
|
161
|
+
restoreComment();
|
|
162
|
+
}
|
|
163
|
+
assertSafeFenCounters(snapshot.fen());
|
|
164
|
+
return snapshot;
|
|
165
|
+
}
|
package/dist/chess.d.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
2
|
import type { Move } from "chess.js";
|
|
3
|
-
|
|
3
|
+
export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
|
|
4
|
+
export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
|
|
5
|
+
import type { ChessState, DrawResult } from "./domain.js";
|
|
4
6
|
export declare const MAX_EVALUATED_MOVES = 10;
|
|
5
|
-
export declare const MAX_PGN_BYTES: number;
|
|
6
|
-
export declare const MAX_PGN_PLIES = 4096;
|
|
7
|
-
export declare function assertSafeFenCounters(fen: string): void;
|
|
8
|
-
export declare function snapshotChess(chess: Chess): Chess;
|
|
9
7
|
export declare function drawResult(chess: Chess): DrawResult | null;
|
|
10
|
-
export declare function
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export declare function stateOf<Revision extends number>(chess: Chess, revision: Revision): ChessState & {
|
|
9
|
+
revision: Revision;
|
|
10
|
+
};
|
|
13
11
|
export declare function parseMove(chess: Chess, move: string): Move;
|
|
14
12
|
export declare function playParsedMove(chess: Chess, move: Move): Move;
|
|
15
13
|
export declare function pvToSan(chess: Chess, pv: readonly string[]): string[];
|
package/dist/chess.js
CHANGED
|
@@ -1,51 +1,14 @@
|
|
|
1
1
|
import { Chess } from "chess.js";
|
|
2
|
+
export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
|
|
3
|
+
export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
|
|
2
4
|
import { ChessError } from "./errors.js";
|
|
3
5
|
export const MAX_EVALUATED_MOVES = 10;
|
|
4
|
-
export const MAX_PGN_BYTES = 1024 * 1024;
|
|
5
|
-
export const MAX_PGN_PLIES = 4096;
|
|
6
|
-
const PGN_RESULTS = ["1-0", "0-1", "1/2-1/2", "*"];
|
|
7
6
|
function moveDescriptor(move) {
|
|
8
7
|
const base = { from: move.from, to: move.to };
|
|
9
8
|
return move.promotion ? { ...base, promotion: move.promotion } : base;
|
|
10
9
|
}
|
|
11
|
-
function isSafeDecimal(value, minimum) {
|
|
12
|
-
return (/^(?:0|[1-9]\d*)$/.test(value) &&
|
|
13
|
-
Number.isSafeInteger(Number(value)) &&
|
|
14
|
-
Number(value) >= minimum);
|
|
15
|
-
}
|
|
16
|
-
export function assertSafeFenCounters(fen) {
|
|
17
|
-
const fields = fen.split(/\s+/);
|
|
18
|
-
if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
|
|
19
|
-
throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
|
|
20
|
-
}
|
|
21
|
-
if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
|
|
22
|
-
throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
export function snapshotChess(chess) {
|
|
26
|
-
const history = chess.history({ verbose: true });
|
|
27
|
-
const initialFen = history[0]?.before ?? chess.fen();
|
|
28
|
-
assertSafeFenCounters(initialFen);
|
|
29
|
-
const snapshot = new Chess(initialFen);
|
|
30
|
-
const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
|
|
31
|
-
for (const [key, value] of Object.entries(chess.getHeaders())) {
|
|
32
|
-
snapshot.setHeader(key, value);
|
|
33
|
-
}
|
|
34
|
-
const restoreComment = () => {
|
|
35
|
-
const comment = comments.get(snapshot.fen());
|
|
36
|
-
if (comment !== undefined)
|
|
37
|
-
snapshot.setComment(comment);
|
|
38
|
-
};
|
|
39
|
-
restoreComment();
|
|
40
|
-
for (const move of history) {
|
|
41
|
-
snapshot.move(moveDescriptor(move));
|
|
42
|
-
restoreComment();
|
|
43
|
-
}
|
|
44
|
-
assertSafeFenCounters(snapshot.fen());
|
|
45
|
-
return snapshot;
|
|
46
|
-
}
|
|
47
10
|
export function drawResult(chess) {
|
|
48
|
-
if (!chess.isDraw())
|
|
11
|
+
if (chess.isCheckmate() || !chess.isDraw())
|
|
49
12
|
return null;
|
|
50
13
|
if (chess.isStalemate())
|
|
51
14
|
return "stalemate";
|
|
@@ -57,146 +20,21 @@ export function drawResult(chess) {
|
|
|
57
20
|
return "fifty_move_rule";
|
|
58
21
|
return "draw";
|
|
59
22
|
}
|
|
60
|
-
function withoutPgnComments(pgn) {
|
|
61
|
-
let result = "";
|
|
62
|
-
let braceComment = false;
|
|
63
|
-
let lineComment = false;
|
|
64
|
-
let quoted = false;
|
|
65
|
-
let escaped = false;
|
|
66
|
-
for (const char of pgn) {
|
|
67
|
-
if (braceComment) {
|
|
68
|
-
if (char === "}")
|
|
69
|
-
braceComment = false;
|
|
70
|
-
result += char === "\n" || char === "\r" ? char : " ";
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
if (lineComment) {
|
|
74
|
-
if (char === "\n" || char === "\r") {
|
|
75
|
-
lineComment = false;
|
|
76
|
-
result += char;
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
result += " ";
|
|
80
|
-
}
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
if (quoted) {
|
|
84
|
-
result += char;
|
|
85
|
-
if (escaped)
|
|
86
|
-
escaped = false;
|
|
87
|
-
else if (char === "\\")
|
|
88
|
-
escaped = true;
|
|
89
|
-
else if (char === '"')
|
|
90
|
-
quoted = false;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (char === '"') {
|
|
94
|
-
quoted = true;
|
|
95
|
-
result += char;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (char === "{") {
|
|
99
|
-
braceComment = true;
|
|
100
|
-
result += " ";
|
|
101
|
-
}
|
|
102
|
-
else if (char === ";") {
|
|
103
|
-
lineComment = true;
|
|
104
|
-
result += " ";
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
result += char;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return result;
|
|
111
|
-
}
|
|
112
|
-
function isPgnResult(value) {
|
|
113
|
-
return PGN_RESULTS.includes(value);
|
|
114
|
-
}
|
|
115
|
-
function declaredPgnResult(pgn) {
|
|
116
|
-
const visiblePgn = withoutPgnComments(pgn);
|
|
117
|
-
const headerResults = [
|
|
118
|
-
...visiblePgn.matchAll(/^\s*\[\s*Result\s+"((?:\\.|[^"\\])*)"\s*\]\s*$/gm),
|
|
119
|
-
].map((match) => match[1] ?? "");
|
|
120
|
-
const movetext = visiblePgn.replace(/^\s*\[[^\r\n]*\]\s*$/gm, "");
|
|
121
|
-
const markers = [
|
|
122
|
-
...movetext.matchAll(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?=\s|$)/g),
|
|
123
|
-
].map((match) => match[1] ?? "");
|
|
124
|
-
const results = [...headerResults, ...markers];
|
|
125
|
-
if (!results.every(isPgnResult)) {
|
|
126
|
-
throw new ChessError("INVALID_PGN", "invalid PGN result");
|
|
127
|
-
}
|
|
128
|
-
const result = results[0];
|
|
129
|
-
if (results.some((value) => value !== result)) {
|
|
130
|
-
throw new ChessError("INVALID_PGN", "PGN result header and marker disagree");
|
|
131
|
-
}
|
|
132
|
-
return result;
|
|
133
|
-
}
|
|
134
|
-
function validatePgnFenCounters(pgn) {
|
|
135
|
-
for (const match of withoutPgnComments(pgn).matchAll(/^\s*\[\s*FEN\s+"((?:\\.|[^"\\])*)"\s*\]\s*$/gim)) {
|
|
136
|
-
assertSafeFenCounters(match[1] ?? "");
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function validateResultForPosition(chess, result) {
|
|
140
|
-
if (result === undefined)
|
|
141
|
-
return;
|
|
142
|
-
if (chess.isCheckmate()) {
|
|
143
|
-
const expected = chess.turn() === "w" ? "0-1" : "1-0";
|
|
144
|
-
if (result === expected)
|
|
145
|
-
return;
|
|
146
|
-
throw new ChessError("INVALID_PGN", `checkmate result must be ${expected}`);
|
|
147
|
-
}
|
|
148
|
-
if ((chess.isStalemate() || chess.isInsufficientMaterial()) &&
|
|
149
|
-
(result === "1-0" || result === "0-1")) {
|
|
150
|
-
throw new ChessError("INVALID_PGN", "a drawn position cannot have a decisive result");
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
export function pgnOf(chess) {
|
|
154
|
-
const result = chess.isCheckmate()
|
|
155
|
-
? (chess.turn() === "w" ? "0-1" : "1-0")
|
|
156
|
-
: chess.isDraw() && (!chess.getHeaders().Result || chess.getHeaders().Result === "*")
|
|
157
|
-
? "1/2-1/2"
|
|
158
|
-
: undefined;
|
|
159
|
-
if (result === undefined)
|
|
160
|
-
return chess.pgn();
|
|
161
|
-
const snapshot = snapshotChess(chess);
|
|
162
|
-
snapshot.setHeader("Result", result);
|
|
163
|
-
return snapshot.pgn();
|
|
164
|
-
}
|
|
165
|
-
export function parseImportedPgn(pgn) {
|
|
166
|
-
if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
|
|
167
|
-
throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
|
|
168
|
-
}
|
|
169
|
-
validatePgnFenCounters(pgn);
|
|
170
|
-
const result = declaredPgnResult(pgn);
|
|
171
|
-
let chess;
|
|
172
|
-
try {
|
|
173
|
-
chess = new Chess();
|
|
174
|
-
chess.loadPgn(pgn);
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
|
|
178
|
-
}
|
|
179
|
-
assertSafeFenCounters(chess.fen());
|
|
180
|
-
if (chess.history().length > MAX_PGN_PLIES) {
|
|
181
|
-
throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
|
|
182
|
-
}
|
|
183
|
-
validateResultForPosition(chess, result);
|
|
184
|
-
return chess;
|
|
185
|
-
}
|
|
186
23
|
export function stateOf(chess, revision) {
|
|
187
24
|
const last = chess.history({ verbose: true }).at(-1);
|
|
25
|
+
const isCheckmate = chess.isCheckmate();
|
|
188
26
|
return {
|
|
189
27
|
fen: chess.fen(),
|
|
190
28
|
turn: chess.turn(),
|
|
191
29
|
revision,
|
|
192
30
|
isCheck: chess.isCheck(),
|
|
193
|
-
isCheckmate
|
|
31
|
+
isCheckmate,
|
|
194
32
|
isStalemate: chess.isStalemate(),
|
|
195
|
-
isDraw: chess.isDraw(),
|
|
33
|
+
isDraw: !isCheckmate && chess.isDraw(),
|
|
196
34
|
isGameOver: chess.isGameOver(),
|
|
197
|
-
isInsufficientMaterial: chess.isInsufficientMaterial(),
|
|
198
|
-
isThreefoldRepetition: chess.isThreefoldRepetition(),
|
|
199
|
-
isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
|
|
35
|
+
isInsufficientMaterial: !isCheckmate && chess.isInsufficientMaterial(),
|
|
36
|
+
isThreefoldRepetition: !isCheckmate && chess.isThreefoldRepetition(),
|
|
37
|
+
isDrawByFiftyMoves: !isCheckmate && chess.isDrawByFiftyMoves(),
|
|
200
38
|
moveNumber: chess.moveNumber(),
|
|
201
39
|
history: chess.history(),
|
|
202
40
|
lastMove: last ? { san: last.san, uci: last.lan } : null,
|