From 219b50b491fa4fdd8d1233e1d3d70f30527c2338 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:23:35 +0300 Subject: [PATCH] feat(backend): port the game logic to Java GameManager, matchmaking, the game clock, live game snapshots, saving finished games and live previews, ported from the Node backend with its tests. Snapshots keep the Node backend's format; a test restores one the Node backend wrote. Valkey writes and saves run off the game loop. --- apps/backend-quarkus/pom.xml | 1 + .../app/playmancala/game/BackendGame.java | 58 ++ .../java/app/playmancala/game/CrashLog.java | 50 ++ .../playmancala/game/GameClockScheduler.java | 32 + .../java/app/playmancala/game/GameClocks.java | 61 ++ .../app/playmancala/game/GameManager.java | 351 ++++++++++ .../app/playmancala/game/GameRecorder.java | 109 ++++ .../playmancala/game/GameSerialization.java | 157 +++++ .../java/app/playmancala/game/GameStore.java | 48 ++ .../java/app/playmancala/game/LiveGames.java | 93 +++ .../java/app/playmancala/game/MatchMaker.java | 39 ++ .../java/app/playmancala/game/PublicIds.java | 29 + .../history/GameHistoryRepository.java | 22 + .../app/playmancala/live/LiveGameStore.java | 24 + .../main/java/app/playmancala/rtmt/Rtmt.java | 24 + .../java/app/playmancala/fakes/FakeRtmt.java | 82 +++ .../fakes/InMemoryGameHistoryRepository.java | 48 ++ .../fakes/InMemoryLiveGameStore.java | 58 ++ .../game/GameClockSchedulerTest.java | 29 + .../app/playmancala/game/GameManagerTest.java | 606 ++++++++++++++++++ .../app/playmancala/game/LiveGamesTest.java | 126 ++++ docs/specs/quarkus-backend/tasks.md | 2 +- 22 files changed, 2048 insertions(+), 1 deletion(-) create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java create mode 100644 apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java create mode 100644 apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java diff --git a/apps/backend-quarkus/pom.xml b/apps/backend-quarkus/pom.xml index 5f59152..0569e61 100644 --- a/apps/backend-quarkus/pom.xml +++ b/apps/backend-quarkus/pom.xml @@ -100,6 +100,7 @@ org.jboss.logmanager.LogManager ${maven.home} + ${project.build.directory}/crashes diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java new file mode 100644 index 0000000..570bbf4 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/BackendGame.java @@ -0,0 +1,58 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import app.playmancala.engine.MancalaGame; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; + +// A live game and the server-side state around it. Only touched on the game loop. +public class BackendGame { + public final String id; + public final MancalaGame mancalaGame; + public GameUsersConnectionInfo gameUsersConnectionInfo; + // Users watching right now. + public final List spectatorIds = new ArrayList<>(); + // Everyone who watched at some point, and the most at once. + public final List allSpectatorIds; + public int peakSpectators; + // Keyed by user id, captured when the game is created. + public final Map players; + public final long startedAt; + // Time of each move, aligned with mancalaGame.history. + public final List moveTimes; + 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 moveRemainingMs; + + public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, + List allSpectatorIds, int peakSpectators, Map players, long startedAt, + List moveTimes, GameClock clock, List moveRemainingMs) { + this.id = mancalaGame.getId(); + this.mancalaGame = mancalaGame; + this.gameUsersConnectionInfo = gameUsersConnectionInfo; + this.allSpectatorIds = new ArrayList<>(allSpectatorIds); + this.peakSpectators = peakSpectators; + this.players = Map.copyOf(players); + this.startedAt = startedAt; + this.moveTimes = new ArrayList<>(moveTimes); + this.clock = clock; + this.moveRemainingMs = new ArrayList<>(moveRemainingMs); + } + + public PlayerSummary player(String userId) { + PlayerSummary player = players.get(userId); + return player != null ? player : PlayerSummary.unknown(userId); + } + + public boolean isPlayer(String userId) { + return mancalaGame.getPlayer1Id().equals(userId) || mancalaGame.getPlayer2Id().equals(userId); + } + + public String opponentOf(String userId) { + return userId.equals(mancalaGame.getPlayer1Id()) ? mancalaGame.getPlayer2Id() : mancalaGame.getPlayer1Id(); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java new file mode 100644 index 0000000..6c2c1c0 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/CrashLog.java @@ -0,0 +1,50 @@ +package app.playmancala.game; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jboss.logging.Logger; + +import app.playmancala.model.Json; + +// Writes a file to ./crashes (or the folder in the mancala.crash-dir system property) for each game that failed while a move was played, with the error and the game. +public final class CrashLog { + private static final Logger LOG = Logger.getLogger(CrashLog.class); + private static final Path CRASH_FOLDER = Path.of(System.getProperty("mancala.crash-dir", "crashes")); + + private CrashLog() { + } + + // The JSON to write; call it on the game loop, while the game cannot change. + public static String describe(Throwable error, BackendGame game) { + StringWriter stack = new StringWriter(); + error.printStackTrace(new PrintWriter(stack)); + Map crash = new LinkedHashMap<>(); + crash.put("message", error.getMessage()); + crash.put("stack", stack.toString()); + crash.put("game", game); + return Json.write(crash); + } + + // Returns the file name, e.g. "::2026-10-16-12:5:3:42" as the Node backend named them. + public static String fileName(String gameId) { + LocalDateTime now = LocalDateTime.now(); + return String.format("%s::%d-%02d-%02d-%d:%d:%d:%d", gameId, now.getYear(), now.getMonthValue(), + now.getDayOfMonth(), now.getHour(), now.getMinute(), now.getSecond(), now.getNano() / 1_000_000); + } + + public static void write(String fileName, String crash) { + try { + Files.createDirectories(CRASH_FOLDER); + Files.writeString(CRASH_FOLDER.resolve(fileName), crash); + } catch (IOException e) { + LOG.errorf("(CrashLog) Could not write %s: %s", fileName, e.getMessage()); + } + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java new file mode 100644 index 0000000..6747859 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClockScheduler.java @@ -0,0 +1,32 @@ +package app.playmancala.game; + +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; + +// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected +// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs +// only decides how soon the end is announced, never who wins. +public class GameClockScheduler { + public static final long CLOCK_CHECK_INTERVAL_MS = 1_000; + + private final Runnable endGamesOutOfTime; + private final Scheduler scheduler; + private final long intervalMs; + private Cancellable timer; + + public GameClockScheduler(Runnable endGamesOutOfTime, Scheduler scheduler, long intervalMs) { + this.endGamesOutOfTime = endGamesOutOfTime; + this.scheduler = scheduler; + this.intervalMs = intervalMs; + } + + public void start() { + if (timer != null) return; + timer = scheduler.scheduleAtFixedRate(endGamesOutOfTime, intervalMs); + } + + public void stop() { + if (timer != null) timer.cancel(); + timer = null; + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java new file mode 100644 index 0000000..b617989 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameClocks.java @@ -0,0 +1,61 @@ +package app.playmancala.game; + +import app.playmancala.model.GameClock; +import app.playmancala.model.Seat; + +// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn +// plus when that turn started, so it only changes on moves; the time left at any moment is computed. +public final class GameClocks { + private GameClocks() { + } + + public record ClockTimes(long player1Ms, long player2Ms) { + } + + public record ChargedMove(GameClock clock, long remainingMs) { + } + + // The seat of the player, or null if they do not play in the game. + public static Seat getSeat(String player1Id, String player2Id, String playerId) { + if (playerId.equals(player1Id)) return Seat.PLAYER1; + if (playerId.equals(player2Id)) return Seat.PLAYER2; + return null; + } + + public static GameClock start(long initialMs, long now) { + return new GameClock(initialMs, initialMs, initialMs, now); + } + + // Both players' time at `now`; never below zero. Port of getClockTimes in packages/core. + public static ClockTimes getClockTimes(GameClock clock, Seat runningSeat, long now) { + long elapsed = runningSeat != null && clock.turnStartedAt() != null ? Math.max(0, now - clock.turnStartedAt()) : 0; + return new ClockTimes( + Math.max(0, clock.player1Ms() - (runningSeat == Seat.PLAYER1 ? elapsed : 0)), + Math.max(0, clock.player2Ms() - (runningSeat == Seat.PLAYER2 ? elapsed : 0))); + } + + public static long getRemainingMs(GameClock clock, Seat seat, Seat runningSeat, long now) { + ClockTimes times = getClockTimes(clock, runningSeat, now); + return seat == Seat.PLAYER1 ? times.player1Ms() : times.player2Ms(); + } + + // Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn. + public static ChargedMove chargeMove(GameClock clock, Seat moverSeat, long now) { + long remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now); + GameClock charged = moverSeat == Seat.PLAYER1 + ? new GameClock(clock.initialMs(), remainingMs, clock.player2Ms(), now) + : new GameClock(clock.initialMs(), clock.player1Ms(), remainingMs, now); + return new ChargedMove(charged, remainingMs); + } + + // Deducts the running seat's time, if any, and stops the clock. + public static GameClock stop(GameClock clock, Seat runningSeat, long now) { + ClockTimes times = getClockTimes(clock, runningSeat, now); + return new GameClock(clock.initialMs(), times.player1Ms(), times.player2Ms(), null); + } + + // Starts the running clock again from the time it had at the start of the turn, e.g. after a restart. + public static GameClock resume(GameClock clock, long now) { + return new GameClock(clock.initialMs(), clock.player1Ms(), clock.player2Ms(), now); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java new file mode 100644 index 0000000..545155a --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameManager.java @@ -0,0 +1,351 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.LongSupplier; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.engine.CommonMancalaGame; +import app.playmancala.engine.GameState; +import app.playmancala.engine.MancalaGame; +import app.playmancala.game.GameSerialization.GameEnding; +import app.playmancala.game.GameSerialization.LiveGameSnapshot; +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.ClientGame; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameEndMessage; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Seat; +import app.playmancala.model.UserConnectionInfo; +import app.playmancala.rtmt.Rtmt; + +// Runs live games: matchmaking, moves, the clock, spectators and every way a game ends. Only touched on the game loop. +// 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); + + private final GameStore gameStore; + private final Rtmt rtmt; + private final MatchMaker matchMaker; + private final LiveGameStore liveGameStore; + private final GameRecorder recorder; + private final Executor storeExecutor; + private final LongSupplier clock; + // Time each player has for a new game. + private final long gameClockMs; + private final List liveGamesListeners = new ArrayList<>(); + + public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, + GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { + this.gameStore = gameStore; + this.rtmt = rtmt; + this.matchMaker = matchMaker; + this.liveGameStore = liveGameStore; + this.recorder = recorder; + this.storeExecutor = storeExecutor; + this.clock = clock; + this.gameClockMs = gameClockMs; + matchMaker.listenOnPlayersPaired((player1Id, player2Id) -> startGame(createMancalaGame(player1Id, player2Id))); + listenRtmtMessages(); + listenUserConnectionChange(); + } + + // Called whenever a live game starts, changes or ends. + public void onLiveGamesChanged(Runnable listener) { + liveGamesListeners.add(listener); + } + + private void listenRtmtMessages() { + rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { + BackendGame activeGame = gameStore.getGameByUser(userId); + if (activeGame != null) { + rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id)); + return; + } + matchMaker.join(userId); + }); + 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)); + rtmt.listenMessage(Channels.LISTEN_GAME_EVENTS, (userId, message) -> { + if (message.isTextual()) addSpectator(userId, message.asText()); + }); + rtmt.listenMessage(Channels.UNLISTEN_GAME_EVENTS, (userId, message) -> { + if (message.isTextual()) removeSpectator(userId, message.asText()); + }); + } + + private void onGameMove(String userId, JsonNode gameMove) { + BackendGame game = gameStore.getGameByUser(userId); + if (game == null) { + LOG.debug("Game not found!"); + return; + } + MancalaGame mancalaGame = game.mancalaGame; + try { + long now = clock.getAsLong(); + // A move that arrives after the mover's time has run out is not played. + if (userId.equals(mancalaGame.getTurnPlayerId()) && isOutOfTime(game, now)) { + endGameOnTime(game); + return; + } + JsonNode index = gameMove.path("index"); + if (!index.canConvertToInt() || !index.isIntegralNumber()) { + throw new IllegalStateException("Invalid move: " + gameMove); + } + int movesBefore = mancalaGame.getHistory().size(); + Seat moverSeat = seatOf(mancalaGame, userId); + mancalaGame.moveByPlayerPit(userId, index.asInt()); + if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { + game.moveTimes.add(now); + GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); + game.clock = charged.clock(); + game.moveRemainingMs.add(charged.remainingMs()); + } + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_UPDATE, toClientGame(game)); + if (mancalaGame.getState() == GameState.ENDED) { + endGame(game, new GameEnding(EndReason.COMPLETED, mancalaGame.getWonPlayerId(), null)); + } else { + saveLiveGame(game); + notifyLiveGamesChanged(); + } + } catch (RuntimeException e) { + onGameError(game, e); + } + } + + private void onGameError(BackendGame game, RuntimeException error) { + LOG.error("(GameManager) Game " + game.id + " failed", error); + String crash = CrashLog.describe(error, game); + String fileName = CrashLog.fileName(game.id); + storeExecutor.execute(() -> CrashLog.write(fileName, crash)); + LOG.infof("Game crash saved to file : %s", fileName); + // The Node backend sent the Error object, which serialises to {}; clients only use the channel. + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_CRASHED, Map.of()); + } + + private void onPlayerLeave(String userId) { + BackendGame game = gameStore.getGameByUser(userId); + if (game != null) { + endGame(game, new GameEnding(EndReason.LEFT, game.opponentOf(userId), userId)); + } + } + + // Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it. + private void endGame(BackendGame game, GameEnding ending) { + if (gameStore.get(game.id) != game) return; + deleteGame(game); + game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong()); + GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending), + ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId()); + if (ending.leftUserId() != null) { + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId()); + } + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_END, endMessage); + game.spectatorIds.clear(); + + GameRecord record = GameSerialization.toGameRecord(game, ending, clock.getAsLong()); + inStore(() -> liveGameStore.removeGame(game.id)); + recorder.save(record); + notifyLiveGamesChanged(); + } + + // The player whose turn it is ran out of time: the opponent wins. + private void endGameOnTime(BackendGame game) { + endGame(game, new GameEnding(EndReason.TIMEOUT, game.opponentOf(game.mancalaGame.getTurnPlayerId()), null)); + } + + private boolean isOutOfTime(BackendGame game, long now) { + Seat runningSeat = getRunningSeat(game); + return runningSeat != null && GameClocks.getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0; + } + + // The seat whose clock runs; null once the game has ended. + private Seat getRunningSeat(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + if (mancalaGame.getState() == GameState.ENDED || game.clock.turnStartedAt() == null) return null; + return seatOf(mancalaGame, mancalaGame.getTurnPlayerId()); + } + + private static Seat seatOf(MancalaGame mancalaGame, String playerId) { + return GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), playerId); + } + + // Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or + // not the players are online, so this also ends games that both players have left. + public void endGamesOutOfTime() { + long now = clock.getAsLong(); + for (BackendGame game : gameStore.getAll()) { + try { + if (isOutOfTime(game, now)) endGameOnTime(game); + } catch (RuntimeException e) { + LOG.error("(GameManager) Could not end game " + game.id + " on time", e); + } + } + } + + // Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy. Blocks on Valkey and the + // database; runs once at startup. + public int restoreLiveGames() { + List stored; + try { + stored = liveGameStore.loadGames(); + } catch (RuntimeException e) { + LOG.errorf("(GameManager) Could not load live games: %s", e.getMessage()); + return 0; + } + int restored = 0; + for (JsonNode json : stored) { + LiveGameSnapshot snapshot = GameSerialization.parseSnapshot(json); + if (snapshot == null || gameStore.get(snapshot.id()) != null) continue; + // A game that ended but whose snapshot could not be removed must not come back. + if (isRecorded(snapshot.id())) { + inStore(() -> liveGameStore.removeGame(snapshot.id())); + continue; + } + BackendGame game = GameSerialization.fromSnapshot(snapshot, clock.getAsLong(), gameClockMs); + gameStore.set(game.id, game); + gameStore.setGameByUser(game.mancalaGame.getPlayer1Id(), game); + gameStore.setGameByUser(game.mancalaGame.getPlayer2Id(), game); + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + restored++; + } + if (restored > 0) { + LOG.infof("(GameManager) Restored %d live game(s)", restored); + notifyLiveGamesChanged(); + } + return restored; + } + + private boolean isRecorded(String gameId) { + try { + return recorder.isRecorded(gameId); + } catch (RuntimeException e) { + return false; + } + } + + private void addSpectator(String userId, String gameId) { + BackendGame game = gameStore.get(gameId); + if (game == null || game.isPlayer(userId) || game.spectatorIds.contains(userId)) return; + game.spectatorIds.add(userId); + if (!game.allSpectatorIds.contains(userId)) game.allSpectatorIds.add(userId); + game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.size()); + saveLiveGame(game); + notifyLiveGamesChanged(); + } + + private void removeSpectator(String userId, String gameId) { + BackendGame game = gameStore.get(gameId); + if (game != null && game.spectatorIds.remove(userId)) { + notifyLiveGamesChanged(); + } + } + + private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { + rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); + rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); + List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, 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) { + MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); + mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); + long now = clock.getAsLong(); + PlayerSummary player1 = getPlayerInfo(userId1); + PlayerSummary player2 = getPlayerInfo(userId2); + Map players = new LinkedHashMap<>(); + 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()); + gameStore.set(game.id, game); + gameStore.setGameByUser(userId1, game); + gameStore.setGameByUser(userId2, game); + return game; + } + + private PlayerSummary getPlayerInfo(String userId) { + AuthUser user = rtmt.getUser(userId); + return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); + } + + public GameUsersConnectionInfo getGameUsersConnectionInfo(BackendGame game) { + return getGameUsersConnectionInfo(game.player(game.mancalaGame.getPlayer1Id()), + game.player(game.mancalaGame.getPlayer2Id())); + } + + private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) { + return new GameUsersConnectionInfo( + UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), + UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); + } + + public void startGame(BackendGame game) { + sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_START, toClientGame(game)); + sendUserConnectionInfo(game); + saveLiveGame(game); + notifyLiveGamesChanged(); + } + + public void sendUserConnectionInfo(BackendGame game) { + game.gameUsersConnectionInfo = getGameUsersConnectionInfo(game); + sendMessageToPlayersAndSpectators(game, Channels.ON_USER_CONNECTION_CHANGE, game.gameUsersConnectionInfo); + } + + public void deleteGame(BackendGame game) { + gameStore.removeGameByPlayer(game.mancalaGame.getPlayer1Id()); + gameStore.removeGameByPlayer(game.mancalaGame.getPlayer2Id()); + gameStore.remove(game.id); + } + + private void saveLiveGame(BackendGame game) { + LiveGameSnapshot snapshot = GameSerialization.toSnapshot(game); + inStore(() -> liveGameStore.saveGame(game.id, snapshot)); + } + + // Runs a Valkey write off the loop; a failure is only logged, the game goes on from memory. + private void inStore(Runnable write) { + storeExecutor.execute(() -> { + try { + write.run(); + } catch (RuntimeException e) { + LOG.errorf("(GameManager) Live game store: %s", e.getMessage()); + } + }); + } + + private void notifyLiveGamesChanged() { + liveGamesListeners.forEach(Runnable::run); + } + + private void listenUserConnectionChange() { + rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { + BackendGame game = gameStore.getGameByUser(clientId); + if (game != null) sendUserConnectionInfo(game); + if (!isOnline) { + gameStore.getSpectatingGameIdsByUser(clientId).forEach(gameId -> removeSpectator(clientId, gameId)); + } else if (game != null) { + rtmt.sendMessage(clientId, Channels.ON_GAME_UPDATE, toClientGame(game)); + } + }); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java new file mode 100644 index 0000000..4a9f3b1 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameRecorder.java @@ -0,0 +1,109 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import org.jboss.logging.Logger; + +import app.playmancala.history.GameHistoryRepository; +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.GameRecord; + +// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is +// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. Saves run on the +// given executor; the lists of unsaved games are read from any thread. +public class GameRecorder { + public static final long RETRY_INTERVAL_MS = 30_000; + private static final Logger LOG = Logger.getLogger(GameRecorder.class); + + private final GameHistoryRepository repository; + private final LiveGameStore liveGameStore; + private final Executor io; + // Being saved right now. + private final Map saving = new ConcurrentHashMap<>(); + // Failed to save; retried later. + private final Map pending = new ConcurrentHashMap<>(); + private final List> savedListeners = new CopyOnWriteArrayList<>(); + private final AtomicBoolean retrying = new AtomicBoolean(); + + public GameRecorder(GameHistoryRepository repository, LiveGameStore liveGameStore, Executor io) { + this.repository = repository; + this.liveGameStore = liveGameStore; + this.io = io; + } + + public void onSaved(Consumer listener) { + savedListeners.add(listener); + } + + public void save(GameRecord record) { + saving.put(record.id(), record); + io.execute(() -> { + try { + repository.save(record); + saving.remove(record.id()); + notifySaved(record); + } catch (RuntimeException e) { + LOG.errorf("(GameRecorder) Could not save game %s, will retry: %s", record.id(), e.getMessage()); + pending.put(record.id(), record); + saving.remove(record.id()); + try { + liveGameStore.savePendingRecord(record); + } catch (RuntimeException ignored) { + // Kept in memory. + } + } + }); + } + + // Finished games that are not in the database yet, so readers can include them. + public List getUnsaved() { + List unsaved = new ArrayList<>(saving.values()); + unsaved.addAll(pending.values()); + return unsaved; + } + + // True if the game is saved or waiting to be saved. Throws if the database cannot be asked. + public boolean isRecorded(String gameId) { + return pending.containsKey(gameId) || repository.get(gameId) != null; + } + + // Saves the records that failed before, including those a previous run left in Valkey. Blocks. + public void retryPending() { + if (!retrying.compareAndSet(false, true)) return; + try { + try { + liveGameStore.loadPendingRecords().forEach(record -> pending.put(record.id(), record)); + } catch (RuntimeException ignored) { + // Valkey is down; retry what is in memory. + } + for (GameRecord record : List.copyOf(pending.values())) { + try { + repository.save(record); + } catch (RuntimeException e) { + return; // still unreachable; try again later + } + pending.remove(record.id()); + try { + liveGameStore.removePendingRecord(record.id()); + } catch (RuntimeException ignored) { + // Saving it again later is a no-op. + } + LOG.infof("(GameRecorder) Saved game %s after retrying", record.id()); + notifySaved(record); + } + } finally { + retrying.set(false); + } + } + + private void notifySaved(GameRecord record) { + savedListeners.forEach(listener -> listener.accept(record)); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java new file mode 100644 index 0000000..7331021 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameSerialization.java @@ -0,0 +1,157 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.engine.CommonMancalaGame; +import app.playmancala.engine.GameState; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.engine.Pit; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; +import app.playmancala.model.GameUsersConnectionInfo; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; +import app.playmancala.model.PlayerSummary; +import app.playmancala.model.UserConnectionInfo; + +// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend +// can restore what the other saved. +public final class GameSerialization { + private static final Logger LOG = Logger.getLogger(GameSerialization.class); + private static final int BOARD_SIZE = 14; + + private GameSerialization() { + } + + // What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock. Built on + // the game loop from copies, so it can be serialised on another thread. + @JsonInclude(JsonInclude.Include.NON_NULL) + public record LiveGameSnapshot( + int version, + String id, + String player1Id, + String player2Id, + String turnPlayerId, + GameState state, + List board, + List history, + Map players, + long startedAt, + List moveTimes, + int peakSpectators, + List allSpectatorIds, + GameClock clock, + @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs) { + } + + public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { + // Without a winner. + public static GameEnding of(EndReason endReason) { + return new GameEnding(endReason, null, null); + } + } + + public static LiveGameSnapshot toSnapshot(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + return new LiveGameSnapshot(2, game.id, mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), + 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))); + } + + // The snapshot in the JSON, or null if it is not one this backend can restore. + public static LiveGameSnapshot parseSnapshot(JsonNode json) { + if (!isLiveGameSnapshot(json)) return null; + try { + return Json.MAPPER.treeToValue(json, LiveGameSnapshot.class); + } catch (Exception e) { + LOG.errorf("(GameSerialization) Ignoring unreadable snapshot %s: %s", json.path("id").asText(), e.getMessage()); + return null; + } + } + + private static boolean isLiveGameSnapshot(JsonNode json) { + if (json == null || !json.isObject()) return false; + boolean valid = json.path("id").isTextual() && json.path("board").isArray() && json.path("board").size() == BOARD_SIZE + && json.path("history").isArray() && json.path("players").isObject(); + int version = json.path("version").asInt(0); + if (version == 1) return valid; + return version == 2 && valid && json.path("clock").path("initialMs").isNumber() + && json.path("moveRemainingMs").isArray(); + } + + // Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on + // from the start of the turn, so the time the backend was down is not charged; games from before the game clock + // get a new clock. + public static BackendGame fromSnapshot(LiveGameSnapshot snapshot, long now, long gameClockMs) { + MancalaGame mancalaGame = new CommonMancalaGame(snapshot.id(), snapshot.player1Id(), snapshot.player2Id()); + List pits = mancalaGame.getBoard().getPits(); + for (int index = 0; index < pits.size(); index++) pits.get(index).setStoneCount(snapshot.board().get(index)); + mancalaGame.setTurnPlayerId(snapshot.turnPlayerId()); + mancalaGame.setState(snapshot.state()); + mancalaGame.getHistory().addAll(snapshot.history()); + Map players = snapshot.players(); + PlayerSummary player1 = players.getOrDefault(snapshot.player1Id(), PlayerSummary.unknown(snapshot.player1Id())); + PlayerSummary player2 = players.getOrDefault(snapshot.player2Id(), PlayerSummary.unknown(snapshot.player2Id())); + GameClock clock = snapshot.clock() != null ? GameClocks.resume(snapshot.clock(), now) : GameClocks.start(gameClockMs, now); + List moveRemainingMs = snapshot.moveRemainingMs() != null ? snapshot.moveRemainingMs() + : Collections.nCopies(snapshot.history().size(), null); + 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); + } + + // Null when the game ended without a winner and was not completed, e.g. both players left. + public static GameResult getResult(BackendGame game, GameEnding ending) { + if (game.mancalaGame.getPlayer1Id().equals(ending.winnerId())) return GameResult.PLAYER1; + if (game.mancalaGame.getPlayer2Id().equals(ending.winnerId())) return GameResult.PLAYER2; + return ending.endReason() == EndReason.COMPLETED ? GameResult.DRAW : null; + } + + // The record saved for a finished game; every id in it is a public id. + public static GameRecord toGameRecord(BackendGame game, GameEnding ending, long endedAt) { + MancalaGame mancalaGame = game.mancalaGame; + List history = mancalaGame.getHistory(); + List moves = new ArrayList<>(history.size()); + for (int i = 0; i < history.size(); i++) { + MoveHistoryItem entry = history.get(i); + moves.add(new MoveRecord(PublicIds.publicUserId(entry.getPlayerId()), entry.getMoveIndex(), + boxed(entry.getBoardSnapshot()), List.copyOf(entry.getGameSteps()), + i < game.moveTimes.size() ? game.moveTimes.get(i) : endedAt, + i < game.moveRemainingMs.size() ? game.moveRemainingMs.get(i) : null)); + } + return new GameRecord(game.id, publicSummary(game, mancalaGame.getPlayer1Id()), + publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()), + getResult(game, ending), ending.endReason(), + ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt, + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size()); + } + + // The player with a public id, for anything saved or listed. + static PlayerSummary publicSummary(BackendGame game, String userId) { + PlayerSummary player = game.player(userId); + return new PlayerSummary(PublicIds.publicUserId(userId), player.name(), player.isGuest()); + } + + private static List orEmpty(List list) { + return list != null ? list : List.of(); + } + + static List boxed(int[] values) { + return Arrays.stream(values).boxed().toList(); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java new file mode 100644 index 0000000..2387f78 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/GameStore.java @@ -0,0 +1,48 @@ +package app.playmancala.game; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +// 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. + private final Map games = new LinkedHashMap<>(); + private final Map gameIdsByUser = new HashMap<>(); + + public BackendGame get(String id) { + return games.get(id); + } + + public List getAll() { + return new ArrayList<>(games.values()); + } + + public void set(String id, BackendGame game) { + games.put(id, game); + } + + public void remove(String id) { + games.remove(id); + } + + public void setGameByUser(String userId, BackendGame game) { + gameIdsByUser.put(userId, game.id); + } + + // The game the user plays in, or null. + public BackendGame getGameByUser(String userId) { + String gameId = gameIdsByUser.get(userId); + return gameId == null ? null : games.get(gameId); + } + + public void removeGameByPlayer(String userId) { + gameIdsByUser.remove(userId); + } + + public List getSpectatingGameIdsByUser(String userId) { + return games.values().stream().filter(game -> game.spectatorIds.contains(userId)).map(game -> game.id).toList(); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java new file mode 100644 index 0000000..440f886 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/LiveGames.java @@ -0,0 +1,93 @@ +package app.playmancala.game; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import app.playmancala.loop.Cancellable; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Channels; +import app.playmancala.model.Json; +import app.playmancala.model.LiveGameSummary; +import app.playmancala.rtmt.Rtmt; + +// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games +// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a +// spectator. Only touched on the game loop. +public class LiveGames { + public static final int LIVE_GAMES_PUSH_COUNT = 5; + private static final long PUSH_INTERVAL_MS = 1_000; + + private final Rtmt rtmt; + private final GameStore gameStore; + private final Scheduler scheduler; + private final Set subscribers = new LinkedHashSet<>(); + private String lastPushed = ""; + private long lastPushAt; + private Cancellable pushTimer; + + public LiveGames(Rtmt rtmt, GameStore gameStore, GameManager gameManager, Scheduler scheduler) { + this.rtmt = rtmt; + this.gameStore = gameStore; + this.scheduler = scheduler; + rtmt.listenMessage(Channels.SUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribe(userId)); + rtmt.listenMessage(Channels.UNSUBSCRIBE_LIVE_GAMES, (userId, message) -> subscribers.remove(userId)); + rtmt.listenOnClientConnectionChange((userId, isOnline) -> { + if (!isOnline) subscribers.remove(userId); + }); + gameManager.onLiveGamesChanged(this::schedulePush); + } + + public static LiveGameSummary toLiveGameSummary(BackendGame game) { + var mancalaGame = game.mancalaGame; + return new LiveGameSummary(game.id, + GameSerialization.publicSummary(game, mancalaGame.getPlayer1Id()), + GameSerialization.publicSummary(game, mancalaGame.getPlayer2Id()), + GameSerialization.boxed(mancalaGame.getBoard().getStoneArray()), + PublicIds.publicUserId(mancalaGame.getTurnPlayerId()), game.spectatorIds.size(), + mancalaGame.getHistory().size(), game.startedAt); + } + + private static long lastActivity(BackendGame game) { + return game.moveTimes.isEmpty() ? game.startedAt : game.moveTimes.getLast(); + } + + public List getTop(int limit) { + return gameStore.getAll().stream() + .sorted(Comparator.comparingInt(game -> game.spectatorIds.size()) + .thenComparingLong(LiveGames::lastActivity).reversed()) + .limit(limit) + .map(LiveGames::toLiveGameSummary) + .toList(); + } + + public void dispose() { + if (pushTimer != null) pushTimer.cancel(); + } + + private void subscribe(String userId) { + subscribers.add(userId); + rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, Map.of("games", getTop(LIVE_GAMES_PUSH_COUNT))); + } + + private void schedulePush() { + if (pushTimer != null) return; + long wait = Math.max(0, lastPushAt + PUSH_INTERVAL_MS - scheduler.now()); + pushTimer = scheduler.schedule(() -> { + pushTimer = null; + push(); + }, wait); + } + + private void push() { + List games = getTop(LIVE_GAMES_PUSH_COUNT); + String serialized = Json.write(games); + if (serialized.equals(lastPushed)) return; + lastPushed = serialized; + lastPushAt = scheduler.now(); + Map message = Map.of("games", games); + List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message)); + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java new file mode 100644 index 0000000..e9c87fd --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/MatchMaker.java @@ -0,0 +1,39 @@ +package app.playmancala.game; + +import java.util.function.BiConsumer; + +import app.playmancala.rtmt.Rtmt; + +// Pairs the next two players who ask for a game. Only touched on the game loop. +public class MatchMaker { + private final Rtmt rtmt; + private String waitingPlayerId; + private BiConsumer onPlayersPaired = (player1Id, player2Id) -> { }; + + public MatchMaker(Rtmt rtmt) { + this.rtmt = rtmt; + } + + public void join(String newcomerPlayerId) { + if (newcomerPlayerId.equals(waitingPlayerId)) return; + if (waitingPlayerId != null && rtmt.isClientOnline(waitingPlayerId)) { + String waiting = waitingPlayerId; + waitingPlayerId = null; + onPlayersPaired.accept(waiting, newcomerPlayerId); + } else { + waitingPlayerId = newcomerPlayerId; + } + } + + public boolean cancel(String playerId) { + if (playerId.equals(waitingPlayerId)) { + waitingPlayerId = null; + return true; + } + return false; + } + + public void listenOnPlayersPaired(BiConsumer onPlayersPaired) { + this.onPlayersPaired = onPlayersPaired; + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java new file mode 100644 index 0000000..9ee5652 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/game/PublicIds.java @@ -0,0 +1,29 @@ +package app.playmancala.game; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import app.playmancala.auth.KeycloakTokenVerifier; + +// 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. +public final class PublicIds { + private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; + + private PublicIds() { + } + + public static String publicUserId(String userId) { + if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) { + return userId; + } + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(userId.getBytes(StandardCharsets.UTF_8)); + return LEGACY_PUBLIC_ID_PREFIX + HexFormat.of().formatHex(hash).substring(0, 20); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java b/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java new file mode 100644 index 0000000..c9ae70f --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/history/GameHistoryRepository.java @@ -0,0 +1,22 @@ +package app.playmancala.history; + +import java.util.List; + +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; + +// Saved, finished games. Ids in records are public ids (see PublicIds). Methods block and throw when the database +// is unreachable. +public interface GameHistoryRepository { + // Saving the same game twice is a no-op, so failed saves can be retried. + void save(GameRecord record); + + // Null if there is no such game. + GameRecord get(String id); + + // The user's finished games, most recent first. + List getRecentForUser(String userId, int limit); + + // Finished games with the most spectators, most recent first among equals. + List getPopular(int limit); +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java b/apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java new file mode 100644 index 0000000..a843497 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/live/LiveGameStore.java @@ -0,0 +1,24 @@ +package app.playmancala.live; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.GameRecord; + +// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished +// games that could not be saved to the database yet. Methods block and throw when Valkey is unreachable. +public interface LiveGameStore { + void saveGame(String gameId, Object snapshot); + + void removeGame(String gameId); + + // As stored; the caller checks what it can use. + List loadGames(); + + void savePendingRecord(GameRecord record); + + void removePendingRecord(String gameId); + + List loadPendingRecords(); +} diff --git a/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java new file mode 100644 index 0000000..6a57eb2 --- /dev/null +++ b/apps/backend-quarkus/src/main/java/app/playmancala/rtmt/Rtmt.java @@ -0,0 +1,24 @@ +package app.playmancala.rtmt; + +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.AuthUser; + +// Real-time messaging with connected users, as the game logic sees it. Call it on the game loop only. +public interface Rtmt { + // Sends to every socket of the user; nothing happens if the user is offline. + void sendMessage(String clientId, String channel, Object message); + + // One listener per channel; it gets the sender's user id and the message (JSON null if none was sent). + void listenMessage(String channel, BiConsumer callback); + + boolean isClientOnline(String clientId); + + // Null if the user is not connected. + AuthUser getUser(String clientId); + + // Adds a listener; every listener is called, also for each further socket of a user who is already online. + void listenOnClientConnectionChange(BiConsumer listener); +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java new file mode 100644 index 0000000..111f98a --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/FakeRtmt.java @@ -0,0 +1,82 @@ +package app.playmancala.fakes; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.NullNode; + +import app.playmancala.model.AuthUser; +import app.playmancala.model.Json; +import app.playmancala.rtmt.Rtmt; + +// In-memory Rtmt: tests connect users, send messages as them and inspect what the server sent back. +public class FakeRtmt implements Rtmt { + public record SentMessage(String clientId, String channel, JsonNode message) { + } + + public final List sent = new ArrayList<>(); + private final Map> listeners = new HashMap<>(); + private final Map online = new LinkedHashMap<>(); + private final List> connectionListeners = new ArrayList<>(); + + public void connect(AuthUser user) { + online.put(user.id(), user); + connectionListeners.forEach(listener -> listener.accept(user.id(), true)); + } + + public void disconnect(String userId) { + online.remove(userId); + connectionListeners.forEach(listener -> listener.accept(userId, false)); + } + + public void receive(String userId, String channel) { + receive(userId, channel, Map.of()); + } + + public void receive(String userId, String channel, Object message) { + BiConsumer listener = listeners.get(channel); + if (listener == null) throw new IllegalStateException("No listener for " + channel); + listener.accept(userId, message == null ? NullNode.getInstance() : Json.tree(message)); + } + + public List messagesTo(String userId, String channel) { + return sent.stream().filter(m -> m.clientId().equals(userId) && m.channel().equals(channel)) + .map(SentMessage::message).toList(); + } + + public JsonNode lastMessageTo(String userId, String channel) { + List messages = messagesTo(userId, channel); + return messages.isEmpty() ? null : messages.getLast(); + } + + @Override + public void sendMessage(String clientId, String channel, Object message) { + // Serialised at once, like sending over the wire. + sent.add(new SentMessage(clientId, channel, Json.tree(message))); + } + + @Override + public void listenMessage(String channel, BiConsumer callback) { + listeners.put(channel, callback); + } + + @Override + public boolean isClientOnline(String clientId) { + return online.containsKey(clientId); + } + + @Override + public AuthUser getUser(String clientId) { + return online.get(clientId); + } + + @Override + public void listenOnClientConnectionChange(BiConsumer listener) { + connectionListeners.add(listener); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java new file mode 100644 index 0000000..5d3bf27 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryGameHistoryRepository.java @@ -0,0 +1,48 @@ +package app.playmancala.fakes; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import app.playmancala.history.GameHistoryRepository; +import app.playmancala.model.FinishedGameSummary; +import app.playmancala.model.GameRecord; + +// Lists leave out the moves, like the Postgres repository. +public class InMemoryGameHistoryRepository implements GameHistoryRepository { + public final Map records = new LinkedHashMap<>(); + public boolean failing; + + private void check() { + if (failing) throw new IllegalStateException("database is down"); + } + + @Override + public void save(GameRecord record) { + check(); + records.putIfAbsent(record.id(), record); + } + + @Override + public GameRecord get(String id) { + check(); + return records.get(id); + } + + @Override + public List getRecentForUser(String userId, int limit) { + check(); + return records.values().stream().filter(record -> record.hasPlayer(userId)) + .sorted(Comparator.comparingLong(GameRecord::endedAt).reversed()) + .limit(limit).map(GameRecord::toSummary).toList(); + } + + @Override + public List getPopular(int limit) { + check(); + return records.values().stream() + .sorted(Comparator.comparingInt(GameRecord::peakSpectators).thenComparingLong(GameRecord::endedAt).reversed()) + .limit(limit).map(GameRecord::toSummary).toList(); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java new file mode 100644 index 0000000..2b3f788 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/fakes/InMemoryLiveGameStore.java @@ -0,0 +1,58 @@ +package app.playmancala.fakes; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.live.LiveGameStore; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; + +public class InMemoryLiveGameStore implements LiveGameStore { + public final Map games = new LinkedHashMap<>(); + public final Map pending = new LinkedHashMap<>(); + public boolean failing; + + private void check() { + if (failing) throw new IllegalStateException("Valkey is down"); + } + + @Override + public void saveGame(String gameId, Object snapshot) { + check(); + games.put(gameId, Json.tree(snapshot)); + } + + @Override + public void removeGame(String gameId) { + check(); + games.remove(gameId); + } + + @Override + public List loadGames() { + check(); + return new ArrayList<>(games.values()); + } + + @Override + public void savePendingRecord(GameRecord record) { + check(); + pending.put(record.id(), record); + } + + @Override + public void removePendingRecord(String gameId) { + check(); + pending.remove(gameId); + } + + @Override + public List loadPendingRecords() { + check(); + return new ArrayList<>(pending.values()); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java new file mode 100644 index 0000000..6730c82 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameClockSchedulerTest.java @@ -0,0 +1,29 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import app.playmancala.loop.ManualScheduler; + +class GameClockSchedulerTest { + @Test + void endsGamesOutOfTimeOnEveryIntervalUntilStopped() { + ManualScheduler scheduler = new ManualScheduler(0); + AtomicInteger calls = new AtomicInteger(); + GameClockScheduler clockScheduler = new GameClockScheduler(calls::incrementAndGet, scheduler, 1_000); + clockScheduler.start(); + clockScheduler.start(); // starting twice does not run it twice + + scheduler.advance(999); + assertEquals(0, calls.get()); + scheduler.advance(2_001); + assertEquals(3, calls.get()); + + clockScheduler.stop(); + scheduler.advance(10_000); + assertEquals(3, calls.get()); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java new file mode 100644 index 0000000..d925a3b --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/game/GameManagerTest.java @@ -0,0 +1,606 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +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.GameResult; +import app.playmancala.model.Json; +import app.playmancala.model.MoveRecord; + +// Ported from the Node backend's tests/game/GameManager.test.ts. +class GameManagerTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser BOB = new AuthUser("kc:bob", "guest-1a2b3c4d", true); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final AuthUser DAVE = new AuthUser("kc:dave", "dave", false); + static final AuthUser LEGACY = new AuthUser("0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", null, true); + static final long CLOCK_MS = 5 * 60_000; + + long now; + FakeRtmt rtmt; + GameStore gameStore; + InMemoryLiveGameStore liveGameStore; + InMemoryGameHistoryRepository repository; + GameManager gameManager; + + // Saves run at once, so the tests need no waiting. + GameRecorder createManager() { + 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, + () -> now, CLOCK_MS); + return recorder; + } + + @BeforeEach + void setUp() { + now = Instant.parse("2026-10-16T12:00:00Z").toEpochMilli(); + liveGameStore = new InMemoryLiveGameStore(); + repository = new InMemoryGameHistoryRepository(); + createManager(); + List.of(ALICE, BOB, CAROL, DAVE).forEach(rtmt::connect); + } + + BackendGame startGame() { + return startGame(ALICE, BOB); + } + + BackendGame startGame(AuthUser first, AuthUser second) { + rtmt.receive(first.id(), Channels.NEW_GAME); + rtmt.receive(second.id(), Channels.NEW_GAME); + BackendGame game = gameStore.getGameByUser(first.id()); + assertNotNull(game, "game did not start"); + return game; + } + + // Plays the current player's first non-empty pit. + void playAnyMove(BackendGame game) { + String turnPlayerId = game.mancalaGame.getTurnPlayerId(); + List pits = game.mancalaGame.getBoard().getPits(); + for (int pit = 0; pit < 6; pit++) { + if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(turnPlayerId, pit)).getStoneCount() > 0) { + rtmt.receive(turnPlayerId, Channels.GAME_MOVE, Map.of("index", pit)); + return; + } + } + throw new IllegalStateException("no move"); + } + + // Leaves the given player a single stone next to their store; playing it empties their side and ends the game. + static void setUpLastMove(BackendGame game, String playerId) { + boolean isPlayer1 = game.mancalaGame.getPlayer1Id().equals(playerId); + 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); + game.mancalaGame.setTurnPlayerId(playerId); + } + + static String opponentOf(String playerId) { + return playerId.equals(ALICE.id()) ? BOB.id() : ALICE.id(); + } + + static boolean isPlayer1(BackendGame game, String playerId) { + return game.mancalaGame.getPlayer1Id().equals(playerId); + } + + static long timeOf(GameClock clock, BackendGame game, String playerId) { + return isPlayer1(game, playerId) ? clock.player1Ms() : clock.player2Ms(); + } + + // The clock with the given times for the two players, in their seats. + static GameClock clockOf(BackendGame game, String playerId, long playerMs, long opponentMs, Long turnStartedAt) { + return isPlayer1(game, playerId) + ? new GameClock(CLOCK_MS, playerMs, opponentMs, turnStartedAt) + : new GameClock(CLOCK_MS, opponentMs, playerMs, turnStartedAt); + } + + static GameResult seatOf(BackendGame game, String playerId) { + return isPlayer1(game, playerId) ? GameResult.PLAYER1 : GameResult.PLAYER2; + } + + @Nested + class WhileAGameIsPlayed { + @Test + void sendsTheConnectionInfoOnceWhenAGameStarts() { + startGame(); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_START).size()); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_USER_CONNECTION_CHANGE).size()); + } + + @Test + void doesNotSendSpectatorIdsToClients() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + playAnyMove(game); + JsonNode update = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_UPDATE); + assertFalse(update.has("spectatorIds")); + assertFalse(update.has("allSpectatorIds")); + assertEquals(1, update.get("spectatorCount").asInt()); + } + + @Test + void recordsTheTimeOfEachMove() { + BackendGame game = startGame(); + now += 1000; + playAnyMove(game); + now += 2000; + playAnyMove(game); + assertEquals(List.of(game.startedAt + 1000, game.startedAt + 3000), game.moveTimes); + assertEquals(2, rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_UPDATE).get("moveTimes").size()); + } + + @Test + void rejectsANewGameForAPlayerWhoHasOneInProgress() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), + rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED)); + assertSame(game, gameStore.getGameByUser(ALICE.id())); + assertNull(gameStore.getGameByUser(CAROL.id())); + } + + @Test + void countsSpectatorsRemovedOnDisconnectPeakAndDistinctTotalKept() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.receive(DAVE.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.disconnect(CAROL.id()); + rtmt.connect(CAROL); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.disconnect(DAVE.id()); + assertEquals(List.of(CAROL.id()), game.spectatorIds); + assertEquals(2, game.peakSpectators); + + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + GameRecord record = repository.records.get(game.id); + assertEquals(2, record.peakSpectators()); + assertEquals(2, record.totalSpectators()); + } + + @Test + void keepsASnapshotOfTheLiveGameUntilItEnds() { + BackendGame game = startGame(); + playAnyMove(game); + JsonNode snapshot = liveGameStore.games.get(game.id); + assertEquals(game.id, snapshot.get("id").asText()); + assertEquals(Json.tree(List.of(now)), snapshot.get("moveTimes")); + + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertFalse(liveGameStore.games.containsKey(game.id)); + } + + @Test + void sendsTheGameAsTheNodeBackendDid() { + BackendGame game = startGame(); + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes", + "spectatorCount", "clock", "serverTime"), fieldNames(start)); + assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history", + "currentHistoryItem"), fieldNames(start.get("mancalaGame"))); + assertEquals("initial", start.at("/mancalaGame/state").asText()); + assertTrue(start.at("/mancalaGame/currentHistoryItem").isNull()); + JsonNode alice = isPlayer1(game, ALICE.id()) ? start.at("/gameUsersConnectionInfo/user1ConnectionInfo") + : start.at("/gameUsersConnectionInfo/user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", ALICE.id(), "name", "alice", "isGuest", false, "isOnline", true)), alice); + } + + @Test + void anInvalidMoveIsReportedAsACrashAndChangesNothing() { + BackendGame game = startGame(); + String waiting = opponentOf(game.mancalaGame.getTurnPlayerId()); + rtmt.receive(waiting, Channels.GAME_MOVE, Map.of("index", 0)); + assertEquals(List.of(Json.tree(Map.of())), rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_CRASHED)); + assertEquals(0, game.mancalaGame.getHistory().size()); + assertSame(game, gameStore.get(game.id)); + } + } + + @Nested + class Clock { + @Test + void startsWithTheFullTimeForBothPlayersRunningForThePlayerWhoMovesFirst() { + BackendGame game = startGame(); + assertEquals(new GameClock(CLOCK_MS, CLOCK_MS, CLOCK_MS, now), game.clock); + JsonNode start = rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_START).getFirst(); + assertEquals(Json.tree(game.clock), start.get("clock")); + assertEquals(now, start.get("serverTime").asLong()); + } + + @Test + void aMoveDeductsTheTurnsTimeFromTheMoverOnlyAndStartsTheOpponentsClock() { + BackendGame game = startGame(); + String first = game.mancalaGame.getTurnPlayerId(); + now += 7_000; + playAnyMove(game); + assertEquals(opponentOf(first), game.mancalaGame.getTurnPlayerId()); + assertEquals(CLOCK_MS - 7_000, timeOf(game.clock, game, first)); + assertEquals(CLOCK_MS, timeOf(game.clock, game, opponentOf(first))); + assertEquals(now, game.clock.turnStartedAt()); + JsonNode update = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_UPDATE); + assertEquals(Json.tree(game.clock), update.get("clock")); + assertEquals(now, update.get("serverTime").asLong()); + + now += 3_000; + playAnyMove(game); + assertEquals(CLOCK_MS - 3_000, timeOf(game.clock, game, opponentOf(first))); + assertEquals(List.of(CLOCK_MS - 7_000, CLOCK_MS - 3_000), game.moveRemainingMs); + } + + @Test + void anExtraTurnKeepsTheMoversClockRunning() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += 2_000; + // Four stones from the fourth pit: the last one lands in the mover's store. + rtmt.receive(mover, Channels.GAME_MOVE, Map.of("index", 3)); + assertEquals(mover, game.mancalaGame.getTurnPlayerId()); + now += 1_000; + playAnyMove(game); + assertEquals(List.of(CLOCK_MS - 2_000, CLOCK_MS - 3_000), game.moveRemainingMs); + } + + @Test + void aMoveWithTimeLeftIsPlayed() { + BackendGame game = startGame(); + now += CLOCK_MS - 1; + playAnyMove(game); + assertEquals(1, game.mancalaGame.getHistory().size()); + assertEquals(List.of(1L), game.moveRemainingMs); + } + + @Test + void aMoveThatArrivesAfterTheTimeHasRunOutIsNotPlayedAndTheOpponentWins() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += CLOCK_MS; + playAnyMove(game); + + assertEquals(0, game.mancalaGame.getHistory().size()); + assertNull(gameStore.get(game.id)); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.TIMEOUT, record.endReason()); + assertEquals(seatOf(game, opponentOf(mover)), record.result()); + JsonNode end = rtmt.messagesTo(mover, Channels.ON_GAME_END).getFirst(); + assertEquals("timeout", end.get("endReason").asText()); + assertEquals(opponentOf(mover), end.get("winnerId").asText()); + assertEquals(Json.tree(clockOf(game, mover, 0, CLOCK_MS, null)), end.get("clock")); + } + + @Test + void stopsWhenTheGameEndsAfterDeductingTheRunningPlayersTime() { + BackendGame game = startGame(); + String mover = game.mancalaGame.getTurnPlayerId(); + now += 4_000; + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(clockOf(game, mover, CLOCK_MS - 4_000, CLOCK_MS, null), game.clock); + assertEquals(Json.tree(game.clock), rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END).getFirst().get("clock")); + } + + @Test + void stopsWhenTheGameIsCompletedByTheRules() { + BackendGame game = startGame(); + playAnyMove(game); + setUpLastMove(game, ALICE.id()); + now += 1_000; + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5)); + assertNull(game.clock.turnStartedAt()); + assertEquals(Json.tree(game.clock), rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END).getFirst().get("clock")); + } + } + + @Nested + class EndingGamesOnTime { + @Test + void endsOnlyTheGamesWhoseRunningClockHasReachedZeroTellingPlayersAndSpectators() { + BackendGame late = startGame(ALICE, BOB); + rtmt.receive(DAVE.id(), Channels.LISTEN_GAME_EVENTS, late.id); + now += 60_000; + AuthUser erin = new AuthUser("kc:erin", "erin", false); + rtmt.connect(erin); + BackendGame early = startGame(CAROL, erin); + String latePlayer = late.mancalaGame.getTurnPlayerId(); + + now += CLOCK_MS - 60_000 - 1; + gameManager.endGamesOutOfTime(); + assertSame(late, gameStore.get(late.id)); + + now += 1; + gameManager.endGamesOutOfTime(); + assertNull(gameStore.get(late.id)); + assertSame(early, gameStore.get(early.id)); + GameResult winnerSeat = seatOf(late, opponentOf(latePlayer)); + GameRecord record = repository.records.get(late.id); + assertEquals(EndReason.TIMEOUT, record.endReason()); + assertEquals(winnerSeat, record.result()); + for (AuthUser user : List.of(ALICE, BOB, DAVE)) { + List ends = rtmt.messagesTo(user.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + assertEquals(late.id, ends.getFirst().get("gameId").asText()); + assertEquals("timeout", ends.getFirst().get("endReason").asText()); + assertEquals(Json.tree(winnerSeat), ends.getFirst().get("result")); + } + } + + @Test + void keepsTheClockRunningWhilePlayersAreOfflineSoGamesNobodyComesBackToEndToo() { + BackendGame game = startGame(); + rtmt.disconnect(ALICE.id()); + rtmt.disconnect(BOB.id()); + now += CLOCK_MS; + gameManager.endGamesOutOfTime(); + assertEquals(EndReason.TIMEOUT, repository.records.get(game.id).endReason()); + } + + @Test + void tellsThePlayersWhenAPlayerWentOffline() { + startGame(); + rtmt.disconnect(BOB.id()); + JsonNode info = rtmt.lastMessageTo(ALICE.id(), Channels.ON_USER_CONNECTION_CHANGE); + JsonNode bob = info.get("user1ConnectionInfo").get("userId").asText().equals(BOB.id()) + ? info.get("user1ConnectionInfo") : info.get("user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", BOB.id(), "name", BOB.name(), "isGuest", true, "isOnline", false)), bob); + } + } + + @Nested + class HowGamesEnd { + @Test + void completedTheWinnerByTheRulesWithEveryMoveSaved() { + BackendGame game = startGame(); + playAnyMove(game); + setUpLastMove(game, ALICE.id()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5)); + + // Alice's stone goes into her store and empties her side, so she also collects Bob's last stone: 2-0. + GameResult aliceSeat = seatOf(game, ALICE.id()); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.COMPLETED, record.endReason()); + assertEquals(aliceSeat, record.result()); + assertEquals(2, record.moves().size()); + assertEquals(game.moveTimes, record.moves().stream().map(MoveRecord::at).toList()); + assertNull(gameStore.get(game.id)); + List ends = rtmt.messagesTo(BOB.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + ObjectNode end = ends.getFirst().deepCopy(); + assertTrue(end.remove("clock").isObject()); + assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed", + "winnerId", ALICE.id())), end); + } + + @Test + void leftThePlayerWhoLeavesLoses() { + BackendGame game = startGame(); + rtmt.receive(CAROL.id(), Channels.LISTEN_GAME_EVENTS, game.id); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + + GameRecord record = repository.records.get(game.id); + GameResult bobSeat = seatOf(game, BOB.id()); + assertEquals(EndReason.LEFT, record.endReason()); + assertEquals(bobSeat, record.result()); + assertEquals(ALICE.id(), record.leftUserId()); + for (AuthUser user : List.of(ALICE, BOB, CAROL)) { + assertEquals(List.of(Json.tree(ALICE.id())), rtmt.messagesTo(user.id(), Channels.ON_GAME_USER_LEAVE)); + List ends = rtmt.messagesTo(user.id(), Channels.ON_GAME_END); + assertEquals(1, ends.size()); + ObjectNode end = ends.getFirst().deepCopy(); + assertTrue(end.remove("clock").isObject()); + assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left", + "winnerId", BOB.id(), "leftUserId", ALICE.id())), end); + } + } + } + + @Nested + class Saving { + @Test + void savesTheInitialTimeAndEachMovesTimeLeft() { + BackendGame game = startGame(); + now += 4_000; + playAnyMove(game); + now += 6_000; + playAnyMove(game); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + + GameRecord record = repository.records.get(game.id); + assertEquals(CLOCK_MS, record.clockInitialMs()); + assertEquals(List.of(CLOCK_MS - 4_000, CLOCK_MS - 6_000), record.moves().stream().map(MoveRecord::remainingMs).toList()); + } + + @Test + void replacesLegacyIdsWithPublicIdsInSavedGames() { + rtmt.connect(LEGACY); + BackendGame game = startGame(ALICE, LEGACY); + playAnyMove(game); + rtmt.receive(LEGACY.id(), Channels.LEAVE_GAME); + + String saved = Json.write(repository.records.get(game.id)); + assertFalse(saved.contains(LEGACY.id())); + assertTrue(saved.contains(PublicIds.publicUserId(LEGACY.id()))); + assertTrue(saved.contains(ALICE.id())); + } + + @Test + void keepsAGameThatCouldNotBeSavedAndSavesItOnceTheDatabaseIsBack() { + repository.failing = true; + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertTrue(liveGameStore.pending.containsKey(game.id)); + + // A restarted backend picks the pending record up from Valkey. + repository.failing = false; + GameRecorder recorder = createManager(); + recorder.retryPending(); + assertTrue(repository.records.containsKey(game.id)); + assertFalse(liveGameStore.pending.containsKey(game.id)); + } + + @Test + void keepsGamesPlayableWhileValkeyAndTheDatabaseAreDown() { + liveGameStore.failing = true; + repository.failing = true; + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(2, game.mancalaGame.getHistory().size()); + assertNull(gameStore.get(game.id)); + } + } + + @Nested + class Restart { + @Test + void restoresLiveGamesWhichCanThenBePlayedOn() { + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + int[] board = game.mancalaGame.getBoard().getStoneArray(); + String turnPlayer = game.mancalaGame.getTurnPlayerId(); + + createManager(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(game.id, restored.id); + assertArrayEquals(board, restored.mancalaGame.getBoard().getStoneArray()); + assertEquals(turnPlayer, restored.mancalaGame.getTurnPlayerId()); + assertEquals(2, restored.mancalaGame.getHistory().size()); + assertSame(restored, gameStore.getGameByUser(BOB.id())); + assertEquals(Json.tree(game.mancalaGame.getHistory()), Json.tree(restored.mancalaGame.getHistory())); + + rtmt.connect(ALICE); + rtmt.connect(BOB); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size()); + playAnyMove(restored); + assertEquals(3, restored.mancalaGame.getHistory().size()); + } + + @Test + void restoresTheClockWithoutChargingTheTimeTheBackendWasDown() { + BackendGame game = startGame(); + String first = game.mancalaGame.getTurnPlayerId(); + now += 10_000; + playAnyMove(game); + now += 5_000; // the second player thinks, then the backend goes down for a minute + now += 60_000; + + createManager(); + gameManager.restoreLiveGames(); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(clockOf(game, first, CLOCK_MS - 10_000, CLOCK_MS, now), restored.clock); + assertEquals(List.of(CLOCK_MS - 10_000), restored.moveRemainingMs); + + now += 1_000; + playAnyMove(restored); + assertEquals(CLOCK_MS - 1_000, timeOf(restored.clock, game, opponentOf(first))); + } + + @Test + void givesGamesFromBeforeTheGameClockANewClock() { + BackendGame game = startGame(); + playAnyMove(game); + playAnyMove(game); + ObjectNode snapshot = liveGameStore.games.get(game.id).deepCopy(); + snapshot.remove(List.of("clock", "moveRemainingMs")); + snapshot.put("version", 1); + liveGameStore.games.put(game.id, snapshot); + + now += 60_000; + createManager(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(ALICE.id()); + assertEquals(new GameClock(CLOCK_MS, CLOCK_MS, CLOCK_MS, now), restored.clock); + assertEquals(Arrays.asList(null, null), restored.moveRemainingMs); + + now += 2_000; + playAnyMove(restored); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + GameRecord record = repository.records.get(game.id); + assertEquals(CLOCK_MS, record.clockInitialMs()); + assertEquals(Arrays.asList(null, null, CLOCK_MS - 2_000), + record.moves().stream().map(MoveRecord::remainingMs).toList()); + } + + @Test + void doesNotBringBackAGameThatHasAlreadyBeenSaved() { + BackendGame game = startGame(); + JsonNode snapshot = liveGameStore.games.get(game.id); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + liveGameStore.games.put(game.id, snapshot); // as if removing it had failed + + createManager(); + assertEquals(0, gameManager.restoreLiveGames()); + assertFalse(liveGameStore.games.containsKey(game.id)); + } + + @Test + void restoresASnapshotWrittenByTheNodeBackend() { + // Written by the Node backend after one move; ids and times as JSON.stringify left them. + String snapshot = """ + {"version":2,"id":"5c0b7d0e-8a2f-4c8e-9a51-3f0c2b6d8e11","player1Id":"kc:alice","player2Id":"kc:bob", + "turnPlayerId":"kc:bob","state":"playing","board":[0,5,5,5,5,4,0,4,4,4,4,4,4,0], + "history":[{"boardSnapshot":[0,5,5,5,5,4,0,4,4,4,4,4,4,0],"gameSteps":[ + {"index":1,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":2,"type":"GAME_STEP_GAME_MOVE","data":null}, + {"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":4,"type":"GAME_STEP_GAME_MOVE","data":null}], + "playerId":"kc:alice","moveIndex":0}], + "players":{"kc:alice":{"userId":"kc:alice","name":"alice","isGuest":false}, + "kc:bob":{"userId":"kc:bob","name":"guest-1a2b3c4d","isGuest":true}}, + "startedAt":1792152000000,"moveTimes":[1792152003000],"peakSpectators":1,"allSpectatorIds":["kc:carol"], + "clock":{"initialMs":300000,"player1Ms":297000,"player2Ms":300000,"turnStartedAt":1792152003000}, + "moveRemainingMs":[297000]} + """; + try { + liveGameStore.games.put("5c0b7d0e-8a2f-4c8e-9a51-3f0c2b6d8e11", Json.parse(snapshot)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + createManager(); + List.of(ALICE, BOB).forEach(rtmt::connect); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.getGameByUser(BOB.id()); + assertEquals(new GameClock(300_000, 297_000, 300_000, now), restored.clock); + assertEquals(List.of("kc:carol"), restored.allSpectatorIds); + + rtmt.receive(BOB.id(), Channels.GAME_MOVE, Map.of("index", 0)); + assertEquals(2, restored.mancalaGame.getHistory().size()); + // What is written back is what the Node backend wrote, plus the new move. + JsonNode written = liveGameStore.games.get(restored.id); + assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history")); + assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history", + "players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs"), + fieldNames(written)); + } + } + + static List fieldNames(JsonNode node) { + return node.propertyStream().map(Map.Entry::getKey).toList(); + } +} diff --git a/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java b/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java new file mode 100644 index 0000000..037ade6 --- /dev/null +++ b/apps/backend-quarkus/src/test/java/app/playmancala/game/LiveGamesTest.java @@ -0,0 +1,126 @@ +package app.playmancala.game; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +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; + +// Ported from the Node backend's tests/game/LiveGames.test.ts. +class LiveGamesTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser BOB = new AuthUser("kc:bob", "bob", false); + static final AuthUser VIEWER = new AuthUser("kc:viewer", "viewer", false); + static final AuthUser SPECTATOR = new AuthUser("kc:spectator", "spectator", false); + + ManualScheduler scheduler; + FakeRtmt rtmt; + GameStore gameStore; + LiveGames liveGames; + + @BeforeEach + void setUp() { + scheduler = new ManualScheduler(1_000_000); + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + InMemoryLiveGameStore liveGameStore = new InMemoryLiveGameStore(); + GameRecorder recorder = new GameRecorder(new InMemoryGameHistoryRepository(), liveGameStore, Runnable::run); + GameManager gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, + Runnable::run, scheduler::now, 300_000); + liveGames = new LiveGames(rtmt, gameStore, gameManager, scheduler); + List.of(ALICE, BOB, VIEWER, SPECTATOR).forEach(rtmt::connect); + } + + @AfterEach + void tearDown() { + liveGames.dispose(); + } + + List pushes() { + return rtmt.messagesTo(VIEWER.id(), Channels.ON_LIVE_GAMES); + } + + BackendGame startGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(BOB.id(), Channels.NEW_GAME); + return gameStore.getGameByUser(ALICE.id()); + } + + @Test + void sendsTheCurrentGamesOnSubscribe() { + BackendGame game = startGame(); + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + assertEquals(1, pushes().size()); + assertEquals(game.id, pushes().getFirst().at("/games/0/id").asText()); + assertEquals(1, pushes().getFirst().get("games").size()); + } + + @Test + void pushesChangesAtMostOncePerSecond() { + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + BackendGame game = startGame(); + scheduler.advance(0); + assertEquals(2, pushes().size()); + + // Two changes right after a push are sent together, a second later. + rtmt.receive(game.mancalaGame.getTurnPlayerId(), Channels.GAME_MOVE, Map.of("index", 0)); + rtmt.receive(SPECTATOR.id(), Channels.LISTEN_GAME_EVENTS, game.id); + scheduler.advance(999); + assertEquals(2, pushes().size()); + scheduler.advance(1); + assertEquals(3, pushes().size()); + JsonNode pushed = pushes().get(2).at("/games/0"); + assertEquals(1, pushed.get("moveCount").asInt()); + assertEquals(1, pushed.get("spectatorCount").asInt()); + } + + @Test + void doesNotCountSubscribersAsSpectators() { + BackendGame game = startGame(); + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + assertEquals(List.of(), game.spectatorIds); + assertEquals(0, pushes().getFirst().at("/games/0/spectatorCount").asInt()); + } + + @Test + void stopsPushingToUsersWhoUnsubscribeOrGoOffline() { + rtmt.receive(VIEWER.id(), Channels.SUBSCRIBE_LIVE_GAMES); + rtmt.receive(SPECTATOR.id(), Channels.SUBSCRIBE_LIVE_GAMES); + rtmt.receive(VIEWER.id(), Channels.UNSUBSCRIBE_LIVE_GAMES); + rtmt.disconnect(SPECTATOR.id()); + int before = rtmt.sent.size(); + startGame(); + scheduler.advance(1000); + assertEquals(List.of(), rtmt.sent.subList(before, rtmt.sent.size()).stream() + .filter(message -> message.channel().equals(Channels.ON_LIVE_GAMES)).toList()); + } + + @Test + void ordersByMostSpectatedThenMostRecentlyActive() { + AuthUser carol = new AuthUser("kc:carol", "carol", false); + AuthUser dave = new AuthUser("kc:dave", "dave", false); + List.of(carol, dave).forEach(rtmt::connect); + BackendGame older = startGame(); + scheduler.advance(1000); + rtmt.receive(carol.id(), Channels.NEW_GAME); + rtmt.receive(dave.id(), Channels.NEW_GAME); + BackendGame newer = gameStore.getGameByUser(carol.id()); + assertEquals(List.of(newer.id, older.id), liveGames.getTop(5).stream().map(summary -> summary.id()).toList()); + + rtmt.receive(SPECTATOR.id(), Channels.LISTEN_GAME_EVENTS, older.id); + assertEquals(List.of(older.id, newer.id), liveGames.getTop(5).stream().map(summary -> summary.id()).toList()); + assertEquals(1, liveGames.getTop(1).size()); + } +} diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md index 6551a56..10bd8a0 100644 --- a/docs/specs/quarkus-backend/tasks.md +++ b/docs/specs/quarkus-backend/tasks.md @@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. with unit tests; `scripts/record-engine-games.js` and the parity test (R2). - [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. -- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, +- [x] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported unit tests. - [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`,