diff --git a/README.md b/README.md
index 86d621d..2e7a8ca 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,42 @@ Tunex currently targets IronPhoenix's 4PC UCI behavior, including its immediate
+## SpellWarden (8×8 Spell Chess) support
+
+TuneX supports two match variants; existing IronPhoenix configs default to
+`"variant": "four_player_teams"`. SpellWarden configs use
+`"variant": "spell_chess"` and the engine's native 8×8 UCI extension.
+
+Start from `configs/spellwarden.example.json` and update `engine.path`:
+
+~~~powershell
+tunex -c configs/spellwarden.example.json probe
+tunex -c configs/spellwarden.example.json match --games 20
+tunex -c configs/spellwarden.example.json sprt
+tunex -c configs/spellwarden.example.json spsa
+~~~
+
+The match runner alternates **White and Black**, swapping candidate and
+baseline colors in paired games. Complete spell turns are single tokens:
+`freeze@d5:e2e4`, `jump@d4:d1d8`, or a normal `e2e4`.
+Openings may contain standard six-field chess FEN with optional SpellWarden
+`sw=...` metadata, or move lists beginning with `moves`.
+Opening turns are validated through the engine's exhaustive `legalmoves`
+command. SAN is not yet supported for Spell Chess.
+
+For accurate checkmate/stalemate adjudication, use SpellWarden with the
+integration update to its `d` diagnostic, which exposes
+`info string check: yes/no` alongside its existing FEN. SpellWarden
+reports its own spell-aware check status; TuneX must not approximate it with
+orthodox chess attack rules. King captures are recognized from the returned
+8×8 board FEN; max-plies are draws. The engine's existing
+`SpellTargets`, `RootSpellTargets`, and `UseMoveOrdering` options can be
+used in matches and SPSA. **There is no automatic Spell Chess rating oracle**:
+test results reflect SpellWarden's current rules implementation.
+
+IronPhoenix four-player behavior, FEN4/SAN4 parsing, team pairing, and all
+existing configs remain unchanged when `variant` is omitted.
+
##
Requirements
- Python **3.11+**
diff --git a/configs/spellwarden.example.json b/configs/spellwarden.example.json
new file mode 100644
index 0000000..cc9770d
--- /dev/null
+++ b/configs/spellwarden.example.json
@@ -0,0 +1,78 @@
+{
+ "variant": "spell_chess",
+ "engine": {
+ "path": "C:/path/to/SpellWarden/out/build/x64-release/spellwarden.exe",
+ "startup_timeout": 15,
+ "move_timeout": 90,
+ "strict_options": true,
+ "common_options": {
+ "Hash": 64,
+ "Threads": 1,
+ "UCI_Variant": "spell"
+ }
+ },
+ "search": {
+ "mode": "movetime",
+ "value": 250
+ },
+ "match": {
+ "games": 100,
+ "workers": 4,
+ "max_plies": 200,
+ "paired": true,
+ "seed": 20261113,
+ "openings_file": null,
+ "results_file": "results/spellwarden_games.jsonl"
+ },
+ "baseline": {
+ "name": "SpellWarden baseline",
+ "options": {
+ "SpellTargets": 6,
+ "RootSpellTargets": 24,
+ "UseMoveOrdering": true
+ }
+ },
+ "candidate": {
+ "name": "SpellWarden candidate",
+ "options": {
+ "SpellTargets": 8,
+ "RootSpellTargets": 24,
+ "UseMoveOrdering": true
+ }
+ },
+ "sprt": {
+ "elo0": 0,
+ "elo1": 5,
+ "alpha": 0.05,
+ "beta": 0.05,
+ "max_games": 10000,
+ "batch_games": 20
+ },
+ "spsa": {
+ "iterations": 200,
+ "games_per_iteration": 40,
+ "a": 50.0,
+ "c": 4.0,
+ "alpha": 0.602,
+ "gamma": 0.101,
+ "A": 20,
+ "seed": 20261113,
+ "state_file": "results/spellwarden_spsa_state.json",
+ "parameters": [
+ {
+ "name": "SpellTargets",
+ "value": 6,
+ "min": 2,
+ "max": 16,
+ "step": 1
+ },
+ {
+ "name": "RootSpellTargets",
+ "value": 24,
+ "min": 8,
+ "max": 48,
+ "step": 1
+ }
+ ]
+ }
+}
diff --git a/src/tunex/cli.py b/src/tunex/cli.py
index 40d1ac0..6a3c05e 100644
--- a/src/tunex/cli.py
+++ b/src/tunex/cli.py
@@ -171,7 +171,7 @@ def command_spsa(config: Config, iterations: int | None) -> int:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tunex",
- description="UCI match testing, SPRT, and SPSA tuning for IronPhoenix.",
+ description="UCI match testing, SPRT, and SPSA tuning for 4PC Teams and Spell Chess.",
)
parser.add_argument(
"-c",
diff --git a/src/tunex/config.py b/src/tunex/config.py
index 8a278fd..55f0faa 100644
--- a/src/tunex/config.py
+++ b/src/tunex/config.py
@@ -206,11 +206,15 @@ class Config:
candidate: VariantConfig
sprt: SprtConfig
spsa: SpsaConfig | None = None
+ variant: str = "four_player_teams"
@classmethod
def load(cls, path: str | Path) -> "Config":
config_path = Path(path)
data = json.loads(config_path.read_text(encoding="utf-8"))
+ variant = str(data.get("variant", "four_player_teams")).lower()
+ if variant not in {"four_player_teams", "spell_chess"}:
+ raise ValueError("variant must be four_player_teams or spell_chess")
engine = EngineConfig.from_dict(data.get("engine", {}))
match = MatchConfig.from_dict(data.get("match"))
openings = match.openings_file
@@ -220,9 +224,9 @@ def load(cls, path: str | Path) -> "Config":
engine.path = str((config_path.parent / engine.path).resolve())
baseline = VariantConfig.from_dict(data.get("baseline", {}), "baseline")
candidate = VariantConfig.from_dict(data.get("candidate", {}), "candidate")
- for variant in (baseline, candidate):
- if variant.path is not None and not Path(variant.path).is_absolute():
- variant.path = str((config_path.parent / variant.path).resolve())
+ for engine_variant in (baseline, candidate):
+ if engine_variant.path is not None and not Path(engine_variant.path).is_absolute():
+ engine_variant.path = str((config_path.parent / engine_variant.path).resolve())
return cls(
engine=engine,
search=SearchConfig.from_dict(data.get("search")),
@@ -231,4 +235,5 @@ def load(cls, path: str | Path) -> "Config":
candidate=candidate,
sprt=SprtConfig.from_dict(data.get("sprt")),
spsa=SpsaConfig.from_dict(data["spsa"]) if "spsa" in data else None,
+ variant=variant,
)
diff --git a/src/tunex/engine.py b/src/tunex/engine.py
index fee639d..4ef1ebe 100644
--- a/src/tunex/engine.py
+++ b/src/tunex/engine.py
@@ -261,6 +261,63 @@ def opening_position(self, position_command: str) -> tuple[str, tuple[str, ...]]
raise EngineError(f"{self.name}: incomplete legal move listing")
return fen, moves
+ def spell_status(self, position_command: str) -> tuple[str, bool]:
+ """Read SpellWarden's board and exact side-to-move check status.
+
+ The spell-aware attack rules belong to SpellWarden, not Tunex.
+ Requires SpellWarden's 'd' command to emit 'info string check: yes/no'.
+ """
+ self.send(position_command)
+ self.send("d")
+ self.send("isready")
+ lines = self._read_until(lambda line: line == "readyok", self.startup_timeout)
+ for line in lines:
+ if line.startswith("info string ") and any(
+ token in line.lower() for token in (
+ "illegal move", "invalid move", "fen requires", "unknown command",
+ )
+ ):
+ raise EngineError(f"{self.name}: {line}")
+ fen = next(
+ (line[len("info string FEN "):] for line in lines
+ if line.startswith("info string FEN ")), None
+ )
+ check = next(
+ (_CHECK_RE.search(line).group(1).lower() == "yes"
+ for line in lines if _CHECK_RE.search(line)), None
+ )
+ if fen is None or check is None:
+ raise EngineError(
+ f"{self.name} needs SpellWarden's FEN and check status from 'd'; "
+ "use the SpellWarden integration branch"
+ )
+ return fen, check
+
+ def spell_legal_turns(self, position_command: str) -> set[str]:
+ """Get full spell turns via SpellWarden's exhaustive legalmoves command."""
+ self.send(position_command)
+ self.send("legalmoves")
+ self.send("isready")
+ lines = self._read_until(lambda line: line == "readyok", self.startup_timeout)
+ header = next(
+ (line for line in lines if line.startswith("info string legal turns ")),
+ None,
+ )
+ if header is None:
+ raise EngineError(f"{self.name} does not support SpellWarden legalmoves")
+ match = re.fullmatch(r"info string legal turns (\d+)", header)
+ if match is None:
+ raise EngineError(f"{self.name}: invalid legal turns header")
+ start = lines.index(header) + 1
+ count = int(match.group(1))
+ turns = [
+ line[len("info string "):] for line in lines[start:start + count]
+ if line.startswith("info string ")
+ ]
+ if len(turns) != count:
+ raise EngineError(f"{self.name}: incomplete legal turns listing")
+ return set(turns)
+
def side_in_check(self, position_command: str) -> bool | None:
self.send(position_command)
self.send("d")
diff --git a/src/tunex/match.py b/src/tunex/match.py
index 329da0e..c219c69 100644
--- a/src/tunex/match.py
+++ b/src/tunex/match.py
@@ -5,6 +5,7 @@
import json
from pathlib import Path
import random
+import re
import threading
from typing import Any, Callable
@@ -60,7 +61,7 @@ def _is_bare_fen4(line: str) -> bool:
or (len(line) >= 2 and line[0].upper() in COLORS and line[1] == "-"))
-def _load_openings(path: str | None) -> list[tuple[str | None, tuple[str, ...]]]:
+def _load_openings(path: str | None, variant: str = "four_player_teams") -> list[tuple[str | None, tuple[str, ...]]]:
if not path:
return [(None, ())]
openings: list[tuple[str | None, tuple[str, ...]]] = []
@@ -75,7 +76,9 @@ def _load_openings(path: str | None) -> list[tuple[str | None, tuple[str, ...]]]
fen_text = None
if line[:4].lower() == "fen ":
fen_text = line[4:].strip()
- elif _is_bare_fen4(line):
+ elif variant == "four_player_teams" and _is_bare_fen4(line):
+ fen_text = line
+ elif variant == "spell_chess" and "/" in line.split()[0]:
fen_text = line
if fen_text is not None:
@@ -138,6 +141,145 @@ def _convert_opening(engine: UciEngine, start_fen: str | None,
return result
+_SPELL_TURN_RE = re.compile(
+ r"(?:(?:freeze|jump)@[a-h][1-8]:)?[a-h][1-8][a-h][1-8][qrbn]?$"
+)
+
+
+def _spell_winner(fen: str) -> int | None:
+ # The board is the first field; SpellWarden's extended spell metadata
+ # is separate and must not be interpreted as a chess position.
+ board = fen.split()[0]
+ if "k" not in board:
+ return 0
+ if "K" not in board:
+ return 1
+ return None
+
+
+def _spell_start_side(start_fen: str | None) -> int:
+ if not start_fen:
+ return 0
+ parts = start_fen.split()
+ if len(parts) < 6 or parts[1] not in ("w", "b"):
+ raise ValueError("Spell Chess openings require six-field FEN (optional sw= field)")
+ return int(parts[1] == "b")
+
+
+def _spell_opening(engine: UciEngine, start_fen: str | None,
+ opening_moves: tuple[str, ...]) -> list[str]:
+ moves: list[str] = []
+ for ply, move in enumerate(opening_moves, 1):
+ if not _SPELL_TURN_RE.fullmatch(move):
+ raise EngineError(
+ f"Spell Chess opening ply {ply}: expected coordinate or "
+ f"freeze@/jump@ spell turn, got {move!r}; SAN is not supported"
+ )
+ position = _position_command(start_fen, moves)
+ legal = engine.spell_legal_turns(position)
+ if move not in legal:
+ raise EngineError(f"Spell Chess opening ply {ply}: illegal turn {move!r}")
+ moves.append(move)
+ return moves
+
+
+def _play_spell_game(
+ config: Config, spec: GameSpec, baseline: VariantConfig, candidate: VariantConfig,
+ cancel_event: threading.Event | None = None,
+ on_progress: Callable[[GameSpec, str, int], None] | None = None,
+) -> GameRecord:
+ if on_progress is not None:
+ on_progress(spec, "starting", 0)
+ candidate_options = _merged_options(config.engine.common_options, candidate)
+ baseline_options = _merged_options(config.engine.common_options, baseline)
+ engines = {
+ spec.candidate_team: UciEngine(
+ candidate.path or config.engine.path, name=candidate.name,
+ startup_timeout=config.engine.startup_timeout,
+ move_timeout=config.engine.move_timeout,
+ strict_options=config.engine.strict_options, cancel_event=cancel_event,
+ ),
+ 1 - spec.candidate_team: UciEngine(
+ baseline.path or config.engine.path, name=baseline.name,
+ startup_timeout=config.engine.startup_timeout,
+ move_timeout=config.engine.move_timeout,
+ strict_options=config.engine.strict_options, cancel_event=cancel_event,
+ ),
+ }
+ moves: list[str] = []
+ last_result: SearchResult | None = None
+ try:
+ for engine in engines.values():
+ engine.start()
+ engines[spec.candidate_team].configure(candidate_options)
+ engines[1 - spec.candidate_team].configure(baseline_options)
+ for engine in engines.values():
+ engine.new_game()
+ moves = _spell_opening(
+ engines[spec.candidate_team], spec.start_fen, spec.opening_moves
+ )
+ opening_uci = list(moves)
+ initial_side = _spell_start_side(spec.start_fen)
+ if on_progress is not None:
+ on_progress(spec, "playing", len(moves))
+
+ def result_record(winner: int | None, reason: str) -> GameRecord:
+ return GameRecord(
+ game_id=spec.game_id, candidate_team=spec.candidate_team,
+ result=_candidate_result(winner, spec.candidate_team),
+ winner_team=winner, reason=reason, plies=len(moves),
+ moves=list(moves), opening_moves=list(opening_uci),
+ start_fen=spec.start_fen,
+ last_score_cp=last_result.score_cp if last_result else None,
+ last_score_mate=last_result.score_mate if last_result else None,
+ )
+
+ for _ in range(config.match.max_plies):
+ if cancel_event is not None and cancel_event.is_set():
+ raise EngineError("match interrupted")
+ side = (initial_side + len(moves)) % 2
+ engine = engines[side]
+ position = _position_command(spec.start_fen, moves)
+
+ # A custom opening may itself be terminal.
+ if last_result is None and moves:
+ fen, _ = engine.spell_status(position)
+ winner = _spell_winner(fen)
+ if winner is not None:
+ return result_record(winner, "terminal-king-capture")
+
+ last_result = engine.search(position, config.search.go_command())
+ if last_result.bestmove == "0000":
+ fen, in_check = engine.spell_status(position)
+ winner = _spell_winner(fen)
+ if winner is not None:
+ return result_record(winner, "terminal-king-capture")
+ return result_record(1 - side if in_check else None,
+ "checkmate/no-legal-move" if in_check
+ else "stalemate/no-legal-move")
+
+ if not _SPELL_TURN_RE.fullmatch(last_result.bestmove):
+ raise EngineError(
+ f"{engine.name} returned malformed Spell Chess turn "
+ f"{last_result.bestmove!r}"
+ )
+ moves.append(last_result.bestmove)
+ # Terminal king captures are not guaranteed to have a mate 1
+ # score, and cannot be adjudicated with 4PC team heuristics.
+ after = _position_command(spec.start_fen, moves)
+ fen, _ = engine.spell_status(after)
+ winner = _spell_winner(fen)
+ if winner is not None:
+ return result_record(winner, "terminal-king-capture")
+ if on_progress is not None and len(moves) % 16 == 0:
+ on_progress(spec, "playing", len(moves))
+
+ return result_record(None, "max-plies")
+ finally:
+ for engine in engines.values():
+ engine.close()
+
+
def _candidate_result(winner_team: int | None, candidate_team: int) -> float:
if winner_team is None:
return 0.5
@@ -155,6 +297,9 @@ def _play_game(
cancel_event: threading.Event | None = None,
on_progress: Callable[[GameSpec, str, int], None] | None = None,
) -> GameRecord:
+ if getattr(config, "variant", "four_player_teams") == "spell_chess":
+ return _play_spell_game(config, spec, baseline, candidate,
+ cancel_event, on_progress)
if on_progress is not None:
on_progress(spec, "starting", 0)
if cancel_event is not None and cancel_event.is_set():
@@ -278,7 +423,7 @@ def _play_game(
class MatchRunner:
def __init__(self, config: Config) -> None:
self.config = config
- self.openings = _load_openings(config.match.openings_file)
+ self.openings = _load_openings(config.match.openings_file, getattr(config, "variant", "four_player_teams"))
self.cancel_event = threading.Event()
def _specs(self, games: int, offset: int = 0) -> list[GameSpec]:
diff --git a/tests/test_spell_chess.py b/tests/test_spell_chess.py
new file mode 100644
index 0000000..806a790
--- /dev/null
+++ b/tests/test_spell_chess.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from tunex.config import (
+ Config, EngineConfig, MatchConfig, SearchConfig, SprtConfig, VariantConfig,
+)
+from tunex.engine import EngineError, SearchResult
+from tunex.match import (
+ GameSpec, _is_bare_fen4, _load_openings, _play_game, _spell_start_side,
+ _spell_winner, _spell_opening,
+)
+
+
+START_FEN = "4k3/8/8/8/8/8/8/4K3 w - - 0 1"
+NO_BLACK_KING = "8/8/8/8/8/8/8/4K3 b - - 0 1"
+
+
+class FakeSpellEngine:
+ mode = "capture"
+ instances = []
+
+ def __init__(self, path, *, name, **kwargs):
+ self.name = name
+ self.path = path
+ self.commands = []
+ self.closed = False
+ self.__class__.instances.append(self)
+
+ def start(self):
+ pass
+
+ def configure(self, options):
+ self.options = options
+
+ def new_game(self):
+ self.commands.append("ucinewgame")
+
+ def close(self):
+ self.closed = True
+
+ def spell_legal_turns(self, position):
+ return {"freeze@d5:e2e4", "e2e4", "jump@d4:d1d8"}
+
+ def spell_status(self, position):
+ self.commands.append(position)
+ if "freeze@d5:e2e4" in position and self.mode == "capture":
+ return NO_BLACK_KING, False
+ return START_FEN, self.mode == "checkmate"
+
+ def search(self, position, go):
+ self.commands.append(go)
+ if self.mode == "capture":
+ return SearchResult(bestmove="freeze@d5:e2e4", score_cp=42)
+ return SearchResult(bestmove="0000")
+
+
+def config():
+ return Config(
+ engine=EngineConfig(path="spellwarden"),
+ search=SearchConfig(mode="movetime", value=250),
+ match=MatchConfig(games=2, workers=1, paired=True, max_plies=6),
+ baseline=VariantConfig(name="baseline", options={}),
+ candidate=VariantConfig(name="candidate", options={}),
+ sprt=SprtConfig(),
+ variant="spell_chess",
+ )
+
+
+class SpellChessTests(unittest.TestCase):
+ def setUp(self):
+ FakeSpellEngine.instances = []
+ FakeSpellEngine.mode = "capture"
+
+ def test_turn_order_and_terminal_king_capture(self):
+ cfg = config()
+ with patch("tunex.match.UciEngine", FakeSpellEngine):
+ white = _play_game(cfg, GameSpec(0, 0), cfg.baseline, cfg.candidate)
+ black = _play_game(cfg, GameSpec(1, 1), cfg.baseline, cfg.candidate)
+ self.assertEqual(white.result, 1.0)
+ self.assertEqual(black.result, 0.0)
+ self.assertEqual(white.winner_team, 0)
+ self.assertEqual(white.reason, "terminal-king-capture")
+ self.assertEqual(white.moves, ["freeze@d5:e2e4"])
+ self.assertEqual(white.plies, 1)
+ self.assertTrue(all(engine.closed for engine in FakeSpellEngine.instances))
+ self.assertTrue(all("ucinewgame" in engine.commands
+ for engine in FakeSpellEngine.instances))
+
+ def test_no_moves_checkmate_and_stalemate(self):
+ cfg = config()
+ with patch("tunex.match.UciEngine", FakeSpellEngine):
+ FakeSpellEngine.mode = "checkmate"
+ mate = _play_game(cfg, GameSpec(0, 0), cfg.baseline, cfg.candidate)
+ FakeSpellEngine.mode = "stalemate"
+ draw = _play_game(cfg, GameSpec(1, 0), cfg.baseline, cfg.candidate)
+ self.assertEqual(mate.result, 0.0)
+ self.assertEqual(mate.winner_team, 1)
+ self.assertEqual(mate.reason, "checkmate/no-legal-move")
+ self.assertEqual(draw.result, 0.5)
+ self.assertEqual(draw.reason, "stalemate/no-legal-move")
+
+ def test_spell_opening_validation(self):
+ engine = FakeSpellEngine("fake", name="spell")
+ opening = _spell_opening(engine, None,
+ ("freeze@d5:e2e4", "jump@d4:d1d8"))
+ self.assertEqual(opening, ["freeze@d5:e2e4", "jump@d4:d1d8"])
+ with self.assertRaisesRegex(EngineError, "SAN is not supported"):
+ _spell_opening(engine, None, ("Nf3",))
+ with self.assertRaisesRegex(EngineError, "illegal turn"):
+ _spell_opening(engine, None, ("a1a7",))
+
+ def test_spell_fen_and_opening_file(self):
+ self.assertEqual(_spell_start_side(START_FEN.replace(" w ", " b ")), 1)
+ self.assertEqual(_spell_start_side(None), 0)
+ self.assertEqual(_spell_winner(NO_BLACK_KING), 0)
+ self.assertIsNone(_spell_winner(START_FEN))
+ self.assertTrue(_is_bare_fen4("4PC"))
+ with tempfile.TemporaryDirectory() as tmp:
+ file = Path(tmp) / "openings.txt"
+ file.write_text(
+ START_FEN + " | moves freeze@d5:e2e4\n"
+ + "moves jump@d4:d1d8\n", encoding="utf-8"
+ )
+ parsed = _load_openings(str(file), "spell_chess")
+ self.assertEqual(len(parsed), 2)
+ self.assertEqual(parsed[0], (START_FEN, ("freeze@d5:e2e4",)))
+ self.assertEqual(parsed[1], (None, ("jump@d4:d1d8",)))
+
+ def test_variant_config_loading(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "config.json"
+ path.write_text('{"variant": "spell_chess", '
+ '"engine": {"path": "spellwarden"}}', encoding="utf-8")
+ self.assertEqual(Config.load(path).variant, "spell_chess")
+ path.write_text('{"engine": {"path": "ironphoenix"}}',
+ encoding="utf-8")
+ self.assertEqual(Config.load(path).variant, "four_player_teams")
+ path.write_text('{"variant": "unknown", "engine": {"path": "engine"}}',
+ encoding="utf-8")
+ with self.assertRaisesRegex(ValueError, "variant must"):
+ Config.load(path)
+
+
+if __name__ == "__main__":
+ unittest.main()