import type { Personality } from "./personalities"; const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href; const WASM_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.wasm`, location.href).href; // A tiny worker that fetches the wasm as a plain binary and hands it to Stockfish // through Module.wasmBinary. That path ignores the response's MIME type, so a server // that serves .wasm as text/plain (a very common misconfig) can't break the engine. const BOOTSTRAP = `self.onmessage=function(e){var d=e.data||{};if(!d.__boot)return;` + `fetch(d.wasmUrl).then(function(r){return r.arrayBuffer();}).then(function(buf){` + `self.Module={wasmBinary:buf,locateFile:function(p){return p.slice(-5)==='.wasm'?d.wasmUrl:p;}};` + `try{importScripts(d.jsUrl);postMessage('__ready');}catch(err){postMessage('__err '+err);}` + `}).catch(function(err){postMessage('__err '+err);});};`; type Line = string; export class Engine { private worker: Worker; private ready: Promise; private listeners: ((line: Line) => void)[] = []; constructor() { const bootUrl = URL.createObjectURL(new Blob([BOOTSTRAP], { type: "text/javascript" })); this.worker = new Worker(bootUrl); 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(); } private send(cmd: string) { this.worker.postMessage(cmd); } private waitFor(token: string, timeout = 0): Promise { return new Promise((resolve, reject) => { let timer: ReturnType | undefined; const fn = (line: 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); } }); } private async boot() { this.worker.postMessage({ __boot: true, jsUrl: ENGINE_URL, wasmUrl: WASM_URL }); await this.waitFor("__ready", 20000); this.send("uci"); await this.waitFor("uciok", 20000); this.send("isready"); await this.waitFor("readyok", 20000); } async configure(p: Personality) { 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: string, p: Personality, legal: string[]): Promise { const started = performance.now(); const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); let move: string | null; 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; } private search(fen: string, p: Personality): Promise { return new Promise((resolve) => { const candidates = new Map(); let done = false; const finish = (m: string | null) => { if (done) return; done = true; clearTimeout(timer); this.listeners = this.listeners.filter((l) => l !== fn); resolve(m); }; const fn = (line: 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: string, movetime = 500): Promise<{ cp: number | null; mate: number | null }> { try { await this.ready; } catch { return { cp: null, mate: null }; } return new Promise((resolve) => { let cp: number | null = null; let mate: number | null = 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: 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: Map, best: string, multipv: number): string { 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: string[]): string | null { return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null; } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }