mancala/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Halit Aksoy 31b51d04af feat(backend): choose bot moves by strength
Negamax with alpha-beta on Position scores every move; the strength sets the
depth, the softmax temperature and the chance of a random move. The levels
are ranges of strengths drawn per game. A calibration tournament checks that
each level beats the one below.
2026-10-16 14:18:00 +03:00

141 lines
5.8 KiB
Java

package app.playmancala.bot;
import java.util.Arrays;
import app.playmancala.engine.Position;
// Scores every move of a position with negamax and alpha-beta pruning. Scores are from the mover's view in
// hundredths of a stone; a move that gives an extra turn keeps the side to move, so its score is not negated.
// Deepens one ply at a time up to the depth; when the node budget runs out, the scores of the last completed depth
// are returned. Nothing depends on time, so the same position, depth and budget always give the same scores.
public final class BotSearch {
public static final long DEFAULT_NODE_BUDGET = 3_000_000;
// An ended (or decided) game scores beyond any bank difference.
public static final int WIN = 100_000;
// A bank with more than half of the 48 stones has won.
private static final int DECIDING_BANK = 24;
private static final int STONE = 100;
// Leaf score for each stone more in the mover's pits than in the opponent's.
static final int PIT_STONE_WEIGHT = 0;
public record Result(int[] pits, int[] scores, int depth, long nodes) {
public int count() {
return pits.length;
}
}
private static final class OutOfBudget extends RuntimeException {
OutOfBudget() {
super(null, null, false, false);
}
}
private final long nodeBudget;
private Position[][] children;
private int[][] order;
private boolean[][] extraTurns;
private int[][] orderKeys;
private long nodes;
private boolean budgetApplies;
private BotSearch(long nodeBudget) {
this.nodeBudget = nodeBudget;
}
public static Result scoreMoves(Position root, int maxDepth) {
return scoreMoves(root, maxDepth, DEFAULT_NODE_BUDGET);
}
public static Result scoreMoves(Position root, int maxDepth, long nodeBudget) {
return new BotSearch(nodeBudget).run(root, Math.max(1, maxDepth));
}
private Result run(Position root, int maxDepth) {
children = new Position[maxDepth + 1][Position.PIT_COUNT];
order = new int[maxDepth + 1][Position.PIT_COUNT];
extraTurns = new boolean[maxDepth + 1][Position.PIT_COUNT];
orderKeys = new int[maxDepth + 1][Position.PIT_COUNT];
for (Position[] ply : children) {
for (int i = 0; i < ply.length; i++) ply[i] = root.copy();
}
int[] pits = new int[Position.PIT_COUNT];
int count = root.legalPits(pits);
if (count == 0) throw new IllegalArgumentException("No moves in " + root);
pits = Arrays.copyOf(pits, count);
Position[] rootChildren = new Position[count];
boolean[] rootExtraTurns = new boolean[count];
for (int i = 0; i < count; i++) {
rootChildren[i] = root.copy();
rootExtraTurns[i] = root.play(pits[i], rootChildren[i]);
}
int[] scores = null;
int completedDepth = 0;
for (int depth = 1; depth <= maxDepth; depth++) {
budgetApplies = depth > 1;
int[] depthScores = new int[count];
try {
for (int i = 0; i < count; i++) {
nodes++;
int value = negamax(rootChildren[i], depth - 1, -Integer.MAX_VALUE, Integer.MAX_VALUE, 1);
depthScores[i] = rootExtraTurns[i] ? value : -value;
}
} catch (OutOfBudget e) {
break;
}
scores = depthScores;
completedDepth = depth;
}
return new Result(pits, scores, completedDepth, nodes);
}
private int negamax(Position position, int depth, int alpha, int beta, int ply) {
if (budgetApplies && nodes > nodeBudget) throw new OutOfBudget();
if (position.isOver()) return endScore(position.bankDifference());
int ownBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK);
int opponentBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER2_BANK : Position.PLAYER1_BANK);
if (ownBank > DECIDING_BANK || opponentBank > DECIDING_BANK) return endScore(ownBank - opponentBank);
if (depth == 0) return STONE * (ownBank - opponentBank) + PIT_STONE_WEIGHT * position.pitStoneDifference();
// Play every move once to order them: extra turns first, then by the stones they bring to the bank.
Position[] next = children[ply];
int[] moveOrder = order[ply];
boolean[] extra = extraTurns[ply];
int[] keys = orderKeys[ply];
int count = 0;
for (int pit = 0; pit < Position.PIT_COUNT; pit++) {
if (position.stonesAt(position.boardIndex(pit)) == 0) continue;
boolean extraTurn = position.play(pit, next[pit]);
extra[pit] = extraTurn;
int gain = next[pit].stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK) - ownBank;
int key = (extraTurn ? 1000 : 0) + gain;
int at = count++;
while (at > 0 && keys[at - 1] < key) {
keys[at] = keys[at - 1];
moveOrder[at] = moveOrder[at - 1];
at--;
}
keys[at] = key;
moveOrder[at] = pit;
}
int best = -Integer.MAX_VALUE;
for (int i = 0; i < count; i++) {
int pit = moveOrder[i];
nodes++;
int value = extra[pit]
? negamax(next[pit], depth - 1, alpha, beta, ply + 1)
: -negamax(next[pit], depth - 1, -beta, -alpha, ply + 1);
if (value > best) best = value;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
}
return best;
}
private static int endScore(int bankDifference) {
int sign = Integer.signum(bankDifference);
return sign * WIN + STONE * bankDifference;
}
}