mancala/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java

107 lines
4.7 KiB
Java
Raw Normal View History

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