Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
11 changed files with 647 additions and 7 deletions
Showing only changes of commit 31b51d04af - Show all commits

View File

@ -17,6 +17,8 @@
<surefire-plugin.version>3.5.6</surefire-plugin.version> <surefire-plugin.version>3.5.6</surefire-plugin.version>
<!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. --> <!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. -->
<debug>5006</debug> <debug>5006</debug>
<!-- Slow bot calibration games; run them with -Dgroups=calibration -DexcludedGroups=none. -->
<excludedGroups>calibration</excludedGroups>
</properties> </properties>
<dependencyManagement> <dependencyManagement>

View File

@ -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;
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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];
}
}

View File

@ -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<List<Integer>> 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<Integer> 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));
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -83,12 +83,13 @@ empty.
negated. negated.
- Scores are from the mover's view, in hundredths of a stone. An ended game scores - 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 `±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 ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there
turn; the weights are tuned in task 3. 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, then captures, then the rest, then the best move of the previous iteration. - 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 - 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 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`) ### Strength (`BotLevel`, `BotStrength`)
@ -99,7 +100,7 @@ empty.
| Hard | `hard` | 60–80 | | Hard | `hard` | 60–80 |
| Master | `master` | 85–100 | | 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 | | 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% | | 25 | 2 | 2 | 15% |
| 50 | 4 | 1 | 5% | | 50 | 4 | 1 | 5% |
| 75 | 7 | 0.4 | 1% | | 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`) ### Choosing a move (`BotPolicy`)
```java ```java

View File

@ -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] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity - [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity
games and random games. 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 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`. 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`, - [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`,