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`,