metropolis-chess / app /engine /mock_engine.py
Forkei's picture
Phase 4.0–4.3: demo prep — auth, Elo fairness, rooms, lobbies, conversational entry, evolution
7bad702
"""Deterministic engine used in tests and in dev when real engines are missing.
Phase 2a shipped a naive "pick alphabetically-first UCI move" strategy which
produced pathological shuffling in the opening (e.g. Nf3-Ng1-Nf3-Ng1 for
six plies). Phase 4.0c fixes this by filtering out:
1. Direct reversals of the agent's last N own moves (avoids 2-move cycles).
2. Moves that would produce a positional repetition (3-fold).
If the remaining legal set is empty the engine falls back to the raw
alphabetical pick so it always returns *some* legal move.
"""
from __future__ import annotations
import time
import chess
from app.engine.base import ChessEngine, ConsideredMove, EngineConfig, MoveResult
# How far back in own-move history to look for reversals. 3 = last three of
# our own moves; any move that reverses one of those is filtered. This
# handles A→B→A→B cycles *and* A→B→C→A sortie patterns.
REVERSAL_LOOKBACK = 3
class MockEngine(ChessEngine):
name = "mock"
@classmethod
def is_available(cls) -> bool: # always available
return True
def get_move(self, board: chess.Board, config: EngineConfig) -> MoveResult:
started = time.perf_counter()
legal = sorted(board.legal_moves, key=lambda m: m.uci())
if not legal:
raise ValueError("No legal moves — game is already over")
chosen = _pick_non_shuffle(board, legal)
san = board.san(chosen)
considered = []
for alt in legal[:3]:
considered.append(
ConsideredMove(
uci=alt.uci(),
san=board.san(alt),
eval_cp=None,
probability=1.0 / len(legal),
)
)
return MoveResult(
move=chosen.uci(),
san=san,
eval_cp=None,
considered_moves=considered,
time_taken_ms=int((time.perf_counter() - started) * 1000),
engine_name="mock",
thinking_depth=None,
)
def _recent_own_moves(board: chess.Board, depth: int) -> list[chess.Move]:
"""Return up to `depth` of this side's most recent own moves, newest first.
Stack layout: opponent-move, own-move, opponent-move, own-move, ...
Own moves sit at indices -2, -4, -6, ... when it's our turn (since the
most recent pushed move was the opponent's).
"""
stack = board.move_stack
out: list[chess.Move] = []
for i in range(2, 2 * depth + 1, 2):
if len(stack) >= i:
out.append(stack[-i])
return out
def _is_reversal(move: chess.Move, prior: chess.Move) -> bool:
"""True iff `move` directly reverses `prior` (same piece, swap squares).
Promotions are never treated as reversals (a promoted move can't undo).
"""
if move.promotion is not None or prior.promotion is not None:
return False
return move.from_square == prior.to_square and move.to_square == prior.from_square
def _would_repeat_position(board: chess.Board, move: chess.Move) -> bool:
"""Would pushing `move` create a 3-fold repetition?"""
board.push(move)
try:
return board.is_repetition(3)
finally:
board.pop()
def _pick_non_shuffle(board: chess.Board, legal: list[chess.Move]) -> chess.Move:
"""Choose the alphabetically-first legal move that isn't a shuffle
reversal of any of our recent own moves and doesn't force repetition.
Falls back to `legal[0]` if every option trips the filters.
"""
recents = _recent_own_moves(board, REVERSAL_LOOKBACK)
for move in legal:
if any(_is_reversal(move, r) for r in recents):
continue
if _would_repeat_position(board, move):
continue
return move
return legal[0]