llm-chess-mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/server";
3
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
+ import * as z from "zod/v4";
5
+ import { Chess } from "chess.js";
6
+ import { loadEnv } from "./env.js";
7
+ import { createGame, createGameFromChess, getGame, deleteGame } from "./games.js";
8
+ import { stockfish } from "./engines/stockfish.js";
9
+ import { humanMoveDistribution } from "./maia3/inference.js";
10
+ import { openingExplorer, explorerEnabled } from "./explorer.js";
11
+ import { computeCandidates, rankByIntent } from "./intents.js";
12
+ import { scoreToCp, classifyCpLoss } from "./eval.js";
13
+ import { ChessError } from "./errors.js";
14
+ const INTENTS = [
15
+ "best",
16
+ "strong",
17
+ "natural",
18
+ "balanced",
19
+ "ease_off",
20
+ "give_chance",
21
+ ];
22
+ function text(data) {
23
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
24
+ }
25
+ function stateOf(chess) {
26
+ const last = chess.history({ verbose: true }).at(-1);
27
+ return {
28
+ fen: chess.fen(),
29
+ turn: chess.turn(),
30
+ isCheck: chess.isCheck(),
31
+ isCheckmate: chess.isCheckmate(),
32
+ isStalemate: chess.isStalemate(),
33
+ isDraw: chess.isDraw(),
34
+ isGameOver: chess.isGameOver(),
35
+ isInsufficientMaterial: chess.isInsufficientMaterial(),
36
+ isThreefoldRepetition: chess.isThreefoldRepetition(),
37
+ isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
38
+ moveNumber: chess.moveNumber(),
39
+ history: chess.history(),
40
+ lastMove: last ? { san: last.san, uci: last.lan } : null,
41
+ castling: {
42
+ whiteKingside: chess.getCastlingRights("w").k,
43
+ whiteQueenside: chess.getCastlingRights("w").q,
44
+ blackKingside: chess.getCastlingRights("b").k,
45
+ blackQueenside: chess.getCastlingRights("b").q,
46
+ },
47
+ };
48
+ }
49
+ function parseMove(chess, move) {
50
+ const verbose = chess.moves({ verbose: true });
51
+ const san = move.replace(/[+#]$/, "");
52
+ const bySan = verbose.find((m) => m.san.replace(/[+#]$/, "") === san);
53
+ if (bySan)
54
+ return bySan;
55
+ const byLan = verbose.find((m) => m.lan === move);
56
+ if (byLan)
57
+ return byLan;
58
+ throw new ChessError("ILLEGAL_MOVE", `illegal move: ${move}`);
59
+ }
60
+ function wrap(handler) {
61
+ return async (args) => {
62
+ try {
63
+ return (await handler(args));
64
+ }
65
+ catch (e) {
66
+ if (e instanceof ChessError) {
67
+ return text({ error: { code: e.code, message: e.message } });
68
+ }
69
+ const msg = e instanceof Error ? e.message : String(e);
70
+ return text({ error: { code: "INTERNAL", message: msg } });
71
+ }
72
+ };
73
+ }
74
+ export function buildServer() {
75
+ const server = new McpServer({ name: "llm-chess-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
76
+ server.registerTool("create_game", {
77
+ description: "Create a new chess game and return its game_id. The server is the authoritative source of board state — never track the board yourself. Optionally pass a FEN to start from a custom position.",
78
+ inputSchema: z.object({
79
+ fen: z.string().optional(),
80
+ }),
81
+ }, wrap(async ({ fen }) => {
82
+ let id;
83
+ try {
84
+ id = createGame(fen);
85
+ }
86
+ catch {
87
+ throw new ChessError("INVALID_FEN", "invalid FEN");
88
+ }
89
+ return text({ game_id: id });
90
+ }));
91
+ server.registerTool("delete_game", {
92
+ description: "Delete a game and free its session.",
93
+ inputSchema: z.object({ game_id: z.string() }),
94
+ }, wrap(async ({ game_id }) => {
95
+ const ok = deleteGame(game_id);
96
+ if (!ok)
97
+ throw new ChessError("GAME_NOT_FOUND", `game not found: ${game_id}`);
98
+ return text({ game_id, deleted: true });
99
+ }));
100
+ server.registerTool("game_state", {
101
+ description: "Return the authoritative state of a game: FEN, turn, check/mate/draw flags, move history, last move, castling rights. Use this instead of remembering the board.",
102
+ inputSchema: z.object({ game_id: z.string() }),
103
+ }, wrap(async ({ game_id }) => {
104
+ const { chess } = getGame(game_id);
105
+ return text({ game_id, ...stateOf(chess) });
106
+ }));
107
+ server.registerTool("game_state_ascii", {
108
+ description: "Return an ASCII diagram of the current board.",
109
+ inputSchema: z.object({ game_id: z.string() }),
110
+ }, wrap(async ({ game_id }) => {
111
+ const { chess } = getGame(game_id);
112
+ return text({ game_id, board: chess.ascii() });
113
+ }));
114
+ server.registerTool("game_play_move", {
115
+ description: "Play a move (SAN like 'e4' or UCI like 'e2e4') and return the resulting state. This is the ONLY tool that mutates the game. Illegal moves are rejected and the state is unchanged.",
116
+ inputSchema: z.object({ game_id: z.string(), move: z.string() }),
117
+ }, wrap(async ({ game_id, move }) => {
118
+ const { chess } = getGame(game_id);
119
+ if (chess.isGameOver()) {
120
+ throw new ChessError("GAME_OVER", "game is already over");
121
+ }
122
+ const m = parseMove(chess, move);
123
+ chess.move({ from: m.from, to: m.to, promotion: m.promotion });
124
+ return text({ game_id, move: m.san, ...stateOf(chess) });
125
+ }));
126
+ server.registerTool("game_legal_moves", {
127
+ description: "List all legal moves in the current position (SAN, UCI, piece, capture, promotion).",
128
+ inputSchema: z.object({ game_id: z.string() }),
129
+ }, wrap(async ({ game_id }) => {
130
+ const { chess } = getGame(game_id);
131
+ const moves = chess.moves({ verbose: true }).map((m) => ({
132
+ san: m.san,
133
+ uci: m.lan,
134
+ from: m.from,
135
+ to: m.to,
136
+ piece: m.piece,
137
+ captured: m.captured ?? null,
138
+ promotion: m.promotion ?? null,
139
+ isCapture: m.flags.includes("c"),
140
+ isCheck: m.san.includes("+") || m.san.includes("#"),
141
+ }));
142
+ return text({ game_id, count: moves.length, moves });
143
+ }));
144
+ server.registerTool("position_analyze", {
145
+ description: "Run Stockfish on the current position and return the top engine lines (multipv). Scores are from the side-to-move perspective: positive cp = side to move is better; mate N = side to move mates in N. Does NOT mutate the game.",
146
+ inputSchema: z.object({
147
+ game_id: z.string(),
148
+ depth: z.number().int().min(1).max(30).default(15),
149
+ multipv: z.number().int().min(1).max(10).default(3),
150
+ }),
151
+ }, wrap(async ({ game_id, depth, multipv }) => {
152
+ const { chess } = getGame(game_id);
153
+ const lines = await stockfish.analyze(chess.fen(), depth, multipv);
154
+ return text({
155
+ game_id,
156
+ fen: chess.fen(),
157
+ turn: chess.turn(),
158
+ lines: lines.map((l) => ({
159
+ multipv: l.multipv,
160
+ scoreCp: l.scoreCp,
161
+ scoreMate: l.scoreMate,
162
+ pv: l.pv,
163
+ })),
164
+ });
165
+ }));
166
+ server.registerTool("human_move_distribution", {
167
+ description: "Return the Maia3 human-like move probability distribution for the current position, conditioned on a target Elo. Higher probability = more human-typical at that rating. This is NOT move quality — a high-probability move can still be objectively bad.",
168
+ inputSchema: z.object({
169
+ game_id: z.string(),
170
+ elo: z.number().int().min(600).max(2600).default(1500),
171
+ oppo_elo: z.number().int().min(600).max(2600).optional(),
172
+ top_n: z.number().int().min(1).max(20).default(5),
173
+ }),
174
+ }, wrap(async ({ game_id, elo, oppo_elo, top_n }) => {
175
+ const { chess } = getGame(game_id);
176
+ const moves = await humanMoveDistribution(chess, elo, oppo_elo ?? elo, top_n);
177
+ return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, moves });
178
+ }));
179
+ server.registerTool("move_evaluate", {
180
+ description: "Evaluate a specific move with Stockfish without mutating the game. Returns the score after the move (from the mover's perspective) plus cpLoss vs the best move and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
181
+ inputSchema: z.object({
182
+ game_id: z.string(),
183
+ move: z.string(),
184
+ depth: z.number().int().min(1).max(30).default(15),
185
+ }),
186
+ }, wrap(async ({ game_id, move, depth }) => {
187
+ const { chess } = getGame(game_id);
188
+ const m = parseMove(chess, move);
189
+ const copy = new Chess(chess.fen());
190
+ copy.move({ from: m.from, to: m.to, promotion: m.promotion });
191
+ if (copy.isCheckmate()) {
192
+ return text({
193
+ game_id,
194
+ move: m.san,
195
+ uci: m.lan,
196
+ result: "checkmate",
197
+ scoreCp: null,
198
+ scoreMate: 0,
199
+ bestCp: null,
200
+ cpLoss: null,
201
+ classification: "best",
202
+ pv: [],
203
+ });
204
+ }
205
+ if (copy.isStalemate()) {
206
+ return text({
207
+ game_id,
208
+ move: m.san,
209
+ uci: m.lan,
210
+ result: "stalemate",
211
+ scoreCp: 0,
212
+ scoreMate: null,
213
+ bestCp: null,
214
+ cpLoss: null,
215
+ classification: null,
216
+ pv: [],
217
+ });
218
+ }
219
+ const [afterLines, beforeLines] = await Promise.all([
220
+ stockfish.analyze(copy.fen(), depth, 1),
221
+ stockfish.analyze(chess.fen(), depth, 1),
222
+ ]);
223
+ const after = afterLines[0];
224
+ const before = beforeLines[0];
225
+ // Stockfish scores are side-to-move perspective. After the move the side
226
+ // to move is the opponent, so negate to get the mover's perspective.
227
+ const afterCp = after ? -scoreToCp(after) : null;
228
+ const beforeCp = before ? scoreToCp(before) : null;
229
+ const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
230
+ return text({
231
+ game_id,
232
+ move: m.san,
233
+ uci: m.lan,
234
+ result: "ongoing",
235
+ scoreCp: afterCp,
236
+ scoreMate: after?.scoreMate != null ? -after.scoreMate : null,
237
+ bestCp: beforeCp,
238
+ cpLoss,
239
+ classification: cpLoss !== null ? classifyCpLoss(cpLoss) : null,
240
+ pv: after?.pv ?? [],
241
+ });
242
+ }));
243
+ server.registerTool("move_candidates", {
244
+ description: "Combine Stockfish evaluation, Maia3 human probability, and Lichess human statistics into a unified candidate list. Each candidate has sfCp (side-to-move perspective), cpLoss (vs best), maia3Prob (human likelihood, NOT quality), and lichess (real game counts). Use this before choosing a move; the final choice is yours.",
245
+ inputSchema: z.object({
246
+ game_id: z.string(),
247
+ elo: z.number().int().min(600).max(2600).default(1500),
248
+ sf_depth: z.number().int().min(1).max(30).default(15),
249
+ sf_multipv: z.number().int().min(1).max(10).default(5),
250
+ maia_top_n: z.number().int().min(1).max(20).default(5),
251
+ lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
252
+ lichess_speeds: z.array(z.string()).default([]),
253
+ lichess_ratings: z.array(z.number().int()).default([]),
254
+ }),
255
+ }, wrap(async ({ game_id, elo, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
256
+ const { chess } = getGame(game_id);
257
+ const candidates = await computeCandidates(chess, elo, sf_depth, sf_multipv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
258
+ return text({ game_id, elo, candidates });
259
+ }));
260
+ server.registerTool("move_candidates_by_intent", {
261
+ description: "Return candidate moves ranked for a given strategic intent. intents: best (strongest engine move), strong (engine-strong but human-plausible), natural (most human-typical), balanced (blend of strength and human-likeness), ease_off (slightly weaker but human), give_chance (clearly weaker, gives opponent chances).",
262
+ inputSchema: z.object({
263
+ game_id: z.string(),
264
+ intent: z.enum(INTENTS),
265
+ elo: z.number().int().min(600).max(2600).default(1500),
266
+ sf_depth: z.number().int().min(1).max(30).default(15),
267
+ sf_multipv: z.number().int().min(1).max(10).default(8),
268
+ maia_top_n: z.number().int().min(1).max(20).default(10),
269
+ lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
270
+ lichess_speeds: z.array(z.string()).default([]),
271
+ lichess_ratings: z.array(z.number().int()).default([]),
272
+ }),
273
+ }, wrap(async ({ game_id, intent, elo, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
274
+ const { chess } = getGame(game_id);
275
+ const candidates = await computeCandidates(chess, elo, sf_depth, sf_multipv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
276
+ const ranked = rankByIntent(candidates, intent);
277
+ return text({ game_id, intent, elo, candidates: ranked });
278
+ }));
279
+ server.registerTool("opening_explorer", {
280
+ description: "Query the Lichess opening explorer for real human game statistics in the current position (requires LICHESS_TOKEN).",
281
+ inputSchema: z.object({
282
+ game_id: z.string(),
283
+ db: z.enum(["lichess", "masters"]).default("lichess"),
284
+ speeds: z.array(z.string()).default([]),
285
+ ratings: z.array(z.number().int()).default([]),
286
+ }),
287
+ }, wrap(async ({ game_id, db, speeds, ratings }) => {
288
+ if (!explorerEnabled()) {
289
+ throw new ChessError("LICHESS_DISABLED", "LICHESS_TOKEN not set; opening explorer is disabled");
290
+ }
291
+ const { chess } = getGame(game_id);
292
+ const result = await openingExplorer(chess, db, speeds, ratings);
293
+ return text({ game_id, ...result });
294
+ }));
295
+ server.registerTool("game_pgn", {
296
+ description: "Export the current game as PGN.",
297
+ inputSchema: z.object({ game_id: z.string() }),
298
+ }, wrap(async ({ game_id }) => {
299
+ const { chess } = getGame(game_id);
300
+ return text({ game_id, pgn: chess.pgn() });
301
+ }));
302
+ server.registerTool("game_import_pgn", {
303
+ description: "Import a PGN into a new game. Returns a new game_id with the position after all PGN moves. Rejects malformed or illegal PGN.",
304
+ inputSchema: z.object({ pgn: z.string() }),
305
+ }, wrap(async ({ pgn }) => {
306
+ let chess;
307
+ try {
308
+ chess = new Chess();
309
+ chess.loadPgn(pgn);
310
+ }
311
+ catch {
312
+ throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
313
+ }
314
+ const id = createGameFromChess(chess);
315
+ return text({ game_id: id, ...stateOf(chess) });
316
+ }));
317
+ return server;
318
+ }
319
+ loadEnv();
320
+ serveStdio(() => buildServer());
@@ -0,0 +1,104 @@
1
+ import { stockfish } from "./engines/stockfish.js";
2
+ import { humanMoveDistribution } from "./maia3/inference.js";
3
+ import { openingExplorer, explorerEnabled } from "./explorer.js";
4
+ import { scoreToCp } from "./eval.js";
5
+ function softmax(values, temperature) {
6
+ const scaled = values.map((v) => v / temperature);
7
+ const max = Math.max(...scaled);
8
+ const exps = scaled.map((v) => Math.exp(v - max));
9
+ const sum = exps.reduce((a, b) => a + b, 0);
10
+ return exps.map((e) => e / sum);
11
+ }
12
+ export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN, lichess) {
13
+ const [sfLines, maiaMoves, lichessMoves] = await Promise.all([
14
+ stockfish.analyze(chess.fen(), sfDepth, sfMultipv),
15
+ humanMoveDistribution(chess, elo, elo, maiaTopN),
16
+ lichess && explorerEnabled()
17
+ ? openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings)
18
+ .then((r) => r.moves)
19
+ .catch(() => [])
20
+ : Promise.resolve([]),
21
+ ]);
22
+ const maiaByUci = new Map(maiaMoves.map((m) => [m.uci, m.prob]));
23
+ const sfByUci = new Map(sfLines.map((l) => [l.pv[0], l]));
24
+ const lichessByUci = new Map(lichessMoves.map((m) => [m.uci, m]));
25
+ const bestCp = sfLines.length
26
+ ? Math.max(...sfLines.map((l) => scoreToCp(l)))
27
+ : 0;
28
+ const ucis = new Set([
29
+ ...sfByUci.keys(),
30
+ ...maiaByUci.keys(),
31
+ ...lichessByUci.keys(),
32
+ ]);
33
+ const candidates = [];
34
+ for (const uci of ucis) {
35
+ const sf = sfByUci.get(uci);
36
+ const cp = sf ? scoreToCp(sf) : null;
37
+ candidates.push({
38
+ uci,
39
+ san: toSan(chess, uci),
40
+ sfCp: cp,
41
+ cpLoss: cp !== null ? bestCp - cp : null,
42
+ maia3Prob: maiaByUci.get(uci) ?? null,
43
+ lichess: lichessByUci.get(uci) ?? null,
44
+ });
45
+ }
46
+ return candidates;
47
+ }
48
+ function toSan(chess, uci) {
49
+ const m = chess.moves({ verbose: true }).find((x) => x.lan === uci);
50
+ return m ? m.san : uci;
51
+ }
52
+ export function rankByIntent(candidates, intent) {
53
+ const withSf = candidates.filter((c) => c.sfCp !== null);
54
+ const bestCp = withSf.length ? Math.max(...withSf.map((c) => c.sfCp)) : 0;
55
+ const sfProbs = intent === "balanced"
56
+ ? softmax(candidates.map((x) => x.sfCp ?? -1000), 100)
57
+ : null;
58
+ const scored = candidates.map((c) => {
59
+ let score;
60
+ switch (intent) {
61
+ case "best":
62
+ score = c.sfCp ?? -Infinity;
63
+ break;
64
+ case "strong": {
65
+ const sf = c.sfCp ?? -Infinity;
66
+ const human = c.maia3Prob ?? 0;
67
+ score = human > 0 ? sf : -Infinity;
68
+ break;
69
+ }
70
+ case "natural":
71
+ score = c.maia3Prob ?? 0;
72
+ break;
73
+ case "balanced": {
74
+ const idx = candidates.indexOf(c);
75
+ score = 0.5 * sfProbs[idx] + 0.5 * (c.maia3Prob ?? 0);
76
+ break;
77
+ }
78
+ case "ease_off": {
79
+ const gap = bestCp - (c.sfCp ?? -Infinity);
80
+ const inRange = gap >= 30 && gap <= 80;
81
+ score = inRange ? c.maia3Prob ?? 0 : -Infinity;
82
+ break;
83
+ }
84
+ case "give_chance": {
85
+ const gap = bestCp - (c.sfCp ?? -Infinity);
86
+ const inRange = gap >= 80 && gap <= 150;
87
+ score = inRange ? c.maia3Prob ?? 0 : -Infinity;
88
+ break;
89
+ }
90
+ }
91
+ return { c, score };
92
+ });
93
+ const ranked = scored
94
+ .filter((x) => x.score !== -Infinity)
95
+ .sort((a, b) => b.score - a.score)
96
+ .map((x) => x.c);
97
+ if (ranked.length === 0 && (intent === "ease_off" || intent === "give_chance")) {
98
+ const human = candidates.filter((c) => c.maia3Prob !== null);
99
+ return human
100
+ .sort((a, b) => (a.sfCp ?? -Infinity) - (b.sfCp ?? -Infinity))
101
+ .slice(0, 5);
102
+ }
103
+ return ranked;
104
+ }
@@ -0,0 +1,76 @@
1
+ import * as ort from "onnxruntime-node";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ import { buildInput } from "./tokenize.js";
6
+ import { vocabIndex } from "./vocab.js";
7
+ import { mirrorMove } from "./mirror.js";
8
+ const MODEL_KEY = process.env.MAIA3_MODEL || "5m";
9
+ let session = null;
10
+ let sessionPromise = null;
11
+ function modelPath() {
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ const candidates = [
14
+ resolve(here, "../../models", `maia3-${MODEL_KEY}.onnx`),
15
+ resolve(process.cwd(), "models", `maia3-${MODEL_KEY}.onnx`),
16
+ ];
17
+ for (const c of candidates) {
18
+ if (existsSync(c))
19
+ return c;
20
+ }
21
+ throw new Error(`maia3 model not found (models/maia3-${MODEL_KEY}.onnx). Run \`pnpm export:maia3\` first.`);
22
+ }
23
+ async function getSession() {
24
+ if (session)
25
+ return session;
26
+ if (sessionPromise)
27
+ return sessionPromise;
28
+ sessionPromise = ort.InferenceSession.create(modelPath()).then((s) => {
29
+ session = s;
30
+ return s;
31
+ });
32
+ return sessionPromise;
33
+ }
34
+ function softmax(logits) {
35
+ let max = -Infinity;
36
+ for (let i = 0; i < logits.length; i++)
37
+ if (logits[i] > max)
38
+ max = logits[i];
39
+ let sum = 0;
40
+ const out = new Float32Array(logits.length);
41
+ for (let i = 0; i < logits.length; i++) {
42
+ out[i] = Math.exp(logits[i] - max);
43
+ sum += out[i];
44
+ }
45
+ for (let i = 0; i < out.length; i++)
46
+ out[i] /= sum;
47
+ return out;
48
+ }
49
+ export async function humanMoveDistribution(chess, elo, oppoElo, topN) {
50
+ const s = await getSession();
51
+ const legal = chess.moves({ verbose: true });
52
+ if (legal.length === 0)
53
+ return [];
54
+ const input = buildInput(chess);
55
+ const tokens = new ort.Tensor("float32", input, [1, 64, 96]);
56
+ const selfElo = new ort.Tensor("int64", BigInt64Array.from([BigInt(elo)]), [1]);
57
+ const oppoEloTensor = new ort.Tensor("int64", BigInt64Array.from([BigInt(oppoElo)]), [1]);
58
+ const feeds = { tokens, self_elo: selfElo, oppo_elo: oppoEloTensor };
59
+ const results = await s.run(feeds);
60
+ const logits = results.logits_move.data;
61
+ const turn = chess.turn();
62
+ const legalMask = new Float32Array(logits.length).fill(-Infinity);
63
+ for (const m of legal) {
64
+ const uci = turn === "w" ? m.lan : mirrorMove(m.lan);
65
+ const idx = vocabIndex(uci);
66
+ legalMask[idx] = logits[idx];
67
+ }
68
+ const probs = softmax(legalMask);
69
+ const ranked = legal
70
+ .map((m) => {
71
+ const uci = turn === "w" ? m.lan : mirrorMove(m.lan);
72
+ return { uci: m.lan, san: m.san, prob: probs[vocabIndex(uci)] };
73
+ })
74
+ .sort((a, b) => b.prob - a.prob);
75
+ return ranked.slice(0, topN);
76
+ }
@@ -0,0 +1,10 @@
1
+ const FILES = "abcdefgh";
2
+ export function mirrorSquare(sq) {
3
+ return sq[0] + String(9 - Number(sq[1]));
4
+ }
5
+ export function mirrorMove(uci) {
6
+ return mirrorSquare(uci.slice(0, 2)) + mirrorSquare(uci.slice(2, 4)) + uci.slice(4);
7
+ }
8
+ export function squareName(rank, file) {
9
+ return FILES[file] + String(rank + 1);
10
+ }
@@ -0,0 +1,55 @@
1
+ import { Chess } from "chess.js";
2
+ const PIECE_MAP = { p: 1, n: 2, b: 3, r: 4, q: 5, k: 6 };
3
+ export const HISTORY = 8;
4
+ export const TOKEN_DIM = 12;
5
+ export const INPUT_DIM = TOKEN_DIM * HISTORY;
6
+ function tokenizeBoard(chess) {
7
+ const tokens = new Float32Array(64 * TOKEN_DIM);
8
+ const turn = chess.turn();
9
+ const board = chess.board();
10
+ for (let s = 0; s < 64; s++) {
11
+ const rank = Math.floor(s / 8);
12
+ const file = s % 8;
13
+ let piece;
14
+ if (turn === "w") {
15
+ piece = board[7 - rank][file];
16
+ }
17
+ else {
18
+ const p = board[rank][file];
19
+ piece = p ? { type: p.type, color: p.color === "w" ? "b" : "w" } : null;
20
+ }
21
+ if (piece) {
22
+ const mapped = PIECE_MAP[piece.type];
23
+ const token = mapped + (piece.color === "b" ? 6 : 0);
24
+ tokens[s * TOKEN_DIM + (token - 1)] = 1;
25
+ }
26
+ }
27
+ return tokens;
28
+ }
29
+ function historyPositions(chess) {
30
+ const moves = chess.history({ verbose: true });
31
+ const positions = [new Chess()];
32
+ const replay = new Chess();
33
+ for (const m of moves) {
34
+ replay.move({ from: m.from, to: m.to, promotion: m.promotion });
35
+ positions.push(new Chess(replay.fen()));
36
+ }
37
+ return positions;
38
+ }
39
+ export function buildInput(chess) {
40
+ const positions = historyPositions(chess);
41
+ const recent = positions.slice(-HISTORY);
42
+ const boards = recent.map(tokenizeBoard);
43
+ const pad = HISTORY - boards.length;
44
+ const input = new Float32Array(64 * INPUT_DIM);
45
+ for (let s = 0; s < 64; s++) {
46
+ for (let h = 0; h < HISTORY; h++) {
47
+ const idx = h - pad;
48
+ const b = idx < 0 ? boards[0] : boards[idx];
49
+ for (let t = 0; t < TOKEN_DIM; t++) {
50
+ input[s * INPUT_DIM + h * TOKEN_DIM + t] = b[s * TOKEN_DIM + t];
51
+ }
52
+ }
53
+ }
54
+ return input;
55
+ }
@@ -0,0 +1,32 @@
1
+ import { squareName } from "./mirror.js";
2
+ const FILES = "abcdefgh";
3
+ const PROMO = ["q", "r", "b", "n"];
4
+ export const VOCAB_SIZE = 4352;
5
+ export const MOVE_VOCAB = (() => {
6
+ const moves = [];
7
+ for (let fromRank = 0; fromRank < 8; fromRank++) {
8
+ for (let fromFile = 0; fromFile < 8; fromFile++) {
9
+ const from = squareName(fromRank, fromFile);
10
+ for (let toRank = 0; toRank < 8; toRank++) {
11
+ for (let toFile = 0; toFile < 8; toFile++) {
12
+ moves.push(from + squareName(toRank, toFile));
13
+ }
14
+ }
15
+ }
16
+ }
17
+ for (let fromFile = 0; fromFile < 8; fromFile++) {
18
+ for (let toFile = 0; toFile < 8; toFile++) {
19
+ for (const piece of PROMO) {
20
+ moves.push(FILES[fromFile] + "7" + FILES[toFile] + "8" + piece);
21
+ }
22
+ }
23
+ }
24
+ return moves;
25
+ })();
26
+ const VOCAB_INDEX = new Map(MOVE_VOCAB.map((m, i) => [m, i]));
27
+ export function vocabIndex(uci) {
28
+ const i = VOCAB_INDEX.get(uci);
29
+ if (i === undefined)
30
+ throw new Error(`move not in vocab: ${uci}`);
31
+ return i;
32
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};