const ENGINE_URL = `${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`; // A thin wrapper around Stockfish running in a Web Worker. Besides talking UCI it // enforces a minimum "thinking" time, so even when the engine answers instantly the // opponent still appears to sit and consider the move. 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.handshake(); } send(cmd) { this.worker.postMessage(cmd); } waitFor(token) { return new Promise((resolve) => { const fn = (line) => { if (line.includes(token)) { this.listeners = this.listeners.filter((l) => l !== fn); resolve(); } }; this.listeners.push(fn); }); } async handshake() { this.send("uci"); await this.waitFor("uciok"); this.send("isready"); await this.waitFor("readyok"); } async configure(p) { await this.ready; 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"); } // Returns a move in UCI form (e.g. "e2e4"), never before the personality's think // floor has passed. `legal` lets us occasionally throw a beginner's blunder. async chooseMove(fen, p, legal) { await this.ready; const started = performance.now(); const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); let move; if (legal.length && Math.random() < p.blunderChance) { move = legal[Math.floor(Math.random() * legal.length)]; } else { move = await this.search(fen, p); } 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(); 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")) { this.listeners = this.listeners.filter((l) => l !== fn); const best = line.split(" ")[1]; resolve(best === "(none)" ? null : pick(candidates, best, p.multipv)); } }; 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) { await this.ready; return new Promise((resolve) => { let cp = null; let mate = null; 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")) { this.listeners = this.listeners.filter((l) => l !== fn); resolve({ cp, mate }); } }; 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(); } } // With MultiPV the softer opponents don't always grab the single best line; they lean // toward it but sometimes take the second- or third-best, which feels more human. 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 sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }