feat(backend): play games against the bot

new_bot_game starts a game against the bot at the chosen level and ends the
search for an opponent. BotPlayer works out the bot's moves on its own thread
pool and plays them on the game loop after the previous move's animation and a
thinking time. Bot games keep their settings across restarts.
This commit is contained in:
Halit Aksoy 2026-10-16 14:18:45 +03:00
parent 31b51d04af
commit f0b75e5cee
12 changed files with 493 additions and 21 deletions

View File

@ -1,6 +1,7 @@
package app.playmancala; package app.playmancala;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.SplittableRandom;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import app.playmancala.auth.TokenVerifier; import app.playmancala.auth.TokenVerifier;
import app.playmancala.bot.BotPlayer;
import app.playmancala.game.GameClockScheduler; import app.playmancala.game.GameClockScheduler;
import app.playmancala.game.GameManager; import app.playmancala.game.GameManager;
import app.playmancala.game.GameRecorder; import app.playmancala.game.GameRecorder;
@ -55,6 +57,10 @@ public class Backend {
@ConfigProperty(name = "mancala.game-clock-ms") @ConfigProperty(name = "mancala.game-clock-ms")
long gameClockMs; long gameClockMs;
// 0: half the processors.
@ConfigProperty(name = "mancala.bot-search-threads")
int botSearchThreads;
private GameLoop loop; private GameLoop loop;
// Valkey writes, in order. // Valkey writes, in order.
private ExecutorService storeWriter; private ExecutorService storeWriter;
@ -62,6 +68,8 @@ public class Backend {
private ScheduledExecutorService recorderIo; private ScheduledExecutorService recorderIo;
// Token checks. // Token checks.
private ExecutorService verifier; private ExecutorService verifier;
// Working out the bot's moves.
private ExecutorService botSearch;
private RtmtServer rtmt; private RtmtServer rtmt;
private GameStore gameStore; private GameStore gameStore;
@ -76,6 +84,10 @@ public class Backend {
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
verifier = Executors.newVirtualThreadPerTaskExecutor(); verifier = Executors.newVirtualThreadPerTaskExecutor();
int searchThreads = botSearchThreads > 0 ? botSearchThreads
: Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
botSearch = Executors.newFixedThreadPool(searchThreads,
Thread.ofPlatform().name("bot-search-", 0).daemon().factory());
ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis); ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
@ -90,6 +102,7 @@ public class Backend {
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
loop::now, gameClockMs); loop::now, gameClockMs);
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
@ -112,6 +125,7 @@ public class Backend {
storeWriter.shutdown(); storeWriter.shutdown();
recorderIo.shutdownNow(); recorderIo.shutdownNow();
verifier.shutdownNow(); verifier.shutdownNow();
botSearch.shutdownNow();
} }
private void migrate() { private void migrate() {

View File

@ -0,0 +1,106 @@
package app.playmancala.bot;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.random.RandomGenerator;
import org.jboss.logging.Logger;
import app.playmancala.engine.GameState;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.MoveHistoryItem;
import app.playmancala.engine.Position;
import app.playmancala.game.BackendGame;
import app.playmancala.game.GameClocks;
import app.playmancala.game.GameManager;
import app.playmancala.game.GameStore;
import app.playmancala.loop.Scheduler;
import app.playmancala.model.Seat;
// Plays the bot's turns in live games. The move is worked out on `searchExecutor`, never on the game loop; it is
// played on the loop once the previous move's animation and the bot's thinking time are over, if the game is still
// waiting for it. Everything except the search runs on the loop.
public class BotPlayer {
private static final Logger LOG = Logger.getLogger(BotPlayer.class);
// PitAnimator's DEFAULT_ANIMATION_INTERVAL in packages/core: clients animate a move one step at a time.
static final long ANIMATION_STEP_MS = 300;
// The game page needs a moment to open before the bot's first move.
static final long GAME_START_DELAY_MS = 1_500;
// With less time than this on its clock, the bot stops pretending to think.
static final long LOW_TIME_MS = 30_000;
static final long LOW_TIME_THINK_MS = 300;
private final GameManager gameManager;
private final GameStore gameStore;
private final Scheduler loop;
private final Executor searchExecutor;
private final RandomGenerator random;
// Games whose bot move is being worked out; by identity, since a restored game is a new object with the same id.
private final Set<BackendGame> pending = Collections.newSetFromMap(new IdentityHashMap<>());
public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor,
RandomGenerator random) {
this.gameManager = gameManager;
this.gameStore = gameStore;
this.loop = loop;
this.searchExecutor = searchExecutor;
this.random = random;
gameManager.onTurnStarted(this::onTurnStarted);
}
private void onTurnStarted(BackendGame game) {
if (!game.isBotTurn() || game.mancalaGame.getState() == GameState.ENDED || !pending.add(game)) return;
Position position = Position.of(game.mancalaGame);
int moveNumber = game.mancalaGame.getHistory().size();
BotSettings bot = game.bot;
long readyAt = loop.now() + delayMs(game);
searchExecutor.execute(() -> {
int pit = choose(position, bot, moveNumber);
loop.execute(() -> loop.schedule(() -> play(game, moveNumber, pit), Math.max(0, readyAt - loop.now())));
});
}
private static int choose(Position position, BotSettings bot, int moveNumber) {
try {
return BotPolicy.chooseMove(position, bot, moveNumber);
} catch (RuntimeException e) {
// A failed search must not leave the game waiting: play any move.
LOG.errorf(e, "(BotPlayer) Search failed in %s", position);
int[] pits = new int[Position.PIT_COUNT];
position.legalPits(pits);
return pits[0];
}
}
private void play(BackendGame game, int moveNumber, int pit) {
pending.remove(game);
// The game may have ended (the player left or ran out of time) or been replaced by a restore meanwhile.
if (gameStore.get(game.id) != game || game.mancalaGame.getHistory().size() != moveNumber || !game.isBotTurn()) {
return;
}
gameManager.playMove(game, game.bot.botId(), pit);
}
// The previous move's animation plus the bot's thinking time, shorter when its clock runs low.
long delayMs(BackendGame game) {
MancalaGame mancalaGame = game.mancalaGame;
Seat seat = GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), game.bot.botId());
if (game.clock.turnStartedAt() != null
&& GameClocks.getRemainingMs(game.clock, seat, seat, loop.now()) < LOW_TIME_MS) {
return LOW_TIME_THINK_MS;
}
List<MoveHistoryItem> history = mancalaGame.getHistory();
long waitBefore = history.isEmpty() ? GAME_START_DELAY_MS : animationMs(history.getLast());
return waitBefore + game.bot.level().drawThinkMs(random);
}
static long animationMs(MoveHistoryItem move) {
long steps = move.getGameSteps().stream().map(GameStep::type)
.filter(MancalaGame.GAME_STEP_GAME_MOVE::equals).count();
return (steps + 1) * ANIMATION_STEP_MS;
}
}

