pythonartificial-intelligencetic-tac-toeminimax

Depth never reaching 0 in depth-limited minimax algorithm


I have been trying to code an AI for a traditional Turkish game "3 Stones" ("3 Taş" in Turkish), a variant of Three Men's Morris.

Background: The game

The game is played as follows. The first player has 3 white stones and the second player has 3 black stones. In a first phase, players take turns to place once of their stones on the board, until they are all placed on the board. In a second phase, players move each turn one of their stones one step along a line of the board to an adjacent, free spot. The player that can move all their three stones in one connected line (vertical or horizontal) wins the game.

enter image description here

My approach

I implemented the minimax algorithm using Python.

The game is not necessarily finite (moves can keep repeating), so instead of using the normal minimax, I chose to make it depth limited.

The code is shown below.

import copy
import math

board=[["_","_","_"],
       ["_","_","_"],
        ["_","_","_"]]
turn="b"
black_stones=[]
white_stones=[]
win=""


def print_board():
    print(board[0])
    print(board[1])
    print(board[2])

def is_move_possible(move):
    if(len(black_stones)<3):
        if(board[int(move[0]) - 1][int(move[1]) - 1]=="_"):
            return True
        return False
def is_move_possible_after(piece,move,turn,board):


    if (abs(int(move[0])-int(piece[0]))==1 or abs(int(move[1])-int(piece[1]))==1) and not(abs(int(move[0])-int(piece[0]))==1 and abs(int(move[1])-int(piece[1]))==1):
        if(board[int(move[0]) - 1][int(move[1]) - 1]=="_") and (board[int(piece[0]) - 1][int(piece[1]) - 1]==turn):
            return True
    return False

def move_once():
    global last_move
    global turn
    global board
    if (turn == "b"):
        move = input("Move:")
        if not (is_move_possible(move)):
            return move_once()
        board[int(move[0]) - 1][int(move[1]) - 1] = turn

        white_stones.append([int(move[0])-1,int(move[1])-1])
        last_move=move
        turn="s"
    elif (turn == "s"):
        move=minimax(board,3)
        board[int(move[0])][int(move[1])] = turn
        black_stones.append([int(move[0]), int(move[1])])
        turn = "b"
def move_sonra():

    global board
    global turn


    if turn=="b":
        piece = input("piece:")
        move = input("move:")
        if not(is_move_possible_after(piece,move,turn,board)):
            return move_sonra()

        board[int(piece[0])-1][int(piece[1])-1],board[int(move[0])-1][int(move[1])-1]=board[int(move[0])-1][int(move[1])-1],board[int(piece[0])-1][int(piece[1])-1]


        turn="s"


    elif (turn=="s"):
        aimove=minimax(board,3)
        piece=aimove[0]
        move=aimove[1]








        board[int(piece[0])][int(piece[1])], board[int(move[0])][int(move[1])] = board[int(move[0])][int(move[1])], board[int(piece[0])][int(piece[1])]

        turn="b"
def winner(board):


    columns=[[board[0][0],board[1][0],board[2][0]],[board[0][1],board[1][1],board[2][1]],[board[0][2],board[1][2],board[2][2]]]
    for i in columns:
        if i==["b","b","b"]:
            win="b"
            return win
        elif i==["s","s","s"]:
            win="s"
            return win
    for i in board:
        if i==["b","b","b"]:
            win="b"
            return win
        elif i==["s","s","s"]:
            win="s"
            return win
    if(len(white_stones)+len(black_stones)==9):
        win="="
        return win
    return False


def is_finished(board):
    columns = [[board[0][0], board[1][0], board[2][0]], [board[0][1], board[1][1], board[2][1]],
               [board[0][2], board[1][2], board[2][2]]]
    for i in columns:
        if i == ["b", "b", "b"]:

            return True
        elif i == ["s", "s", "s"]:

            return True
    for i in board:
        if i == ["b", "b", "b"]:

            return True
        elif i == ["s", "s", "s"]:

            return True

    return False
def actions_before(board):
    moves=[]
    for i in range(0,len(board)):
        for j in range(0,len(board[i])):
            if(board[i][j]=="_"):
                moves.append([i,j])
    return moves
