Skip to content

Commit 5a2af9a

Browse files
committed
Refactor code structure for improved readability and maintainability
1 parent 7564168 commit 5a2af9a

File tree

7 files changed

+1784
-80
lines changed

7 files changed

+1784
-80
lines changed

draughts/boards/american.py

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import numpy as np
66

77
from draughts.boards.base import BaseBoard
8-
from draughts.models import Color, Figure
8+
from draughts.models import Color, Figure, EMPTY, MAN, KING
99
from draughts.move import Move
1010

1111
# fmt: off
@@ -46,58 +46,77 @@ def is_draw(self) -> bool:
4646

4747
@property
4848
def legal_moves(self) -> Generator[Move, None, None]:
49-
if self.turn == Color.BLACK:
50-
squares_list = np.transpose(np.nonzero(self._pos > 0))
51-
else:
52-
squares_list = np.transpose(np.nonzero(self._pos < 0))
53-
for square in squares_list.flatten():
49+
# Use flatnonzero for faster piece lookup
50+
turn_val = self.turn.value
51+
squares_list = np.flatnonzero(self._pos * turn_val > 0)
52+
for square in squares_list:
5453
moves = self._legal_moves_from(square)
5554
for move in moves:
5655
yield move
5756

5857
def _legal_moves_from(
5958
self, square: int, is_after_capture=False
60-
) -> Generator[Move, None, None]:
61-
row = self.ROW_IDX[square]
59+
) -> list[Move]:
60+
"""
61+
Generate legal moves using pre-computed attack tables.
62+
American checkers: men can only capture forward, kings can capture in all directions.
63+
"""
6264
moves = []
63-
odd = bool(row % 2 != 0 and self.turn == Color.BLACK) or (
64-
row % 2 == 0 and self.turn == Color.WHITE
65-
)
66-
is_king = bool(self[square] == self.turn.value * Figure.KING)
67-
# is_king = False # DEBUG
68-
for mv_offset, cap_offset, dir in [
69-
(4 - odd, 7, self.turn.value),
70-
(5 - odd, 9, self.turn.value),
71-
] + is_king * [
72-
(4 - (not odd), 7, -self.turn.value),
73-
(5 - (not odd), 9, -self.turn.value),
74-
]:
75-
move_sq = square + mv_offset * (dir)
76-
capture_sq = square + cap_offset * (dir)
77-
78-
if (
79-
0 <= move_sq < len(self._pos)
80-
and row + 1 * (dir) == self.ROW_IDX[move_sq]
81-
and self[move_sq] == 0
82-
and not is_after_capture
83-
):
84-
moves.append(Move([square, move_sq]))
85-
elif (
86-
0 <= capture_sq < len(self._pos)
87-
and row + 2 * (dir) == self.ROW_IDX[capture_sq]
88-
and self[capture_sq] == 0
89-
and self[move_sq] * self.turn.value < 0
90-
):
91-
move = Move(
92-
[square, capture_sq],
93-
captured_list=[move_sq],
94-
captured_entities=[self[move_sq]],
95-
)
96-
moves.append(move)
97-
self.push(move, False)
98-
moves += [move + m for m in self._legal_moves_from(capture_sq, True)]
99-
self.pop(False)
100-
65+
pos = self._pos
66+
turn_val = self.turn.value
67+
piece_val = abs(pos[square])
68+
is_king = piece_val == KING
69+
70+
# Use pre-computed attack tables
71+
attack_table = self.WHITE_MAN_ATTACKS if turn_val < 0 else self.BLACK_MAN_ATTACKS
72+
73+
if is_king:
74+
# Kings can move and capture in all directions using KING_DIAGONALS
75+
for direction in self.KING_DIAGONALS[square]:
76+
if len(direction) >= 1:
77+
target = direction[0]
78+
# Regular move (not after capture)
79+
if pos[target] == EMPTY and not is_after_capture:
80+
moves.append(Move([square, target]))
81+
# Capture
82+
if len(direction) >= 2:
83+
jump_over = direction[0]
84+
land_on = direction[1]
85+
if pos[jump_over] * turn_val < 0 and pos[land_on] == EMPTY:
86+
move = Move(
87+
[square, land_on],
88+
captured_list=[jump_over],
89+
captured_entities=[pos[jump_over]],
90+
)
91+
moves.append(move)
92+
self.push(move, False)
93+
moves += [move + m for m in self._legal_moves_from(land_on, True)]
94+
self.pop(False)
95+
else:
96+
# Men use attack table (forward moves only, but can capture forward)
97+
for entry in attack_table[square]:
98+
target = entry.target
99+
jump_over = entry.jump_over
100+
land_on = entry.land_on
101+
102+
# Regular move (only forward, not after capture)
103+
if target >= 0 and not is_after_capture:
104+
if pos[target] == EMPTY:
105+
moves.append(Move([square, target]))
106+
107+
# Capture move (men can only capture forward in American checkers)
108+
if jump_over >= 0 and land_on >= 0:
109+
if pos[jump_over] * turn_val < 0 and pos[land_on] == EMPTY:
110+
move = Move(
111+
[square, land_on],
112+
captured_list=[jump_over],
113+
captured_entities=[pos[jump_over]],
114+
)
115+
moves.append(move)
116+
self.push(move, False)
117+
moves += [move + m for m in self._legal_moves_from(land_on, True)]
118+
self.pop(False)
119+
101120
return moves
102121

