Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -20,6 +20,7 @@ import app.playmancala.game.GameRecorder;
|
||||
import app.playmancala.game.GameStore;
|
||||
import app.playmancala.game.LiveGames;
|
||||
import app.playmancala.game.MatchMaker;
|
||||
import app.playmancala.game.RematchOffers;
|
||||
import app.playmancala.history.GameHistoryService;
|
||||
import app.playmancala.history.Migrations;
|
||||
import app.playmancala.history.PostgresGameHistoryRepository;
|
||||
@ -103,6 +104,7 @@ public class Backend {
|
||||
loop::now, gameClockMs);
|
||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
||||
new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom());
|
||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
@ -57,6 +58,8 @@ public class GameManager {
|
||||
private final long gameClockMs;
|
||||
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
||||
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
|
||||
private final List<BiConsumer<BackendGame, GameEndMessage>> gameEndedListeners = new ArrayList<>();
|
||||
private final List<Consumer<String>> searchingListeners = new ArrayList<>();
|
||||
|
||||
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||
@ -84,8 +87,19 @@ public class GameManager {
|
||||
turnListeners.add(listener);
|
||||
}
|
||||
|
||||
// Called when a game has ended, for any reason but a crash, with what the players were sent.
|
||||
public void onGameEnded(BiConsumer<BackendGame, GameEndMessage> listener) {
|
||||
gameEndedListeners.add(listener);
|
||||
}
|
||||
|
||||
// Called with a user who asks for a new game or a bot game, before it is handled.
|
||||
public void onPlayerSearching(Consumer<String> listener) {
|
||||
searchingListeners.add(listener);
|
||||
}
|
||||
|
||||
private void listenRtmtMessages() {
|
||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||
searchingListeners.forEach(listener -> listener.accept(userId));
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
if (activeGame != null) {
|
||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
||||
@ -111,6 +125,7 @@ public class GameManager {
|
||||
LOG.debugf("(GameManager) Unknown bot level: %s", message);
|
||||
return;
|
||||
}
|
||||
searchingListeners.forEach(listener -> listener.accept(userId));
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
if (activeGame != null) {
|
||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
||||
@ -203,7 +218,8 @@ public class GameManager {
|
||||
Series series = game.series.next(new SeriesGame(game.id, game.firstSeat(), result, ending.endReason(),
|
||||
stones[board.player1BankIndex()], stones[board.player2BankIndex()]));
|
||||
GameEndMessage endMessage = new GameEndMessage(game.id, result, ending.endReason(), game.clock,
|
||||
ending.winnerId(), ending.leftUserId(), series);
|
||||
ending.winnerId(), ending.leftUserId(), series,
|
||||
ending.leftUserId() == null ? GameEndMessage.REMATCH_AVAILABLE : null);
|
||||
if (ending.leftUserId() != null) {
|
||||
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
|
||||
}
|
||||
@ -214,6 +230,7 @@ public class GameManager {
|
||||
inStore(() -> liveGameStore.removeGame(game.id));
|
||||
recorder.save(record);
|
||||
notifyLiveGamesChanged();
|
||||
gameEndedListeners.forEach(listener -> listener.accept(game, endMessage));
|
||||
}
|
||||
|
||||
// The player whose turn it is ran out of time: the opponent wins.
|
||||
|
||||
@ -0,0 +1,197 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.loop.Cancellable;
|
||||
import app.playmancala.loop.Scheduler;
|
||||
import app.playmancala.model.Channels;
|
||||
import app.playmancala.model.GameEndMessage;
|
||||
import app.playmancala.model.Seat;
|
||||
import app.playmancala.model.Series;
|
||||
import app.playmancala.rtmt.Rtmt;
|
||||
|
||||
// Rematches between the players of a game that just ended (docs/specs/rematch). An offer is open for a while after
|
||||
// the game; one player asks, the other accepts and the next game of the series starts. Kept in memory only, and only
|
||||
// touched on the game loop.
|
||||
public class RematchOffers {
|
||||
// How long the opponent has to answer a request.
|
||||
static final long ANSWER_MS = 60_000;
|
||||
// How long after a game a rematch can be asked for.
|
||||
static final long OPEN_MS = 10 * 60_000;
|
||||
|
||||
// Why a request, or the offer, ended; sent in on_rematch_ended.
|
||||
enum Reason {
|
||||
DECLINED, WITHDRAWN, EXPIRED, OFFLINE, LEFT, CLOSED;
|
||||
|
||||
String wireName() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// What the next game needs, and the request in progress.
|
||||
private static final class Offer {
|
||||
final String gameId;
|
||||
final String player1Id;
|
||||
final String player2Id;
|
||||
// Games against the bot only.
|
||||
final BotLevel botLevel;
|
||||
final Series series;
|
||||
final Seat firstSeat;
|
||||
// The player who asked; null while nobody has.
|
||||
String askedBy;
|
||||
Cancellable answerTimer;
|
||||
Cancellable closeTimer;
|
||||
|
||||
Offer(String gameId, String player1Id, String player2Id, BotLevel botLevel, Series series, Seat firstSeat) {
|
||||
this.gameId = gameId;
|
||||
this.player1Id = player1Id;
|
||||
this.player2Id = player2Id;
|
||||
this.botLevel = botLevel;
|
||||
this.series = series;
|
||||
this.firstSeat = firstSeat;
|
||||
}
|
||||
|
||||
boolean isPlayer(String userId) {
|
||||
return player1Id.equals(userId) || player2Id.equals(userId);
|
||||
}
|
||||
|
||||
String opponentOf(String userId) {
|
||||
return player1Id.equals(userId) ? player2Id : player1Id;
|
||||
}
|
||||
}
|
||||
|
||||
private final GameManager gameManager;
|
||||
private final GameStore gameStore;
|
||||
private final Rtmt rtmt;
|
||||
private final Scheduler loop;
|
||||
private final RandomGenerator random;
|
||||
private final Map<String, Offer> offersByGame = new HashMap<>();
|
||||
// A player has at most one offer, the one of their last game. The bot has none.
|
||||
private final Map<String, Offer> offersByUser = new HashMap<>();
|
||||
|
||||
public RematchOffers(GameManager gameManager, GameStore gameStore, Rtmt rtmt, Scheduler loop, RandomGenerator random) {
|
||||
this.gameManager = gameManager;
|
||||
this.gameStore = gameStore;
|
||||
this.rtmt = rtmt;
|
||||
this.loop = loop;
|
||||
this.random = random;
|
||||
gameManager.onGameEnded(this::onGameEnded);
|
||||
gameManager.onPlayerSearching(userId -> close(offersByUser.get(userId), Reason.LEFT));
|
||||
rtmt.listenMessage(Channels.REMATCH, (userId, message) -> onRematch(userId, gameIdOf(message)));
|
||||
rtmt.listenMessage(Channels.DECLINE_REMATCH, (userId, message) -> onDecline(userId, gameIdOf(message)));
|
||||
rtmt.listenOnClientConnectionChange((userId, isOnline) -> {
|
||||
Offer offer = offersByUser.get(userId);
|
||||
if (!isOnline && offer != null && offer.askedBy != null) endRequest(offer, Reason.OFFLINE);
|
||||
});
|
||||
}
|
||||
|
||||
private static String gameIdOf(JsonNode message) {
|
||||
JsonNode gameId = message.path("gameId");
|
||||
return gameId.isTextual() ? gameId.asText() : null;
|
||||
}
|
||||
|
||||
private void onGameEnded(BackendGame game, GameEndMessage endMessage) {
|
||||
if (!endMessage.isRematchAvailable()) return;
|
||||
String player1Id = game.mancalaGame.getPlayer1Id(), player2Id = game.mancalaGame.getPlayer2Id();
|
||||
close(offersByUser.get(player1Id), Reason.CLOSED);
|
||||
close(offersByUser.get(player2Id), Reason.CLOSED);
|
||||
// The other player moves first next (R2.2).
|
||||
Seat nextFirstSeat = game.firstSeat() == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1;
|
||||
Offer offer = new Offer(game.id, player1Id, player2Id, game.bot != null ? game.bot.level() : null,
|
||||
endMessage.series(), nextFirstSeat);
|
||||
offer.closeTimer = loop.schedule(() -> close(offer, Reason.CLOSED), OPEN_MS);
|
||||
offersByGame.put(offer.gameId, offer);
|
||||
for (String playerId : new String[] { player1Id, player2Id }) {
|
||||
if (!BotLevel.isBotId(playerId)) offersByUser.put(playerId, offer);
|
||||
}
|
||||
}
|
||||
|
||||
private void onRematch(String userId, String gameId) {
|
||||
if (gameId == null) return;
|
||||
Offer offer = offersByGame.get(gameId);
|
||||
if (offer == null || !offer.isPlayer(userId)) {
|
||||
rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.CLOSED));
|
||||
return;
|
||||
}
|
||||
String opponentId = offer.opponentOf(userId);
|
||||
// The bot always accepts (R4.2).
|
||||
if (BotLevel.isBotId(opponentId) || opponentId.equals(offer.askedBy)) {
|
||||
startNextGame(offer);
|
||||
return;
|
||||
}
|
||||
if (userId.equals(offer.askedBy)) return;
|
||||
if (!rtmt.isClientOnline(opponentId)) {
|
||||
rtmt.sendMessage(userId, Channels.ON_REMATCH_ENDED, ended(gameId, Reason.OFFLINE));
|
||||
return;
|
||||
}
|
||||
offer.askedBy = userId;
|
||||
rtmt.sendMessage(opponentId, Channels.ON_REMATCH_REQUEST, Map.of("gameId", gameId));
|
||||
offer.answerTimer = loop.schedule(() -> endRequest(offer, Reason.EXPIRED), ANSWER_MS);
|
||||
}
|
||||
|
||||
// Withdraws the user's own request, or says no to the opponent's, which ends the series (R1.6).
|
||||
private void onDecline(String userId, String gameId) {
|
||||
Offer offer = gameId == null ? null : offersByGame.get(gameId);
|
||||
if (offer == null || !offer.isPlayer(userId)) return;
|
||||
if (userId.equals(offer.askedBy)) endRequest(offer, Reason.WITHDRAWN);
|
||||
else close(offer, Reason.DECLINED);
|
||||
}
|
||||
|
||||
// Ends the request in progress; the offer stays open, so either player can ask again.
|
||||
private void endRequest(Offer offer, Reason reason) {
|
||||
cancel(offer.answerTimer);
|
||||
offer.answerTimer = null;
|
||||
offer.askedBy = null;
|
||||
notifyPlayers(offer, reason);
|
||||
}
|
||||
|
||||
// Ends the offer: no rematch of this game any more.
|
||||
private void close(Offer offer, Reason reason) {
|
||||
if (offer == null || !remove(offer)) return;
|
||||
notifyPlayers(offer, reason);
|
||||
}
|
||||
|
||||
private boolean remove(Offer offer) {
|
||||
if (offersByGame.get(offer.gameId) != offer) return false;
|
||||
offersByGame.remove(offer.gameId);
|
||||
offersByUser.remove(offer.player1Id, offer);
|
||||
offersByUser.remove(offer.player2Id, offer);
|
||||
cancel(offer.answerTimer);
|
||||
cancel(offer.closeTimer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void startNextGame(Offer offer) {
|
||||
remove(offer);
|
||||
// Matchmaking ends the offer, so neither player can be in a game; a restored game must not make it two.
|
||||
if (gameStore.getGameByUser(offer.player1Id) != null || gameStore.getGameByUser(offer.player2Id) != null) {
|
||||
notifyPlayers(offer, Reason.CLOSED);
|
||||
return;
|
||||
}
|
||||
// The bot keeps its level and draws a new strength, as in any new game.
|
||||
BotSettings bot = offer.botLevel != null ? BotSettings.draw(offer.botLevel, random) : null;
|
||||
gameManager.startGame(gameManager.createMancalaGame(offer.player1Id, offer.player2Id, bot, offer.series,
|
||||
offer.firstSeat));
|
||||
}
|
||||
|
||||
private void notifyPlayers(Offer offer, Reason reason) {
|
||||
Map<String, String> message = ended(offer.gameId, reason);
|
||||
for (String playerId : new String[] { offer.player1Id, offer.player2Id }) {
|
||||
if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, Channels.ON_REMATCH_ENDED, message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> ended(String gameId, Reason reason) {
|
||||
return Map.of("gameId", gameId, "reason", reason.wireName());
|
||||
}
|
||||
|
||||
private static void cancel(Cancellable timer) {
|
||||
if (timer != null) timer.cancel();
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,14 @@ public final class Channels {
|
||||
public static final String ON_LIVE_GAMES = "on_live_games";
|
||||
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
|
||||
public static final String ON_NEW_GAME_REJECTED = "on_new_game_rejected";
|
||||
// Rematches (docs/specs/rematch). client -> server: rematch { gameId } asks for a rematch of an ended game or
|
||||
// accepts the opponent's request; decline_rematch { gameId } says no or withdraws one's own request.
|
||||
// server -> client: on_rematch_request { gameId } when the opponent asks; on_rematch_ended { gameId, reason } to
|
||||
// both players when a request or the chance of a rematch ends.
|
||||
public static final String REMATCH = "rematch";
|
||||
public static final String DECLINE_REMATCH = "decline_rematch";
|
||||
public static final String ON_REMATCH_REQUEST = "on_rematch_request";
|
||||
public static final String ON_REMATCH_ENDED = "on_rematch_ended";
|
||||
|
||||
private Channels() {
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
package app.playmancala.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and
|
||||
// sent as null) when the game ended without a winner. `series` includes the game that ended.
|
||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series" })
|
||||
// sent as null) when the game ended without a winner. `series` includes the game that ended; `rematch` is "available"
|
||||
// when the players can ask for a rematch, i.e. unless a player left.
|
||||
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series", "rematch" })
|
||||
public record GameEndMessage(
|
||||
String gameId,
|
||||
GameResult result,
|
||||
@ -13,5 +15,12 @@ public record GameEndMessage(
|
||||
GameClock clock,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series) {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String rematch) {
|
||||
public static final String REMATCH_AVAILABLE = "available";
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isRematchAvailable() {
|
||||
return REMATCH_AVAILABLE.equals(rematch);
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,7 +397,7 @@ class GameManagerTest {
|
||||
assertTrue(end.remove("clock").isObject());
|
||||
assertTrue(end.remove("series").isObject());
|
||||
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
|
||||
"winnerId", ALICE.id())), end);
|
||||
"winnerId", ALICE.id(), "rematch", "available")), end);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -0,0 +1,268 @@
|
||||
package app.playmancala.game;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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 org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.Pit;
|
||||
import app.playmancala.fakes.FakeRtmt;
|
||||
import app.playmancala.fakes.InMemoryGameHistoryRepository;
|
||||
import app.playmancala.fakes.InMemoryLiveGameStore;
|
||||
import app.playmancala.loop.ManualScheduler;
|
||||
import app.playmancala.model.AuthUser;
|
||||
import app.playmancala.model.Channels;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.Seat;
|
||||
|
||||
// Rematch requests with the real game logic and a manual clock (docs/specs/rematch).
|
||||
class RematchOffersTest {
|
||||
static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false);
|
||||
static final AuthUser BOB = new AuthUser("kc:bob", "bob", false);
|
||||
static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false);
|
||||
static final long CLOCK_MS = 5 * 60_000;
|
||||
|
||||
ManualScheduler loop;
|
||||
FakeRtmt rtmt;
|
||||
GameStore gameStore;
|
||||
GameManager gameManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
loop = new ManualScheduler(Instant.parse("2026-10-17T12:00:00Z").toEpochMilli());
|
||||
rtmt = new FakeRtmt();
|
||||
gameStore = new GameStore();
|
||||
InMemoryLiveGameStore liveGameStore = new InMemoryLiveGameStore();
|
||||
GameRecorder recorder = new GameRecorder(new InMemoryGameHistoryRepository(), liveGameStore, Runnable::run);
|
||||
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run,
|
||||
loop::now, CLOCK_MS);
|
||||
new RematchOffers(gameManager, gameStore, rtmt, loop, new SplittableRandom(1));
|
||||
List.of(ALICE, BOB, CAROL).forEach(rtmt::connect);
|
||||
}
|
||||
|
||||
BackendGame startGame() {
|
||||
rtmt.receive(ALICE.id(), Channels.NEW_GAME);
|
||||
rtmt.receive(BOB.id(), Channels.NEW_GAME);
|
||||
return gameStore.getGameByUser(ALICE.id());
|
||||
}
|
||||
|
||||
// The player to move plays a last stone that empties their side, which ends the game by the rules.
|
||||
void finish(BackendGame game) {
|
||||
String moverId = game.mancalaGame.getTurnPlayerId();
|
||||
boolean isPlayer1 = game.mancalaGame.getPlayer1Id().equals(moverId);
|
||||
List<Pit> pits = game.mancalaGame.getBoard().getPits();
|
||||
pits.forEach(pit -> pit.setStoneCount(0));
|
||||
pits.get(isPlayer1 ? 5 : 12).setStoneCount(1);
|
||||
pits.get(isPlayer1 ? 7 : 0).setStoneCount(1);
|
||||
gameManager.playMove(game, moverId, 5);
|
||||
assertNull(gameStore.get(game.id), "game did not end");
|
||||
}
|
||||
|
||||
void ask(AuthUser user, BackendGame game) {
|
||||
rtmt.receive(user.id(), Channels.REMATCH, Map.of("gameId", game.id));
|
||||
}
|
||||
|
||||
void decline(AuthUser user, BackendGame game) {
|
||||
rtmt.receive(user.id(), Channels.DECLINE_REMATCH, Map.of("gameId", game.id));
|
||||
}
|
||||
|
||||
List<JsonNode> requestsTo(AuthUser user) {
|
||||
return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_REQUEST);
|
||||
}
|
||||
|
||||
List<String> endedReasonsTo(AuthUser user) {
|
||||
return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_ENDED).stream().map(m -> m.get("reason").asText()).toList();
|
||||
}
|
||||
|
||||
static Seat other(Seat seat) {
|
||||
return seat == Seat.PLAYER1 ? Seat.PLAYER2 : Seat.PLAYER1;
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenBothAskTheNextGameStartsWithTheSameSeatsTheOtherFirstSeatAndTheSeries() {
|
||||
BackendGame game = startGame();
|
||||
Seat firstSeat = game.firstSeat();
|
||||
finish(game);
|
||||
|
||||
ask(ALICE, game);
|
||||
assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), requestsTo(BOB));
|
||||
assertNull(gameStore.getGameByUser(ALICE.id()));
|
||||
ask(BOB, game);
|
||||
|
||||
BackendGame next = gameStore.getGameByUser(ALICE.id());
|
||||
assertNotNull(next);
|
||||
assertEquals(game.mancalaGame.getPlayer1Id(), next.mancalaGame.getPlayer1Id());
|
||||
assertEquals(game.mancalaGame.getPlayer2Id(), next.mancalaGame.getPlayer2Id());
|
||||
assertEquals(other(firstSeat), next.firstSeat());
|
||||
assertEquals(game.series.id(), next.series.id());
|
||||
assertEquals(List.of(game.id), next.series.games().stream().map(g -> g.gameId()).toList());
|
||||
for (AuthUser user : List.of(ALICE, BOB)) {
|
||||
assertEquals(next.id, rtmt.lastMessageTo(user.id(), Channels.ON_GAME_START).get("id").asText());
|
||||
}
|
||||
assertEquals(0, loop.pendingTimers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFirstSeatAlternatesThroughTheSeries() {
|
||||
BackendGame game = startGame();
|
||||
Seat firstSeat = game.firstSeat();
|
||||
for (int number = 2; number <= 4; number++) {
|
||||
finish(game);
|
||||
ask(BOB, game);
|
||||
ask(ALICE, game);
|
||||
game = gameStore.getGameByUser(ALICE.id());
|
||||
assertEquals(number % 2 == 0 ? other(firstSeat) : firstSeat, game.firstSeat());
|
||||
assertEquals(number - 1, game.series.games().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void thereIsNoRematchAfterAPlayerLeft() {
|
||||
BackendGame game = startGame();
|
||||
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
|
||||
assertFalse(rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).has("rematch"));
|
||||
|
||||
ask(BOB, game);
|
||||
assertEquals(List.of("closed"), endedReasonsTo(BOB));
|
||||
assertTrue(requestsTo(ALICE).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sayingNoEndsTheSeries() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
decline(BOB, game);
|
||||
assertEquals(List.of("declined"), endedReasonsTo(ALICE));
|
||||
assertEquals(List.of("declined"), endedReasonsTo(BOB));
|
||||
|
||||
ask(ALICE, game);
|
||||
assertEquals(List.of("declined", "closed"), endedReasonsTo(ALICE));
|
||||
assertEquals(1, requestsTo(BOB).size());
|
||||
assertEquals(0, loop.pendingTimers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWithdrawnRequestLeavesTheOfferOpen() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
decline(ALICE, game);
|
||||
assertEquals(List.of("withdrawn"), endedReasonsTo(BOB));
|
||||
|
||||
ask(BOB, game);
|
||||
assertEquals(1, requestsTo(ALICE).size());
|
||||
ask(ALICE, game);
|
||||
assertNotNull(gameStore.getGameByUser(BOB.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequestNotAnsweredWithinAMinuteExpiresAndCanBeMadeAgain() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
loop.advance(RematchOffers.ANSWER_MS - 1);
|
||||
assertTrue(endedReasonsTo(ALICE).isEmpty());
|
||||
loop.advance(1);
|
||||
assertEquals(List.of("expired"), endedReasonsTo(ALICE));
|
||||
assertEquals(List.of("expired"), endedReasonsTo(BOB));
|
||||
|
||||
ask(ALICE, game);
|
||||
assertEquals(2, requestsTo(BOB).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPlayerGoingOfflineEndsTheRequest() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
rtmt.disconnect(BOB.id());
|
||||
assertEquals(List.of("offline"), endedReasonsTo(ALICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequestToAnOpponentWhoIsOfflineEndsAtOnce() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
rtmt.disconnect(BOB.id());
|
||||
ask(ALICE, game);
|
||||
assertEquals(List.of("offline"), endedReasonsTo(ALICE));
|
||||
assertTrue(requestsTo(BOB).isEmpty());
|
||||
|
||||
rtmt.connect(BOB);
|
||||
ask(ALICE, game);
|
||||
assertEquals(1, requestsTo(BOB).size());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { Channels.NEW_GAME, Channels.NEW_BOT_GAME })
|
||||
void lookingForAnotherGameEndsTheSeries(String channel) {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
rtmt.receive(BOB.id(), channel, Map.of("level", "easy"));
|
||||
assertEquals(List.of("left"), endedReasonsTo(ALICE));
|
||||
|
||||
ask(ALICE, game);
|
||||
assertEquals(List.of("left", "closed"), endedReasonsTo(ALICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRematchCanBeAskedForUpToTenMinutesAfterTheGame() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
loop.advance(RematchOffers.OPEN_MS);
|
||||
assertEquals(List.of("closed"), endedReasonsTo(ALICE));
|
||||
assertEquals(List.of("closed"), endedReasonsTo(BOB));
|
||||
|
||||
ask(ALICE, game);
|
||||
assertTrue(requestsTo(BOB).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBotAcceptsAtOnceAtTheSameLevel() {
|
||||
BotSettings bot = BotSettings.draw(BotLevel.HARD, new SplittableRandom(2));
|
||||
BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot);
|
||||
gameManager.startGame(game);
|
||||
Seat firstSeat = game.firstSeat();
|
||||
finish(game);
|
||||
|
||||
ask(ALICE, game);
|
||||
BackendGame next = gameStore.getGameByUser(ALICE.id());
|
||||
assertNotNull(next);
|
||||
assertEquals(bot.botId(), next.mancalaGame.getPlayer2Id());
|
||||
assertEquals(BotLevel.HARD, next.bot.level());
|
||||
assertEquals(other(firstSeat), next.firstSeat());
|
||||
assertEquals(game.series.id(), next.series.id());
|
||||
assertEquals(1, next.series.games().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPlayerAlreadyInAnotherGameIsNotPulledIntoARematch() {
|
||||
BackendGame game = startGame();
|
||||
finish(game);
|
||||
ask(ALICE, game);
|
||||
// As after a restore: Bob has a game without having looked for one.
|
||||
BackendGame other = gameManager.createMancalaGame(BOB.id(), CAROL.id());
|
||||
ask(BOB, game);
|
||||
|
||||
assertNull(gameStore.getGameByUser(ALICE.id()));
|
||||
assertEquals(other, gameStore.getGameByUser(BOB.id()));
|
||||
assertEquals(List.of("closed"), endedReasonsTo(ALICE));
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
||||
- [x] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in
|
||||
`createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with
|
||||
the restore of old snapshots); unit and serialisation tests.
|
||||
- [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
|
||||
- [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on
|
||||
`new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests.
|
||||
- [ ] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests.
|
||||
- [ ] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user