Add Stockfish evaluation API endpoint

This commit is contained in:
stefan-kp
2025-12-03 21:30:39 +01:00
parent 3787696aa8
commit cab02cfefd
3 changed files with 179 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Stockfish API (v1)
## Welche Informationen brauchen wir?
Der bestehende Code nutzt Stockfish überall dort, wo Spielzüge validiert, bewertet oder nachträglich analysiert werden. Die zentralen Datenpunkte sind:
- **Best move & ponder**: Wird verwendet, um die Computerzüge auszuführen und verpasste Taktiken zu erkennen. Beispiel: Im laufenden Spiel wird der aus `bestMove` abgeleitete Zug sofort gespielt und für Taktikvergleiche gespeichert.【F:src/components/ChessGame.tsx†L448-L517】【F:src/lib/stockfish.ts†L1-L58】
- **Stellungsbewertung (score in Centipawns) & Mattdistanz**: Dient zur Bewertungsanzeige, Berechnung des CP-Verlusts und zum Speichern der Analysehistorie.【F:src/components/ChessGame.tsx†L486-L517】【F:src/app/analysis/page.tsx†L82-L113】
- **Suchtiefe**: Wird gespeichert, um die Qualität der Bewertung anzuzeigen und später wiederzugeben (z.B. in der Historie und bei Analysevergleichen).【F:src/lib/stockfish.ts†L1-L58】【F:src/components/ChessGame.tsx†L290-L305】
Damit lassen sich alle bestehenden Features abdecken: Live-Zugempfehlungen, Move-History mit CP-Loss, Taktik-Erkennung und die schrittweise Analyse importierter Partien.
## API-Endpoint
- **POST `/api/v1/stockfish`**
- **Body**: `{ fen: string, depth?: number, multiPV?: number }`
- `fen` obligatorisch, aktuelle Stellung.
- `depth` optional (Standard: 15), Suchtiefe für Stockfish.
- `multiPV` optional (Standard: 1), Anzahl der Varianten; aktuell wird die Hauptvariante zurückgegeben.
- **Response**: `{ evaluation: { bestMove: string, ponder: string | null, score: number, mate: number | null, depth: number } }`
- `score` und `mate` sind aus Weiß-Perspektive normalisiert, passend zum bestehenden Frontend-Verhalten.【F:src/lib/server/stockfishEngine.ts†L60-L97】
Der Endpunkt kapselt den Stockfish-Worker serverseitig und liefert exakt die Daten, die das Frontend heute schon für Züge, Taktikerkennung und Analyse benötigt. Damit kann die React-Native-App die gleiche Engine-Funktionalität per HTTP nutzen.
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { evaluateStockfish } from "@/lib/server/stockfishEngine";
import { StockfishEvaluation } from "@/lib/stockfish";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fen, depth = 15, multiPV = 1 } = body ?? {};
if (!fen || typeof fen !== "string") {
return NextResponse.json({ error: "Missing or invalid FEN" }, { status: 400 });
}
const parsedDepth = Number(depth);
const parsedMultiPV = Number(multiPV);
if (!Number.isFinite(parsedDepth) || parsedDepth <= 0) {
return NextResponse.json({ error: "Depth must be a positive number" }, { status: 400 });
}
if (!Number.isFinite(parsedMultiPV) || parsedMultiPV <= 0) {
return NextResponse.json({ error: "multiPV must be a positive number" }, { status: 400 });
}
const evaluation: StockfishEvaluation = await evaluateStockfish(fen, parsedDepth, parsedMultiPV);
return NextResponse.json({ evaluation });
} catch (error) {
console.error("Stockfish API error", error);
return NextResponse.json({ error: "Failed to evaluate position" }, { status: 500 });
}
}
+123
View File
@@ -0,0 +1,123 @@
import path from "path";
import { Worker } from "worker_threads";
import { StockfishEvaluation } from "../stockfish";
const WORKER_CODE = `
const { parentPort, workerData } = require('worker_threads');
const { enginePath } = workerData;
const emit = (msg) => parentPort.postMessage(msg);
global.postMessage = emit;
global.self = global;
global.window = global;
global.document = {};
global.close = () => parentPort.close();
let handler = null;
Object.defineProperty(global, 'onmessage', {
get() { return handler; },
set(fn) { handler = fn; }
});
require(enginePath);
parentPort.on('message', (data) => {
if (typeof handler === 'function') {
handler({ data });
}
});
`;
const ENGINE_PATH = path.resolve(process.cwd(), "node_modules/stockfish.js/stockfish.js");
const DEFAULT_TIMEOUT_MS = 15_000;
export async function evaluateStockfish(
fen: string,
depth: number = 15,
multiPV: number = 1,
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<StockfishEvaluation> {
return new Promise((resolve, reject) => {
const worker = new Worker(WORKER_CODE, {
eval: true,
workerData: { enginePath: ENGINE_PATH },
});
let lastScore = 0;
let lastMate: number | null = null;
let lastDepth = 0;
let resolved = false;
let timeout: NodeJS.Timeout | null = null;
const cleanup = () => {
if (timeout) clearTimeout(timeout);
worker.removeAllListeners();
worker.terminate().catch(() => undefined);
};
const onMessage = (msg: unknown) => {
if (typeof msg !== "string") return;
if (msg.startsWith("info depth")) {
const depthMatch = msg.match(/depth (\d+)/);
const scoreMatch = msg.match(/score cp (-?\d+)/);
const mateMatch = msg.match(/score mate (-?\d+)/);
if (depthMatch) lastDepth = parseInt(depthMatch[1], 10);
if (scoreMatch) {
lastScore = parseInt(scoreMatch[1], 10);
lastMate = null;
}
if (mateMatch) {
lastMate = parseInt(mateMatch[1], 10);
lastScore = 0;
}
}
if (msg.startsWith("bestmove")) {
const parts = msg.split(" ");
const bestMove = parts[1];
let ponder: string | null = null;
if (parts.length > 3 && parts[2] === "ponder") {
ponder = parts[3];
}
const evaluation: StockfishEvaluation = {
bestMove,
ponder,
score: lastScore,
mate: lastMate,
depth: lastDepth,
};
const sideToMove = fen.split(" ")[1];
if (sideToMove === "b") {
if (evaluation.score !== 0) evaluation.score = -evaluation.score;
if (evaluation.mate !== null && evaluation.mate !== 0) {
evaluation.mate = -evaluation.mate;
}
}
resolved = true;
cleanup();
resolve(evaluation);
}
};
worker.on("message", onMessage);
worker.on("error", (err) => {
if (resolved) return;
resolved = true;
cleanup();
reject(err);
});
timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
cleanup();
reject(new Error("Stockfish evaluation timed out"));
}, timeoutMs);
worker.postMessage("uci");
worker.postMessage("setoption name MultiPV value " + multiPV);
worker.postMessage("isready");
worker.postMessage(`position fen ${fen}`);
worker.postMessage(`go depth ${depth}`);
});
}