103122

draughts/boards/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from draughts.utils import (
1313
get_diagonal_moves,
1414
get_short_diagonal_moves,
15+
generate_man_attack_tables,
16+
generate_king_attack_tables,
1517
)
1618

1719
# fmt: off
@@ -97,6 +99,16 @@ class BaseBoard(ABC):
9799
Despite the name, this contains LONG moves (all squares on diagonal) for kings.
98100
(one for move and one for capture)
99101
"""
102+
103+
# Pre-computed attack tables for optimized move generation
104+
WHITE_MAN_ATTACKS = ...
105+
"""Pre-computed attack table for white men: square -> list of (target, jump_over, land_on)"""
106+
107+
BLACK_MAN_ATTACKS = ...
108+
"""Pre-computed attack table for black men: square -> list of (target, jump_over, land_on)"""
109+
110+
KING_DIAGONALS = ...
111+
"""Pre-computed diagonal lines for kings: square -> [up-right, up-left, down-right, down-left]"""
100112

101113
def __init_subclass__(cls, **kwargs):
102114
parent_class = cls.__bases__[0]
@@ -110,6 +122,10 @@ def __init_subclass__(cls, **kwargs):
110122
# (first 2 squares) which is sufficient for men who only move/capture one square at a time
111123
cls.DIAGONAL_SHORT_MOVES = get_diagonal_moves(len(cls.STARTING_POSITION))
112124
cls.DIAGONAL_LONG_MOVES = get_short_diagonal_moves(len(cls.STARTING_POSITION))
125+
126+
# Generate pre-computed attack tables for optimized move generation
127+
cls.WHITE_MAN_ATTACKS, cls.BLACK_MAN_ATTACKS = generate_man_attack_tables(len(cls.STARTING_POSITION))
128+
cls.KING_DIAGONALS = generate_king_attack_tables(len(cls.STARTING_POSITION))
113129

114130
def __init__(
115131
self,

draughts/boards/standard.py

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@
66
from draughts.boards.base import BaseBoard
77
from draughts.models import Color, Figure, EMPTY, MAN, KING
88
from draughts.move import Move
9-
from draughts.utils import (
10-
get_diagonal_moves,
11-
get_short_diagonal_moves,
12-
)
139

1410
# fmt: off
1511
SQUARES= [ B10, D10, F10, H10, J10,
@@ -141,38 +137,41 @@ def _get_man_legal_moves_from(
141137
moves = []
142138
pos = self._pos # Local reference for faster access
143139
turn_val = self.turn.value
144-
# white can move only on even directions
145-
for idx, direction in enumerate(self.DIAGONAL_LONG_MOVES[square]):
146-
if (
147-
len(direction) > 0
148-
and (turn_val + idx)
149-
in [-1, 0, 3, 4] # TERRIBLE HACK get only directions for given piece
150-
and pos[direction[0]] == EMPTY
151-
and not is_capture_mandatory
152-
):
153-
moves.append(Move([square, direction[0]]))
154-
elif (
155-
len(direction) > 1
156-
and pos[direction[0]] * turn_val < 0
157-
and pos[direction[1]] == EMPTY
158-
):
159-
move = Move(
160-
[square, direction[1]], [direction[0]], [pos[direction[0]]]
161-
)
162-
# moves.append(move)
163-
self.push(move, False)
164-
new_moves = [
165-
move + m for m in self._get_man_legal_moves_from(direction[1], True)
166-
]
167-
moves += [move] if len(new_moves) == 0 else new_moves
168-
self.pop(False)
140+
141+
# Use pre-computed attack tables based on turn
142+
attack_table = self.WHITE_MAN_ATTACKS if turn_val < 0 else self.BLACK_MAN_ATTACKS
143+
144+
for entry in attack_table[square]:
145+
target = entry.target
146+
jump_over = entry.jump_over
147+
land_on = entry.land_on
148+
149+
# Regular move (only if target is valid and not in capture mode)
150+
if target >= 0 and not is_capture_mandatory:
151+
if pos[target] == EMPTY:
152+
moves.append(Move([square, target]))
153+
154+
# Capture move (jump over enemy, land on empty)
155+
if jump_over >= 0 and land_on >= 0:
156+
if pos[jump_over] * turn_val < 0 and pos[land_on] == EMPTY:
157+
move = Move(
158+
[square, land_on], [jump_over], [pos[jump_over]]
159+
)
160+
self.push(move, False)
161+
new_moves = [
162+
move + m for m in self._get_man_legal_moves_from(land_on, True)
163+
]
164+
moves += [move] if len(new_moves) == 0 else new_moves
165+
self.pop(False)
166+
169167
return moves
170168

171169
def _get_king_legal_moves_from(
172170
self, square: int, is_capture_mandatory: bool, forbidden_squares: set[int] | None = None
173171
) -> list[Move]:
174172
"""
175173
Generate legal moves for a king from a given square.
174+
Uses pre-computed KING_DIAGONALS for optimized diagonal traversal.
176175
177176
Args:
178177
square: The current square of the king
@@ -184,7 +183,8 @@ def _get_king_legal_moves_from(
184183
turn_val = self.turn.value
185184
max_len = 0 # Track max length across ALL directions
186185

187-
for direction in self.DIAGONAL_SHORT_MOVES[square]:
186+
# Use pre-computed king diagonal table
187+
for direction in self.KING_DIAGONALS[square]:
188188
dir_len = len(direction)
189189
for idx, target in enumerate(direction):
190190
# Check if path crosses a forbidden square (previously captured piece)

draughts/engines/hub.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ def __init__(
349349
self,
350350
path: str | Path,
351351
time_limit: float = 1.0,
352-
depth_limit: int = 6,
352+
depth_limit: Optional[int] = None,
353353
init_timeout: float = 10.0,
354354
):
355355
"""
@@ -358,7 +358,7 @@ def __init__(
358358
Args:
359359
path: Path to the engine executable
360360
time_limit: Time limit per move in seconds (default 1.0)
361-
depth_limit: Maximum search depth
361+
depth_limit: Maximum search depth (None for no limit)
362362
init_timeout: Timeout for engine initialization in seconds
363363
"""
364364
self.path = Path(path)

draughts/utils.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,79 @@
11
import numpy as np
22
from collections import defaultdict
3+
from typing import NamedTuple
4+
5+
6+
class AttackEntry(NamedTuple):
7+
"""Pre-computed attack info for a square in a direction."""
8+
target: int # Move target (adjacent empty square)
9+
jump_over: int # Square to jump over for capture
10+
land_on: int # Landing square after capture
11+
12+
13+
def generate_man_attack_tables(position_length: int) -> tuple[dict, dict]:
14+
"""
15+
Generate pre-computed attack tables for men pieces.
16+
17+
Returns:
18+
white_attacks: dict[square, list[AttackEntry]] - for white men (moving up)
19+
black_attacks: dict[square, list[AttackEntry]] - for black men (moving down)
20+
"""
21+
diagonal_moves = get_short_diagonal_moves(position_length)
22+
23+
white_attacks: dict[int, list[AttackEntry]] = {}
24+
black_attacks: dict[int, list[AttackEntry]] = {}
25+
26+
for square in range(position_length):
27+
directions = diagonal_moves[square]
28+
# directions: [up-right, up-left, down-right, down-left]
29+
30+
white_attacks[square] = []
31+
black_attacks[square] = []
32+
33+
# White moves up (directions 0, 1)
34+
for dir_idx in [0, 1]: # up-right, up-left
35+
d = directions[dir_idx]
36+
if len(d) >= 1:
37+
target = d[0]
38+
jump_over = d[0] if len(d) >= 2 else -1
39+
land_on = d[1] if len(d) >= 2 else -1
40+
white_attacks[square].append(AttackEntry(target, jump_over, land_on))
41+
42+
# Black moves down (directions 2, 3)
43+
for dir_idx in [2, 3]: # down-right, down-left
44+
d = directions[dir_idx]
45+
if len(d) >= 1:
46+
target = d[0]
47+
jump_over = d[0] if len(d) >= 2 else -1
48+
land_on = d[1] if len(d) >= 2 else -1
49+
black_attacks[square].append(AttackEntry(target, jump_over, land_on))
50+
51+
# Both colors can capture in all 4 directions
52+
for dir_idx in range(4):
53+
d = directions[dir_idx]
54+
if len(d) >= 2:
55+
# Add capture-only entries for backward captures
56+
if dir_idx in [2, 3]: # backward for white
57+
jump_over = d[0]
58+
land_on = d[1]
59+
white_attacks[square].append(AttackEntry(-1, jump_over, land_on))
60+
if dir_idx in [0, 1]: # backward for black
61+
jump_over = d[0]
62+
land_on = d[1]
63+
black_attacks[square].append(AttackEntry(-1, jump_over, land_on))
64+
65+
return white_attacks, black_attacks
66+
67+
68+
def generate_king_attack_tables(position_length: int) -> dict[int, list[list[int]]]:
69+
"""
70+
Generate pre-computed diagonal lines for kings.
71+
Kings can move along entire diagonals, so we store the full diagonal for each direction.
72+
73+
Returns:
74+
dict[square, list[list[int]]] - for each square, 4 lists of squares in each diagonal direction
75+
"""
76+
return get_diagonal_moves(position_length)
377

478

579
def _get_all_squares_at_the_diagonal(square: int, position_length: int) -> list[list[int]]:

tools/compare_versions.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,22 @@ def start(self):
228228
stderr=subprocess.PIPE,
229229
text=True,
230230
bufsize=1, # Line buffered
231+
env={**__import__("os").environ, "PYTHONUNBUFFERED": "1"},
231232
)
232233
# Wait for ready signal
233234
assert self.process.stdout is not None
234235
ready = self.process.stdout.readline()
235-
if not ready or "ready" not in ready:
236-
raise RuntimeError(f"Worker failed to start: {ready}")
236+
if not ready:
237+
# Try to get stderr for better error message
238+
assert self.process.stderr is not None
239+
stderr = self.process.stderr.read()
240+
raise RuntimeError(f"Worker failed to start (no output). stderr: {stderr}")
241+
try:
242+
data = json.loads(ready)
243+
if data.get("status") != "ready":
244+
raise RuntimeError(f"Worker failed to start: {ready}")
245+
except json.JSONDecodeError:
246+
raise RuntimeError(f"Worker failed to start (invalid JSON): {ready}")
237247

238248
def get_move(self, fen: str | None, depth: int) -> dict:
239249
"""Get engine move from persistent worker."""

0 commit comments

Comments
 (0)