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.
107 lines
4.7 KiB
Java
107 lines
4.7 KiB
Java
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;
|
|
}
|
|
}
|