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`,