From 50464981c2e3be49dfe606273c706c1189e31217 Mon Sep 17 00:00:00 2001 From: Michess Date: Mon, 13 Apr 2026 10:23:33 +0200 Subject: [PATCH] fix: Stockfish via npm-Paket statt Alpine apk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 8 +- docker-compose.yml | 4 - server/package.json | 1 + .../src/controllers/stockfish.controller.ts | 149 +++++++++--------- 4 files changed, 77 insertions(+), 85 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6e634e1..5c10f55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:lts-alpine3.20 -# Install stockfish and git -RUN apk update && apk add --no-cache stockfish git +# Install git only (stockfish via npm package) +RUN apk update && apk add --no-cache git ENV PNPM_HOME=/usr/local/bin @@ -12,9 +12,9 @@ COPY . . RUN corepack enable && \ corepack prepare pnpm@latest --activate && \ pnpm config set store-dir /opt/michess/.pnpm-store && \ - pnpm install --frozen-lockfile + pnpm install --no-frozen-lockfile -# Build both client and server +# Build server and client RUN pnpm build:server && pnpm build:client EXPOSE 3000 3001 diff --git a/docker-compose.yml b/docker-compose.yml index ae493c5..5274476 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: michess: build: . @@ -20,8 +18,6 @@ services: CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} # Admin setup: user with this email gets admin role on startup ADMIN_EMAIL: ${ADMIN_EMAIL:-} - # Stockfish binary path (stockfish is installed in the image) - STOCKFISH_PATH: stockfish # Git update path (used by admin panel update button) APP_DIR: /opt/michess ports: diff --git a/server/package.json b/server/package.json index 169b6ae..0c1d8b7 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "nanoid": "^5.0.7", "pg": "^8.12.0", "socket.io": "^4.7.5", + "stockfish": "^16.0.0", "xss": "^1.0.15" }, "devDependencies": { diff --git a/server/src/controllers/stockfish.controller.ts b/server/src/controllers/stockfish.controller.ts index 756c7bf..bfef8ef 100644 --- a/server/src/controllers/stockfish.controller.ts +++ b/server/src/controllers/stockfish.controller.ts @@ -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 void> = new Map(); + private engine: SFEngine | null = null; private ready = false; + private resolvers: Array<(move: string) => void> = []; + private initPromise: Promise; 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((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 { + 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 => {