def actions_after(board,turn):

    moves=[]
    beyaz_pullar2 = []
    for i in range(0, len(board)):
        for j in range(0, len(board[i])):
            if (board[i][j] == "b"):
                beyaz_pullar2.append([i, j])


    if(turn=="b"):
        for piece in beyaz_pullar2:
            for move in actions_before(board):




                if(is_move_possible_after([piece[0]+1,piece[1]+1],[move[0]+1,move[1]+1],"b",board)):

                    moves.append([piece,move])
    elif(turn=="s"):
        siyah_pullar2=[]
        for i in range(0,len(board)):
            for j in range(0,len(board[i])):
                if(board[i][j]=="s"):

                    siyah_pullar2.append((i,j))


        for piece in siyah_pullar2:
            for move in actions_before(board):

                if(is_move_possible_after([piece[0]+1,piece[1]+1],[move[0]+1,move[1]+1],turn,board)):
                    moves.append([piece,move])
    return moves
def player(board):


      b_count = 0
      s_count = 0
      for row in board:

          for cell in row:
              if (cell == "b"):
                  b_count += 1
              if (cell == "s"):
                  s_count += 1

      if (b_count == s_count):
          return "b"
      return "s"
def result_before(board,move):
    copyboard=copy.deepcopy(board)
    copyboard[move[0]][move[1]]=player(board)
    return copyboard
def result_after(board,move):
    copyboard=copy.deepcopy(board)
    piece=move[0]
    place=move[1]
    copyboard[piece[0]][piece[1]],copyboard[place[0]][place[1]]=copyboard[place[0]][place[1]],copyboard[piece[0]][piece[1]]
    return copyboard
def utility(board):
    if((winner(board)=="s")):
        return 100
    elif(winner(board)=="b"):
        return -100
    else:
        return 0
def eval(board,depth,turn):
    print("Depth:",depth)


    black_stones = []
    for i in range(0, len(board)):
        for j in range(0, len(board[i])):
            if (board[i][j] == "s"):
                black_stones.append((i, j))


    if(is_finished(board)):

        return utility(board)
    elif(depth<=0):

        return 0





    elif(len(black_stones)<3):
        if(turn=="s"):
            v=-math.inf
            for move in actions_before(board):
                black_stones.append(move)


                score=eval(result_before(board,move),depth-1,"b")

                v=max(v,score)
                black_stones.pop(-1)


            print("girdi")
            return v
        elif (turn == "b"):
            v = math.inf
            for move in actions_before(board):




                score= eval(result_before(board,move),depth-1,"s")

                v = min(v,score )

            return v

    else:




        if (turn == "s"):



            v = -math.inf
            actions=actions_after(board,turn)


            for move in actions:

                score=eval(result_after(board,move),depth-1,"b")

                v = max(v, score)




            return v
        elif (turn== "b"):


            v = math.inf
            actions=actions_after(board,turn)

            for move in actions:


                score = eval(result_after(board, move), depth-1, "s")

                v = min(v, score)
          

            return v
def minimax(board,depth):

    if(len(black_stones)<3):
        if (player(board) == "s"):
            v = -math.inf
            best_move=actions_before(board)[0]

            for move in actions_before(board):
                black_stones.append(move)

                score=eval(result_before(board,move),depth-1,"b")

                if(score>v):
                    v=score
                    best_move=move
                black_stones.pop(-1)

            return best_move
    else:
        if (player(board) == "s"):
            v = -math.inf
            actions=actions_after(board)
            best_move=actions[0]
            for move in actions:
                score=eval(result_after(board, move),depth-1,"b")

                if(score>v):
                    v=score
                    best_move=move



            return best_move


while True:
    if(len(black_stones)<3):
        move_once()
        print_board()

    else:
        print_board()

        move_sonra()
        print_board()

    if(is_finished(board))==True:
        break

if(winner(board)=="s"):
    print("KAZANAN SİYAH")
elif(winner(board)=="b"):
    print("KAZANAN BEYAZ")

Problem

In the evaluation function the depth sometimes never reaches 0. Yet I always decrease the depth by 1 when doing recursion... So the value returned from the evaluation can sometimes be None which causes errors.

I believe the problem is in the function eval, which is the function for evaluation.

This is the output or error I get:

Error message after running the function

How can I fix this?


