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 f431315..1b1f13a 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -156,7 +156,8 @@ public final class GameSerialization { 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(), game.bot); + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot, + game.series.id()); } // The player with a public id, for anything saved or listed. diff --git a/apps/backend/src/main/java/app/playmancala/history/Migrations.java b/apps/backend/src/main/java/app/playmancala/history/Migrations.java index 779afc2..3fd67fd 100644 --- a/apps/backend/src/main/java/app/playmancala/history/Migrations.java +++ b/apps/backend/src/main/java/app/playmancala/history/Migrations.java @@ -23,7 +23,7 @@ public final class Migrations { private static final long MIGRATION_LOCK_ID = 727274; // Applied in this order; never change a migration that has been released, add a new one instead. - public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot"); + public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot", "004_series"); private Migrations() { } diff --git a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java index 13f70fc..36a5de3 100644 --- a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java +++ b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -59,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { int inserted; try (PreparedStatement statement = connection.prepareStatement(""" INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, - move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot) + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot, series_id) VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), - ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb) + ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid) ON CONFLICT (id) DO NOTHING""")) { statement.setString(1, record.id()); statement.setString(2, record.player1().userId()); @@ -79,6 +79,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); statement.setString(15, record.bot() == null ? null : Json.write(record.bot())); + statement.setString(16, record.seriesId()); inserted = statement.executeUpdate(); } if (inserted == 0) return null; @@ -105,7 +106,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { if (!UUID_PATTERN.matcher(id).matches()) return null; return query(connection -> { try (PreparedStatement statement = connection.prepareStatement( - "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot " + FROM_GAMES + " WHERE g.id = ?::uuid")) { + "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot, g.series_id " + FROM_GAMES + " WHERE g.id = ?::uuid")) { statement.setString(1, id); try (ResultSet row = statement.executeQuery()) { if (!row.next()) return null; @@ -114,7 +115,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(), summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(), Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"), - row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class)); + row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class), + row.getString("series_id")); } } }); diff --git a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java index a3014f3..1c028a6 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java @@ -8,9 +8,10 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import app.playmancala.bot.BotSettings; // A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it -// is a public id. `bot` is how the bot played, in games against the bot. +// is a public id. `bot` is how the bot played, in games against the bot; `seriesId` is missing in games saved before +// rematches. @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", - "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" }) + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot", "seriesId" }) public record GameRecord( String id, PlayerSummary player1, @@ -26,14 +27,15 @@ public record GameRecord( @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, List moves, int totalSpectators, - @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot) { + @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot, + @JsonInclude(JsonInclude.Include.NON_NULL) String seriesId) { // A game between people. public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List board, GameResult result, EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators, Long clockInitialMs, List moves, int totalSpectators) { this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators, - clockInitialMs, moves, totalSpectators, null); + clockInitialMs, moves, totalSpectators, null, null); } public FinishedGameSummary toSummary() { diff --git a/apps/backend/src/main/resources/db/migrations/004_series.sql b/apps/backend/src/main/resources/db/migrations/004_series.sql new file mode 100644 index 0000000..0abf21f --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/004_series.sql @@ -0,0 +1,3 @@ +-- The series a game belongs to: a game and the rematches after it (docs/specs/rematch). NULL for games saved before. +ALTER TABLE games ADD COLUMN series_id uuid; +CREATE INDEX games_series_id ON games (series_id); 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 01a0962..624e5a3 100644 --- a/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java +++ b/apps/backend/src/test/java/app/playmancala/game/GameManagerTest.java @@ -668,6 +668,13 @@ class GameManagerTest { assertEquals(game.id, games.get(0).get("gameId").asText()); } + @Test + void savesTheSeriesWithTheGame() { + BackendGame game = startGame(); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + assertEquals(game.series.id(), repository.records.get(game.id).seriesId()); + } + @Test void restoresTheSeriesOfALiveGame() { Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null, diff --git a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java index a68a211..ab41959 100644 --- a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java +++ b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -90,7 +90,7 @@ class PostgresGameHistoryRepositoryTest { ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) { while (rows.next()) versions.add(rows.getString(1)); } - assertEquals(List.of("001_games", "002_clock", "003_bot"), versions); + assertEquals(List.of("001_games", "002_clock", "003_bot", "004_series"), versions); } @Test @@ -122,7 +122,7 @@ class PostgresGameHistoryRepositoryTest { GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false)); GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, - game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L)); + game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L), null); repository.save(botGame); assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); @@ -131,6 +131,19 @@ class PostgresGameHistoryRepositoryTest { assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot")); } + @Test + void keepsTheSeriesAGameBelongsTo() { + GameRecord game = record(); + GameRecord inSeries = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators(), null, UUID.randomUUID().toString()); + repository.save(inSeries); + assertEquals(Json.tree(inSeries), Json.tree(repository.get(game.id()))); + GameRecord before = record(); + repository.save(before); + assertFalse(Json.tree(repository.get(before.id())).has("seriesId")); + } + @Test void keepsLeftUserIdForGamesAPlayerLeft() { GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index 6bc8dba..6e68bae 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. the restore of old snapshots); unit and serialisation tests. - [x] 3. **Rematch requests**: `RematchOffers`, the four channels, the timers on the `Scheduler`, ending requests on `new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests. -- [ ] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests. +- [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests. - [ ] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests. - [ ] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch states, "Play again" as a rematch, the score in two columns.