2222MAN_VALUE = 1.0
2323KING_VALUE = 2.5 # Kings are very powerful in draughts
2424
25- # PST Tables for men - rewards advancement (simple linear)
26- PST_MAN_BLACK = np .array ([
27- 0.25 , 0.30 , 0.30 , 0.30 , 0.25 , # 0-4: Near promotion
28- 0.20 , 0.22 , 0.25 , 0.22 , 0.20 , # 5-9
29- 0.15 , 0.18 , 0.20 , 0.18 , 0.15 , # 10-14
30- 0.12 , 0.15 , 0.18 , 0.15 , 0.12 , # 15-19
31- 0.08 , 0.12 , 0.15 , 0.12 , 0.08 , # 20-24
32- 0.06 , 0.10 , 0.12 , 0.10 , 0.06 , # 25-29
33- 0.04 , 0.08 , 0.10 , 0.08 , 0.04 , # 30-34
34- 0.02 , 0.05 , 0.08 , 0.05 , 0.02 , # 35-39
35- 0.01 , 0.02 , 0.04 , 0.02 , 0.01 , # 40-44
36- 0.00 , 0.00 , 0.00 , 0.00 , 0.00 # 45-49: Starting rank
37- ])
38-
39- PST_MAN_WHITE = PST_MAN_BLACK [:: - 1 ]
40-
41- # King PST - strongly prefers center, avoids edges
42- PST_KING_BLACK = np . array ([
43- 0.00 , 0.05 , 0.05 , 0.05 , 0.00 ,
44- 0.05 , 0.10 , 0.12 , 0.10 , 0.05 ,
45- 0.05 , 0.12 , 0.15 , 0.12 , 0.05 ,
46- 0.08 , 0.15 , 0.20 , 0.15 , 0.08 ,
47- 0.10 , 0.18 , 0.25 , 0.18 , 0.10 ,
48- 0.10 , 0.18 , 0.25 , 0.18 , 0.10 ,
49- 0.08 , 0.15 , 0.20 , 0.15 , 0.08 ,
50- 0.05 , 0.12 , 0.15 , 0.12 , 0.05 ,
51- 0.05 , 0.10 , 0.12 , 0.10 , 0.05 ,
52- 0.00 , 0.05 , 0.05 , 0.05 , 0.00
53- ])
54-
55- PST_KING_WHITE = PST_KING_BLACK [:: - 1 ]
25+
26+ def _create_pst_man ( num_squares : int , rows : int ) -> np .ndarray :
27+ """Create piece-square table for men (rewards advancement)."""
28+ squares_per_row = num_squares // rows
29+ pst = np . zeros ( num_squares )
30+ for i in range ( num_squares ):
31+ row = i // squares_per_row
32+ # Higher value for squares closer to promotion (row 0)
33+ advancement_bonus = ( rows - 1 - row ) / ( rows - 1 ) * 0.3
34+ # Small center bonus
35+ col = i % squares_per_row
36+ center_bonus = ( 1 - abs ( col - squares_per_row / 2 ) / ( squares_per_row / 2 )) * 0.05
37+ pst [ i ] = advancement_bonus + center_bonus
38+ return pst
39+
40+
41+ def _create_pst_king ( num_squares : int , rows : int ) -> np . ndarray :
42+ """Create piece-square table for kings (prefers center)."""
43+ squares_per_row = num_squares // rows
44+ pst = np . zeros ( num_squares )
45+ center_row = rows / 2
46+ center_col = squares_per_row / 2
47+ for i in range ( num_squares ):
48+ row = i // squares_per_row
49+ col = i % squares_per_row
50+ # Distance from center (normalized)
51+ row_dist = abs ( row - center_row ) / center_row
52+ col_dist = abs ( col - center_col ) / center_col
53+ # Higher value for center squares
54+ pst [ i ] = ( 1 - ( row_dist + col_dist ) / 2 ) * 0.25
55+ return pst
5656
5757
5858class AlphaBetaEngine (Engine ):
5959 """
6060 AI engine using Negamax search with alpha-beta pruning.
6161
6262 This engine implements a strong draughts AI with several optimizations
63- for efficient tree search.
63+ for efficient tree search. Works with any board variant (Standard, American, etc.).
6464
6565 **Algorithm:**
6666
@@ -88,6 +88,12 @@ class AlphaBetaEngine(Engine):
8888 >>> move = engine.get_best_move(board)
8989 >>> board.push(move)
9090
91+ Example with American Draughts:
92+ >>> from draughts.boards.american import Board
93+ >>> board = Board()
94+ >>> engine = AlphaBetaEngine(depth_limit=6)
95+ >>> move = engine.get_best_move(board)
96+
9197 Example with evaluation:
9298 >>> move, score = engine.get_best_move(board, with_evaluation=True)
9399 >>> print(f"Best: {move}, Score: {score:.2f}")
@@ -116,10 +122,18 @@ def __init__(self, depth_limit: int = 6, time_limit: float | None = None, name:
116122 self .history : dict [tuple [int , int ], int ] = {}
117123 self .killers : dict [int , list [Move ]] = {}
118124
119- # Zobrist Hashing (deterministic per-engine; does not perturb global RNG)
125+ # Zobrist Hashing - initialized lazily per board size
120126 self ._zobrist_rng = random .Random (0 )
121- self .zobrist_table = self ._init_zobrist ()
122- self .zobrist_turn = self ._zobrist_rng .getrandbits (64 )
127+ self ._zobrist_tables : dict [int , list [list [int ]]] = {} # num_squares -> table
128+ self ._zobrist_turn = self ._zobrist_rng .getrandbits (64 )
129+
130+ # PST tables - cached per board configuration
131+ self ._pst_cache : dict [tuple [int , int ], tuple [np .ndarray , np .ndarray , np .ndarray , np .ndarray ]] = {}
132+
133+ # Current search state (set at start of search, used during eval)
134+ # Initialize with standard 50-square defaults so evaluate() works standalone
135+ self ._current_zobrist : list [list [int ]] = self ._get_zobrist_table (50 )
136+ self ._current_pst : tuple [np .ndarray , np .ndarray , np .ndarray , np .ndarray ] = self ._get_pst_tables (50 , 10 )
123137
124138 self .start_time : float = 0.0
125139 self .stop_search : bool = False
@@ -133,29 +147,58 @@ def inspected_nodes(self) -> int:
133147 def inspected_nodes (self , value : int ) -> None :
134148 self .nodes = value
135149
136- def _init_zobrist (self ):
137- # 50 squares, 5 piece types
138- table = [[self ._zobrist_rng .getrandbits (64 ) for _ in range (5 )] for _ in range (50 )]
139- return table
150+ def _get_zobrist_table (self , num_squares : int ) -> list [list [int ]]:
151+ """Get or create Zobrist table for given board size."""
152+ if num_squares not in self ._zobrist_tables :
153+ # Create new table with deterministic RNG
154+ rng = random .Random (num_squares ) # Seed based on size for consistency
155+ table = [[rng .getrandbits (64 ) for _ in range (5 )] for _ in range (num_squares )]
156+ self ._zobrist_tables [num_squares ] = table
157+ return self ._zobrist_tables [num_squares ]
158+
159+ def _get_pst_tables (self , num_squares : int , rows : int ) -> tuple [np .ndarray , np .ndarray , np .ndarray , np .ndarray ]:
160+ """Get or create PST tables for given board configuration."""
161+ key = (num_squares , rows )
162+ if key not in self ._pst_cache :
163+ pst_man_black = _create_pst_man (num_squares , rows )
164+ pst_man_white = pst_man_black [::- 1 ].copy ()
165+ pst_king_black = _create_pst_king (num_squares , rows )
166+ pst_king_white = pst_king_black [::- 1 ].copy ()
167+ self ._pst_cache [key ] = (pst_man_black , pst_man_white , pst_king_black , pst_king_white )
168+ return self ._pst_cache [key ]
140169
141170 def _get_piece_index (self , piece ):
142171 return piece + 2
143172
173+ def _compute_hash_fast (self , board : BaseBoard ) -> int :
174+ """Compute hash using cached zobrist table."""
175+ h = 0
176+ for i , piece in enumerate (board ._pos ):
177+ if piece != 0 :
178+ h ^= self ._current_zobrist [i ][self ._get_piece_index (piece )]
179+ if board .turn == Color .BLACK :
180+ h ^= self ._zobrist_turn
181+ return h
182+
144183 def compute_hash (self , board : BaseBoard ) -> int :
145- """Compute Zobrist hash for a board position."""
184+ """Compute Zobrist hash for a board position (standalone, slower)."""
185+ num_squares = len (board ._pos )
186+ zobrist_table = self ._get_zobrist_table (num_squares )
187+
146188 h = 0
147189 for i , piece in enumerate (board ._pos ):
148190 if piece != 0 :
149- h ^= self . zobrist_table [i ][self ._get_piece_index (piece )]
191+ h ^= zobrist_table [i ][self ._get_piece_index (piece )]
150192 if board .turn == Color .BLACK :
151- h ^= self .zobrist_turn
193+ h ^= self ._zobrist_turn
152194 return h
153195
154196 def evaluate (self , board : BaseBoard ) -> float :
155197 """
156198 Evaluate the board position.
157199
158200 Uses material count and piece-square tables.
201+ Works with any board variant.
159202
160203 Args:
161204 board: The board to evaluate.
@@ -165,33 +208,31 @@ def evaluate(self, board: BaseBoard) -> float:
165208 Positive = good for current player.
166209 """
167210 pos = board ._pos
211+ pst = self ._current_pst # Cached at init or start of search
168212
169213 # Piece masks
170214 white_men = (pos == - 1 )
171215 white_kings = (pos == - 2 )
172216 black_men = (pos == 1 )
173217 black_kings = (pos == 2 )
174218
175- # Count pieces
176- n_white_men = np .sum (white_men )
177- n_white_kings = np .sum (white_kings )
178- n_black_men = np .sum (black_men )
179- n_black_kings = np .sum (black_kings )
180-
181219 # Material
220+ n_white_men = white_men .sum ()
221+ n_white_kings = white_kings .sum ()
222+ n_black_men = black_men .sum ()
223+ n_black_kings = black_kings .sum ()
224+
182225 score = (n_black_men - n_white_men ) * MAN_VALUE
183226 score += (n_black_kings - n_white_kings ) * KING_VALUE
184227
185228 # PST - Piece Square Tables
186- score += np . sum ( PST_MAN_BLACK [ black_men ])
187- score -= np . sum ( PST_MAN_WHITE [ white_men ])
188- score += np . sum ( PST_KING_BLACK [ black_kings ])
189- score -= np . sum ( PST_KING_WHITE [ white_kings ])
229+ score += pst [ 0 ][ black_men ]. sum () # pst_man_black
230+ score -= pst [ 1 ][ white_men ]. sum () # pst_man_white
231+ score += pst [ 2 ][ black_kings ]. sum () # pst_king_black
232+ score -= pst [ 3 ][ white_kings ]. sum () # pst_king_white
190233
191234 # Return score relative to side to move
192- if board .turn == Color .WHITE :
193- return - score
194- return score
235+ return - score if board .turn == Color .WHITE else score
195236
196237 def get_best_move (self , board : BaseBoard , with_evaluation : bool = False ) -> Move | tuple [Move , float ]:
197238 """
@@ -215,12 +256,18 @@ def get_best_move(self, board: BaseBoard, with_evaluation: bool = False) -> Move
215256 self .nodes = 0
216257 self .stop_search = False
217258
259+ # Cache board-specific data for this search (avoids repeated lookups)
260+ num_squares = len (board ._pos )
261+ self ._current_zobrist = self ._get_zobrist_table (num_squares )
262+ rows = 10 if num_squares == 50 else (8 if num_squares == 32 else int (np .sqrt (num_squares * 2 )))
263+ self ._current_pst = self ._get_pst_tables (num_squares , rows )
264+
218265 # Age history table (decay old values)
219266 for key in self .history :
220267 self .history [key ] //= 2
221268
222269 # Initial Hash
223- current_hash = self .compute_hash (board )
270+ current_hash = self ._compute_hash_fast (board )
224271
225272 best_move : Move | None = None
226273 best_score = - INF
@@ -403,27 +450,28 @@ def quiescence_search(self, board: BaseBoard, alpha: float, beta: float, h: int,
403450 return alpha
404451
405452 def _update_hash (self , current_hash : int , board : BaseBoard , move : Move ) -> int :
453+ zt = self ._current_zobrist # Cached zobrist table
454+
406455 # XOR out source
407456 start_sq = move .square_list [0 ]
408457 piece = board ._pos [start_sq ]
409- current_hash ^= self . zobrist_table [start_sq ][self . _get_piece_index ( piece ) ]
458+ current_hash ^= zt [start_sq ][piece + 2 ]
410459
411460 # XOR in dest
412461 end_sq = move .square_list [- 1 ]
413462 new_piece = piece
414463 if move .is_promotion :
415- if piece == 1 : new_piece = 2
416- elif piece == - 1 : new_piece = - 2
464+ new_piece = 2 if piece == 1 else - 2
417465
418- current_hash ^= self . zobrist_table [end_sq ][self . _get_piece_index ( new_piece ) ]
466+ current_hash ^= zt [end_sq ][new_piece + 2 ]
419467
420468 # XOR out captures
421469 for cap_sq in move .captured_list :
422470 cap_piece = board ._pos [cap_sq ]
423- current_hash ^= self . zobrist_table [cap_sq ][self . _get_piece_index ( cap_piece ) ]
471+ current_hash ^= zt [cap_sq ][cap_piece + 2 ]
424472
425473 # Switch turn
426- current_hash ^= self .zobrist_turn
474+ current_hash ^= self ._zobrist_turn
427475
428476 return current_hash
429477
0 commit comments