play-ch0 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ch0/__init__.py +1 -0
- ch0/__main__.py +4 -0
- ch0/cli.py +416 -0
- ch0/engines/__init__.py +1 -0
- ch0/engines/andoma/LICENSE +21 -0
- ch0/engines/andoma/README.md +114 -0
- ch0/engines/andoma/__init__.py +0 -0
- ch0/engines/andoma/communication.py +83 -0
- ch0/engines/andoma/evaluate.py +217 -0
- ch0/engines/andoma/main.py +3 -0
- ch0/engines/andoma/movegeneration.py +150 -0
- ch0/engines/andoma/requirements-dev.txt +1 -0
- ch0/engines/andoma/requirements.txt +1 -0
- ch0/engines/andoma/uci-interface.md +544 -0
- ch0/engines/andoma/ui.py +86 -0
- ch0/engines/stockfish-191-64-ja +0 -0
- ch0/engines/stockfish_8_x64 +0 -0
- ch0/engines/sunfish/LICENSE.md +596 -0
- ch0/engines/sunfish/README.md +104 -0
- ch0/engines/sunfish/__init__.py +0 -0
- ch0/engines/sunfish/main.py +16 -0
- ch0/engines/sunfish/packed.sh +0 -0
- ch0/engines/sunfish/sunfish.py +894 -0
- ch0/engines/sunfish/sunfish_uci.py +53 -0
- ch0/engines/sunfish.py +450 -0
- ch0/engines/uci.py +160 -0
- play_ch0-0.2.2.dist-info/METADATA +657 -0
- play_ch0-0.2.2.dist-info/RECORD +31 -0
- play_ch0-0.2.2.dist-info/WHEEL +4 -0
- play_ch0-0.2.2.dist-info/entry_points.txt +3 -0
- play_ch0-0.2.2.dist-info/licenses/LICENSE +596 -0
ch0/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ch0 package."""
|
ch0/__main__.py
ADDED
ch0/cli.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run
|
|
2
|
+
import random
|
|
3
|
+
import subprocess
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
import chess
|
|
10
|
+
import chess.pgn
|
|
11
|
+
import chess.polyglot
|
|
12
|
+
import chess.engine
|
|
13
|
+
|
|
14
|
+
from .engines.andoma.movegeneration import next_move as andoma_gen
|
|
15
|
+
from .engines.sunfish import sunfish_uci
|
|
16
|
+
from .engines.sunfish.tools import uci
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Colors (ANSI) ------------------------------------------------------------
|
|
20
|
+
# Works in most terminals. On Windows, ANSI is supported in modern terminals;
|
|
21
|
+
# if you need broader support, install `colorama` and it will be used if present.
|
|
22
|
+
try:
|
|
23
|
+
import colorama # type: ignore
|
|
24
|
+
colorama.just_fix_windows_console()
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Style:
|
|
30
|
+
RESET = "\033[0m"
|
|
31
|
+
BOLD = "\033[1m"
|
|
32
|
+
DIM = "\033[2m"
|
|
33
|
+
|
|
34
|
+
RED = "\033[31m"
|
|
35
|
+
GREEN = "\033[32m"
|
|
36
|
+
YELLOW = "\033[33m"
|
|
37
|
+
BLUE = "\033[34m"
|
|
38
|
+
MAGENTA = "\033[35m"
|
|
39
|
+
CYAN = "\033[36m"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def c(text: str, *styles: str) -> str:
|
|
43
|
+
return "".join(styles) + text + Style.RESET
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- Game logic --------------------------------------------------------------
|
|
47
|
+
class Game:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
engine_kind: str,
|
|
51
|
+
engine_name: str,
|
|
52
|
+
player_color: chess.Color,
|
|
53
|
+
engine: chess.engine.SimpleEngine | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.board = chess.Board()
|
|
56
|
+
self.engine_kind = engine_kind # "random", "andoma", "sunfish", "uci"
|
|
57
|
+
self.engine_name = engine_name
|
|
58
|
+
self.player_color = player_color
|
|
59
|
+
self.engine = engine
|
|
60
|
+
self.turn = chess.WHITE # whose turn it is to move in our bookkeeping
|
|
61
|
+
self.count = 0 # move number (full moves)
|
|
62
|
+
self.pgn_text = ""
|
|
63
|
+
self.ended = False
|
|
64
|
+
|
|
65
|
+
def reset(self):
|
|
66
|
+
self.board.set_fen(chess.STARTING_FEN)
|
|
67
|
+
self.turn = chess.WHITE
|
|
68
|
+
self.count = 0
|
|
69
|
+
self.pgn_text = ""
|
|
70
|
+
self.ended = False
|
|
71
|
+
|
|
72
|
+
def close_engine(self):
|
|
73
|
+
if self.engine is None:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
self.engine.quit()
|
|
77
|
+
except Exception:
|
|
78
|
+
try:
|
|
79
|
+
self.engine.close()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
self.engine = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_a_draw(board: chess.Board):
|
|
86
|
+
"""Return (is_draw, message)."""
|
|
87
|
+
if board.is_stalemate():
|
|
88
|
+
return True, "Stalemate"
|
|
89
|
+
elif board.is_insufficient_material():
|
|
90
|
+
return True, "Insufficient Material"
|
|
91
|
+
elif board.can_claim_fifty_moves():
|
|
92
|
+
return True, "Fifty-move rule"
|
|
93
|
+
elif board.can_claim_threefold_repetition():
|
|
94
|
+
return True, "Threefold repetition"
|
|
95
|
+
return False, ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def bool_color_to_string(color_b: chess.Color) -> str:
|
|
99
|
+
return "white" if color_b == chess.WHITE else "black"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def finalize_pgn(pgn_str: str, player_color: chess.Color, engine_name: str):
|
|
103
|
+
final_pgn = pgn_str + "\n\n"
|
|
104
|
+
game_pgn = chess.pgn.read_game(io.StringIO(final_pgn))
|
|
105
|
+
game_pgn.headers["Event"] = "Blind-chess match"
|
|
106
|
+
game_pgn.headers["Site"] = "Terminal"
|
|
107
|
+
if player_color == chess.WHITE:
|
|
108
|
+
game_pgn.headers["White"] = "Me"
|
|
109
|
+
game_pgn.headers["Black"] = f"{engine_name} Bot"
|
|
110
|
+
else:
|
|
111
|
+
game_pgn.headers["White"] = f"{engine_name} Bot"
|
|
112
|
+
game_pgn.headers["Black"] = "Me"
|
|
113
|
+
game_pgn.headers["Date"] = date.today().isoformat()
|
|
114
|
+
return game_pgn
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def bot_makes_a_move(game: Game):
|
|
118
|
+
board = game.board
|
|
119
|
+
move = random.choice(list(board.legal_moves))
|
|
120
|
+
|
|
121
|
+
if game.engine_kind == "andoma":
|
|
122
|
+
move = andoma_gen(depth=4, board=board, debug=False)
|
|
123
|
+
elif game.engine_kind == "sunfish":
|
|
124
|
+
position = uci.from_fen(*board.fen().split())
|
|
125
|
+
current_hist = (
|
|
126
|
+
[position]
|
|
127
|
+
if uci.get_color(position) == uci.WHITE
|
|
128
|
+
else [position.rotate(), position]
|
|
129
|
+
)
|
|
130
|
+
total_time = random.randint(10, 60)
|
|
131
|
+
_, uci_move_str = sunfish_uci.generate_move(current_hist, total_time)
|
|
132
|
+
move = chess.Move.from_uci(uci_move_str)
|
|
133
|
+
elif game.engine_kind == "uci":
|
|
134
|
+
if game.engine is None:
|
|
135
|
+
raise RuntimeError("UCI engine is not initialized.")
|
|
136
|
+
think_time = random.uniform(0.1, 0.5)
|
|
137
|
+
result = game.engine.play(board, chess.engine.Limit(time=think_time))
|
|
138
|
+
move = result.move
|
|
139
|
+
|
|
140
|
+
# optional opening book
|
|
141
|
+
coin = random.randint(0, 1)
|
|
142
|
+
if game.count < 15 and coin == 1:
|
|
143
|
+
try:
|
|
144
|
+
with chess.polyglot.open_reader("book.bin") as reader:
|
|
145
|
+
move = reader.weighted_choice(board).move
|
|
146
|
+
except (IndexError, FileNotFoundError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
move_san = board.san(move)
|
|
150
|
+
board.push(move)
|
|
151
|
+
|
|
152
|
+
if game.turn == chess.WHITE:
|
|
153
|
+
game.count += 1
|
|
154
|
+
game.pgn_text += f"\n{game.count}. {move_san}"
|
|
155
|
+
else:
|
|
156
|
+
game.pgn_text += f" {move_san}"
|
|
157
|
+
|
|
158
|
+
# Minimal engine output (colored, no label)
|
|
159
|
+
print(c(move_san, Style.MAGENTA, Style.BOLD))
|
|
160
|
+
|
|
161
|
+
# draw / checkmate handling
|
|
162
|
+
check_draw, draw_type = is_a_draw(board)
|
|
163
|
+
if check_draw:
|
|
164
|
+
print(c(f"Draw: {draw_type}", Style.YELLOW, Style.BOLD))
|
|
165
|
+
game.pgn_text += " { The game is a draw. } 1/2-1/2"
|
|
166
|
+
game.ended = True
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
if board.is_checkmate():
|
|
170
|
+
print(c("Checkmate.", Style.RED, Style.BOLD))
|
|
171
|
+
result = "0-1" if game.player_color == chess.WHITE else "1-0"
|
|
172
|
+
game.pgn_text += (
|
|
173
|
+
f" {{ {bool_color_to_string(not game.player_color)} wins by checkmate. }} "
|
|
174
|
+
f"{result}"
|
|
175
|
+
)
|
|
176
|
+
game.ended = True
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
game.turn = not game.turn
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _engine_display_name(command: str, engine: chess.engine.SimpleEngine) -> str:
|
|
183
|
+
name = engine.id.get("name")
|
|
184
|
+
if name:
|
|
185
|
+
return name
|
|
186
|
+
return os.path.basename(command.split()[0]) or "UCI"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _spawn_uci_engine(command: str) -> chess.engine.SimpleEngine | None:
|
|
190
|
+
cmd = shlex.split(command)
|
|
191
|
+
if not cmd:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
return chess.engine.SimpleEngine.popen_uci(cmd, stderr=subprocess.DEVNULL)
|
|
195
|
+
except (FileNotFoundError, PermissionError, OSError, chess.engine.EngineError):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def choose_engine() -> tuple[str, str, chess.engine.SimpleEngine | None]:
|
|
200
|
+
options = ["random", "andoma", "sunfish", "uci"]
|
|
201
|
+
print(c("Choose engine:", Style.CYAN, Style.BOLD))
|
|
202
|
+
for i, name in enumerate(options, start=1):
|
|
203
|
+
print(f" {c(str(i) + '.', Style.DIM)} {c(name, Style.CYAN)}")
|
|
204
|
+
while True:
|
|
205
|
+
choice = input(c("engine> ", Style.DIM)).strip().lower()
|
|
206
|
+
if choice in {"1", "2", "3", "4"}:
|
|
207
|
+
choice = options[int(choice) - 1]
|
|
208
|
+
if choice in options:
|
|
209
|
+
if choice != "uci":
|
|
210
|
+
return choice, choice, None
|
|
211
|
+
while True:
|
|
212
|
+
cmd = input(c("uci engine path/command> ", Style.DIM)).strip()
|
|
213
|
+
engine = _spawn_uci_engine(cmd)
|
|
214
|
+
if engine is not None:
|
|
215
|
+
display_name = _engine_display_name(cmd, engine)
|
|
216
|
+
return "uci", display_name, engine
|
|
217
|
+
print(c("Could not start engine. Try again.", Style.RED))
|
|
218
|
+
print(c("Invalid choice.", Style.RED))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def choose_color():
|
|
222
|
+
print(c("Choose your color:", Style.CYAN, Style.BOLD))
|
|
223
|
+
print(f" {c('1.', Style.DIM)} {c('white', Style.CYAN)}")
|
|
224
|
+
print(f" {c('2.', Style.DIM)} {c('black', Style.CYAN)}")
|
|
225
|
+
print(f" {c('3.', Style.DIM)} {c('random', Style.CYAN)}")
|
|
226
|
+
while True:
|
|
227
|
+
choice = input(c("color> ", Style.DIM)).strip().lower()
|
|
228
|
+
if choice in {"1", "white"}:
|
|
229
|
+
return chess.WHITE
|
|
230
|
+
if choice in {"2", "black"}:
|
|
231
|
+
return chess.BLACK
|
|
232
|
+
if choice in {"3", "random"}:
|
|
233
|
+
return random.choice([chess.WHITE, chess.BLACK])
|
|
234
|
+
print(c("Invalid choice.", Style.RED))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def print_help():
|
|
238
|
+
print(c("Lobby:", Style.CYAN, Style.BOLD))
|
|
239
|
+
print(f" {c('start', Style.CYAN)} start a new game")
|
|
240
|
+
print(f" {c('help', Style.CYAN)} show this help")
|
|
241
|
+
print(f" {c('quit', Style.CYAN)} quit")
|
|
242
|
+
print()
|
|
243
|
+
print(c("In-game:", Style.CYAN, Style.BOLD))
|
|
244
|
+
print(f" {c('show', Style.CYAN)} show the board")
|
|
245
|
+
print(f" {c('moves', Style.CYAN)} show legal moves (SAN)")
|
|
246
|
+
print(f" {c('fen', Style.CYAN)} show FEN")
|
|
247
|
+
print(f" {c('pgn', Style.CYAN)} show PGN so far")
|
|
248
|
+
print(f" {c('resign', Style.CYAN)} resign the game")
|
|
249
|
+
print()
|
|
250
|
+
print(c("Or type a move in SAN, e.g. e4, Nf3, exd5, a8=Q.", Style.DIM))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_command(s: str):
|
|
254
|
+
"""Normalize commands: ':show' and 'show' both become 'show'."""
|
|
255
|
+
s = s.strip()
|
|
256
|
+
if not s:
|
|
257
|
+
return None
|
|
258
|
+
if s.startswith(":"):
|
|
259
|
+
s = s[1:]
|
|
260
|
+
return s.lower()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def ask_yes_no(prompt: str, default_no: bool = True) -> bool:
|
|
264
|
+
suffix = " [y/N]: " if default_no else " [Y/n]: "
|
|
265
|
+
while True:
|
|
266
|
+
ans = input(c(prompt + suffix, Style.DIM)).strip().lower()
|
|
267
|
+
if not ans:
|
|
268
|
+
return not default_no
|
|
269
|
+
if ans in {"y", "yes"}:
|
|
270
|
+
return True
|
|
271
|
+
if ans in {"n", "no"}:
|
|
272
|
+
return False
|
|
273
|
+
print(c("Please answer y or n.", Style.RED))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main():
|
|
277
|
+
print(c("\nBlindfold Chess\n", Style.BOLD))
|
|
278
|
+
print_help()
|
|
279
|
+
print()
|
|
280
|
+
|
|
281
|
+
game: Game | None = None
|
|
282
|
+
|
|
283
|
+
while True:
|
|
284
|
+
# If a game ended, optionally print PGN, then return to lobby.
|
|
285
|
+
if game is not None and game.ended:
|
|
286
|
+
if game.pgn_text and ask_yes_no("Print final PGN?", default_no=True):
|
|
287
|
+
print()
|
|
288
|
+
print(c("Final PGN:", Style.CYAN, Style.BOLD))
|
|
289
|
+
print(finalize_pgn(game.pgn_text, game.player_color, game.engine_name))
|
|
290
|
+
game.close_engine()
|
|
291
|
+
game = None
|
|
292
|
+
print()
|
|
293
|
+
print(c("Lobby. Type 'start' to play.", Style.DIM))
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# No active game: only limited commands work.
|
|
297
|
+
if game is None:
|
|
298
|
+
user_in = input(c("> ", Style.DIM)).strip()
|
|
299
|
+
if not user_in:
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
cmd = parse_command(user_in)
|
|
303
|
+
if cmd == "start":
|
|
304
|
+
engine_kind, engine_name, engine = choose_engine()
|
|
305
|
+
player_color = choose_color()
|
|
306
|
+
game = Game(engine_kind, engine_name, player_color, engine=engine)
|
|
307
|
+
|
|
308
|
+
print()
|
|
309
|
+
print(
|
|
310
|
+
c("You:", Style.DIM)
|
|
311
|
+
+ " "
|
|
312
|
+
+ c(bool_color_to_string(player_color), Style.CYAN, Style.BOLD)
|
|
313
|
+
+ c(" vs ", Style.DIM)
|
|
314
|
+
+ c(engine_name, Style.CYAN, Style.BOLD)
|
|
315
|
+
)
|
|
316
|
+
print(c("Tip: type 'show' to display the board.", Style.DIM))
|
|
317
|
+
print()
|
|
318
|
+
|
|
319
|
+
# If the engine is white, let it move first.
|
|
320
|
+
if player_color == chess.BLACK:
|
|
321
|
+
bot_makes_a_move(game)
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
if cmd == "help":
|
|
325
|
+
print_help()
|
|
326
|
+
print()
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
if cmd == "quit":
|
|
330
|
+
print(c("Goodbye.", Style.DIM))
|
|
331
|
+
if game is not None:
|
|
332
|
+
game.close_engine()
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
print(c("No active game. Type 'start' (or 'help', 'quit').", Style.RED))
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# If it's engine's turn, just let it move.
|
|
339
|
+
if game.board.turn != game.player_color:
|
|
340
|
+
bot_makes_a_move(game)
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
user_in = input(c("> ", Style.DIM)).strip()
|
|
344
|
+
if not user_in:
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
cmd = parse_command(user_in)
|
|
348
|
+
|
|
349
|
+
# Known in-game commands
|
|
350
|
+
if cmd in {"help", "show", "moves", "fen", "pgn", "resign", "quit", "start"}:
|
|
351
|
+
if cmd == "help":
|
|
352
|
+
print_help()
|
|
353
|
+
elif cmd == "show":
|
|
354
|
+
print(game.board)
|
|
355
|
+
elif cmd == "moves":
|
|
356
|
+
moves_in_uci = list(game.board.legal_moves)
|
|
357
|
+
moves_in_san = [game.board.san(m) for m in moves_in_uci]
|
|
358
|
+
print(" ".join(moves_in_san))
|
|
359
|
+
elif cmd == "fen":
|
|
360
|
+
print(game.board.fen())
|
|
361
|
+
elif cmd == "pgn":
|
|
362
|
+
print(finalize_pgn(game.pgn_text, game.player_color, game.engine_name))
|
|
363
|
+
elif cmd == "resign":
|
|
364
|
+
print(c("Resigned.", Style.YELLOW, Style.BOLD))
|
|
365
|
+
result = "0-1" if game.player_color == chess.WHITE else "1-0"
|
|
366
|
+
game.pgn_text += (
|
|
367
|
+
f" {{ {bool_color_to_string(game.player_color)} resigns. }} {result}"
|
|
368
|
+
)
|
|
369
|
+
game.ended = True
|
|
370
|
+
elif cmd == "quit":
|
|
371
|
+
print(c("Goodbye.", Style.DIM))
|
|
372
|
+
game.close_engine()
|
|
373
|
+
break
|
|
374
|
+
elif cmd == "start":
|
|
375
|
+
print(c("Game in progress. Finish or resign first.", Style.RED))
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
# Otherwise, try to interpret it as a move in SAN
|
|
379
|
+
try:
|
|
380
|
+
game.board.push_san(user_in)
|
|
381
|
+
except ValueError:
|
|
382
|
+
print(c("Illegal move / unknown command.", Style.RED))
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# Optional: extremely subtle acknowledgement (comment out if you want *zero* noise)
|
|
386
|
+
# print(c("✓", Style.GREEN, Style.DIM))
|
|
387
|
+
|
|
388
|
+
if game.turn == chess.WHITE:
|
|
389
|
+
game.count += 1
|
|
390
|
+
game.pgn_text += f"\n{game.count}. {user_in}"
|
|
391
|
+
else:
|
|
392
|
+
game.pgn_text += f" {user_in}"
|
|
393
|
+
|
|
394
|
+
check_draw, draw_type = is_a_draw(game.board)
|
|
395
|
+
if check_draw:
|
|
396
|
+
print(c(f"Draw: {draw_type}", Style.YELLOW, Style.BOLD))
|
|
397
|
+
game.pgn_text += " { The game is a draw. } 1/2-1/2"
|
|
398
|
+
game.ended = True
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
if game.board.is_checkmate():
|
|
402
|
+
print(c("Checkmate. You win.", Style.GREEN, Style.BOLD))
|
|
403
|
+
result = "1-0" if game.player_color == chess.WHITE else "0-1"
|
|
404
|
+
game.pgn_text += (
|
|
405
|
+
f" {{ {bool_color_to_string(game.player_color)} wins by checkmate. }} "
|
|
406
|
+
f"{result}"
|
|
407
|
+
)
|
|
408
|
+
game.ended = True
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
game.turn = not game.turn
|
|
412
|
+
# engine will move in the next iteration
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
if __name__ == "__main__":
|
|
416
|
+
main()
|
ch0/engines/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine package wrapper for distribution."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Andrew Healey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[](https://github.com/healeycodes/andoma/actions/workflows/python-app.yml)
|
|
2
|
+
|
|
3
|
+
# ♟ Andoma
|
|
4
|
+
> My blog post: [Building My Own Chess Engine](https://healeycodes.com/building-my-own-chess-engine/)
|
|
5
|
+
|
|
6
|
+
<br>
|
|
7
|
+
|
|
8
|
+
A chess engine which implements:
|
|
9
|
+
- [Alpha-beta pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning) for move searching
|
|
10
|
+
- [Move ordering](https://www.chessprogramming.org/Move_Ordering) based off heuristics like captures and promotions
|
|
11
|
+
- Tomasz Michniewski's [Simplified Evaluation Function](https://www.chessprogramming.org/Simplified_Evaluation_Function) for board evaluation and piece-square tables
|
|
12
|
+
- A slice of the Universal Chess Interface (UCI) to allow challenges via lichess.org
|
|
13
|
+
- A command-line user interface
|
|
14
|
+
|
|
15
|
+
It uses Python 3.8 with Mypy type hints and unit + integration tests.
|
|
16
|
+
|
|
17
|
+
See [Contributing](#contributing) to help out!
|
|
18
|
+
|
|
19
|
+
<br>
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
`pip install -r requirements.txt`
|
|
24
|
+
|
|
25
|
+
<br>
|
|
26
|
+
|
|
27
|
+
## Use it via command-line
|
|
28
|
+
|
|
29
|
+
Start the engine with:
|
|
30
|
+
|
|
31
|
+
`python ui.py`
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
Start as [w]hite or [b]lack:
|
|
35
|
+
w
|
|
36
|
+
|
|
37
|
+
8 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
|
|
38
|
+
7 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙
|
|
39
|
+
6 · · · · · · · ·
|
|
40
|
+
5 · · · · · · · ·
|
|
41
|
+
4 · · · · · · · ·
|
|
42
|
+
3 · · · · · · · ·
|
|
43
|
+
2 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟
|
|
44
|
+
1 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜
|
|
45
|
+
a b c d e f g h
|
|
46
|
+
|
|
47
|
+
Enter a move like g1h3:
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
<br>
|
|
51
|
+
|
|
52
|
+
## Use it as a UCI engine
|
|
53
|
+
|
|
54
|
+
_The only interfaces that Andoma currently supports are [ShailChoksi/lichess-bot](https://github.com/ShailChoksi/lichess-bot) and the command-line UI (ui.py). Debug information and configuration options are minimal compared to a full UCI engine._
|
|
55
|
+
|
|
56
|
+
<br>
|
|
57
|
+
|
|
58
|
+
Start the engine with:
|
|
59
|
+
|
|
60
|
+
`python main.py`
|
|
61
|
+
|
|
62
|
+
An example interaction with the engine (responses have `#`):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uci
|
|
66
|
+
# id name Andoma
|
|
67
|
+
# id author Andrew Healey & Roma Parramore
|
|
68
|
+
# uciok
|
|
69
|
+
position startpos moves e2e4
|
|
70
|
+
go
|
|
71
|
+
# bestmove g8f6
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Also accepts a FEN string:
|
|
75
|
+
|
|
76
|
+
`position fen rnbqk1nr/p1ppppbp/1p4p1/8/2P5/2Q5/PP1PPPPP/RNB1KBNR b KQkq - 0 1`
|
|
77
|
+
|
|
78
|
+
<br>
|
|
79
|
+
|
|
80
|
+
See the [UCI interface doc](https://github.com/healeycodes/andoma/blob/main/uci-interface.md) for more information on communicating with the engine.
|
|
81
|
+
|
|
82
|
+
<br>
|
|
83
|
+
|
|
84
|
+
## Lichess.org
|
|
85
|
+
|
|
86
|
+
The UCI protocol slice that's implemented by this engine means you can play it via lichess.org by using [ShailChoksi/lichess-bot](https://github.com/ShailChoksi/lichess-bot) (a bridge between Lichess API and chess engines) and a BOT account.
|
|
87
|
+
|
|
88
|
+
The engine file required by `lichess-bot` may be generated using [pyinstaller](https://www.pyinstaller.org/).
|
|
89
|
+
|
|
90
|
+
<br>
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
There are unit tests for the engine, UI, and evaluation modules. Mate-in-two/mate-in-three puzzles are being added.
|
|
95
|
+
|
|
96
|
+
`python -m unittest discover test/`
|
|
97
|
+
|
|
98
|
+
Type hints:
|
|
99
|
+
|
|
100
|
+
`pip install -r requirements-dev.txt`
|
|
101
|
+
|
|
102
|
+
`mypy .`
|
|
103
|
+
|
|
104
|
+
<br>
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Raise an issue to propose a bug fix or feature (or pick up an existing one).
|
|
109
|
+
|
|
110
|
+
I ([@healeycodes](https://github.com/healeycodes)) am happy to help you along the way.
|
|
111
|
+
|
|
112
|
+
For coding style: look at the existing files, use Mypy types, use PEP8, and add a test for any change in functionality.
|
|
113
|
+
|
|
114
|
+
Please run the tests locally before submitting a PR.
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import chess
|
|
3
|
+
import argparse
|
|
4
|
+
from movegeneration import next_move
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def talk():
|
|
8
|
+
"""
|
|
9
|
+
The main input/output loop.
|
|
10
|
+
This implements a slice of the UCI protocol.
|
|
11
|
+
"""
|
|
12
|
+
board = chess.Board()
|
|
13
|
+
depth = get_depth()
|
|
14
|
+
|
|
15
|
+
while True:
|
|
16
|
+
msg = input()
|
|
17
|
+
command(depth, board, msg)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def command(depth: int, board: chess.Board, msg: str):
|
|
21
|
+
"""
|
|
22
|
+
Accept UCI commands and respond.
|
|
23
|
+
The board state is also updated.
|
|
24
|
+
"""
|
|
25
|
+
msg = msg.strip()
|
|
26
|
+
tokens = msg.split(" ")
|
|
27
|
+
while "" in tokens:
|
|
28
|
+
tokens.remove("")
|
|
29
|
+
|
|
30
|
+
if msg == "quit":
|
|
31
|
+
sys.exit()
|
|
32
|
+
|
|
33
|
+
if msg == "uci":
|
|
34
|
+
print("id name Andoma") # Andrew/Roma -> And/oma
|
|
35
|
+
print("id author Andrew Healey & Roma Parramore")
|
|
36
|
+
print("uciok")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if msg == "isready":
|
|
40
|
+
print("readyok")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if msg == "ucinewgame":
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if msg.startswith("position"):
|
|
47
|
+
if len(tokens) < 2:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Set starting position
|
|
51
|
+
if tokens[1] == "startpos":
|
|
52
|
+
board.reset()
|
|
53
|
+
moves_start = 2
|
|
54
|
+
elif tokens[1] == "fen":
|
|
55
|
+
fen = " ".join(tokens[2:8])
|
|
56
|
+
board.set_fen(fen)
|
|
57
|
+
moves_start = 8
|
|
58
|
+
else:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Apply moves
|
|
62
|
+
if len(tokens) <= moves_start or tokens[moves_start] != "moves":
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
for move in tokens[(moves_start+1):]:
|
|
66
|
+
board.push_uci(move)
|
|
67
|
+
|
|
68
|
+
if msg == "d":
|
|
69
|
+
# Non-standard command, but supported by Stockfish and helps debugging
|
|
70
|
+
print(board)
|
|
71
|
+
print(board.fen())
|
|
72
|
+
|
|
73
|
+
if msg[0:2] == "go":
|
|
74
|
+
_move = next_move(depth, board)
|
|
75
|
+
print(f"bestmove {_move}")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_depth() -> int:
|
|
80
|
+
parser = argparse.ArgumentParser()
|
|
81
|
+
parser.add_argument("--depth", default=3, help="provide an integer (default: 3)")
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
return max([1, int(args.depth)])
|