fix: Stockfish via npm-Paket statt Alpine apk

- stockfish als npm dependency (kein apk add nötig, portabel)
- Dockerfile: nur noch git via apk, kein stockfish
- docker-compose.yml: version-Zeile entfernt (veraltet)
- Controller auf npm-Package-API umgeschrieben (postMessage/onmessage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-13 10:23:33 +02:00
parent 7dac042d73
commit 50464981c2
4 changed files with 77 additions and 85 deletions

View File

@@ -1,5 +1,3 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
export interface StockfishLevel {
level: number;
name: string;
@@ -9,100 +7,98 @@ export interface StockfishLevel {
}
export const AI_LEVELS: StockfishLevel[] = [
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
{ level: 4, name: "Fortgeschritten",skillLevel: 14, depth: 10, moveTime: 1000 },
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
{ level: 4, name: "Fortgeschritten", skillLevel: 14, depth: 10, moveTime: 1000 },
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
];
type SFEngine = {
postMessage: (cmd: string) => void;
onmessage: ((line: string | { data: string }) => void) | null;
terminate?: () => void;
};
export class StockfishEngine {
private process: ChildProcessWithoutNullStreams | null = null;
private buffer = "";
private resolvers: Map<string, (move: string) => void> = new Map();
private engine: SFEngine | null = null;
private ready = false;
private resolvers: Array<(move: string) => void> = [];
private initPromise: Promise<void>;
constructor() {
this.start();
this.initPromise = this.init();
}
private start() {
// Try system stockfish first, then fallback to npm stockfish
const binary = process.env.STOCKFISH_PATH || "stockfish";
private async init() {
try {
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
} catch {
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
return;
}
// Dynamic import of the stockfish npm package
const { default: Stockfish } = await import("stockfish") as { default: () => SFEngine };
this.engine = Stockfish();
this.process.stdout.on("data", (data: Buffer) => {
this.buffer += data.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
for (const line of lines) {
this.handleLine(line.trim());
}
});
await new Promise<void>((resolve) => {
let uciOk = false;
let readyOk = false;
this.process.stderr.on("data", (data: Buffer) => {
console.error("Stockfish stderr:", data.toString());
});
this.engine!.onmessage = (event: string | { data: string }) => {
const line = typeof event === "string" ? event : event.data;
this.process.on("exit", (code) => {
console.log("Stockfish exited with code", code);
this.ready = false;
});
if (line === "uciok") {
uciOk = true;
this.engine!.postMessage("isready");
}
if (line === "readyok") {
readyOk = true;
this.ready = true;
resolve();
}
if (line.startsWith("bestmove ")) {
const parts = line.split(" ");
const move = parts[1];
const resolver = this.resolvers.shift();
if (resolver) resolver(move === "(none)" ? "" : move);
}
};
this.send("uci");
}
this.engine!.postMessage("uci");
private send(cmd: string) {
if (this.process?.stdin.writable) {
this.process.stdin.write(cmd + "\n");
}
}
// Timeout after 5s if engine doesn't respond
setTimeout(() => {
if (!readyOk) {
console.warn("Stockfish init timeout");
resolve();
}
}, 5000);
});
private handleLine(line: string) {
if (line === "uciok") {
this.ready = true;
this.send("isready");
}
if (line === "readyok") {
// engine ready
}
if (line.startsWith("bestmove ")) {
const parts = line.split(" ");
const move = parts[1];
// resolve any pending resolver
const [key] = this.resolvers.entries().next().value ?? [];
if (key) {
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
this.resolvers.delete(key);
}
console.log("Stockfish engine ready");
} catch (err) {
console.error("Failed to initialize Stockfish:", err);
}
}
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
await this.initPromise;
if (!this.engine || !this.ready) {
console.warn("Stockfish not ready");
return "";
}
return new Promise((resolve) => {
if (!this.process || !this.ready) {
resolve("");
return;
}
this.resolvers.push(resolve);
const id = `${Date.now()}-${Math.random()}`;
this.resolvers.set(id, resolve);
this.engine!.postMessage("ucinewgame");
this.engine!.postMessage(`setoption name Skill Level value ${level.skillLevel}`);
this.engine!.postMessage(`position fen ${fen}`);
this.engine!.postMessage(`go depth ${level.depth} movetime ${level.moveTime}`);
this.send("ucinewgame");
this.send(`setoption name Skill Level value ${level.skillLevel}`);
this.send(`position fen ${fen}`);
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
// Timeout safety
// Safety timeout
setTimeout(() => {
if (this.resolvers.has(id)) {
this.resolvers.delete(id);
const idx = this.resolvers.indexOf(resolve);
if (idx !== -1) {
this.resolvers.splice(idx, 1);
resolve("");
}
}, level.moveTime + 5000);
@@ -110,13 +106,12 @@ export class StockfishEngine {
}
destroy() {
this.send("quit");
this.process?.kill();
this.process = null;
this.engine?.terminate?.();
this.engine = null;
}
}
// Singleton engine instance
// Singleton
let engineInstance: StockfishEngine | null = null;
export const getEngine = (): StockfishEngine => {