Solution

  • The problem is that your player function does not give the correct result once both players have placed their three discs on the board. From that moment onwards, player will always return "b" even when that player had last moved. In essence, in the second phase of the game you cannot know from the number of discs on the board whose turn it is, because there will continually be three black and three white discs, no matter whose turn it is.

    And although minimax is called after having ensured it is the turn of "s", with this code:

        elif (turn == "s"):
            move=minimax(board,3)
    

    ...minimax will call player(board) to determine itself whose turn it is and get "b" in the above described situation. minimax has no code to execute when the player is "b" and silently returns None which is the cause of the problem.

    In short, you need to revise your code so that either the player() function has the tools to really determine whose turn it is, or you have to omit this function and instead ensure that the turn variable correctly reflects whose turn it is, also when performing the minimax search.

    Other remarks

    There is a lot to wish for in this code:

    Here is the code that is heavily adapted with the above remarks:

    class ThreeMenMorris:
        def __init__(self):
            self.board = [["_", "_", "_"], 
                          ["_", "_", "_"], 
                          ["_", "_", "_"]]
            self.turn = "b"
            self.num_played_moves = 0
    
        def __repr__(self):
            return "\n".join(map(repr, self.board))
            
        def is_move_possible(self, piece, move):
            return (
                # Must place new stone on board in first 6 plies, and must not do that in other moves
                (not piece) == (self.num_played_moves < 6) and
                # Target field must be free
                self.board[move[0]][move[1]] == "_" and 
                # Either a new piece or a move with taxicab distance of 1 with a piece of own color
                not piece or (abs(move[0]-piece[0]) + abs(move[1]-piece[1]) == 1 and 
                              self.board[piece[0]][piece[1]] == self.turn)
            )
            
        def play_move(self, piece, move):
            self.board[move[0]][move[1]] = self.turn
            if piece:
                self.board[piece[0]][piece[1]] = "_"
            self.num_played_moves += 1
            self.turn = "b" if self.turn == "s" else "s"
    
        def undo_move(self, piece, move):
            self.turn = "b" if self.turn == "s" else "s"
            self.num_played_moves -= 1
            if piece:
                self.board[piece[0]][piece[1]] = self.turn
            self.board[move[0]][move[1]] = "_"
    
        def winner(self):
            columns = [[self.board[0][0], self.board[1][0], self.board[2][0]], 
                       [self.board[0][1], self.board[1][1], self.board[2][1]], 
                       [self.board[0][2], self.board[1][2], self.board[2][2]]]
            for lines in (columns, self.board):
                for line in lines:
                    if "".join(line) in ("bbb", "sss"):
                        return line[0]
            return False
    
        def actions(self):
            moves = []
            for i, row in enumerate(self.board):
                for j, content in enumerate(row):
                    if content == "_":
                        moves.append((None, (i, j)))  # Always include piece
            if self.num_played_moves >= 6:
                targets = moves
                moves = []
                for i, row in enumerate(self.board):
                    for j, content in enumerate(row):
                        if content == self.turn:
                            piece = (i, j)
                            for _, move in targets:
                                if self.is_move_possible(piece, move):
                                    moves.append((piece, move))
            return moves
    
        def utility(self):
            win = self.winner()
            if win == "s":
                return 100
            if win == "b":
                return -100
            return 0
    
        def minimax(self, depth):
            best_move = None
            best_score = self.utility()
            if best_score == 0 and depth > 0:
                optimal = (min, max)[self.turn == "s"]  # Choose min or max as optimalisation function
                best_score = -optimal(-float("inf"), float("inf"))
                for piece, move in self.actions():
                    self.play_move(piece, move)
                    score = self.minimax(depth - 1)[0]  # extract score
                    if optimal(best_score, score) != best_score:
                        best_score = score
                        best_move = (piece, move)
                    self.undo_move(piece, move)
            return best_score, best_move  # return both the score and the move
    
    
    def get_input(prompt):
        while True:  # Don't use recursion to get user input until it is valid
            move = input(prompt)
            if len(move) == 2 and move[0] in "123" and move[1] in "123":
                return [int(x) - 1 for x in move]  # immediately convert to zero-based int
            print("Invalid format. Type two digits, each between 1 and 3.")
    
    def main():
        DEPTH = 2
        game = ThreeMenMorris()
        print(game)
        winner = False
        while not winner:
            if game.turn == "b":
                while True:
                    piece = None
                    if game.num_played_moves >= 6: 
                        piece = get_input("Piece:")
                    move = get_input("Move:")
                    if game.is_move_possible(piece, move):
                        break
                    print("Invalid move. Retry:")
            else:
                piece, move = game.minimax(DEPTH)[1]  # Extract the move
                print("AI has moved:")
            game.play_move(piece, move)
            print(game)
            winner = game.winner()
    
        print(("Black wins", "White wins")[winner == "b"])
    
    main()