local bench = script and require(script.Parent.bench_support) or pcall(require, "bench_support") or require("../bench_support") function test() -- https://github.com/stefandd/Tic4 local negaMax = {maxdepth = 4, minsearchpos = 0, numsearchpos = 0} negaMax.__index = negaMax function negaMax:evaluate(board, depth) --[[ What can be confusing is how the heuristic value of the current node is calculated. In this implementation, this value is always calculated from the point of view of player A, whose color value is one. In other words, higher heuristic values always represent situations more favorable for player A. This is the same behavior as the normal minimax algorithm. The heuristic value is not necessarily the same as a node's return value due to value negation by negamax and the color parameter. The negamax node's return value is a heuristic score from the point of view of the node's current player. Negamax scores match minimax scores for nodes where player A is about to play, and where player A is the maximizing player in the minimax equivalent. Negamax always searches for the maximum value for all its nodes. Hence for player B nodes, the minimax score is a negation of its negamax score. Player B is the minimizing player in the minimax equivalent. Variations in negamax implementations may omit the color parameter. In this case, the heuristic evaluation function must return values from the point of view of the node's current player. --]] print ("This function needs to be implemented!") end function negaMax:move_candidates(board, side_to_move) print ("This function needs to be implemented!") end function negaMax:make_move(board, side_to_move, move) print ("This function needs to be implemented!") end function negaMax:negaMax(board, side_to_move, depth, alpha, beta) -- side_to_move: e.g. 1 is blue, -1 is read -- -- init vars for root call -- if not depth then -- root call depth = 0 alpha = -math.huge beta = math.huge self.numsearchpos = 0 -- reset call counter end -- -- test if the node is terminal (i.e. full board or win) -- local best_move = -1 local score, is_term_node = self:evaluate(board, depth) -- we abort the recursion if this is a terminal node, or if one of the search abort conditions are met -- if is_term_node or depth == self.maxdepth then return side_to_move*score, best_move, is_term_node end -- -- if not terminal node, eval child nodes -- local moves = self:move_candidates(board, side_to_move) score = -math.huge for _, analyzed_move in pairs(moves) do -- iterate over all boards self.numsearchpos = self.numsearchpos + 1 local b = self:make_move(board, side_to_move, analyzed_move) local move_score, _, _ = -self:negaMax(b, -side_to_move, depth+1, -beta, -alpha) if move_score > score then score = move_score best_move = analyzed_move end -- disable alpha-beta pruning -- alpha = math.max(alpha, score) if alpha >= beta then break end -- end if depth == 0 then -- -- exit for root call (depth == 0) -- -- debug stuff --print(string.format("---- Negamax: node info, depth: %d, side: %d, score: %d, best move: %d", depth, side_to_move, score, best_move)) --print_board(board) --print(string.format("----")) print("Analyzed positions: " .. self.numsearchpos) end return score, best_move, game_over end local empty_board = {0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0} -- 16 empty positions ----------- helper methods function copy_board(board) local copy = {} for i = 1, #board do copy[i] = board[i] end return copy end function print_board(board) gboard = {} for i = 1, #board do if board[i] == 0 then gboard[i] = '.' elseif board[i] == 1 then gboard[i] = 'x' else gboard[i] = 'o' end end print(string.format("\n%s %s %s %s\n%s %s %s %s\n%s %s %s %s\n%s %s %s %s\n", unpack(gboard))) end function is_board_full(board) for i = 1, #board do if board[i] == 0 then return false end end return true end ----------- implement negaMax methods negaMax.index_quadruplets = { {1,2,3,4}, {5,6,7,8}, {9,10,11,12}, -- rows {13,14,15,16}, {1,5,9,13}, {2,6,10,14}, -- cols {3,7,11,15}, {4,8,12,16}, {1,6,11,16}, {4,7,10,13}, -- diags {1,2,5,6}, {2,3,6,7}, {3,4,7,8}, -- squares {5,6,9,10}, {6,7,10,11}, {7,8,11,12}, {9,10,13,14}, {10,11,14,15}, {11,12,15,16} } function negaMax:evaluate(board, depth) -- return format is score, is_terminal_position --[[ What can be confusing is how the heuristic value of the current node is calculated. In this implementation, this value is always calculated from the point of view of player A, whose color value is one. In other words, higher heuristic values always represent situations more favorable for player A. This is the same behavior as the normal minimax algorithm. The heuristic value is not necessarily the same as a node's return value due to value negation by negamax and the color parameter. The negamax node's return value is a heuristic score from the point of view of the node's current player. Negamax scores match minimax scores for nodes where player A is about to play, and where player A is the maximizing player in the minimax equivalent. Negamax always searches for the maximum value for all its nodes. Hence for player B nodes, the minimax score is a negation of its negamax score. Player B is the minimizing player in the minimax equivalent. Variations in negamax implementations may omit the color parameter. In this case, the heuristic evaluation function must return values from the point of view of the node's current player. --]] local player_plus_score, player_minus_score = 0, 0 local game_won = false for _, curr_qdr in pairs(negaMax.index_quadruplets) do -- iterate over all index quadruplets -- count the empty positions and positions occupied by the side whos move it is local player_plus_fields, player_minus_fields, empties = 0, 0, 0 for _, index in next, curr_qdr do -- iterate over all indices if board[index] == 0 then empties = empties + 1 elseif board[index] == 1 then player_plus_fields = player_plus_fields + 1 elseif board[index] == -1 then player_minus_fields = player_minus_fields + 1 end end -- evaluate the quadruplets score by looking at empty vs occupied positions if empties == 3 then if player_plus_fields == 1 then player_plus_score = player_plus_score + 3 elseif player_minus_fields == 1 then player_minus_score = player_minus_score + 3 end elseif empties == 2 then if player_plus_fields == 2 then player_plus_score = player_plus_score + 13 elseif player_minus_fields == 2 then player_minus_score = player_minus_score + 13 end elseif empties == 1 then if player_plus_fields == 3 then player_plus_score = player_plus_score + 31 elseif player_minus_fields == 3 then player_minus_score = player_minus_score + 31 end elseif empties == 0 then -- check for winning situations if player_plus_fields == 4 then player_plus_score = 999-depth player_minus_score = 0 game_won = true break elseif player_minus_fields == 4 then -- this should not happen if there is a proper terminal node detection! player_plus_score = 0 player_minus_score = 999-depth game_won = true break end end end -- return format is score, is_terminal_position if not game_won and is_board_full(board) then return 0, true -- DRAW else return (player_plus_score - player_minus_score), game_won -- >0 is good for player 1 [+], <0 means good for the other player (player 2 [-])) end end function negaMax:move_candidates(board, side_to_move) local moves = {} for i = 1, #board do if board[i] == 0 then -- empty? moves[#moves + 1] = i -- save move that was made end end return moves end function negaMax:make_move(board, side_to_move, move) local copy = copy_board(board) copy[move] = side_to_move return copy end local human_player = 1 local AI_player = -human_player local game_board = copy_board(empty_board) local curr_move = -1 local curr_player = human_player -- human player goes first local score = 0 local stop_loop = false local game_over = false negaMax.maxdepth = 5 local t0 = os.clock() score, curr_move = negaMax:negaMax(game_board, curr_player) local t1 = os.clock() return t1-t0 end bench.runCode(test, "tictactoe")