thomasahle / sunfish

Sunfish: a Python Chess Engine in 111 lines of code
https://www.chessprogramming.org/Sunfish
Other
2.95k stars 543 forks source link

Three Check Chess variant #107

Closed purvi1508 closed 1 year ago

purvi1508 commented 1 year ago

How could we make changes in code to make it suitable for three check chess variant

thomasahle commented 1 year ago

Yes, you could probably do that! Just add a tracker counting the number of checks given so far to the Board object.

purvi1508 commented 1 year ago

I have did the necessary changes but faced an error, could you suggest to me where I am going wrong?

import chess import chess.engine import chess.variant

import time, math from itertools import count from collections import namedtuple, defaultdict

If we could rely on the env -S argument, we could just use "pypy3 -u"

as the shebang to unbuffer stdout. But alas we have to do this instead:

from functools import partial print = partial(print, flush=True)

version = "___"

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

Lists of possible moves for each piece type.

N, E, S, W = -10, 1, 10, -1 directions = { "P": (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 8queen + 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"]

Constants for tuning search

QS = 35 EVAL_ROUGHNESS = 15

minifier-hide start

opt_ranges = dict( QS = (0, 300), EVAL_ROUGHNESS = (0, 50), )

minifier-hide end

import sys

INF = sys.maxsize

import chess import chess.variant from collections import namedtuple from itertools import count board = chess.variant.ThreeCheckBoard() Move = namedtuple("Move", "i j prom")

class Position(namedtuple("Position", "board score kp checks1 checks2")): """A state of a chess game board -- a ThreeCheckBoard representation of the board score -- the board evaluation kp - the king passant square checks1 - number of checks inflicted by player 1 checks2 - number of checks inflicted by player 2 """

def is_check(self, move):
    i, j, prom = move
    p, q = self.board.piece_at(i), self.board.piece_at(j)
    # Copy variables and reset kp and checks
    board = self.board.copy()
    kp = None
    checks1, checks2 = self.checks1, self.checks2
    # Simulate the move
    board.remove_piece_at(i)
    board.set_piece_at(j, p)
    # Check if the move gives a check
    if q and q.piece_type == chess.KING:
        if p.piece_type == chess.PAWN:
            checks1 += 1
            if checks1 == 3:
                self.score = -chess.INFINITY
        else:
            checks2 += 1
            if checks2 == 3:
                self.score = chess.INFINITY
        return True
    return False

def is_game_over(self):
    # Check if repeated 3 position
    if self.board.is_repetition(3):
        return True, "Draw"
    # Check for three-check
    if self.is_three_check():
        return True, "Three-check"
    else:
        return False, None

def gen_moves(self):

    self.history.append(self.board.fen())
    for i, p in self.board.piece_map().items():
        if p.color != self.board.turn:
            continue
        for d in p.piece_type.directions:
            for j in count(i + d, d):
                q = self.board.piece_at(j)
                # Stay inside the board, and off friendly pieces
                if q is None or q.color == p.color:
                    break
                # Pawn move, double move and capture
                if p.piece_type == chess.PAWN:
                    if d in (chess.WEST, chess.EAST) and q:
                        break
                    if (
                        d in (chess.WEST + chess.NORTH, chess.EAST + chess.NORTH)
                        and not q
                        and j not in (self.board.ep_square, self.board.ep_square - 1, self.board.ep_square + 1)
                        and j !=  abs(j - self.board.ep_square) >= 2
                    ):
                        break

                    if d in (chess.WEST + chess.NORTH, chess.EAST + chess.NORTH) and not q:
                        if j not in (self.board.ep_square, self.board.ep_square - 1, self.board.ep_square + 1) or j != self.board.ep_square and abs(j - self.board.ep_square) >= 2:
                            break
                        else:
                            yield chess.Move(i, j)
                    if d == chess.NORTH and not q:
                        if j + chess.NORTH * self.board.turn in self.board.occupied_co[chess.WHITE if self.board.turn == chess.BLACK else chess.BLACK]:
                            break
                        elif j + chess.NORTH * self.board.turn not in self.board.occupied:
                            if j < 16 or j >= 48:
                                 for promotion in (chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT):
                                      yield chess.Move(i, j, promotion=promotion)
                            else:
                                  yield chess.Move(i, j)
            # Other pieces    
                else:
                    if j in chess.SQUARES:
                        if q is None:
                            yield chess.Move(i, j)
                        elif q.color != p.color:
                            yield chess.Move(i, j)
                        break
                    else:
                        break
def move(self, move):
    i, j, prom = move.i, move.j, move.prom
    p = self.board.piece_at(i)
    # Copy variables and reset kp and checks
    board = self.board.copy()
    kp = None
    checks1, checks2 = self.checks1, self.checks2
    # Simulate the move
    board.remove_piece_at(i)
    if prom:
        board.set_piece_at(j, chess.Piece(prom, p.color))
    else:
        board.set_piece_at(j, p)
    if p.piece_type == chess.KING:
        if abs(i-j) == 2:
            kp = chess.square((i+j)//2, j if j<i else i)
    elif p.piece_type == chess.PAWN and abs(i-j) == 16:
        kp = i + (j-i)//2
    # Count number of checks
    checks1, checks2 = 0, 0
    opp_king = chess.KING | (chess.BLACK if p.color == chess.WHITE else chess.WHITE)
    for k in board.pieces(opp_king, p.color):
        for l in board.attacks_mask(k) & board.occupied_co[p.color]:
            checks2 += 1 if board.gives_check(chess.Move(l, k)) else 0
    opp_king = chess.KING | (chess.WHITE if p.color == chess.WHITE else chess.BLACK)
    for k in board.pieces(opp_king, not p.color):
        for l in board.attacks_mask(k) & board.occupied_co[not p.color]:
            checks1 += 1 if board.gives_check(chess.Move(l, k)) else 0
    return Position(board, self.score, kp, checks1, checks2)

def is_three_check(self):
    return self.checks1 >= 3 or self.checks2 >= 3

def evaluate(self):
    # Check for checkmate
    if self.board.is_checkmate():
        return -chess.INFINITY if self.board.turn == chess.WHITE else chess.INFINITY
    # Check for stalemate
    if self.board.is_stalemate() or self.board.is_insufficient_material():
        return 0
    # Evaluate based on material and positional advantage
    score = 0
    for i in range(64):
        p = self.board.piece_at(i)
        if p is None:
            continue
        score += (chess.piece_value[p.piece_type] if p.color == chess.WHITE else -chess.piece_value[p.piece_type])
        if p.color == chess.WHITE:
            if p.piece_type == chess.PAWN and i+8 in self.board.occupied:
                score -= 5
            if p.piece_type == chess.KING and i in (chess.C1, chess.C8, chess.G1, chess.G8):
                score -= 25
        else:
            if p.piece_type == chess.PAWN and i-8 in self.board.occupied:
                score += 5
            if p.piece_type == chess.KING and i in (chess.C1, chess.C8, chess.G1, chess.G8):
                score += 25
    return score

def value(self, move):
    i, j, prom = move.from_square, move.to_square, move.promotion
    p = self.piece_type_at(i)
    q = self.piece_type_at(j)

    # Actual move
    score = pst[p][j] - pst[p][i]

    # Capture
    if q is not None and q != chess.PAWN:
       score += pst[q][j]

     # Special pawn stuff
    if p == chess.PAWN:
       if chess.square_rank(j) in [0, 7]:
          score += pst[prom][j] - pst[chess.PAWN][j]

    return score

lower <= s(pos) <= upper

Entry = namedtuple("Entry", "lower upper", defaults=(-MATE_UPPER, MATE_UPPER))

class Searcher: def init(self): self.tp_score = defaultdict(Entry) self.tp_move = {} self.history = set() self.nodes = 0

def bound(self, pos, gamma, depth, can_null=True):
    """ Let s* be the "true" score of the sub-tree we are searching.
        The method returns r, where
        if gamma >  s* then s* <= r < gamma  (A better upper bound)
        if gamma <= s* then gamma <= r <= s* (A better lower bound) """
    self.nodes += 1

    depth = max(depth, 0)
    if pos.score <= -MATE_LOWER:
        return -MATE_UPPER

    entry = self.tp_score[pos, depth, can_null]
    if entry.lower >= gamma: return entry.lower
    if entry.upper < gamma: return entry.upper

    if can_null and depth > 0 and pos in self.history:
        return 0

    # 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 > 2 and can_null and any(c in pos.board for c in "RBNQ"):
            yield None, -self.bound(pos.rotate(nullmove=True), 1 - gamma, depth - 3)

        # 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

        # Look for the strongest ove from last time, the hash-move.
        killer = self.tp_move.get(pos)

        # If there isn't one, try to find one with a more shallow search.

        if not killer and depth > 2:
            self.bound(pos, gamma, depth - 3, can_null=False)
            killer = self.tp_move.get(pos)

        # If depth == 0 we only try moves with high intrinsic score (captures and
        # promotions). Otherwise we do all moves. This is called quiescent search.
        val_lower = QS if depth == 0 else -MATE_LOWER

        # Only play the move if it would be included at the current val-limit,
        # since otherwise we'd get search instability.
        # We will search it again in the main loop below, but the tp will fix
        # things for us.
        if killer and pos.value(killer) >= val_lower:
            yield killer, -self.bound(pos.move(killer), 1 - gamma, depth - 1)

        # Then all the other moves
        for val, move in sorted(((pos.value(m), m) for m in pos.gen_moves()), reverse=True):
            # Quiescent search
            if val < val_lower:
                break

            if depth <= 1 and pos.score + val < gamma:
                # Need special case for MATE, since it would normally be caught
                # before standing pat.
                yield move, pos.score + val if val < MATE_LOWER else MATE_UPPER
                # We can also break, since we have ordered the moves by value,
                # so it can't get any better than this.
                break

            yield move, -self.bound(pos.move(move), 1 - gamma, depth - 1)

    # Run through the moves, shortcutting when possible
    best = -MATE_UPPER
    for move, score in moves():
        best = max(best, score)
        if best >= gamma:
            # Save the move for pv construction and killer heuristic
            if move is not None:
                self.tp_move[pos] = move
            break

    if depth > 0 and best == -MATE_UPPER:
        flipped = pos.rotate(nullmove=True)
        # Hopefully this is already in the TT because of null-move
        in_check = self.bound(flipped, MATE_UPPER, 0) == MATE_UPPER
        best = -MATE_LOWER if in_check else 0

    # Table part 2
    if best >= gamma:
        self.tp_score[pos, depth, can_null] = Entry(best, entry.upper)
    if best < gamma:
        self.tp_score[pos, depth, can_null] = Entry(entry.lower, best)

    return best

def search(self, history):
    """Iterative deepening MTD-bi search"""
    self.nodes = 0
    self.history = set(history)
    self.tp_score.clear()

    gamma = 0
    # In finished games, we could potentially go far enough to cause a recursion
    # limit exception. Hence we bound the ply. We also can't start at 0, since
    # that's quiscent search, and we don't always play legal moves there.
    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 it's too much effort to spend
        # on what's probably not going to change the move played.
        lower, upper = -MATE_LOWER, MATE_LOWER
        while lower < upper - EVAL_ROUGHNESS:
            score = self.bound(history[-1], gamma, depth, can_null=False)
            if score >= gamma:
                lower = score
            if score < gamma:
                upper = score
            yield depth, gamma, score, self.tp_move.get(history[-1])
            gamma = (lower + upper + 1) // 2

A1, H1, A8, H8 = 91, 98, 21, 28

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 )

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)

hist = [Position(initial, 0, 0,0,0)]

searcher = Searcher() while True: args = input().split() if args[0] == "uci": print(f"id name {version}") print("uciok")

elif args[0] == "isready":
    print("readyok")

elif args[0] == "quit":
    break

elif args[:2] == ["position", "startpos"]:
    del hist[1:]
    for ply, move in enumerate(args[3:]):
        i, j, prom = parse(move[:2]), parse(move[2:4]), move[4:].upper()
        if ply % 2 == 1:
            i, j = 119 - i, 119 - j
        hist.append(hist[-1].move(Move(i, j, prom)))

elif args[0] == "go":
    wtime, btime, winc, binc = [int(a) / 1000 for a in args[2::2]]
    if len(hist) % 2 == 0:
        wtime, winc = btime, binc
    think = min(wtime / 40 + winc, wtime / 2 - 1)

    start = time.time()
    move_str = None
    for depth, gamma, score, move in Searcher().search(hist):
        # The only way we can be sure to have the real move in tp_move,
        # is if we have just failed high.
        if score >= gamma:
            i, j = move.i, move.j
            if len(hist) % 2 == 0:
                i, j = 119 - i, 119 - j
            move_str = render(i) + render(j) + move.prom.lower()
            print(f"info depth {depth} score cp {score} pv {move_str}")
        if move_str and time.time() - start > think * 0.8:
            break

    print("bestmove", move_str or '(none)')