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

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
22 changed files with 2048 additions and 1 deletions
Showing only changes of commit 219b50b491 - Show all commits

View File

@ -100,6 +100,7 @@
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
<mancala.crash-dir>${project.build.directory}/crashes</mancala.crash-dir>
</systemPropertyVariables>
</configuration>
</plugin>

View File

@ -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<String> spectatorIds = new ArrayList<>();
// Everyone who watched at some point, and the most at once.
public final List<String> allSpectatorIds;
public int peakSpectators;
// Keyed by user id, captured when the game is created.
public final Map<String, PlayerSummary> players;
public final long startedAt;
// Time of each move, aligned with mancalaGame.history.
public final List<Long> 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<Long> moveRemainingMs;
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
List<Long> moveTimes, GameClock clock, List<Long> 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();
}
}

View File

@ -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<String, Object> 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. "<game id>::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());
}
}
}

View File

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

View File

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

View File

@ -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<Runnable> 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<JsonNode> 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<String, PlayerSummary> 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));
}
});
}
}

View File

@ -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<String, GameRecord> saving = new ConcurrentHashMap<>();
// Failed to save; retried later.
private final Map<String, GameRecord> pending = new ConcurrentHashMap<>();
private final List<Consumer<GameRecord>> 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<GameRecord> 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<GameRecord> getUnsaved() {
List<GameRecord> 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));
}
}

View File

@ -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<Integer> board,
List<MoveHistoryItem> history,
Map<String, PlayerSummary> players,
long startedAt,
List<Long> moveTimes,
int peakSpectators,
List<String> allSpectatorIds,
GameClock clock,
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> 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<Pit> 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<String, PlayerSummary> 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<Long> 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<MoveHistoryItem> history = mancalaGame.getHistory();
List<MoveRecord> 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 <T> List<T> orEmpty(List<T> list) {
return list != null ? list : List.of();
}
static List<Integer> boxed(int[] values) {
return Arrays.stream(values).boxed().toList();
}
}

View File

@ -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<String, BackendGame> games = new LinkedHashMap<>();
private final Map<String, String> gameIdsByUser = new HashMap<>();
public BackendGame get(String id) {
return games.get(id);
}
public List<BackendGame> 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<String> getSpectatingGameIdsByUser(String userId) {
return games.values().stream().filter(game -> game.spectatorIds.contains(userId)).map(game -> game.id).toList();
}
}

View File

@ -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<String> 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<LiveGameSummary> getTop(int limit) {
return gameStore.getAll().stream()
.sorted(Comparator.<BackendGame>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<LiveGameSummary> games = getTop(LIVE_GAMES_PUSH_COUNT);
String serialized = Json.write(games);
if (serialized.equals(lastPushed)) return;
lastPushed = serialized;
lastPushAt = scheduler.now();
Map<String, Object> message = Map.of("games", games);
List.copyOf(subscribers).forEach(userId -> rtmt.sendMessage(userId, Channels.ON_LIVE_GAMES, message));
}
}

View File

@ -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<String, String> 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<String, String> onPlayersPaired) {
this.onPlayersPaired = onPlayersPaired;
}
}

View File

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

View File

@ -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<FinishedGameSummary> getRecentForUser(String userId, int limit);
// Finished games with the most spectators, most recent first among equals.
List<FinishedGameSummary> getPopular(int limit);
}

View File

@ -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<JsonNode> loadGames();
void savePendingRecord(GameRecord record);
void removePendingRecord(String gameId);
List<GameRecord> loadPendingRecords();
}

View File

@ -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<String, JsonNode> 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<String, Boolean> listener);
}

View File

@ -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<SentMessage> sent = new ArrayList<>();
private final Map<String, BiConsumer<String, JsonNode>> listeners = new HashMap<>();
private final Map<String, AuthUser> online = new LinkedHashMap<>();
private final List<BiConsumer<String, Boolean>> 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<String, JsonNode> 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<JsonNode> 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<JsonNode> 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<String, JsonNode> 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<String, Boolean> listener) {
connectionListeners.add(listener);
}
}

View File

@ -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<String, GameRecord> 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<FinishedGameSummary> 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<FinishedGameSummary> getPopular(int limit) {
check();
return records.values().stream()
.sorted(Comparator.comparingInt(GameRecord::peakSpectators).thenComparingLong(GameRecord::endedAt).reversed())
.limit(limit).map(GameRecord::toSummary).toList();
}
}

View File

@ -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<String, JsonNode> games = new LinkedHashMap<>();
public final Map<String, GameRecord> 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<JsonNode> 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<GameRecord> loadPendingRecords() {
check();
return new ArrayList<>(pending.values());
}
}

View File

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

View File

@ -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<Pit> 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<Pit> pits = game.mancalaGame.getBoard().getPits();
pits.forEach(pit -> pit.setStoneCount(0));
pits.get(isPlayer1 ? 5 : 12).setStoneCount(1);
pits.get(isPlayer1 ? 7 : 0).setStoneCount(1);
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<JsonNode> 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<JsonNode> 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<JsonNode> 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<String> fieldNames(JsonNode node) {
return node.propertyStream().map(Map.Entry::getKey).toList();
}
}

View File

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

View File

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