forked from dbqpdb/RLM
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrlm.py
executable file
·2014 lines (1805 loc) · 100 KB
/
rlm.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/python3
# RLM.py
#
# The python implementation of the Random Legal Move chess-playin', sass talkin', ... thing
#
#
versionNumber = 0.40 # basic gameplay is now possible!
import numpy as np
import re
import sys
import random
import time
import gzip
import pandas as pd
# Let's start off with this as a single file and refactor it into multple files when we feel
# like this one is getting unwieldy
# --Okay. :)
class Board:
'''A class to hold and manipulate representations of the chess board'''
EMPTY_SQUARE = '-' # class constant, a single character string to represent an empty square
# In the terminal, the unicode glyphs actually look reversed, so:
def __init__(self, board_position=None, piece_list=None):
'''
Constructor for Board class. Optional board_position input allows construction
from a given position. board_position can be supplied as a numpy array of single
characters (which is what Board uses internally, or as an FEN-style board position
string. Alternatively, a list of Piece objects can be supplied, and the board
constructed by placing each piece based on its 'current_square' property
: param board_position: a board position, specified as ndarray or FEN string
: param piece_list: a list of Piece objects to place on an empty board
'''
self.pieces = ['P', 'R', 'N', 'B', 'Q', 'K', 'p', 'r', 'n', 'b', 'q', 'k']
self.glyphs = ['♟︎', '♜', '♞', '♝', '♛', '♚', '♙', '♖', '♘', '♗', '♕', '♔']
self.glyphmap = dict(zip(self.pieces, self.glyphs))
self.use_glyphs = True
#print('running init...')
if board_position is None and piece_list is None:
# default to standard starting position
b = np.array([Board.EMPTY_SQUARE]*64).reshape((8,8))
b[0,:] = [piece for piece in ['R','N','B','Q','K','B','N','R']] # RNBQKBNR
b[1,:] = ['P']*8
b[6,:] = ['p']*8
b[7,:] = [piece for piece in ['r','n','b','q','k','b','n','r']]
self.board_array = b
elif board_position is not None:
# a board_position was supplied, check if it's valid
if isinstance(board_position, np.ndarray):
if board_position.shape==(8,8) and board_position.dtype==np.dtype('<U1'):
# right shape and data type
self.board_array = np.copy(board_position) # make a copy of the input array, don't just use a reference!
else:
raise Exception('Input array has wrong shape or data type!')
elif self.is_FEN(board_position):
# Convert FEN to array
self.board_array = self.convert_FEN_to_board_array(board_position)
else:
# Couldn't interpret board position input, throw an error
raise Exception("Couldn't interpret board position input as 8x8 numpy array of single characters or as FEN board position!")
elif piece_list is not None:
# Make board from pieces
b = np.array([Board.EMPTY_SQUARE]*64).reshape((8,8))
for piece in piece_list:
file_idx, rank_idx = self.square_name_to_array_idxs(piece.current_square)
b[rank_idx, file_idx] = piece.char # NB that indexing into numpy array is rank,file whereas everywhere else we use file,rank
self.board_array = b
FILE_TO_IDX_DICT = {
'a': 0,
'b': 1,
'c': 2,
'd': 3,
'e': 4,
'f': 5,
'g': 6,
'h': 7,
'1': 0,
'2': 1,
'3': 2,
'4': 3,
'5': 4,
'6': 5,
'7': 6,
'8': 7,
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
6: 6,
7: 7,
}
IDX_TO_FILE_DICT = {
0: 'a',
1: 'b',
2: 'c',
3: 'd',
4: 'e',
5: 'f',
6: 'g',
7: 'h',
}
def __getitem__(self, square_name):
''' Allows indexing into Board objects. If b is a Board, then b['a3'] should return
the piece which is on square a3. This function should handle indexing in pretty much any
sensible way we can think of. The actual intepretation of the square name is handled
by square_name_to_array_idxs(), but here are ways indexing can currently be used to
access the contents of the a3 square:
b['a3'] - a two-character string, a letter for the file and a number for the rank
b['a','3'] - two one-character strings, a letter for the file and number for the rank
b[0, 2] - two integers, zero-based, these are indices into the board_array (but in opposite order; file, rank instead of rank, file)
b['1', '3'] - two one-character strings, a number for the file and a number for the rank (one-based, not zero-based)
We'll need to decide as we carry on whether the non-string inputs should be allowed and if so, whether they should be
file, rank, (consistent with other inputs order) or rank, file (consistent with board_array index order).
The return value is either a 1 character string containing the case-sensitive piece name on that square, the
Board.EMPTY_SQUARE string if the square was empty, or None if the square name is invalid or off the board.
'''
file_idx, rank_idx = self.square_name_to_array_idxs(square_name)
if rank_idx is None or file_idx is None:
return None
else:
return self.board_array[rank_idx, file_idx] # NB that indexing into numpy array is rank,file whereas everywhere else we use file,rank
VALID_BOARD_SQUARE_CONTENTS_PATTERN = re.compile('(^[pnbrkqPNBRKQ]$)|(^%s$)' % EMPTY_SQUARE)
def __setitem__(self, square_name, new_value):
''' Allows setting of board positions via indexing expressions. If b is a Board, then
b['a3'] = 'P' should place a white pawn on square 'a3' of the board. All the ways of specifiying
a square name allowed by square_name_to_array_idxs() are allowed. new_value must be a single
character string with a case-sensitive piece name, or the Board.EMPTY_SQUARE string.
'''
assert re.match(Board.VALID_BOARD_SQUARE_CONTENTS_PATTERN, new_value), 'new_value "%s" is not a valid character to place in a Board array' % (new_value)
file_idx, rank_idx = self.square_name_to_array_idxs(square_name)
if rank_idx is None or file_idx is None:
Exception('Square name "%s" did not parse to valid rank and file indices, setting board position failed!'%(square_name))
else:
self.board_array[rank_idx, file_idx] = new_value # NB that indexing into numpy array is rank,file whereas everywhere else we use file,rank
@classmethod
def square_name_to_array_idxs(cls, square_name):
'''This function should handle interpreting square names in pretty much any
sensible way we can think of. Here are some thoughts of how it might make sense to call
this:
'a3' - a two-character string, a letter for the file and a number for the rank
['a','3'] - two one-character strings, a letter for the file and number for the rank
[0, 2] - two integers, zero-based, these are indices into the board_array (but in opposite order; file, rank instead of rank, file)
['1', '3'] - two one-character strings, a number for the file and a number for the rank (one-based, not zero-based)
We'll need to decide as we carry on whether the non-string inputs should be allowed and if so, whether they should be
file, rank, (consistent with other inputs order) or rank, file (consistent with board_array index order).
Returns file, rank. If either can't be interpreted or are off board, that index is returned as None
'''
assert len(square_name)==2, 'Board square names must have len 2 to be interpretable!'
# Convert first element of square name to a file index (or None if it doesn't convert)
file_idx = Board.FILE_TO_IDX_DICT.setdefault(square_name[0], None)
# Try to convert second element of square name to a rank index
try:
if isinstance(square_name[1], str):
rank_idx = int(square_name[1]) - 1 # go from 1-based to 0-based
else:
rank_idx = int(square_name[1])
if rank_idx < 0 or rank_idx > 7:
rank_idx = None # off board
except:
# Conversion failed
rank_idx = None
return file_idx, rank_idx
def copy(self):
'''Return a copy of the existing board'''
board_copy = Board(board_position=self.board_array )
return board_copy
def move(self, source_square_name, destination_square_name):
'''Moves whatever piece is on the source square to the destination square.
Throws an error if the source square is empty. Returns the contents of the
destination square (might be handy for capture processing). Square names
are processed by square_name_to_array_idxs(), so any format that function
can handle is fine for square names.
NOTE that this currently does not update any Piece objects, only the board representation!!
'''
moving_piece = self[source_square_name]
if moving_piece==Board.EMPTY_SQUARE:
raise Exception('You attempted to move an empty square!')
destination_occupant = self[destination_square_name]
# Move the piece
self[source_square_name] = Board.EMPTY_SQUARE # former square becomes empty
self[destination_square_name] = moving_piece # new square filled by moving piece
return destination_occupant # return the captured piece (or empty square if it was empty)
def list_pieces(self):
'''Lists all pieces which are on the board, divided into a list of white pieces
and a list of black pieces. (Note that these are single characters, not Piece objects)'''
pieces = [piece for piece in self.board_array.ravel() if not (piece==Board.EMPTY_SQUARE)]
white_pieces = [piece for piece in pieces if piece==piece.upper()]
black_pieces = [piece for piece in pieces if piece==piece.lower()]
return white_pieces, black_pieces
@classmethod
def is_same_square(cls, square_name_1, square_name_2):
# Returns True if square name 1 and 2 refer to the same board location, even if they are in different formats
# If not, or if either is None, returns False
if square_name_1 is None or square_name_2 is None:
return False
else:
# Standardize and compare
sq1 = cls.square_name_to_array_idxs(square_name_1)
sq2 = cls.square_name_to_array_idxs(square_name_2)
return sq1==sq2
@classmethod
def square_rank_str(cls, square_idxs):
# Returns the rank number as a single character string (one-based, not zero-based)
return str(int(square_idxs[1])+1)
@classmethod
def square_file_lett(cls, square_idxs):
# Returns the file letter as a single character string
file_idx = square_idxs[0]
file_lett = Board.IDX_TO_FILE_DICT[file_idx]
return file_lett
def __str__(self):
'''This is called whenever a board is converted to a string (like when it is being printed)'''
# How about something like this:
'''
+-------------------------------+
8 | r | n | b | q | k | b | n | r |
|---|---|---|---|---|---|---|---|
7 | p | p | p | p | p | p | p | p |
|---|---|---|---|---|---|---|---|
6 | | | | | | | | |
|---|---|---|---|---|---|---|---|
5 | | | | | | | | |
|---|---|---|---|---|---|---|---|
4 | | | | | | | | |
|---|---|---|---|---|---|---|---|
3 | | | | | | | | |
|---|---|---|---|---|---|---|---|
2 | P | P | P | P | P | P | P | P |
|---|---|---|---|---|---|---|---|
1 | R | N | B | Q | K | B | N | R |
+-------------------------------+
a b c d e f g h
'''
upper_edge = ' +-------------------------------+\n'
lower_edge = upper_edge
internal_row_edge = ' |---|---|---|---|---|---|---|---|\n'
make_row_string = lambda row_num, row: '%i | %c | %c | %c | %c | %c | %c | %c | %c |\n'%(row_num, *row)
file_labels = ' a b c d e f g h \n'
board_string = upper_edge # start with the upper edge
for rank_num in range(8,0,-1):
row_idx = rank_num-1
row = list(self.board_array[row_idx,:]) # get list of piece characters (including '-' for empty squares)
row_string = make_row_string(rank_num, row)
# Substitute glyphs for letters if requested...
if self.use_glyphs:
for piece, glyph in self.glyphmap.items():
row_string = row_string.replace(piece, glyph)
board_string += row_string
if rank_num > 1:
board_string += internal_row_edge
else:
board_string += lower_edge
board_string += file_labels
return board_string
@classmethod
def isValidFENboard(cls, board: str) -> bool:
'''
Checks that a given string is a valid FEN board representation.
:param str board: the string to test
:return bool: whether it's valid
'''
rows = board.split('/')
if len(rows) != 8:
return False
for whalefart in rows:
# if the row has a non-piece character or non 1-8 digit,
# or the sum of represented squares is un-8-ly, return Nope
if re.search('[^prnbqk1-8]', whalefart, re.IGNORECASE) or sum([int(x) if x.isdigit() else 1 for x in whalefart]) != 8:
return False
return True
@classmethod
def is_FEN(cls, possible_FEN: str) -> bool:
'''
Checks if input is a valid complete FEN
:param str possible_FEN: the candidate FEN string
:return bool: whether it's valid
'''
fen_fields = possible_FEN.split()
if len(fen_fields) != 6:
return False
boardMaybe, side, castle, enpass, halfmovecounter, turnnum = fen_fields
if not Board.isValidFENboard(boardMaybe):
return False
if side not in ['w', 'b']:
return False
# The castling string can be 1-4 "k"s and "q"s, or the string "-"
if len(castle) not in [1, 2, 3, 4]:
return False
if re.search('[^qk]', castle, re.IGNORECASE) and castle != '-':
return False
# The en passant field can be '-' or a square representation in row 3 or 6 depending on the side.
if enpass != '-' and not (side == 'w' and re.match('^[a-h]6$', enpass)) and not (side == 'b' and re.match('^[a-h]3$', enpass)):
return False
# halfmovecounter starts at 0 and increments every non-capture non-pawn-advance move; movenum starts at 1 and increments after each black move.
if int(halfmovecounter) < 0 or int(turnnum) < 0 or int(halfmovecounter) >= 2 * int(turnnum):
return False
return True
def to_FEN_board(self):
'''Export current board position as FEN board string'''
row_strings = []
for rank_idx in range(7,-1,-1):
currently_counting_empty_squares = False
empty_square_count = 0
row_string = ''
for sq in self.board_array[rank_idx,:]:
if sq == Board.EMPTY_SQUARE:
if not currently_counting_empty_squares:
currently_counting_empty_squares = True
empty_square_count = 1
else:
empty_square_count +=1
else:
# non-empty square
if currently_counting_empty_squares:
# Complete the empty square count
currently_counting_empty_squares = False
row_string += '%i' % empty_square_count
# add piece from current square
row_string += sq
if currently_counting_empty_squares:
row_string += '%i' % empty_square_count
row_strings.append(row_string)
# Assemble rows into one long string with slashes between rows
FEN_board_string = '/'.join(row_strings)
return FEN_board_string
def find_king_square(self, color):
''' Should return the square of the king of the given color (color should start
with 'w' or 'b', case insensitive, representing white or black). Square is returned as
algebraic string'''
color_letter = color[0].lower()
if color_letter == 'w':
K_str = 'K'
elif color_letter == 'b':
K_str = 'k'
#
rank_idx_tuple, file_idx_tuple = np.where(self.board_array == K_str)
square_str = Board.IDX_TO_FILE_DICT[file_idx_tuple[0]] + str(rank_idx_tuple[0] + 1)
return square_str
@classmethod
def convert_FEN_to_board_array(cls, FEN):
'''Converts FEN or FEN board position to a board array and returns it'''
# FEN's look like "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
FEN_board = FEN.split()[0] # keep only first part of FEN if full FEN
FEN_chars = list(FEN_board)
# NB that FEN order is increasing column, then decreasing row
squareIdx = 0
board_array = np.array([Board.EMPTY_SQUARE]*64).reshape(8,8)
valid_pieces = list('rnbqkpRNBQKP') # split into list of characters
valid_digits = list('12345678')
for char in FEN_chars:
if char in valid_pieces:
# This character is a piece, add it to the board array
fileIdx = squareIdx % 8
rankIdx, fileIdx = cls.squareIdx_to_boardIdxs(squareIdx)
board_array[rankIdx, fileIdx] = char
squareIdx+=1 # increment
elif char in valid_digits:
# This character is a number, representing that many empty squares
squareIdx+= int(char)
elif char=='/':
# This character is a slash, ignore it
pass
else:
# This character is not a valid FEN board position character!
raise Exception('Invalid character "%s" found in FEN input'%(char))
return board_array
@classmethod
def squareIdx_to_boardIdxs(cls, squareIdx, board_size=(8,8)):
# FEN square index to board_array indices. Must take into
# account that ranks are in a different order and that board
# array indices are 0-based.
# First FEN square is board_array[7,0], next is [7,1],
# 8th is [7,7], 9th is [6,0], 10th is [6,1], 64th is [0,7]
# So, if we take squareIdx to be 0-based, mappings are looking like
# [0] -> [7,0]
# [1] -> [7,1]
# [7] -> [7,7]
# [8] -> [6,0]
# [10] -> [6,2]
# [63] -> [0,7]
#
# OK, so we can get the file index by taking the square idx mod 8
fileIdx = squareIdx % board_size[1]
# The rank index is based on the floor of the square idx / 8
rankIdx = int(7 - np.floor(squareIdx/board_size[0]))
return rankIdx, fileIdx
@classmethod
def square_to_alg_name(cls, square_name):
'''Convert any square representation to algebraic square name, i.e. letter file followed by 1-based rank'''
sq_arr = cls.square_name_to_array_idxs(square_name) # standardize
alg_name = cls.IDX_TO_FILE_DICT[sq_arr[0]] + "%i" % (sq_arr[1] + 1) # convert
return alg_name
class Move:
'''Should handle having an internal representation of moves and converting to various output
representations
'''
def __init__(self, char, starting_square, destination_square, captured_piece=None, is_castling=False, promotion_piece=None, is_en_passant_capture=False, new_en_passant_square=None):
''' Move representation keeps track of everything needed to relate to moves.
'''
self.single_char = char
self.starting_square = Board.square_name_to_array_idxs(starting_square) # standardize to array idxs
self.destination_square = Board.square_name_to_array_idxs(destination_square)# standardize to array idxs
self.captured_piece = captured_piece
self.is_castling = is_castling
self.promotion_piece = promotion_piece
self.is_en_passant_capture = is_en_passant_capture
self.new_en_passant_square = new_en_passant_square
def is_capture(self):
return not (self.captured_piece is None)
def is_promotion(self):
return not (self.promotion_piece is None)
def to_tuple(self):
# For debugging, this is a way to return the move in the internal format which is used in initialization
move_tuple = (self.single_char, self.starting_square, self.destination_square, self.captured_piece, self.is_castling, self.promotion_piece, self.is_en_passant_capture, self.new_en_passant_square)
return move_tuple
def to_long_algebraic(self, use_figurine=False, note_ep=False):
'''Long algebraic includes starting and destination square'''
if use_figurine:
p = self.PIECE_TO_FIGURINE_DICT[self.single_char]
else:
p = self.single_char.upper()
start_sq = self.square_to_string(self.starting_square)
dest_sq = self.square_to_string(self.destination_square)
if self.is_castling:
# figure out if kingside or queenside
if self.destination_square[0]> self.starting_square[0]:
move_string = 'O-O' # kingside
else:
move_string = 'O-O-O' # queenside
else:
cap_str = '' if self.captured_piece is None else 'x'
prom_str = '' if self.promotion_piece is None else '='+self.promotion_piece.upper()
move_string = p + start_sq + cap_str + dest_sq + prom_str
if self.is_en_passant_capture and note_ep:
move_string += 'e.p.' #add notation that this capture is en passant
return move_string
def to_short_algebraic(self, board):
# Same as long algebraic except drop destination square
# TODO: actually, this should have a lot more logic so it includes elements of starting square if necessary
# TODO: hmm, to do this, we actually need the board, because that's what we need to resolve ambiguities
p = self.single_char
start_sq = self.square_to_string(self.starting_square)
dest_sq = self.square_to_string(self.destination_square)
if self.is_castling:
# figure out if kingside or queenside
if self.destination_square[0]> self.starting_square[0]:
move_string = 'O-O' # kingside
else:
move_string = 'O-O-O' # queenside
else:
cap_str = '' if self.captured_piece is None else 'x'
prom_str = '' if self.promotion_piece is None else '='+self.promotion_piece.upper()
move_string = p + start_sq + cap_str + dest_sq + prom_str
return move_string
# Currently, this is duplicated in the Board class, could revisit to explore whether it should be reworked to just appear one place or whether this is more convenient
IDX_TO_FILE_DICT = {
0: 'a',
1: 'b',
2: 'c',
3: 'd',
4: 'e',
5: 'f',
6: 'g',
7: 'h',
}
PIECE_TO_FIGURINE_DICT = { # using named unicode code point
'P': '\N{WHITE CHESS PAWN}',
'N': '\N{WHITE CHESS KNIGHT}',
'B': '\N{WHITE CHESS BISHOP}',
'R': '\N{WHITE CHESS ROOK}',
'Q': '\N{WHITE CHESS QUEEN}',
'K': '\N{WHITE CHESS KING}',
'p': '\N{BLACK CHESS PAWN}',
'n': '\N{BLACK CHESS KNIGHT}',
'b': '\N{BLACK CHESS BISHOP}',
'r': '\N{BLACK CHESS ROOK}',
'q': '\N{BLACK CHESS QUEEN}',
'k': '\N{BLACK CHESS KING}',
}
def __str__(self):
'''String representation of Move class.'''
return self.to_long_algebraic() # just use long algebraic for now
def __repr__(self):
'''String representation for Move objects (this one shows up for example in lists)'''
return 'MoveObject.['+str(self)+']'
@classmethod
def square_to_string(cls, sq):
'''Convert array indices to string'''
# should this handle non-array index square representations also?
file_letter = cls.IDX_TO_FILE_DICT[sq[0]]
rank_number = '%i'%(sq[1]+1)
return file_letter+rank_number
@classmethod
def parse_move_without_game(cls, entered_move, white_is_moving=True, make_assumptions=False):
''' This function tries to extract as much information as possible from an entered move text string,
parsing it into move elements. It keeps track of what move elements are known, what remain unknown,
and what have some partial information (e.g. a piece was captured, but it wasn't specified which).
The goal is to be able to use the extracted information to choose the correct, fully specified Move
from a list of legal Move objects generated from the Game state, AND to be able to explain why there
is no match if there is no match (or how the matches differ if there are multiple matches)
This function returns a dict with the following keys:
['single_char','starting_file', 'starting_rank','destination_square',
'captured_piece','is_castling','promotion_piece','is_en_passant_capture',
'new_en_passant_square']
The values will be either "unknown" if the move element cannot be determined from the entered move,
or "not_None" if the move element was determined to be not None but couldn't be further specified (this
is possible for captured_piece and promotion_piece), or else it will be the known value of that move
element.
In order to get the piece capitalization parts correct, to determine squares if castling, and key ranks
for pawns, it is necessary to know who is moving (white or black). The argument "white_is_moving" is
treated as boolean throughout and if evaluates to False, then black is considered to be moving.
The 'make_assumptions' argument controls whether the moving piece is assumed to be a pawn if no moving
piece is specified. In general, this should probably be True if we wanted to guess in the abstract what
a person most likely meant, but it should probably be False if we are going to use a Game legal move
list to narrow down what they could have meant. In that case it is better to leave the piece as unknown
and see what the known move elements match. Since the plan is to mostly use this function to compare
with legal move lists, the default is False.
'''
black_is_moving = not white_is_moving
kingside_castling_patt = re.compile(r"^\s*([oO0])-\1\s*")
queenside_castling_patt = re.compile(r"^\s*([oO0])-\1-\1\s*")
no_dest_capture_patt = re.compile(r"""
^\s*
(?P<moving_piece>[KQRBNPkqrbnp]) # pieces which can capture
x
(?P<captured_piece>[QRBNPqrbnp]) # pieces which can be captured
[+]? # check indicator (optional and ignored)
\s*$
""", re.VERBOSE) # Matches moves like RxB with no square information
normal_move_patt = re.compile(r"""
\s* # ignore any leading whitespace
(?P<piece_char>[KQRBNPkqrbnp])? # piece character, if present (optional for pawns)
(?P<starting_file>[a-h])?(?P<starting_rank>[1-8])? # any elements of the source square, if present
(?P<capture_indicator>[xX][QRBNqrbn]?)? # capture indicator, if present (this is always optional, but we could use it to catch user error if they try to capture an empty square)
(?P<dest_square>[a-h][1-8]) # destination square (the only non-optional part of the move for this pattern)
( (?P<promotion_indicator>=) # pawn promotion is indicated by = sign
(?P<promotion_piece>[QRBNqrbn]) # promotion piece character
)? # promotion indicator and piece (required for pawn promotion, required to be absent for all other moves)
(?P<ep_capture_indicator>e[.]?p[.]?)? # optional ep or e.p. to indicate en passant capture (always optional but could use to catch user errors)
[+]? # check indicator (optional and ignored)
\s*$ # Ignore any trailing whitespace
""", re.VERBOSE)
move_elements = ['single_char','starting_file', 'starting_rank','destination_square','captured_piece','is_castling','promotion_piece','is_en_passant_capture','new_en_passant_square']
move_elem_dict = {}
for e in move_elements:
move_elem_dict[e] = 'unknown'
notNone = "not_None"
msg = 'Other Messages:\n'
# Queenside castling (queenside first because kingside pattern will match queenside castling)
if queenside_castling_patt.match(entered_move):
# Queenside castling
move_elem_dict['is_castling'] = True
move_elem_dict['captured_piece'] = None
move_elem_dict['promotion_piece'] = None
move_elem_dict['is_en_passant_capture'] = False
move_elem_dict['new_en_passant_square'] = None
if white_is_moving:
move_elem_dict['single_char'] = 'K'
move_elem_dict['starting_file'] = 'e'
move_elem_dict['starting_rank'] = '1'
move_elem_dict['destination_square'] = 'c1'
elif black_is_moving:
move_elem_dict['single_char'] = 'k'
move_elem_dict['starting_file'] = 'e'
move_elem_dict['starting_rank'] = '8'
move_elem_dict['destination_square'] = 'c8'
# Kingside castling
elif kingside_castling_patt.match(entered_move):
move_elem_dict['is_castling'] = True
move_elem_dict['captured_piece'] = None
move_elem_dict['promotion_piece'] = None
move_elem_dict['is_en_passant_capture'] = False
move_elem_dict['new_en_passant_square'] = None
if white_is_moving:
move_elem_dict['single_char'] = 'K'
move_elem_dict['starting_file'] = 'e'
move_elem_dict['starting_rank'] = '1'
move_elem_dict['destination_square'] = 'g1'
elif black_is_moving:
move_elem_dict['single_char'] = 'k'
move_elem_dict['starting_file'] = 'e'
move_elem_dict['starting_rank'] = '8'
move_elem_dict['destination_square'] = 'g8'
elif no_dest_capture_patt.match(entered_move):
# can tell moving piece, captured piece, and know that new_en_passant_square should be None
m = no_dest_capture_patt.match(entered_move)
single_char = m.group('moving_piece')
captured_piece = m.group('captured_piece')
move_elem_dict['single_char'] = single_char.upper() if white_is_moving else single_char.lower()
move_elem_dict['captured_piece'] = captured_piece.lower() if white_is_moving else captured_piece.upper()
move_elem_dict['new_en_passant_square'] = None # a capture cannot generate a new ep square
move_elem_dict['is_castling'] = False
if single_char.lower()!='p' or captured_piece.lower()!='p':
move_elem_dict['is_en_passant_capture'] = False # only pawn take pawn could be ep capture
if single_char.lower()!='p':
move_elem_dict['promotion_piece'] = None # non-pawns can't promote
elif captured_piece.lower()=='p':
move_elem_dict['promotion_piece'] = None # if you captured a pawn you can't be promoting, because an enemy pawn can't be on the final rank
elif normal_move_patt.match(entered_move):
move_elem_dict['is_castling'] = False
m = normal_move_patt.match(entered_move)
destination_square = m.group('dest_square') # this is the only non-optional part of the match, this must be present if we are in this branch
move_elem_dict['destination_square'] = destination_square
single_char = m.group('piece_char') # may be None
starting_rank = m.group('starting_rank') # may be None
if starting_rank is not None:
move_elem_dict['starting_rank'] = starting_rank
starting_file = m.group('starting_file') # may be None
if starting_file is not None:
move_elem_dict['starting_file'] = starting_file
capture_indicator = m.group('capture_indicator') # may be x or x<piece> or None
promotion_indicator = m.group('promotion_indicator')
promotion_piece = m.group('promotion_piece') # may be None
ep_capture_indicator = m.group('ep_capture_indicator') # may be None
# Piece_char?
if single_char is None and make_assumptions:
# Assume pawn
single_char = 'P' if white_is_moving else 'p'
msg += 'assumed "P" for missing piece character\n'
if single_char is not None:
single_char = single_char.upper() if white_is_moving else single_char.lower()
move_elem_dict['single_char'] = single_char
# Captured piece?
if capture_indicator is not None:
if len(capture_indicator)==2:
captured_piece = capture_indicator[1].lower() if white_is_moving else capture_indicator[1].upper()
move_elem_dict['captured_piece'] = captured_piece
else:
move_elem_dict['captured_piece'] = notNone
msg += 'we know there was a capture (captured piece not None), but not what piece was captured\n'
# Promotion piece?
if single_char and single_char.lower() != 'p':
# if piece is known and not a pawn, can't be promotion
move_elem_dict['promotion_piece'] = None # non-pawns can't promote
elif destination_square[1] != '8' and destination_square[1] !='1':
# no matter what the piece (known or unknown), if not going to rank 1 or 8 can't be promoting
move_elem_dict['promotion_piece'] = None
elif single_char and single_char.lower() == 'p':
# pawn moving to last rank, must involve promotion!
if promotion_piece is not None:
move_elem_dict['promotion_piece'] = promotion_piece.upper() if white_is_moving else promotion_piece.lower()
else:
move_elem_dict['promotion_piece'] = notNone
msg += "promotion_piece is not None, but we don't know what it should be\n"
else:
# single_char is None, and destination_square is on first or last rank
# Promotion state is unknown, could be a pawn promoting or could be a non-pawn not promoting.
# However, if the user specified a promotion piece, then safe to assume unspecified piece is
# a pawn and that promotion is taking place. Otherwise don't assume either way
if promotion_piece is not None:
move_elem_dict['promotion_piece'] = promotion_piece
move_elem_dict['single_char'] = 'P' if white_is_moving else 'p'
# E.P. Capture? and/or New EP Square?
if single_char and single_char.lower() != 'p':
# non-pawn moving piece
move_elem_dict['is_en_passant_capture'] = False # can't be ep capture if moving piece is not a pawn
move_elem_dict['new_en_passant_square'] = None # non-pawn moves can't create ep squares
elif single_char and single_char.lower() == 'p':
# moving piece IS a pawn
if ep_capture_indicator is not None:
move_elem_dict['is_en_passant_capture'] = True # if moving piece is a pawn
if capture_indicator is not None and len(capture_indicator)==2:
# captured piece already assigned and marked known
pass
else:
move_elem_dict['captured_piece'] = 'P' if white_is_moving else 'p'
move_elem_dict['new_en_passant_square'] = None # ep captures can't create new ep squares
# Otherwise, the only way to know an ep square creation state is to know the moving piece and the starting and destination squares
if starting_file is not None and starting_rank is not None:
if starting_rank=='2' and destination_square[1]=='4':
new_en_passant_square = destination_square[0]+'3'
elif starting_rank=='7' and destination_square[1]=='5':
new_en_passant_square = destination_square[0]+'6'
else:
new_en_passant_square = None
move_elem_dict['new_en_passant_square'] = new_en_passant_square
else:
# Moving piece is unknown, might be a pawn, might not
# However, if the destination square is not on the proper rank, it
# cannot possibly be an ep capture
if (white_is_moving and destination_square[1] != '6') or (black_is_moving and destination_square[1] != '3'):
move_elem_dict['is_en_passant_capture'] = False
elif starting_rank and ((white_is_moving and starting_rank != '5') or (black_is_moving and starting_rank != '4')):
# Even if destination rank is correct for ep capture, we can still rule it out if the starting rank
# is not correct for possible ep capture
move_elem_dict['is_en_passant_capture'] = False
# Likewise, if the destination square is not on the proper rank, it
# cannot possibly create a new ep square
if (white_is_moving and destination_square[1] !='4') or (black_is_moving and destination_square[1] != '5'):
move_elem_dict['new_en_passant_square'] = None
elif starting_rank and ((white_is_moving and starting_rank != '2') or (black_is_moving and starting_rank !='7')):
# Even if destination square is the right rank, if the starting square isn't the right rank, then
# it is impossible to generate a new ep square
move_elem_dict['new_en_passant_square'] = None
# Conditional tree traversed, let's take a look at the results
print("Move Elements")
for key, value in move_elem_dict.items():
print("%s: %s"%(key, str(value)))
print(msg)
# Could have a dict where move element names are keys, and all initially have value of 'unknown'
# Then could fill in with actual value or with 'notNone', or leave as 'unknown'
# Then, for unpacking, can check if value is 'unknown', 'notNone', or something else (means known)
# TODO: could add reason_dict with move element keys and reasons as values (i.e. "because only pawns can promote", or "because castling can't cause captures")
return move_elem_dict
@classmethod
def find_matches_to_partial_move(cls, partial_move_dict, move_list):
''' Find all possible matches where every known or partially known element of partial_move_dict
is consistent with a Move object on the given move_list. partial_move_dict should be the
output of parse_move_without_game().
'''
unk = 'unknown'
notNone = 'not_None'
pmd = partial_move_dict # save typing
matched_moves = []
for move in move_list:
# Check each field
if ( (pmd['single_char']==unk or (move.single_char == pmd['single_char']))
and (pmd['starting_file']==unk or (Board.square_file_lett(move.starting_square) == pmd['starting_file']))
and (pmd['starting_rank']==unk or (Board.square_rank_str(move.starting_square) == pmd['starting_rank']))
and (pmd['destination_square']==unk or (Board.square_to_alg_name(move.destination_square) == pmd['destination_square']))
and (pmd['captured_piece']==unk or (move.captured_piece == pmd['captured_piece']) or (pmd['captured_piece']==notNone and move.captured_piece is not None))
and (pmd['is_castling']==unk or (move.is_castling == pmd['is_castling']))
and (pmd['promotion_piece']==unk or (pmd['promotion_piece']==notNone and move.promotion_piece is not None) or (move.promotion_piece == pmd['promotion_piece']))
and (pmd['is_en_passant_capture']==unk or (pmd['is_en_passant_capture'] == move.is_en_passant_capture))
and (pmd['new_en_passant_square']==unk or (pmd['new_en_passant_square'] == move.new_en_passant_square))
):
matched_moves.append(move)
return matched_moves
@classmethod
def parse_entered_move(cls, entered_move, white_is_moving, legal_moves_list):
# Parses entered text move, and returns the set of legal moves which is
# consistent with entered info
partial_move_dict = cls.parse_move_without_game(entered_move, white_is_moving)
matched_moves = cls.find_matches_to_partial_move(partial_move_dict, legal_moves_list)
return matched_moves
@classmethod
def is_on_move_list(cls, move, move_list):
# Returns true if given move is on given move list
for list_move in move_list:
if list_move == move:
return True
return False
def __eq__(self, move_to_match):
# Returns true if self and move_to_match represent the same move in all respects
if (self.single_char == move_to_match.single_char and
self.starting_square == move_to_match.starting_square and
self.destination_square == move_to_match.destination_square and
self.captured_piece == move_to_match.captured_piece and
self.promotion_piece == move_to_match.promotion_piece and
self.is_en_passant_capture == move_to_match.is_en_passant_capture and
self.new_en_passant_square == self.new_en_passant_square):
return True
else:
return False
class Player:
''' Parent class for players
'''
def __init__(self, color=None):
self.set_color(color)
def set_color(self, color):
if color is None:
self.color = None
elif color[0].lower()=='w':
self.color = 'w'
elif color[0].lower()=='b':
self.color = 'b'
else:
raise Exception('Invalid color')
def choose_move(self, game, legal_moves_list):
'''Placeholder which subclasses should implement, needs to return a Move object'''
pass
def is_valid_move(self, move, legal_move_list):
# Checks if move matches one on legal move list
pass # Maybe should be a Move function??? Maybe want Game object too?
class RLMPlayer (Player):
''' Class to encapsulate RLM player behaviors
'''
def choose_move(self, game, legal_moves_list):
# RLM player generates the list of possible legal moves, and chooses a random one off the list
chosen_move = random.choice(legal_moves_list)
print('RLM player played %s'%chosen_move.to_long_algebraic())
return chosen_move
class HumanPlayer (Player):
''' Class to handle interaction with human player during a game (mostly requesting a move)
'''
def choose_move(self, game, legal_moves_list):
'''Prompt the human player to enter a move'''
valid_move_entered = False
while not valid_move_entered:
entered_move = input("What is your move?\nEnter move: ") # TODO make this better
# Convert entered move to Move object
partial_move_dict = Move.parse_move_without_game(entered_move, white_is_moving=(game.side_to_move=='w'))
matching_moves = Move.find_matches_to_partial_move(partial_move_dict, legal_moves_list)
if len(matching_moves)==1:
move = matching_moves[0]
valid_move_entered = True
msg = 'Your move is %s, got it!'%(move.to_long_algebraic())
elif len(matching_moves)==0:
msg = 'Your entered move did not match any legal moves... try again!\n'
# TODO this can be much improved!! We could identify the move with the closest match, ask them if they
# meant that, we can explain what move elements could not be matched, etc.
else:
# More than 1 legal move matched all the information they supplied, let's offer them a choice...
move_str_list = [m.to_long_algebraic() for m in matching_moves]
msg = 'Your entered move was consistent with %i legal moves, one of the following would be less ambiguous:\n'%len(matching_moves)
for m in move_str_list:
msg += m + '\n'
msg += 'Try again!\n'
print(msg)
return move
class NRLMPlayer (Player):
'''Non-Random Legal Move Player. Chooses moves in a non-random way (currently just the first move on the legal move list)
'''
def choose_move(self, game, legal_moves_list):
return legal_moves_list[0]
class GameController:
'''
Class to manage game flow. Should handle gathering player info, setting up game,
prompting players for moves, calling comment generation routines, orchestrating
post-game processes (e.g. saving to PGN). Game state should be held in a Game
object, board state in a Board object.
'''
'''
Move generation is complete, what would we need to add to have a playable game?
* Interface with human player (prompts, move validation)
* Record game history
* Recognize checkmate and stalemate and handle game end
'''
def start_new_game(self):
'''Start a new game'''
# Ask about playing game
start_game_answer = input('Hey there, do you want to play a game of chess?\n(Y/n): ')
if len(start_game_answer)>0 and start_game_answer[0].lower()=='n':
print("Fine!! I'll play myself then!! You can watch.")
white_player = RLMPlayer()
black_player = RLMPlayer()
else:
# Choose colors
side_answer = input('Would you like to play as white or black?\n(W/b): ')
if len(side_answer)>0 and side_answer[0].lower()=='b':
print("OK, I'll play as white!")
white_player = RLMPlayer()
black_player = HumanPlayer()
else:
print("OK, I'll play as black!")
black_player = RLMPlayer()
white_player = HumanPlayer()
# Initialize game and force normal starting position for now...
game = Game()
game.set_board(Board()) # defaults to normal starting position
game.set_players(white_player, black_player)
print("Here is the starting position:")
game.show_board()
# The game loop
game_is_over = False
legal_moves = game.get_moves_for()
while not game_is_over:
if game.side_to_move[0] == 'w':
move = white_player.choose_move(game, legal_moves)
else:
move = black_player.choose_move(game, legal_moves)
# Carry out chosen move and update game
game.make_move(move)
game.show_board()
# To see if game is over, check if there are legal moves (if there aren't any, it's either stalemate or checkmate)
legal_moves = game.get_moves_for()
if len(legal_moves)==0:
game_is_over = True # checkmate or stalemate
# Need to find if the side to move's king is currently in check
if game.side_to_move=='w':
K = [p for p in game.white_pieces if isinstance(p, King)][0]
game_over_msg = 'CHECKMATE!! Black wins!' if K.is_in_check() else "STALEMATE!! It's a draw!"
else:
k = [p for p in game.black_pieces if isinstance(p, King)][0]
game_over_msg = 'CHECKMATE!! White wins!' if k.is_in_check() else "STALEMATE!! It's a draw!"
elif game.half_moves_since >= 100: # TODO: check if this should be > or >=
game_is_over = True
game_over_msg = "DRAW!! That's 50 moves with no captures or pawn moves!"
# The game has ended...
print(game_over_msg)
print("Thanks for playing!")
move_hist_ans = input("Shall I print the move history for this game?\n[Y/n]:")
if not (move_hist_ans and move_hist_ans[0].lower()=='n'):
# Print move history unless user indicates no
game.print_move_history()
class Game:
'''Class to hold a game state. Game state includes everything in an FEN, plus
a unique GameID. Probably makes sense for it to keep track of everything that
would go into a PGN too (player names, game history, location, event, site)
'''
def __init__(self, ep_square = None):
self.ep_square = ep_square
self.castling_state = ['K','Q','k','q'] # TODO: currently just a placeholder which allows all castling options
self.side_to_move = 'w' # 'w' or 'b' for White or Black
self.move_counter = 1 # move counter to increment after each Black move
self.half_moves_since = 0 # counter for half moves since last pawn move or capture
self.white_pieces = []
self.black_pieces = []
self.board = None # This needs to be initialized before we can really play a game, but let's start with a placeholder which indicates it's not initialized
self.white_player = None
self.black_player = None
self.move_history = []
def copy(self):
game_copy = Game()
game_copy.ep_square = self.ep_square