@chessceo/mcp 0.36.6 → 0.40.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 +673 -117
- package/dist/pgn/describe.js +268 -2
- package/dist/pgn/mutations.js +22 -0
- package/dist/pgn/paths.js +32 -0
- package/docs/engine-usage.md +40 -0
- package/docs/pgn-authoring.md +26 -8
- package/docs/prep-files-guide.md +4 -0
- package/docs/prep-strategy.md +27 -0
- package/package.json +1 -1
- package/tools/fenfind/fenfind.py +30 -2
- package/tools/fenfind/readpgn +11 -0
- package/tools/fenfind/readpgn.py +199 -0
- package/tools/sf_eval/sf_eval +4 -0
- package/tools/sf_eval/sf_eval.py +136 -0
package/dist/pgn/describe.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// cloud_analyse.
|
|
11
11
|
import { parseFen } from "chessops/fen";
|
|
12
12
|
import { Chess } from "chessops/chess";
|
|
13
|
-
import { attacks } from "chessops/attacks";
|
|
14
|
-
import { makeSquare, squareRank } from "chessops/util";
|
|
13
|
+
import { attacks, pawnAttacks } from "chessops/attacks";
|
|
14
|
+
import { makeSquare, squareRank, squareFile } from "chessops/util";
|
|
15
15
|
import { makeSan } from "chessops/san";
|
|
16
|
+
import { SquareSet } from "chessops/squareSet";
|
|
16
17
|
const ROLE_LETTER = {
|
|
17
18
|
pawn: "", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
|
|
18
19
|
};
|
|
@@ -99,6 +100,7 @@ export function describePosition(fen) {
|
|
|
99
100
|
for (const sq of ctx.checkers)
|
|
100
101
|
checkers.push(pieceSANFromBoard(board, sq));
|
|
101
102
|
const legalMoves = generateLegalMoves(pos);
|
|
103
|
+
const structural = computeStructural(pos);
|
|
102
104
|
return {
|
|
103
105
|
fen,
|
|
104
106
|
sideToMove: pos.turn,
|
|
@@ -113,11 +115,275 @@ export function describePosition(fen) {
|
|
|
113
115
|
pieces: { white: whitePieces, black: blackPieces },
|
|
114
116
|
contested,
|
|
115
117
|
hanging: { white: hangingWhite, black: hangingBlack },
|
|
118
|
+
pawnStructure: structural.pawnStructure,
|
|
119
|
+
weakSquares: structural.weakSquares,
|
|
120
|
+
outposts: structural.outposts,
|
|
121
|
+
bishops: structural.bishops,
|
|
122
|
+
space: structural.space,
|
|
116
123
|
castling: { white: castlingW, black: castlingB },
|
|
117
124
|
epSquare: typeof pos.epSquare === "number" ? makeSquare(pos.epSquare) : null,
|
|
118
125
|
legalMoves,
|
|
119
126
|
};
|
|
120
127
|
}
|
|
128
|
+
// ── Structural analysis ──────────────────────────────────────────────
|
|
129
|
+
// Concepts a human sees at a glance but the LLM cannot derive from a
|
|
130
|
+
// FEN. All chess-primitive computations on top of chessops bitboards;
|
|
131
|
+
// no engine.
|
|
132
|
+
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
|
133
|
+
// Which files are occupied by pawns of each colour (bitmask, 8 bits).
|
|
134
|
+
function pawnFilesMask(pos, color) {
|
|
135
|
+
let mask = 0;
|
|
136
|
+
for (const sq of pos.board.pawn.intersect(pos.board[color])) {
|
|
137
|
+
mask |= 1 << squareFile(sq);
|
|
138
|
+
}
|
|
139
|
+
return mask;
|
|
140
|
+
}
|
|
141
|
+
function computeStructural(pos) {
|
|
142
|
+
const whitePawns = pos.board.pawn.intersect(pos.board.white);
|
|
143
|
+
const blackPawns = pos.board.pawn.intersect(pos.board.black);
|
|
144
|
+
const wFileMask = pawnFilesMask(pos, "white");
|
|
145
|
+
const bFileMask = pawnFilesMask(pos, "black");
|
|
146
|
+
// File status per column.
|
|
147
|
+
const files = {};
|
|
148
|
+
for (let f = 0; f < 8; f++) {
|
|
149
|
+
const wHas = (wFileMask & (1 << f)) !== 0;
|
|
150
|
+
const bHas = (bFileMask & (1 << f)) !== 0;
|
|
151
|
+
if (!wHas && !bHas)
|
|
152
|
+
files[FILES[f]] = "open";
|
|
153
|
+
else if (!bHas && wHas)
|
|
154
|
+
files[FILES[f]] = "half_open_for_black"; // black has no pawn → open for black's rooks
|
|
155
|
+
else if (!wHas && bHas)
|
|
156
|
+
files[FILES[f]] = "half_open_for_white";
|
|
157
|
+
else
|
|
158
|
+
files[FILES[f]] = "closed";
|
|
159
|
+
}
|
|
160
|
+
// Islands: count runs of consecutive occupied files.
|
|
161
|
+
const countIslands = (mask) => {
|
|
162
|
+
let count = 0, prev = false;
|
|
163
|
+
for (let f = 0; f < 8; f++) {
|
|
164
|
+
const has = (mask & (1 << f)) !== 0;
|
|
165
|
+
if (has && !prev)
|
|
166
|
+
count++;
|
|
167
|
+
prev = has;
|
|
168
|
+
}
|
|
169
|
+
return count;
|
|
170
|
+
};
|
|
171
|
+
// Isolated pawns: own pawn with no friend on adjacent files.
|
|
172
|
+
const isolated = (color) => {
|
|
173
|
+
const mask = color === "white" ? wFileMask : bFileMask;
|
|
174
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const sq of own) {
|
|
177
|
+
const f = squareFile(sq);
|
|
178
|
+
const left = f > 0 && ((mask >> (f - 1)) & 1);
|
|
179
|
+
const right = f < 7 && ((mask >> (f + 1)) & 1);
|
|
180
|
+
if (!left && !right)
|
|
181
|
+
out.push(makeSquare(sq));
|
|
182
|
+
}
|
|
183
|
+
return out.sort();
|
|
184
|
+
};
|
|
185
|
+
// Doubled: files with >1 own pawn.
|
|
186
|
+
const doubled = (color) => {
|
|
187
|
+
const counts = {};
|
|
188
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
189
|
+
for (const sq of own) {
|
|
190
|
+
const f = squareFile(sq);
|
|
191
|
+
counts[f] = (counts[f] || 0) + 1;
|
|
192
|
+
}
|
|
193
|
+
return Object.entries(counts).filter(([, c]) => c > 1).map(([f]) => FILES[Number(f)]).sort();
|
|
194
|
+
};
|
|
195
|
+
// Passed pawns: no enemy pawn on same or adjacent file ahead of it.
|
|
196
|
+
const passed = (color) => {
|
|
197
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
198
|
+
const enemyMask = color === "white" ? bFileMask : wFileMask;
|
|
199
|
+
const enemyPawns = color === "white" ? blackPawns : whitePawns;
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const sq of own) {
|
|
202
|
+
const f = squareFile(sq);
|
|
203
|
+
const r = squareRank(sq);
|
|
204
|
+
let blocked = false;
|
|
205
|
+
for (const ef of [f - 1, f, f + 1]) {
|
|
206
|
+
if (ef < 0 || ef > 7)
|
|
207
|
+
continue;
|
|
208
|
+
if (((enemyMask >> ef) & 1) === 0)
|
|
209
|
+
continue;
|
|
210
|
+
// any enemy pawn on this file ahead of us?
|
|
211
|
+
for (const esq of enemyPawns) {
|
|
212
|
+
if (squareFile(esq) !== ef)
|
|
213
|
+
continue;
|
|
214
|
+
const er = squareRank(esq);
|
|
215
|
+
if (color === "white" ? er > r : er < r) {
|
|
216
|
+
blocked = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (blocked)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (!blocked)
|
|
224
|
+
out.push(makeSquare(sq));
|
|
225
|
+
}
|
|
226
|
+
return out.sort();
|
|
227
|
+
};
|
|
228
|
+
// Backward: own pawn attacked by enemy pawn, unable to safely advance
|
|
229
|
+
// (advance square attacked by enemy pawn and undefended by own pawn).
|
|
230
|
+
const backward = (color) => {
|
|
231
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
232
|
+
const dir = color === "white" ? 8 : -8;
|
|
233
|
+
const enemyColor = color === "white" ? "black" : "white";
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const sq of own) {
|
|
236
|
+
const advanceSq = (sq + dir);
|
|
237
|
+
if (advanceSq < 0 || advanceSq > 63)
|
|
238
|
+
continue;
|
|
239
|
+
// Attacked by enemy pawn?
|
|
240
|
+
const enemyAttackers = pawnAttacks(enemyColor, advanceSq).intersect(pos.board.pawn).intersect(pos.board[enemyColor]);
|
|
241
|
+
if (enemyAttackers.isEmpty())
|
|
242
|
+
continue;
|
|
243
|
+
// Defended by own pawn?
|
|
244
|
+
const ownDefenders = pawnAttacks(color, advanceSq).intersect(pos.board.pawn).intersect(pos.board[color]);
|
|
245
|
+
if (ownDefenders.nonEmpty())
|
|
246
|
+
continue;
|
|
247
|
+
out.push(makeSquare(sq));
|
|
248
|
+
}
|
|
249
|
+
return out.sort();
|
|
250
|
+
};
|
|
251
|
+
// Weak squares (holes): squares in ranks 3-6 that no friendly pawn can
|
|
252
|
+
// ever attack. For White the "own attack" is a pawn on rank-1 diagonal
|
|
253
|
+
// behind (files ±1). We check every square in the middle four ranks.
|
|
254
|
+
const weakSquares = (color) => {
|
|
255
|
+
const own = color === "white" ? whitePawns : blackPawns;
|
|
256
|
+
const ownMask = color === "white" ? wFileMask : bFileMask;
|
|
257
|
+
const out = [];
|
|
258
|
+
const rankRange = color === "white" ? [3, 4, 5] : [2, 3, 4]; // 0-indexed 4,5,6 / 3,4,5 in real ranks
|
|
259
|
+
for (const r of rankRange) {
|
|
260
|
+
for (let f = 0; f < 8; f++) {
|
|
261
|
+
const sq = (r * 8 + f);
|
|
262
|
+
// Any friendly pawn on adjacent files that could ever attack this square?
|
|
263
|
+
// For White, an attack on square (f,r) comes from (f±1, r-1);
|
|
264
|
+
// any pawn on those adjacent files with rank < r could eventually reach.
|
|
265
|
+
let canBeAttacked = false;
|
|
266
|
+
for (const df of [-1, 1]) {
|
|
267
|
+
const nf = f + df;
|
|
268
|
+
if (nf < 0 || nf > 7)
|
|
269
|
+
continue;
|
|
270
|
+
if (((ownMask >> nf) & 1) === 0)
|
|
271
|
+
continue;
|
|
272
|
+
// Any pawn on this file at a rank behind the target?
|
|
273
|
+
for (const psq of own) {
|
|
274
|
+
if (squareFile(psq) !== nf)
|
|
275
|
+
continue;
|
|
276
|
+
const pr = squareRank(psq);
|
|
277
|
+
if (color === "white" ? pr < r : pr > r) {
|
|
278
|
+
canBeAttacked = true;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (canBeAttacked)
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (!canBeAttacked)
|
|
286
|
+
out.push(makeSquare(sq));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out.sort();
|
|
290
|
+
};
|
|
291
|
+
const wWeak = weakSquares("white");
|
|
292
|
+
const bWeak = weakSquares("black");
|
|
293
|
+
const wWeakSet = new Set(wWeak);
|
|
294
|
+
const bWeakSet = new Set(bWeak);
|
|
295
|
+
// Outposts: friendly N/B on a hole (WEAKNESS of the enemy) in enemy
|
|
296
|
+
// territory, defended by an own pawn.
|
|
297
|
+
const outposts = (color) => {
|
|
298
|
+
const enemyWeak = color === "white" ? bWeakSet : wWeakSet;
|
|
299
|
+
const own = pos.board[color];
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const sq of own) {
|
|
302
|
+
const piece = pos.board.get(sq);
|
|
303
|
+
if (!piece || (piece.role !== "knight" && piece.role !== "bishop"))
|
|
304
|
+
continue;
|
|
305
|
+
const sqName = makeSquare(sq);
|
|
306
|
+
if (!enemyWeak.has(sqName))
|
|
307
|
+
continue;
|
|
308
|
+
// Defended by own pawn?
|
|
309
|
+
const enemyColor = color === "white" ? "black" : "white";
|
|
310
|
+
// A pawn defending sq for `color` = pawn one rank behind on adjacent file.
|
|
311
|
+
const defenders = pawnAttacks(enemyColor, sq).intersect(pos.board.pawn).intersect(pos.board[color]);
|
|
312
|
+
if (defenders.isEmpty())
|
|
313
|
+
continue;
|
|
314
|
+
out.push({ piece: ROLE_LETTER_INV[piece.role], square: sqName });
|
|
315
|
+
}
|
|
316
|
+
return out.sort((a, b) => a.square.localeCompare(b.square));
|
|
317
|
+
};
|
|
318
|
+
// Bishops: quality based on own pawns on the bishop's colour.
|
|
319
|
+
const bishopColor = (sq) => {
|
|
320
|
+
// a1 is dark, b1 is light. (file+rank) even = dark.
|
|
321
|
+
return (squareFile(sq) + squareRank(sq)) % 2 === 0 ? "dark" : "light";
|
|
322
|
+
};
|
|
323
|
+
const bishops = (color) => {
|
|
324
|
+
const own = pos.board.bishop.intersect(pos.board[color]);
|
|
325
|
+
const ownPawns = color === "white" ? whitePawns : blackPawns;
|
|
326
|
+
let pawnsLight = 0, pawnsDark = 0;
|
|
327
|
+
for (const p of ownPawns) {
|
|
328
|
+
if (bishopColor(p) === "light")
|
|
329
|
+
pawnsLight++;
|
|
330
|
+
else
|
|
331
|
+
pawnsDark++;
|
|
332
|
+
}
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const sq of own) {
|
|
335
|
+
const c = bishopColor(sq);
|
|
336
|
+
const onColor = c === "light" ? pawnsLight : pawnsDark;
|
|
337
|
+
const q = onColor <= 3 ? "good" : onColor >= 5 ? "bad" : "mixed";
|
|
338
|
+
out.push({ square: makeSquare(sq), colorSquare: c, ownPawnsOnColor: onColor, quality: q });
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
};
|
|
342
|
+
// Space: squares in the enemy half controlled by side's pieces + pawns.
|
|
343
|
+
const space = (color) => {
|
|
344
|
+
const attackedByColor = SquareSet.empty();
|
|
345
|
+
let acc = attackedByColor;
|
|
346
|
+
for (const sq of pos.board[color]) {
|
|
347
|
+
const piece = pos.board.get(sq);
|
|
348
|
+
acc = acc.union(attacks(piece, sq, pos.board.occupied));
|
|
349
|
+
}
|
|
350
|
+
// Enemy half: white attacks ranks 5-8 (rank index 4-7); black attacks 1-4 (0-3).
|
|
351
|
+
let count = 0;
|
|
352
|
+
for (const sq of acc) {
|
|
353
|
+
const r = squareRank(sq);
|
|
354
|
+
if (color === "white" && r >= 4)
|
|
355
|
+
count++;
|
|
356
|
+
else if (color === "black" && r <= 3)
|
|
357
|
+
count++;
|
|
358
|
+
}
|
|
359
|
+
return count;
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
pawnStructure: {
|
|
363
|
+
files,
|
|
364
|
+
islandsWhite: countIslands(wFileMask),
|
|
365
|
+
islandsBlack: countIslands(bFileMask),
|
|
366
|
+
isolated: { white: isolated("white"), black: isolated("black") },
|
|
367
|
+
doubled: { white: doubled("white"), black: doubled("black") },
|
|
368
|
+
passed: { white: passed("white"), black: passed("black") },
|
|
369
|
+
backward: { white: backward("white"), black: backward("black") },
|
|
370
|
+
},
|
|
371
|
+
weakSquares: { white: wWeak, black: bWeak },
|
|
372
|
+
outposts: { white: outposts("white"), black: outposts("black") },
|
|
373
|
+
bishops: {
|
|
374
|
+
white: bishops("white"),
|
|
375
|
+
black: bishops("black"),
|
|
376
|
+
pair: {
|
|
377
|
+
white: pos.board.bishop.intersect(pos.board.white).size() >= 2,
|
|
378
|
+
black: pos.board.bishop.intersect(pos.board.black).size() >= 2,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
space: {
|
|
382
|
+
whiteSquaresInBlackHalf: space("white"),
|
|
383
|
+
blackSquaresInWhiteHalf: space("black"),
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
121
387
|
// All legal moves for the side to move, in SAN. Sorted for stable output.
|
|
122
388
|
// Handles promotion by expanding each pawn-promotion destination into
|
|
123
389
|
// the four promotion pieces (=Q, =R, =B, =N).
|
package/dist/pgn/mutations.js
CHANGED
|
@@ -172,6 +172,28 @@ export function setCeoEval(file, path, ev) {
|
|
|
172
172
|
target.ceoEval = ev;
|
|
173
173
|
return { file: { tags: file.tags, root: newRoot }, id: target.id };
|
|
174
174
|
}
|
|
175
|
+
// Set the same ceoEval on every path in `paths`. One clone-and-return
|
|
176
|
+
// rather than N sequential setCeoEval calls. Used by cloud_analyse to
|
|
177
|
+
// propagate a single measurement to every transposition of the position
|
|
178
|
+
// in the file — the LLM shouldn't have to re-analyse a position it
|
|
179
|
+
// already measured under a different move order.
|
|
180
|
+
export function setCeoEvalMany(file, paths, ev) {
|
|
181
|
+
if (paths.length === 0)
|
|
182
|
+
return { file, ids: [] };
|
|
183
|
+
// Sort deepest-first so cloning one target doesn't invalidate later
|
|
184
|
+
// ones' path references — cloneOnPath re-parents everything along
|
|
185
|
+
// the path, so mutating a shallower path after a deeper one is safe;
|
|
186
|
+
// sorting is defensive.
|
|
187
|
+
const sorted = [...paths].sort((a, b) => b.length - a.length);
|
|
188
|
+
let cur = file;
|
|
189
|
+
const ids = [];
|
|
190
|
+
for (const p of sorted) {
|
|
191
|
+
const step = setCeoEval(cur, p, ev);
|
|
192
|
+
cur = step.file;
|
|
193
|
+
ids.push(step.id);
|
|
194
|
+
}
|
|
195
|
+
return { file: cur, ids };
|
|
196
|
+
}
|
|
175
197
|
// Set or clear a tag. Passing null / empty removes.
|
|
176
198
|
export function setTag(file, key, value) {
|
|
177
199
|
const cleanedKey = key.trim();
|
package/dist/pgn/paths.js
CHANGED
|
@@ -101,6 +101,38 @@ export function getParent(root, path) {
|
|
|
101
101
|
const index = path[path.length - 1];
|
|
102
102
|
return { parent, index };
|
|
103
103
|
}
|
|
104
|
+
// Transposition key: the part of the FEN that decides whether two
|
|
105
|
+
// positions are the same for opening/preparation purposes. Matches the
|
|
106
|
+
// frontend's rule (frontend/src/game/gamestate/services/TreeService.ts
|
|
107
|
+
// fenPositionMatch): piece placement + side to move + castling rights.
|
|
108
|
+
// En-passant square, halfmove clock and fullmove number are excluded —
|
|
109
|
+
// they diverge across move orders that reach the same position, and
|
|
110
|
+
// treating them as significant would defeat the whole point of
|
|
111
|
+
// transposition detection.
|
|
112
|
+
export function positionKey(fen) {
|
|
113
|
+
return fen.split(" ").slice(0, 3).join(" ");
|
|
114
|
+
}
|
|
115
|
+
// Group every node in the tree by transposition key. Returned in DFS
|
|
116
|
+
// order — the FIRST node in each list is the earliest (mainline-preferred)
|
|
117
|
+
// occurrence, which is what the LLM should treat as the canonical anchor
|
|
118
|
+
// for prose ("this transposes to line X"). Groups with only one member
|
|
119
|
+
// are still included, so callers can check membership cheaply; filter
|
|
120
|
+
// for size ≥ 2 to get actual transpositions.
|
|
121
|
+
export function buildFenIndex(root) {
|
|
122
|
+
const index = new Map();
|
|
123
|
+
const walk = (node) => {
|
|
124
|
+
const key = positionKey(node.fen);
|
|
125
|
+
const arr = index.get(key);
|
|
126
|
+
if (arr)
|
|
127
|
+
arr.push(node);
|
|
128
|
+
else
|
|
129
|
+
index.set(key, [node]);
|
|
130
|
+
for (const c of node.children)
|
|
131
|
+
walk(c);
|
|
132
|
+
};
|
|
133
|
+
walk(root);
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
104
136
|
// Deep-clone nodes on the path from root to the mutation target,
|
|
105
137
|
// leaving unrelated subtrees shared. Downstream code treats siblings
|
|
106
138
|
// as immutable so this sharing is safe.
|
package/docs/engine-usage.md
CHANGED
|
@@ -16,6 +16,8 @@ When you call `cloud_analyse`, chess.ceo runs Stockfish and Lc0 in parallel on t
|
|
|
16
16
|
|
|
17
17
|
Concrete failure this rule blocks: the LLM says *"9...Bb7: both engines 0.00"* after only calling `cloud_analyse` on the child positions (post-1.d4, post-castling). Both continuations really returned 0.00, but the Bb7 node was never analysed, and the claim reads to the user as a measurement. With this protocol, `quote_engine_eval(node_id=Bb7)` would return null and the LLM would either analyse it or reword to *"both continuations run to 0.00, so this position looks balanced"* (soft inference, honestly labelled).
|
|
18
18
|
|
|
19
|
+
**Transposition propagation.** `cloud_analyse({file_id, node_id})` also stamps the resulting `ceoEval` on every OTHER node in the same file that reaches the same position by a different move order (3-field FEN match: pieces + side-to-move + castling). The response includes `also_stored_on: [id, id]` when this happens, and a follow-up `quote_engine_eval` on any of those twin nodes returns the same measurement — no second analysis needed. `auto_evaluate` also dedupes candidates by the same key and returns `skipped_transpositions` so you can see how much engine time it saved. See `list_transpositions` / `list_nodes({filter: "transpositions"})` for auditing where duplication exists before you start.
|
|
20
|
+
|
|
19
21
|
**Concrete failure modes to avoid:**
|
|
20
22
|
|
|
21
23
|
- Inventing an evaluation. If you say "this is +0.4 for White", that number must come from an engine call. Not a guess, not a vibe.
|
|
@@ -66,6 +68,27 @@ Trust Lc0 for:
|
|
|
66
68
|
- Lc0 higher: probably a long-term positional factor beyond Stockfish's horizon
|
|
67
69
|
- Never dismiss either engine — the disagreement itself is the signal
|
|
68
70
|
|
|
71
|
+
## 0.00 is not "dead draw"
|
|
72
|
+
|
|
73
|
+
The single most common misread of an engine number. **0.00 means "objectively balanced with optimal play from both sides".** It says nothing about how often the position actually draws over the board — a sharp middlegame with imbalances and only-moves can sit at 0.00 for 40 plies and be a lively fighting game.
|
|
74
|
+
|
|
75
|
+
An eval quantifies WHO STANDS BETTER, not HOW LIKELY A DRAW IS. The two questions are independent.
|
|
76
|
+
|
|
77
|
+
- **A wrong reasoning pattern** (documented failure): the LLM sees `SF 0.00, depth 58, 6.6B nodes` on a Bg5 sideline, concludes "actually a dead draw", and marks the line `?!` as a dead end. Wrong. All that eval says is neither side is objectively better with optimal play — the practical drawing chance is a separate question the SF number doesn't answer.
|
|
78
|
+
|
|
79
|
+
Signals that actually correlate with high drawing chance:
|
|
80
|
+
|
|
81
|
+
- **Forced repetition visible in the PV** (`Nf3 Nf6 Ng1 Ng8 …`) — that's a draw, not the eval.
|
|
82
|
+
- **Reduced material + 0.00** — opposite-coloured bishops, queen + rook endgames with balanced pawns, king-and-pawn structures with no breakthrough. The material context makes the draw likely, not the eval.
|
|
83
|
+
- **Symmetrical structures with no imbalances and no plan for either side** — engine says 0.00, humans agree by move 25.
|
|
84
|
+
|
|
85
|
+
If you actually want to estimate PRACTICAL drawing chance, ask the tools that answer that question:
|
|
86
|
+
|
|
87
|
+
- **`predict_human_move.wdlWhitePov`** — game-outcome prediction from the neural net, rating-conditioned. The `draw` component IS the answer to "how often does this position draw at this level".
|
|
88
|
+
- **Lc0's practical eval** — often diverges from SF at 0.00 to say "still slightly better for White in a real game". That divergence is where the practical winning chance hides.
|
|
89
|
+
|
|
90
|
+
**Never conflate the objective eval with the practical outcome.** A 0.00 middlegame with a piece imbalance, opposite-side attacks, or a rating gap is a fighting game; the number just told you nobody has a forced win.
|
|
91
|
+
|
|
69
92
|
## Lc0 contempt
|
|
70
93
|
|
|
71
94
|
Contempt is an Lc0 option that skews its evaluation and move choice toward one side. Passing `contempt` to `cloud_analyse` sets it on the Lc0 leg only; Stockfish always analyzes objectively.
|
|
@@ -103,6 +126,23 @@ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend d
|
|
|
103
126
|
|
|
104
127
|
**Gotcha:** flipping side-to-move invalidates the en passant target field (if the last move was a two-square pawn push, the ep square is now stale). Also, both sides are counted as still having whichever castling rights are in the FEN — don't do this in the middle of a castling sequence. For opening / middlegame threat-checking it's a very useful idiom.
|
|
105
128
|
|
|
129
|
+
## Why the position is what it is: `describe_position.engineEvalTerms`
|
|
130
|
+
|
|
131
|
+
`describe_position` returns Stockfish's classical eval decomposed into 13 named terms (Material, Imbalance, Pawns, Knights, Bishops, Rooks, Queens, Mobility, King safety, Threats, Passed, Space, Winnable) alongside the chess-primitive observations. Each term carries white / black / total values in middlegame + endgame. This is how you answer "WHY is Black better here" beyond pointing at `-0.35`.
|
|
132
|
+
|
|
133
|
+
Primary pattern — **the delta**. Call `describe_position` on the position BEFORE and AFTER a candidate move, compare `engineEvalTerms`; the term with the biggest shift tells you what the move CHANGED. Kim et al. NAACL 2025 showed feeding these named deltas to an LLM roughly doubles chess-commentary correctness vs a bare eval number. Example:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
before 12.Nd5: king_safety = +0.10, mobility = -0.05, threats = 0.00
|
|
137
|
+
after 12.Nd5: king_safety = +0.40, mobility = +0.30, threats = +0.55
|
|
138
|
+
→ the delta is dominated by threats + king safety
|
|
139
|
+
→ commentary: "12.Nd5 lands an outpost that both eyes the kingside (threats) and improves piece coordination (mobility)"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Absolute values are useful on their own too — a large "King safety" term flags an exposed king regardless of what caused it.
|
|
143
|
+
|
|
144
|
+
Complements `cloud_analyse` (best move + PV): different questions, two angles on the same position.
|
|
145
|
+
|
|
106
146
|
## Deep Stockfish thinks: `deep_analyse`
|
|
107
147
|
|
|
108
148
|
`cloud_analyse` runs both engines with a movetime cap of 10 s — deliberately fast because most opening-tree questions are answered in 2–3 s. For **one specific critical position** where you want depth 35+ instead of the usual depth 22, use `deep_analyse`:
|
package/docs/pgn-authoring.md
CHANGED
|
@@ -40,7 +40,8 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
|
|
|
40
40
|
|
|
41
41
|
## Typical build order
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
0. **`read_example_prep_files`** — do this once per session, before writing any prose. Not optional. Log analysis shows most sessions skip this and produce documented anti-patterns (long PVs in prose, restating what the app renders, verbose citations). Reading the two bundled reference files once inoculates the LLM against those.
|
|
44
|
+
1. `read_prep_file` — see what's there. Every node has an `id` you'll pass to the mutation and engine/DB tools. Use `view: "compact"` (default) plus `node_id` + `max_depth` to scope; the full tree of a 500+-node file can blow the token limit.
|
|
44
45
|
2. `apply_mutations([...])` — one call with your whole intended build (a mix of `add_move` / `add_line` for structure, plus any `set_comment`/`set_annotations` you already know at author time, plus any `set_nags` where you already have a clear judgment — novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
|
|
45
46
|
3. `auto_evaluate(id)` — spawns a background job that PERSISTS engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference. Grab the returned `job_id` and either (a) poll `auto_evaluate_status(job_id)` every ~10-30s until done, or (b) fire and do useful work meanwhile (write more of the tree, walk the opponent's repertoire) and check back later — engine walk-time serialises on the per-combo semaphore, so it takes roughly `target_count × movetime_ms` in wall time.
|
|
46
47
|
4. Once the job reports `done:true`, re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
|
|
@@ -163,6 +164,16 @@ Prose is NEVER for:
|
|
|
163
164
|
|
|
164
165
|
Test: if the reader can see it by looking at the position or clicking a branch, don't write it. Prose is only for plans, prep-signal, or WHY — the layer the app can't derive.
|
|
165
166
|
|
|
167
|
+
## Transpositions: don't analyse (or comment on) the same position twice
|
|
168
|
+
|
|
169
|
+
Move orders diverge and re-converge constantly. `1.d4 Nf6 2.c4 e6 3.Nc3` and `1.c4 e6 2.Nc3 Nf6 3.d4` land on the same position. The tree model doesn't merge those into one node — it stores both nodes with the same position — so if you're not careful you'll analyse both, quote engine numbers on both, and write two different comments for what is the same chess.
|
|
170
|
+
|
|
171
|
+
**Detection.** `read_prep_file` shows `transposes_to: [id, id]` on every node whose position appears elsewhere in the file, and the header carries `transposition_groups` / `transposition_nodes` counts. `list_nodes({filter: "transpositions"})` lists just the affected nodes. `list_transpositions` groups every duplicated position with its member nodes. Match key is the frontend's rule: piece placement + side to move + castling rights (en-passant + clocks intentionally ignored).
|
|
172
|
+
|
|
173
|
+
**Auto-propagation.** When `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file — see `also_stored_on: [id, id]` in the response. This means `auto_evaluate({only_missing: true})` naturally skips the twin. `auto_evaluate` also dedupes candidates before it starts and returns `skipped_transpositions` in the initial response so you can see how much engine time was saved.
|
|
174
|
+
|
|
175
|
+
**Prose.** For commentary, pick a canonical node (usually the mainline-order occurrence — first in `transposes_to`) and write the plans / prep-signal / novelty there. On the twin, either point at it (`{transposes to 3.Nc3 in the mainline — see the note there}`) or leave the comment empty. Don't paste the same three sentences on both — they'll drift as you edit one and forget the other.
|
|
176
|
+
|
|
166
177
|
## Move-judgment symbols (NAGs)
|
|
167
178
|
|
|
168
179
|
NAGs are the compact way to attach an evaluation to a move. Pass as `$N` strings to `set_nags`.
|
|
@@ -240,15 +251,22 @@ Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). T
|
|
|
240
251
|
|
|
241
252
|
### Before you write any commentary: describe_position
|
|
242
253
|
|
|
243
|
-
LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position(fen)`.
|
|
254
|
+
LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position(fen)`. Pure computation (~1 ms, no engine), returns two layers:
|
|
255
|
+
|
|
256
|
+
**Board state** — piece placements, material, contested pieces (attackers + defenders), hanging list, check state, castling, en passant, legal moves. Fixes the *"Black's queen on c7 is defended by the knight on d5"* failure when actually there's no knight on d5 and the queen is on c8.
|
|
257
|
+
|
|
258
|
+
**Structural analysis** — the concepts a strong player reads at a glance but the LLM can't derive from a FEN:
|
|
259
|
+
|
|
260
|
+
- `pawnStructure.files` — open / half-open / closed per column (rook targets).
|
|
261
|
+
- `pawnStructure.isolated`, `doubled`, `passed`, `backward` — structural weaknesses (and strengths, for passed).
|
|
262
|
+
- `weakSquares` — holes no friendly pawn can ever attack.
|
|
263
|
+
- `outposts` — friendly N/B on an enemy hole defended by own pawn.
|
|
264
|
+
- `bishops` — good / mixed / bad per bishop, based on own pawns on its colour. `bishops.pair` flags who has both.
|
|
265
|
+
- `space` — squares in the enemy half controlled by each side.
|
|
244
266
|
|
|
245
|
-
|
|
246
|
-
- Material balance in pawn units (`"white +1 (39 vs 38)"`)
|
|
247
|
-
- Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
|
|
248
|
-
- Hanging list — attacked and undefended
|
|
249
|
-
- Check state + checkers, castling rights, en passant
|
|
267
|
+
Use these instead of inventing structural claims. Commentary that says *"Black has the bad light-squared bishop"* should trace to `bishops.black[0].quality === "bad"`, not to a memory of similar Carlsbad positions.
|
|
250
268
|
|
|
251
|
-
|
|
269
|
+
The same call also returns `engineEvalTerms` — Stockfish's classical-eval breakdown into 13 named terms per colour (mg/eg). Compare it before/after a candidate move: the term that shifts most tells you WHAT the move changed. See the engine-usage guide for the delta pattern.
|
|
252
270
|
|
|
253
271
|
### When prose ADDS to the NAG
|
|
254
272
|
|
package/docs/prep-files-guide.md
CHANGED
|
@@ -31,6 +31,10 @@ Every mutation call takes a `node_id` (or `parent_id` for add-style ops) and aut
|
|
|
31
31
|
|
|
32
32
|
Duplicate files are the #1 way to lose your user's trust in this system.
|
|
33
33
|
|
|
34
|
+
## Before writing any prose: read the examples
|
|
35
|
+
|
|
36
|
+
**Call `read_example_prep_files` early in the session, before your first `set_comment` or comment-carrying `apply_mutations` batch.** Not once per project — once per LLM session. It returns two reference PGNs by a strong human coach and is the single biggest quality lift documented in this MCP; log analysis shows <5% of sessions call it and the ones that don't produce the exact anti-patterns the authoring guide warns against. Reading the two files takes one tool call and inoculates against most commentary failure modes.
|
|
37
|
+
|
|
34
38
|
## PGN authoring — separate concern
|
|
35
39
|
|
|
36
40
|
Everything about **how to write good PGN** (structure, NAGs, arrows, coloured squares, variation discipline, common pitfalls) lives in `pgn-authoring.md` — call `read_pgn_authoring_guide` for the full doc. That guide is universal to any chess file you might write; this guide is about the prep-files *feature* on top.
|
package/docs/prep-strategy.md
CHANGED
|
@@ -80,6 +80,32 @@ For a rare position where the opponent has 0–2 games as their side, it's often
|
|
|
80
80
|
|
|
81
81
|
Do this by dropping the `color` filter on `prepare_opponent`, or making a second session with the opposite colour — then compare game counts. When density is scarce, information from the "wrong" colour is worth more than no information at all. Just be honest about what it is: knowledge, not prediction.
|
|
82
82
|
|
|
83
|
+
## Using the user's course library (`find_position_in_courses` + `read_course_at_position`)
|
|
84
|
+
|
|
85
|
+
The user has a private index of their own Chessable / PGN files (dozens of GB of prep material). This is a **reference library, not memory** — nobody remembers what's in it, but the LLM can query on demand and cite specific chapters.
|
|
86
|
+
|
|
87
|
+
Two-tool workflow:
|
|
88
|
+
|
|
89
|
+
1. **`find_position_in_courses(fen)`** → metadata: which courses cover this position, ranked most-recently-updated first (10-year-old material is less trustworthy than 2-month-old). Each hit has a `course_file_id`.
|
|
90
|
+
2. **`read_course_at_position({course_file_id, fen})`** → the actual commentary, variations, arrows from that specific chapter. Depth-bounded (`max_plies_below`); widen or call again with a different `fen` to explore further.
|
|
91
|
+
|
|
92
|
+
Query patterns worth reaching for:
|
|
93
|
+
|
|
94
|
+
- **"Does my chosen line have coverage?"** — search from the target position. If several recent courses cover it, read the top 3-5 hits and see how much they agree; if they diverge, the position is a decision point worth annotating with the disagreement itself.
|
|
95
|
+
- **"What do opposite-colour repertoires recommend against this move?"** — same position, look at hits authored for the OTHER side. That's the LLM's window into "what will opponents have been told to play here". Read every same-side hit — a repertoire that appears in one course may still be someone's line.
|
|
96
|
+
- **"Has anyone tried my novelty before?"** — search the post-novelty position. Zero hits = genuine novelty (good). Hits = it's been tried; **read all of them**, not just the first — a novelty covered by three 2025 courses is not a novelty, but one that appears only in a single 2019 course probably still is.
|
|
97
|
+
- **"Compare how multiple authors annotate the same critical position"** — several `read_course_at_position` calls with different `course_file_id`s from the same search. This is the common case, not a special one.
|
|
98
|
+
|
|
99
|
+
**Read multiple hits, not just the top one.** The default action after `find_position_in_courses` returns 5 hits is FIVE `read_course_at_position` calls, not one. Different Chessable authors recommend different moves at critical junctions, and that disagreement is exactly the signal your user needs — it tells them where prep depth actually matters versus where the field is unanimous. Reading only the top hit gives the LLM one author's opinion presented as consensus; reading five gives the actual state of theory as the library sees it. The only excuse to stop after one is that the position is trivial (mainline endgame draw, hyper-quiet) and there's nothing to disagree about.
|
|
100
|
+
|
|
101
|
+
Discipline:
|
|
102
|
+
|
|
103
|
+
- Course material is a signal, not truth. A course written 2019 may be objectively refuted by current engine analysis; cross-check with `cloud_analyse` before adopting a course's recommendation wholesale.
|
|
104
|
+
- Recency ranking matters most in fast-moving lines (Najdorf, KID, Grünfeld) and less in stable structures (Caro-Kann, Slav mainlines). Weight accordingly.
|
|
105
|
+
- Cite the sources when you use them: `{Ganguly (Reinventing the Ragozin, 2025) recommends 12.Rd1; Aagaard (Attacking Manual II) prefers 12.a3 — decide based on what you're comfortable with.}` Naming multiple authors when they disagree gives the reader something to act on; naming one when others also cover the position understates the picture.
|
|
106
|
+
|
|
107
|
+
Not available if fenfind isn't installed on the server; both tools respond with `status: "not_available"` in that case.
|
|
108
|
+
|
|
83
109
|
## Prep is symmetric — both sides know the same things
|
|
84
110
|
|
|
85
111
|
The single biggest LLM error in prep is treating it like writing a book: "here's what you should play against this opponent's weakness." A real chess opponent:
|
|
@@ -174,6 +200,7 @@ Depth calibrates to the *character* of the position, not to whether the DB has g
|
|
|
174
200
|
- **Forcing → keep going.** Concrete tactics, only-moves, sharp sequences — analyse until the position calms (real endgame, stable middlegame structure). No game data doesn't matter; the moves are forced, so there's nothing to look up anyway. `describe_position` + engine is enough.
|
|
175
201
|
- **Quiet → stop early.** Once the position is strategic — many reasonable moves, no immediate threats — one sentence of plans is worth more than another 6 plies of tree. Stop and describe.
|
|
176
202
|
- **Novelties still need coverage — but the position tells you HOW.** If you're introducing a move nobody's played, "the DB has no games" is not a reason to stop; the reader will reach this position over the board and needs an answer. The shape of that coverage follows the same forcing/quiet rule above: if the novelty forces a single reply, cover just that line; if it opens several plausible replies, branch on them. Predict the opponent with `predict_human_move` (rating-conditioned), sanity-check with Stockfish (objective) and Lc0 (practical alternatives). All three signals matter *most* here precisely because no game-database answer exists. See also "Novelty burns" — that's a separate rule about WHEN to spend a novelty.
|
|
203
|
+
- **`predict_human_move.wdlWhitePov.draw` compares directly across positions.** When choosing between two lines for a specific practical goal (must-win, must-hold), call the tool on both and compare the draw share. Actual numbers only from actual calls — do not paste guessed percentages into prose without running the tool first.
|
|
177
204
|
|
|
178
205
|
## How to combine these
|
|
179
206
|
|
package/package.json
CHANGED
package/tools/fenfind/fenfind.py
CHANGED
|
@@ -49,6 +49,8 @@ def main():
|
|
|
49
49
|
ap.add_argument('--games', action='store_true', help='include game-database hits')
|
|
50
50
|
ap.add_argument('--min', type=float, default=400)
|
|
51
51
|
ap.add_argument('-n', type=int, default=25)
|
|
52
|
+
ap.add_argument('--sort', choices=['recency', 'notes'], default='recency',
|
|
53
|
+
help='ranking: recency (default, most recently updated file first) or notes (annotation depth, notes_chars desc)')
|
|
52
54
|
ap.add_argument('--json', action='store_true', help='emit structured JSON on stdout instead of the text table (used by the MCP wrapper)')
|
|
53
55
|
a = ap.parse_args()
|
|
54
56
|
|
|
@@ -60,7 +62,7 @@ def main():
|
|
|
60
62
|
|
|
61
63
|
con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
|
|
62
64
|
rows = [dict(r) for r in con.execute(
|
|
63
|
-
"""SELECT f.name,f.course,f.author,o.chapter,o.line,o.ply,o.subtree,o.chars
|
|
65
|
+
"""SELECT f.id AS file_id,f.name,f.path,f.course,f.author,o.chapter,o.line,o.ply,o.subtree,o.chars
|
|
64
66
|
FROM occ o JOIN files f ON f.id=o.file_id WHERE o.z=?""", (z,))]
|
|
65
67
|
if not rows:
|
|
66
68
|
if a.json:
|
|
@@ -70,6 +72,15 @@ def main():
|
|
|
70
72
|
print("position not found in the collection"); return
|
|
71
73
|
total = len(rows)
|
|
72
74
|
|
|
75
|
+
# Attach file mtime for recency ranking. Cheap — one stat() per row,
|
|
76
|
+
# done here rather than at index time so re-indexing isn't required
|
|
77
|
+
# for the recency signal to work.
|
|
78
|
+
for r in rows:
|
|
79
|
+
try:
|
|
80
|
+
r['mtime'] = os.path.getmtime(r['path'])
|
|
81
|
+
except OSError:
|
|
82
|
+
r['mtime'] = 0
|
|
83
|
+
|
|
73
84
|
ngame = 0
|
|
74
85
|
if not a.games:
|
|
75
86
|
keep = [r for r in rows if not looks_like_gamedb(r['chapter'], r['line'])]
|
|
@@ -99,10 +110,24 @@ def main():
|
|
|
99
110
|
if k not in groups or (r['chars'], r['subtree']) > (groups[k]['chars'], groups[k]['subtree']):
|
|
100
111
|
groups[k] = r
|
|
101
112
|
|
|
102
|
-
|
|
113
|
+
# Ranking. `recency` (default) sorts by file mtime desc — 2-month-old
|
|
114
|
+
# material beats 10-year-old on the assumption that theory shifts. Ties
|
|
115
|
+
# broken by notes_chars so a deep chapter beats a thin one at the same
|
|
116
|
+
# mtime. `notes` sorts by annotation depth alone, useful when the
|
|
117
|
+
# question is "who explains this best regardless of age".
|
|
118
|
+
if a.sort == 'notes':
|
|
119
|
+
hits = sorted(groups.values(), key=lambda r: (-r['chars'], -r['subtree']))
|
|
120
|
+
else: # recency
|
|
121
|
+
hits = sorted(groups.values(), key=lambda r: (-r['mtime'], -r['chars']))
|
|
103
122
|
shown = hits if a.all else [h for h in hits if h['chars'] >= a.min]
|
|
104
123
|
thin = len(hits) - len(shown)
|
|
105
124
|
|
|
125
|
+
import datetime as _dt
|
|
126
|
+
def _updated_at(ts):
|
|
127
|
+
if not ts:
|
|
128
|
+
return None
|
|
129
|
+
return _dt.date.fromtimestamp(ts).isoformat()
|
|
130
|
+
|
|
106
131
|
if a.json:
|
|
107
132
|
# Structured output for the MCP wrapper. Field names spelled out
|
|
108
133
|
# so the LLM reader doesn't need to know the SQLite column names.
|
|
@@ -110,6 +135,7 @@ def main():
|
|
|
110
135
|
"fen": b.fen(),
|
|
111
136
|
"found": True,
|
|
112
137
|
"total_occurrences": total,
|
|
138
|
+
"sort": a.sort,
|
|
113
139
|
"excluded": {
|
|
114
140
|
"game_db_hits": ngame,
|
|
115
141
|
"unmapped_files": ncourse,
|
|
@@ -118,6 +144,7 @@ def main():
|
|
|
118
144
|
},
|
|
119
145
|
"hits": [
|
|
120
146
|
{
|
|
147
|
+
"course_file_id": h['file_id'], # opaque handle; pass to read_course_at_position
|
|
121
148
|
"course": h['course'] or None,
|
|
122
149
|
"file": h['name'],
|
|
123
150
|
"author": h['author'] or None,
|
|
@@ -126,6 +153,7 @@ def main():
|
|
|
126
153
|
"ply": h['ply'],
|
|
127
154
|
"notes_chars": h['chars'],
|
|
128
155
|
"subtree_moves": h['subtree'],
|
|
156
|
+
"updated_at": _updated_at(h['mtime']),
|
|
129
157
|
}
|
|
130
158
|
for h in shown[:a.n]
|
|
131
159
|
],
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run readpgn.py with whichever interpreter has python-chess available.
|
|
3
|
+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
4
|
+
for py in "$here/.venv/bin/python" "$HOME/chess/.venv/bin/python" "$HOME/.venv/bin/python" python3; do
|
|
5
|
+
if "$py" -c 'import chess' 2>/dev/null; then
|
|
6
|
+
exec "$py" "$here/readpgn.py" "$@"
|
|
7
|
+
fi
|
|
8
|
+
done
|
|
9
|
+
echo "readpgn: python-chess not found. Install it, e.g.:" >&2
|
|
10
|
+
echo " python3 -m venv \"$here/.venv\" && \"$here/.venv/bin/pip\" install python-chess" >&2
|
|
11
|
+
exit 1
|