feat(backend): keep the series a live game belongs to

Every game starts a series; a game created in a series gets its seat to
move first. Clients get the series and the first seat with the game, the
end message the series with the ended game; snapshots keep the series.
This commit is contained in:
Halit Aksoy 2026-10-17 02:00:44 +03:00
parent c56d1f2c33
commit ee9e5261eb
9 changed files with 191 additions and 19 deletions

View File

@ -6,9 +6,12 @@ import java.util.Map;
import app.playmancala.bot.BotSettings; import app.playmancala.bot.BotSettings;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.MoveHistoryItem;
import app.playmancala.model.GameClock; import app.playmancala.model.GameClock;
import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.GameUsersConnectionInfo;
import app.playmancala.model.PlayerSummary; import app.playmancala.model.PlayerSummary;
import app.playmancala.model.Seat;
import app.playmancala.model.Series;
// A live game and the server-side state around it. Only touched on the game loop. // A live game and the server-side state around it. Only touched on the game loop.
public class BackendGame { public class BackendGame {
@ -30,10 +33,12 @@ public class BackendGame {
public final List<Long> moveRemainingMs; public final List<Long> moveRemainingMs;
// How the bot plays in a game against the bot; null in games between people. // How the bot plays in a game against the bot; null in games between people.
public final BotSettings bot; public final BotSettings bot;
// The games the same players played before this one (docs/specs/rematch).
public final Series series;
public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt, List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot) { List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot, Series series) {
this.id = mancalaGame.getId(); this.id = mancalaGame.getId();
this.mancalaGame = mancalaGame; this.mancalaGame = mancalaGame;
this.gameUsersConnectionInfo = gameUsersConnectionInfo; this.gameUsersConnectionInfo = gameUsersConnectionInfo;
@ -45,6 +50,14 @@ public class BackendGame {
this.clock = clock; this.clock = clock;
this.moveRemainingMs = new ArrayList<>(moveRemainingMs); this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
this.bot = bot; this.bot = bot;
this.series = series;
}
// The seat that moved first: the first move's player, or the player to move before any move.
public Seat firstSeat() {
List<MoveHistoryItem> history = mancalaGame.getHistory();
String firstPlayerId = history.isEmpty() ? mancalaGame.getTurnPlayerId() : history.getFirst().getPlayerId();
return mancalaGame.getPlayer1Id().equals(firstPlayerId) ? Seat.PLAYER1 : Seat.PLAYER2;
} }
public boolean isBotTurn() { public boolean isBotTurn() {

View File

@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel; import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings; import app.playmancala.bot.BotSettings;
import app.playmancala.engine.Board;
import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.CommonMancalaGame;
import app.playmancala.engine.GameState; import app.playmancala.engine.GameState;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
@ -29,9 +30,12 @@ import app.playmancala.model.ClientGame;
import app.playmancala.model.EndReason; import app.playmancala.model.EndReason;
import app.playmancala.model.GameEndMessage; import app.playmancala.model.GameEndMessage;
import app.playmancala.model.GameRecord; import app.playmancala.model.GameRecord;
import app.playmancala.model.GameResult;
import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.GameUsersConnectionInfo;
import app.playmancala.model.PlayerSummary; import app.playmancala.model.PlayerSummary;
import app.playmancala.model.Seat; import app.playmancala.model.Seat;
import app.playmancala.model.Series;
import app.playmancala.model.SeriesGame;
import app.playmancala.model.UserConnectionInfo; import app.playmancala.model.UserConnectionInfo;
import app.playmancala.rtmt.Rtmt; import app.playmancala.rtmt.Rtmt;
@ -193,8 +197,13 @@ public class GameManager {
if (gameStore.get(game.id) != game) return; if (gameStore.get(game.id) != game) return;
deleteGame(game); deleteGame(game);
game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong()); game.clock = GameClocks.stop(game.clock, getRunningSeat(game), clock.getAsLong());
GameEndMessage endMessage = new GameEndMessage(game.id, GameSerialization.getResult(game, ending), GameResult result = GameSerialization.getResult(game, ending);
ending.endReason(), game.clock, ending.winnerId(), ending.leftUserId()); Board board = game.mancalaGame.getBoard();
int[] stones = board.getStoneArray();
Series series = game.series.next(new SeriesGame(game.id, game.firstSeat(), result, ending.endReason(),
stones[board.player1BankIndex()], stones[board.player2BankIndex()]));
GameEndMessage endMessage = new GameEndMessage(game.id, result, ending.endReason(), game.clock,
ending.winnerId(), ending.leftUserId(), series);
if (ending.leftUserId() != null) { if (ending.leftUserId() != null) {
sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId()); sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId());
} }
@ -313,7 +322,8 @@ public class GameManager {
public ClientGame toClientGame(BackendGame game) { public ClientGame toClientGame(BackendGame game) {
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong(), game.series,
game.firstSeat());
} }
public BackendGame createMancalaGame(String userId1, String userId2) { public BackendGame createMancalaGame(String userId1, String userId2) {
@ -322,8 +332,14 @@ public class GameManager {
// `bot` is set for a game against the bot, which plays as one of the two users. // `bot` is set for a game against the bot, which plays as one of the two users.
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) { public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) {
return createMancalaGame(userId1, userId2, bot, Series.start(), null);
}
// A game in `series`; `firstSeat` moves first, or a player drawn at random when it is null.
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot, Series series, Seat firstSeat) {
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); boolean player1First = firstSeat != null ? firstSeat == Seat.PLAYER1 : ThreadLocalRandom.current().nextBoolean();
mancalaGame.setTurnPlayerId(player1First ? userId1 : userId2);
long now = clock.getAsLong(); long now = clock.getAsLong();
PlayerSummary player1 = getPlayerInfo(userId1); PlayerSummary player1 = getPlayerInfo(userId1);
PlayerSummary player2 = getPlayerInfo(userId2); PlayerSummary player2 = getPlayerInfo(userId2);
@ -331,7 +347,7 @@ public class GameManager {
players.put(userId1, player1); players.put(userId1, player1);
players.put(userId2, player2); players.put(userId2, player2);
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0, BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot); players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot, series);
gameStore.set(game.id, game); gameStore.set(game.id, game);
gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId1, game);
gameStore.setGameByUser(userId2, game); gameStore.setGameByUser(userId2, game);

View File

@ -27,6 +27,7 @@ import app.playmancala.model.GameUsersConnectionInfo;
import app.playmancala.model.Json; import app.playmancala.model.Json;
import app.playmancala.model.MoveRecord; import app.playmancala.model.MoveRecord;
import app.playmancala.model.PlayerSummary; import app.playmancala.model.PlayerSummary;
import app.playmancala.model.Series;
import app.playmancala.model.UserConnectionInfo; import app.playmancala.model.UserConnectionInfo;
// Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend // Live game snapshots (kept in Valkey) and saved records. Snapshots have the Node backend's format, so either backend
@ -58,7 +59,9 @@ public final class GameSerialization {
GameClock clock, GameClock clock,
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs, @JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
// Games against the bot only. // Games against the bot only.
BotSettings bot) { BotSettings bot,
// Missing in snapshots from before rematches.
Series series) {
} }
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { public record GameEnding(EndReason endReason, String winnerId, String leftUserId) {
@ -74,7 +77,7 @@ public final class GameSerialization {
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()), mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock, List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot); Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot, game.series);
} }
// The snapshot in the JSON, or null if it is not one this backend can restore. // The snapshot in the JSON, or null if it is not one this backend can restore.
@ -117,7 +120,8 @@ public final class GameSerialization {
return new BackendGame(mancalaGame, return new BackendGame(mancalaGame,
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(), orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot)); orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot),
snapshot.series() != null ? snapshot.series() : Series.start());
} }
// The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at // The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at

