mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 17:03:08 +02:00
- R0914 (too many locals): Extract helper functions in generate_jpeg.py, engine.py, lichess_api.py, main.py - R0902 (too many instance attributes): Use dataclasses in keyboard_coop - W0621 (redefined outer name): Rename parameters/variables to avoid shadowing - W0201 (attribute outside init): Initialize all attrs in __init__ - R1705 (no-else-return): Remove unnecessary else after return - C1805 (implicit booleaness): Use implicit boolean checks - R1732 (consider-using-with): Use context manager for subprocess.Popen - E0401 (import-error): Add pylint disable for optional deps (selenium, mitmproxy) - Clean up pyproject.toml: update comments, remove redundant settings Pylint score: 10.00/10
167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
"""Chess engine wrapper for the C-based random/scoring engine."""
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
import chess
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RandomEngine:
|
|
"""Thin wrapper around the C engine in C/lichess_random_engine/random_engine.
|
|
|
|
Contract:
|
|
- Given a chess.Board, call the C binary with all legal moves encoded as
|
|
UCI (with optional annotations in the future). The binary prints the
|
|
chosen move's UCI on stdout (or JSON when --explain, which we don't need).
|
|
- We do not compute or rank anything in Python; we just pass through moves
|
|
and play exactly what the engine returns.
|
|
- If the binary is missing or returns an invalid/illegal move, raise.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
engine_path: str | None = None,
|
|
max_time_sec: float = 2.0,
|
|
depth: int | None = None,
|
|
) -> None:
|
|
"""Initialize the engine wrapper with path and time settings."""
|
|
self.max_time_sec = max_time_sec
|
|
# depth is accepted for compatibility with existing callers but is unused;
|
|
# the C engine handles its own scoring/selection.
|
|
self.depth = depth
|
|
# Default relative path inside this repo
|
|
default_path = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "C"
|
|
/ "lichess_random_engine"
|
|
/ "random_engine"
|
|
)
|
|
self.engine_path = Path(engine_path) if engine_path else default_path
|
|
if not self.engine_path.is_file() or not os.access(self.engine_path, os.X_OK):
|
|
msg = (
|
|
f"C engine not found or not executable at '{self.engine_path}'. "
|
|
"Build it first (make -C C/lichess_random_engine)."
|
|
)
|
|
raise FileNotFoundError(msg)
|
|
|
|
def _call_engine(self, args: list[str], *, timeout: float) -> str:
|
|
# S603: subprocess call is safe - engine_path is validated in __init__
|
|
# with is_file() and X_OK permission check, args are explicit strings
|
|
try:
|
|
proc = subprocess.run(
|
|
[self.engine_path, *args],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=True,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
stderr = (e.stderr or "").strip()
|
|
msg = f"C engine failed: {stderr or e}"
|
|
raise RuntimeError(msg) from e
|
|
except subprocess.TimeoutExpired as e:
|
|
msg = "C engine timed out"
|
|
raise TimeoutError(msg) from e
|
|
return (proc.stdout or "").strip()
|
|
|
|
def choose_move(self, board: chess.Board) -> chess.Move:
|
|
"""Choose a move for the given board position."""
|
|
mv, _ = self.choose_move_with_explanation(
|
|
board, time_budget_sec=self.max_time_sec
|
|
)
|
|
return mv
|
|
|
|
def choose_move_with_explanation(
|
|
self, board: chess.Board, *, time_budget_sec: float
|
|
) -> tuple[chess.Move | None, str]:
|
|
"""Choose a move and return explanation for the decision."""
|
|
# Collect legal moves and send to engine as plain UCI tokens.
|
|
legal = list(board.legal_moves)
|
|
if not legal:
|
|
return None, "no_legal_moves"
|
|
|
|
args = ["--fen", board.fen()] + [m.uci() for m in legal]
|
|
# Optionally pass a seed for reproducibility when desired;
|
|
# keep default behavior otherwise.
|
|
# We deliberately avoid adding annotations here per request.
|
|
|
|
output = self._call_engine(args, timeout=max(0.1, time_budget_sec))
|
|
|
|
# The engine, without --explain, should print the chosen UCI.
|
|
chosen_uci = output.splitlines()[-1].strip() if output else ""
|
|
try:
|
|
move = chess.Move.from_uci(chosen_uci)
|
|
except ValueError:
|
|
msg = f"Engine returned invalid move: '{chosen_uci}' (output: {output!r})"
|
|
raise RuntimeError(msg) from None
|
|
|
|
if move not in board.legal_moves:
|
|
msg = f"Engine returned illegal move for position: {chosen_uci}"
|
|
raise RuntimeError(msg)
|
|
|
|
return move, "from_c_engine"
|
|
|
|
def _parse_engine_analysis(
|
|
self, out: str, legal_moves: list[chess.Move]
|
|
) -> tuple[float, str, chess.Move | None, str]:
|
|
"""Parse JSON output from engine analysis.
|
|
|
|
Returns (candidate_score, candidate_expl, best_move, best_expl).
|
|
"""
|
|
cand_score = 0.0
|
|
best_move: chess.Move | None = None
|
|
cand_expl = out
|
|
best_expl = out
|
|
|
|
try:
|
|
data = json.loads(out)
|
|
analyze = data.get("analyze") or {}
|
|
cs = analyze.get("candidate_score")
|
|
if isinstance(cs, int | float):
|
|
cand_score = float(cs)
|
|
chosen = data.get("chosen_move")
|
|
if isinstance(chosen, str):
|
|
with contextlib.suppress(Exception):
|
|
bm = chess.Move.from_uci(chosen)
|
|
if bm in legal_moves:
|
|
best_move = bm
|
|
cand_expl = json.dumps(analyze, ensure_ascii=False)
|
|
best_expl = json.dumps(
|
|
{"chosen_index": data.get("chosen_index"), "chosen_move": chosen},
|
|
ensure_ascii=False,
|
|
)
|
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
_logger.debug("Failed to parse engine JSON output")
|
|
|
|
return cand_score, cand_expl, best_move, best_expl
|
|
|
|
def evaluate_proposed_move_with_suggestion(
|
|
self,
|
|
board: chess.Board,
|
|
proposed_move_uci: str,
|
|
*,
|
|
time_budget_sec: float,
|
|
) -> tuple[float, str, chess.Move | None, str]:
|
|
"""Ask the C engine to explain and analyze a specific candidate.
|
|
|
|
Returns (candidate_score, candidate_expl, best_move, best_expl)
|
|
where explanations are concise JSON snippets from the engine. All logic is
|
|
delegated to the C binary; no scoring is done in Python.
|
|
"""
|
|
legal = list(board.legal_moves)
|
|
if not legal:
|
|
return 0.0, "no_legal_moves", None, "no_best_move"
|
|
|
|
args = ["--fen", board.fen(), "--explain", "--analyze", proposed_move_uci] + [
|
|
m.uci() for m in legal
|
|
]
|
|
out = self._call_engine(args, timeout=max(0.1, time_budget_sec))
|
|
return self._parse_engine_analysis(out, legal)
|