diff --git a/apps/backend/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java index 2be5492..ab6b2c0 100644 --- a/apps/backend/src/main/java/app/playmancala/Backend.java +++ b/apps/backend/src/main/java/app/playmancala/Backend.java @@ -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); diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java index f52b344..1c2211a 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameManager.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -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 liveGamesListeners = new ArrayList<>(); private final List> turnListeners = new ArrayList<>(); + private final List> gameEndedListeners = new ArrayList<>(); + private final List> 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 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 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. diff --git a/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java b/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java new file mode 100644 index 0000000..b3d5436 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/game/RematchOffers.java @@ -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 offersByGame = new HashMap<>(); + // A player has at most one offer, the one of their last game. The bot has none. + private final Map 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 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 ended(String gameId, Reason reason) { + return Map.of("gameId", gameId, "reason", reason.wireName()); + } + + private static void cancel(Cancellable timer) { + if (timer != null) timer.cancel(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java index 25fb9e2..03a30c3 100644 --- a/apps/backend/src/main/java/app/playmancala/model/Channels.java +++ b/apps/backend/src/main/java/app/playmancala/model/Channels.java @@ -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() { } diff --git a/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java index e4b6cb1..9aee0de 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java @@ -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); + } } diff --git a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java index cbc8d3b..01a0962 100644 --- a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java +++ b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java @@ -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 diff --git a/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java b/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java new file mode 100644 index 0000000..d63360d --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/game/RematchOffersTest.java @@ -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 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 requestsTo(AuthUser user) { + return rtmt.messagesTo(user.id(), Channels.ON_REMATCH_REQUEST); + } + + List 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)); + } +} diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index ed14f83..6bc8dba 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -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.