// A real same-origin URL (not a blob), so Stockfish resolves its .wasm next to itself. // The server must send the .wasm as application/wasm. const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href; export class Engine { constructor() { this.listeners = []; this.worker = new Worker(ENGINE_URL); this.worker.onmessage = (e) => { const line = typeof e.data === "string" ? e.data : String(e.data?.data ?? ""); for (const fn of this.listeners) fn(line); }; this.ready = this.boot(); } send(cmd) { this.worker.postMessage(cmd); } waitFor(token, timeout = 0) { return new Promise((resolve, reject) => { let timer; const fn = (line) => { if (line.includes(token)) { this.listeners = this.listeners.filter((l) => l !== fn); if (timer) clearTimeout(timer); resolve(); } }; this.listeners.push(fn); if (timeout > 0) { timer = setTimeout(() => { this.listeners = this.listeners.filter((l) => l !== fn); reject(new Error("engine timeout waiting for " + token)); }, timeout); } }); } async boot() { this.send("uci"); await this.waitFor("uciok", 20000); this.send("isready"); await this.waitFor("readyok", 20000); } async configure(p) { try { await this.ready; } catch { return; } this.send("setoption name Skill Level value " + p.skill); this.send("setoption name MultiPV value " + Math.max(1, p.multipv)); this.send("isready"); await this.waitFor("readyok", 8000).catch(() => { }); } // Returns a move in UCI form, never before the personality's think floor. Falls back // to a legal move if the engine is unavailable, so the game can never hang. async chooseMove(fen, p, legal) { const started = performance.now(); const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); let move; let alive = true; try { await this.ready; } catch { alive = false; } if (!alive) { move = randomOf(legal); } else if (legal.length && Math.random() < p.blunderChance) { move = randomOf(legal); } else { move = (await this.search(fen, p)) ?? randomOf(legal); } const elapsed = performance.now() - started; if (elapsed < floor) await sleep(floor - elapsed); return move; } search(fen, p) { return new Promise((resolve) => { const candidates = new Map(); let done = false; const finish = (m) => { if (done) return; done = true; clearTimeout(timer); this.listeners = this.listeners.filter((l) => l !== fn); resolve(m); }; const fn = (line) => { if (line.startsWith("info")) { const pv = line.match(/multipv (\d+).*? pv (\w+)/); if (pv) candidates.set(Number(pv[1]), pv[2]); } else if (line.startsWith("bestmove")) { const best = line.split(" ")[1]; finish(best === "(none)" ? null : pick(candidates, best, p.multipv)); } }; const timer = setTimeout(() => finish(null), p.movetime + 4000); this.listeners.push(fn); this.send("setoption name Skill Level value " + p.skill); this.send("setoption name MultiPV value " + Math.max(1, p.multipv)); this.send("position fen " + fen); this.send("go movetime " + p.movetime); }); } // Full-strength evaluation for the coach, from the side-to-move's point of view. async evaluate(fen, movetime = 500) { try { await this.ready; } catch { return { cp: null, mate: null }; } return new Promise((resolve) => { let cp = null; let mate = null; let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timer); this.listeners = this.listeners.filter((l) => l !== fn); resolve({ cp, mate }); }; const fn = (line) => { if (line.startsWith("info")) { const s = line.match(/score (cp|mate) (-?\d+)/); if (s) { if (s[1] === "cp") { cp = Number(s[2]); mate = null; } else { mate = Number(s[2]); cp = null; } } } else if (line.startsWith("bestmove")) { finish(); } }; const timer = setTimeout(finish, movetime + 4000); this.listeners.push(fn); this.send("setoption name Skill Level value 20"); this.send("setoption name MultiPV value 1"); this.send("position fen " + fen); this.send("go movetime " + movetime); }); } destroy() { this.send("quit"); this.worker.terminate(); } } function pick(candidates, best, multipv) { if (multipv <= 1 || candidates.size <= 1) return best; const weights = [0.62, 0.26, 0.09, 0.03]; const roll = Math.random(); let acc = 0; for (let i = 1; i <= Math.min(multipv, candidates.size); i++) { acc += weights[i - 1] ?? 0.02; if (roll < acc && candidates.has(i)) return candidates.get(i); } return best; } function randomOf(moves) { return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }