javascriptminimax

Minimax for Avalanche version of Mancala: My code returns a bad move


I have been making a mancala bot using a minimax algorithm. It works for the first bit but it often just gives an output landing on a blank space. Anyone have any idea how to fix this so it plays well?

Here is my code:

let final;
function increment(board, ltif, player, re) {
    let a = 0;
    for(let i = 0; i < board[ltif]; i++) {
        if((player && (ltif+i+a+1)%14 == 13) || (!player && (ltif+i+a+1)%14 == 6)) {
            a += 1;
        }
        board[(ltif + i + a + 1)%14] += 1;
    }
    const bltif = board[ltif];
    board[ltif] = 0;
    let ans;
    board[(bltif + ltif + a)%14] == 1 || (bltif + ltif + a)%14 == 6 || (bltif + ltif + a)%14 == 13 ? ans = board : ans = increment(board, (bltif + ltif + a)%14, player);
    if(((bltif + ltif + a)%14 == 6 || (bltif + ltif + a)%14 == 13) && !re) {
        ans = 2;;
        }
    if(board[(bltif + ltif + a)%14] == 1 && !re) {
        ans = 3;
    }
    return ans;
}
function minimax(board, depth, player) {
    if(board[6] > 24) {
        return 15;
    }else if(board[13] > 24) {
        return -15;
    }else if(board[6] == 24 && board[13] == 24) {
        return 0;
    }else if(depth === 0) {
        return Math.floor((board[6]-board[13])/2);
    }
    let avail = board.map((element, index) => (element !== 0 && ((index < 6 && player)|| (index < 13 && index > 6 && !player)) ? index : -1)).filter(element => element !== -1);
    if(player) {
        let maxEval = [-Infinity];
        for(let i = 0; i < avail.length; i++) {
            let tboard = increment(board.slice(), avail[i], player, false);
            let Eval;
            if(tboard == 2) {
                Eval = 13;
                tboard = increment(board.slice(), avail[i], player, true);
            }else if(tboard == 3) {
                Eval = -13;
                tboard = increment(board.slice(), avail[i], player, true);
            }else{
                Eval = minimax(tboard, depth - 1, false);
            }
            maxEval = [Math.max(Eval, maxEval[0]),avail[i],tboard];
        }
        final = [maxEval[1], maxEval[2]];
        return maxEval[0];
    }else{
        let minEval = +Infinity;
        for(let i = 0; i < avail.length; i++) {
            let tboard = increment(board.slice(), avail[i], player, false);
            let Eval;
            if(tboard == 2) {
                Eval = 13;
                tboard = increment(board.slice(), avail[i], player, true);
            }else if(tboard == 3) {
                Eval = -13;
                tboard = increment(board.slice(), avail[i], player, true);
            }else{
                Eval = minimax(tboard, depth - 1, false);
            }
            minEval = Math.min(Eval, minEval);
        }
    return minEval;
    }
}
minimax([
    5, 0, 5, 5, 5, 0,
    3, 5, 5, 0, 5, 5,
    5, 0
  ], 9, true);
console.log(final);

It runs out of a text based editor so that's why the output is into console and it only checks one board then you have to input another. Also just to clarify it is the avalanche version of mancala.

I do not have much experience using a minimax algorithm so if anyone has any insight into the problem that would be very helpful.

One example of this is that when given the board state:

[4, 4, 4, 4, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0] 

...it tells me to move the one on the 4th spot of the array so 5 from the right which gives this output:

[1, 6, 6, 6, 0, 1, 3, 6, 1, 6, 6, 0, 6, 0]

There are much better possible moves (like index 2) and I have no idea why the algorithm is selecting this. Also, the minimax algorithm I am using does not use alpha-beta pruning which is intended and not just a mistake in the code.


