From eaf3afe0cc92f48c08bf07daaa9ca761b2a0ac9b Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 18 Aug 2025 21:51:14 -0400 Subject: [PATCH 1/3] Add attacker and check logic with tests --- engine/src/bitboard.c | 44 ++++++++++++++++++ test/base.py | 4 +- test/chess_ffi.py | 28 ++++++++++-- test/test_attack_to.py | 100 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/test_attack_to.py diff --git a/engine/src/bitboard.c b/engine/src/bitboard.c index 3583eb4..cf862db 100644 --- a/engine/src/bitboard.c +++ b/engine/src/bitboard.c @@ -530,6 +530,50 @@ int gen_pseudo_moves(const struct Board *board, struct Move *moves, bool capture return count; } +uint64_t attackers_to(const struct Board *board, int sq, enum Color by) { + uint64_t occ = board->occ[BOTH]; + + uint64_t pawns = (by == WHITE) ? board->pieces[P] : board->pieces[p]; + uint64_t knights = (by == WHITE) ? board->pieces[N] : board->pieces[n]; + uint64_t bishops = (by == WHITE) ? board->pieces[B] : board->pieces[b]; + uint64_t rooks = (by == WHITE) ? board->pieces[R] : board->pieces[r]; + uint64_t queens = (by == WHITE) ? board->pieces[Q] : board->pieces[q]; + uint64_t kings = (by == WHITE) ? board->pieces[K] : board->pieces[k]; + + uint64_t attacks = 0; + + attacks |= PAWN_ATTACKS[by ^ 1][sq] & pawns; + attacks |= KNIGHT_ATTACKS[sq] & knights; + attacks |= KING_ATTACKS[sq] & kings; + + // Squares than can see sq given the occupancy of both sides but intersect + // with the specified color pieces. + attacks |= bishop_attacks(sq, occ) & (bishops | queens); + attacks |= rook_attacks(sq, occ) & (rooks | queens); + + return attacks; +} + +bool in_check(const struct Board *board, enum Color side) { + uint64_t king_bb = (side == WHITE) ? board->pieces[K] : board->pieces[k]; + + // Do we need this check here? + // This would be an illegal position. + if (!king_bb) return false; + + int king_sq = pop_lsb_index(&king_bb); + + // Is the king square in the attack map of opposite side? + return attackers_to(board, king_sq, side ^ 1) != 0; +} + +bool square_attacked(const struct Board *board, int sq, enum Color by) { + // Should we have another way to calculate this and have + // earlier exits in the code? For example, we could iterate + // each piece type and return boolean sooner and skip the + // ray scanning. + return attackers_to(board, sq, by) != 0; +} void print_board(const struct Board *board) { const char PIECE_CH[12] = { diff --git a/test/base.py b/test/base.py index 02b4d63..c8143cd 100644 --- a/test/base.py +++ b/test/base.py @@ -14,8 +14,8 @@ class ChessLibTestBase(unittest.TestCase): init_attack_caches() cls.KNIGHT_ATTACKS = KNIGHT_ATTACKS - cls.KING_ATTACKS = KING_ATTACKS - cls.PAWN_ATTACKS = PAWN_ATTACKS + cls.KING_ATTACKS = KING_ATTACKS + cls.PAWN_ATTACKS = PAWN_ATTACKS def setUp(self): diff --git a/test/chess_ffi.py b/test/chess_ffi.py index b0abc45..1c10502 100644 --- a/test/chess_ffi.py +++ b/test/chess_ffi.py @@ -87,6 +87,16 @@ gen_queen_moves = _bind_opt("gen_queen_moves", *PIECE_SIG) gen_king_moves = _bind_opt("gen_king_moves", *PIECE_SIG) +# Attack checks. +ATTACKED_SIG = (C.POINTER(Board), C.c_int, C.c_int) +INCHECK_ARGS = (C.POINTER(Board), C.c_int) +ATTACKERS_TO = (C.POINTER(Board), C.c_int, C.c_int) + +square_attacked = _bind_opt("square_attacked", ATTACKED_SIG, C.c_bool) +in_check = _bind_opt("in_check", INCHECK_ARGS, C.c_bool) +attackers_to = _bind_opt("attackers_to", ATTACKERS_TO, C.c_uint64) + + # Attack cache tables. KnightArr = (C.c_uint64 * 64) KingArr = (C.c_uint64 * 64) @@ -102,8 +112,8 @@ except ValueError: def init_attack_caches(): if create_knight_attack_cache: create_knight_attack_cache() - if create_king_attack_cache: create_king_attack_cache() - if create_pawn_attack_cache: create_pawn_attack_cache() + if create_king_attack_cache: create_king_attack_cache() + if create_pawn_attack_cache: create_pawn_attack_cache() def load_fen(board, fen): @@ -116,6 +126,18 @@ def gen_moves(board, captures_only=False, cap=256): return buf, n +def is_square_attacked(board, sq, by): + return bool(square_attacked(C.byref(board), int(sq), int(by))) + + +def is_in_check(board, side): + return bool(in_check(C.byref(board), int(side))) + + +def get_attackers_to(board, sq, by): + return int(attackers_to(C.byref(board), int(sq), int(by))) + + def sq_to_coord(sq): return chr(ord('a') + (sq % 8)) + chr(ord('1') + (sq // 8)) @@ -137,7 +159,7 @@ def popcount(x: int) -> int: return x.bit_count() -def draw_bb(mask: int, origin: int | None = None) -> str: +def draw_bb(mask, origin=None): print("\n") lines = [] for r in range(7, -1, -1): diff --git a/test/test_attack_to.py b/test/test_attack_to.py new file mode 100644 index 0000000..3d5aa3b --- /dev/null +++ b/test/test_attack_to.py @@ -0,0 +1,100 @@ +import ctypes +from test.base import ChessLibTestBase +from test.chess_ffi import get_attackers_to +from test.chess_ffi import is_square_attacked +from test.chess_ffi import is_in_check +from test.chess_ffi import sq +from test.chess_ffi import Board +from test.chess_ffi import BLACK +from test.chess_ffi import WHITE +from test.chess_ffi import draw_bb + + +class TestAttackers(ChessLibTestBase): + def test_square_attacked(self): + cases = [ + # Pawns: bp d5 attacks c4/e4; not d4 + ("8/8/8/3p4/8/8/8/8 w - - 0 1", "c4", BLACK, True, "bp d5 -> c4"), + ("8/8/8/3p4/8/8/8/8 w - - 0 1", "e4", BLACK, True, "bp d5 -> e4"), + ("8/8/8/3p4/8/8/8/8 w - - 0 1", "d4", BLACK, False, "bp d5 not d4"), + + # Knights + ("8/8/8/3n4/8/8/8/8 w - - 0 1", "f4", BLACK, True, "bn d5 -> f4"), + ("8/8/8/3n4/8/8/8/8 w - - 0 1", "e7", BLACK, True, "bn d5 -> e7"), + ("8/8/8/3n4/8/8/8/8 w - - 0 1", "e5", BLACK, False, "bn d5 not e5"), + + # Bishops (wB d4) + ("8/8/8/8/3B4/8/8/8 w - - 0 1", "c5", WHITE, True, "wB d4 -> c5"), + ("8/8/8/8/3B4/8/8/8 w - - 0 1", "e5", WHITE, True, "wB d4 -> e5"), + + # Rooks (wR e4) + ("8/8/8/8/4R3/8/8/8 w - - 0 1", "e8", WHITE, True, "wR e4 -> e8 clear"), + ("4k3/4p3/8/8/4R3/8/8/8 w - - 0 1", "e8", WHITE, False, "wR e4 blocked by pe7"), + ("4k3/4p3/8/8/4R3/8/8/8 w - - 0 1", "e7", WHITE, True, "wR e4 blocked can attack pawn on e7"), + ("8/8/8/8/4R3/8/8/8 w - - 0 1", "a4", WHITE, True, "wR e4 -> a4"), + + # Queens (wQ d4) + ("8/8/8/8/3Q4/8/8/8 w - - 0 1", "h8", WHITE, True, "wQ d4 -> h8"), + ("8/8/8/8/3Q4/8/8/8 w - - 0 1", "d8", WHITE, True, "wQ d4 -> d8"), + + # Kings (bk e4) + ("8/8/8/8/4k3/8/8/4K3 w - - 0 1", "e3", BLACK, True, "bk e4 -> e3"), + ("8/8/8/8/4k3/8/8/4K3 w - - 0 1", "g4", BLACK, False, "bk e4 not g4"), + + # Edge knights (bn a1) + ("8/8/8/8/8/8/8/n7 w - - 0 1", "b3", BLACK, True, "bn a1 -> b3"), + ("8/8/8/8/8/8/8/n7 w - - 0 1", "c2", BLACK, True, "bn a1 -> c2"), + ("8/8/8/8/8/8/8/n7 w - - 0 1", "a2", BLACK, False, "bn a1 not a2"), + ] + + for fen, sq_str, by, expected, msg in cases: + with self.subTest(msg=msg, fen=fen, sq=sq_str, by=by): + b = Board() + self.load_fen(fen, board=b) + got = bool(is_square_attacked(b, sq(sq_str), by)) + self.assertEqual(expected, got, msg) + + + def test_in_check(self): + cases = [ + # Unblocked slider (Qe2 checks e8) + ("4k3/8/8/8/8/8/4Q3/4K3 b - - 0 1", BLACK, True, "Qe2 -> e8"), + # Blocked by pe7 + ("4k3/4p3/8/8/8/8/4Q3/4K3 b - - 0 1", BLACK, False, "Qe2 blocked by pe7"), + + # Knight check (Nc7 checks e8) + ("4k3/2N5/8/8/8/8/8/4K3 b - - 0 1", BLACK, True, "Nc7 -> e8"), + + # Pawn check: bp e5 checks f4 (WK on f4) + ("4k3/8/8/4p3/5K2/8/8/8 b - - 0 1", WHITE, True, "bp e5 -> f4"), + # Not checking WK e1 + ("4k3/8/8/4p3/8/8/8/4K3 b - - 0 1", WHITE, False, "bp e5 not e1"), + + # King adjacency (illegal but detected) + # Ensure that this case does not make it through a legality filter. + ("8/8/8/8/8/8/4k3/4K3 w - - 0 1", WHITE, True, "BK e2 checks WK e1"), + + # Double check (Qe2 + Nc7 vs BK e8) + ("4k3/2N5/8/8/8/8/4Q3/4K3 b - - 0 1", BLACK, True, "double check still true"), + ] + + for fen, side, expected, msg in cases: + with self.subTest(msg=msg, fen=fen, side=side): + b = Board() + self.load_fen(fen, board=b) + actual = bool(is_in_check(b, side)) + self.assertEqual(expected, actual, msg) + + def test_attackers_to_popcount(self): + cases = [ + # Bishop on d4, adjacent enemies on each ray → 4 attackers + ("8/8/8/2p1p3/3B4/2p1p3/8/8 w - - 0 1", "d4", BLACK, 2, "adjacent enemies on c5,e5,c3,e3"), + ] + + for fen, sq_str, by, expected_cnt, msg in cases: + with self.subTest(msg=msg, fen=fen, sq=sq_str, by=by): + b = Board() + self.load_fen(fen, board=b) + + mask = get_attackers_to(b, sq(sq_str), by) + self.assertEqual(expected_cnt, int(mask).bit_count()) \ No newline at end of file -- 2.34.1 From 9b2cbbcc727eef88dfb0216c45a0e2ebc0c1f3b5 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 11:52:50 -0400 Subject: [PATCH 2/3] Add attack to count function tests --- test/test_attack_to.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/test/test_attack_to.py b/test/test_attack_to.py index 3d5aa3b..a235916 100644 --- a/test/test_attack_to.py +++ b/test/test_attack_to.py @@ -87,8 +87,45 @@ class TestAttackers(ChessLibTestBase): def test_attackers_to_popcount(self): cases = [ - # Bishop on d4, adjacent enemies on each ray → 4 attackers - ("8/8/8/2p1p3/3B4/2p1p3/8/8 w - - 0 1", "d4", BLACK, 2, "adjacent enemies on c5,e5,c3,e3"), + # Pawns (original correction): only c5 & e5 attack d4 + ("8/8/8/2p1p3/3B4/2p1p3/8/8 w - - 0 1", "d4", BLACK, 2, "black pawns c5,e5 attack d4; c3,e3 do not"), + + # 0 attackers on empty board + ("8/8/8/8/8/8/8/8 w - - 0 1", "d4", WHITE, 0, "no pieces -> no attackers"), + + # Knights: b5 and f5 both hit d4 + ("8/8/8/1n3n2/8/8/8/8 w - - 0 1", "d4", BLACK, 2, "two black knights b5,f5 attack d4"), + + # White pawns from below (c3,e3) attack d4 + ("8/8/8/8/8/2P1P3/8/8 w - - 0 1", "d4", WHITE, 2, "white pawns c3,e3 attack d4"), + + # Rooks on the file: d8 and d1 both see d4 + ("3R4/8/8/8/8/8/8/3R4 w - - 0 1", "d4", WHITE, 2, "two white rooks d8,d1 attack d4"), + + # Bishops on diagonals: b6 and f2 see d4 + ("8/8/1B6/8/8/8/5B2/8 w - - 0 1", "d4", WHITE, 2, "two white bishops b6,f2 attack d4"), + + # Mixed sliders: Ba7 and Rd1 both hit d4 + ("8/B7/8/8/8/8/8/3R4 w - - 0 1", "d4", WHITE, 2, "white bishop a7 and rook d1 attack d4"), + + # Rook blocked by own pawn -> no attack up the file + ("8/8/8/8/8/8/3P4/3R4 w - - 0 1", "d4", WHITE, 0, "rook d1 blocked by own pawn d2"), + + # Two queens (different lines) both attack d4 + ("3Q4/8/8/8/7Q/8/8/8 w - - 0 1", "d4", WHITE, 2, "queens d8 (file) and h4 (rank) attack d4"), + + # Double-check-style: Qe2 and Nc7 attack e8 + ("4k3/2N5/8/8/8/8/4Q3/4K3 w - - 0 1", "e8", WHITE, 2, "white queen e2 and knight c7 attack e8"), + + # Blocked slider by enemy pawn: rook e4 cannot reach e8 + ("4k3/4p3/8/8/4R3/8/8/4K3 w - - 0 1", "e8", WHITE, 0, "white rook e4 blocked by black pawn e7"), + + # Three independent attackers to e4: Nf6, Be3 (from b1 path), Re1 + ("8/8/5N2/8/8/8/8/1B2R3 w - - 0 1", "e4", WHITE, 3, "knight f6, bishop b1 path, rook e1 all attack e4"), + + # King adjacency (illegal in play but functionally one attacker) + # We need to ensure that the legal move filter catches this case. + ("8/8/8/8/8/8/4k3/4K3 w - - 0 1", "e1", BLACK, 1, "black king e2 attacks e1"), ] for fen, sq_str, by, expected_cnt, msg in cases: -- 2.34.1 From 7db0e3b90f10ad2fab4118bb3ef918b2cc09cc4f Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 11:53:22 -0400 Subject: [PATCH 3/3] Add space --- test/test_attack_to.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_attack_to.py b/test/test_attack_to.py index a235916..cc099d3 100644 --- a/test/test_attack_to.py +++ b/test/test_attack_to.py @@ -85,6 +85,7 @@ class TestAttackers(ChessLibTestBase): actual = bool(is_in_check(b, side)) self.assertEqual(expected, actual, msg) + def test_attackers_to_popcount(self): cases = [ # Pawns (original correction): only c5 & e5 attack d4 -- 2.34.1