View File

@ -2,10 +2,12 @@ package app.playmancala.model;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
// A live game as players and spectators receive it; leaves out spectator ids and other server-side state. // A live game as players and spectators receive it; leaves out spectator ids and other server-side state.
// It refers to the live MancalaGame, so serialise it on the game loop. // It refers to the live MancalaGame, so serialise it on the game loop. `series` holds the games before this one.
public record ClientGame( public record ClientGame(
String id, String id,
MancalaGame mancalaGame, MancalaGame mancalaGame,
@ -14,5 +16,7 @@ public record ClientGame(
List<Long> moveTimes, List<Long> moveTimes,
int spectatorCount, int spectatorCount,
GameClock clock, GameClock clock,
long serverTime) { long serverTime,
@JsonInclude(JsonInclude.Include.NON_NULL) Series series,
Seat firstSeat) {
} }

View File

@ -4,13 +4,14 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and // on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and
// sent as null) when the game ended without a winner. // sent as null) when the game ended without a winner. `series` includes the game that ended.
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" }) @JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series" })
public record GameEndMessage( public record GameEndMessage(
String gameId, String gameId,
GameResult result, GameResult result,
EndReason endReason, EndReason endReason,
GameClock clock, GameClock clock,
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId, @JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId) { @JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
@JsonInclude(JsonInclude.Include.NON_NULL) Series series) {
} }

