llm-chess-mcp 0.1.1 → 0.1.2
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 +111 -20
- package/dist/engines/stockfish.js +8 -0
- package/dist/eval.js +22 -4
- package/dist/games.js +9 -2
- package/dist/index.js +145 -97
- package/dist/intents.js +127 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# llm-chess-mcp
|
|
2
2
|
|
|
3
|
-
An MCP
|
|
3
|
+
An MCP chess runtime that lets LLMs play, analyze, and adapt their strength
|
|
4
|
+
without outsourcing every decision to an engine.
|
|
5
|
+
|
|
6
|
+
Rather than returning a single best move, it exposes objective strength
|
|
7
|
+
(Stockfish), human move likelihood (Maia3), and real-game statistics (Lichess)
|
|
8
|
+
so the LLM can choose how it wants to play. The LLM does the strategy and
|
|
9
|
+
judgment; the MCP server handles all the computation.
|
|
4
10
|
|
|
5
11
|
## Engines
|
|
6
12
|
|
|
@@ -10,8 +16,8 @@ An MCP server for truly enjoying chess games with your LLM. It adds a feature th
|
|
|
10
16
|
| **Maia3** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
|
|
11
17
|
| **Lichess explorer** | Real human game statistics | HTTP (needs token) |
|
|
12
18
|
|
|
13
|
-
Everything runs inside the Node process — no
|
|
14
|
-
runtime
|
|
19
|
+
Everything runs inside the Node process — no external engine process or Python
|
|
20
|
+
runtime is required at deploy time. Maia3 is exported to ONNX once at build time.
|
|
15
21
|
|
|
16
22
|
## Install
|
|
17
23
|
|
|
@@ -135,32 +141,93 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
135
141
|
|---|---|
|
|
136
142
|
| `create_game` | Create a game (optionally from a FEN), returns `game_id` |
|
|
137
143
|
| `delete_game` | Delete a game and free its session |
|
|
138
|
-
| `game_state` | Authoritative state: FEN, turn, check/mate/draw flags, history, last move, castling |
|
|
139
|
-
| `
|
|
140
|
-
| `game_play_move` | Play a move (SAN or UCI) — the only mutating tool |
|
|
144
|
+
| `game_state` | Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
|
|
145
|
+
| `game_play_move` | Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
|
|
141
146
|
| `game_legal_moves` | All legal moves with metadata |
|
|
142
147
|
| `game_pgn` | Export the game as PGN |
|
|
143
148
|
| `game_import_pgn` | Import a PGN into a new game |
|
|
144
|
-
| `position_analyze` | Stockfish multipv lines (cp/mate + PV) |
|
|
149
|
+
| `position_analyze` | Stockfish multipv lines (cp/mate/WDL + PV), `analysis_level` preset |
|
|
145
150
|
| `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
|
|
146
|
-
| `move_evaluate` | Score
|
|
147
|
-
| `move_candidates` |
|
|
148
|
-
| `move_candidates_by_intent` |
|
|
151
|
+
| `move_evaluate` | Score one or more moves + cpLoss + classification |
|
|
152
|
+
| `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
|
|
153
|
+
| `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
|
|
149
154
|
| `opening_explorer` | Lichess human game statistics |
|
|
150
155
|
|
|
151
156
|
## Score conventions
|
|
152
157
|
|
|
153
158
|
- Stockfish scores are **side-to-move perspective**: positive cp = side to move is
|
|
154
|
-
better; `mate N` = side to move mates in N.
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
better; `mate N` = side to move mates in N. `wdl` is `[win, draw, loss]` in
|
|
160
|
+
permille for the side to move.
|
|
161
|
+
- `move_candidates` gives `moverCp` (the mover's perspective — higher is better
|
|
162
|
+
for the player choosing the move) and `whiteCp` (fixed white perspective) so
|
|
163
|
+
the sign never flips on you.
|
|
164
|
+
- `move_evaluate` reports the score **from the mover's perspective**, plus `cpLoss`
|
|
165
|
+
(centipawns lost vs the best move) and a classification:
|
|
157
166
|
`best / excellent / good / inaccuracy / mistake / blunder`.
|
|
158
167
|
- `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
|
|
159
168
|
can still be objectively bad.
|
|
160
169
|
|
|
170
|
+
## Candidate structure
|
|
171
|
+
|
|
172
|
+
`move_candidates` returns each candidate with three independent facets:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"uci": "g1f3",
|
|
177
|
+
"san": "Nf3",
|
|
178
|
+
"objective": { "rank": 1, "moverCp": 55, "whiteCp": 55, "cpLoss": 0, "moverMate": null, "wdl": [153, 844, 3] },
|
|
179
|
+
"human": { "maia3Prob": 0.62, "selfElo": 1500, "opponentElo": 1500 },
|
|
180
|
+
"opening": { "status": "available", "games": 18421, "frequency": 0.31 }
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- `objective` — Stockfish: engine strength, never conflated with human-likeness.
|
|
185
|
+
`moverCp` is from the mover's perspective (higher = better for the chooser).
|
|
186
|
+
- `human` — Maia3 conditional probability at a target Elo.
|
|
187
|
+
- `opening` — Lichess empirical frequency (a different signal from Maia3).
|
|
188
|
+
|
|
189
|
+
`opening.status` is `available`, `no_data` (API ok but no games in this
|
|
190
|
+
position), `unavailable` (timeout/429/401), or `disabled` (no token).
|
|
191
|
+
Stockfish + Maia3 results are always returned regardless.
|
|
192
|
+
|
|
193
|
+
`move_candidates` also returns `moveSensitivity`, describing how sharply the
|
|
194
|
+
evaluation changes across the top engine lines:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{ "moveSensitivity": { "level": "high", "topMoveSpreadCp": 245 } }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`level` is `low` (<80cp spread), `medium` (80–200cp), or `high` (≥200cp). High
|
|
201
|
+
sensitivity means choosing among plausible alternatives can materially change
|
|
202
|
+
the evaluation — useful for deciding whether to ease off or play precisely.
|
|
203
|
+
|
|
204
|
+
## Analysis levels
|
|
205
|
+
|
|
206
|
+
Stockfish tools accept an `analysis_level` preset instead of raw UCI knobs:
|
|
207
|
+
|
|
208
|
+
| Level | Depth | MultiPV |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `fast` | 8 | 5 |
|
|
211
|
+
| `normal` | 15 | 8 |
|
|
212
|
+
| `deep` | 22 | 10 |
|
|
213
|
+
|
|
214
|
+
Explicit `depth`/`multipv` overrides are still available for advanced use.
|
|
215
|
+
|
|
216
|
+
## Stale-position guard
|
|
217
|
+
|
|
218
|
+
Every state read returns a `revision`. `game_play_move` **requires**
|
|
219
|
+
`expected_revision`; if the game has advanced since your last read, the move is
|
|
220
|
+
rejected:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{ "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
|
|
224
|
+
```
|
|
225
|
+
|
|
161
226
|
## Intents
|
|
162
227
|
|
|
163
|
-
`move_candidates_by_intent` ranks candidates for a chosen intent
|
|
228
|
+
`move_candidates_by_intent` ranks candidates for a chosen intent. It is a
|
|
229
|
+
convenience layer over `move_candidates`; the fixed thresholds below are
|
|
230
|
+
heuristic defaults, not the source of truth:
|
|
164
231
|
|
|
165
232
|
| Intent | Meaning |
|
|
166
233
|
|---|---|
|
|
@@ -168,16 +235,40 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
168
235
|
| `strong` | Engine-strong but human-plausible |
|
|
169
236
|
| `natural` | Most human-typical at the target Elo |
|
|
170
237
|
| `balanced` | Blend of strength and human-likeness |
|
|
171
|
-
| `ease_off` |
|
|
172
|
-
| `give_chance` |
|
|
238
|
+
| `ease_off` | Human-plausible moves that modestly reduce advantage without changing the expected result |
|
|
239
|
+
| `give_chance` | Human-plausible inaccuracies that meaningfully improve the opponent's chances |
|
|
240
|
+
|
|
241
|
+
This tool ranks candidates but does not choose a move. Use the returned signals
|
|
242
|
+
and conversation context to make the final decision — do not map user skill
|
|
243
|
+
mechanically to an intent.
|
|
173
244
|
|
|
174
245
|
## Example flow
|
|
175
246
|
|
|
247
|
+
The normal play loop is three calls:
|
|
248
|
+
|
|
176
249
|
1. `create_game` → `game_id`
|
|
177
|
-
2. `
|
|
178
|
-
3. `
|
|
179
|
-
|
|
180
|
-
|
|
250
|
+
2. `move_candidates` → pick a move
|
|
251
|
+
3. `game_play_move` (with `expected_revision`) → commit it
|
|
252
|
+
|
|
253
|
+
Go deeper only when you need to:
|
|
254
|
+
|
|
255
|
+
- `position_analyze` — objective best lines
|
|
256
|
+
- `human_move_distribution` — what a human of a given Elo would play
|
|
257
|
+
- `opening_explorer` — real-game statistics
|
|
258
|
+
- `move_evaluate` — score a specific move (or compare several)
|
|
259
|
+
|
|
260
|
+
## Maia3 ONNX verification
|
|
261
|
+
|
|
262
|
+
The exported ONNX model is regression-tested against the upstream Maia3
|
|
263
|
+
implementation across fixed positions and Elo pairs:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
.venv-maia3/bin/python scripts/verify_maia3.py --model 5m
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
It checks top-1/top-k move agreement and max probability error to detect
|
|
270
|
+
export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
|
|
271
|
+
and top-5 agreement and max probability error < 1e-4.
|
|
181
272
|
|
|
182
273
|
## License & attribution
|
|
183
274
|
|
|
@@ -10,6 +10,12 @@ function parseScore(token) {
|
|
|
10
10
|
return { cp: null, mate: Number(token.slice(4)) };
|
|
11
11
|
return { cp: null, mate: null };
|
|
12
12
|
}
|
|
13
|
+
function parseWdl(line) {
|
|
14
|
+
const m = line.match(/ wdl (\d+) (\d+) (\d+)/);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
18
|
+
}
|
|
13
19
|
export class Stockfish {
|
|
14
20
|
engine = null;
|
|
15
21
|
ready = null;
|
|
@@ -84,6 +90,7 @@ export class Stockfish {
|
|
|
84
90
|
multipv: n,
|
|
85
91
|
scoreCp: score.cp,
|
|
86
92
|
scoreMate: score.mate,
|
|
93
|
+
wdl: parseWdl(line),
|
|
87
94
|
pv: pv ? pv[1].split(" ") : [],
|
|
88
95
|
});
|
|
89
96
|
}
|
|
@@ -95,6 +102,7 @@ export class Stockfish {
|
|
|
95
102
|
};
|
|
96
103
|
engine.sendCommand("position fen " + fen);
|
|
97
104
|
engine.sendCommand(`setoption name MultiPV value ${multipv}`);
|
|
105
|
+
engine.sendCommand("setoption name UCI_ShowWDL value true");
|
|
98
106
|
engine.sendCommand(`go depth ${depth}`);
|
|
99
107
|
stopTimer = setTimeout(() => {
|
|
100
108
|
engine.sendCommand("stop");
|
package/dist/eval.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function toEval(line) {
|
|
2
2
|
if (line.scoreMate !== null) {
|
|
3
|
-
|
|
4
|
-
return sign * (10000 - Math.abs(line.scoreMate) * 100);
|
|
3
|
+
return { type: "mate", plies: line.scoreMate };
|
|
5
4
|
}
|
|
6
|
-
|
|
5
|
+
if (line.scoreCp !== null) {
|
|
6
|
+
return { type: "cp", value: line.scoreCp };
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
export function evalToCp(e) {
|
|
11
|
+
if (e.type === "cp")
|
|
12
|
+
return e.value;
|
|
13
|
+
const sign = e.plies >= 0 ? 1 : -1;
|
|
14
|
+
return sign * (10000 - Math.abs(e.plies) * 100);
|
|
15
|
+
}
|
|
16
|
+
export function negateEval(e) {
|
|
17
|
+
if (e.type === "cp")
|
|
18
|
+
return { type: "cp", value: -e.value };
|
|
19
|
+
return { type: "mate", plies: -e.plies };
|
|
7
20
|
}
|
|
8
21
|
export const CLASSIFICATION = {
|
|
9
22
|
best: 0,
|
|
@@ -25,3 +38,8 @@ export function classifyCpLoss(cpLoss) {
|
|
|
25
38
|
return "mistake";
|
|
26
39
|
return "blunder";
|
|
27
40
|
}
|
|
41
|
+
export const ANALYSIS_PRESETS = {
|
|
42
|
+
fast: { depth: 8, multipv: 5 },
|
|
43
|
+
normal: { depth: 15, multipv: 8 },
|
|
44
|
+
deep: { depth: 22, multipv: 10 },
|
|
45
|
+
};
|
package/dist/games.js
CHANGED
|
@@ -5,12 +5,12 @@ const games = new Map();
|
|
|
5
5
|
export function createGame(fen) {
|
|
6
6
|
const id = randomUUID();
|
|
7
7
|
const chess = fen ? new Chess(fen) : new Chess();
|
|
8
|
-
games.set(id, { chess, createdAt: Date.now() });
|
|
8
|
+
games.set(id, { chess, createdAt: Date.now(), revision: 0 });
|
|
9
9
|
return id;
|
|
10
10
|
}
|
|
11
11
|
export function createGameFromChess(chess) {
|
|
12
12
|
const id = randomUUID();
|
|
13
|
-
games.set(id, { chess, createdAt: Date.now() });
|
|
13
|
+
games.set(id, { chess, createdAt: Date.now(), revision: 0 });
|
|
14
14
|
return id;
|
|
15
15
|
}
|
|
16
16
|
export function getGame(id) {
|
|
@@ -19,6 +19,13 @@ export function getGame(id) {
|
|
|
19
19
|
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
20
20
|
return g;
|
|
21
21
|
}
|
|
22
|
+
export function bumpRevision(id) {
|
|
23
|
+
const g = games.get(id);
|
|
24
|
+
if (!g)
|
|
25
|
+
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
26
|
+
g.revision += 1;
|
|
27
|
+
return g.revision;
|
|
28
|
+
}
|
|
22
29
|
export function deleteGame(id) {
|
|
23
30
|
return games.delete(id);
|
|
24
31
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,12 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { Chess } from "chess.js";
|
|
6
6
|
import { loadEnv } from "./env.js";
|
|
7
|
-
import { createGame, createGameFromChess, getGame, deleteGame } from "./games.js";
|
|
7
|
+
import { createGame, createGameFromChess, getGame, deleteGame, bumpRevision, } from "./games.js";
|
|
8
8
|
import { stockfish } from "./engines/stockfish.js";
|
|
9
9
|
import { humanMoveDistribution } from "./maia3/inference.js";
|
|
10
10
|
import { openingExplorer, explorerEnabled } from "./explorer.js";
|
|
11
11
|
import { computeCandidates, rankByIntent } from "./intents.js";
|
|
12
|
-
import {
|
|
12
|
+
import { toEval, evalToCp, negateEval, classifyCpLoss, ANALYSIS_PRESETS } from "./eval.js";
|
|
13
13
|
import { ChessError } from "./errors.js";
|
|
14
14
|
const INTENTS = [
|
|
15
15
|
"best",
|
|
@@ -22,11 +22,12 @@ const INTENTS = [
|
|
|
22
22
|
function text(data) {
|
|
23
23
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
24
24
|
}
|
|
25
|
-
function stateOf(chess) {
|
|
25
|
+
function stateOf(chess, revision) {
|
|
26
26
|
const last = chess.history({ verbose: true }).at(-1);
|
|
27
27
|
return {
|
|
28
28
|
fen: chess.fen(),
|
|
29
29
|
turn: chess.turn(),
|
|
30
|
+
revision,
|
|
30
31
|
isCheck: chess.isCheck(),
|
|
31
32
|
isCheckmate: chess.isCheckmate(),
|
|
32
33
|
isStalemate: chess.isStalemate(),
|
|
@@ -86,7 +87,7 @@ export function buildServer() {
|
|
|
86
87
|
catch {
|
|
87
88
|
throw new ChessError("INVALID_FEN", "invalid FEN");
|
|
88
89
|
}
|
|
89
|
-
return text({ game_id: id });
|
|
90
|
+
return text({ game_id: id, revision: 0 });
|
|
90
91
|
}));
|
|
91
92
|
server.registerTool("delete_game", {
|
|
92
93
|
description: "Delete a game and free its session.",
|
|
@@ -98,30 +99,39 @@ export function buildServer() {
|
|
|
98
99
|
return text({ game_id, deleted: true });
|
|
99
100
|
}));
|
|
100
101
|
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({
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
description: "Return the authoritative state of a game: FEN, turn, revision, check/mate/draw flags, move history, last move, castling rights. Use this instead of remembering the board. Set include_ascii=true to also get a board diagram.",
|
|
103
|
+
inputSchema: z.object({
|
|
104
|
+
game_id: z.string(),
|
|
105
|
+
include_ascii: z.boolean().default(false),
|
|
106
|
+
}),
|
|
107
|
+
}, wrap(async ({ game_id, include_ascii }) => {
|
|
108
|
+
const { chess, revision } = getGame(game_id);
|
|
109
|
+
const state = stateOf(chess, revision);
|
|
110
|
+
return text({
|
|
111
|
+
game_id,
|
|
112
|
+
...state,
|
|
113
|
+
...(include_ascii ? { board: chess.ascii() } : {}),
|
|
114
|
+
});
|
|
113
115
|
}));
|
|
114
116
|
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.
|
|
116
|
-
inputSchema: z.object({
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
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. expected_revision is required: pass the revision from your most recent game_state/move_candidates read. If the game has advanced since then, the move is rejected with STALE_POSITION.",
|
|
118
|
+
inputSchema: z.object({
|
|
119
|
+
game_id: z.string(),
|
|
120
|
+
move: z.string(),
|
|
121
|
+
expected_revision: z.number().int().min(0),
|
|
122
|
+
}),
|
|
123
|
+
}, wrap(async ({ game_id, move, expected_revision }) => {
|
|
124
|
+
const { chess, revision } = getGame(game_id);
|
|
125
|
+
if (expected_revision !== revision) {
|
|
126
|
+
throw new ChessError("STALE_POSITION", `position changed: expected revision ${expected_revision}, current ${revision}`);
|
|
127
|
+
}
|
|
119
128
|
if (chess.isGameOver()) {
|
|
120
129
|
throw new ChessError("GAME_OVER", "game is already over");
|
|
121
130
|
}
|
|
122
131
|
const m = parseMove(chess, move);
|
|
123
132
|
chess.move({ from: m.from, to: m.to, promotion: m.promotion });
|
|
124
|
-
|
|
133
|
+
const newRevision = bumpRevision(game_id);
|
|
134
|
+
return text({ game_id, move: m.san, ...stateOf(chess, newRevision) });
|
|
125
135
|
}));
|
|
126
136
|
server.registerTool("game_legal_moves", {
|
|
127
137
|
description: "List all legal moves in the current position (SAN, UCI, piece, capture, promotion).",
|
|
@@ -142,23 +152,30 @@ export function buildServer() {
|
|
|
142
152
|
return text({ game_id, count: moves.length, moves });
|
|
143
153
|
}));
|
|
144
154
|
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.",
|
|
155
|
+
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. wdl is [win, draw, loss] in permille for the side to move. Use analysis_level (fast/normal/deep) or explicit depth/multipv. Does NOT mutate the game.",
|
|
146
156
|
inputSchema: z.object({
|
|
147
157
|
game_id: z.string(),
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
|
|
159
|
+
depth: z.number().int().min(1).max(30).optional(),
|
|
160
|
+
multipv: z.number().int().min(1).max(10).optional(),
|
|
150
161
|
}),
|
|
151
|
-
}, wrap(async ({ game_id, depth, multipv }) => {
|
|
152
|
-
const { chess } = getGame(game_id);
|
|
153
|
-
const
|
|
162
|
+
}, wrap(async ({ game_id, analysis_level, depth, multipv }) => {
|
|
163
|
+
const { chess, revision } = getGame(game_id);
|
|
164
|
+
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
165
|
+
const d = depth ?? preset.depth;
|
|
166
|
+
const mpv = multipv ?? preset.multipv;
|
|
167
|
+
const lines = await stockfish.analyze(chess.fen(), d, mpv);
|
|
154
168
|
return text({
|
|
155
169
|
game_id,
|
|
156
170
|
fen: chess.fen(),
|
|
157
171
|
turn: chess.turn(),
|
|
172
|
+
revision,
|
|
173
|
+
analysis_level,
|
|
158
174
|
lines: lines.map((l) => ({
|
|
159
175
|
multipv: l.multipv,
|
|
160
176
|
scoreCp: l.scoreCp,
|
|
161
177
|
scoreMate: l.scoreMate,
|
|
178
|
+
wdl: l.wdl,
|
|
162
179
|
pv: l.pv,
|
|
163
180
|
})),
|
|
164
181
|
});
|
|
@@ -172,109 +189,140 @@ export function buildServer() {
|
|
|
172
189
|
top_n: z.number().int().min(1).max(20).default(5),
|
|
173
190
|
}),
|
|
174
191
|
}, wrap(async ({ game_id, elo, oppo_elo, top_n }) => {
|
|
175
|
-
const { chess } = getGame(game_id);
|
|
192
|
+
const { chess, revision } = getGame(game_id);
|
|
176
193
|
const moves = await humanMoveDistribution(chess, elo, oppo_elo ?? elo, top_n);
|
|
177
|
-
return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, moves });
|
|
194
|
+
return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, revision, moves });
|
|
178
195
|
}));
|
|
179
196
|
server.registerTool("move_evaluate", {
|
|
180
|
-
description: "Evaluate
|
|
197
|
+
description: "Evaluate one or more moves with Stockfish without mutating the game. Pass a single move string or an array of moves to compare. Returns, for each move, the score after the move (from the mover's perspective), cpLoss vs the best move, and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
|
|
181
198
|
inputSchema: z.object({
|
|
182
199
|
game_id: z.string(),
|
|
183
|
-
move: z.string(),
|
|
200
|
+
move: z.union([z.string(), z.array(z.string())]),
|
|
184
201
|
depth: z.number().int().min(1).max(30).default(15),
|
|
185
202
|
}),
|
|
186
203
|
}, wrap(async ({ game_id, move, depth }) => {
|
|
187
|
-
const { chess } = getGame(game_id);
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const { chess, revision } = getGame(game_id);
|
|
205
|
+
const moves = Array.isArray(move) ? move : [move];
|
|
206
|
+
const beforeLines = await stockfish.analyze(chess.fen(), depth, 1);
|
|
207
|
+
const before = beforeLines[0];
|
|
208
|
+
const beforeEval = before ? toEval(before) : null;
|
|
209
|
+
const beforeCp = beforeEval ? evalToCp(beforeEval) : null;
|
|
210
|
+
const results = [];
|
|
211
|
+
for (const mv of moves) {
|
|
212
|
+
const m = parseMove(chess, mv);
|
|
213
|
+
const copy = new Chess(chess.fen());
|
|
214
|
+
copy.move({ from: m.from, to: m.to, promotion: m.promotion });
|
|
215
|
+
if (copy.isCheckmate()) {
|
|
216
|
+
results.push({
|
|
217
|
+
move: m.san,
|
|
218
|
+
uci: m.lan,
|
|
219
|
+
result: "checkmate",
|
|
220
|
+
scoreCp: null,
|
|
221
|
+
scoreMate: 0,
|
|
222
|
+
bestCp: beforeCp,
|
|
223
|
+
cpLoss: null,
|
|
224
|
+
classification: "best",
|
|
225
|
+
pv: [],
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (copy.isStalemate()) {
|
|
230
|
+
results.push({
|
|
231
|
+
move: m.san,
|
|
232
|
+
uci: m.lan,
|
|
233
|
+
result: "stalemate",
|
|
234
|
+
scoreCp: 0,
|
|
235
|
+
scoreMate: null,
|
|
236
|
+
bestCp: beforeCp,
|
|
237
|
+
cpLoss: null,
|
|
238
|
+
classification: null,
|
|
239
|
+
pv: [],
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const afterLines = await stockfish.analyze(copy.fen(), depth, 1);
|
|
244
|
+
const after = afterLines[0];
|
|
245
|
+
const afterEval = after ? toEval(after) : null;
|
|
246
|
+
const moverEval = afterEval ? negateEval(afterEval) : null;
|
|
247
|
+
const afterCp = moverEval ? evalToCp(moverEval) : null;
|
|
248
|
+
const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
|
|
249
|
+
results.push({
|
|
208
250
|
move: m.san,
|
|
209
251
|
uci: m.lan,
|
|
210
|
-
result: "
|
|
211
|
-
scoreCp:
|
|
212
|
-
scoreMate: null,
|
|
213
|
-
bestCp:
|
|
214
|
-
cpLoss
|
|
215
|
-
classification: null,
|
|
216
|
-
pv: [],
|
|
252
|
+
result: "ongoing",
|
|
253
|
+
scoreCp: afterCp,
|
|
254
|
+
scoreMate: moverEval?.type === "mate" ? moverEval.plies : null,
|
|
255
|
+
bestCp: beforeCp,
|
|
256
|
+
cpLoss,
|
|
257
|
+
classification: cpLoss !== null ? classifyCpLoss(cpLoss) : null,
|
|
258
|
+
pv: after?.pv ?? [],
|
|
217
259
|
});
|
|
218
260
|
}
|
|
219
|
-
const
|
|
220
|
-
|
|
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
|
-
});
|
|
261
|
+
const payload = { game_id, revision, results };
|
|
262
|
+
return text(moves.length === 1 ? { ...payload, ...results[0] } : payload);
|
|
242
263
|
}));
|
|
243
264
|
server.registerTool("move_candidates", {
|
|
244
|
-
description: "Combine Stockfish evaluation, Maia3 human probability, and Lichess
|
|
265
|
+
description: "The primary move-selection tool. Combine Stockfish objective evaluation (moverCp, whiteCp, cpLoss, mate, WDL), Maia3 human probability, and Lichess real-game statistics into a unified candidate list. moverCp is from the mover's perspective: higher = better for the player choosing the move. Use this before choosing a move; the final choice is yours.",
|
|
245
266
|
inputSchema: z.object({
|
|
246
267
|
game_id: z.string(),
|
|
247
268
|
elo: z.number().int().min(600).max(2600).default(1500),
|
|
248
|
-
|
|
249
|
-
|
|
269
|
+
analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
|
|
270
|
+
sf_depth: z.number().int().min(1).max(30).optional(),
|
|
271
|
+
sf_multipv: z.number().int().min(1).max(10).optional(),
|
|
250
272
|
maia_top_n: z.number().int().min(1).max(20).default(5),
|
|
251
273
|
lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
|
|
252
274
|
lichess_speeds: z.array(z.string()).default([]),
|
|
253
275
|
lichess_ratings: z.array(z.number().int()).default([]),
|
|
254
276
|
}),
|
|
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
|
|
258
|
-
|
|
277
|
+
}, wrap(async ({ game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
|
|
278
|
+
const { chess, revision } = getGame(game_id);
|
|
279
|
+
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
280
|
+
const d = sf_depth ?? preset.depth;
|
|
281
|
+
const mpv = sf_multipv ?? preset.multipv;
|
|
282
|
+
const { candidates, moveSensitivity } = await computeCandidates(chess, elo, d, mpv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
|
|
283
|
+
return text({
|
|
284
|
+
game_id,
|
|
285
|
+
revision,
|
|
286
|
+
fen: chess.fen(),
|
|
287
|
+
turn: chess.turn(),
|
|
288
|
+
elo,
|
|
289
|
+
analysis_level,
|
|
290
|
+
moveSensitivity,
|
|
291
|
+
candidates,
|
|
292
|
+
});
|
|
259
293
|
}));
|
|
260
294
|
server.registerTool("move_candidates_by_intent", {
|
|
261
|
-
description: "
|
|
295
|
+
description: "Convenience layer over move_candidates: rank candidates for a strategic intent. This tool RANKS candidates but does NOT choose a move — use the returned signals and conversation context to make the final decision. Do not map user skill mechanically to an 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 (human-plausible moves that modestly reduce advantage without changing the expected result), give_chance (human-plausible inaccuracies that meaningfully improve the opponent's chances).",
|
|
262
296
|
inputSchema: z.object({
|
|
263
297
|
game_id: z.string(),
|
|
264
298
|
intent: z.enum(INTENTS),
|
|
265
299
|
elo: z.number().int().min(600).max(2600).default(1500),
|
|
266
|
-
|
|
267
|
-
|
|
300
|
+
analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
|
|
301
|
+
sf_depth: z.number().int().min(1).max(30).optional(),
|
|
302
|
+
sf_multipv: z.number().int().min(1).max(10).optional(),
|
|
268
303
|
maia_top_n: z.number().int().min(1).max(20).default(10),
|
|
269
304
|
lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
|
|
270
305
|
lichess_speeds: z.array(z.string()).default([]),
|
|
271
306
|
lichess_ratings: z.array(z.number().int()).default([]),
|
|
272
307
|
}),
|
|
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
|
|
308
|
+
}, wrap(async ({ game_id, intent, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
|
|
309
|
+
const { chess, revision } = getGame(game_id);
|
|
310
|
+
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
311
|
+
const d = sf_depth ?? preset.depth;
|
|
312
|
+
const mpv = sf_multipv ?? preset.multipv;
|
|
313
|
+
const { candidates, moveSensitivity } = await computeCandidates(chess, elo, d, mpv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
|
|
276
314
|
const ranked = rankByIntent(candidates, intent);
|
|
277
|
-
return text({
|
|
315
|
+
return text({
|
|
316
|
+
game_id,
|
|
317
|
+
revision,
|
|
318
|
+
fen: chess.fen(),
|
|
319
|
+
turn: chess.turn(),
|
|
320
|
+
intent,
|
|
321
|
+
elo,
|
|
322
|
+
analysis_level,
|
|
323
|
+
moveSensitivity,
|
|
324
|
+
candidates: ranked,
|
|
325
|
+
});
|
|
278
326
|
}));
|
|
279
327
|
server.registerTool("opening_explorer", {
|
|
280
328
|
description: "Query the Lichess opening explorer for real human game statistics in the current position (requires LICHESS_TOKEN).",
|
|
@@ -312,7 +360,7 @@ export function buildServer() {
|
|
|
312
360
|
throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
|
|
313
361
|
}
|
|
314
362
|
const id = createGameFromChess(chess);
|
|
315
|
-
return text({ game_id: id, ...stateOf(chess) });
|
|
363
|
+
return text({ game_id: id, ...stateOf(chess, 0) });
|
|
316
364
|
}));
|
|
317
365
|
return server;
|
|
318
366
|
}
|
package/dist/intents.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stockfish } from "./engines/stockfish.js";
|
|
2
2
|
import { humanMoveDistribution } from "./maia3/inference.js";
|
|
3
3
|
import { openingExplorer, explorerEnabled } from "./explorer.js";
|
|
4
|
-
import {
|
|
4
|
+
import { toEval, evalToCp } from "./eval.js";
|
|
5
5
|
function softmax(values, temperature) {
|
|
6
6
|
const scaled = values.map((v) => v / temperature);
|
|
7
7
|
const max = Math.max(...scaled);
|
|
@@ -9,22 +9,65 @@ function softmax(values, temperature) {
|
|
|
9
9
|
const sum = exps.reduce((a, b) => a + b, 0);
|
|
10
10
|
return exps.map((e) => e / sum);
|
|
11
11
|
}
|
|
12
|
+
function toSan(chess, uci) {
|
|
13
|
+
const m = chess.moves({ verbose: true }).find((x) => x.lan === uci);
|
|
14
|
+
return m ? m.san : uci;
|
|
15
|
+
}
|
|
16
|
+
function objectiveFromLine(line, turn, bestCp) {
|
|
17
|
+
if (!line) {
|
|
18
|
+
return {
|
|
19
|
+
rank: null,
|
|
20
|
+
moverCp: null,
|
|
21
|
+
whiteCp: null,
|
|
22
|
+
cpLoss: null,
|
|
23
|
+
moverMate: null,
|
|
24
|
+
whiteMate: null,
|
|
25
|
+
wdl: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const e = toEval(line);
|
|
29
|
+
const cp = e ? evalToCp(e) : null;
|
|
30
|
+
const whiteCp = cp !== null ? (turn === "w" ? cp : -cp) : null;
|
|
31
|
+
const mate = line.scoreMate;
|
|
32
|
+
const whiteMate = mate !== null ? (turn === "w" ? mate : -mate) : null;
|
|
33
|
+
return {
|
|
34
|
+
rank: line.multipv,
|
|
35
|
+
moverCp: cp,
|
|
36
|
+
whiteCp,
|
|
37
|
+
cpLoss: cp !== null && bestCp !== null ? bestCp - cp : null,
|
|
38
|
+
moverMate: mate,
|
|
39
|
+
whiteMate,
|
|
40
|
+
wdl: line.wdl,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
12
43
|
export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN, lichess) {
|
|
13
|
-
const [sfLines, maiaMoves,
|
|
44
|
+
const [sfLines, maiaMoves, lichessResult] = await Promise.all([
|
|
14
45
|
stockfish.analyze(chess.fen(), sfDepth, sfMultipv),
|
|
15
46
|
humanMoveDistribution(chess, elo, elo, maiaTopN),
|
|
16
47
|
lichess && explorerEnabled()
|
|
17
48
|
? openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings)
|
|
18
|
-
.then((r) =>
|
|
19
|
-
.
|
|
20
|
-
|
|
49
|
+
.then((r) => ({
|
|
50
|
+
status: (r.moves.length > 0 ? "available" : "no_data"),
|
|
51
|
+
moves: r.moves,
|
|
52
|
+
}))
|
|
53
|
+
.catch((e) => ({
|
|
54
|
+
status: "unavailable",
|
|
55
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
56
|
+
moves: [],
|
|
57
|
+
}))
|
|
58
|
+
: Promise.resolve({
|
|
59
|
+
status: "disabled",
|
|
60
|
+
moves: [],
|
|
61
|
+
}),
|
|
21
62
|
]);
|
|
63
|
+
const turn = chess.turn();
|
|
22
64
|
const maiaByUci = new Map(maiaMoves.map((m) => [m.uci, m.prob]));
|
|
23
65
|
const sfByUci = new Map(sfLines.map((l) => [l.pv[0], l]));
|
|
24
|
-
const lichessByUci = new Map(
|
|
66
|
+
const lichessByUci = new Map(lichessResult.moves.map((m) => [m.uci, m]));
|
|
25
67
|
const bestCp = sfLines.length
|
|
26
|
-
? Math.max(...sfLines.map((l) =>
|
|
27
|
-
:
|
|
68
|
+
? Math.max(...sfLines.map((l) => evalToCp(toEval(l))))
|
|
69
|
+
: null;
|
|
70
|
+
const totalGames = lichessResult.moves.reduce((a, m) => a + m.count, 0);
|
|
28
71
|
const ucis = new Set([
|
|
29
72
|
...sfByUci.keys(),
|
|
30
73
|
...maiaByUci.keys(),
|
|
@@ -33,58 +76,107 @@ export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN
|
|
|
33
76
|
const candidates = [];
|
|
34
77
|
for (const uci of ucis) {
|
|
35
78
|
const sf = sfByUci.get(uci);
|
|
36
|
-
const
|
|
79
|
+
const lc = lichessByUci.get(uci);
|
|
80
|
+
const opening = lc
|
|
81
|
+
? {
|
|
82
|
+
status: lichessResult.status,
|
|
83
|
+
games: lc.count,
|
|
84
|
+
frequency: totalGames > 0 ? lc.count / totalGames : null,
|
|
85
|
+
white: lc.white,
|
|
86
|
+
draws: lc.draws,
|
|
87
|
+
black: lc.black,
|
|
88
|
+
averageRating: lc.averageRating,
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
status: lichessResult.status,
|
|
92
|
+
reason: lichessResult.status === "unavailable" ? lichessResult.reason : undefined,
|
|
93
|
+
games: null,
|
|
94
|
+
frequency: null,
|
|
95
|
+
white: null,
|
|
96
|
+
draws: null,
|
|
97
|
+
black: null,
|
|
98
|
+
averageRating: null,
|
|
99
|
+
};
|
|
37
100
|
candidates.push({
|
|
38
101
|
uci,
|
|
39
102
|
san: toSan(chess, uci),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
103
|
+
objective: objectiveFromLine(sf, turn, bestCp),
|
|
104
|
+
human: {
|
|
105
|
+
maia3Prob: maiaByUci.get(uci) ?? null,
|
|
106
|
+
selfElo: elo,
|
|
107
|
+
opponentElo: elo,
|
|
108
|
+
},
|
|
109
|
+
opening,
|
|
44
110
|
});
|
|
45
111
|
}
|
|
46
|
-
return candidates;
|
|
112
|
+
return { candidates, moveSensitivity: computeMoveSensitivity(sfLines) };
|
|
47
113
|
}
|
|
48
|
-
function
|
|
49
|
-
const
|
|
50
|
-
|
|
114
|
+
function winMargin(c) {
|
|
115
|
+
const wdl = c.objective.wdl;
|
|
116
|
+
if (!wdl)
|
|
117
|
+
return null;
|
|
118
|
+
return wdl[0] - wdl[2];
|
|
119
|
+
}
|
|
120
|
+
export function computeMoveSensitivity(sfLines) {
|
|
121
|
+
const cps = sfLines
|
|
122
|
+
.map((l) => toEval(l))
|
|
123
|
+
.filter((e) => e !== null)
|
|
124
|
+
.map((e) => evalToCp(e));
|
|
125
|
+
if (cps.length < 2) {
|
|
126
|
+
return { level: "low", topMoveSpreadCp: null };
|
|
127
|
+
}
|
|
128
|
+
const spread = Math.max(...cps) - Math.min(...cps);
|
|
129
|
+
const level = spread >= 200 ? "high" : spread >= 80 ? "medium" : "low";
|
|
130
|
+
return { level, topMoveSpreadCp: spread };
|
|
51
131
|
}
|
|
52
132
|
export function rankByIntent(candidates, intent) {
|
|
53
|
-
const withSf = candidates.filter((c) => c.
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
: null;
|
|
133
|
+
const withSf = candidates.filter((c) => c.objective.moverCp !== null);
|
|
134
|
+
const bestMargin = withSf.length
|
|
135
|
+
? Math.max(...withSf.map((c) => winMargin(c) ?? -Infinity))
|
|
136
|
+
: 0;
|
|
58
137
|
const scored = candidates.map((c) => {
|
|
59
138
|
let score;
|
|
60
139
|
switch (intent) {
|
|
61
140
|
case "best":
|
|
62
|
-
score = c.
|
|
141
|
+
score = c.objective.moverCp ?? -Infinity;
|
|
63
142
|
break;
|
|
64
143
|
case "strong": {
|
|
65
|
-
const sf = c.
|
|
66
|
-
const human = c.maia3Prob ?? 0;
|
|
144
|
+
const sf = c.objective.moverCp ?? -Infinity;
|
|
145
|
+
const human = c.human.maia3Prob ?? 0;
|
|
67
146
|
score = human > 0 ? sf : -Infinity;
|
|
68
147
|
break;
|
|
69
148
|
}
|
|
70
149
|
case "natural":
|
|
71
|
-
score = c.maia3Prob ?? 0;
|
|
150
|
+
score = c.human.maia3Prob ?? 0;
|
|
72
151
|
break;
|
|
73
152
|
case "balanced": {
|
|
153
|
+
const sfProbs = softmax(candidates.map((x) => x.objective.moverCp ?? -1000), 100);
|
|
74
154
|
const idx = candidates.indexOf(c);
|
|
75
|
-
score = 0.5 * sfProbs[idx] + 0.5 * (c.maia3Prob ?? 0);
|
|
155
|
+
score = 0.5 * sfProbs[idx] + 0.5 * (c.human.maia3Prob ?? 0);
|
|
76
156
|
break;
|
|
77
157
|
}
|
|
78
158
|
case "ease_off": {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
159
|
+
const margin = winMargin(c);
|
|
160
|
+
const human = c.human.maia3Prob ?? 0;
|
|
161
|
+
if (margin === null || human === 0) {
|
|
162
|
+
score = -Infinity;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const drop = bestMargin - margin;
|
|
166
|
+
const modest = drop >= 15 && drop <= 50 && margin > 0;
|
|
167
|
+
score = modest ? human : -Infinity;
|
|
82
168
|
break;
|
|
83
169
|
}
|
|
84
170
|
case "give_chance": {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
171
|
+
const margin = winMargin(c);
|
|
172
|
+
const human = c.human.maia3Prob ?? 0;
|
|
173
|
+
if (margin === null || human === 0) {
|
|
174
|
+
score = -Infinity;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const drop = bestMargin - margin;
|
|
178
|
+
const meaningful = drop >= 50 && drop <= 150;
|
|
179
|
+
score = meaningful ? human : -Infinity;
|
|
88
180
|
break;
|
|
89
181
|
}
|
|
90
182
|
}
|
|
@@ -95,9 +187,9 @@ export function rankByIntent(candidates, intent) {
|
|
|
95
187
|
.sort((a, b) => b.score - a.score)
|
|
96
188
|
.map((x) => x.c);
|
|
97
189
|
if (ranked.length === 0 && (intent === "ease_off" || intent === "give_chance")) {
|
|
98
|
-
const human = candidates.filter((c) => c.maia3Prob !== null);
|
|
190
|
+
const human = candidates.filter((c) => c.human.maia3Prob !== null && c.objective.moverCp !== null);
|
|
99
191
|
return human
|
|
100
|
-
.sort((a, b) => (a.
|
|
192
|
+
.sort((a, b) => (a.objective.moverCp ?? -Infinity) - (b.objective.moverCp ?? -Infinity))
|
|
101
193
|
.slice(0, 5);
|
|
102
194
|
}
|
|
103
195
|
return ranked;
|