Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -1,6 +1,7 @@
|
|||||||
package app.playmancala;
|
package app.playmancala;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.SplittableRandom;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
|
|||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import app.playmancala.auth.TokenVerifier;
|
import app.playmancala.auth.TokenVerifier;
|
||||||
|
import app.playmancala.bot.BotPlayer;
|
||||||
import app.playmancala.game.GameClockScheduler;
|
import app.playmancala.game.GameClockScheduler;
|
||||||
import app.playmancala.game.GameManager;
|
import app.playmancala.game.GameManager;
|
||||||
import app.playmancala.game.GameRecorder;
|
import app.playmancala.game.GameRecorder;
|
||||||
@ -55,6 +57,10 @@ public class Backend {
|
|||||||
@ConfigProperty(name = "mancala.game-clock-ms")
|
@ConfigProperty(name = "mancala.game-clock-ms")
|
||||||
long gameClockMs;
|
long gameClockMs;
|
||||||
|
|
||||||
|
// 0: half the processors.
|
||||||
|
@ConfigProperty(name = "mancala.bot-search-threads")
|
||||||
|
int botSearchThreads;
|
||||||
|
|
||||||
private GameLoop loop;
|
private GameLoop loop;
|
||||||
// Valkey writes, in order.
|
// Valkey writes, in order.
|
||||||
private ExecutorService storeWriter;
|
private ExecutorService storeWriter;
|
||||||
@ -62,6 +68,8 @@ public class Backend {
|
|||||||
private ScheduledExecutorService recorderIo;
|
private ScheduledExecutorService recorderIo;
|
||||||
// Token checks.
|
// Token checks.
|
||||||
private ExecutorService verifier;
|
private ExecutorService verifier;
|
||||||
|
// Working out the bot's moves.
|
||||||
|
private ExecutorService botSearch;
|
||||||
|
|
||||||
private RtmtServer rtmt;
|
private RtmtServer rtmt;
|
||||||
private GameStore gameStore;
|
private GameStore gameStore;
|
||||||
@ -76,6 +84,10 @@ public class Backend {
|
|||||||
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
||||||
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
||||||
verifier = Executors.newVirtualThreadPerTaskExecutor();
|
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);
|
ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
|
||||||
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
||||||
@ -90,6 +102,7 @@ public class Backend {
|
|||||||
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
||||||
loop::now, gameClockMs);
|
loop::now, gameClockMs);
|
||||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||||
|
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
||||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
@ -112,6 +125,7 @@ public class Backend {
|
|||||||
storeWriter.shutdown();
|
storeWriter.shutdown();
|
||||||
recorderIo.shutdownNow();
|
recorderIo.shutdownNow();
|
||||||
verifier.shutdownNow();
|
verifier.shutdownNow();
|
||||||
|
botSearch.shutdownNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void migrate() {
|
private void migrate() {
|
||||||
|
|||||||
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal file
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal file
@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import app.playmancala.bot.BotSettings;
|
||||||
import app.playmancala.engine.MancalaGame;
|
import app.playmancala.engine.MancalaGame;
|
||||||
import app.playmancala.model.GameClock;
|
import app.playmancala.model.GameClock;
|
||||||
import app.playmancala.model.GameUsersConnectionInfo;
|
import app.playmancala.model.GameUsersConnectionInfo;
|
||||||
@ -27,10 +28,12 @@ public class BackendGame {
|
|||||||
public GameClock clock;
|
public GameClock clock;
|
||||||
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
||||||
public final List<Long> moveRemainingMs;
|
public final List<Long> 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,
|
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
|
||||||
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
|
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
|
||||||
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs) {
|
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot) {
|
||||||
this.id = mancalaGame.getId();
|
this.id = mancalaGame.getId();
|
||||||
this.mancalaGame = mancalaGame;
|
this.mancalaGame = mancalaGame;
|
||||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||||
@ -41,6 +44,11 @@ public class BackendGame {
|
|||||||
this.moveTimes = new ArrayList<>(moveTimes);
|
this.moveTimes = new ArrayList<>(moveTimes);
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||||
|
this.bot = bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBotTurn() {
|
||||||
|
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerSummary player(String userId) {
|
public PlayerSummary player(String userId) {
|
||||||
|
|||||||
@ -7,12 +7,16 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.IntSupplier;
|
||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
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.CommonMancalaGame;
|
||||||
import app.playmancala.engine.GameState;
|
import app.playmancala.engine.GameState;
|
||||||
import app.playmancala.engine.MancalaGame;
|
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.
|
// Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop.
|
||||||
public class GameManager {
|
public class GameManager {
|
||||||
private static final Logger LOG = Logger.getLogger(GameManager.class);
|
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 GameStore gameStore;
|
||||||
private final Rtmt rtmt;
|
private final Rtmt rtmt;
|
||||||
@ -46,6 +52,7 @@ public class GameManager {
|
|||||||
// Time each player has for a new game.
|
// Time each player has for a new game.
|
||||||
private final long gameClockMs;
|
private final long gameClockMs;
|
||||||
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
||||||
|
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
|
||||||
|
|
||||||
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
||||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||||
@ -67,6 +74,12 @@ public class GameManager {
|
|||||||
liveGamesListeners.add(listener);
|
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<BackendGame> listener) {
|
||||||
|
turnListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
private void listenRtmtMessages() {
|
private void listenRtmtMessages() {
|
||||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||||
@ -76,6 +89,7 @@ public class GameManager {
|
|||||||
}
|
}
|
||||||
matchMaker.join(userId);
|
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.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId));
|
||||||
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
|
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
|
||||||
rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId));
|
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) {
|
private void onGameMove(String userId, JsonNode gameMove) {
|
||||||
BackendGame game = gameStore.getGameByUser(userId);
|
BackendGame game = gameStore.getGameByUser(userId);
|
||||||
if (game == null) {
|
if (game == null) {
|
||||||
LOG.debug("Game not found!");
|
LOG.debug("Game not found!");
|
||||||
return;
|
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;
|
MancalaGame mancalaGame = game.mancalaGame;
|
||||||
try {
|
try {
|
||||||
long now = clock.getAsLong();
|
long now = clock.getAsLong();
|
||||||
@ -101,13 +147,10 @@ public class GameManager {
|
|||||||
endGameOnTime(game);
|
endGameOnTime(game);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JsonNode index = gameMove.path("index");
|
int index = pitIndex.getAsInt();
|
||||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
|
||||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
|
||||||
}
|
|
||||||
int movesBefore = mancalaGame.getHistory().size();
|
int movesBefore = mancalaGame.getHistory().size();
|
||||||
Seat moverSeat = seatOf(mancalaGame, userId);
|
Seat moverSeat = seatOf(mancalaGame, userId);
|
||||||
mancalaGame.moveByPlayerPit(userId, index.asInt());
|
mancalaGame.moveByPlayerPit(userId, index);
|
||||||
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
|
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
|
||||||
game.moveTimes.add(now);
|
game.moveTimes.add(now);
|
||||||
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
|
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
|
||||||
@ -121,6 +164,7 @@ public class GameManager {
|
|||||||
} else {
|
} else {
|
||||||
saveLiveGame(game);
|
saveLiveGame(game);
|
||||||
notifyLiveGamesChanged();
|
notifyLiveGamesChanged();
|
||||||
|
notifyTurnStarted(game);
|
||||||
}
|
}
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
onGameError(game, e);
|
onGameError(game, e);
|
||||||
@ -226,6 +270,7 @@ public class GameManager {
|
|||||||
if (restored > 0) {
|
if (restored > 0) {
|
||||||
LOG.infof("(GameManager) Restored %d live game(s)", restored);
|
LOG.infof("(GameManager) Restored %d live game(s)", restored);
|
||||||
notifyLiveGamesChanged();
|
notifyLiveGamesChanged();
|
||||||
|
gameStore.getAll().forEach(this::notifyTurnStarted);
|
||||||
}
|
}
|
||||||
return restored;
|
return restored;
|
||||||
}
|
}
|
||||||
@ -256,17 +301,27 @@ public class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
|
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
|
||||||
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message);
|
sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||||
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message);
|
sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message);
|
||||||
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, 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) {
|
public ClientGame toClientGame(BackendGame game) {
|
||||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
||||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
||||||
}
|
}
|
||||||
|
|
||||||
public BackendGame createMancalaGame(String userId1, String userId2) {
|
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 mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
|
||||||
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
||||||
long now = clock.getAsLong();
|
long now = clock.getAsLong();
|
||||||
@ -276,7 +331,7 @@ public class GameManager {
|
|||||||
players.put(userId1, player1);
|
players.put(userId1, player1);
|
||||||
players.put(userId2, player2);
|
players.put(userId2, player2);
|
||||||
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
|
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.set(game.id, game);
|
||||||
gameStore.setGameByUser(userId1, game);
|
gameStore.setGameByUser(userId1, game);
|
||||||
gameStore.setGameByUser(userId2, game);
|
gameStore.setGameByUser(userId2, game);
|
||||||
@ -284,6 +339,7 @@ public class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PlayerSummary getPlayerInfo(String userId) {
|
private PlayerSummary getPlayerInfo(String userId) {
|
||||||
|
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
|
||||||
AuthUser user = rtmt.getUser(userId);
|
AuthUser user = rtmt.getUser(userId);
|
||||||
return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest());
|
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) {
|
private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) {
|
||||||
return new GameUsersConnectionInfo(
|
return new GameUsersConnectionInfo(
|
||||||
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())),
|
UserConnectionInfo.of(player1, isOnline(player1.userId())),
|
||||||
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.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) {
|
public void startGame(BackendGame game) {
|
||||||
@ -304,6 +365,7 @@ public class GameManager {
|
|||||||
sendUserConnectionInfo(game);
|
sendUserConnectionInfo(game);
|
||||||
saveLiveGame(game);
|
saveLiveGame(game);
|
||||||
notifyLiveGamesChanged();
|
notifyLiveGamesChanged();
|
||||||
|
notifyTurnStarted(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendUserConnectionInfo(BackendGame game) {
|
public void sendUserConnectionInfo(BackendGame game) {
|
||||||
@ -337,6 +399,10 @@ public class GameManager {
|
|||||||
liveGamesListeners.forEach(Runnable::run);
|
liveGamesListeners.forEach(Runnable::run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void notifyTurnStarted(BackendGame game) {
|
||||||
|
turnListeners.forEach(listener -> listener.accept(game));
|
||||||
|
}
|
||||||
|
|
||||||
private void listenUserConnectionChange() {
|
private void listenUserConnectionChange() {
|
||||||
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
|
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
|
||||||
BackendGame game = gameStore.getGameByUser(clientId);
|
BackendGame game = gameStore.getGameByUser(clientId);
|
||||||
|
|||||||
@ -5,12 +5,15 @@ import java.util.Arrays;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
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.CommonMancalaGame;
|
||||||
import app.playmancala.engine.GameState;
|
import app.playmancala.engine.GameState;
|
||||||
import app.playmancala.engine.MancalaGame;
|
import app.playmancala.engine.MancalaGame;
|
||||||
@ -53,7 +56,9 @@ public final class GameSerialization {
|
|||||||
int peakSpectators,
|
int peakSpectators,
|
||||||
List<String> allSpectatorIds,
|
List<String> allSpectatorIds,
|
||||||
GameClock clock,
|
GameClock clock,
|
||||||
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs) {
|
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
|
||||||
|
// Games against the bot only.
|
||||||
|
BotSettings bot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
|
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()),
|
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||||
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
|
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.
|
// 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,
|
return new BackendGame(mancalaGame,
|
||||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||||
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
|
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.
|
// Null when the game ended without a winner and was not completed, e.g. both players left.
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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.
|
// Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop.
|
||||||
public class GameStore {
|
public class GameStore {
|
||||||
// In the order games started.
|
// In the order games started.
|
||||||
@ -28,7 +30,9 @@ public class GameStore {
|
|||||||
games.remove(id);
|
games.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bots play many games at once, so they are not mapped to one.
|
||||||
public void setGameByUser(String userId, BackendGame game) {
|
public void setGameByUser(String userId, BackendGame game) {
|
||||||
|
if (BotLevel.isBotId(userId)) return;
|
||||||
gameIdsByUser.put(userId, game.id);
|
gameIdsByUser.put(userId, game.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
|
||||||
import app.playmancala.auth.KeycloakTokenVerifier;
|
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
|
// 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 {
|
public final class PublicIds {
|
||||||
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
||||||
|
|
||||||
@ -16,7 +17,8 @@ public final class PublicIds {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String publicUserId(String userId) {
|
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;
|
return userId;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ package app.playmancala.model;
|
|||||||
public final class Channels {
|
public final class Channels {
|
||||||
public static final String NEW_GAME = "new_game";
|
public static final String NEW_GAME = "new_game";
|
||||||
public static final String CANCEL_NEW_GAME = "cancel_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 ON_GAME_START = "on_game_start";
|
||||||
public static final String GAME_MOVE = "game_move";
|
public static final String GAME_MOVE = "game_move";
|
||||||
public static final String ON_GAME_UPDATE = "on_game_update";
|
public static final String ON_GAME_UPDATE = "on_game_update";
|
||||||
|
|||||||
@ -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).
|
# Time each player has for a whole game (docs/specs/game-clock).
|
||||||
mancala.game-clock-ms=${GAME_CLOCK_MS:300000}
|
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.
|
# Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried.
|
||||||
quarkus.datasource.db-kind=postgresql
|
quarkus.datasource.db-kind=postgresql
|
||||||
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}
|
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}
|
||||||
|
|||||||
@ -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<Pit> 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<Pit> 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<String> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -150,12 +150,13 @@ static int chooseMove(Position position, BotSettings bot, int moveNumber); //
|
|||||||
|
|
||||||
### `BotPlayer`
|
### `BotPlayer`
|
||||||
```java
|
```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:
|
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
|
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
|
`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).
|
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,
|
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.
|
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
|
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.
|
`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
|
`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`.
|
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
|
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.
|
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;
|
- `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;
|
a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where
|
||||||
low time shortens the wait; a restored game where it is the bot's turn gets a move.
|
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;
|
- `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
|
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.
|
`bot`; leaving and running out of time end the game as usual.
|
||||||
|
|||||||
@ -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
|
- [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
|
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`.
|
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
|
`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.
|
`Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests.
|
||||||
- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
|
- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user