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 97990d6..b394159 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -152,7 +152,7 @@ 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()); + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot); } // 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 136b54c..779afc2 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"); + public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot"); 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 b3ed987..13f70fc 100644 --- a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java +++ b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -14,6 +14,7 @@ import javax.sql.DataSource; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import app.playmancala.bot.BotSettings; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.GameRecord; @@ -58,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) + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot) VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), - ?, ?::jsonb, ?::jsonb, ?, ?, ?) + ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb) ON CONFLICT (id) DO NOTHING""")) { statement.setString(1, record.id()); statement.setString(2, record.player1().userId()); @@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { statement.setInt(13, record.totalSpectators()); 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())); inserted = statement.executeUpdate(); } if (inserted == 0) return null; @@ -103,7 +105,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 " + FROM_GAMES + " WHERE g.id = ?::uuid")) { + "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot " + FROM_GAMES + " WHERE g.id = ?::uuid")) { statement.setString(1, id); try (ResultSet row = statement.executeQuery()) { if (!row.next()) return null; @@ -111,7 +113,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(), 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")); + Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"), + row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class)); } } }); 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 325ab9c..a3014f3 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java @@ -5,10 +5,12 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; 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. +// is a public id. `bot` is how the bot played, in games against the bot. @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", - "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" }) public record GameRecord( String id, PlayerSummary player1, @@ -23,7 +25,16 @@ public record GameRecord( int peakSpectators, @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, List moves, - int totalSpectators) { + int totalSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot) { + + // 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); + } public FinishedGameSummary toSummary() { return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, diff --git a/apps/backend/src/main/resources/db/migrations/003_bot.sql b/apps/backend/src/main/resources/db/migrations/003_bot.sql new file mode 100644 index 0000000..0119f3d --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/003_bot.sql @@ -0,0 +1,2 @@ +-- How the bot played in a game against the bot: { level, strength, seed }; NULL for games between people. +ALTER TABLE games ADD COLUMN bot jsonb; diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java index ae5faa8..a6ae07b 100644 --- a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -209,6 +209,7 @@ class BotPlayerTest { assertNotNull(record, "the game did not end"); assertEquals(EndReason.COMPLETED, record.endReason()); assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); + assertEquals(new BotSettings(BotLevel.MEDIUM, 40, 11), record.bot()); assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium"))); assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); } 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 dcf8bb9..a68a211 100644 --- a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java +++ b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.GameStep; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; @@ -88,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"), versions); + assertEquals(List.of("001_games", "002_clock", "003_bot"), versions); } @Test @@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest { assertFalse(saved.at("/moves/0").has("remainingMs")); } + @Test + void keepsHowTheBotPlayed() { + 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)); + repository.save(botGame); + assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); + assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); + GameRecord betweenPeople = record(); + repository.save(betweenPeople); + assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot")); + } + @Test void keepsLeftUserIdForGamesAPlayerLeft() { GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index abd5d58..6d688c6 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -11,7 +11,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. -- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot