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