#!/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 anything 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 ############################################################################### 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 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()