diff --git a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java index 0a4ac6f..993c106 100644 --- a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java +++ b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java @@ -6,9 +6,12 @@ import java.util.Map; import app.playmancala.bot.BotSettings; import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; import app.playmancala.model.GameClock; import app.playmancala.model.GameUsersConnectionInfo; 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. public class BackendGame { @@ -30,10 +33,12 @@ public class BackendGame { public final List moveRemainingMs; // How the bot plays in a game against the bot; null in games between people. 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, List allSpectatorIds, int peakSpectators, Map players, long startedAt, - List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot) { + List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot, Series series) { this.id = mancalaGame.getId(); this.mancalaGame = mancalaGame; this.gameUsersConnectionInfo = gameUsersConnectionInfo; @@ -45,6 +50,14 @@ public class BackendGame { this.clock = clock; this.moveRemainingMs = new ArrayList<>(moveRemainingMs); 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 history = mancalaGame.getHistory(); + String firstPlayerId = history.isEmpty() ? mancalaGame.getTurnPlayerId() : history.getFirst().getPlayerId(); + return mancalaGame.getPlayer1Id().equals(firstPlayerId) ? Seat.PLAYER1 : Seat.PLAYER2; } public boolean isBotTurn() { diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java index a92d460..f52b344 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameManager.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode; import app.playmancala.bot.BotLevel; import app.playmancala.bot.BotSettings; +import app.playmancala.engine.Board; import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.GameState; import app.playmancala.engine.MancalaGame; @@ -29,9 +30,12 @@ import app.playmancala.model.ClientGame; import app.playmancala.model.EndReason; import app.playmancala.model.GameEndMessage; import app.playmancala.model.GameRecord; +import app.playmancala.model.GameResult; import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.PlayerSummary; import app.playmancala.model.Seat; +import app.playmancala.model.Series; +import app.playmancala.model.SeriesGame; import app.playmancala.model.UserConnectionInfo; import app.playmancala.rtmt.Rtmt; @@ -193,8 +197,13 @@ public class GameManager { 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()); + GameResult result = GameSerialization.getResult(game, ending); + 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) { sendMessageToPlayersAndSpectators(game, Channels.ON_GAME_USER_LEAVE, ending.leftUserId()); } @@ -313,7 +322,8 @@ public class GameManager { 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()); + List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong(), game.series, + game.firstSeat()); } 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. 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.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(); PlayerSummary player1 = getPlayerInfo(userId1); PlayerSummary player2 = getPlayerInfo(userId2); @@ -331,7 +347,7 @@ public class GameManager { 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(), bot); + players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot, series); gameStore.set(game.id, game); gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId2, game); diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java index b394159..f431315 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -27,6 +27,7 @@ import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.Json; import app.playmancala.model.MoveRecord; import app.playmancala.model.PlayerSummary; +import app.playmancala.model.Series; import app.playmancala.model.UserConnectionInfo; // 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, @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs, // 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) { @@ -74,7 +77,7 @@ public final class GameSerialization { 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)), 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. @@ -117,7 +120,8 @@ public final class GameSerialization { 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, 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 diff --git a/apps/backend/src/main/java/app/playmancala/model/ClientGame.java b/apps/backend/src/main/java/app/playmancala/model/ClientGame.java index f4ffebc..fa4432d 100644 --- a/apps/backend/src/main/java/app/playmancala/model/ClientGame.java +++ b/apps/backend/src/main/java/app/playmancala/model/ClientGame.java @@ -2,10 +2,12 @@ package app.playmancala.model; import java.util.List; +import com.fasterxml.jackson.annotation.JsonInclude; + import app.playmancala.engine.MancalaGame; // 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( String id, MancalaGame mancalaGame, @@ -14,5 +16,7 @@ public record ClientGame( List moveTimes, int spectatorCount, GameClock clock, - long serverTime) { + long serverTime, + @JsonInclude(JsonInclude.Include.NON_NULL) Series series, + Seat firstSeat) { } diff --git a/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java index c300b97..e4b6cb1 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameEndMessage.java @@ -4,13 +4,14 @@ import com.fasterxml.jackson.annotation.JsonInclude; 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 -// sent as null) when the game ended without a winner. -@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" }) +// sent as null) when the game ended without a winner. `series` includes the game that ended. +@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId", "series" }) public record GameEndMessage( String gameId, GameResult result, EndReason endReason, GameClock clock, @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) { } diff --git a/apps/backend/src/main/java/app/playmancala/model/Series.java b/apps/backend/src/main/java/app/playmancala/model/Series.java new file mode 100644 index 0000000..8a0a18f --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/Series.java @@ -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 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 played = new ArrayList<>(games); + played.add(finished); + return new Series(id, played); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java b/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java new file mode 100644 index 0000000..e92b6d1 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/model/SeriesGame.java @@ -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) { +} diff --git a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java index d925a3b..cbc8d3b 100644 --- a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java +++ b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java @@ -32,6 +32,9 @@ import app.playmancala.model.GameRecord; import app.playmancala.model.GameResult; import app.playmancala.model.Json; 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. class GameManagerTest { @@ -199,11 +202,11 @@ class GameManagerTest { } @Test - void sendsTheGameAsTheNodeBackendDid() { + void sendsTheGameAsTheNodeBackendDidPlusTheSeries() { 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)); + "spectatorCount", "clock", "serverTime", "series", "firstSeat"), 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()); @@ -392,6 +395,7 @@ class GameManagerTest { assertEquals(1, ends.size()); ObjectNode end = ends.getFirst().deepCopy(); assertTrue(end.remove("clock").isObject()); + assertTrue(end.remove("series").isObject()); assertEquals(Json.tree(Map.of("gameId", game.id, "result", aliceSeat, "endReason", "completed", "winnerId", ALICE.id())), end); } @@ -413,6 +417,7 @@ class GameManagerTest { assertEquals(1, ends.size()); ObjectNode end = ends.getFirst().deepCopy(); assertTrue(end.remove("clock").isObject()); + assertTrue(end.remove("series").isObject()); assertEquals(Json.tree(Map.of("gameId", game.id, "result", bobSeat, "endReason", "left", "winnerId", BOB.id(), "leftUserId", ALICE.id())), end); } @@ -595,8 +600,101 @@ class GameManagerTest { 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)); + "players", "startedAt", "moveTimes", "peakSpectators", "allSpectatorIds", "clock", "moveRemainingMs", + "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()); } } diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index 6c7a2f9..ed14f83 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -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. - [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 the restore of old snapshots); unit and serialisation tests. - [ ] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on