-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathstockfish.ts
More file actions
379 lines (331 loc) · 11.7 KB
/
stockfish.ts
File metadata and controls
379 lines (331 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
import { Chess } from 'chess.ts'
import { cpToWinrate } from 'src/lib'
import StockfishWeb from 'lila-stockfish-web'
import { StockfishEvaluation } from 'src/types'
class Engine {
private fen: string
private moves: string[]
private isEvaluating: boolean
private stockfish: StockfishWeb | null = null
private isReady = false
private nnueLoaded = false
private currentPositionId = ''
private store: {
[key: string]: StockfishEvaluation
}
private legalMoveCount: number
private evaluationResolver: ((value: StockfishEvaluation) => void) | null
private evaluationRejecter: ((reason?: unknown) => void) | null
private evaluationPromise: Promise<StockfishEvaluation> | null
private evaluationGenerator: AsyncGenerator<StockfishEvaluation> | null
constructor() {
this.fen = ''
this.store = {}
this.moves = []
this.isEvaluating = false
this.legalMoveCount = 0
this.evaluationResolver = null
this.evaluationRejecter = null
this.evaluationPromise = null
this.evaluationGenerator = null
this.onMessage = this.onMessage.bind(this)
setupStockfish()
.then((stockfish: StockfishWeb) => {
this.stockfish = stockfish
stockfish.uci('uci')
stockfish.uci('isready')
stockfish.uci('setoption name MultiPV value 100')
stockfish.onError = this.onError
stockfish.listen = this.onMessage
this.isReady = true
this.nnueLoaded = true
})
.catch((error) => {
console.error('Failed to initialize Stockfish:', error)
this.isReady = false
})
}
get ready(): boolean {
return this.isReady && this.stockfish !== null && this.nnueLoaded
}
async *streamEvaluations(
fen: string,
legalMoveCount: number,
targetDepth = 18,
): AsyncGenerator<StockfishEvaluation> {
if (this.stockfish && this.isReady) {
if (typeof global.gc === 'function') {
global.gc()
}
// Stop any previous evaluation
this.stopEvaluation()
this.store = {}
this.legalMoveCount = legalMoveCount
const board = new Chess(fen)
this.moves = board
.moves({ verbose: true })
.map((x) => x.from + x.to + (x.promotion || ''))
this.fen = fen
this.currentPositionId = fen + '_' + Date.now()
this.isEvaluating = true
this.evaluationGenerator = this.createEvaluationGenerator()
this.sendMessage('ucinewgame')
this.sendMessage(`position fen ${fen}`)
this.sendMessage(`go depth ${targetDepth}`)
while (this.isEvaluating) {
try {
const evaluation = await this.getNextEvaluation()
if (evaluation) {
yield evaluation
} else {
break
}
} catch (error) {
console.error('Error in evaluation stream:', error)
break
}
}
}
}
private async getNextEvaluation(): Promise<StockfishEvaluation | null> {
return new Promise((resolve, reject) => {
this.evaluationResolver = resolve
this.evaluationRejecter = reject
})
}
private createEvaluationGenerator(): AsyncGenerator<StockfishEvaluation> | null {
return null
}
private sendMessage(message: string) {
if (this.stockfish) {
this.stockfish.uci(message)
}
}
private async waitForReady(): Promise<void> {
if (!this.stockfish) return
return new Promise((resolve) => {
if (!this.stockfish) {
resolve()
return
}
let resolved = false
const originalListen = this.stockfish.listen
const tempListener = (msg: string) => {
if (msg.includes('readyok') && !resolved) {
resolved = true
if (this.stockfish) {
this.stockfish.listen = originalListen
}
resolve()
} else {
// Forward all other messages to the original listener
originalListen(msg)
}
}
this.stockfish.listen = tempListener
this.stockfish.uci('isready')
})
}
private onMessage(msg: string) {
// Only process evaluation messages if we're currently evaluating
if (!this.isEvaluating) {
return
}
const matches = [
...msg.matchAll(
/info depth (\d+) seldepth (\d+) multipv (\d+) score (?:cp (-?\d+)|mate (-?\d+)).+ pv ((?:\S+\s*)+)/g,
),
][0]
if (!matches || !matches.length) {
return
}
const depth = parseInt(matches[1], 10)
const multipv = parseInt(matches[3], 10)
let cp = parseInt(matches[4], 10)
const mate = parseInt(matches[5], 10)
const pv = matches[6]
const move = pv.split(' ')[0]
if (!this.moves.includes(move)) {
return
}
// Handle mate values properly instead of converting to ±10000
let mateIn: number | undefined = undefined
if (!isNaN(mate) && isNaN(cp)) {
// For mate scores, use a very high cp value for sorting but keep mate info
mateIn = mate
cp = mate > 0 ? 10000 : -10000
}
/*
The Stockfish engine, by default, reports centipawn (CP) scores from White's perspective.
This means a positive CP indicates an advantage for White, while a negative CP indicates
an advantage for Black.
However, when it's Black's turn to move, we want to interpret the CP score from Black's
perspective. To achieve this, we invert the sign of the CP score when it's Black's turn.
This ensures that a positive CP always represents an advantage for the player whose turn it is.
For example:
- If Stockfish reports CP = 100 (White's advantage) and it's White's turn, we keep CP = 100.
- If Stockfish reports CP = 100 (White's advantage) and it's Black's turn, we change CP to -100, indicating that Black is at a disadvantage.
The same logic applies to mate values - they need to be adjusted for the current player's perspective.
*/
const board = new Chess(this.fen)
const isBlackTurn = board.turn() === 'b'
if (isBlackTurn) {
cp *= -1
if (mateIn !== undefined) {
mateIn *= -1
}
}
if (this.store[depth]) {
/*
The cp_relative_vec (centipawn relative vector) is calculated to determine how much worse or better a given move is compared to the engine's "optimal" move (model_move) at the same depth.
Because the centipawn score (cp) has already been flipped to be relative to the current player's perspective (positive is good for the current player),
we need to ensure that the comparison to the optimal move (model_optimal_cp) is done in a consistent manner.
Therefore, we also flip the sign of model_optimal_cp when it is black's turn, so that the relative value is calculated correctly.
For example:
- If the engine evaluates the optimal move as CP = 50 when it's Black's turn, model_optimal_cp will be -50 after the initial flip.
- To calculate the relative value of another move with CP = 20, we use modelOptimalCp - cp, which is (-50) - (-20) = 30
- This indicates that the move with CP = 20 is significantly worse than the optimal move from Black's perspective.
*/
this.store[depth].cp_vec[move] = cp
this.store[depth].cp_relative_vec[move] = isBlackTurn
? this.store[depth].model_optimal_cp - cp
: cp - this.store[depth].model_optimal_cp
const winrate = mateIn
? mateIn > 0
? 1.0
: 0.0
: cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
if (!this.store[depth].winrate_vec) {
this.store[depth].winrate_vec = {}
}
if (!this.store[depth].winrate_loss_vec) {
this.store[depth].winrate_loss_vec = {}
}
const winrateVec = this.store[depth].winrate_vec
if (winrateVec) {
winrateVec[move] = winrate
}
} else {
const winrate = mateIn
? mateIn > 0
? 1.0
: 0.0
: cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
this.store[depth] = {
depth: depth,
model_move: move,
model_optimal_cp: cp,
cp_vec: { [move]: cp },
cp_relative_vec: { [move]: 0 },
winrate_vec: { [move]: winrate },
winrate_loss_vec: { [move]: 0 },
mate_in: mateIn,
sent: false,
}
}
if (!this.store[depth].sent && multipv === this.legalMoveCount) {
let bestWinrate = -Infinity
const winrateVec = this.store[depth].winrate_vec
if (winrateVec) {
for (const m in winrateVec) {
const wr = winrateVec[m]
if (wr > bestWinrate) {
bestWinrate = wr
}
}
const winrateLossVec = this.store[depth].winrate_loss_vec
if (winrateLossVec) {
for (const m in winrateVec) {
winrateLossVec[m] = winrateVec[m] - bestWinrate
}
}
}
if (this.store[depth].winrate_vec) {
this.store[depth].winrate_vec = Object.fromEntries(
Object.entries(this.store[depth].winrate_vec || {}).sort(
([, a], [, b]) => b - a,
),
)
}
if (this.store[depth].winrate_loss_vec) {
this.store[depth].winrate_loss_vec = Object.fromEntries(
Object.entries(this.store[depth].winrate_loss_vec || {}).sort(
([, a], [, b]) => b - a,
),
)
}
// Check if position is checkmate (no legal moves and king in check)
const board = new Chess(this.fen)
this.store[depth].is_checkmate = board.inCheckmate()
this.store[depth].sent = true
if (this.evaluationResolver) {
this.evaluationResolver(this.store[depth])
this.evaluationResolver = null
this.evaluationRejecter = null
}
}
}
private onError(msg: string) {
console.error(msg)
if (this.evaluationRejecter) {
this.evaluationRejecter(msg)
this.evaluationResolver = null
this.evaluationRejecter = null
}
this.isEvaluating = false
}
stopEvaluation() {
if (this.isEvaluating) {
this.isEvaluating = false
this.sendMessage('stop')
}
}
}
const sharedWasmMemory = (lo: number, hi = 32767): WebAssembly.Memory => {
let shrink = 4 // 32767 -> 24576 -> 16384 -> 12288 -> 8192 -> 6144 -> etc
while (true) {
try {
return new WebAssembly.Memory({ shared: true, initial: lo, maximum: hi })
} catch (e) {
if (hi <= lo || !(e instanceof RangeError)) throw e
hi = Math.max(lo, Math.ceil(hi - hi / shrink))
shrink = shrink === 4 ? 3 : 4
}
}
}
const setupStockfish = (): Promise<StockfishWeb> => {
return new Promise<StockfishWeb>((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import('lila-stockfish-web/sf17-79.js').then((makeModule: any) => {
makeModule
.default({
wasmMemory: sharedWasmMemory(2560),
onError: (msg: string) => reject(new Error(msg)),
locateFile: (name: string) => `/stockfish/${name}`,
})
.then(async (instance: StockfishWeb) => {
// Load NNUE models before resolving
Promise.all([
fetch(`/stockfish/${instance.getRecommendedNnue(0)}`),
fetch(`/stockfish/${instance.getRecommendedNnue(1)}`),
])
.then((responses) => {
return Promise.all([
responses[0].arrayBuffer(),
responses[1].arrayBuffer(),
])
})
.then((buffers) => {
instance.setNnueBuffer(new Uint8Array(buffers[0]), 0)
instance.setNnueBuffer(new Uint8Array(buffers[1]), 1)
resolve(instance)
})
.catch((error) => {
console.error('Failed to load NNUE models:', error)
reject(error)
})
})
})
})
}
export default Engine