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