Permalink
Join GitHub today
GitHub is home to over 50 million developers working together to host and review code, manage projects, and build software together.
Sign up| #!/usr/bin/env pypy | |
| # -*- coding: utf-8 -*- | |
| from __future__ import print_function | |
| import re, sys, time | |
| from itertools import count | |
| from collections import namedtuple | |
| ############################################################################### | |
| # Piece-Square tables. Tune these to change sunfish's behaviour | |
| ############################################################################### | |
| piece = { 'P': 100, 'N': 280, 'B': 320, 'R': 479, 'Q': 929, 'K': 60000 } | |
| pst = { | |
| 'P': ( 0, 0, 0, 0, 0, 0, 0, 0, | |
| 78, 83, 86, 73, 102, 82, 85, 90, | |
| 7, 29, 21, 44, 40, 31, 44, 7, | |
| -17, 16, -2, 15, 14, 0, 15, -13, | |
| -26, 3, 10, 9, 6, 1, 0, -23, | |
| -22, 9, 5, -11, -10, -2, 3, -19, | |
| -31, 8, -7, -37, -36, -14, 3, -31, | |
| 0, 0, 0, 0, 0, 0, 0, 0), | |
| 'N': ( -66, -53, -75, -75, -10, -55, -58, -70, | |
| -3, -6, 100, -36, 4, 62, -4, -14, | |
| 10, 67, 1, 74, 73, 27, 62, -2, | |
| 24, 24, 45, 37, 33, 41, 25, 17, | |
| -1, 5, 31, 21, 22, 35, 2, 0, | |
| -18, 10, 13, 22, 18, 15, 11, -14, | |
| -23, -15, 2, 0, 2, 0, -23, -20, | |
| -74, -23, -26, -24, -19, -35, -22, -69), | |
| 'B': ( -59, -78, -82, -76, -23,-107, -37, -50, | |
| -11, 20, 35, -42, -39, 31, 2, -22, | |
| -9, 39, -32, 41, 52, -10, 28, -14, | |
| 25, 17, 20, 34, 26, 25, 15, 10, | |
| 13, 10, 17, 23, 17, 16, 0, 7, | |
| 14, 25, 24, 15, 8, 25, 20, 15, | |
| 19, 20, 11, 6, 7, 6, 20, 16, | |
| -7, 2, -15, -12, -14, -15, -10, -10), | |
| 'R': ( 35, 29, 33, 4, 37, 33, 56, 50, | |
| 55, 29, 56, 67, 55, 62, 34, 60, | |
| 19, 35, 28, 33, 45, 27, 25, 15, | |
| 0, 5, 16, 13, 18, -4, -9, -6, | |
| -28, -35, -16, -21, -13, -29, -46, -30, | |
| -42, -28, -42, -25, -25, -35, -26, -46, | |
| -53, -38, -31, -26, -29, -43, -44, -53, | |
| -30, -24, -18, 5, -2, -18, -31, -32), | |
| 'Q': ( 6, 1, -8,-104, 69, 24, 88, 26, | |
| 14, 32, 60, -10, 20, 76, 57, 24, | |
| -2, 43, 32, 60, 72, 63, 43, 2, | |
| 1, -16, 22, 17, 25, 20, -13, -6, | |
| -14, -15, -2, -5, -1, -10, -20, -22, | |
| -30, -6, -13, -11, -16, -11, -16, -27, | |
| -36, -18, 0, -19, -15, -15, -21, -38, | |
| -39, -30, -31, -13, -31, -36, -34, -42), | |
| 'K': ( 4, 54, 47, -99, -99, 60, 83, -62, | |
| -32, 10, 55, 56, 56, 55, 10, 3, | |
| -62, 12, -57, 44, -67, 28, 37, -31, | |
| -55, 50, 11, -4, -19, 13, 0, -49, | |
| -55, -43, -52, -28, -51, -47, -8, -50, | |
| -47, -42, -43, -79, -64, -32, -29, -32, | |
| -4, 3, -14, -50, -57, -18, 13, 4, | |
| 17, 30, -3, -14, 6, -1, 40, 18), | |
| } | |
| # Pad tables and join piece and pst dictionaries | |
| for k, table in pst.items(): | |
| padrow = lambda row: (0,) + tuple(x+piece[k] for x in row) + (0,) | |
| pst[k] = sum((padrow(table[i*8:i*8+8]) for i in range(8)), ()) | |
| pst[k] = (0,)*20 + pst[k] + (0,)*20 | |
| ############################################################################### | |
| # Global constants | |
| ############################################################################### | |
| # Our board is represented as a 120 character string. The padding allows for | |
| # fast detection of moves that don't stay within the board. | |
| A1, H1, A8, H8 = 91, 98, 21, 28 | |
| initial = ( | |
| ' \n' # 0 - 9 | |
| ' \n' # 10 - 19 | |
| ' rnbqkbnr\n' # 20 - 29 | |
| ' pppppppp\n' # 30 - 39 | |
| ' ........\n' # 40 - 49 | |
| ' ........\n' # 50 - 59 | |
| ' ........\n' # 60 - 69 | |
| ' ........\n' # 70 - 79 | |
| ' PPPPPPPP\n' # 80 - 89 | |
| ' RNBQKBNR\n' # 90 - 99 | |
| ' \n' # 100 -109 | |
| ' \n' # 110 -119 | |
| ) | |
| # Lists of possible moves for each piece type. | |
| N, E, S, W = -10, 1, 10, -1 | |
| directions = { | |
| 'P': (N, N+N, N+W, N+E), | |
| 'N': (N+N+E, E+N+E, E+S+E, S+S+E, S+S+W, W+S+W, W+N+W, N+N+W), | |
| 'B': (N+E, S+E, S+W, N+W), | |
| 'R': (N, E, S, W), | |
| 'Q': (N, E, S, W, N+E, S+E, S+W, N+W), | |
| 'K': (N, E, S, W, N+E, S+E, S+W, N+W) | |
| } | |
| # Mate value must be greater than 8*queen + 2*(rook+knight+bishop) | |
| # King value is set to twice this value such that if the opponent is | |
| # 8 queens up, but we got the king, we still exceed MATE_VALUE. | |
| # When a MATE is detected, we'll set the score to MATE_UPPER - plies to get there | |
| # E.g. Mate in 3 will be MATE_UPPER - 6 | |
| MATE_LOWER = piece['K'] - 10*piece['Q'] | |
| MATE_UPPER = piece['K'] + 10*piece['Q'] | |
| # The table size is the maximum number of elements in the transposition table. | |
| TABLE_SIZE = 1e7 | |
| # Constants for tuning search | |
| QS_LIMIT = 219 | |
| EVAL_ROUGHNESS = 13 | |
| DRAW_TEST = True | |
| ############################################################################### | |
| # Chess logic | |
| ############################################################################### | |
| class Position(namedtuple('Position', 'board score wc bc ep kp')): | |
| """ A state of a chess game | |
| board -- a 120 char representation of the board | |
| score -- the board evaluation | |
| wc -- the castling rights, [west/queen side, east/king side] | |
| bc -- the opponent castling rights, [west/king side, east/queen side] | |
| ep - the en passant square | |
| kp - the king passant square | |
| """ | |
| def gen_moves(self): | |
| # For each of our pieces, iterate through each possible 'ray' of moves, | |
| # as defined in the 'directions' map. The rays are broken e.g. by | |
| # captures or immediately in case of pieces such as knights. | |
| for i, p in enumerate(self.board): | |
| if not p.isupper(): continue | |
| for d in directions[p]: | |
| for j in count(i+d, d): | |
| q = self.board[j] | |
| # Stay inside the board, and off friendly pieces | |
| if q.isspace() or q.isupper(): break | |
| # Pawn move, double move and capture | |
| if p == 'P' and d in (N, N+N) and q != '.': break | |
| if p == 'P' and d == N+N and (i < A1+N or self.board[i+N] != '.'): break | |
| if p == 'P' and d in (N+W, N+E) and q == '.' \ | |
| and j not in (self.ep, self.kp, self.kp-1, self.kp+1): break | |
| # Move it | |
| yield (i, j) | |
| # Stop crawlers from sliding, and sliding after captures | |
| if p in 'PNK' or q.islower(): break | |
| # Castling, by sliding the rook next to the king | |
| if i == A1 and self.board[j+E] == 'K' and self.wc[0]: yield (j+E, j+W) | |
| if i == H1 and self.board[j+W] == 'K' and self.wc[1]: yield (j+W, j+E) | |
| def rotate(self): | |
| ''' Rotates the board, preserving enpassant ''' | |
| return Position( | |
| self.board[::-1].swapcase(), -self.score, self.bc, self.wc, | |
| 119-self.ep if self.ep else 0, | |
| 119-self.kp if self.kp else 0) | |
| def nullmove(self): | |
| ''' Like rotate, but clears ep and kp ''' | |
| return Position( | |
| self.board[::-1].swapcase(), -self.score, | |
| self.bc, self.wc, 0, 0) | |
| def move(self, move): | |
| i, j = move | |
| p, q = self.board[i], self.board[j] | |
| put = lambda board, i, p: board[:i] + p + board[i+1:] | |
| # Copy variables and reset ep and kp | |
| board = self.board | |
| wc, bc, ep, kp = self.wc, self.bc, 0, 0 | |
| score = self.score + self.value(move) | |
| # Actual move | |
| board = put(board, j, board[i]) | |
| board = put(board, i, '.') | |
| # Castling rights, we move the rook or capture the opponent's | |
| if i == A1: wc = (False, wc[1]) | |
| if i == H1: wc = (wc[0], False) | |
| if j == A8: bc = (bc[0], False) | |
| if j == H8: bc = (False, bc[1]) | |
| # Castling | |
| if p == 'K': | |
| wc = (False, False) | |
| if abs(j-i) == 2: | |
| kp = (i+j)//2 | |
| board = put(board, A1 if j < i else H1, '.') | |
| board = put(board, kp, 'R') | |
| # Pawn promotion, double move and en passant capture | |
| if p == 'P': | |
| if A8 <= j <= H8: | |
| board = put(board, j, 'Q') | |
| if j - i == 2*N: | |
| ep = i + N | |
| if j == self.ep: | |
| board = put(board, j+S, '.') | |
| # We rotate the returned position, so it's ready for the next player | |
| return Position(board, score, wc, bc, ep, kp).rotate() | |
| def value(self, move): | |
| i, j = move | |
| p, q = self.board[i], self.board[j] | |
| # Actual move | |
| score = pst[p][j] - pst[p][i] | |
| # Capture | |
| if q.islower(): | |
| score += pst[q.upper()][119-j] | |
| # Castling check detection | |
| if abs(j-self.kp) < 2: | |
| score += pst['K'][119-j] | |
| # Castling | |
| if p == 'K' and abs(i-j) == 2: | |
| score += pst['R'][(i+j)//2] | |
| score -= pst['R'][A1 if j < i else H1] | |
| # Special pawn stuff | |
| if p == 'P': | |
| if A8 <= j <= H8: | |
| score += pst['Q'][j] - pst['P'][j] | |
| if j == self.ep: | |
| score += pst['P'][119-(j+S)] | |
| return score | |
| ############################################################################### | |
| # Search logic | |
| ############################################################################### | |
| # lower <= s(pos) <= upper | |
| Entry = namedtuple('Entry', 'lower upper') | |
| class Searcher: | |
| def __init__(self): | |
| self.tp_score = {} | |
| self.tp_move = {} | |
| self.history = set() | |
| self.nodes = 0 | |
| def bound(self, pos, gamma, depth, root=True): | |
| """ returns r where | |
| s(pos) <= r < gamma if gamma > s(pos) | |
| gamma <= r <= s(pos) if gamma <= s(pos)""" | |
| self.nodes += 1 | |
| # Depth <= 0 is QSearch. Here any position is searched as deeply as is needed for | |
| # calmness, and from this point on there is no difference in behaviour depending on | |
| # depth, so so there is no reason to keep different depths in the transposition table. | |
| depth = max(depth, 0) | |
| # Sunfish is a king-capture engine, so we should always check if we | |
| # still have a king. Notice since this is the only termination check, | |
| # the remaining code has to be comfortable with being mated, stalemated | |
| # or able to capture the opponent king. | |
| if pos.score <= -MATE_LOWER: | |
| return -MATE_UPPER | |
| # We detect 3-fold captures by comparing against previously | |
| # _actually played_ positions. | |
| # Note that we need to do this before we look in the table, as the | |
| # position may have been previously reached with a different score. | |
| # This is what prevents a search instability. | |
| # FIXME: This is not true, since other positions will be affected by | |
| # the new values for all the drawn positions. | |
| if DRAW_TEST: | |
| if not root and pos in self.history: | |
| return 0 | |
| # Look in the table if we have already searched this position before. | |
| # We also need to be sure, that the stored search was over the same | |
| # nodes as the current search. | |
| entry = self.tp_score.get((pos, depth, root), Entry(-MATE_UPPER, MATE_UPPER)) | |
| if entry.lower >= gamma and (not root or self.tp_move.get(pos) is not None): | |
| return entry.lower | |
| if entry.upper < gamma: | |
| return entry.upper | |
| # Here extensions may be added | |
| # Such as 'if in_check: depth += 1' | |
| # Generator of moves to search in order. | |
| # This allows us to define the moves, but only calculate them if needed. | |
| def moves(): | |
| # First try not moving at all. We only do this if there is at least one major | |
| # piece left on the board, since otherwise zugzwangs are too dangerous. | |
| if depth > 0 and not root and any(c in pos.board for c in 'RBNQ'): | |
| yield None, -self.bound(pos.nullmove(), 1-gamma, depth-3, root=False) | |
| # For QSearch we have a different kind of null-move, namely we can just stop | |
| # and not capture anythign else. | |
| if depth == 0: | |
| yield None, pos.score | |
| # Then killer move. We search it twice, but the tp will fix things for us. | |
| # Note, we don't have to check for legality, since we've already done it | |
| # before. Also note that in QS the killer must be a capture, otherwise we | |
| # will be non deterministic. | |
| killer = self.tp_move.get(pos) | |
| if killer and (depth > 0 or pos.value(killer) >= QS_LIMIT): | |
| yield killer, -self.bound(pos.move(killer), 1-gamma, depth-1, root=False) | |
| # Then all the other moves | |
| for move in sorted(pos.gen_moves(), key=pos.value, reverse=True): | |
| #for val, move in sorted(((pos.value(move), move) for move in pos.gen_moves()), reverse=True): | |
| # If depth == 0 we only try moves with high intrinsic score (captures and | |
| # promotions). Otherwise we do all moves. | |
| if depth > 0 or pos.value(move) >= QS_LIMIT: | |
| yield move, -self.bound(pos.move(move), 1-gamma, depth-1, root=False) | |
| # Run through the moves, shortcutting when possible | |
| best = -MATE_UPPER | |
| for move, score in moves(): | |
| best = max(best, score) | |
| if best >= gamma: | |
| # Clear before setting, so we always have a value | |
| if len(self.tp_move) > TABLE_SIZE: self.tp_move.clear() | |
| # Save the move for pv construction and killer heuristic | |
| self.tp_move[pos] = move | |
| break | |
| # Stalemate checking is a bit tricky: Say we failed low, because | |
| # we can't (legally) move and so the (real) score is -infty. | |
| # At the next depth we are allowed to just return r, -infty <= r < gamma, | |
| # which is normally fine. | |
| # However, what if gamma = -10 and we don't have any legal moves? | |
| # Then the score is actaully a draw and we should fail high! | |
| # Thus, if best < gamma and best < 0 we need to double check what we are doing. | |
| # This doesn't prevent sunfish from making a move that results in stalemate, | |
| # but only if depth == 1, so that's probably fair enough. | |
| # (Btw, at depth 1 we can also mate without realizing.) | |
| if best < gamma and best < 0 and depth > 0: | |
| is_dead = lambda pos: any(pos.value(m) >= MATE_LOWER for m in pos.gen_moves()) | |
| if all(is_dead(pos.move(m)) for m in pos.gen_moves()): | |
| in_check = is_dead(pos.nullmove()) | |
| best = -MATE_UPPER if in_check else 0 | |
| # Clear before setting, so we always have a value | |
| if len(self.tp_score) > TABLE_SIZE: self.tp_score.clear() | |
| # Table part 2 | |
| if best >= gamma: | |
| self.tp_score[pos, depth, root] = Entry(best, entry.upper) | |
| if best < gamma: | |
| self.tp_score[pos, depth, root] = Entry(entry.lower, best) | |
| return best | |
| def search(self, pos, history=()): | |
| """ Iterative deepening MTD-bi search """ | |
| self.nodes = 0 | |
| if DRAW_TEST: | |
| self.history = set(history) | |
| # print('# Clearing table due to new history') | |
| self.tp_score.clear() | |
| # In finished games, we could potentially go far enough to cause a recursion | |
| # limit exception. Hence we bound the ply. | |
| for depth in range(1, 1000): | |
| # The inner loop is a binary search on the score of the position. | |
| # Inv: lower <= score <= upper | |
| # 'while lower != upper' would work, but play tests show a margin of 20 plays | |
| # better. | |
| lower, upper = -MATE_UPPER, MATE_UPPER | |
| while lower < upper - EVAL_ROUGHNESS: | |
| gamma = (lower+upper+1)//2 | |
| score = self.bound(pos, gamma, depth) | |
| if score >= gamma: | |
| lower = score | |
| if score < gamma: | |
| upper = score | |
| # We want to make sure the move to play hasn't been kicked out of the table, | |
| # So we make another call that must always fail high and thus produce a move. | |
| self.bound(pos, lower, depth) | |
| # If the game hasn't finished we can retrieve our move from the | |
| # transposition table. | |
| yield depth, self.tp_move.get(pos), self.tp_score.get((pos, depth, True)).lower | |
| ############################################################################### | |
| # User interface | |
| ############################################################################### | |
| # Python 2 compatability | |
| if sys.version_info[0] == 2: | |
| input = raw_input | |
| def parse(c): | |
| fil, rank = ord(c[0]) - ord('a'), int(c[1]) - 1 | |
| return A1 + fil - 10*rank | |
| def render(i): | |
| rank, fil = divmod(i - A1, 10) | |
| return chr(fil + ord('a')) + str(-rank + 1) | |
| def print_pos(pos): | |
| print() | |
| uni_pieces = {'R':'♜', 'N':'♞', 'B':'♝', 'Q':'♛', 'K':'♚', 'P':'♟', | |
| 'r':'♖', 'n':'♘', 'b':'♗', 'q':'♕', 'k':'♔', 'p':'♙', '.':'·'} | |
| for i, row in enumerate(pos.board.split()): | |
| print(' ', 8-i, ' '.join(uni_pieces.get(p, p) for p in row)) | |
| print(' a b c d e f g h \n\n') | |
| def main(): | |
| hist = [Position(initial, 0, (True,True), (True,True), 0, 0)] | |
| searcher = Searcher() | |
| while True: | |
| print_pos(hist[-1]) | |
| if hist[-1].score <= -MATE_LOWER: | |
| print("You lost") | |
| break | |
| # We query the user until she enters a (pseudo) legal move. | |
| move = None | |
| while move not in hist[-1].gen_moves(): | |
| match = re.match('([a-h][1-8])'*2, input('Your move: ')) | |
| if match: | |
| move = parse(match.group(1)), parse(match.group(2)) | |
| else: | |
| # Inform the user when invalid input (e.g. "help") is entered | |
| print("Please enter a move like g8f6") | |
| hist.append(hist[-1].move(move)) | |
| # After our move we rotate the board and print it again. | |
| # This allows us to see the effect of our move. | |
| print_pos(hist[-1].rotate()) | |
| if hist[-1].score <= -MATE_LOWER: | |
| print("You won") | |
| break | |
| # Fire up the engine to look for a move. | |
| start = time.time() | |
| for _depth, move, score in searcher.search(hist[-1], hist): | |
| if time.time() - start > 1: | |
| break | |
| if score == MATE_UPPER: | |
| print("Checkmate!") | |
| # The black player moves from a rotated position, so we have to | |
| # 'back rotate' the move before printing it. | |
| print("My move:", render(119-move[0]) + render(119-move[1])) | |
| hist.append(hist[-1].move(move)) | |
| if __name__ == '__main__': | |
| main() |