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 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 m = line.match(/ wdl (\d+) (\d+) (\d+)/);
42
- if (!m)
41
+ const groups = line.match(/ wdl (?<wins>\d+) (?<draws>\d+) (?<losses>\d+)/)?.groups;
42
+ if (!groups)
43
43
  return null;
44
- return [Number(m[1]), Number(m[2]), Number(m[3])];
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 m = line.match(/multipv (\d+)/);
250
- const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
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 n = Number(m[1]);
255
- const score = s ? parseScore(s[1]) : { cp: null, mate: null };
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[1].split(" ") : [],
263
+ pv: pv ? pv.split(" ") : [],
262
264
  });
263
265
  }
264
266
  else if (line.startsWith("bestmove")) {
package/dist/errors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export class ChessError extends Error {
2
2
  code;
3
3
  constructor(code, message) {
4
- super(`${code}: ${message}`);
4
+ super(message);
5
5
  this.code = code;
6
6
  this.name = "ChessError";
7
7
  }
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
- export async function openingExplorer(chess, db, speeds, ratings) {
6
- if (!explorerEnabled()) {
7
- throw new Error("LICHESS_TOKEN not set; opening explorer is disabled");
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 res = await fetch(`${BASE}/${db}?${params}`, {
16
- headers: { Authorization: `Bearer ${process.env.LICHESS_TOKEN}` },
17
- });
18
- if (!res.ok) {
19
- throw new Error(`lichess explorer ${db} failed: HTTP ${res.status}`);
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
- const data = (await res.json());
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
- function isExpired(game, now) {
8
- return now - game.lastAccessedAt >= GAME_TTL_MS;
9
- }
10
- export function cleanupGames(now = Date.now()) {
11
- let removed = 0;
12
- for (const [id, game] of games) {
13
- if (!isExpired(game, now))
14
- continue;
15
- games.delete(id);
16
- removed += 1;
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
- return removed;
19
- }
20
- function storeGame(chess) {
21
- cleanupGames();
22
- if (games.size >= MAX_GAMES) {
23
- throw new ChessError("GAME_LIMIT_REACHED", `game session limit reached: ${MAX_GAMES}`);
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
- const id = randomUUID();
26
- const now = Date.now();
27
- games.set(id, { chess, createdAt: now, lastAccessedAt: now, revision: 0 });
28
- return id;
29
- }
30
- export function createGame(fen) {
31
- const chess = fen ? new Chess(fen) : new Chess();
32
- return storeGame(chess);
33
- }
34
- export function createGameFromChess(chess) {
35
- return storeGame(chess);
36
- }
37
- export function getGame(id) {
38
- const g = games.get(id);
39
- if (!g)
40
- throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
41
- if (isExpired(g, Date.now())) {
42
- games.delete(id);
43
- throw new ChessError("GAME_EXPIRED", `game expired: ${id}`);
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();