531 lines
18 KiB
Python
531 lines
18 KiB
Python
from itertools import product
|
|
from unicodedata import lookup
|
|
from copy import deepcopy
|
|
|
|
def cross(A, B):
|
|
"""Return sum of of every element from A and B."""
|
|
return tuple(a+b for a in A for b in B)
|
|
|
|
pieces = ["pawn", "knight", "bishop", "rook", "queen", "king"]
|
|
colors = ["white", "black"]
|
|
|
|
ranks = "87654321"
|
|
files = "abcdefgh"
|
|
squares = cross(files, ranks)
|
|
|
|
pawn_ranks = {"white" : '2',
|
|
"black" : '7'
|
|
}
|
|
home_ranks = {"white" : '1',
|
|
"black" : '8'
|
|
}
|
|
init_positions = {"pawn" : cross(files, pawn_ranks.values()),
|
|
"rook" : cross("ah", home_ranks.values()),
|
|
"knight" : cross("bg", home_ranks.values()),
|
|
"bishop" : cross("cf", home_ranks.values()),
|
|
"queen" : cross("d", home_ranks.values()),
|
|
"king" : cross("e", home_ranks.values())
|
|
}
|
|
|
|
AN_names = {'R' : "rook",
|
|
'N' : "knight",
|
|
'B' : "bishop",
|
|
'Q' : "queen",
|
|
'K' : "king"}
|
|
|
|
def move(sq, v):
|
|
"""Add vector v to square and return a square, even if not in board."""
|
|
return chr(ord(sq[0]) + v[0]) + str(int(sq[1]) + v[1])
|
|
|
|
def moves(sq, dirs):
|
|
"""Add multiple vectors to a square. Return squares that are in board."""
|
|
targets = [move(sq, dir) for dir in dirs]
|
|
return [sq for sq in targets if sq in squares]
|
|
|
|
def invert(color):
|
|
"""Return inverted color."""
|
|
if color == "white":
|
|
return "black"
|
|
return "white"
|
|
|
|
class Empty:
|
|
"""Empty class to avoid unnecessary quotations."""
|
|
def __repr__(self):
|
|
return str(vars(self))
|
|
|
|
def parse_AN(move):
|
|
"""Parse chess move writen in algebraic notation. Return relevant
|
|
information encapsulated within an Empty class, or False if parsing
|
|
fails."""
|
|
def suffix_in(l, dval=False, i=1):
|
|
nonlocal move
|
|
x = move[-i:]
|
|
if x in list(l):
|
|
move = move[:-i]
|
|
return x
|
|
return dval
|
|
|
|
queenside = ["{}-{}-{}".format(c,c,c) for c in "0O"]
|
|
kingside = ["{}-{}".format(c,c) for c in "0O"]
|
|
|
|
r = Empty()
|
|
r.kingside = r.queenside = False
|
|
if move in queenside:
|
|
r.queenside = True
|
|
return r
|
|
if move in kingside:
|
|
r.kingside = True
|
|
return r
|
|
|
|
r.move = move
|
|
r.mate = suffix_in('#') == '#'
|
|
r.check = suffix_in('+') == '+'
|
|
r.promotion = suffix_in(AN_names, None)
|
|
if r.promotion:
|
|
r.promotion = AN_names[r.promotion]
|
|
_ = suffix_in('=')
|
|
r.dest = suffix_in(squares, i=2)
|
|
r.capture = suffix_in("xX") != False
|
|
r.rank = suffix_in(ranks, None)
|
|
r.file = suffix_in(files, None)
|
|
r.piece = suffix_in(AN_names, 'pawn')
|
|
if len(move) > 0 or (r.promotion and r.piece != "pawn") or not r.dest:
|
|
return False
|
|
if r.piece != "pawn":
|
|
r.piece = AN_names[r.piece]
|
|
return r
|
|
|
|
# Names for vectors
|
|
up, down = (0, 1), (0, -1)
|
|
left, right = (-1, 0), (1, 0)
|
|
rook_dirs = (up, down, left, right)
|
|
|
|
upper_left, upper_right = (-1, 1), (1, 1)
|
|
lower_left, lower_right = (-1, -1), (1, -1)
|
|
bishop_dirs = (upper_left, upper_right,
|
|
lower_left, lower_right)
|
|
|
|
class Piece:
|
|
"""Contains piece color and piece type"""
|
|
def __init__(self, color=None, piece=None):
|
|
"""Assign color and piece. If not specified they
|
|
default to None."""
|
|
self.color = color
|
|
self.piece = piece
|
|
|
|
def __repr__(self):
|
|
"""Returns unicode representation of instance."""
|
|
if None in (self.piece, self.color):
|
|
return ""
|
|
name = self.color.upper() + " CHESS " + self.piece.upper()
|
|
return lookup(name)
|
|
|
|
def __str__(self):
|
|
"""Same as repr."""
|
|
return repr(self)
|
|
|
|
def __eq__(self, other):
|
|
"""Ensures equality testing. If color or piece or both
|
|
equal None, instance equals None."""
|
|
if other == None:
|
|
return None in (self.color, self.piece)
|
|
if not isinstance(other, Piece):
|
|
return NotImplemented
|
|
return self.piece == other.piece and self.color == other.color
|
|
|
|
class Game:
|
|
"""Chess game class.
|
|
Squares are represented by strings made of letters and integers e.g. 'h4'.
|
|
Board is represented by a dictionary whose keys are squares
|
|
and values instances of Piece class.
|
|
Moves are represented internally by tuples of squares;
|
|
a source and destination.
|
|
Moves are appended to moves stack.
|
|
Upon each move, the relevant attributes of the instance (the game state)
|
|
are appended to the history stack. This allows loading previous
|
|
game states to instance variables."""
|
|
def __init__(self):
|
|
self.index = 0
|
|
self.make_board()
|
|
self.moves = []
|
|
self.AN_moves = []
|
|
self.history = []
|
|
self.turn = "white"
|
|
self.can_castle = {"white" : {"queen" : True,
|
|
"king" : True},
|
|
"black" : {"queen" : True,
|
|
"king" : True}
|
|
}
|
|
self.history.append((deepcopy(self.can_castle),
|
|
deepcopy(self.board),
|
|
self.turn))
|
|
|
|
def __repr__(self):
|
|
"""Return unicode board representation."""
|
|
r = ""
|
|
for rank in ranks:
|
|
r += rank + " |"
|
|
for file in files:
|
|
sq = file+rank
|
|
r += " "
|
|
if self.is_empty(sq):
|
|
r += " "
|
|
else:
|
|
r += repr(self.board[sq])
|
|
r += "\n"
|
|
r += " +" + "-"*16 + "\n"
|
|
r += " "*4 + " ".join(list(files))
|
|
return r
|
|
|
|
def make_board(self):
|
|
"""Create board and store it in self.board."""
|
|
self.board = dict((sq, Piece()) for sq in squares)
|
|
# Add pieces
|
|
for piece, positions in init_positions.items():
|
|
for sq in positions:
|
|
self.board[sq].piece = piece
|
|
# Add colors
|
|
for sq in cross(files, "12"):
|
|
self.board[sq].color = "white"
|
|
for sq in cross(files, "78"):
|
|
self.board[sq].color = "black"
|
|
# Castling markers
|
|
self.board["a1"].side = "queen"
|
|
self.board["a8"].side = "queen"
|
|
self.board["h1"].side = "king"
|
|
self.board["h8"].side = "king"
|
|
|
|
def rebase(self):
|
|
"""Shorten moves and history stacks if move is made
|
|
while past game state is loaded into memory. Essentialy
|
|
overrides future game states."""
|
|
self.moves = self.moves[:self.index]
|
|
self.history = self.history[:self.index+1]
|
|
|
|
def timetravel(self, i):
|
|
"""Load game state from past into instance variables.
|
|
Can be reversed since history and moves stack stay intact."""
|
|
if i >= len(self.history) or i < 0:
|
|
return False
|
|
c, board, turn = self.history[i]
|
|
self.board = board
|
|
self.turn = turn
|
|
self.can_castle = c
|
|
self.index = i
|
|
|
|
def prev(self):
|
|
"""Load game state from previous turn, if it exists."""
|
|
return self.timetravel(self.index-1)
|
|
|
|
def next(self):
|
|
"""Load game state from next turn, if it exists."""
|
|
return self.timetravel(self.index+1)
|
|
|
|
def last(self):
|
|
"""Load game state from last move."""
|
|
return self.timetravel(len(self.history)-1)
|
|
|
|
def first(self):
|
|
"""Load first recorded game state, an empty board."""
|
|
return self.timetravel(0)
|
|
|
|
def is_empty(self, sq):
|
|
"""Return True if square is empty, else False."""
|
|
return self.board[sq] == None
|
|
|
|
def is_check(self, color=None):
|
|
"""Return True if king of active player is in check else False."""
|
|
if color == None:
|
|
color = self.turn
|
|
sq = self.occupying(Piece(color, "king"))[0]
|
|
return self.is_attacked(color, sq)
|
|
|
|
def is_mate(self):
|
|
"""Return True if checkmate for active player."""
|
|
return self.is_check() and self.is_stall()
|
|
|
|
def is_stall(self):
|
|
"""Return True if active player has no legal move. Whether
|
|
the king is in check is irrelevant."""
|
|
for move in self.all_legal_moves():
|
|
new = deepcopy(self)
|
|
if self.board[move[0]].piece == "pawn" and move[1][1]==home_ranks[invert(self.turn)]:
|
|
move += ("queen",)
|
|
if new.move(*move):
|
|
return False
|
|
return True
|
|
|
|
def endofgame(self):
|
|
"""Return True if active player can't make a legal move."""
|
|
return self.is_stall() or self.is_mate()
|
|
|
|
def occupying(self, piece):
|
|
"""Return squares occupied by piece. Piece variable is an
|
|
instance of Piece class."""
|
|
return [sq for sq in squares if self.board[sq] == piece]
|
|
|
|
def castle(self, side):
|
|
"""Castle if possible, if not return False."""
|
|
color = self.turn
|
|
rank = home_ranks[self.turn]
|
|
|
|
if side not in ("queen", "king"):
|
|
return False
|
|
if not self.can_castle[color][side]:
|
|
return False
|
|
|
|
if side == "queen":
|
|
check_if_empty = cross("bcd", rank)
|
|
check_if_attacked = cross("cde", rank)
|
|
rook_move = "a"+rank, "d"+rank
|
|
king_move = "e"+rank, "c"+rank
|
|
elif side == "king":
|
|
check_if_empty = cross("fg", rank)
|
|
check_if_attacked = check_if_empty
|
|
rook_move = "h"+rank, "f"+rank
|
|
king_move = "e"+rank, "g"+rank
|
|
are_empty = all(map(self.is_empty, check_if_empty))
|
|
not_attacked = not any(self.is_attacked(color, sq)
|
|
for sq in check_if_attacked)
|
|
|
|
if not (are_empty and not_attacked):
|
|
return False
|
|
|
|
self.rebase()
|
|
self.board[king_move[1]] = self.board[king_move[0]]
|
|
self.board[rook_move[1]] = self.board[rook_move[0]]
|
|
self.board[king_move[0]] = Piece()
|
|
self.board[rook_move[0]] = Piece()
|
|
self.can_castle[color]["queen"] = False
|
|
self.can_castle[color]["king"] = False
|
|
self.moves.append(king_move)
|
|
self.turn = invert(self.turn)
|
|
self.history.append((deepcopy(self.can_castle),
|
|
deepcopy(self.board),
|
|
self.turn))
|
|
self.index += 1
|
|
return True
|
|
|
|
def move(self, source, target, promotion=None):
|
|
"""Move piece from source to target if legal and promote it
|
|
to promotion if it's a pawn whos reached the last
|
|
rank. Return False if fails."""
|
|
new = deepcopy(self)
|
|
if not new.is_legal(source, target, promotion):
|
|
return False
|
|
|
|
board = new.board
|
|
moved = board[source]
|
|
eaten = board[target]
|
|
|
|
if promotion == None:
|
|
promotion = moved
|
|
else:
|
|
promotion = Piece(new.turn, promotion)
|
|
|
|
if moved.piece == "pawn" and target in moves(source, bishop_dirs) and eaten==None:
|
|
# En passant
|
|
a, b = move(target, up), move(target, down)
|
|
board[a] = Piece()
|
|
board[b] = Piece()
|
|
|
|
# Castling
|
|
if moved.piece == "rook":
|
|
new.can_castle[new.turn][moved.side] = False
|
|
if eaten.piece == "rook":
|
|
new.can_castle[eaten.color][eaten.side] = False
|
|
if moved.piece == "king":
|
|
new.can_castle[new.turn]["queen"] = False
|
|
new.can_castle[new.turn]["king"] = False
|
|
|
|
board[source] = Piece()
|
|
board[target] = promotion
|
|
|
|
if new.is_check(self.turn):
|
|
return False
|
|
|
|
self.rebase()
|
|
self.moves.append((source, target))
|
|
self.board = new.board
|
|
self.can_castle = new.can_castle
|
|
self.turn = invert(moved.color)
|
|
self.history.append((deepcopy(self.can_castle),
|
|
deepcopy(self.board),
|
|
self.turn))
|
|
self.index += 1
|
|
return True
|
|
|
|
def is_legal(self, source, target, promotion):
|
|
"""Return True if move is partially legal. Does not
|
|
check whether the active players king is in check
|
|
after the move! See the validate_move method."""
|
|
piece = self.board[source].piece
|
|
color = self.board[source].color
|
|
pawn_on_last_rank = piece == "pawn" and \
|
|
home_ranks[invert(color)] == target[1]
|
|
if promotion != None and not pawn_on_last_rank:
|
|
return False
|
|
if promotion == None and pawn_on_last_rank:
|
|
return False
|
|
if promotion in ("king", "pawn"):
|
|
return False
|
|
return color == self.turn and target in self.possible_moves(source)
|
|
|
|
def is_attacked(self, color, sq):
|
|
"""Return True if square is attacked by enemy of opposite color."""
|
|
enemy_color = invert(color)
|
|
attacked = []
|
|
for piece in pieces:
|
|
occupied = self.occupying(Piece(enemy_color, piece))
|
|
attacked += sum(list(map(self.attacks, occupied)), [])
|
|
return sq in attacked
|
|
|
|
def all_legal_moves(self):
|
|
"""Return all possible partially legal moves by active
|
|
player. These moves can put the active player in check,
|
|
thus they are not completely legal. Castlings not included."""
|
|
color = self.turn
|
|
sqrs = []
|
|
for piece in pieces:
|
|
piece = Piece(color, piece)
|
|
sqrs += self.occupying(piece)
|
|
r = []
|
|
for sq in sqrs:
|
|
mvs = self.possible_moves(sq)
|
|
r += [(sq, m) for m in mvs]
|
|
return r
|
|
|
|
def possible_moves(self, sq):
|
|
"""Return all squares the piece from sq can move to.
|
|
Moving to these squares might put the player in check.
|
|
Castlings not included."""
|
|
board = self.board
|
|
piece = board[sq].piece
|
|
color = board[sq].color
|
|
|
|
def can_eat(target):
|
|
eaten = board[target]
|
|
return eaten != None and eaten.color != color
|
|
|
|
is_empty = self.is_empty
|
|
|
|
targets = [t for t in self.attacks(sq)
|
|
if can_eat(t) or is_empty(t)]
|
|
|
|
if piece == "pawn":
|
|
r = []
|
|
dir, back = up, down
|
|
if color == "black":
|
|
dir, back = down, up
|
|
frwd = move(sq, dir)
|
|
jump = move(frwd, dir)
|
|
|
|
if frwd in squares and is_empty(frwd):
|
|
r.append(frwd)
|
|
is_on_pawn_rank = pawn_ranks[color] == sq[1]
|
|
if is_on_pawn_rank and is_empty(jump):
|
|
r.append(jump)
|
|
for t in targets:
|
|
a, b = move(t, dir), move(t, back)
|
|
en_passant = can_eat(b) and self.index>0 and\
|
|
self.moves[self.index-1] == (a,b)
|
|
if can_eat(t) or en_passant:
|
|
r.append(t)
|
|
return r
|
|
elif piece == "king":
|
|
return [sq for sq in targets if not self.is_attacked(color, sq)]
|
|
else:
|
|
return targets
|
|
|
|
def attacks(self, sq):
|
|
"""Return all squares the piece on sq attacks. Return empty list
|
|
if square is empty."""
|
|
if sq not in squares:
|
|
return []
|
|
|
|
board = self.board
|
|
piece = board[sq].piece
|
|
color = board[sq].color
|
|
|
|
def possible_line(dir):
|
|
target = move(sq, dir)
|
|
while target in board and self.is_empty(target):
|
|
yield target
|
|
target = move(target, dir)
|
|
if target in board:
|
|
yield target
|
|
|
|
def possible_lines(dirs):
|
|
return sum(list(map(list, map(possible_line, dirs))), [])
|
|
|
|
rook = possible_lines(rook_dirs)
|
|
bishop = possible_lines(bishop_dirs)
|
|
|
|
if piece == "pawn":
|
|
if color == "white":
|
|
dirs = upper_left, upper_right
|
|
else:
|
|
dirs = lower_left, lower_right
|
|
return moves(sq, dirs)
|
|
elif piece == "knight":
|
|
nums = [2, -2, 1, -1]
|
|
dirs = [(a, b) for a,b in product(nums, nums)
|
|
if abs(a) != abs(b)]
|
|
return moves(sq, dirs)
|
|
elif piece == "rook":
|
|
return rook
|
|
elif piece == "bishop":
|
|
return bishop
|
|
elif piece == "queen":
|
|
return rook + bishop
|
|
elif piece == "king":
|
|
dirs = rook_dirs + bishop_dirs
|
|
return moves(sq, dirs)
|
|
return []
|
|
|
|
def validate_move(self, source, dest, promotion):
|
|
"""Create a copy of the game and try given move.
|
|
Return True if the move succeeds. This method
|
|
exists because is_legal does not test whether
|
|
the move puts the player in check."""
|
|
new = deepcopy(self)
|
|
valid = new.move(source, dest, promotion)
|
|
new.turn = self.turn
|
|
return valid, new
|
|
|
|
def AN_move(self, move):
|
|
"""Move is given in algebraic notation. The method
|
|
moves the piece if the move is legal and unambiguous,
|
|
else it returns False."""
|
|
AN = move
|
|
info = parse_AN(move)
|
|
if info == False:
|
|
return False
|
|
if info.queenside:
|
|
return self.castle("queen")
|
|
if info.kingside:
|
|
return self.castle("king")
|
|
|
|
piece = info.piece
|
|
sqrs = self.occupying(Piece(self.turn, piece))
|
|
promotion = info.promotion
|
|
dest = info.dest
|
|
if info.file != None:
|
|
sqrs = [sq for sq in sqrs if sq[0]==info.file]
|
|
if info.rank != None:
|
|
sqrs = [sq for sq in sqrs if sq[1]==info.rank]
|
|
moves = []
|
|
for sq in sqrs:
|
|
valid, new = self.validate_move(sq, dest, promotion)
|
|
check = new.is_check()==info.check and new.is_mate()==info.mate
|
|
if valid and check:
|
|
moves.append((sq, dest, promotion))
|
|
if len(moves) != 1:
|
|
return False
|
|
move = moves[0]
|
|
self.move(*move)
|
|
self.AN_moves.append(AN)
|
|
return True
|
|
|
|
|