diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml
index 52f430f..7293f4c 100644
--- a/apps/backend/pom.xml
+++ b/apps/backend/pom.xml
@@ -17,6 +17,8 @@
3.5.6
5006
+
+ calibration
diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
new file mode 100644
index 0000000..574b293
--- /dev/null
+++ b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
@@ -0,0 +1,80 @@
+package app.playmancala.bot;
+
+import java.util.random.RandomGenerator;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+// The levels players choose from. Each is a range of strengths (0-100); the bot draws its strength for a game from
+// it. The ranges leave gaps so neighbouring levels stay clearly apart.
+public enum BotLevel {
+ EASY("easy", 5, 25, 800, 1800),
+ MEDIUM("medium", 30, 55, 1000, 2200),
+ HARD("hard", 60, 80, 1200, 2600),
+ MASTER("master", 85, 100, 1400, 3000);
+
+ private static final String BOT_ID_PREFIX = "bot:";
+
+ private final String wireName;
+ private final int minStrength;
+ private final int maxStrength;
+ // How long the bot seems to think before a move, on top of the previous move's animation.
+ private final int minThinkMs;
+ private final int maxThinkMs;
+
+ BotLevel(String wireName, int minStrength, int maxStrength, int minThinkMs, int maxThinkMs) {
+ this.wireName = wireName;
+ this.minStrength = minStrength;
+ this.maxStrength = maxStrength;
+ this.minThinkMs = minThinkMs;
+ this.maxThinkMs = maxThinkMs;
+ }
+
+ @JsonValue
+ public String wireName() {
+ return wireName;
+ }
+
+ public int minStrength() {
+ return minStrength;
+ }
+
+ public int maxStrength() {
+ return maxStrength;
+ }
+
+ public int middleStrength() {
+ return (minStrength + maxStrength) / 2;
+ }
+
+ public int drawStrength(RandomGenerator random) {
+ return random.nextInt(minStrength, maxStrength + 1);
+ }
+
+ public long drawThinkMs(RandomGenerator random) {
+ return random.nextLong(minThinkMs, maxThinkMs + 1);
+ }
+
+ // The user id the bot plays with at this level, e.g. bot:medium.
+ public String botId() {
+ return BOT_ID_PREFIX + wireName;
+ }
+
+ // The level from its wire name, or null.
+ @JsonCreator
+ public static BotLevel fromWireName(String wireName) {
+ for (BotLevel level : values()) {
+ if (level.wireName.equals(wireName)) return level;
+ }
+ return null;
+ }
+
+ public static boolean isBotId(String userId) {
+ return userId != null && userId.startsWith(BOT_ID_PREFIX);
+ }
+
+ // The level of a bot id, or null if it is not one.
+ public static BotLevel fromBotId(String userId) {
+ return isBotId(userId) ? fromWireName(userId.substring(BOT_ID_PREFIX.length())) : null;
+ }
+}
diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java
new file mode 100644
index 0000000..c6a8992
--- /dev/null
+++ b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java
@@ -0,0 +1,58 @@
+package app.playmancala.bot;
+
+import java.util.SplittableRandom;
+
+import app.playmancala.engine.Position;
+
+// Chooses the bot's move. Everything random comes from the game's seed and the move number, so a move can be
+// reproduced from the saved game.
+public final class BotPolicy {
+ // How far the strength of a single move may stray from the game's strength.
+ static final int MOVE_STRENGTH_SPREAD = 5;
+ private static final long MOVE_NUMBER_MIX = 0x9E3779B97F4A7C15L;
+
+ private BotPolicy() {
+ }
+
+ // The pit (0-5) of the side to move to play. `moveNumber` is the number of moves played in the game so far.
+ public static int chooseMove(Position position, BotSettings bot, int moveNumber) {
+ return chooseMove(position, bot, moveNumber, BotSearch.DEFAULT_NODE_BUDGET);
+ }
+
+ static int chooseMove(Position position, BotSettings bot, int moveNumber, long nodeBudget) {
+ SplittableRandom random = new SplittableRandom(bot.seed() + MOVE_NUMBER_MIX * (moveNumber + 1));
+ int strength = Math.clamp(bot.strength() + random.nextInt(-MOVE_STRENGTH_SPREAD, MOVE_STRENGTH_SPREAD + 1), 0, 100);
+ BotStrength.Params params = BotStrength.at(strength);
+ int[] pits = new int[Position.PIT_COUNT];
+ int count = position.legalPits(pits);
+ if (count == 0) throw new IllegalArgumentException("No moves in " + position);
+ // Drawn every time, so the draws that follow do not depend on how many moves there are.
+ double randomMoveDraw = random.nextDouble();
+ double choiceDraw = random.nextDouble();
+ if (count == 1) return pits[0];
+ if (randomMoveDraw < params.randomMoveChance()) return pits[(int) (choiceDraw * count)];
+
+ BotSearch.Result result = BotSearch.scoreMoves(position, params.depth(), nodeBudget);
+ return pick(result, params.temperature(), choiceDraw);
+ }
+
+ // A move with probability proportional to exp(score / temperature); temperature in stones.
+ static int pick(BotSearch.Result result, double temperature, double draw) {
+ int[] scores = result.scores();
+ int best = Integer.MIN_VALUE;
+ for (int score : scores) best = Math.max(best, score);
+ double scale = Math.max(temperature, 0.001) * 100;
+ double[] weights = new double[scores.length];
+ double total = 0;
+ for (int i = 0; i < scores.length; i++) {
+ weights[i] = Math.exp((scores[i] - best) / scale);
+ total += weights[i];
+ }
+ double target = draw * total;
+ for (int i = 0; i < scores.length; i++) {
+ target -= weights[i];
+ if (target < 0) return result.pits()[i];
+ }
+ return result.pits()[scores.length - 1];
+ }
+}
diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
new file mode 100644
index 0000000..e844587
--- /dev/null
+++ b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
@@ -0,0 +1,140 @@
+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;
+ }
+}
diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java
new file mode 100644
index 0000000..e91b567
--- /dev/null
+++ b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java
@@ -0,0 +1,16 @@
+package app.playmancala.bot;
+
+import java.util.random.RandomGenerator;
+
+// How the bot plays one game: its level, the strength drawn for the game and the seed its choices come from. Kept
+// with the live game and the saved game, so a restart does not change how it plays and its moves can be reproduced.
+public record BotSettings(BotLevel level, int strength, long seed) {
+
+ public static BotSettings draw(BotLevel level, RandomGenerator random) {
+ return new BotSettings(level, level.drawStrength(random), random.nextLong());
+ }
+
+ public String botId() {
+ return level.botId();
+ }
+}
diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java
new file mode 100644
index 0000000..20d5207
--- /dev/null
+++ b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java
@@ -0,0 +1,34 @@
+package app.playmancala.bot;
+
+// What a strength (0-100) means for one move: how deep the bot searches, how strongly it prefers the best move
+// (softmax temperature in stones; lower is more exact) and how often it plays a random move instead. Values between
+// the rows of the table are interpolated linearly.
+public final class BotStrength {
+
+ public record Params(int depth, double temperature, double randomMoveChance) {
+ }
+
+ private static final double[] STRENGTHS = { 0, 25, 50, 75, 100 };
+ private static final double[] DEPTHS = { 1, 2, 4, 7, 16 };
+ private static final double[] TEMPERATURES = { 4, 2, 1, 0.4, 0.05 };
+ private static final double[] RANDOM_MOVE_CHANCES = { 0.35, 0.15, 0.05, 0.01, 0 };
+
+ private BotStrength() {
+ }
+
+ public static Params at(double strength) {
+ double s = Math.clamp(strength, 0, 100);
+ return new Params((int) Math.round(interpolate(DEPTHS, s)), interpolate(TEMPERATURES, s),
+ interpolate(RANDOM_MOVE_CHANCES, s));
+ }
+
+ private static double interpolate(double[] values, double strength) {
+ for (int row = 1; row < STRENGTHS.length; row++) {
+ if (strength <= STRENGTHS[row]) {
+ double t = (strength - STRENGTHS[row - 1]) / (STRENGTHS[row] - STRENGTHS[row - 1]);
+ return values[row - 1] + t * (values[row] - values[row - 1]);
+ }
+ }
+ return values[values.length - 1];
+ }
+}
diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java
new file mode 100644
index 0000000..68bf92e
--- /dev/null
+++ b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java
@@ -0,0 +1,126 @@
+package app.playmancala.bot;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.SplittableRandom;
+
+import org.junit.jupiter.api.Test;
+
+import app.playmancala.engine.Position;
+
+class BotPolicyTest {
+
+ @Test
+ void levelsHaveWireNamesAndBotIds() {
+ assertEquals(BotLevel.MEDIUM, BotLevel.fromWireName("medium"));
+ assertNull(BotLevel.fromWireName("impossible"));
+ assertEquals("bot:master", BotLevel.MASTER.botId());
+ assertEquals(BotLevel.EASY, BotLevel.fromBotId("bot:easy"));
+ assertTrue(BotLevel.isBotId("bot:hard"));
+ assertNull(BotLevel.fromBotId("kc:someone"));
+ }
+
+ @Test
+ void strengthsAreDrawnWithinTheLevel() {
+ SplittableRandom random = new SplittableRandom(1);
+ for (BotLevel level : BotLevel.values()) {
+ for (int i = 0; i < 500; i++) {
+ int strength = BotSettings.draw(level, random).strength();
+ assertTrue(strength >= level.minStrength() && strength <= level.maxStrength(), level + " " + strength);
+ }
+ }
+ }
+
+ @Test
+ void strengthIsInterpolatedFromTheTable() {
+ assertParams(1, 4, 0.35, BotStrength.at(0));
+ assertParams(4, 1, 0.05, BotStrength.at(50));
+ assertParams(16, 0.05, 0, BotStrength.at(100));
+ BotStrength.Params between = BotStrength.at(37.5);
+ assertEquals(3, between.depth());
+ assertEquals(1.5, between.temperature(), 1e-9);
+ assertEquals(BotStrength.at(100), BotStrength.at(130));
+ }
+
+ private static void assertParams(int depth, double temperature, double randomMoveChance, BotStrength.Params params) {
+ assertEquals(depth, params.depth());
+ assertEquals(temperature, params.temperature(), 1e-9);
+ assertEquals(randomMoveChance, params.randomMoveChance(), 1e-9);
+ }
+
+ @Test
+ void sameSeedAndMoveNumberGiveTheSameMove() {
+ Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, true);
+ for (BotLevel level : BotLevel.values()) {
+ BotSettings bot = new BotSettings(level, level.middleStrength(), 42);
+ int first = BotPolicy.chooseMove(position, bot, 10);
+ for (int i = 0; i < 5; i++) assertEquals(first, BotPolicy.chooseMove(position, bot, 10));
+ }
+ }
+
+ @Test
+ void playsTheOnlyMove() {
+ Position position = Position.of(new int[] { 0, 0, 0, 0, 0, 3, 20, 1, 1, 1, 1, 1, 1, 19 }, true);
+ assertEquals(5, BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, 1), 0));
+ }
+
+ @Test
+ void easyMissesAClearBestMoveMuchMoreOftenThanMaster() {
+ // Pit 0 captures 10 stones; no other move comes close.
+ Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true);
+ int[] master = new int[Position.PIT_COUNT];
+ int[] easy = new int[Position.PIT_COUNT];
+ for (int seed = 0; seed < 200; seed++) {
+ master[BotPolicy.chooseMove(position, new BotSettings(BotLevel.MASTER, 100, seed), 0)]++;
+ easy[BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, seed), 0)]++;
+ }
+ assertTrue(master[0] >= 195, "Master: " + Arrays.toString(master));
+ assertTrue(easy[0] <= 180, "Easy: " + Arrays.toString(easy));
+ }
+
+ @Test
+ void gamesAtTheSameLevelDiffer() {
+ // R2.5: 20 games against an opponent that always plays its first pit; the bot moves first.
+ Set> openings = new HashSet<>();
+ SplittableRandom random = new SplittableRandom(3);
+ int[] pits = new int[Position.PIT_COUNT];
+ for (int game = 0; game < 20; game++) {
+ BotSettings bot = BotSettings.draw(BotLevel.MEDIUM, random);
+ Position position = Position.initial(true);
+ List botMoves = new ArrayList<>();
+ int moveNumber = 0;
+ while (!position.isOver() && botMoves.size() < 5) {
+ int pit;
+ if (position.isPlayer1ToMove()) {
+ pit = BotPolicy.chooseMove(position, bot, moveNumber);
+ botMoves.add(pit);
+ } else {
+ position.legalPits(pits);
+ pit = pits[0];
+ }
+ position.play(pit, position);
+ moveNumber++;
+ }
+ openings.add(botMoves);
+ }
+ assertTrue(openings.size() > 1, "every game opened with " + openings);
+ }
+
+ @Test
+ void picksMovesByTheirScores() {
+ BotSearch.Result result = new BotSearch.Result(new int[] { 0, 2, 4 }, new int[] { 0, 500, 100 }, 1, 0);
+ // At a low temperature the best move takes almost all the weight.
+ assertEquals(2, BotPolicy.pick(result, 0.05, 0.01));
+ assertEquals(2, BotPolicy.pick(result, 0.05, 0.999));
+ // At a high temperature every move can be drawn.
+ assertEquals(0, BotPolicy.pick(result, 100, 0.0));
+ assertEquals(4, BotPolicy.pick(result, 100, 0.999));
+ }
+}
diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java
new file mode 100644
index 0000000..d8fc568
--- /dev/null
+++ b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java
@@ -0,0 +1,82 @@
+package app.playmancala.bot;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import app.playmancala.engine.Position;
+
+class BotSearchTest {
+
+ private static int bestPit(BotSearch.Result result) {
+ int best = 0;
+ for (int i = 1; i < result.count(); i++) {
+ if (result.scores()[i] > result.scores()[best]) best = i;
+ }
+ return result.pits()[best];
+ }
+
+ @Test
+ void scoresEveryLegalMove() {
+ BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 3);
+ assertArrayEquals(new int[] { 0, 1, 2, 3, 4, 5 }, result.pits());
+ assertEquals(6, result.scores().length);
+ assertEquals(3, result.depth());
+ }
+
+ @Test
+ void takesTheExtraTurn() {
+ // Pit 3 ends in the bank and gives another move; pits 4 and 5 gain a stone too but pass the turn.
+ BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 2);
+ assertEquals(3, bestPit(result));
+ }
+
+ @Test
+ void capturesTheStonesOpposite() {
+ // Pit 0's single stone lands in the empty pit 1, opposite pit 11 with 9 stones.
+ Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true);
+ assertEquals(0, bestPit(BotSearch.scoreMoves(position, 1)));
+ }
+
+ @Test
+ void takesAnEvenPitOfTheOpponent() {
+ // Pit 5 with 3 stones: 5, the bank and pit 7, which then holds 6.
+ Position position = Position.of(new int[] { 1, 1, 1, 1, 1, 3, 10, 5, 1, 1, 1, 1, 1, 20 }, true);
+ BotSearch.Result result = BotSearch.scoreMoves(position, 1);
+ assertEquals(5, bestPit(result));
+ }
+
+ @Test
+ void seesAWinTwoMovesAhead() {
+ // Pit 5's stone goes to the bank; with the extra turn, pit 4's stone captures pit 7, the side is empty and
+ // takes the opponent's stones. Playing pit 4 first passes the turn.
+ Position position = Position.of(new int[] { 0, 0, 0, 0, 1, 1, 21, 1, 1, 1, 1, 1, 1, 19 }, true);
+ BotSearch.Result result = BotSearch.scoreMoves(position, 2);
+ int best = 0;
+ for (int i = 1; i < result.count(); i++) {
+ if (result.scores()[i] > result.scores()[best]) best = i;
+ }
+ assertEquals(5, result.pits()[best]);
+ assertTrue(result.scores()[best] > BotSearch.WIN, "a won game: " + result.scores()[best]);
+ }
+
+ @Test
+ void sameInputGivesTheSameScores() {
+ Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, false);
+ BotSearch.Result first = BotSearch.scoreMoves(position, 8);
+ BotSearch.Result second = BotSearch.scoreMoves(position, 8);
+ assertArrayEquals(first.scores(), second.scores());
+ assertEquals(first.nodes(), second.nodes());
+ }
+
+ @Test
+ void stopsAtTheNodeBudgetWithTheLastCompletedDepth() {
+ BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 30, 20_000);
+ assertTrue(result.depth() >= 1 && result.depth() < 30, "depth " + result.depth());
+ assertEquals(6, result.scores().length);
+ // Stops within the depth that ran out of budget.
+ assertTrue(result.nodes() < 20_000 + 20_000, "nodes " + result.nodes());
+ }
+}
diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java
new file mode 100644
index 0000000..bcbad53
--- /dev/null
+++ b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java
@@ -0,0 +1,88 @@
+package app.playmancala.bot;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.SplittableRandom;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import app.playmancala.engine.Position;
+
+// Plays the levels against each other (R2.4). The full tournament is slow and tagged `calibration`, so it is left
+// out of the normal tests; run it with ./mvnw test -Dgroups=calibration -DexcludedGroups=none after changing the
+// search or the strength table.
+class BotTournamentTest {
+
+ record Score(int wins, int losses, int draws, long moves, long nanos) {
+ int games() {
+ return wins + losses + draws;
+ }
+
+ double winRate() {
+ return (wins + draws / 2.0) / games();
+ }
+ }
+
+ @Test
+ void masterBeatsEasy() {
+ Score score = play(BotLevel.MASTER, BotLevel.EASY, 20, 1);
+ assertTrue(score.wins() >= 19, () -> "Master against Easy: " + score);
+ }
+
+ @Test
+ @Tag("calibration")
+ void strongerLevelsWin() {
+ int games = Integer.getInteger("bot.tournament.games", 200);
+ BotLevel[] levels = BotLevel.values();
+ StringBuilder report = new StringBuilder();
+ boolean ok = true;
+ for (int i = 1; i < levels.length; i++) {
+ Score score = play(levels[i], levels[i - 1], games, 100 + i);
+ report.append(line(levels[i], levels[i - 1], score));
+ ok &= score.winRate() >= 0.65;
+ }
+ Score masterEasy = play(BotLevel.MASTER, BotLevel.EASY, games, 200);
+ report.append(line(BotLevel.MASTER, BotLevel.EASY, masterEasy));
+ ok &= masterEasy.winRate() >= 0.95;
+ System.out.println(report);
+ assertTrue(ok, report::toString);
+ }
+
+ private static String line(BotLevel stronger, BotLevel weaker, Score score) {
+ return String.format("%-6s vs %-6s won %3d lost %3d drawn %3d rate %.2f %.1f ms/move%n", stronger, weaker,
+ score.wins(), score.losses(), score.draws(), score.winRate(),
+ score.nanos() / 1e6 / Math.max(1, score.moves()));
+ }
+
+ // Plays `games` games with strengths drawn per game, alternating who moves first.
+ static Score play(BotLevel first, BotLevel second, int games, long seed) {
+ SplittableRandom random = new SplittableRandom(seed);
+ int wins = 0, losses = 0, draws = 0;
+ long moves = 0, nanos = 0;
+ for (int game = 0; game < games; game++) {
+ BotSettings a = BotSettings.draw(first, random);
+ BotSettings b = BotSettings.draw(second, random);
+ boolean aIsPlayer1 = game % 2 == 0;
+ Position position = Position.initial(random.nextBoolean());
+ int moveNumber = 0;
+ while (!position.isOver()) {
+ boolean aToMove = position.isPlayer1ToMove() == aIsPlayer1;
+ long start = System.nanoTime();
+ int pit = BotPolicy.chooseMove(position, aToMove ? a : b, moveNumber);
+ if (aToMove) {
+ nanos += System.nanoTime() - start;
+ moves++;
+ }
+ position.play(pit, position);
+ moveNumber++;
+ }
+ int player1Lead = position.stonesAt(Position.PLAYER1_BANK) - position.stonesAt(Position.PLAYER2_BANK);
+ int aLead = aIsPlayer1 ? player1Lead : -player1Lead;
+ if (aLead > 0) wins++;
+ else if (aLead < 0) losses++;
+ else draws++;
+ }
+ return new Score(wins, losses, draws, moves, nanos);
+ }
+}
diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md
index 79fd051..e5fdf23 100644
--- a/docs/specs/game-bot/design.md
+++ b/docs/specs/game-bot/design.md
@@ -83,12 +83,13 @@ empty.
negated.
- Scores are from the mover's view, in hundredths of a stone. An ended game scores
`±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as
- ended. Other leaves: `100 × bank difference` plus small terms for stones on each side and moves that give an extra
- turn; the weights are tuned in task 3.
-- Move order: extra turns, then captures, then the rest, then the best move of the previous iteration.
+ ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there
+ but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player.
+- Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to
+ order them, into arrays kept per ply, so searching allocates nothing.
- Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an
exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed
- depth are used.
+ depth are used; depth 1 always completes.
### Strength (`BotLevel`, `BotStrength`)
@@ -99,7 +100,7 @@ empty.
| Hard | `hard` | 60–80 |
| Master | `master` | 85–100 |
-Starting values, interpolated linearly between rows (depth rounded); tuned in task 3 until R2.4 holds:
+Interpolated linearly between rows (depth rounded):
| Strength | Depth | Temperature (stones) | Random move chance |
|---|---|---|---|
@@ -107,7 +108,20 @@ Starting values, interpolated linearly between rows (depth rounded); tuned in ta
| 25 | 2 | 2 | 15% |
| 50 | 4 | 1 | 5% |
| 75 | 7 | 0.4 | 1% |
-| 100 | 12 | 0.05 | 0% |
+| 100 | 16 | 0.05 | 0% |
+
+The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves
+only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most
+without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s.
+
+`BotTournamentTest` with these values, 200 games per pair (October 2026, 24 cores, one thread):
+
+| Pair | Won | Lost | Drawn | Score | Stronger level's time per move |
+|---|---|---|---|---|---|
+| Medium vs Easy | 155 | 34 | 11 | 0.80 | < 0.1 ms |
+| Hard vs Medium | 158 | 29 | 13 | 0.82 | 0.2 ms |
+| Master vs Hard | 177 | 16 | 7 | 0.90 | 20 ms |
+| Master vs Easy | 200 | 0 | 0 | 1.00 | 22 ms |
### Choosing a move (`BotPolicy`)
```java
diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md
index 20f91e8..b7290c3 100644
--- a/docs/specs/game-bot/tasks.md
+++ b/docs/specs/game-bot/tasks.md
@@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity
games and random games.
-- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit
+- [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit
tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the
strength table until R2.4 holds, and write the final values into `design.md`.
- [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`,