Solution

  • There are the following issues in your implementation:

    Other remarks:

    Corrected code

    Here is the code I ended up with while applying the above corrections and suggestions:

    // Don't use magic values, but instead define constants with meaningful names:
    const STORE = { false: 13, true: 6 }; // Per player: the index of their store
    const STORES = new Set(Object.values(STORE));
          // Per player: the indices of their pits 
    const PITS = { false: [7, 8, 9, 10, 11, 12], true: [0, 1, 2, 3, 4, 5] };
    const NUM_STONES = 48;
    const GOOD_SCORE = NUM_STONES / 4 + 1; // Better score than max heuristic score
    const MAX_SCORE = GOOD_SCORE + 1; // Overall best possible score
    
    // The function increment returns a boolean that is true when the move ended with a stone 
    //     in the player's store, giving them a right to an extra move.
    //     The given board is mutated, so the caller can see the effect of the move in that board.
    function increment(board, startPit, player) {
        const numStones = board[startPit];
        let lastPit = startPit;
        for (let i = 1; i <= board[startPit]; i++) {
            // We can use a conditional inline expression to decide to add an extra 1 or not.
            lastPit = (lastPit + 1 + (lastPit + 1 == STORE[!player])) % board.length;
            board[lastPit]++;
        }
        board[startPit] = 0;
        // There are three cases:
        if (lastPit === STORE[player]) { // Extra turn!
            return true;
        }
        if (board[lastPit] == 1) { // Normal move ended.
            return false;
        }
        // The turn is not finished yet; continue...
        return increment(board, lastPit, player);
    }
    
    function minimax(board, depth, player) {
        if (board[STORE[true]] * 2 > NUM_STONES) {
            // Maximizing player has more than half of the stones, so they won the game.
            return MAX_SCORE;
        } else if (board[STORE[false]] * 2 > NUM_STONES) {
            // Minimizing player has more than half of the stones, so they won the game.
            return -MAX_SCORE;
        } else if (board[STORE[true]] + board[STORE[false]] == NUM_STONES) {
            // All stones are in the stores, and both players have an equal amount: it's draw
            return 0;
        } else if (depth === 0) {
            // Search must stop here. Give a heuristic value:
            return Math.floor((board[STORE[true]] - board[STORE[false]]) / 2);
        }
        // Get the player's own pits that have at least one stone in it:
        const availablePits = PITS[player].filter(pit => board[pit]); 
        if (player) { // Maximizing player
            let best = { score: -Infinity };
            for (const pit of availablePits) {
                const newBoard = board.slice(); // Keep a reference to the new board
                const extraTurn = increment(newBoard, pit, player);
                const score = extraTurn ? GOOD_SCORE // We get an extra turn (good!), so we will not look deeper
                                          // Otherwise: no extra turn. Continue the recursion.
                                        : minimax(newBoard, depth - 1, false).score; // Get score component
                if (score > best.score) {
                    // Only make updates when the score is better
                    best = { score, pit, board: newBoard };
                }
            }
            return best; // Don't alter a global. Just return complete information: {score, pit, board}.
        } else {
            let best = { score: +Infinity };
            for (const pit of availablePits) {
                const newBoard = board.slice(); 
                const extraTurn = increment(newBoard, pit, player);
                const score = extraTurn ? -GOOD_SCORE // Minimizing user wants a negative value here!
                                        : minimax(newBoard, depth - 1, false).score;
                if (score < best.score) {
                    best = { score, pit, board: newBoard };
                }
            }
            return best;
        }
    }
    
    // Minimax should *return* the information instead of setting a global variable.
    const { score, pit, board } = minimax([ 
        // Layout was confusing. This seems clearer:
        5, 0, 5, 5, 5, 0,     3, 
        5, 5, 0, 5, 5, 5,     0
      ], 9, true);
    
    console.log(`The best move is from pit ${pit}. It got score ${score}, and leads to this board: ${board}`);

    The algorithm now returns as best move, pit number 2. I didn't play this game enough to judge whether that is really the best move or not.