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:
@@ -1,7 +1,7 @@
|
|||||||
FROM node:lts-alpine3.20
|
FROM node:lts-alpine3.20
|
||||||
|
|
||||||
# Install stockfish and git
|
# Install git only (stockfish via npm package)
|
||||||
RUN apk update && apk add --no-cache stockfish git
|
RUN apk update && apk add --no-cache git
|
||||||
|
|
||||||
ENV PNPM_HOME=/usr/local/bin
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ COPY . .
|
|||||||
RUN corepack enable && \
|
RUN corepack enable && \
|
||||||
corepack prepare pnpm@latest --activate && \
|
corepack prepare pnpm@latest --activate && \
|
||||||
pnpm config set store-dir /opt/michess/.pnpm-store && \
|
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
|
RUN pnpm build:server && pnpm build:client
|
||||||
|
|
||||||
EXPOSE 3000 3001
|
EXPOSE 3000 3001
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
michess:
|
michess:
|
||||||
build: .
|
build: .
|
||||||
@@ -20,8 +18,6 @@ services:
|
|||||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
||||||
# Admin setup: user with this email gets admin role on startup
|
# Admin setup: user with this email gets admin role on startup
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
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)
|
# Git update path (used by admin panel update button)
|
||||||
APP_DIR: /opt/michess
|
APP_DIR: /opt/michess
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
|
"stockfish": "^16.0.0",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
|
||||||
|
|
||||||
export interface StockfishLevel {
|
export interface StockfishLevel {
|
||||||
level: number;
|
level: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,92 +15,90 @@ export const AI_LEVELS: StockfishLevel[] = [
|
|||||||
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
|
{ 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 {
|
export class StockfishEngine {
|
||||||
private process: ChildProcessWithoutNullStreams | null = null;
|
private engine: SFEngine | null = null;
|
||||||
private buffer = "";
|
|
||||||
private resolvers: Map<string, (move: string) => void> = new Map();
|
|
||||||
private ready = false;
|
private ready = false;
|
||||||
|
private resolvers: Array<(move: string) => void> = [];
|
||||||
|
private initPromise: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.start();
|
this.initPromise = this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private start() {
|
private async init() {
|
||||||
// Try system stockfish first, then fallback to npm stockfish
|
|
||||||
const binary = process.env.STOCKFISH_PATH || "stockfish";
|
|
||||||
try {
|
try {
|
||||||
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
|
// Dynamic import of the stockfish npm package
|
||||||
} catch {
|
const { default: Stockfish } = await import("stockfish") as { default: () => SFEngine };
|
||||||
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
|
this.engine = Stockfish();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.process.stdout.on("data", (data: Buffer) => {
|
await new Promise<void>((resolve) => {
|
||||||
this.buffer += data.toString();
|
let uciOk = false;
|
||||||
const lines = this.buffer.split("\n");
|
let readyOk = false;
|
||||||
this.buffer = lines.pop() || "";
|
|
||||||
for (const line of lines) {
|
|
||||||
this.handleLine(line.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process.stderr.on("data", (data: Buffer) => {
|
this.engine!.onmessage = (event: string | { data: string }) => {
|
||||||
console.error("Stockfish stderr:", data.toString());
|
const line = typeof event === "string" ? event : event.data;
|
||||||
});
|
|
||||||
|
|
||||||
this.process.on("exit", (code) => {
|
|
||||||
console.log("Stockfish exited with code", code);
|
|
||||||
this.ready = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.send("uci");
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(cmd: string) {
|
|
||||||
if (this.process?.stdin.writable) {
|
|
||||||
this.process.stdin.write(cmd + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleLine(line: string) {
|
|
||||||
if (line === "uciok") {
|
if (line === "uciok") {
|
||||||
this.ready = true;
|
uciOk = true;
|
||||||
this.send("isready");
|
this.engine!.postMessage("isready");
|
||||||
}
|
}
|
||||||
if (line === "readyok") {
|
if (line === "readyok") {
|
||||||
// engine ready
|
readyOk = true;
|
||||||
|
this.ready = true;
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
if (line.startsWith("bestmove ")) {
|
if (line.startsWith("bestmove ")) {
|
||||||
const parts = line.split(" ");
|
const parts = line.split(" ");
|
||||||
const move = parts[1];
|
const move = parts[1];
|
||||||
// resolve any pending resolver
|
const resolver = this.resolvers.shift();
|
||||||
const [key] = this.resolvers.entries().next().value ?? [];
|
if (resolver) resolver(move === "(none)" ? "" : move);
|
||||||
if (key) {
|
|
||||||
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
|
|
||||||
this.resolvers.delete(key);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.engine!.postMessage("uci");
|
||||||
|
|
||||||
|
// Timeout after 5s if engine doesn't respond
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!readyOk) {
|
||||||
|
console.warn("Stockfish init timeout");
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Stockfish engine ready");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to initialize Stockfish:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
|
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
await this.initPromise;
|
||||||
if (!this.process || !this.ready) {
|
|
||||||
resolve("");
|
if (!this.engine || !this.ready) {
|
||||||
return;
|
console.warn("Stockfish not ready");
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = `${Date.now()}-${Math.random()}`;
|
return new Promise((resolve) => {
|
||||||
this.resolvers.set(id, resolve);
|
this.resolvers.push(resolve);
|
||||||
|
|
||||||
this.send("ucinewgame");
|
this.engine!.postMessage("ucinewgame");
|
||||||
this.send(`setoption name Skill Level value ${level.skillLevel}`);
|
this.engine!.postMessage(`setoption name Skill Level value ${level.skillLevel}`);
|
||||||
this.send(`position fen ${fen}`);
|
this.engine!.postMessage(`position fen ${fen}`);
|
||||||
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
|
this.engine!.postMessage(`go depth ${level.depth} movetime ${level.moveTime}`);
|
||||||
|
|
||||||
// Timeout safety
|
// Safety timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.resolvers.has(id)) {
|
const idx = this.resolvers.indexOf(resolve);
|
||||||
this.resolvers.delete(id);
|
if (idx !== -1) {
|
||||||
|
this.resolvers.splice(idx, 1);
|
||||||
resolve("");
|
resolve("");
|
||||||
}
|
}
|
||||||
}, level.moveTime + 5000);
|
}, level.moveTime + 5000);
|
||||||
@@ -110,13 +106,12 @@ export class StockfishEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.send("quit");
|
this.engine?.terminate?.();
|
||||||
this.process?.kill();
|
this.engine = null;
|
||||||
this.process = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton engine instance
|
// Singleton
|
||||||
let engineInstance: StockfishEngine | null = null;
|
let engineInstance: StockfishEngine | null = null;
|
||||||
|
|
||||||
export const getEngine = (): StockfishEngine => {
|
export const getEngine = (): StockfishEngine => {
|
||||||
|
|||||||
Reference in New Issue
Block a user