View File

@ -0,0 +1,24 @@
package app.playmancala.model;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
// The series a live game belongs to: its id and the games played before this one, oldest first. A game that is not a
// rematch starts a series of its own (docs/specs/rematch).
public record Series(String id, List<SeriesGame> games) {
public Series {
games = List.copyOf(games);
}
public static Series start() {
return new Series(UUID.randomUUID().toString(), List.of());
}
// The same series with `finished` played.
public Series next(SeriesGame finished) {
List<SeriesGame> played = new ArrayList<>(games);
played.add(finished);
return new Series(id, played);
}
}

View File

@ -0,0 +1,12 @@
package app.playmancala.model;
// One finished game of a series, as the series strip shows it (docs/specs/rematch). Seats rather than user ids: the
// players keep their seats through a series, and no id can leak. `result` is null when the game ended without a winner.
public record SeriesGame(
String gameId,
Seat firstSeat,
GameResult result,
EndReason endReason,
int player1Store,
int player2Store) {
}

View File

@ -32,6 +32,9 @@ import app.playmancala.model.GameRecord;
import app.playmancala.model.GameResult; import app.playmancala.model.GameResult;
import app.playmancala.model.Json; import app.playmancala.model.Json;
import app.playmancala.model.MoveRecord; import app.playmancala.model.MoveRecord;
import app.playmancala.model.Seat;
import app.playmancala.model.Series;
import app.playmancala.model.SeriesGame;
// Ported from the Node backend's tests/game/GameManager.test.ts. // Ported from the Node backend's tests/game/GameManager.test.ts.
class GameManagerTest { class GameManagerTest {
@ -199,11 +202,11 @@ class GameManagerTest {
} }
@Test @Test
void sendsTheGameAsTheNodeBackendDid() { void sendsTheGameAsTheNodeBackendDidPlusTheSeries() {
BackendGame game = startGame(); BackendGame game = startGame();
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes", assertEquals(List.of("id", "mancalaGame", "gameUsersConnectionInfo", "startedAt", "moveTimes",
"spectatorCount", "clock", "serverTime"), fieldNames(start)); "spectatorCount", "clock", "serverTime", "series", "firstSeat"), fieldNames(start));
assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history", assertEquals(List.of("id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
"currentHistoryItem"), fieldNames(start.get("mancalaGame"))); "currentHistoryItem"), fieldNames(start.get("mancalaGame")));
assertEquals("initial", start.at("/mancalaGame/state").asText()); assertEquals("initial", start.at("/mancalaGame/state").asText());
@ -392,6 +395,7 @@ class GameManagerTest {
assertEquals(1, ends.size()); assertEquals(1, ends.size());
ObjectNode end = ends.getFirst().deepCopy(); ObjectNode end = ends.getFirst().deepCopy();
assertTrue(end.remove("clock").isObject()); assertTrue(end.remove("clock").isObject());
assertTrue(end.remove("series").isObject());
assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed", assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed",
"winnerId", ALICE.id())), end); "winnerId", ALICE.id())), end);
} }
@ -413,6 +417,7 @@ class GameManagerTest {
assertEquals(1, ends.size()); assertEquals(1, ends.size());
ObjectNode end = ends.getFirst().deepCopy(); ObjectNode end = ends.getFirst().deepCopy();
assertTrue(end.remove("clock").isObject()); assertTrue(end.remove("clock").isObject());
assertTrue(end.remove("series").isObject());
assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left", assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left",
"winnerId", BOB.id(), "leftUserId", ALICE.id())), end); "winnerId", BOB.id(), "leftUserId", ALICE.id())), end);
} }
@ -595,8 +600,101 @@ class GameManagerTest {
JsonNode written = liveGameStore.games.get(restored.id); JsonNode written = liveGameStore.games.get(restored.id);
assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history")); assertEquals(Json.tree(restored.mancalaGame.getHistory()), written.get("history"));
assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history", assertEquals(List.of("version", "id", "player1Id", "player2Id", "turnPlayerId", "state", "board", "history",
"players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs"), "players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs",
fieldNames(written)); "series"), fieldNames(written));
}
}
@Nested
class InASeries {
@Test
void everyNewGameStartsASeriesOfItsOwn() {
BackendGame first = startGame(ALICE, BOB);
BackendGame second = startGame(CAROL, DAVE);
assertTrue(first.series.games().isEmpty());
assertNotNull(first.series.id());
assertFalse(first.series.id().equals(second.series.id()));
JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START);
assertEquals(Json.tree(Map.of("id", first.series.id(), "games", List.of())), start.get("series"));
}
@Test
void theFirstSeatIsTheSeatThatMovedFirst() {
BackendGame game = startGame();
Seat firstSeat = isPlayer1(game, game.mancalaGame.getTurnPlayerId()) ? Seat.PLAYER1 : Seat.PLAYER2;
assertEquals(firstSeat, game.firstSeat());
assertEquals(Json.tree(firstSeat), rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_START).get("firstSeat"));
playAnyMove(game);
playAnyMove(game);
assertEquals(firstSeat, game.firstSeat());
}
@Test
void aGameCanBeCreatedInASeriesWithTheSeatToMoveFirst() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.PLAYER1,
EndReason.COMPLETED, 30, 18)));
for (Seat seat : Seat.values()) {
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, seat);
assertEquals(ALICE.id(), game.mancalaGame.getPlayer1Id());
assertEquals(seat == Seat.PLAYER1 ? ALICE.id() : BOB.id(), game.mancalaGame.getTurnPlayerId());
assertSame(series, game.series);
assertEquals(Json.tree(series), Json.tree(gameManager.toClientGame(game)).get("series"));
gameManager.deleteGame(game);
}
}
@Test
void theEndMessageHasTheSeriesWithTheGameThatEnded() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, GameResult.DRAW,
EndReason.COMPLETED, 24, 24)));
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER1);
gameManager.startGame(game);
playAnyMove(game);
setUpLastMove(game, ALICE.id());
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 5));
JsonNode end = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END);
assertEquals(Json.tree(series.next(new SeriesGame(game.id, Seat.PLAYER1, GameResult.PLAYER1,
EndReason.COMPLETED, 2, 0))), end.get("series"));
}
@Test
void aGameThatEndsWithoutAWinnerIsInTheSeriesToo() {
BackendGame game = startGame();
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
JsonNode games = rtmt.lastMessageTo(BOB.id(), Channels.ON_GAME_END).at("/series/games");
assertEquals(1, games.size());
assertEquals("left", games.get(0).get("endReason").asText());
assertEquals(game.id, games.get(0).get("gameId").asText());
}
@Test
void restoresTheSeriesOfALiveGame() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null,
EndReason.TIMEOUT, 10, 12)));
BackendGame game = gameManager.createMancalaGame(ALICE.id(), BOB.id(), null, series, Seat.PLAYER2);
gameManager.startGame(game);
playAnyMove(game);
createManager();
gameManager.restoreLiveGames();
BackendGame restored = gameStore.getGameByUser(ALICE.id());
assertEquals(series, restored.series);
assertEquals(Seat.PLAYER2, restored.firstSeat());
}
@Test
void aSnapshotFromBeforeRematchesStartsANewSeries() {
BackendGame game = startGame();
ObjectNode snapshot = liveGameStore.games.get(game.id).deepCopy();
snapshot.remove("series");
liveGameStore.games.put(game.id, snapshot);
createManager();
gameManager.restoreLiveGames();
BackendGame restored = gameStore.getGameByUser(ALICE.id());
assertNotNull(restored.series.id());
assertTrue(restored.series.games().isEmpty());
} }
} }

View File

@ -3,7 +3,7 @@
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
- [x] 1. **Spec**: requirements, design and tasks. - [x] 1. **Spec**: requirements, design and tasks.
- [ ] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in - [x] 2. **Series in live games**: `Series`, `SeriesGame`, `BackendGame.series` and `firstSeat()`, the first seat in
`createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with `createMancalaGame`; `series` and `firstSeat` in `ClientGame` and `GameEndMessage`, `series` in snapshots (with
the restore of old snapshots); unit and serialisation tests. the restore of old snapshots); unit and serialisation tests.
- [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on - [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on