llm-chess-mcp 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/chess.js +83 -0
- package/dist/engines/stockfish.js +12 -10
- package/dist/errors.js +1 -1
- package/dist/explorer.js +220 -25
- package/dist/games.js +83 -56
- package/dist/index.js +9 -419
- package/dist/intents.js +74 -44
- 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 +121 -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/package.json +17 -4
package/README.md
CHANGED
|
@@ -48,6 +48,24 @@ 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
|
+
[the changelog](CHANGELOG.md) records client-visible changes.
|
|
59
|
+
|
|
60
|
+
Local quality commands:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm typecheck
|
|
64
|
+
pnpm test:coverage
|
|
65
|
+
pnpm contract:check
|
|
66
|
+
pnpm check
|
|
67
|
+
```
|
|
68
|
+
|
|
51
69
|
### Export Maia3 to ONNX (build-time only)
|
|
52
70
|
|
|
53
71
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
@@ -74,6 +92,13 @@ cp .env.example .env
|
|
|
74
92
|
|
|
75
93
|
Without a token, `opening_explorer` returns a disabled notice; all other tools work.
|
|
76
94
|
|
|
95
|
+
Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
|
|
96
|
+
`rapid`, `classical`, and `correspondence`; rating buckets are `0`, `1000`,
|
|
97
|
+
`1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
|
|
98
|
+
neither filter. Invalid filters fail locally. Transient failures (network,
|
|
99
|
+
timeout, 429, and 5xx) are retried once within a 12-second total budget;
|
|
100
|
+
invalid requests and other 4xx responses are not retried.
|
|
101
|
+
|
|
77
102
|
## Configure in your MCP client
|
|
78
103
|
|
|
79
104
|
### opencode
|
|
@@ -155,6 +180,20 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
155
180
|
| `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
|
|
156
181
|
| `opening_explorer` | Lichess human game statistics |
|
|
157
182
|
|
|
183
|
+
## Result format and 0.1.x migration
|
|
184
|
+
|
|
185
|
+
`structuredContent` is the canonical successful result. Handler-level failures
|
|
186
|
+
set `isError` and provide `structuredContent.error`. Input-schema failures are
|
|
187
|
+
generated by the MCP SDK before the handler and use its standard `isError` text
|
|
188
|
+
result without `structuredContent`. Otherwise, `content` is only a short
|
|
189
|
+
human-readable summary and must not be parsed as data.
|
|
190
|
+
|
|
191
|
+
Clients upgrading from 0.1.x should stop parsing `content` and consume
|
|
192
|
+
`structuredContent` instead. Check `isError` and the structured error code when
|
|
193
|
+
a tool fails. `move_evaluate` now always returns
|
|
194
|
+
`{ game_id, revision, results }`; its former single-move top-level duplicates
|
|
195
|
+
are removed.
|
|
196
|
+
|
|
158
197
|
## Score conventions
|
|
159
198
|
|
|
160
199
|
- Stockfish scores are **side-to-move perspective**: positive cp = side to move is
|
|
@@ -279,6 +318,16 @@ It checks top-1/top-k move agreement and max probability error to detect
|
|
|
279
318
|
export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
|
|
280
319
|
and top-5 agreement and max probability error < 1e-4.
|
|
281
320
|
|
|
321
|
+
## Releases
|
|
322
|
+
|
|
323
|
+
Releases are verified locally; this project intentionally has no hosted CI
|
|
324
|
+
release workflow.
|
|
325
|
+
|
|
326
|
+
For `0.2.0`, run `pnpm release:check`, pack the tarball, and smoke-test a clean
|
|
327
|
+
install of that tarball with `llm-chess-mcp`. Publish only after that succeeds.
|
|
328
|
+
Use the same local gate and clean-install smoke test before promoting the proven
|
|
329
|
+
`0.2.x` release process to `1.0.0`.
|
|
330
|
+
|
|
282
331
|
## License & attribution
|
|
283
332
|
|
|
284
333
|
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,21 @@ 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 score = scoreToken
|
|
256
|
+
? parseScore(scoreToken)
|
|
257
|
+
: { cp: null, mate: null };
|
|
256
258
|
byPv.set(n, {
|
|
257
259
|
multipv: n,
|
|
258
260
|
scoreCp: score.cp,
|
|
259
261
|
scoreMate: score.mate,
|
|
260
262
|
wdl: parseWdl(line),
|
|
261
|
-
pv: pv ? pv
|
|
263
|
+
pv: pv ? pv.split(" ") : [],
|
|
262
264
|
});
|
|
263
265
|
}
|
|
264
266
|
else if (line.startsWith("bestmove")) {
|
package/dist/errors.js
CHANGED
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();
|