// SPDX-License-Identifier: GPL-3.0-or-later // // Gamengager - Stockfish 연결 코드 (Stockfish UCI connector) // Copyright (C) 2026 Gamengager // // This program is free software: you can redistribute it and/or modify it under the terms of the // GNU General Public License as published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. This program is distributed in the hope that it // will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details: // https://www.gnu.org/licenses/gpl-3.0.html // // 원본 그대로 배포하는 Stockfish(별도 파일, Web Worker)와 표준 UCI 텍스트 명령만 주고받는다. export const ENGINE_URL = "/engines/stockfish/stockfish-19-lite-single.js"; export interface EngineLevel { skill: number; movetimeMs: number; } const INIT_TIMEOUT_MS = 20_000; export class StockfishEngine { private worker: Worker | null = null; private listeners = new Set<(line: string) => void>(); private ready: Promise | null = null; private currentSkill = -1; /** 엔진을 불러와 UCI 준비가 끝날 때까지 기다린다. 이미 불렀으면 그대로 돌려준다. */ load(): Promise { if (this.ready) return this.ready; this.ready = new Promise((resolve, reject) => { let worker: Worker; try { worker = new Worker(ENGINE_URL); } catch (e) { reject(e); return; } this.worker = worker; const timer = setTimeout(() => fail(new Error("engine init timeout")), INIT_TIMEOUT_MS); const fail = (err: unknown) => { clearTimeout(timer); this.dispose(); reject(err); }; worker.onmessage = (e: MessageEvent) => { const line = typeof e.data === "string" ? e.data : String(e.data); for (const fn of this.listeners) fn(line); }; worker.onerror = (e) => fail(e); this.waitFor("uciok") .then(() => { this.send("setoption name UCI_LimitStrength value false"); this.send("isready"); return this.waitFor("readyok"); }) .then(() => { clearTimeout(timer); resolve(); }, fail); this.send("uci"); }); // 실패하면 다음 load()에서 다시 시도할 수 있게 한다 this.ready.catch(() => { this.ready = null; }); return this.ready; } /** FEN 국면에서 최선 수를 UCI 표기(e2e4, e7e8q)로 돌려준다. 둘 수 없으면 null. */ async bestMove(fen: string, level: EngineLevel): Promise { await this.load(); // 멈춘 이전 계산의 bestmove가 새 계산의 답으로 섞이지 않도록 먼저 끝나기를 기다린다 if (this.searching) await this.searching; if (level.skill !== this.currentSkill) { this.send(`setoption name Skill Level value ${level.skill}`); this.currentSkill = level.skill; } this.send("ucinewgame"); this.send(`position fen ${fen}`); const line = this.waitFor("bestmove"); this.searching = line; this.send(`go movetime ${level.movetimeMs}`); const result = await line; if (this.searching === line) this.searching = null; const move = result.split(/\s+/)[1]; return !move || move === "(none)" ? null : move; } private searching: Promise | null = null; /** 계산 중인 수를 멈춘다 (결과는 버린다). */ stop(): void { this.send("stop"); } dispose(): void { this.worker?.terminate(); this.worker = null; this.listeners.clear(); this.ready = null; this.searching = null; this.currentSkill = -1; } private send(command: string): void { this.worker?.postMessage(command); } /** 주어진 단어로 시작하는 줄이 올 때까지 기다린다. */ private waitFor(prefix: string): Promise { return new Promise((resolve) => { const fn = (line: string) => { if (line.startsWith(prefix)) { this.listeners.delete(fn); resolve(line); } }; this.listeners.add(fn); }); } }