feat(backend): port the game logic to Java

GameManager, matchmaking, the game clock, live game snapshots, saving
finished games and live previews, ported from the Node backend with its
tests. Snapshots keep the Node backend's format; a test restores one the
Node backend wrote. Valkey writes and saves run off the game loop.
This commit is contained in:
Halit Aksoy 2026-10-16 11:23:35 +03:00
parent f0246cca61
commit 219b50b491
22 changed files with 2048 additions and 1 deletions

View File

@ -100,6 +100,7 @@
<systemPropertyVariables> <systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager> <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home> <maven.home>${maven.home}</maven.home>
<mancala.crash-dir>${project.build.directory}/crashes</mancala.crash-dir>
</systemPropertyVariables> </systemPropertyVariables>
</configuration> </configuration>
</plugin> </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). 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`, - [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`,
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests. `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 `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported
unit tests. unit tests.
- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, - [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`,