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 41 additions and 13 deletions
Showing only changes of commit af11fd1cf1 - Show all commits

View File

@ -156,7 +156,8 @@ 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(), 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. // 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", "003_bot"); public static final List<String> VERSIONS = List.of("001_games", "002_clock", "003_bot", "004_series");
private Migrations() { private Migrations() {
} }

View File

@ -59,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, 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), VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb) ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?::uuid)
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());
@ -79,6 +79,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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())); statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
statement.setString(16, record.seriesId());
inserted = statement.executeUpdate(); inserted = statement.executeUpdate();
} }
if (inserted == 0) return null; if (inserted == 0) return null;
@ -105,7 +106,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, 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); statement.setString(1, id);
try (ResultSet row = statement.executeQuery()) { try (ResultSet row = statement.executeQuery()) {
if (!row.next()) return null; if (!row.next()) return null;
@ -114,7 +115,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
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)); row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class),
row.getString("series_id"));
} }
} }
}); });

View File

@ -8,9 +8,10 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import app.playmancala.bot.BotSettings; 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. `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", @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( public record GameRecord(
String id, String id,
PlayerSummary player1, PlayerSummary player1,
@ -26,14 +27,15 @@ public record GameRecord(
@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) { @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot,
@JsonInclude(JsonInclude.Include.NON_NULL) String seriesId) {
// A game between people. // A game between people.
public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List<Integer> board, GameResult result, 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, EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators,
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) { Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators, this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
clockInitialMs, moves, totalSpectators, null); clockInitialMs, moves, totalSpectators, null, null);
} }
public FinishedGameSummary toSummary() { public FinishedGameSummary toSummary() {

View File

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

View File

@ -668,6 +668,13 @@ class GameManagerTest {
assertEquals(game.id, games.get(0).get("gameId").asText()); 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 @Test
void restoresTheSeriesOfALiveGame() { void restoresTheSeriesOfALiveGame() {
Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null, Series series = new Series("series-1", List.of(new SeriesGame("game-1", Seat.PLAYER2, null,

View File

@ -90,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", "003_bot"), versions); assertEquals(List.of("001_games", "002_clock", "003_bot", "004_series"), versions);
} }
@Test @Test
@ -122,7 +122,7 @@ class PostgresGameHistoryRepositoryTest {
GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false)); GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false));
GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), 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.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); repository.save(botGame);
assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id())));
assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); 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")); 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 @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

@ -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. 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 - [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. `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. - [ ] 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 - [ ] 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. states, "Play again" as a rematch, the score in two columns.