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