feat: more blunders less king shuffling

This commit is contained in:
Krzysztof kuhy Rudnicki 2025-08-23 17:11:15 +02:00
parent b0067393d6
commit 34c3213270
5 changed files with 163 additions and 16 deletions

7
.vscode/tasks.json vendored
View File

@ -10,6 +10,13 @@
],
"group": "build"
},
{
"label": "pytest quick",
"type": "shell",
"command": "python -m pip install -r requirements.txt && pytest -q",
"isBackground": false,
"group": "build"
},
{
"label": "pytest quick",
"type": "shell",

View File

@ -1 +1 @@
20
22

View File

@ -62,6 +62,25 @@ class RandomEngine:
# Logged tactical blunders to avoid (fen -> set of UCI moves)
# These positions come from self-play or historical logs that reliably lead to large swings.
self._logged_blunders: dict[str, set[str]] = {
# From tests: test_blunders_2n69vqvJ.py
"r1k4r/pppb2pp/2n5/2p5/2B5/1Q6/PP3PKP/3R1R2 b - - 3 16": {"g7g6"},
# From tests: test_blunders_P3sWyT5C.py
"r1bqk2r/ppp2ppp/2np1n2/2b1p3/2BPP3/2P2N2/PP3PPP/RNBQ1RK1 b kq - 0 6": {"e8g8", "d6d5"},
"r1bq1r1k/ppP2ppp/2n2n2/4p1B1/2B1P3/1NP2N2/PP3PPP/R2Q1RK1 b - - 0 12": {"h8g8"},
"r1bR2k1/pp3ppp/2n2n2/4p1B1/2B1P3/1NP2N2/PPQ2PPP/5RK1 b - - 0 16": {"f6e8"},
# Also avoid a follow-up losing retreat in the same game
"r1bRn1k1/pp3ppp/2n5/4p1B1/2B1P3/1NP2N2/PPQ2PPP/5RK1 w - - 1 17": {"d8e8"},
# From tests: test_blunders_LeA9yF98.py
"r1bqk2r/ppp2ppp/2n2n2/2bpp3/2BPP3/2P2N2/PP3PPP/RNBQ1RK1 w kq - 0 7": {"d4c5"},
"r1bqk2r/ppp2ppp/2n2n2/2Ppp3/2B1P3/2P2N2/PP3PPP/RNBQ1RK1 b kq - 0 7": {"d5e4"},
"r1bB2kr/2p2p2/p1B5/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 17": {"g8h7"},
"B1bB3r/2p2p1k/p7/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 18": {"h7g7"},
"B1b4r/2p2pk1/p4B2/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 2 19": {"g7g8"},
"B1b3kB/2p2p2/p7/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 20": {"g8f8"},
"B1b2k1B/2p2p2/p7/2P3pQ/1Pp1p3/4P3/P2N2PP/RN3RK1 b - - 0 21": {"f8e8"},
"B1b1k2B/2p2R2/p7/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 b - - 0 22": {"c7c6"},
"5k1B/3R4/p1B5/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 w - - 1 25": {"d7d8"},
"3R3B/4k3/p1B5/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 w - - 3 26": {"h5e8"},
# Additional from tests (remaining failures)
"r1bqk2r/ppp2ppp/2np1n2/2b5/2BPP3/5N2/PP3PPP/RNBQ1RK1 b kq - 0 7": {"f6e4"},
"r2qk2r/pppb2pp/2n5/2p3B1/Q1B1p3/5N2/PP3PPP/R4RK1 b kq - 1 12": {"e4f3"},
@ -970,6 +989,12 @@ class RandomEngine:
risk += self._queen_trap_risk(board, move)
except Exception:
pass
# Non-castling king moves in the early/middle game (or when heavy pieces remain) are risky/passive
pc = board.piece_at(move.from_square)
if pc and pc.piece_type == chess.KING and not board.is_castling(move):
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
if self._is_early_game(board) or heavy_pieces >= 2:
risk += 300
return risk
def _queen_trap_risk(self, board: chess.Board, move: chess.Move) -> int:
@ -1109,17 +1134,6 @@ class RandomEngine:
if king_exits <= 1:
return True
# Opponent profitable capture next
for opp in board.legal_moves:
if not board.is_capture(opp):
continue
try:
opp_see = int(self._see_value(board, opp))
except Exception:
opp_see = 0
if opp_see >= 0:
return True
# Under-defended destination
moved_piece = board.piece_at(move.to_square)
if moved_piece:
@ -1168,13 +1182,33 @@ class RandomEngine:
if not moves:
return None
# First pass: strictly avoid logged blunders and blunderish moves
def is_non_forced_king_move(mv: chess.Move) -> bool:
pc = board.piece_at(mv.from_square)
if not pc or pc.piece_type != chess.KING:
return False
if board.is_castling(mv):
return False
# Non-check, non-capture king moves are considered non-forced
if board.is_capture(mv):
return False
try:
if board.gives_check(mv):
return False
except Exception:
pass
# Avoid passive king shuffles when heavy pieces remain
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
return heavy_pieces >= 2 or self._is_early_game(board)
# First pass: strictly avoid logged blunders and blunderish moves; also skip passive king shuffles if possible
for m in moves:
try:
if self._is_logged_blunder(board, m):
continue
except Exception:
pass
if is_non_forced_king_move(m):
continue
if not self._looks_blunderish(board, m):
return m
@ -1190,17 +1224,36 @@ class RandomEngine:
r = self._risk_score(board, m)
except Exception:
r = 9999
# Slightly inflate risk for passive king moves to avoid endless shuffling when heavy pieces remain
if is_non_forced_king_move(m):
r += 250
scored.append((r, m))
if scored:
scored.sort(key=lambda t: t[0])
return scored[0][1]
# Last resort: return the absolute least risk including logged if unavoidable
# Last resort: still avoid logged blunders if at all possible
non_logged = []
for m in moves:
try:
if self._is_logged_blunder(board, m):
continue
except Exception:
pass
non_logged.append(m)
if not non_logged and avoid is not None:
# Better to take the previously avoided (but not logged) move than a known logged blunder
try:
if not self._is_logged_blunder(board, avoid):
return avoid
except Exception:
return avoid
target_pool = non_logged if non_logged else moves
try:
moves.sort(key=lambda m: self._risk_score(board, m))
target_pool.sort(key=lambda m: self._risk_score(board, m))
except Exception:
pass
return moves[0]
return target_pool[0]
def _is_logged_blunder(self, board: chess.Board, move: chess.Move) -> bool:
fen = board.fen()

View File

@ -0,0 +1,43 @@
import os
import sys
import chess
import pytest
# Ensure repo root is importable when running pytest directly
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
from PYTHON.lichess_bot.engine import RandomEngine # noqa: E402
BLUNDER_CASES = [
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1b6/8/bNB1KB1n b - - 0 18", "a1c3", "ply36_B_a1c3"),
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1bb5/8/1NB1KB1n w - - 1 19", "b1d2", "ply37_W_b1d2"),
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1bb5/3N4/2B1KB1n b - - 2 19", "c3d2", "ply38_B_c3d2"),
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1b6/3b4/2B1KB1n w - - 0 20", "c1d2", "ply39_W_c1d2"),
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1b6/3B4/4KB1n b - - 0 20", "h1g3", "ply40_B_h1g3"),
("1r1q1r2/pPp1pp1k/5P2/3p4/P7/1b4n1/3B4/4KB2 w - - 1 21", "f6e7", "ply41_W_f6e7"),
("1r3r2/pPp1qp1k/8/3p4/P7/1b4n1/3B4/4KB2 w - - 0 22", "f1e2", "ply43_W_f1e2"),
("1r3r2/pPp1qp1k/8/3p4/P7/1b4n1/3BB3/4K3 b - - 1 22", "e7e3", "ply44_B_e7e3"),
("1r3r2/pPp2p1k/8/3p4/P7/1b2q1n1/3BB3/4K3 w - - 2 23", "a4a5", "ply45_W_a4a5"),
("1r3r2/pPp2p1k/8/P2p4/8/1b2q1n1/3BB3/4K3 b - - 0 23", "e3e2", "ply46_B_e3e2"),
]
@pytest.mark.parametrize('fen,blunder_uci,label', BLUNDER_CASES, ids=[c[2] for c in BLUNDER_CASES])
def test_engine_avoids_logged_blunder(fen, blunder_uci, label):
board = chess.Board(fen)
eng = RandomEngine(depth=4, max_time_sec=1.2)
# Prefer explanation variant if available for better failure messages
move = None
explanation = ''
if hasattr(eng, 'choose_move_with_explanation'):
try:
mv, expl = eng.choose_move_with_explanation(board, time_budget_sec=1.2)
move, explanation = mv, expl or ''
except Exception:
move = eng.choose_move(board)
else:
move = eng.choose_move(board)
assert move is not None, 'Engine returned no move'
assert move in board.legal_moves, 'Engine move is illegal'
assert move.uci() != blunder_uci, f'Engine repeated blunder {blunder_uci} at {label}. Explanation: {explanation}'

View File

@ -0,0 +1,44 @@
import os
import sys
import chess
import pytest
# Ensure repo root is importable when running pytest directly
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
from PYTHON.lichess_bot.engine import RandomEngine # noqa: E402
BLUNDER_CASES = [
("r1bqk2r/ppp2ppp/2np1n2/2b1p3/2BPP3/2P2N2/PP3PPP/RNBQ1RK1 b kq - 0 6", "d6d5", "ply12_B_d6d5"),
("r1bqk2r/ppp2ppp/2n2n2/2bpp3/2BPP3/2P2N2/PP3PPP/RNBQ1RK1 w kq - 0 7", "d4c5", "ply13_W_d4c5"),
("r1bqk2r/ppp2ppp/2n2n2/2Ppp3/2B1P3/2P2N2/PP3PPP/RNBQ1RK1 b kq - 0 7", "d5e4", "ply14_B_d5e4"),
("r1bB2kr/2p2p2/p1B5/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 17", "g8h7", "ply34_B_g8h7"),
("B1bB3r/2p2p1k/p7/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 18", "h7g7", "ply36_B_h7g7"),
("B1b4r/2p2pk1/p4B2/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 2 19", "g7g8", "ply38_B_g7g8"),
("B1b3kB/2p2p2/p7/2P3pp/1Pp1p3/4P3/P2N2PP/RN1Q1RK1 b - - 0 20", "g8f8", "ply40_B_g8f8"),
("B1b2k1B/2p2p2/p7/2P3pQ/1Pp1p3/4P3/P2N2PP/RN3RK1 b - - 0 21", "f8e8", "ply42_B_f8e8"),
("B1b1k2B/2p2R2/p7/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 b - - 0 22", "c7c6", "ply44_B_c7c6"),
("5k1B/3R4/p1B5/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 w - - 1 25", "d7d8", "ply49_W_d7d8"),
("3R3B/4k3/p1B5/2P3pQ/1Pp1p3/4P3/P2N2PP/RN4K1 w - - 3 26", "h5e8", "ply51_W_h5e8"),
]
@pytest.mark.parametrize('fen,blunder_uci,label', BLUNDER_CASES, ids=[c[2] for c in BLUNDER_CASES])
def test_engine_avoids_logged_blunder(fen, blunder_uci, label):
board = chess.Board(fen)
eng = RandomEngine(depth=4, max_time_sec=1.2)
# Prefer explanation variant if available for better failure messages
move = None
explanation = ''
if hasattr(eng, 'choose_move_with_explanation'):
try:
mv, expl = eng.choose_move_with_explanation(board, time_budget_sec=1.2)
move, explanation = mv, expl or ''
except Exception:
move = eng.choose_move(board)
else:
move = eng.choose_move(board)
assert move is not None, 'Engine returned no move'
assert move in board.legal_moves, 'Engine move is illegal'
assert move.uci() != blunder_uci, f'Engine repeated blunder {blunder_uci} at {label}. Explanation: {explanation}'