diff --git a/docs/stockfish_api.md b/docs/stockfish_api.md new file mode 100644 index 0000000..710279e --- /dev/null +++ b/docs/stockfish_api.md @@ -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. diff --git a/src/app/api/v1/stockfish/route.ts b/src/app/api/v1/stockfish/route.ts new file mode 100644 index 0000000..9952cb0 --- /dev/null +++ b/src/app/api/v1/stockfish/route.ts @@ -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 }); + } +} diff --git a/src/lib/server/stockfishEngine.ts b/src/lib/server/stockfishEngine.ts new file mode 100644 index 0000000..98cac5f --- /dev/null +++ b/src/lib/server/stockfishEngine.ts @@ -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 { + 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}`); + }); +}