View File

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
import app.playmancala.model.GameClock; import app.playmancala.model.GameClock;
import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.GameUsersConnectionInfo;
@ -27,10 +28,12 @@ public class BackendGame {
public GameClock clock; public GameClock clock;
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
public final List<Long> moveRemainingMs; public final List<Long> moveRemainingMs;
// How the bot plays in a game against the bot; null in games between people.
public final BotSettings bot;
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt, List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs) { List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot) {
this.id = mancalaGame.getId(); this.id = mancalaGame.getId();
this.mancalaGame = mancalaGame; this.mancalaGame = mancalaGame;
this.gameUsersConnectionInfo = gameUsersConnectionInfo; this.gameUsersConnectionInfo = gameUsersConnectionInfo;
@ -41,6 +44,11 @@ public class BackendGame {
this.moveTimes = new ArrayList<>(moveTimes); this.moveTimes = new ArrayList<>(moveTimes);
this.clock = clock; this.clock = clock;
this.moveRemainingMs = new ArrayList<>(moveRemainingMs); this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
this.bot = bot;
}
public boolean isBotTurn() {
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
} }
public PlayerSummary player(String userId) { public PlayerSummary player(String userId) {

View File

@ -7,12 +7,16 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Consumer;
import java.util.function.IntSupplier;
import java.util.function.LongSupplier; import java.util.function.LongSupplier;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.CommonMancalaGame;
import app.playmancala.engine.GameState; import app.playmancala.engine.GameState;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
@ -35,6 +39,8 @@ import app.playmancala.rtmt.Rtmt;
// Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop. // Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop.
public class GameManager { public class GameManager {
private static final Logger LOG = Logger.getLogger(GameManager.class); private static final Logger LOG = Logger.getLogger(GameManager.class);
// Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names.
static final String BOT_NAME = "Bot";
private final GameStore gameStore; private final GameStore gameStore;
private final Rtmt rtmt; private final Rtmt rtmt;
@ -46,6 +52,7 @@ public class GameManager {
// Time each player has for a new game. // Time each player has for a new game.
private final long gameClockMs; private final long gameClockMs;
private final List<Runnable> liveGamesListeners = new ArrayList<>(); private final List<Runnable> liveGamesListeners = new ArrayList<>();
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
@ -67,6 +74,12 @@ public class GameManager {
liveGamesListeners.add(listener); liveGamesListeners.add(listener);
} }
// Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it,
// including one that gives an extra turn.
public void onTurnStarted(Consumer<BackendGame> listener) {
turnListeners.add(listener);
}
private void listenRtmtMessages() { private void listenRtmtMessages() {
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
BackendGame activeGame = gameStore.getGameByUser(userId); BackendGame activeGame = gameStore.getGameByUser(userId);
@ -76,6 +89,7 @@ public class GameManager {
} }
matchMaker.join(userId); matchMaker.join(userId);
}); });
rtmt.listenMessage(Channels.NEW_BOT_GAME, this::onNewBotGame);
rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId)); rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId));
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove); rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId)); rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId));
@ -87,12 +101,44 @@ public class GameManager {
}); });
} }
private void onNewBotGame(String userId, JsonNode message) {
BotLevel level = BotLevel.fromWireName(message.path("level").asText(null));
if (level == null) {
LOG.debugf("(GameManager) Unknown bot level: %s", message);
return;
}
BackendGame activeGame = gameStore.getGameByUser(userId);
if (activeGame != null) {
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
return;
}
matchMaker.cancel(userId);
BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current());
startGame(createMancalaGame(userId, bot.botId(), bot));
}
private void onGameMove(String userId, JsonNode gameMove) { private void onGameMove(String userId, JsonNode gameMove) {
BackendGame game = gameStore.getGameByUser(userId); BackendGame game = gameStore.getGameByUser(userId);
if (game == null) { if (game == null) {
LOG.debug("Game not found!"); LOG.debug("Game not found!");
return; return;
} }
playMove(game, userId, () -> {
JsonNode index = gameMove.path("index");
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
throw new IllegalStateException("Invalid move: " + gameMove);
}
return index.asInt();
});
}
// Plays a player's pit (0-5) in a live game; the bot's moves come here too.
public void playMove(BackendGame game, String userId, int pitIndex) {
playMove(game, userId, () -> pitIndex);
}
// The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time.
private void playMove(BackendGame game, String userId, IntSupplier pitIndex) {
MancalaGame mancalaGame = game.mancalaGame; MancalaGame mancalaGame = game.mancalaGame;
try { try {
long now = clock.getAsLong(); long now = clock.getAsLong();
@ -101,13 +147,10 @@ public class GameManager {
endGameOnTime(game); endGameOnTime(game);
return; return;
} }
JsonNode index = gameMove.path("index"); int index = pitIndex.getAsInt();
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
throw new IllegalStateException("Invalid move: " + gameMove);
}
int movesBefore = mancalaGame.getHistory().size(); int movesBefore = mancalaGame.getHistory().size();
Seat moverSeat = seatOf(mancalaGame, userId); Seat moverSeat = seatOf(mancalaGame, userId);
mancalaGame.moveByPlayerPit(userId, index.asInt()); mancalaGame.moveByPlayerPit(userId, index);
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
game.moveTimes.add(now); game.moveTimes.add(now);
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
@ -121,6 +164,7 @@ public class GameManager {
} else { } else {
saveLiveGame(game); saveLiveGame(game);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
notifyTurnStarted(game);
} }
} catch (RuntimeException e) { } catch (RuntimeException e) {
onGameError(game, e); onGameError(game, e);
@ -226,6 +270,7 @@ public class GameManager {
if (restored > 0) { if (restored > 0) {
LOG.infof("(GameManager) Restored %d live game(s)", restored); LOG.infof("(GameManager) Restored %d live game(s)", restored);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
gameStore.getAll().forEach(this::notifyTurnStarted);
} }
return restored; return restored;
} }
@ -256,17 +301,27 @@ public class GameManager {
} }
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message);
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message)); List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message));
} }
// The bot has no connection to send to.
private void sendMessageToPlayer(String playerId, String channel, Object message) {
if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, channel, message);
}
public ClientGame toClientGame(BackendGame game) { public ClientGame toClientGame(BackendGame game) {
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
} }
public BackendGame createMancalaGame(String userId1, String userId2) { public BackendGame createMancalaGame(String userId1, String userId2) {
return createMancalaGame(userId1, userId2, null);
}
// `bot` is set for a game against the bot, which plays as one of the two users.
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) {
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
long now = clock.getAsLong(); long now = clock.getAsLong();
@ -276,7 +331,7 @@ public class GameManager {
players.put(userId1, player1); players.put(userId1, player1);
players.put(userId2, player2); players.put(userId2, player2);
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0, BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of()); players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot);
gameStore.set(game.id, game); gameStore.set(game.id, game);
gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId1, game);
gameStore.setGameByUser(userId2, game); gameStore.setGameByUser(userId2, game);
@ -284,6 +339,7 @@ public class GameManager {
} }
private PlayerSummary getPlayerInfo(String userId) { private PlayerSummary getPlayerInfo(String userId) {
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
AuthUser user = rtmt.getUser(userId); AuthUser user = rtmt.getUser(userId);
return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest());
} }
@ -295,8 +351,13 @@ public class GameManager {
private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) { private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) {
return new GameUsersConnectionInfo( return new GameUsersConnectionInfo(
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), UserConnectionInfo.of(player1, isOnline(player1.userId())),
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); UserConnectionInfo.of(player2, isOnline(player2.userId())));
}
// The bot is always there.
private boolean isOnline(String userId) {
return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId);
} }
public void startGame(BackendGame game) { public void startGame(BackendGame game) {
@ -304,6 +365,7 @@ public class GameManager {
sendUserConnectionInfo(game); sendUserConnectionInfo(game);
saveLiveGame(game); saveLiveGame(game);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
notifyTurnStarted(game);
} }
public void sendUserConnectionInfo(BackendGame game) { public void sendUserConnectionInfo(BackendGame game) {
@ -337,6 +399,10 @@ public class GameManager {
liveGamesListeners.forEach(Runnable::run); liveGamesListeners.forEach(Runnable::run);
} }
private void notifyTurnStarted(BackendGame game) {
turnListeners.forEach(listener -> listener.accept(game));
}
private void listenUserConnectionChange() { private void listenUserConnectionChange() {
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
BackendGame game = gameStore.getGameByUser(clientId); BackendGame game = gameStore.getGameByUser(clientId);

View File

@ -5,12 +5,15 @@ import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.CommonMancalaGame;
import app.playmancala.engine.GameState; import app.playmancala.engine.GameState;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
@ -53,7 +56,9 @@ public final class GameSerialization {
int peakSpectators, int peakSpectators,
List<String> allSpectatorIds, List<String> allSpectatorIds,
GameClock clock, GameClock clock,
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs) { @JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
// Games against the bot only.
BotSettings bot) {
} }
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
@ -69,7 +74,7 @@ public final class GameSerialization {
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()), mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock, List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs))); Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot);
} }
// The snapshot in the JSON, or null if it is not one this backend can restore. // The snapshot in the JSON, or null if it is not one this backend can restore.
@ -112,7 +117,16 @@ public final class GameSerialization {
return new BackendGame(mancalaGame, return new BackendGame(mancalaGame,
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(), orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs); orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot));
}
// The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at
// the middle of its level.
private static BotSettings restoreBot(LiveGameSnapshot snapshot) {
if (snapshot.bot() != null) return snapshot.bot();
BotLevel level = BotLevel.fromBotId(snapshot.player2Id());
if (level == null) level = BotLevel.fromBotId(snapshot.player1Id());
return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong());
} }
// Null when the game ended without a winner and was not completed, e.g. both players left. // Null when the game ended without a winner and was not completed, e.g. both players left.

