Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -1,6 +1,7 @@
|
||||
package app.playmancala;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.auth.TokenVerifier;
|
||||
import app.playmancala.bot.BotPlayer;
|
||||
import app.playmancala.game.GameClockScheduler;
|
||||
import app.playmancala.game.GameManager;
|
||||
import app.playmancala.game.GameRecorder;
|
||||
@ -55,6 +57,10 @@ public class Backend {
|
||||
@ConfigProperty(name = "mancala.game-clock-ms")
|
||||
long gameClockMs;
|
||||
|
||||
// 0: half the processors.
|
||||
@ConfigProperty(name = "mancala.bot-search-threads")
|
||||
int botSearchThreads;
|
||||
|
||||
private GameLoop loop;
|
||||
// Valkey writes, in order.
|
||||
private ExecutorService storeWriter;
|
||||
@ -62,6 +68,8 @@ public class Backend {
|
||||
private ScheduledExecutorService recorderIo;
|
||||
// Token checks.
|
||||
private ExecutorService verifier;
|
||||
// Working out the bot's moves.
|
||||
private ExecutorService botSearch;
|
||||
|
||||
private RtmtServer rtmt;
|
||||
private GameStore gameStore;
|
||||
@ -76,6 +84,10 @@ public class Backend {
|
||||
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
||||
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
||||
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);
|
||||
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
||||
@ -90,6 +102,7 @@ public class Backend {
|
||||
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
||||
loop::now, gameClockMs);
|
||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||
|
||||
@ -112,6 +125,7 @@ public class Backend {
|
||||
storeWriter.shutdown();
|
||||
recorderIo.shutdownNow();
|
||||
verifier.shutdownNow();
|
||||
botSearch.shutdownNow();
|
||||
}
|
||||
|
||||
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.Map;
|
||||
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
@ -27,10 +28,12 @@ public class BackendGame {
|
||||
public GameClock 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;
|
||||
// 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,
|
||||
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.mancalaGame = mancalaGame;
|
||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||
@ -41,6 +44,11 @@ public class BackendGame {
|
||||
this.moveTimes = new ArrayList<>(moveTimes);
|
||||
this.clock = clock;
|
||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
public boolean isBotTurn() {
|
||||
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
|
||||
}
|
||||
|
||||
public PlayerSummary player(String userId) {
|
||||
|
||||
@ -7,12 +7,16 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
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.GameState;
|
||||
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.
|
||||
public class GameManager {
|
||||
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 Rtmt rtmt;
|
||||
@ -46,6 +52,7 @@ public class GameManager {
|
||||
// Time each player has for a new game.
|
||||
private final long gameClockMs;
|
||||
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,
|
||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||
@ -67,6 +74,12 @@ public class GameManager {
|
||||
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() {
|
||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
@ -76,6 +89,7 @@ public class GameManager {
|
||||
}
|
||||
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.GAME_MOVE, this::onGameMove);
|
||||
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) {
|
||||
BackendGame game = gameStore.getGameByUser(userId);
|
||||
if (game == null) {
|
||||
LOG.debug("Game not found!");
|
||||
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;
|
||||
try {
|
||||
long now = clock.getAsLong();
|
||||
@ -101,13 +147,10 @@ public class GameManager {
|
||||
endGameOnTime(game);
|
||||
return;
|
||||
}
|
||||
JsonNode index = gameMove.path("index");
|
||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
||||
}
|
||||
int index = pitIndex.getAsInt();
|
||||
int movesBefore = mancalaGame.getHistory().size();
|
||||
Seat moverSeat = seatOf(mancalaGame, userId);
|
||||
mancalaGame.moveByPlayerPit(userId, index.asInt());
|
||||
mancalaGame.moveByPlayerPit(userId, index);
|
||||
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
|
||||
game.moveTimes.add(now);
|
||||
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
|
||||
@ -121,6 +164,7 @@ public class GameManager {
|
||||
} else {
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
notifyTurnStarted(game);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
onGameError(game, e);
|
||||
@ -226,6 +270,7 @@ public class GameManager {
|
||||
if (restored > 0) {
|
||||
LOG.infof("(GameManager) Restored %d live game(s)", restored);
|
||||
notifyLiveGamesChanged();
|
||||
gameStore.getAll().forEach(this::notifyTurnStarted);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
@ -256,17 +301,27 @@ public class GameManager {
|
||||
}
|
||||
|
||||
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message);
|
||||
sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||
sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), 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) {
|
||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
||||
}
|
||||
|
||||
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.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
||||
long now = clock.getAsLong();
|
||||
@ -276,7 +331,7 @@ public class GameManager {
|
||||
players.put(userId1, player1);
|
||||
players.put(userId2, player2);
|
||||
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.setGameByUser(userId1, game);
|
||||
gameStore.setGameByUser(userId2, game);
|
||||
@ -284,6 +339,7 @@ public class GameManager {
|
||||
}
|
||||
|
||||
private PlayerSummary getPlayerInfo(String userId) {
|
||||
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
|
||||
AuthUser user = rtmt.getUser(userId);
|
||||
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) {
|
||||
return new GameUsersConnectionInfo(
|
||||
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())),
|
||||
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId())));
|
||||
UserConnectionInfo.of(player1, isOnline(player1.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) {
|
||||
@ -304,6 +365,7 @@ public class GameManager {
|
||||
sendUserConnectionInfo(game);
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
notifyTurnStarted(game);
|
||||
}
|
||||
|
||||
public void sendUserConnectionInfo(BackendGame game) {
|
||||
@ -337,6 +399,10 @@ public class GameManager {
|
||||
liveGamesListeners.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
private void notifyTurnStarted(BackendGame game) {
|
||||
turnListeners.forEach(listener -> listener.accept(game));
|
||||
}
|
||||
|
||||
private void listenUserConnectionChange() {
|
||||
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
|
||||
BackendGame game = gameStore.getGameByUser(clientId);
|
||||
|
||||
@ -5,12 +5,15 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
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.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
@ -53,7 +56,9 @@ public final class GameSerialization {
|
||||
int peakSpectators,
|
||||
List<String> allSpectatorIds,
|
||||
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) {
|
||||
@ -69,7 +74,7 @@ public final class GameSerialization {
|
||||
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||
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.
|
||||
@ -112,7 +117,16 @@ public final class GameSerialization {
|
||||
return new BackendGame(mancalaGame,
|
||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||
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.
|
||||
|
||||
@ -6,6 +6,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
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.
|
||||
public class GameStore {
|
||||
// In the order games started.
|
||||
@ -28,7 +30,9 @@ public class GameStore {
|
||||
games.remove(id);
|
||||
}
|
||||
|
||||
// Bots play many games at once, so they are not mapped to one.
|
||||
public void setGameByUser(String userId, BackendGame game) {
|
||||
if (BotLevel.isBotId(userId)) return;
|
||||
gameIdsByUser.put(userId, game.id);
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
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
|
||||
// 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 {
|
||||
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
||||
|
||||
@ -16,7 +17,8 @@ public final class PublicIds {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
|
||||
@ -4,6 +4,8 @@ package app.playmancala.model;
|
||||
public final class Channels {
|
||||
public static final String NEW_GAME = "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 GAME_MOVE = "game_move";
|
||||
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).
|
||||
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.
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
%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`
|
||||
```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:
|
||||
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
|
||||
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).
|
||||
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.
|
||||
@ -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
|
||||
`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
|
||||
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
|
||||
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;
|
||||
a game that ended or moved on while searching drops the result; a failing search plays the first legal pit;
|
||||
low time shortens the wait; a restored game where it is the bot's turn gets a move.
|
||||
a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where
|
||||
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;
|
||||
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.
|
||||
|
||||
@ -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
|
||||
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`.
|
||||
- [ ] 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
|
||||
`Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests.
|
||||
- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user