llm-chess-mcp 0.5.0 → 0.6.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
@@ -207,9 +207,10 @@ human-readable summary and must not be parsed as data.
207
207
  `best / excellent / good / inaccuracy / mistake / blunder`.
208
208
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
209
209
  can still be objectively bad.
210
- - Analysis continuations return `pv` in UCI and the same legal prefix in
211
- `pvSan` as SAN. If an engine line contains an invalid move, `pvSan` stops
212
- before it while the original `pv` remains unchanged.
210
+ - Successful analysis continuations return corresponding `pv` and `pvSan`
211
+ arrays of equal length in UCI and SAN. An invalid engine continuation is
212
+ rejected at the internal tool boundary instead of returning a truncated
213
+ `pvSan`.
213
214
 
214
215
  ## Candidate structure
215
216
 
@@ -10,25 +10,41 @@ const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
10
10
  function squareColor(square) {
11
11
  return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
12
12
  }
13
- function minimumPawnCaptures(chess, color) {
14
- const pawns = chess
13
+ function minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) {
14
+ const requirements = chess
15
15
  .findPiece({ type: "p", color })
16
16
  .map((square) => ({
17
+ kind: "pawn",
17
18
  advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
18
19
  file: square.charCodeAt(0) - 97,
19
- }))
20
- .sort((left, right) => left.file - right.file);
20
+ }));
21
+ for (const bishopColor of [0, 1]) {
22
+ for (let count = 0; count < promotedBishops[bishopColor]; count += 1) {
23
+ requirements.push({ kind: "bishop", color: bishopColor });
24
+ }
25
+ }
26
+ for (let count = 0; count < promotedPieces; count += 1) {
27
+ requirements.push({ kind: "promotion" });
28
+ }
21
29
  let costs = new Map([[0, 0]]);