View File

@ -6,6 +6,8 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import app.playmancala.bot.BotLevel;
// Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop. // Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop.
public class GameStore { public class GameStore {
// In the order games started. // In the order games started.
@ -28,7 +30,9 @@ public class GameStore {
games.remove(id); games.remove(id);
} }
// Bots play many games at once, so they are not mapped to one.
public void setGameByUser(String userId, BackendGame game) { public void setGameByUser(String userId, BackendGame game) {
if (BotLevel.isBotId(userId)) return;
gameIdsByUser.put(userId, game.id); gameIdsByUser.put(userId, game.id);
} }

View File

@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException;
import java.util.HexFormat; import java.util.HexFormat;
import app.playmancala.auth.KeycloakTokenVerifier; import app.playmancala.auth.KeycloakTokenVerifier;
import app.playmancala.bot.BotLevel;
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash // Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
// instead. Keycloak ids are not secret and stay as they are. // instead. Keycloak ids and bot ids are not secret and stay as they are.
public final class PublicIds { public final class PublicIds {
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
@ -16,7 +17,8 @@ public final class PublicIds {
} }
public static String publicUserId(String userId) { public static String publicUserId(String userId) {
if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) { if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)
|| BotLevel.isBotId(userId)) {
return userId; return userId;
} }
try { try {

View File

@ -4,6 +4,8 @@ package app.playmancala.model;
public final class Channels { public final class Channels {
public static final String NEW_GAME = "new_game"; public static final String NEW_GAME = "new_game";
public static final String CANCEL_NEW_GAME = "cancel_new_game"; public static final String CANCEL_NEW_GAME = "cancel_new_game";
// client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot).
public static final String NEW_BOT_GAME = "new_bot_game";
public static final String ON_GAME_START = "on_game_start"; public static final String ON_GAME_START = "on_game_start";
public static final String GAME_MOVE = "game_move"; public static final String GAME_MOVE = "game_move";
public static final String ON_GAME_UPDATE = "on_game_update"; public static final String ON_GAME_UPDATE = "on_game_update";

View File

@ -23,6 +23,9 @@ mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true}
# Time each player has for a whole game (docs/specs/game-clock). # Time each player has for a whole game (docs/specs/game-clock).
mancala.game-clock-ms=${GAME_CLOCK_MS:300000} mancala.game-clock-ms=${GAME_CLOCK_MS:300000}
# Threads working out the bot's moves (docs/specs/game-bot); 0 is half the processors.
mancala.bot-search-threads=${BOT_SEARCH_THREADS:0}
# Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried. # Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried.
quarkus.datasource.db-kind=postgresql quarkus.datasource.db-kind=postgresql
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} %dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}

View File

@ -0,0 +1,249 @@
package app.playmancala.bot;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.SplittableRandom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import app.playmancala.engine.GameState;
import app.playmancala.engine.Pit;
import app.playmancala.fakes.FakeRtmt;
import app.playmancala.fakes.InMemoryGameHistoryRepository;
import app.playmancala.fakes.InMemoryLiveGameStore;
import app.playmancala.game.BackendGame;
import app.playmancala.game.GameManager;
import app.playmancala.game.GameRecorder;
import app.playmancala.game.GameStore;
import app.playmancala.game.MatchMaker;
import app.playmancala.game.PublicIds;
import app.playmancala.loop.ManualScheduler;
import app.playmancala.model.AuthUser;
import app.playmancala.model.Channels;
import app.playmancala.model.EndReason;
import app.playmancala.model.GameClock;
import app.playmancala.model.GameRecord;
import app.playmancala.model.Json;
import app.playmancala.model.PlayerSummary;
// Games against the bot, with the real game logic, a manual clock and searches that run at once.
class BotPlayerTest {
static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false);
static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false);
static final long CLOCK_MS = 5 * 60_000;
// Longer than any wait: the start delay or a long animation plus the longest thinking time.
static final long LONGEST_WAIT_MS = 20_000;
ManualScheduler loop;
FakeRtmt rtmt;
GameStore gameStore;
InMemoryLiveGameStore liveGameStore;
InMemoryGameHistoryRepository repository;
GameManager gameManager;
BotPlayer botPlayer;
void createBackend() {
rtmt = new FakeRtmt();
gameStore = new GameStore();
GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run);
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run,
loop::now, CLOCK_MS);
botPlayer = new BotPlayer(gameManager, gameStore, loop, Runnable::run, new SplittableRandom(1));
rtmt.connect(ALICE);
rtmt.connect(CAROL);
}
@BeforeEach
void setUp() {
loop = new ManualScheduler(Instant.parse("2026-10-16T12:00:00Z").toEpochMilli());
liveGameStore = new InMemoryLiveGameStore();
repository = new InMemoryGameHistoryRepository();
createBackend();
}
// A game between Alice and the bot, with the given player to move first.
BackendGame startBotGame(BotSettings bot, String firstPlayerId, int... stones) {
BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot);
game.mancalaGame.setTurnPlayerId(firstPlayerId);
setBoard(game, stones);
gameManager.startGame(game);
return game;
}
static void setBoard(BackendGame game, int... stones) {
List<Pit> pits = game.mancalaGame.getBoard().getPits();
for (int i = 0; i < stones.length; i++) pits.get(i).setStoneCount(stones[i]);
}
void playAnyMove(BackendGame game, String playerId) {
List<Pit> pits = game.mancalaGame.getBoard().getPits();
for (int pit = 0; pit < 6; pit++) {
if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(playerId, pit)).getStoneCount() > 0) {
rtmt.receive(playerId, Channels.GAME_MOVE, Map.of("index", pit));
return;
}
}
throw new IllegalStateException("no move");
}
@Test
void startsAGameAgainstTheBotAtTheChosenLevel() {
rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "medium"));
BackendGame game = gameStore.getGameByUser(ALICE.id());
assertNotNull(game);
assertEquals("bot:medium", game.mancalaGame.getPlayer2Id());
assertEquals(BotLevel.MEDIUM, game.bot.level());
assertTrue(game.bot.strength() >= 30 && game.bot.strength() <= 55);
assertEquals(new PlayerSummary("bot:medium", "Bot", false), game.player("bot:medium"));
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
assertEquals(game.id, start.get("id").asText());
JsonNode bot = start.at("/gameUsersConnectionInfo/user2ConnectionInfo");
assertEquals(Json.tree(Map.of("userId", "bot:medium", "name", "Bot", "isGuest", false, "isOnline", true)), bot);
assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:")));
// The bot is not mapped to the game; it plays many at once.
assertNull(gameStore.getGameByUser("bot:medium"));
}
@Test
void endsTheSearchForAnOpponent() {
rtmt.receive(ALICE.id(), Channels.NEW_GAME);
rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "easy"));
rtmt.receive(CAROL.id(), Channels.NEW_GAME);
assertEquals("bot:easy", gameStore.getGameByUser(ALICE.id()).mancalaGame.getPlayer2Id());
assertNull(gameStore.getGameByUser(CAROL.id()));
}
@Test
void refusesABotGameDuringAnotherGame() {
rtmt.receive(ALICE.id(), Channels.NEW_GAME);
rtmt.receive(CAROL.id(), Channels.NEW_GAME);
BackendGame game = gameStore.getGameByUser(ALICE.id());
rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "hard"));
assertSame(game, gameStore.getGameByUser(ALICE.id()));
assertEquals(List.of(Json.tree(Map.of("gameId", game.id))),
rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED));
}
@Test
void ignoresUnknownLevels() {
rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "impossible"));
rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, "master");
assertNull(gameStore.getGameByUser(ALICE.id()));
}
@Test
void movesAfterTheStartDelayAndThinkingTime() {
BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), "bot:easy");
loop.advance(BotPlayer.GAME_START_DELAY_MS + 800 - 1);
assertEquals(0, game.mancalaGame.getHistory().size());
loop.advance(1000 + 1);
assertEquals(1, game.mancalaGame.getHistory().size());
assertEquals("bot:easy", game.mancalaGame.getHistory().getFirst().getPlayerId());
assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size());
}
@Test
void waitsForTheAnimationOfThePlayersMove() {
BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), ALICE.id());
// Pit 2 with 4 stones passes the turn: four steps to animate.
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2));
assertTrue(game.isBotTurn());
long animation = 5 * BotPlayer.ANIMATION_STEP_MS;
loop.advance(animation + 800 - 1);
assertEquals(1, game.mancalaGame.getHistory().size());
loop.advance(1000 + 1);
assertEquals(2, game.mancalaGame.getHistory().size());
}
@Test
void playsAgainAfterAnExtraTurn() {
// The only move, pit 11 with 3 stones, ends in the bank and leaves stones in pits 11 and 12.
BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 100, 3), "bot:master",
3, 3, 3, 3, 3, 3, 10, 0, 0, 0, 0, 3, 0, 10);
loop.advance(LONGEST_WAIT_MS);
List<String> movers = game.mancalaGame.getHistory().stream().map(move -> move.getPlayerId()).toList();
assertEquals(List.of("bot:master", "bot:master"), movers.subList(0, 2));
assertEquals(11, game.mancalaGame.getHistory().getFirst().getMoveIndex());
}
@Test
void dropsTheMoveWhenTheGameEndedMeanwhile() {
BackendGame game = startBotGame(new BotSettings(BotLevel.HARD, 70, 1), "bot:hard");
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
loop.advance(LONGEST_WAIT_MS);
assertEquals(0, game.mancalaGame.getHistory().size());
assertEquals(0, loop.pendingTimers());
GameRecord record = repository.records.get(game.id);
assertEquals(EndReason.LEFT, record.endReason());
}
@Test
void hurriesWhenItsClockRunsLow() {
BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 90, 1), ALICE.id());
game.clock = new GameClock(CLOCK_MS, CLOCK_MS, 20_000, loop.now());
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2));
loop.advance(BotPlayer.LOW_TIME_THINK_MS);
assertEquals(2, game.mancalaGame.getHistory().size());
}
@Test
void playsAWholeGameThatIsSavedWithTheBot() {
BackendGame game = startBotGame(new BotSettings(BotLevel.MEDIUM, 40, 11), ALICE.id());
for (int turn = 0; turn < 200 && game.mancalaGame.getState() != GameState.ENDED; turn++) {
if (game.isBotTurn()) loop.advance(LONGEST_WAIT_MS);
else playAnyMove(game, ALICE.id());
}
GameRecord record = repository.records.get(game.id);
assertNotNull(record, "the game did not end");
assertEquals(EndReason.COMPLETED, record.endReason());
assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2());
assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium")));
assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:")));
}
@Test
void goesOnAfterARestartAtTheSameStrength() {
BotSettings bot = new BotSettings(BotLevel.HARD, 66, 99);
BackendGame game = startBotGame(bot, "bot:hard");
assertEquals(Json.tree(Map.of("level", "hard", "strength", 66, "seed", 99L)),
liveGameStore.games.get(game.id).get("bot"));
// A new process: the old loop and its timers are gone.
loop = new ManualScheduler(loop.now());
createBackend();
assertEquals(1, gameManager.restoreLiveGames());
BackendGame restored = gameStore.get(game.id);
assertEquals(bot, restored.bot);
loop.advance(LONGEST_WAIT_MS);
assertEquals(1, restored.mancalaGame.getHistory().size());
}
@Test
void restoresABotGameWithoutSettingsAtTheMiddleOfItsLevel() {
BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 6, 5), ALICE.id());
((ObjectNode) liveGameStore.games.get(game.id)).remove("bot");
loop = new ManualScheduler(loop.now());
createBackend();
gameManager.restoreLiveGames();
assertEquals(BotLevel.EASY, gameStore.get(game.id).bot.level());
assertEquals(BotLevel.EASY.middleStrength(), gameStore.get(game.id).bot.strength());
}
@Test
void botIdsArePublic() {
assertEquals("bot:easy", PublicIds.publicUserId("bot:easy"));
}
}

