Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
8 changed files with 507 additions and 6 deletions
Showing only changes of commit dbaa1d2492 - Show all commits

View File

@ -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);

View File

@ -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.

View File

@ -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();
}
}

View File

@ -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() {
}

View File

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

View File

@ -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

View File

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

View File

@ -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.