22
- for (const pawn of pawns) {
30
+ for (const requirement of requirements) {
23
31
  const next = new Map();
24
32
  for (const [mask, cost] of costs) {
25
33
  for (let original = 0; original < 8; original += 1) {
26
34
  const bit = 1 << original;
27
35
  if (mask & bit)
28
36
  continue;
29
- const captures = Math.abs(original - pawn.file);
30
- if (captures > pawn.advances)
31
- continue;
37
+ let captures = 0;
38
+ if (requirement.kind === "pawn") {
39
+ captures = Math.abs(original - requirement.file);
40
+ if (captures > requirement.advances)
41
+ continue;
42
+ }
43
+ else if (requirement.kind === "bishop") {
44
+ const promotionRank = color === "w" ? 8 : 1;
45
+ const promotionColor = ((original + promotionRank) % 2);
46
+ captures = promotionColor === requirement.color ? 0 : 1;
47
+ }
32
48
  const nextMask = mask | bit;
33
49
  next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
34
50
  }
@@ -66,6 +82,69 @@ function clonedChess(chess) {
66
82
  function exactFen(chess) {
67
83
  return chess.fen({ forceEnpassantSquare: true });
68
84
  }
85
+ const FILES = "abcdefgh";
86
+ function squareAt(file, rank) {
87
+ return `${FILES[file]}${rank}`;
88
+ }
89
+ function squareCoordinates(square) {
90
+ return [square.charCodeAt(0) - 97, Number(square[1])];
91
+ }
92
+ function squaresBetween(from, to) {
93
+ const [fromFile, fromRank] = squareCoordinates(from);
94
+ const [toFile, toRank] = squareCoordinates(to);
95
+ const fileDistance = toFile - fromFile;
96
+ const rankDistance = toRank - fromRank;
97
+ if (fileDistance !== 0 &&
98
+ rankDistance !== 0 &&
99
+ Math.abs(fileDistance) !== Math.abs(rankDistance)) {
100
+ return [];
101
+ }
102
+ const fileStep = Math.sign(fileDistance);
103
+ const rankStep = Math.sign(rankDistance);
104
+ const squares = [];
105
+ let file = fromFile + fileStep;
106
+ let rank = fromRank + rankStep;
107
+ while (file !== toFile || rank !== toRank) {
108
+ squares.push(squareAt(file, rank));
109
+ file += fileStep;
110
+ rank += rankStep;
111
+ }
112
+ return squares;
113
+ }
114
+ function priorFullmove(fields, previous) {
115
+ const fullmove = fields[5] ?? "";
116
+ if (!isSafeDecimal(fullmove, 1))
117
+ return null;
118
+ const value = Number(fullmove) - (previous === "b" ? 1 : 0);
119
+ return value >= 1 ? value : null;
120
+ }
121
+ function priorChess(setup, previous, castling, enPassant, halfmove, fullmove) {
122
+ try {
123
+ return new Chess([
124
+ setup.fen().split(" ")[0],
125
+ previous,
126
+ castling,
127
+ enPassant,
128
+ String(halfmove),
129
+ String(fullmove),
130
+ ].join(" "));
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ function reachesPosition(prior, move, currentFen) {
137
+ if (!prior)
138
+ return false;
139
+ try {
140
+ assertLegalPositionInternal(prior, false);
141
+ prior.move(move);
142
+ return exactFen(prior) === currentFen;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
69
148
  function hasPiece(chess, square, type, color) {
70
149
  const piece = chess.get(square);
71
150
  return piece?.type === type && piece.color === color;
@@ -106,6 +185,35 @@ function assertEnPassantPosition(chess) {
106
185
  (turn === "w" && fields[5] === "1")) {
107
186
  throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
108
187
  }
188
+ const fullmove = fields[5] ?? "";
189
+ if (!isSafeDecimal(fullmove, 1)) {
190
+ throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
191
+ }
192
+ const setup = new Chess(fields.join(" "));
193
+ setup.remove(pawnSquare);
194
+ setup.put({ type: "p", color: pawnColor }, originSquare);
195
+ const priorFullmove = turn === "w" ? Number(fullmove) - 1 : Number(fullmove);
196
+ const priorFen = [
197
+ setup.fen().split(" ")[0],
198
+ pawnColor,
199
+ fields[2],
200
+ "-",
201
+ "0",
202
+ String(priorFullmove),
203
+ ].join(" ");
204
+ const prior = new Chess(priorFen);
205
+ assertLegalPosition(prior);
206
+ try {
207
+ prior.move({ from: originSquare, to: pawnSquare });
208
+ }
209
+ catch {
210
+ throw new ChessError("INVALID_FEN", "FEN en passant target does not follow a legal double pawn move");
211
+ }
212
+ const transitioned = exactFen(prior).split(" ");
213
+ transitioned[3] = target;
214
+ if (transitioned.join(" ") !== fields.join(" ")) {
215
+ throw new ChessError("INVALID_FEN", "FEN en passant target does not match the previous position");
216
+ }
109
217
  }
110
218
  function moveDescriptor(move) {
111
219
  const base = { from: move.from, to: move.to };
@@ -116,6 +224,111 @@ function isSafeDecimal(value, minimum) {
116
224
  Number.isSafeInteger(Number(value)) &&
117
225
  Number(value) >= minimum);
118
226
  }
227
+ const CAPTURED_PIECES = ["p", "n", "b", "r", "q"];
228
+ function ordinaryDoubleCheckPredecessor(chess, king, checkers) {
229
+ const currentFen = exactFen(chess);
230
+ const fields = currentFen.split(" ");
231
+ const previous = chess.turn() === "w" ? "b" : "w";
232
+ const active = chess.turn();
233
+ const fullmove = priorFullmove(fields, previous);
234
+ const currentHalfmove = Number(fields[4]);
235
+ if (fullmove === null || !Number.isSafeInteger(currentHalfmove))
236
+ return false;
237
+ for (let movedIndex = 0; movedIndex < 2; movedIndex += 1) {
238
+ const to = checkers[movedIndex];
239
+ const other = checkers[1 - movedIndex];
240
+ const moved = chess.get(to);
241
+ const otherType = chess.get(other)?.type;
242
+ if (!moved ||
243
+ moved.color !== previous ||
244
+ (otherType !== "b" && otherType !== "r" && otherType !== "q")) {
245
+ continue;
246
+ }
247
+ for (const from of squaresBetween(other, king)) {
248
+ if (chess.get(from) !== undefined)
249
+ continue;
250
+ for (const captured of [undefined, ...CAPTURED_PIECES]) {
251
+ if (captured === "p" &&
252
+ (to[1] === "1" || to[1] === "8")) {
253
+ continue;
254
+ }
255
+ const halfmove = moved.type === "p" || captured ? 0 : currentHalfmove - 1;
256
+ if (halfmove < 0 || (moved.type === "p" || captured) && currentHalfmove !== 0) {
257
+ continue;
258
+ }
259
+ const setup = new Chess(currentFen);
260
+ setup.remove(to);
261
+ setup.put(moved, from);
262
+ if (captured)
263
+ setup.put({ type: captured, color: active }, to);
264
+ const prior = priorChess(setup, previous, fields[2], "-", halfmove, fullmove);
265
+ if (reachesPosition(prior, { from, to }, currentFen))
266
+ return true;
267
+ const promotionRank = previous === "w" ? "8" : "1";
268
+ const pawnRank = previous === "w" ? "7" : "2";
269
+ if (moved.type !== "p" &&
270
+ moved.type !== "k" &&
271
+ to[1] === promotionRank &&
272
+ from[1] === pawnRank) {
273
+ const promotedSetup = new Chess(currentFen);
274
+ promotedSetup.remove(to);
275
+ promotedSetup.put({ type: "p", color: previous }, from);
276
+ if (captured) {
277
+ promotedSetup.put({ type: captured, color: active }, to);
278
+ }
279
+ const promotionPrior = priorChess(promotedSetup, previous, fields[2], "-", 0, fullmove);
280
+ if (currentHalfmove === 0 &&
281
+ reachesPosition(promotionPrior, { from, to, promotion: moved.type }, currentFen)) {
282
+ return true;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ return false;
289
+ }
290
+ function enPassantDoubleCheckPredecessor(chess) {
291
+ const currentFen = exactFen(chess);
292
+ const fields = currentFen.split(" ");
293
+ if (fields[3] !== "-" || fields[4] !== "0")
294
+ return false;
295
+ const previous = chess.turn() === "w" ? "b" : "w";
296
+ const active = chess.turn();
297
+ const fullmove = priorFullmove(fields, previous);
298
+ if (fullmove === null)
299
+ return false;
300
+ const destinationRank = previous === "w" ? 6 : 3;
301
+ const originRank = previous === "w" ? 5 : 4;
302
+ for (const to of chess.findPiece({ type: "p", color: previous })) {
303
+ if (Number(to[1]) !== destinationRank)
304
+ continue;
305
+ const [toFile] = squareCoordinates(to);
306
+ const capturedSquare = squareAt(toFile, originRank);
307
+ if (chess.get(capturedSquare) !== undefined)
308
+ continue;
309
+ for (const originFile of [toFile - 1, toFile + 1]) {
310
+ if (originFile < 0 || originFile > 7)
311
+ continue;
312
+ const from = squareAt(originFile, originRank);
313
+ if (chess.get(from) !== undefined)
314
+ continue;
315
+ const setup = new Chess(currentFen);
316
+ setup.remove(to);
317
+ setup.put({ type: "p", color: previous }, from);
318
+ setup.put({ type: "p", color: active }, capturedSquare);
319
+ const prior = priorChess(setup, previous, fields[2], to, 0, fullmove);
320
+ if (reachesPosition(prior, { from, to }, currentFen))
321
+ return true;
322
+ }
323
+ }
324
+ return false;
325
+ }
326
+ function hasDoubleCheckPredecessor(chess, king, checkers) {
327
+ if (exactFen(chess).split(" ")[3] !== "-")
328
+ return true;
329
+ return (ordinaryDoubleCheckPredecessor(chess, king, checkers) ||
330
+ enPassantDoubleCheckPredecessor(chess));
331
+ }
119
332
  export function assertSafeFenCounters(fen) {
120
333
  const fields = fen.split(/\s+/);
121
334
  if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
@@ -126,6 +339,9 @@ export function assertSafeFenCounters(fen) {
126
339
  }
127
340
  }
128
341
  export function assertLegalPosition(chess) {
342
+ assertLegalPositionInternal(chess, true);
343
+ }
344
+ function assertLegalPositionInternal(chess, validateDoubleCheck) {
129
345
  for (const color of ["w", "b"]) {
130
346
  if (chess.findPiece({ type: "k", color }).length !== 1) {
131
347
  throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
@@ -140,19 +356,18 @@ export function assertLegalPosition(chess) {
140
356
  const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
141
357
  Math.max(0, chess.findPiece({ type: type, color }).length -
142
358
  original), 0);
143
- const promotedBishops = [0, 1].reduce((total, squareColorValue) => total +
144
- Math.max(0, chess
145
- .findPiece({ type: "b", color })
146
- .filter((square) => squareColor(square) === squareColorValue)
147
- .length - 1), 0);
148
- const promoted = promotedPieces + promotedBishops;
359
+ const promotedBishops = [0, 1].map((squareColorValue) => Math.max(0, chess
360
+ .findPiece({ type: "b", color })
361
+ .filter((square) => squareColor(square) === squareColorValue)
362
+ .length - 1));
363
+ const promoted = promotedPieces + promotedBishops[0] + promotedBishops[1];
149
364
  if (promoted > 8 - pawns.length) {
150
365
  throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
151
366
  }
152
367
  assertCastlingPosition(chess, color);
153
368
  const opponent = color === "w" ? "b" : "w";
154
369
  const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
155
- if (minimumPawnCaptures(chess, color) > missingOpponentMaterial) {
370
+ if (minimumPawnCaptures(chess, color, promotedPieces, promotedBishops) > missingOpponentMaterial) {
156
371
  throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
157
372
  }
158
373
  }
@@ -173,6 +388,11 @@ export function assertLegalPosition(chess) {
173
388
  if (checkers.length > 2 || leapers.length > 1) {
174
389
  throw new ChessError("INVALID_FEN", "FEN contains an impossible check topology");
175
390
  }
391
+ if (validateDoubleCheck &&
392
+ checkers.length === 2 &&
393
+ !hasDoubleCheckPredecessor(chess, king, checkers)) {
394
+ throw new ChessError("INVALID_FEN", "FEN double check has no legal previous move");
395
+ }
176
396
  }
177
397
  }
178
398
  function expectedInitialFen(headers) {
package/dist/domain.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare const MOVE_EVALUATION_RESULTS: readonly ["ongoing", "checkmate",
13
13
  export type MoveEvaluationResult = (typeof MOVE_EVALUATION_RESULTS)[number];
14
14
  export declare const ANALYSIS_LEVELS: readonly ["fast", "normal", "deep"];
15
15
  export type AnalysisLevel = (typeof ANALYSIS_LEVELS)[number];
16
+ export declare const MAX_HUMAN_MOVES = 20;
17
+ export declare const HUMAN_PROBABILITY_TOLERANCE = 0.00001;
16
18
  export declare const MOVE_CLASSIFICATIONS: readonly ["best", "excellent", "good", "inaccuracy", "mistake", "blunder"];
17
19
  export type MoveClassification = (typeof MOVE_CLASSIFICATIONS)[number];
18
20
  export declare const INTENTS: readonly ["best", "strong", "natural", "balanced", "ease_off", "give_chance"];
@@ -49,13 +51,21 @@ export interface ChessState {
49
51
  };
50
52
  }
51
53
  export type Wdl = [number, number, number];
52
- export interface SfLine {
54
+ type SfScore = {
55
+ scoreCp: number;
56
+ scoreMate: null;
57
+ } | {
58
+ scoreCp: null;
59
+ scoreMate: number;
60
+ } | {
61
+ scoreCp: null;
62
+ scoreMate: null;
63
+ };
64
+ export type SfLine = {
53
65
  multipv: number;
54
- scoreCp: number | null;
55
- scoreMate: number | null;
56
66
  wdl: Wdl | null;
57
67
  pv: string[];
58
- }
68
+ } & SfScore;
59
69
  export interface Maia3Move {
60
70
  uci: string;
61
71
  san: string;
package/dist/domain.js CHANGED
@@ -25,6 +25,8 @@ export const MOVE_EVALUATION_RESULTS = [
25
25
  ...DRAW_RESULTS,
26
26
  ];
27
27
  export const ANALYSIS_LEVELS = ["fast", "normal", "deep"];
28
+ export const MAX_HUMAN_MOVES = 20;
29
+ export const HUMAN_PROBABILITY_TOLERANCE = 1e-5;
28
30
  export const MOVE_CLASSIFICATIONS = [
29
31
  "best",
30
32
  "excellent",
@@ -58,10 +58,16 @@ export function parseAnalysisInfo(line) {
58
58
  };
59
59
  }
60
60
  export function mergeAnalysisInfo(previous, info) {
61
+ const scoreCp = info.score ? info.score.cp : (previous?.scoreCp ?? null);
62
+ const scoreMate = info.score ? info.score.mate : (previous?.scoreMate ?? null);
63
+ const scoreFields = scoreCp !== null
64
+ ? { scoreCp, scoreMate: null }
65
+ : scoreMate !== null
66
+ ? { scoreCp: null, scoreMate }
67
+ : { scoreCp: null, scoreMate: null };
61
68
  return {
62
69
  multipv: info.multipv,
63
- scoreCp: info.score ? info.score.cp : (previous?.scoreCp ?? null),
64
- scoreMate: info.score ? info.score.mate : (previous?.scoreMate ?? null),
70
+ ...scoreFields,
65
71
  wdl: info.wdl ?? previous?.wdl ?? null,
66
72
  pv: info.pv ?? previous?.pv ?? [],
67
73
  };
@@ -33,12 +33,15 @@ export declare class Stockfish {
33
33
  private teardownPending;
34
34
  private readonly drainWaiters;
35
35
  private readonly terminations;
36
+ private readonly processListenerCleanups;
36
37
  private readonly initEngine;
37
38
  private readonly configuredFlavor;
38
39
  private readonly maxQueue;
39
40
  private readonly timeouts;
40
41
  constructor(options?: StockfishOptions);
41
42
  private disposeInitEngine;
43
+ private claimProcessListenerCleanups;
44
+ private moveProcessListenerCleanups;
42
45
  private adoptInitEngine;
43
46
  private completeInit;
44
47
  private init;