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 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 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,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 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 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[1].split(" ") : [],
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
@@ -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/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
- return sign * (10000 - Math.abs(e.plies) * 100);
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
- 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();