Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -17,6 +17,8 @@
|
||||
<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. -->
|
||||
<debug>5006</debug>
|
||||
<!-- Slow bot calibration games; run them with -Dgroups=calibration -DexcludedGroups=none. -->
|
||||
<excludedGroups>calibration</excludedGroups>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal file
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal file
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user