Files
lara-schach-live/server.py

338 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Lokaler Server für Lara Schachturnier
Serviert statische Dateien und bietet Stockfish-Analyse.
"""
import http.server
import socketserver
import os
import subprocess
import json
import re
import sys
import threading
import socket
import queue
PORT = int(os.environ.get("PORT", 8111))
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STOCKFISH_PATH = os.environ.get("STOCKFISH_PATH")
if not STOCKFISH_PATH:
win_path = os.path.join(BASE_DIR, "stockfish.exe")
if os.path.exists(win_path):
STOCKFISH_PATH = win_path
else:
STOCKFISH_PATH = "stockfish"
STOCKFISH_DEPTH = int(os.environ.get("STOCKFISH_DEPTH", 25))
_stockfish_lock = threading.Lock()
class StockfishEngine:
def __init__(self, path):
self.path = path
self.proc = None
self._output_queue = queue.Queue()
self._reader_thread = None
self._reader_alive = threading.Event()
self._cmd_lock = threading.Lock()
self._searching = False
def start(self):
if self.proc:
return
flags = 0
if sys.platform == "win32":
flags = subprocess.CREATE_NO_WINDOW
self.proc = subprocess.Popen(
[self.path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
creationflags=flags,
)
self._reader_alive.set()
self._reader_thread = threading.Thread(target=self._read_output, daemon=True)
self._reader_thread.start()
self._send("uci")
self._read_until("uciok")
self._send("isready")
self._read_until("readyok")
print(f"[STOCKFISH] Engine gestartet: {self.path}")
def _read_output(self):
while self._reader_alive.is_set():
try:
line = self.proc.stdout.readline()
except Exception:
break
if not line:
break
self._output_queue.put(line)
def _send(self, cmd):
with self._cmd_lock:
self.proc.stdin.write(cmd + "\n")
self.proc.stdin.flush()
def _read_until(self, marker):
while True:
try:
line = self._output_queue.get(timeout=5.0)
except queue.Empty:
return
if line.strip() == marker:
return
def _drain_queue(self):
"""Leert die Queue von allen pending Zeilen."""
drained = 0
while True:
try:
self._output_queue.get_nowait()
drained += 1
except queue.Empty:
break
if drained > 0:
print(f"[STOCKFISH] {drained} alte Zeilen verworfen")
def _stop_and_wait(self):
"""Stoppt laufende Suche und wartet auf bestmove."""
if not self._searching:
return
self._send("stop")
while True:
try:
line = self._output_queue.get(timeout=3.0).strip()
except queue.Empty:
# Stockfish antwortet nicht auf stop (z.B. weil Suche schon beendet)
self._searching = False
return
if line.startswith("bestmove"):
self._searching = False
return
def evaluate(self, fen):
# Alte Suche abbrechen und Queue leeren
self._stop_and_wait()
self._drain_queue()
self._send(f"position fen {fen}")
self._send(f"go depth {STOCKFISH_DEPTH} movetime 15000")
self._searching = True
score_cp = None
score_mate = None
bestmove = None
pv = None
last_depth = 0
while True:
try:
line = self._output_queue.get(timeout=1.0).strip()
except queue.Empty:
continue
if not line:
continue
if "score cp" in line:
m = re.search(r"score cp (-?\d+)", line)
if m:
score_cp = int(m.group(1))
if "score mate" in line:
m = re.search(r"score mate (-?\d+)", line)
if m:
score_mate = int(m.group(1))
if " pv " in line:
pv = line.split(" pv ", 1)[1]
depth_m = re.search(r"depth (\d+)", line)
if depth_m and score_cp is not None:
new_depth = int(depth_m.group(1))
if new_depth != last_depth:
last_depth = new_depth
yield {
"scoreCp": score_cp,
"scoreMate": score_mate,
"bestMove": None,
"pv": pv,
"depth": new_depth,
}
if line.startswith("bestmove"):
parts = line.split()
bestmove = parts[1] if len(parts) > 1 else None
self._searching = False
break
yield {
"scoreCp": score_cp,
"scoreMate": score_mate,
"bestMove": bestmove,
"pv": pv,
}
def stop(self):
self._reader_alive.clear()
if self.proc:
self.proc.terminate()
self.proc = None
_engine = StockfishEngine(STOCKFISH_PATH)
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/":
self.path = "/index.html"
filepath = os.path.normpath(os.path.join(BASE_DIR, self.path.lstrip("/")))
if not filepath.startswith(BASE_DIR):
self.send_response(403)
self.end_headers()
return
if os.path.isfile(filepath):
content_types = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}
ext = os.path.splitext(filepath)[1]
content_type = content_types.get(ext, "application/octet-stream")
with open(filepath, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(content)
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path == "/evaluate":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(body)
fen = data.get("fen", "")
except (json.JSONDecodeError, TypeError):
self._send_json({"error": "Invalid JSON"}, 400)
return
if not fen:
self._send_json({"error": "Missing fen"}, 400)
return
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.flush()
# TCP_NODELAY für sofortiges Senden
try:
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
def _write_chunk(chunk_data):
size = len(chunk_data)
self.wfile.write(f"{size:x}\r\n".encode())
self.wfile.write(chunk_data)
self.wfile.write(b"\r\n")
self.wfile.flush()
try:
with _stockfish_lock:
try:
_engine.start()
except FileNotFoundError:
_write_chunk(
json.dumps({"error": "Stockfish nicht gefunden"}).encode("utf-8")
)
return
for result in _engine.evaluate(fen):
try:
data = json.dumps(result).encode("utf-8") + b"\n"
_write_chunk(data)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
# Client hat Verbindung getrennt (neuer Zug angeklickt / Pfeiltasten)
break
# Abschluss-Chunk
_write_chunk(b"")
except Exception as e:
print(f"[STOCKFISH] Fehler: {e}")
try:
_write_chunk(
json.dumps({"error": str(e)}).encode("utf-8")
)
except Exception:
pass
else:
self._send_json({"error": "Not found"}, 404)
def _send_json(self, obj, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(json.dumps(obj).encode("utf-8"))
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {args[0]}")
def main():
print("=" * 50)
print(" Lara Kiesewetter - Live Schachturnier")
print("=" * 50)
print(f" Server laeuft auf: http://localhost:{PORT}")
if os.path.exists(STOCKFISH_PATH) or STOCKFISH_PATH == "stockfish":
print(f" Stockfish-Analyse aktiv (depth={STOCKFISH_DEPTH})")
else:
print(f" Stockfish nicht gefunden unter: {STOCKFISH_PATH}")
print(f" Druecke Ctrl+C zum Beenden")
print("=" * 50)
socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
print(f"\n[SERVER] Bereit für Anfragen\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[BYE] Server gestoppt.")
_engine.stop()
if __name__ == "__main__":
main()