diff --git a/apps/backend/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java index 2386982..2be5492 100644 --- a/apps/backend/src/main/java/app/playmancala/Backend.java +++ b/apps/backend/src/main/java/app/playmancala/Backend.java @@ -1,6 +1,7 @@ package app.playmancala; import java.sql.SQLException; +import java.util.SplittableRandom; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import app.playmancala.auth.TokenVerifier; +import app.playmancala.bot.BotPlayer; import app.playmancala.game.GameClockScheduler; import app.playmancala.game.GameManager; import app.playmancala.game.GameRecorder; @@ -55,6 +57,10 @@ public class Backend { @ConfigProperty(name = "mancala.game-clock-ms") long gameClockMs; + // 0: half the processors. + @ConfigProperty(name = "mancala.bot-search-threads") + int botSearchThreads; + private GameLoop loop; // Valkey writes, in order. private ExecutorService storeWriter; @@ -62,6 +68,8 @@ public class Backend { private ScheduledExecutorService recorderIo; // Token checks. private ExecutorService verifier; + // Working out the bot's moves. + private ExecutorService botSearch; private RtmtServer rtmt; private GameStore gameStore; @@ -76,6 +84,10 @@ public class Backend { storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); 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); PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); @@ -90,6 +102,7 @@ public class Backend { gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, loop::now, gameClockMs); liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); + new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom()); clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); @@ -112,6 +125,7 @@ public class Backend { storeWriter.shutdown(); recorderIo.shutdownNow(); verifier.shutdownNow(); + botSearch.shutdownNow(); } private void migrate() { diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java new file mode 100644 index 0000000..5203605 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java @@ -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 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 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; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java index 570bbf4..0a4ac6f 100644 --- a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java +++ b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.MancalaGame; import app.playmancala.model.GameClock; import app.playmancala.model.GameUsersConnectionInfo; @@ -27,10 +28,12 @@ public class BackendGame { public GameClock clock; // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. public final List 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, List allSpectatorIds, int peakSpectators, Map players, long startedAt, - List moveTimes, GameClock clock, List moveRemainingMs) { + List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot) { this.id = mancalaGame.getId(); this.mancalaGame = mancalaGame; this.gameUsersConnectionInfo = gameUsersConnectionInfo; @@ -41,6 +44,11 @@ public class BackendGame { this.moveTimes = new ArrayList<>(moveTimes); this.clock = clock; this.moveRemainingMs = new ArrayList<>(moveRemainingMs); + this.bot = bot; + } + + public boolean isBotTurn() { + return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId()); } public PlayerSummary player(String userId) { diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java index 545155a..a92d460 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameManager.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -7,12 +7,16 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.function.IntSupplier; import java.util.function.LongSupplier; import org.jboss.logging.Logger; 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.GameState; 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. public class GameManager { 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 Rtmt rtmt; @@ -46,6 +52,7 @@ public class GameManager { // Time each player has for a new game. private final long gameClockMs; private final List liveGamesListeners = new ArrayList<>(); + private final List> turnListeners = new ArrayList<>(); public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { @@ -67,6 +74,12 @@ public class GameManager { 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 listener) { + turnListeners.add(listener); + } + private void listenRtmtMessages() { rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { BackendGame activeGame = gameStore.getGameByUser(userId); @@ -76,6 +89,7 @@ public class GameManager { } 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.GAME_MOVE, this::onGameMove); 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) { BackendGame game = gameStore.getGameByUser(userId); if (game == null) { LOG.debug("Game not found!"); 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; try { long now = clock.getAsLong(); @@ -101,13 +147,10 @@ public class GameManager { endGameOnTime(game); return; } - JsonNode index = gameMove.path("index"); - if (!index.canConvertToInt() || !index.isIntegralNumber()) { - throw new IllegalStateException("Invalid move: " + gameMove); - } + int index = pitIndex.getAsInt(); int movesBefore = mancalaGame.getHistory().size(); Seat moverSeat = seatOf(mancalaGame, userId); - mancalaGame.moveByPlayerPit(userId, index.asInt()); + mancalaGame.moveByPlayerPit(userId, index); if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { game.moveTimes.add(now); GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); @@ -121,6 +164,7 @@ public class GameManager { } else { saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } } catch (RuntimeException e) { onGameError(game, e); @@ -226,6 +270,7 @@ public class GameManager { if (restored > 0) { LOG.infof("(GameManager) Restored %d live game(s)", restored); notifyLiveGamesChanged(); + gameStore.getAll().forEach(this::notifyTurnStarted); } return restored; } @@ -256,17 +301,27 @@ public class GameManager { } private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { - rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); - rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), 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) { return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); } 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.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); long now = clock.getAsLong(); @@ -276,7 +331,7 @@ public class GameManager { players.put(userId1, player1); players.put(userId2, player2); 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.setGameByUser(userId1, game); gameStore.setGameByUser(userId2, game); @@ -284,6 +339,7 @@ public class GameManager { } private PlayerSummary getPlayerInfo(String userId) { + if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false); AuthUser user = rtmt.getUser(userId); 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) { return new GameUsersConnectionInfo( - UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), - UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); + UserConnectionInfo.of(player1, isOnline(player1.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) { @@ -304,6 +365,7 @@ public class GameManager { sendUserConnectionInfo(game); saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } public void sendUserConnectionInfo(BackendGame game) { @@ -337,6 +399,10 @@ public class GameManager { liveGamesListeners.forEach(Runnable::run); } + private void notifyTurnStarted(BackendGame game) { + turnListeners.forEach(listener -> listener.accept(game)); + } + private void listenUserConnectionChange() { rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { BackendGame game = gameStore.getGameByUser(clientId); diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java index 7331021..97990d6 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -5,12 +5,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import org.jboss.logging.Logger; import com.fasterxml.jackson.annotation.JsonInclude; 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.GameState; import app.playmancala.engine.MancalaGame; @@ -53,7 +56,9 @@ public final class GameSerialization { int peakSpectators, List allSpectatorIds, GameClock clock, - @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs) { + @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs, + // Games against the bot only. + BotSettings bot) { } 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()), List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, 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. @@ -112,7 +117,16 @@ public final class GameSerialization { return new BackendGame(mancalaGame, new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), 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. diff --git a/apps/backend/src/main/java/app/playmancala/game/GameStore.java b/apps/backend/src/main/java/app/playmancala/game/GameStore.java index 2387f78..e957e50 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameStore.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameStore.java @@ -6,6 +6,8 @@ import java.util.LinkedHashMap; import java.util.List; 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. public class GameStore { // In the order games started. @@ -28,7 +30,9 @@ public class GameStore { games.remove(id); } + // Bots play many games at once, so they are not mapped to one. public void setGameByUser(String userId, BackendGame game) { + if (BotLevel.isBotId(userId)) return; gameIdsByUser.put(userId, game.id); } diff --git a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java index 9ee5652..498e91b 100644 --- a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java +++ b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java @@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; 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 -// 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 { private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; @@ -16,7 +17,8 @@ public final class PublicIds { } 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; } try { diff --git a/apps/backend/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java index 3336683..25fb9e2 100644 --- a/apps/backend/src/main/java/app/playmancala/model/Channels.java +++ b/apps/backend/src/main/java/app/playmancala/model/Channels.java @@ -4,6 +4,8 @@ package app.playmancala.model; public final class Channels { public static final String NEW_GAME = "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 GAME_MOVE = "game_move"; public static final String ON_GAME_UPDATE = "on_game_update"; diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index b55fd58..a6e3fe4 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -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). 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. quarkus.datasource.db-kind=postgresql %dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java new file mode 100644 index 0000000..ae5faa8 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -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 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 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 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")); + } +} diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index e5fdf23..3d14fa8 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -150,12 +150,13 @@ static int chooseMove(Position position, BotSettings bot, int moveNumber); // ### `BotPlayer` ```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: 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 - 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). 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. @@ -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 `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 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 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; - a game that ended or moved on while searching drops the result; a failing search plays the first legal pit; - low time shortens the wait; a restored game where it is the bot's turn gets a move. + a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where + 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; 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. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index b7290c3..abd5d58 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -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 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`, +- [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 `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. - [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.