View File

@ -150,12 +150,13 @@ static int chooseMove(Position position, BotSettings bot, int moveNumber); //
### `BotPlayer` ### `BotPlayer`
```java ```java
public BotPlayer(GameManager gameManager, GameStore gameStore, GameLoop loop, Executor searchExecutor) public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor,
RandomGenerator random) // random: thinking times only; used on the loop
``` ```
Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game: Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game:
1. On the loop: copy the position (`Position.of`), note the move number and compute 1. On the loop: copy the position (`Position.of`), note the move number and compute
`readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step `readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step
of the last move plus 300 ms. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master
1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3). 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3).
2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used, 2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used,
so the game never waits for a bot that failed. so the game never waits for a bot that failed.
@ -163,6 +164,8 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke
live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call
`gameManager.playMove(game, botId, pit)`; otherwise drop the result. `gameManager.playMove(game, botId, pit)`; otherwise drop the result.
Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it.
`searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least `searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least
1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`. 1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`.
@ -212,8 +215,9 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke
Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per
level. A quick version (20 games, Master against Easy) runs with the normal tests. level. A quick version (20 games, Master against Easy) runs with the normal tests.
- `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns; - `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns;
a game that ended or moved on while searching drops the result; a failing search plays the first legal pit; a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where
low time shortens the wait; a restored game where it is the bot's turn gets a move. it is the bot's turn gets a move. (The fallback for a failing search has no test: `BotPolicy` is static and cannot
be made to fail from outside.)
- `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game; - `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game;
unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and
`bot`; leaving and running out of time end the game as usual. `bot`; leaving and running out of time end the game as usual.

View File

@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 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`, - [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`,
`onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in
`Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests.
- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.