Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
8 changed files with 44 additions and 11 deletions
Showing only changes of commit 4a018c7b53 - Show all commits

View File

@ -152,7 +152,7 @@ public final class GameSerialization {
publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()), publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()),
getResult(game, ending), ending.endReason(), getResult(game, ending), ending.endReason(),
ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt, 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. // The player with a public id, for anything saved or listed.

View File

@ -23,7 +23,7 @@ public final class Migrations {
private static final long MIGRATION_LOCK_ID = 727274; 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. // Applied in this order; never change a migration that has been released, add a new one instead.
public static final List<String> VERSIONS = List.of("001_games", "002_clock"); public static final List<String> VERSIONS = List.of("001_games", "002_clock", "003_bot");
private Migrations() { private Migrations() {
} }

View File

@ -14,6 +14,7 @@ import javax.sql.DataSource;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import app.playmancala.bot.BotSettings;
import app.playmancala.model.EndReason; import app.playmancala.model.EndReason;
import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.FinishedGameSummary;
import app.playmancala.model.GameRecord; import app.playmancala.model.GameRecord;
@ -58,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
int inserted; int inserted;
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, 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), VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
?, ?::jsonb, ?::jsonb, ?, ?, ?) ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb)
ON CONFLICT (id) DO NOTHING""")) { ON CONFLICT (id) DO NOTHING""")) {
statement.setString(1, record.id()); statement.setString(1, record.id());
statement.setString(2, record.player1().userId()); statement.setString(2, record.player1().userId());
@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
statement.setInt(13, record.totalSpectators()); statement.setInt(13, record.totalSpectators());
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
inserted = statement.executeUpdate(); inserted = statement.executeUpdate();
} }
if (inserted == 0) return null; if (inserted == 0) return null;
@ -103,7 +105,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
if (!UUID_PATTERN.matcher(id).matches()) return null; if (!UUID_PATTERN.matcher(id).matches()) return null;
return query(connection -> { return query(connection -> {
try (PreparedStatement statement = connection.prepareStatement( 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); statement.setString(1, id);
try (ResultSet row = statement.executeQuery()) { try (ResultSet row = statement.executeQuery()) {
if (!row.next()) return null; 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(), return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(),
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(), summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(), 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));
} }
} }
}); });

View File

@ -5,10 +5,12 @@ import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder; 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 // 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", @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" })
public record GameRecord( public record GameRecord(
String id, String id,
PlayerSummary player1, PlayerSummary player1,
@ -23,7 +25,16 @@ public record GameRecord(
int peakSpectators, int peakSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
List<MoveRecord> moves, List<MoveRecord> 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<Integer> board, GameResult result,
EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators,
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
clockInitialMs, moves, totalSpectators, null);
}
public FinishedGameSummary toSummary() { public FinishedGameSummary toSummary() {
return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt,

View File

@ -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;

View File

@ -209,6 +209,7 @@ class BotPlayerTest {
assertNotNull(record, "the game did not end"); assertNotNull(record, "the game did not end");
assertEquals(EndReason.COMPLETED, record.endReason()); assertEquals(EndReason.COMPLETED, record.endReason());
assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); 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(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium")));
assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:")));
} }

View File

@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.GameStep; import app.playmancala.engine.GameStep;
import app.playmancala.model.EndReason; import app.playmancala.model.EndReason;
import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.FinishedGameSummary;
@ -88,7 +90,7 @@ class PostgresGameHistoryRepositoryTest {
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) { ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
while (rows.next()) versions.add(rows.getString(1)); 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 @Test
@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest {
assertFalse(saved.at("/moves/0").has("remainingMs")); 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 @Test
void keepsLeftUserIdForGamesAPlayerLeft() { void keepsLeftUserIdForGamesAPlayerLeft() {
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);

View File

@ -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`, - [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 `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. `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`, - [ ] 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. `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 - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot