mirror of
https://github.com/StewBC/cc65-Chess.git
synced 2026-10-24 10:02:24 +00:00
844 lines
26 KiB
Python
844 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Run cc65-Chess UCI against Apple II Sargon II under windowed a2m.
|
|
|
|
See sargon/README.md for the emulator choreography, screen-format quirks,
|
|
calibration procedure, artifact layout, and recovery notes.
|
|
|
|
The repository carries the known-good 143,360-byte DOS 3.3 image beside this
|
|
script as Sargon-trimmed.dsk. The harness validates it and mounts a disposable
|
|
working copy; it never depends on or modifies an external source disk.
|
|
|
|
Boot sequence for the compilation disk is deliberately exact:
|
|
|
|
R, H, wait through the transient copy-disk screen, G RETURN
|
|
|
|
R and H are single-key catalog commands. RETURN while the loader is still on
|
|
the transient screen drops into the monitor, so all later input is gated on
|
|
the text-page contents.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Optional
|
|
|
|
import chess
|
|
import chess.pgn
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
A2M_REPO = REPO.parent / "a2m"
|
|
A2M_BIN = A2M_REPO / "build" / "a2m"
|
|
A2M_TOOLS = A2M_REPO / "tools"
|
|
UCI_BIN = REPO / "tests" / "uci"
|
|
SARGON_DISK = Path(__file__).with_name("Sargon-trimmed.dsk")
|
|
WORK_DISK = Path("/private/tmp/cc65-sargon-match.dsk")
|
|
DOS33_SIZE = 35 * 16 * 256
|
|
DEFAULT_PORT = 6511
|
|
MAX_PLIES = 300
|
|
# How long Sargon is given for one move. 120s is ample at level 1; the deeper
|
|
# levels are a different animal - the manual has level 6 searching several ply
|
|
# and "automatically more deeply" in the opening and the endgame, and a search
|
|
# that overruns this is scored as a bridge failure and retried rather than
|
|
# waited for, which loses the game rather than the move. --move-timeout
|
|
MOVE_TIMEOUT = 120.0
|
|
SCREEN_TIMEOUT = 60.0
|
|
KEY_DELAY = 0.025
|
|
CC65_SKILLS = {
|
|
1: ("Very Easy", 400),
|
|
2: ("Easy", 1200),
|
|
3: ("Harder", 18000),
|
|
4: ("Very Hard", 65000),
|
|
}
|
|
|
|
sys.path.insert(0, str(A2M_TOOLS))
|
|
from a2m_control_client import Ctl # noqa: E402
|
|
|
|
|
|
COORD_RE = re.compile(r"^[A-H][1-8][-X][A-H][1-8][+#]?$", re.IGNORECASE)
|
|
# Sargon stores a two-character decimal move number without a hundreds digit.
|
|
# At 100 the tens character therefore advances from "9" to ":", then ";" at
|
|
# 110, through "?" at 150 (our 300-ply safety cap).
|
|
MOVE_NUMBER_RE = re.compile(r"^\s*([0-9:;<=>?]{1,2})\s")
|
|
|
|
|
|
class BridgeError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def decode_move_number(token: str) -> int:
|
|
value = 0
|
|
for char in token:
|
|
digit = ord(char) - ord("0")
|
|
if not 0 <= digit <= 15:
|
|
raise BridgeError(f"bad Sargon move-number character {char!r}")
|
|
value = value * 10 + digit
|
|
return value
|
|
|
|
|
|
@dataclass
|
|
class GameResult:
|
|
index: int
|
|
cc_color: str
|
|
result: str
|
|
termination: str
|
|
plies: int
|
|
duration: float
|
|
moves: list[str]
|
|
opening: str
|
|
|
|
@property
|
|
def cc_score(self) -> float:
|
|
if self.result == "1/2-1/2":
|
|
return 0.5
|
|
cc_won = (self.result == "1-0" and self.cc_color == "white") or (
|
|
self.result == "0-1" and self.cc_color == "black"
|
|
)
|
|
return 1.0 if cc_won else 0.0
|
|
|
|
|
|
def log(message: str) -> None:
|
|
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
print(f"[{stamp}] {message}", flush=True)
|
|
|
|
|
|
def prepare_disk() -> None:
|
|
if not SARGON_DISK.is_file():
|
|
# the image is deliberately not in the repository - see sargon/README.md
|
|
raise BridgeError(
|
|
f"Sargon disk image not found at {SARGON_DISK}. It is not shipped "
|
|
f"with this repository; sargon/README.md gives the exact size and "
|
|
f"SHA-256 the harness expects."
|
|
)
|
|
if SARGON_DISK.stat().st_size != DOS33_SIZE:
|
|
raise BridgeError(
|
|
f"trimmed Sargon disk is {SARGON_DISK.stat().st_size} bytes; "
|
|
f"expected exactly {DOS33_SIZE}"
|
|
)
|
|
# Mount a disposable working copy in case an emulator or guest ever writes
|
|
# to the disk. The repository fixture remains the authoritative input.
|
|
shutil.copyfile(SARGON_DISK, WORK_DISK)
|
|
log(f"prepared Sargon working disk from repository fixture: {WORK_DISK}")
|
|
|
|
|
|
class UCIEngine:
|
|
def __init__(self, skill: int = 1, own_book: bool = True):
|
|
self.skill = skill
|
|
self.own_book = own_book
|
|
self.book_seed = 1
|
|
self.proc = subprocess.Popen(
|
|
[str(UCI_BIN)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
self._send("uci")
|
|
self._read_until("uciok")
|
|
self._send(f"setoption name Skill value {skill}")
|
|
self._send("isready")
|
|
self._read_until("readyok")
|
|
|
|
def set_book_seed(self, seed: int) -> None:
|
|
"""The game seeds its randomiser from a free-running counter; this is
|
|
that byte. It picks which table entry comes up and, for the first few
|
|
searches, perturbs move ordering among exact ties - both of which the
|
|
shipping front end does and neither of which UCI does by default."""
|
|
self.book_seed = seed & 0xFF
|
|
|
|
def _send(self, line: str) -> None:
|
|
if self.proc.stdin is None:
|
|
raise BridgeError("UCI stdin is closed")
|
|
self.proc.stdin.write(line + "\n")
|
|
self.proc.stdin.flush()
|
|
|
|
def _read_until(self, prefix: str) -> str:
|
|
if self.proc.stdout is None:
|
|
raise BridgeError("UCI stdout is closed")
|
|
for line in self.proc.stdout:
|
|
line = line.strip()
|
|
if line.startswith(prefix):
|
|
return line
|
|
raise BridgeError(f"UCI engine exited while waiting for {prefix!r}")
|
|
|
|
def new_game(self) -> None:
|
|
self._send("ucinewgame")
|
|
self._send(f"setoption name Skill value {self.skill}")
|
|
self._send(f"setoption name OwnBook value {'true' if self.own_book else 'false'}")
|
|
self._send(f"setoption name BookSeed value {self.book_seed}")
|
|
self._send("isready")
|
|
self._read_until("readyok")
|
|
|
|
def best_move(self, moves: Iterable[str]) -> str:
|
|
move_text = " ".join(moves)
|
|
cmd = "position startpos"
|
|
if move_text:
|
|
cmd += " moves " + move_text
|
|
self._send(cmd)
|
|
self._send("go")
|
|
line = self._read_until("bestmove ")
|
|
move = line.split()[1]
|
|
if move == "0000":
|
|
raise BridgeError("cc65 UCI returned no move in a live position")
|
|
return move
|
|
|
|
def close(self) -> None:
|
|
if self.proc.poll() is None:
|
|
try:
|
|
self._send("quit")
|
|
except (BrokenPipeError, BridgeError):
|
|
pass
|
|
try:
|
|
self.proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.terminate()
|
|
|
|
|
|
class SargonEmulator:
|
|
def __init__(self, port: int):
|
|
self.port = port
|
|
self.proc: Optional[subprocess.Popen] = None
|
|
self.ctl: Optional[Ctl] = None
|
|
|
|
def start(self) -> None:
|
|
if self.proc is not None:
|
|
self.close()
|
|
self.proc = subprocess.Popen(
|
|
[
|
|
str(A2M_BIN),
|
|
"--noini",
|
|
"--disk",
|
|
str(WORK_DISK),
|
|
"--control-port",
|
|
str(self.port),
|
|
],
|
|
cwd=A2M_REPO,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
if self.proc.poll() is not None:
|
|
raise BridgeError(f"a2m exited during launch: {self.proc.returncode}")
|
|
try:
|
|
self.ctl = Ctl(port=self.port, timeout=60)
|
|
break
|
|
except (ConnectionRefusedError, OSError):
|
|
time.sleep(0.1)
|
|
else:
|
|
raise BridgeError("timed out connecting to a2m control port")
|
|
self.wait_text("SARGON ][", SCREEN_TIMEOUT)
|
|
self.key("R")
|
|
time.sleep(0.15)
|
|
self.key("H")
|
|
self.wait_text("NEW GAME", SCREEN_TIMEOUT)
|
|
self._ok("set-turbo max")
|
|
log("Sargon booted; control reset returns to this reusable New Game state")
|
|
|
|
def _ok(self, command: str) -> str:
|
|
if self.ctl is None:
|
|
raise BridgeError("emulator control client is not connected")
|
|
result = self.ctl.cmd(command)
|
|
if result[0] != "ok":
|
|
raise BridgeError(f"{command!r} failed: {result}")
|
|
return result[1]
|
|
|
|
def screen(self) -> list[str]:
|
|
if self.ctl is None:
|
|
raise BridgeError("emulator control client is not connected")
|
|
memory = self.ctl.mem(0x400, 0x400)
|
|
lines = []
|
|
for row in range(24):
|
|
address = 0x400 + (row % 8) * 0x80 + (row // 8) * 0x28
|
|
chars = []
|
|
for value in memory[address - 0x400 : address - 0x400 + 40]:
|
|
code = value & 0x7F
|
|
if code < 0x20:
|
|
code += 0x40
|
|
chars.append(chr(code))
|
|
lines.append("".join(chars))
|
|
return lines
|
|
|
|
def wait_text(self, text: str, timeout: float) -> list[str]:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
lines = self.screen()
|
|
if text in "\n".join(lines):
|
|
return lines
|
|
time.sleep(0.05)
|
|
raise BridgeError(f"timed out waiting for screen text {text!r}")
|
|
|
|
def wait_state(self, wanted: str, timeout: float) -> str:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
state = self._ok("get-state")
|
|
if f"state={wanted}" in state:
|
|
return state
|
|
time.sleep(0.025)
|
|
raise BridgeError(f"timed out waiting for emulator state {wanted!r}")
|
|
|
|
def key(self, char: str) -> None:
|
|
if len(char) != 1:
|
|
raise ValueError("key() requires one character")
|
|
self._ok(f"key {ord(char)}")
|
|
|
|
def type_text(self, text: str, delay: float = KEY_DELAY) -> None:
|
|
for char in text:
|
|
self.key(char)
|
|
time.sleep(delay)
|
|
|
|
def restore_ready(self) -> None:
|
|
self._ok("reset")
|
|
self.wait_text("NEW GAME", 5)
|
|
self._ok("set-turbo max")
|
|
|
|
def begin_game(
|
|
self, cc_color: chess.Color, level: int, seed: int
|
|
) -> tuple[tuple[int, str], ...]:
|
|
self.restore_ready()
|
|
self.type_text("G\r")
|
|
self.wait_text("YOUR COLOR", 5)
|
|
user_color = "W" if cc_color == chess.WHITE else "B"
|
|
self.type_text(user_color + "\r")
|
|
self.wait_text("LEVEL OF PLAY", 5)
|
|
before = self._move_entries(self.screen(), not cc_color)
|
|
# Sargon's keyboard wait loop increments zero-page $4E. That byte is
|
|
# the program's entropy source for its randomized opening book, but a
|
|
# scripted reset otherwise lands on the same value every game. Pause so
|
|
# the loop cannot race the write, seed it explicitly, queue the level and
|
|
# RETURN, then resume execution.
|
|
self._ok("pause")
|
|
self.wait_state("paused", 5)
|
|
if self.ctl is None:
|
|
raise BridgeError("emulator control client is not connected")
|
|
self.ctl.set_mem(0x004E, bytes([seed & 0xFF]))
|
|
self.type_text(str(level) + "\r")
|
|
self._ok("run")
|
|
if cc_color == chess.WHITE:
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
if self._player_cursor(self.screen(), cc_color):
|
|
break
|
|
time.sleep(0.025)
|
|
else:
|
|
raise BridgeError("timed out waiting for cc65 White move prompt")
|
|
return before
|
|
|
|
@staticmethod
|
|
def _move_entries(lines: list[str], sargon_color: chess.Color) -> tuple[tuple[int, str], ...]:
|
|
entries = []
|
|
for line in lines:
|
|
match = MOVE_NUMBER_RE.match(line[:10])
|
|
if not match:
|
|
continue
|
|
move_no = decode_move_number(match.group(1))
|
|
cell = line[10:18] if sargon_color == chess.WHITE else line[23:31]
|
|
token = cell.strip().replace("O", "0")
|
|
if token and token != "`":
|
|
entries.append((move_no, token))
|
|
return tuple(entries)
|
|
|
|
@staticmethod
|
|
def _player_cursor(lines: list[str], cc_color: chess.Color) -> bool:
|
|
for line in lines:
|
|
match = MOVE_NUMBER_RE.match(line[:10])
|
|
if not match:
|
|
continue
|
|
cell = line[10:18] if cc_color == chess.WHITE else line[23:31]
|
|
if "`" in cell:
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def _new_move_tokens(
|
|
cls,
|
|
entries: tuple[tuple[int, str], ...],
|
|
before: tuple[tuple[int, str], ...],
|
|
) -> list[str]:
|
|
return [
|
|
token
|
|
for entry in entries
|
|
if entry not in before
|
|
for token in (entry[1],)
|
|
if cls._looks_like_sargon_move(token)
|
|
]
|
|
|
|
def sargon_move(self, board: chess.Board, before: tuple[tuple[int, str], ...]) -> chess.Move:
|
|
deadline = time.monotonic() + MOVE_TIMEOUT
|
|
last_screen = None
|
|
while time.monotonic() < deadline:
|
|
lines = self.screen()
|
|
entries = self._move_entries(lines, board.turn)
|
|
joined = "\n".join(lines)
|
|
terminal_display = "CHECKMATE" in joined or "STALEMATE" in joined
|
|
new_moves = self._new_move_tokens(entries, before)
|
|
if (
|
|
new_moves
|
|
and (self._player_cursor(lines, not board.turn) or terminal_display)
|
|
):
|
|
# A forecast such as "MATE IN 1" can occupy the following row
|
|
# after Sargon has already written its coordinate move and returned
|
|
# the cursor. Select the newest valid added move, not simply the
|
|
# final nonblank cell.
|
|
return self._parse_sargon_move(board, new_moves[-1])
|
|
last_screen = lines
|
|
time.sleep(0.025)
|
|
nonblank = [line.rstrip() for line in (last_screen or []) if line.strip()]
|
|
raise BridgeError(f"timed out waiting for Sargon move; screen={nonblank!r}")
|
|
|
|
@staticmethod
|
|
def _looks_like_sargon_move(token: str) -> bool:
|
|
clean = token.upper().replace("O", "0").strip()[:5].rstrip("+#").strip()
|
|
return bool(
|
|
COORD_RE.match(clean)
|
|
or clean in ("0-0", "0-0-0", "PXPEP")
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_sargon_move(board: chess.Board, token: str) -> chess.Move:
|
|
# A move cell begins with a five-character coordinate/castle/EP token.
|
|
# Sargon writes CHECK or CHECKMATE immediately after it, and the fixed
|
|
# column slice may contain any prefix of that annotation (" CH", etc.).
|
|
clean = token.upper().replace("O", "0").strip()[:5].rstrip("+#").strip()
|
|
legal = list(board.legal_moves)
|
|
if COORD_RE.match(clean):
|
|
uci4 = clean.replace("-", "").replace("X", "").lower()
|
|
matches = [move for move in legal if move.uci()[:4] == uci4]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if matches:
|
|
queens = [move for move in matches if move.promotion == chess.QUEEN]
|
|
if len(queens) == 1:
|
|
return queens[0]
|
|
elif clean in ("0-0", "0-0-0"):
|
|
king_side = clean == "0-0"
|
|
matches = [
|
|
move
|
|
for move in legal
|
|
if board.is_castling(move)
|
|
and (board.is_kingside_castling(move) == king_side)
|
|
]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
elif clean == "PXPEP":
|
|
matches = [move for move in legal if board.is_en_passant(move)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
raise BridgeError(
|
|
f"ambiguous en passant {token!r}: {[move.uci() for move in matches]}"
|
|
)
|
|
raise BridgeError(
|
|
f"cannot translate Sargon token {token!r}; legal={[move.uci() for move in legal]}"
|
|
)
|
|
|
|
def player_move(self, move: chess.Move) -> None:
|
|
if move.promotion not in (None, chess.QUEEN):
|
|
raise BridgeError(f"Sargon cannot be given cc65 underpromotion {move.uci()}")
|
|
name = move.uci()[:2].upper() + "-" + move.uci()[2:4].upper()
|
|
self.type_text(name + "\r")
|
|
|
|
def close(self) -> None:
|
|
if self.ctl is not None:
|
|
try:
|
|
self.ctl.cmd("quit-client")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.ctl.s.close()
|
|
except Exception:
|
|
pass
|
|
self.ctl = None
|
|
if self.proc is not None and self.proc.poll() is None:
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
self.proc = None
|
|
|
|
|
|
def outcome_for(board: chess.Board, plies: int) -> Optional[tuple[str, str]]:
|
|
outcome = board.outcome(claim_draw=True)
|
|
if outcome is not None:
|
|
return outcome.result(), outcome.termination.name.lower()
|
|
if plies >= MAX_PLIES:
|
|
return "1/2-1/2", f"{MAX_PLIES}-ply safety cap"
|
|
return None
|
|
|
|
|
|
def seed_for(batch: str, game_index: int, attempt: int, paired: bool = True) -> int:
|
|
# Give each colour-swapped pair the same seed, then advance the low bits by
|
|
# one for the next pair. Sargon's opening picker only consumes a few low
|
|
# bits, so a large odd stride can look varied as a byte while repeating the
|
|
# exact same book choice for every game of one colour.
|
|
# paired is the alternating-colour default. A single-colour run has no pair
|
|
# to hold a seed constant for, and giving both games of a "pair" the same
|
|
# seed there would halve the openings the run ever sees
|
|
step = (game_index // 2) if paired else game_index
|
|
value = (step + (attempt - 1) * 17 + sum(map(ord, batch))) & 0xFF
|
|
return value or 1
|
|
|
|
|
|
def play_game(
|
|
emulator: SargonEmulator,
|
|
engine: UCIEngine,
|
|
batch: str,
|
|
index: int,
|
|
sargon_level: int,
|
|
attempt: int,
|
|
cc_force_color: Optional[chess.Color] = None,
|
|
) -> GameResult:
|
|
started = time.monotonic()
|
|
if cc_force_color is None:
|
|
cc_color = chess.WHITE if index % 2 == 0 else chess.BLACK
|
|
else:
|
|
cc_color = cc_force_color
|
|
board = chess.Board()
|
|
moves: list[str] = []
|
|
game_seed = seed_for(batch, index, attempt, paired=cc_force_color is None)
|
|
engine.set_book_seed(game_seed)
|
|
engine.new_game()
|
|
pending_sargon_before = emulator.begin_game(
|
|
cc_color, sargon_level, game_seed
|
|
)
|
|
|
|
while True:
|
|
terminal = outcome_for(board, len(moves))
|
|
if terminal is not None:
|
|
result, termination = terminal
|
|
break
|
|
|
|
cc_to_move = board.turn == cc_color
|
|
if cc_to_move:
|
|
# At max Sargon may finish its reply before the next Python statement.
|
|
# Snapshot its move cells before typing the cc65 move, never afterward.
|
|
pending_sargon_before = emulator._move_entries(
|
|
emulator.screen(), not cc_color
|
|
)
|
|
# The opening tables used to be written out again here in Python,
|
|
# because tests/uci called the search directly and could not reach
|
|
# cpu.c at all. It can now, so the harness asks the engine and the
|
|
# thing being measured is the thing that ships - which matters more
|
|
# for the black table than it ever did for the white one, since a
|
|
# Python copy of a table is a copy of what it was believed to be.
|
|
uci = engine.best_move(moves)
|
|
try:
|
|
move = chess.Move.from_uci(uci)
|
|
except ValueError as exc:
|
|
raise BridgeError(f"bad cc65 UCI move {uci!r}") from exc
|
|
if move not in board.legal_moves:
|
|
raise BridgeError(f"cc65 produced illegal move {uci} in {board.fen()}")
|
|
emulator.player_move(move)
|
|
board.push(move)
|
|
moves.append(move.uci())
|
|
else:
|
|
if pending_sargon_before is None:
|
|
raise BridgeError("missing pre-move Sargon screen signature")
|
|
move = emulator.sargon_move(board, pending_sargon_before)
|
|
pending_sargon_before = None
|
|
board.push(move)
|
|
moves.append(move.uci())
|
|
|
|
opening = " ".join(moves[:6])
|
|
return GameResult(
|
|
index=index,
|
|
cc_color="white" if cc_color == chess.WHITE else "black",
|
|
result=result,
|
|
termination=termination,
|
|
plies=len(moves),
|
|
duration=time.monotonic() - started,
|
|
moves=moves,
|
|
opening=opening,
|
|
)
|
|
|
|
|
|
def append_pgn(
|
|
path: Path,
|
|
game_result: GameResult,
|
|
batch: str,
|
|
level: int,
|
|
cc_skill: int,
|
|
) -> None:
|
|
game = chess.pgn.Game()
|
|
cc_name, nodes = CC65_SKILLS[cc_skill]
|
|
cc_player = f"cc65-Chess {cc_name} ({nodes} nodes)"
|
|
game.headers["Event"] = f"cc65-Chess vs Sargon II {batch}"
|
|
game.headers["Site"] = "a2m"
|
|
game.headers["Date"] = time.strftime("%Y.%m.%d")
|
|
game.headers["Round"] = str(game_result.index + 1)
|
|
if game_result.cc_color == "white":
|
|
game.headers["White"] = cc_player
|
|
game.headers["Black"] = f"Sargon II Level {level}"
|
|
else:
|
|
game.headers["White"] = f"Sargon II Level {level}"
|
|
game.headers["Black"] = cc_player
|
|
game.headers["Result"] = game_result.result
|
|
game.headers["Termination"] = game_result.termination
|
|
node = game
|
|
for name in game_result.moves:
|
|
node = node.add_variation(chess.Move.from_uci(name))
|
|
with path.open("a", encoding="utf-8") as handle:
|
|
print(game, file=handle, end="\n\n")
|
|
|
|
|
|
CSV_FIELDS = [
|
|
"index",
|
|
"cc_color",
|
|
"result",
|
|
"cc_score",
|
|
"termination",
|
|
"plies",
|
|
"duration_seconds",
|
|
"opening_6plies",
|
|
"moves",
|
|
]
|
|
|
|
|
|
def load_completed(csv_path: Path) -> list[dict[str, str]]:
|
|
if not csv_path.is_file():
|
|
return []
|
|
with csv_path.open(newline="", encoding="utf-8") as handle:
|
|
return list(csv.DictReader(handle))
|
|
|
|
|
|
def append_csv(csv_path: Path, result: GameResult) -> None:
|
|
exists = csv_path.is_file() and csv_path.stat().st_size > 0
|
|
with csv_path.open("a", newline="", encoding="utf-8") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
|
|
if not exists:
|
|
writer.writeheader()
|
|
writer.writerow(
|
|
{
|
|
"index": result.index,
|
|
"cc_color": result.cc_color,
|
|
"result": result.result,
|
|
"cc_score": result.cc_score,
|
|
"termination": result.termination,
|
|
"plies": result.plies,
|
|
"duration_seconds": f"{result.duration:.3f}",
|
|
"opening_6plies": result.opening,
|
|
"moves": " ".join(result.moves),
|
|
}
|
|
)
|
|
|
|
|
|
def summarize(rows: list[dict[str, str]]) -> dict[str, object]:
|
|
wins = losses = draws = 0
|
|
openings: Counter[str] = Counter()
|
|
for row in rows:
|
|
score = float(row["cc_score"])
|
|
if score == 1.0:
|
|
wins += 1
|
|
elif score == 0.0:
|
|
losses += 1
|
|
else:
|
|
draws += 1
|
|
openings[row["opening_6plies"]] += 1
|
|
n = wins + losses + draws
|
|
score = (wins + 0.5 * draws) / n if n else 0.0
|
|
return {
|
|
"games": n,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"draws": draws,
|
|
"score": score,
|
|
"unique_openings": len(openings),
|
|
"most_common_openings": openings.most_common(10),
|
|
}
|
|
|
|
|
|
def run_batch(
|
|
emulator: SargonEmulator,
|
|
engine: UCIEngine,
|
|
output: Path,
|
|
name: str,
|
|
games: int,
|
|
sargon_level: int,
|
|
cc_force_color: Optional[chess.Color] = None,
|
|
) -> dict[str, object]:
|
|
csv_path = output / f"{name}.csv"
|
|
pgn_path = output / f"{name}.pgn"
|
|
failure_path = output / f"{name}-failures.log"
|
|
rows = load_completed(csv_path)
|
|
start_index = len(rows)
|
|
if start_index:
|
|
log(f"resuming {name} at game {start_index + 1}/{games}")
|
|
|
|
for index in range(start_index, games):
|
|
for attempt in range(1, 4):
|
|
try:
|
|
result = play_game(
|
|
emulator, engine, name, index, sargon_level, attempt,
|
|
cc_force_color,
|
|
)
|
|
break
|
|
except Exception as exc:
|
|
with failure_path.open("a", encoding="utf-8") as handle:
|
|
handle.write(
|
|
f"{time.strftime('%Y-%m-%d %H:%M:%S')} game={index + 1} "
|
|
f"attempt={attempt} error={exc!r}\n"
|
|
)
|
|
log(f"{name} game {index + 1} attempt {attempt} failed: {exc}")
|
|
if attempt == 3:
|
|
raise
|
|
time.sleep(0.5)
|
|
append_csv(csv_path, result)
|
|
append_pgn(pgn_path, result, name, sargon_level, engine.skill)
|
|
rows = load_completed(csv_path)
|
|
summary = summarize(rows)
|
|
log(
|
|
f"{name} {index + 1}/{games}: cc65 {result.cc_color}, "
|
|
f"{result.result} ({result.termination}), {result.plies} plies, "
|
|
f"running W-L-D {summary['wins']}-{summary['losses']}-{summary['draws']}, "
|
|
f"score {summary['score']:.3f}, openings {summary['unique_openings']}"
|
|
)
|
|
(output / f"{name}-summary.json").write_text(
|
|
json.dumps(summary, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
return summarize(load_completed(csv_path))
|
|
|
|
|
|
def main() -> int:
|
|
global MOVE_TIMEOUT
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
parser.add_argument(
|
|
"--move-timeout", type=float, default=MOVE_TIMEOUT,
|
|
help="seconds to allow one Sargon move (raise it for the deep levels)",
|
|
)
|
|
parser.add_argument("--smoke-games", type=int, default=0)
|
|
parser.add_argument("--calibration-games", type=int, default=64)
|
|
parser.add_argument("--match-games", type=int, default=512)
|
|
parser.add_argument("--only", choices=("smoke", "calibration", "match", "auto"), default="auto")
|
|
parser.add_argument("--sargon-level", type=int, choices=range(0, 7))
|
|
parser.add_argument("--cc-skill", type=int, choices=CC65_SKILLS, default=1)
|
|
# A colour split is the one thing this rig measures with half its games, and
|
|
# the black side is where the open question is: 32 alternating games gave
|
|
# five distinct black openings and one of them was played fourteen times.
|
|
# Pinning the colour spends the whole run on the side being asked about
|
|
parser.add_argument(
|
|
"--cc-color", choices=("white", "black"), default=None,
|
|
help="play every game with cc65 on this side (default: alternate)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-own-book", action="store_true",
|
|
help="turn the engine's opening tables off (the pre-2026 behaviour)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
args.output.mkdir(parents=True, exist_ok=True)
|
|
prepare_disk()
|
|
if not A2M_BIN.is_file() or not UCI_BIN.is_file():
|
|
raise BridgeError("a2m or cc65 UCI binary is missing")
|
|
|
|
emulator = SargonEmulator(args.port)
|
|
MOVE_TIMEOUT = args.move_timeout
|
|
|
|
force_color = (
|
|
None if args.cc_color is None
|
|
else (chess.WHITE if args.cc_color == "white" else chess.BLACK)
|
|
)
|
|
engine = UCIEngine(skill=args.cc_skill, own_book=not args.no_own_book)
|
|
|
|
def stop_handler(signum, frame):
|
|
raise KeyboardInterrupt
|
|
|
|
signal.signal(signal.SIGTERM, stop_handler)
|
|
signal.signal(signal.SIGINT, stop_handler)
|
|
|
|
try:
|
|
emulator.start()
|
|
if args.only == "smoke":
|
|
games = args.smoke_games or 4
|
|
level = args.sargon_level if args.sargon_level is not None else 1
|
|
summary = run_batch(
|
|
emulator, engine, args.output, "smoke", games, level,
|
|
cc_force_color=force_color,
|
|
)
|
|
log(f"smoke complete: {json.dumps(summary, sort_keys=True)}")
|
|
return 0
|
|
|
|
if args.only in ("calibration", "auto"):
|
|
calibration_name = (
|
|
"calibration-l1"
|
|
if args.cc_skill == 1
|
|
else f"calibration-cc{args.cc_skill}-sargon1"
|
|
)
|
|
cal = run_batch(
|
|
emulator,
|
|
engine,
|
|
args.output,
|
|
calibration_name,
|
|
args.calibration_games,
|
|
1,
|
|
cc_force_color=force_color,
|
|
)
|
|
level = 1
|
|
recommended_cc_skill = args.cc_skill
|
|
if float(cal["score"]) < 0.35 and args.cc_skill < 4:
|
|
recommended_cc_skill += 1
|
|
decision = {
|
|
"calibration": cal,
|
|
"competitive_score_floor": 0.35,
|
|
"calibrated_cc_skill": args.cc_skill,
|
|
"recommended_cc_skill": recommended_cc_skill,
|
|
"selected_sargon_level": level,
|
|
}
|
|
(args.output / "level-decision.json").write_text(
|
|
json.dumps(decision, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
log(
|
|
f"calibration recommends cc65 Skill {recommended_cc_skill} "
|
|
f"against Sargon Level 1 (score {float(cal['score']):.3f})"
|
|
)
|
|
if args.only == "calibration":
|
|
return 0
|
|
else:
|
|
if args.sargon_level is None:
|
|
decision_path = args.output / "level-decision.json"
|
|
if not decision_path.is_file():
|
|
raise BridgeError("--only match needs --sargon-level or level-decision.json")
|
|
level = int(json.loads(decision_path.read_text())["selected_sargon_level"])
|
|
else:
|
|
level = args.sargon_level
|
|
|
|
if args.only in ("match", "auto"):
|
|
final = run_batch(
|
|
emulator,
|
|
engine,
|
|
args.output,
|
|
f"match-cc{args.cc_skill}-sargon{level}",
|
|
args.match_games,
|
|
level,
|
|
cc_force_color=force_color,
|
|
)
|
|
log(f"measured match complete: {json.dumps(final, sort_keys=True)}")
|
|
return 0
|
|
except KeyboardInterrupt:
|
|
log("interrupted; completed games are saved and the run can be resumed")
|
|
return 130
|
|
finally:
|
|
engine.close()
|
|
emulator.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|