llm-chess-mcp 0.1.3 → 0.3.1
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 +48 -0
- package/dist/chess.js +83 -0
- package/dist/engines/stockfish.js +17 -11
- package/dist/errors.js +1 -1
- package/dist/eval.js +2 -1
- package/dist/explorer.js +220 -25
- package/dist/games.js +83 -56
- package/dist/index.js +9 -419
- package/dist/intents.js +81 -46
- package/dist/maia3/inference.js +60 -14
- package/dist/maia3/mirror.js +11 -2
- package/dist/maia3/tokenize.js +11 -8
- package/dist/server.js +16 -0
- package/dist/services.js +15 -0
- package/dist/tool-inputs.js +117 -0
- package/dist/tool-meta.js +92 -0
- package/dist/tool-result.js +34 -0
- package/dist/tool-schemas.js +226 -0
- package/dist/tools/analysis.js +124 -0
- package/dist/tools/candidates.js +64 -0
- package/dist/tools/explorer.js +21 -0
- package/dist/tools/game.js +101 -0
- package/dist/types.js +8 -1
- package/docs/architecture.md +122 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -48,6 +48,28 @@ pnpm build
|
|
|
48
48
|
pnpm test
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
`pnpm test:unit` runs the unit suite. `pnpm test:e2e` builds first, then runs
|
|
52
|
+
the MCP transport tests. `pnpm check` runs the full local gate; use
|
|
53
|
+
`pnpm release:check` before publishing.
|
|
54
|
+
|
|
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
|
+
|
|
51
73
|
### Export Maia3 to ONNX (build-time only)
|
|
52
74
|
|
|
53
75
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
@@ -74,6 +96,13 @@ cp .env.example .env
|
|
|
74
96
|
|
|
75
97
|
Without a token, `opening_explorer` returns a disabled notice; all other tools work.
|
|
76
98
|
|
|
99
|
+
Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
|
|
100
|
+
`rapid`, `classical`, and `correspondence`; rating buckets are `0`, `1000`,
|
|
101
|
+
`1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
|
|
102
|
+
neither filter. Invalid filters fail locally. Transient failures (network,
|
|
103
|
+
timeout, 429, and 5xx) are retried once within a 12-second total budget;
|
|
104
|
+
invalid requests and other 4xx responses are not retried.
|
|
105
|
+
|
|
77
106
|
## Configure in your MCP client
|
|
78
107
|
|
|
79
108
|
### opencode
|
|
@@ -155,6 +184,14 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
155
184
|
| `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
|
|
156
185
|
| `opening_explorer` | Lichess human game statistics |
|
|
157
186
|
|
|
187
|
+
## Result format
|
|
188
|
+
|
|
189
|
+
`structuredContent` is the canonical successful result. Handler-level failures
|
|
190
|
+
set `isError` and provide `structuredContent.error`. Input-schema failures are
|
|
191
|
+
generated by the MCP SDK before the handler and use its standard `isError` text
|
|
192
|
+
result without `structuredContent`. Otherwise, `content` is only a short
|
|
193
|
+
human-readable summary and must not be parsed as data.
|
|
194
|
+
|
|
158
195
|
## Score conventions
|
|
159
196
|
|
|
160
197
|
- Stockfish scores are **side-to-move perspective**: positive cp = side to move is
|
|
@@ -279,6 +316,17 @@ It checks top-1/top-k move agreement and max probability error to detect
|
|
|
279
316
|
export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
|
|
280
317
|
and top-5 agreement and max probability error < 1e-4.
|
|
281
318
|
|
|
319
|
+
## Package verification
|
|
320
|
+
|
|
321
|
+
Package artifacts are verified locally; this project intentionally has no
|
|
322
|
+
hosted CI workflow.
|
|
323
|
+
|
|
324
|
+
Run `pnpm check` for the deterministic offline gate. Use `pnpm test:package` to
|
|
325
|
+
pack the project, install the tarball in a clean temporary directory, and run
|
|
326
|
+
the installed `llm-chess-mcp` binary against the real Stockfish and Maia
|
|
327
|
+
runtimes. `pnpm release:check` runs both checks plus the production dependency
|
|
328
|
+
audit and package manifest dry run.
|
|
329
|
+
|
|
282
330
|
## License & attribution
|
|
283
331
|
|
|
284
332
|
This project is licensed under the **AGPL-3.0** (see `LICENSE`).
|
package/dist/chess.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Chess } from "chess.js";
|
|
2
|
+
import { ChessError } from "./errors.js";
|
|
3
|
+
export const MAX_EVALUATED_MOVES = 10;
|
|
4
|
+
export const MAX_PGN_BYTES = 1024 * 1024;
|
|
5
|
+
export const MAX_PGN_PLIES = 4096;
|
|
6
|
+
function moveDescriptor(move) {
|
|
7
|
+
const base = { from: move.from, to: move.to };
|
|
8
|
+
return move.promotion ? { ...base, promotion: move.promotion } : base;
|
|
9
|
+
}
|
|
10
|
+
export function snapshotChess(chess) {
|
|
11
|
+
const history = chess.history({ verbose: true });
|
|
12
|
+
const snapshot = new Chess(history[0]?.before ?? chess.fen());
|
|
13
|
+
for (const move of history)
|
|
14
|
+
snapshot.move(moveDescriptor(move));
|
|
15
|
+
return snapshot;
|
|
16
|
+
}
|
|
17
|
+
export function drawResult(chess) {
|
|
18
|
+
if (!chess.isDraw())
|
|
19
|
+
return null;
|
|
20
|
+
if (chess.isStalemate())
|
|
21
|
+
return "stalemate";
|
|
22
|
+
if (chess.isInsufficientMaterial())
|
|
23
|
+
return "insufficient_material";
|
|
24
|
+
if (chess.isThreefoldRepetition())
|
|
25
|
+
return "threefold_repetition";
|
|
26
|
+
if (chess.isDrawByFiftyMoves())
|
|
27
|
+
return "fifty_move_rule";
|
|
28
|
+
return "draw";
|
|
29
|
+
}
|
|
30
|
+
export function parseImportedPgn(pgn) {
|
|
31
|
+
if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
|
|
32
|
+
throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
|
|
33
|
+
}
|
|
34
|
+
let chess;
|
|
35
|
+
try {
|
|
36
|
+
chess = new Chess();
|
|
37
|
+
chess.loadPgn(pgn);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
|
|
41
|
+
}
|
|
42
|
+
if (chess.history().length > MAX_PGN_PLIES) {
|
|
43
|
+
throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
|
|
44
|
+
}
|
|
45
|
+
return chess;
|
|
46
|
+
}
|
|
47
|
+
export function stateOf(chess, revision) {
|
|
48
|
+
const last = chess.history({ verbose: true }).at(-1);
|
|
49
|
+
return {
|
|
50
|
+
fen: chess.fen(),
|
|
51
|
+
turn: chess.turn(),
|
|
52
|
+
revision,
|
|
53
|
+
isCheck: chess.isCheck(),
|
|
54
|
+
isCheckmate: chess.isCheckmate(),
|
|
55
|
+
isStalemate: chess.isStalemate(),
|
|
56
|
+
isDraw: chess.isDraw(),
|
|
57
|
+
isGameOver: chess.isGameOver(),
|
|
58
|
+
isInsufficientMaterial: chess.isInsufficientMaterial(),
|
|
59
|
+
isThreefoldRepetition: chess.isThreefoldRepetition(),
|
|
60
|
+
isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
|
|
61
|
+
moveNumber: chess.moveNumber(),
|
|
62
|
+
history: chess.history(),
|
|
63
|
+
lastMove: last ? { san: last.san, uci: last.lan } : null,
|
|
64
|
+
castling: {
|
|
65
|
+
whiteKingside: chess.getCastlingRights("w").k,
|
|
66
|
+
whiteQueenside: chess.getCastlingRights("w").q,
|
|
67
|
+
blackKingside: chess.getCastlingRights("b").k,
|
|
68
|
+
blackQueenside: chess.getCastlingRights("b").q,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function parseMove(chess, move) {
|
|
73
|
+
const legal = chess.moves({ verbose: true });
|
|
74
|
+
const san = move.replace(/[+#]$/, "");
|
|
75
|
+
const found = legal.find((candidate) => candidate.san.replace(/[+#]$/, "") === san) ??
|
|
76
|
+
legal.find((candidate) => candidate.lan === move);
|
|
77
|
+
if (!found)
|
|
78
|
+
throw new ChessError("ILLEGAL_MOVE", `illegal move: ${move}`);
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
export function playParsedMove(chess, move) {
|
|
82
|
+
return chess.move(moveDescriptor(move));
|
|
83
|
+
}
|
|
@@ -38,10 +38,10 @@ function parseScore(token) {
|
|
|
38
38
|
return { cp: null, mate: null };
|
|
39
39
|
}
|
|
40
40
|
function parseWdl(line) {
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
41
|
+
const groups = line.match(/ wdl (?<wins>\d+) (?<draws>\d+) (?<losses>\d+)/)?.groups;
|
|
42
|
+
if (!groups)
|
|
43
43
|
return null;
|
|
44
|
-
return [Number(
|
|
44
|
+
return [Number(groups.wins), Number(groups.draws), Number(groups.losses)];
|
|
45
45
|
}
|
|
46
46
|
export class Stockfish {
|
|
47
47
|
session = null;
|
|
@@ -246,19 +246,25 @@ export class Stockfish {
|
|
|
246
246
|
const abort = (error) => fail(error, false);
|
|
247
247
|
const listener = (line) => {
|
|
248
248
|
if (line.startsWith("info") && line.includes(" multipv ")) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
const pv = line.match(/ pv (.+)$/);
|
|
252
|
-
if (!m)
|
|
249
|
+
const multipv = line.match(/multipv (?<value>\d+)/)?.groups?.value;
|
|
250
|
+
if (!multipv)
|
|
253
251
|
return;
|
|
254
|
-
const
|
|
255
|
-
const
|
|
252
|
+
const scoreToken = line.match(/ score (?<value>cp -?\d+|mate -?\d+)/)?.groups?.value;
|
|
253
|
+
const pv = line.match(/ pv (?<value>.+)$/)?.groups?.value;
|
|
254
|
+
const n = Number(multipv);
|
|
255
|
+
const previous = byPv.get(n);
|
|
256
|
+
const score = scoreToken
|
|
257
|
+
? parseScore(scoreToken)
|
|
258
|
+
: {
|
|
259
|
+
cp: previous?.scoreCp ?? null,
|
|
260
|
+
mate: previous?.scoreMate ?? null,
|
|
261
|
+
};
|
|
256
262
|
byPv.set(n, {
|
|
257
263
|
multipv: n,
|
|
258
264
|
scoreCp: score.cp,
|
|
259
265
|
scoreMate: score.mate,
|
|
260
|
-
wdl: parseWdl(line),
|
|
261
|
-
pv: pv ? pv
|
|
266
|
+
wdl: parseWdl(line) ?? previous?.wdl ?? null,
|
|
267
|
+
pv: pv ? pv.split(" ") : (previous?.pv ?? []),
|
|
262
268
|
});
|
|
263
269
|
}
|
|
264
270
|
else if (line.startsWith("bestmove")) {
|
package/dist/errors.js
CHANGED
package/dist/eval.js
CHANGED
|
@@ -11,7 +11,8 @@ export function evalToCp(e) {
|
|
|
11
11
|
if (e.type === "cp")
|
|
12
12
|
return e.value;
|
|
13
13
|
const sign = e.plies >= 0 ? 1 : -1;
|
|
14
|
-
|
|
14
|
+
const magnitude = Math.max(9_000, 10_000 - Math.abs(e.plies) * 100);
|
|
15
|
+
return sign * magnitude;
|
|
15
16
|
}
|
|
16
17
|
export function negateEval(e) {
|
|
17
18
|
if (e.type === "cp")
|
package/dist/explorer.js
CHANGED
|
@@ -1,38 +1,233 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
1
2
|
const BASE = "https://explorer.lichess.org";
|
|
3
|
+
export const LICHESS_SPEEDS = [
|
|
4
|
+
"ultraBullet",
|
|
5
|
+
"bullet",
|
|
6
|
+
"blitz",
|
|
7
|
+
"rapid",
|
|
8
|
+
"classical",
|
|
9
|
+
"correspondence",
|
|
10
|
+
];
|
|
11
|
+
export const LICHESS_RATINGS = [
|
|
12
|
+
0, 1000, 1200, 1400, 1600, 1800, 2000, 2200, 2500,
|
|
13
|
+
];
|
|
14
|
+
export const lichessSpeedSchema = z.enum(LICHESS_SPEEDS);
|
|
15
|
+
export const lichessRatingSchema = z.union([
|
|
16
|
+
z.literal(0),
|
|
17
|
+
z.literal(1000),
|
|
18
|
+
z.literal(1200),
|
|
19
|
+
z.literal(1400),
|
|
20
|
+
z.literal(1600),
|
|
21
|
+
z.literal(1800),
|
|
22
|
+
z.literal(2000),
|
|
23
|
+
z.literal(2200),
|
|
24
|
+
z.literal(2500),
|
|
25
|
+
]);
|
|
26
|
+
export const EXPLORER_ATTEMPT_TIMEOUT_MS = 5_000;
|
|
27
|
+
export const EXPLORER_MAX_ATTEMPTS = 2;
|
|
28
|
+
export const EXPLORER_MAX_RETRY_DELAY_MS = 2_000;
|
|
29
|
+
export const EXPLORER_DEFAULT_RETRY_DELAY_MS = 250;
|
|
30
|
+
export const EXPLORER_TOTAL_TIMEOUT_MS = 12_000;
|
|
31
|
+
export const EXPLORER_ERROR_KINDS = [
|
|
32
|
+
"disabled",
|
|
33
|
+
"invalid_input",
|
|
34
|
+
"timeout",
|
|
35
|
+
"network",
|
|
36
|
+
"auth",
|
|
37
|
+
"rate_limited",
|
|
38
|
+
"upstream",
|
|
39
|
+
"http",
|
|
40
|
+
"invalid_response",
|
|
41
|
+
];
|
|
42
|
+
export class ExplorerError extends Error {
|
|
43
|
+
kind;
|
|
44
|
+
status;
|
|
45
|
+
reason;
|
|
46
|
+
constructor(kind, message, status) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.kind = kind;
|
|
49
|
+
this.status = status;
|
|
50
|
+
this.name = "ExplorerError";
|
|
51
|
+
this.reason = kind;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const countSchema = z.number().int().nonnegative();
|
|
55
|
+
const responseSchema = z.object({
|
|
56
|
+
white: countSchema,
|
|
57
|
+
draws: countSchema,
|
|
58
|
+
black: countSchema,
|
|
59
|
+
moves: z.array(z.object({
|
|
60
|
+
uci: z.string().min(1),
|
|
61
|
+
san: z.string().min(1),
|
|
62
|
+
white: countSchema,
|
|
63
|
+
draws: countSchema,
|
|
64
|
+
black: countSchema,
|
|
65
|
+
averageRating: z.number().nonnegative().optional(),
|
|
66
|
+
})),
|
|
67
|
+
opening: z
|
|
68
|
+
.object({ eco: z.string().min(1), name: z.string().min(1) })
|
|
69
|
+
.nullable()
|
|
70
|
+
.optional(),
|
|
71
|
+
});
|
|
72
|
+
const speedSet = new Set(LICHESS_SPEEDS);
|
|
73
|
+
const ratingSet = new Set(LICHESS_RATINGS);
|
|
2
74
|
export function explorerEnabled() {
|
|
3
75
|
return (process.env.LICHESS_TOKEN || "").length > 0;
|
|
4
76
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
77
|
+
function error(kind, status) {
|
|
78
|
+
const message = {
|
|
79
|
+
disabled: "Lichess opening explorer is disabled",
|
|
80
|
+
invalid_input: "Invalid Lichess opening explorer filters",
|
|
81
|
+
timeout: "Lichess opening explorer timed out",
|
|
82
|
+
network: "Lichess opening explorer network failure",
|
|
83
|
+
auth: "Lichess opening explorer authentication failed",
|
|
84
|
+
rate_limited: "Lichess opening explorer rate limited the request",
|
|
85
|
+
upstream: "Lichess opening explorer service failure",
|
|
86
|
+
http: "Lichess opening explorer rejected the request",
|
|
87
|
+
invalid_response: "Lichess opening explorer returned an invalid response",
|
|
88
|
+
};
|
|
89
|
+
return new ExplorerError(kind, message[kind], status);
|
|
90
|
+
}
|
|
91
|
+
function retryAfterMs(value, now) {
|
|
92
|
+
if (!value)
|
|
93
|
+
return EXPLORER_DEFAULT_RETRY_DELAY_MS;
|
|
94
|
+
const seconds = Number(value);
|
|
95
|
+
const delay = Number.isFinite(seconds)
|
|
96
|
+
? seconds * 1_000
|
|
97
|
+
: Date.parse(value) - now;
|
|
98
|
+
if (!Number.isFinite(delay))
|
|
99
|
+
return EXPLORER_DEFAULT_RETRY_DELAY_MS;
|
|
100
|
+
return Math.max(0, delay);
|
|
101
|
+
}
|
|
102
|
+
function isRetryable(kind) {
|
|
103
|
+
return (kind === "timeout" ||
|
|
104
|
+
kind === "network" ||
|
|
105
|
+
kind === "rate_limited" ||
|
|
106
|
+
kind === "upstream");
|
|
107
|
+
}
|
|
108
|
+
export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
|
|
109
|
+
const token = options.token ?? process.env.LICHESS_TOKEN ?? "";
|
|
110
|
+
if (!token)
|
|
111
|
+
throw error("disabled");
|
|
112
|
+
if (!speeds.every((speed) => speedSet.has(speed)) ||
|
|
113
|
+
new Set(speeds).size !== speeds.length) {
|
|
114
|
+
throw error("invalid_input");
|
|
115
|
+
}
|
|
116
|
+
if (!ratings.every((rating) => ratingSet.has(rating)) ||
|
|
117
|
+
new Set(ratings).size !== ratings.length) {
|
|
118
|
+
throw error("invalid_input");
|
|
8
119
|
}
|
|
120
|
+
if (db === "masters" && (speeds.length > 0 || ratings.length > 0)) {
|
|
121
|
+
throw error("invalid_input");
|
|
122
|
+
}
|
|
123
|
+
const request = options.fetch ?? globalThis.fetch;
|
|
124
|
+
const sleep = options.sleep ??
|
|
125
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
126
|
+
const timeout = options.timeout ?? ((ms) => AbortSignal.timeout(ms));
|
|
127
|
+
const now = options.now ?? Date.now;
|
|
128
|
+
const deadline = now() + EXPLORER_TOTAL_TIMEOUT_MS;
|
|
9
129
|
const params = new URLSearchParams();
|
|
10
130
|
params.set("fen", chess.fen());
|
|
11
131
|
if (speeds.length)
|
|
12
132
|
params.set("speeds", speeds.join(","));
|
|
13
133
|
if (ratings.length)
|
|
14
134
|
params.set("ratings", ratings.join(","));
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
135
|
+
const url = `${BASE}/${db}?${params}`;
|
|
136
|
+
const legalMoves = new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san]));
|
|
137
|
+
let lastError = error("network");
|
|
138
|
+
for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
|
|
139
|
+
const remaining = deadline - now();
|
|
140
|
+
if (remaining <= 0)
|
|
141
|
+
throw error("timeout");
|
|
142
|
+
const signal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await request(url, {
|
|
146
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
147
|
+
signal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
lastError = error(signal.aborted ? "timeout" : "network");
|
|
152
|
+
if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
|
|
153
|
+
throw lastError;
|
|
154
|
+
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
|
|
155
|
+
await sleep(delay);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const kind = response.status === 401 || response.status === 403
|
|
160
|
+
? "auth"
|
|
161
|
+
: response.status === 429
|
|
162
|
+
? "rate_limited"
|
|
163
|
+
: response.status >= 500 && response.status <= 599
|
|
164
|
+
? "upstream"
|
|
165
|
+
: "http";
|
|
166
|
+
lastError = error(kind, response.status);
|
|
167
|
+
if (!isRetryable(kind) || attempt + 1 >= EXPLORER_MAX_ATTEMPTS) {
|
|
168
|
+
throw lastError;
|
|
169
|
+
}
|
|
170
|
+
const delay = retryAfterMs(response.headers.get("retry-after"), now());
|
|
171
|
+
const retryBudget = Math.max(0, deadline - now());
|
|
172
|
+
if (delay > EXPLORER_MAX_RETRY_DELAY_MS || delay >= retryBudget) {
|
|
173
|
+
throw lastError;
|
|
174
|
+
}
|
|
175
|
+
await sleep(delay);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let body;
|
|
179
|
+
try {
|
|
180
|
+
body = await response.json();
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
if (signal.aborted || cause instanceof TypeError) {
|
|
184
|
+
lastError = error(signal.aborted ? "timeout" : "network");
|
|
185
|
+
if (attempt + 1 < EXPLORER_MAX_ATTEMPTS) {
|
|
186
|
+
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
|
|
187
|
+
await sleep(delay);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
throw lastError;
|
|
191
|
+
}
|
|
192
|
+
throw error("invalid_response");
|
|
193
|
+
}
|
|
194
|
+
const parsed = responseSchema.safeParse(body);
|
|
195
|
+
if (!parsed.success)
|
|
196
|
+
throw error("invalid_response");
|
|
197
|
+
const data = parsed.data;
|
|
198
|
+
const ucis = new Set();
|
|
199
|
+
let white = 0;
|
|
200
|
+
let draws = 0;
|
|
201
|
+
let black = 0;
|
|
202
|
+
for (const move of data.moves) {
|
|
203
|
+
if (ucis.has(move.uci) ||
|
|
204
|
+
legalMoves.get(move.uci) !== move.san) {
|
|
205
|
+
throw error("invalid_response");
|
|
206
|
+
}
|
|
207
|
+
ucis.add(move.uci);
|
|
208
|
+
white += move.white;
|
|
209
|
+
draws += move.draws;
|
|
210
|
+
black += move.black;
|
|
211
|
+
}
|
|
212
|
+
if (white > data.white || draws > data.draws || black > data.black) {
|
|
213
|
+
throw error("invalid_response");
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
db,
|
|
217
|
+
white: data.white,
|
|
218
|
+
draws: data.draws,
|
|
219
|
+
black: data.black,
|
|
220
|
+
moves: data.moves.map((move) => ({
|
|
221
|
+
uci: move.uci,
|
|
222
|
+
san: move.san,
|
|
223
|
+
white: move.white,
|
|
224
|
+
draws: move.draws,
|
|
225
|
+
black: move.black,
|
|
226
|
+
count: move.white + move.draws + move.black,
|
|
227
|
+
averageRating: move.averageRating ?? null,
|
|
228
|
+
})),
|
|
229
|
+
opening: data.opening ?? null,
|
|
230
|
+
};
|
|
20
231
|
}
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
db,
|
|
24
|
-
white: data.white,
|
|
25
|
-
draws: data.draws,
|
|
26
|
-
black: data.black,
|
|
27
|
-
moves: data.moves.map((m) => ({
|
|
28
|
-
uci: m.uci,
|
|
29
|
-
san: m.san,
|
|
30
|
-
white: m.white,
|
|
31
|
-
draws: m.draws,
|
|
32
|
-
black: m.black,
|
|
33
|
-
count: m.white + m.draws + m.black,
|
|
34
|
-
averageRating: m.averageRating ?? null,
|
|
35
|
-
})),
|
|
36
|
-
opening: data.opening ?? null,
|
|
37
|
-
};
|
|
232
|
+
throw lastError;
|
|
38
233
|
}
|
package/dist/games.js
CHANGED
|
@@ -1,64 +1,91 @@
|
|
|
1
|
-
import { Chess } from "chess.js";
|
|
2
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Chess } from "chess.js";
|
|
3
3
|
import { ChessError } from "./errors.js";
|
|
4
|
-
const games = new Map();
|
|
5
4
|
export const MAX_GAMES = 1_000;
|
|
6
5
|
export const GAME_TTL_MS = 60 * 60 * 1_000;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
export class GameStore {
|
|
7
|
+
maxGames;
|
|
8
|
+
idleTtlMs;
|
|
9
|
+
games = new Map();
|
|
10
|
+
clock;
|
|
11
|
+
createId;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.maxGames = options.maxGames ?? MAX_GAMES;
|
|
14
|
+
this.idleTtlMs = options.idleTtlMs ?? GAME_TTL_MS;
|
|
15
|
+
this.clock = options.clock ?? Date.now;
|
|
16
|
+
this.createId = options.createId ?? randomUUID;
|
|
17
|
+
if (!Number.isInteger(this.maxGames) || this.maxGames < 1) {
|
|
18
|
+
throw new RangeError("maxGames must be a positive integer");
|
|
19
|
+
}
|
|
20
|
+
if (!Number.isFinite(this.idleTtlMs) || this.idleTtlMs < 0) {
|
|
21
|
+
throw new RangeError("idleTtlMs must be a non-negative number");
|
|
22
|
+
}
|
|
17
23
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
cleanupGames(now = this.clock()) {
|
|
25
|
+
let removed = 0;
|
|
26
|
+
for (const [id, game] of this.games) {
|
|
27
|
+
if (!this.isExpired(game, now))
|
|
28
|
+
continue;
|
|
29
|
+
this.games.delete(id);
|
|
30
|
+
removed += 1;
|
|
31
|
+
}
|
|
32
|
+
return removed;
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
games.
|
|
43
|
-
|
|
34
|
+
createGame(fen) {
|
|
35
|
+
return this.createGameFromChess(fen === undefined ? new Chess() : new Chess(fen));
|
|
36
|
+
}
|
|
37
|
+
createGameFromChess(chess) {
|
|
38
|
+
const now = this.clock();
|
|
39
|
+
this.cleanupGames(now);
|
|
40
|
+
if (this.games.size >= this.maxGames) {
|
|
41
|
+
throw new ChessError("GAME_LIMIT_REACHED", `game session limit reached: ${this.maxGames}`);
|
|
42
|
+
}
|
|
43
|
+
const id = this.createId();
|
|
44
|
+
if (this.games.has(id)) {
|
|
45
|
+
throw new ChessError("GAME_ID_COLLISION", `game ID already exists: ${id}`);
|
|
46
|
+
}
|
|
47
|
+
this.games.set(id, { chess, createdAt: now, lastAccessedAt: now, revision: 0 });
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
getGame(id) {
|
|
51
|
+
const game = this.games.get(id);
|
|
52
|
+
if (!game)
|
|
53
|
+
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
54
|
+
const now = this.clock();
|
|
55
|
+
if (this.isExpired(game, now)) {
|
|
56
|
+
this.games.delete(id);
|
|
57
|
+
throw new ChessError("GAME_EXPIRED", `game expired: ${id}`);
|
|
58
|
+
}
|
|
59
|
+
game.lastAccessedAt = now;
|
|
60
|
+
return game;
|
|
61
|
+
}
|
|
62
|
+
bumpRevision(id) {
|
|
63
|
+
const game = this.getGame(id);
|
|
64
|
+
game.revision += 1;
|
|
65
|
+
return game.revision;
|
|
66
|
+
}
|
|
67
|
+
deleteGame(id) {
|
|
68
|
+
this.cleanupGames();
|
|
69
|
+
return this.games.delete(id);
|
|
70
|
+
}
|
|
71
|
+
listGames() {
|
|
72
|
+
this.cleanupGames();
|
|
73
|
+
return [...this.games.keys()];
|
|
74
|
+
}
|
|
75
|
+
gameCount() {
|
|
76
|
+
this.cleanupGames();
|
|
77
|
+
return this.games.size;
|
|
78
|
+
}
|
|
79
|
+
isExpired(game, now) {
|
|
80
|
+
return now - game.lastAccessedAt >= this.idleTtlMs;
|
|
44
81
|
}
|
|
45
|
-
g.lastAccessedAt = Date.now();
|
|
46
|
-
return g;
|
|
47
|
-
}
|
|
48
|
-
export function bumpRevision(id) {
|
|
49
|
-
const g = getGame(id);
|
|
50
|
-
g.revision += 1;
|
|
51
|
-
return g.revision;
|
|
52
|
-
}
|
|
53
|
-
export function deleteGame(id) {
|
|
54
|
-
cleanupGames();
|
|
55
|
-
return games.delete(id);
|
|
56
|
-
}
|
|
57
|
-
export function listGames() {
|
|
58
|
-
cleanupGames();
|
|
59
|
-
return [...games.keys()];
|
|
60
|
-
}
|
|
61
|
-
export function gameCount() {
|
|
62
|
-
cleanupGames();
|
|
63
|
-
return games.size;
|
|
64
82
|
}
|
|
83
|
+
export const defaultGameStore = new GameStore();
|
|
84
|
+
export const cleanupGames = (now) => defaultGameStore.cleanupGames(now);
|
|
85
|
+
export const createGame = (fen) => defaultGameStore.createGame(fen);
|
|
86
|
+
export const createGameFromChess = (chess) => defaultGameStore.createGameFromChess(chess);
|
|
87
|
+
export const getGame = (id) => defaultGameStore.getGame(id);
|
|
88
|
+
export const bumpRevision = (id) => defaultGameStore.bumpRevision(id);
|
|
89
|
+
export const deleteGame = (id) => defaultGameStore.deleteGame(id);
|
|
90
|
+
export const listGames = () => defaultGameStore.listGames();
|
|
91
|
+
export const gameCount = () => defaultGameStore.gameCount();
|