From 816c68aef250e7ef985a2eaf4430212774b86a1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 07:48:33 +0000 Subject: [PATCH 01/14] Initial plan From f5a1c49804c8e689364ae5692cee0e1880c6389a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 08:07:07 +0000 Subject: [PATCH 02/14] animate individual PvB moves in live viewer instead of jumping 2 at once --- .../db/services/PlayerVsBotGameDaoService.kt | 1 + .../dto/lobby/LatestGamesUpdateRequest.kt | 5 +- .../dto/lobby/LatestGamesUpdateResponse.kt | 4 +- .../servicelayer/services/GameDataService.kt | 18 ++++++- .../public/js/lobby/live-games-viewer.js | 8 ++- .../resources/public/js/lobby/lobby-client.js | 10 ++-- .../resources/public/js/widgets/game-thumb.js | 49 ++++++++++++++++++- 7 files changed, 86 insertions(+), 9 deletions(-) diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsBotGameDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsBotGameDaoService.kt index dc871f84f..42d57302d 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsBotGameDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsBotGameDaoService.kt @@ -223,6 +223,7 @@ class PlayerVsBotGameDaoService(private val dslContext: DSLContext) { BOT_GAME.ID, BOT_GAME.GAME_STATUS, BOT_GAME.CURRENT_FEN, + BOT_GAME.CURRENT_HALF_MOVE_INDEX, BOT_GAME.LAST_UPDATED ) .from(BOT_GAME) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt index e3d279feb..fd599731c 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt @@ -2,4 +2,7 @@ package io.elephantchess.servicelayer.dto.lobby import io.elephantchess.model.GameId -data class LatestGamesUpdateRequest(val gameIds: List) +data class LatestGamesUpdateRequest( + val gameIds: List, + val pvbMoveIndexes: Map = emptyMap() +) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateResponse.kt index 1281eee8c..8c98d27b7 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateResponse.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateResponse.kt @@ -9,7 +9,9 @@ data class LatestGamesUpdateResponse(val entries: List) { val gameId: GameId, val status: GameEventType, val fen: String, - val lastUpdated : Long + val lastUpdated: Long, + val moveIndex: Int? = null, + val newMoves: List = emptyList() ) } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt index cf2203568..b1c45adfe 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt @@ -741,6 +741,16 @@ class GameDataService( val pvpGames = pvpGameDaoService.fetchCurrentStatusAndFen(idsByType[PVP].orEmpty()) val pvbGames = pvbGameDaoService.fetchCurrentStatusAndFen(idsByType[PVB].orEmpty()) + val pvbNewMoves = if (request.pvbMoveIndexes.isNotEmpty()) { + val moveTuples = pvbGames + .filter { game -> request.pvbMoveIndexes.containsKey(game.id) } + .map { game -> game.id to request.pvbMoveIndexes[game.id]!! } + pvbGameDaoService.fetchNewMovesForGamesAndIndexes(moveTuples) + .groupBy { it.botGameId } + } else { + emptyMap() + } + val entries = pvpGames.map { game -> LatestGamesUpdateResponse.Entry( gameId = GameId(PVP, game.id), @@ -749,11 +759,17 @@ class GameDataService( lastUpdated = game.lastUpdated.toEpochMilliseconds() ) } + pvbGames.map { game -> + val newMoves = pvbNewMoves[game.id] + ?.sortedBy { it.position } + ?.map { it.uci } + ?: emptyList() LatestGamesUpdateResponse.Entry( gameId = GameId(PVB, game.id), status = game.gameStatus, fen = game.currentFen, - lastUpdated = game.lastUpdated.toEpochMilliseconds() + lastUpdated = game.lastUpdated.toEpochMilliseconds(), + moveIndex = game.currentHalfMoveIndex, + newMoves = newMoves ) } diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 23f35c3cc..67fb34d8e 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -78,6 +78,7 @@ class LiveGamesViewer { elementId: boardElementId, showCoordinates: false, mini: true, + playSounds: false, }); this.#settingsGui.addBoardGui(boardGui); return new GameThumb(div, boardGui); @@ -132,7 +133,12 @@ class LiveGamesViewer { let totalGames = 0; let liveGames = 0; - this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, (updates) => { + const pvbMoveIndexes = {}; + thumbs + .filter((t) => t.metadata.gameId.type === GameType.PVB && t.currentMoveIndex != null) + .forEach((t) => { pvbMoveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); + + this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, pvbMoveIndexes, (updates) => { updates.forEach((update) => { const thumb = thumbs.find((t) => t.metadata.gameId.toString() === update.gameId.toString()); if (thumb != null) { diff --git a/webapp/src/main/resources/public/js/lobby/lobby-client.js b/webapp/src/main/resources/public/js/lobby/lobby-client.js index 51587861d..99dc5e05a 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -69,11 +69,13 @@ class LobbyClient { /** * @param gameIds {Array} - * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number}>)} + * @param pvbMoveIndexes {Object} + * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>)} */ - fetchLatestGamesUpdate(gameIds, cb) { + fetchLatestGamesUpdate(gameIds, pvbMoveIndexes, cb) { const body = { - gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})) + gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})), + pvbMoveIndexes: pvbMoveIndexes, }; postAndHandle('/api/lobby/latest-games-update', body, (json) => { const items = []; @@ -84,6 +86,8 @@ class LobbyClient { status: entry.status, fen: entry.fen, lastUpdated: Number(entry.lastUpdated), + moveIndex: entry.moveIndex ?? null, + newMoves: entry.newMoves ?? [], }); } cb(items); diff --git a/webapp/src/main/resources/public/js/widgets/game-thumb.js b/webapp/src/main/resources/public/js/widgets/game-thumb.js index ad94cf677..c0cbeff66 100644 --- a/webapp/src/main/resources/public/js/widgets/game-thumb.js +++ b/webapp/src/main/resources/public/js/widgets/game-thumb.js @@ -79,6 +79,21 @@ class GameThumb { */ metadata = null; + /** + * The last known move index for PvB games (used to request only new moves from the server). + * Null means unknown (no move index has been received yet from the server). + * + * @type {number|null} + */ + currentMoveIndex = null; + + /** + * @type {string[]} + */ + #moveQueue = []; + + #isProcessingMoveQueue = false; + /** * @param divElement {HTMLDivElement} * @param boardGui {BoardGui} @@ -351,6 +366,9 @@ class GameThumb { .remove('board-container-placeholder'); this.boardGui.loadFen(gameMetadataDto.finalFen); + this.currentMoveIndex = null; + this.#moveQueue = []; + this.#isProcessingMoveQueue = false; this.divElement .classList @@ -361,10 +379,21 @@ class GameThumb { * Refresh this thumb in-place from a {@link LatestGamesUpdateResponse} entry, * without re-rendering player names, ratings, ... * - * @param update {{gameId: GameId, status: string, fen: string, lastUpdated: number}} + * @param update {{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}} */ refresh(update) { - this.boardGui.loadFen(update.fen, true); + if (update.newMoves != null && update.newMoves.length > 0) { + this.#moveQueue = this.#moveQueue.concat(update.newMoves); + if (!this.#isProcessingMoveQueue) { + this.#processMoveQueue(); + } + } else { + this.boardGui.loadFen(update.fen, true); + } + + if (update.moveIndex != null) { + this.currentMoveIndex = update.moveIndex; + } const lastUpdatedDiv = this.#findFirst('game-thumb-status'); if (lastUpdatedDiv != null) { @@ -381,6 +410,22 @@ class GameThumb { } } + #processMoveQueue() { + const move = this.#moveQueue.shift(); + if (move) { + this.#isProcessingMoveQueue = true; + this.boardGui.registerOpponentMove(move, false, () => { + if (this.#moveQueue.length > 0) { + setTimeout(() => this.#processMoveQueue(), 300); + } else { + this.#isProcessingMoveQueue = false; + } + }); + } else { + this.#isProcessingMoveQueue = false; + } + } + /** * Reset all dynamic content of the thumb (used when cloning a thumb * for infinite scroll, before re-rendering with new metadata). From 667cbd201d13acb1c4f237203ce14f7f36a1c9ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:45:45 +0000 Subject: [PATCH 03/14] extend individual move animation to PvP games in live viewer --- .../services/PlayerVsPlayerGameDaoService.kt | 29 +++++++++++++++++++ .../dto/lobby/LatestGamesUpdateRequest.kt | 1 + .../servicelayer/services/GameDataService.kt | 18 +++++++++++- .../public/js/lobby/live-games-viewer.js | 7 ++++- .../resources/public/js/lobby/lobby-client.js | 4 ++- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt index 30b441a9c..9e35d6448 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt @@ -407,6 +407,7 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { GAME.ID, GAME.GAME_STATUS, GAME.CURRENT_FEN, + GAME.CURRENT_HALF_MOVE_INDEX, GAME.LAST_UPDATED ) .from(GAME) @@ -415,6 +416,34 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { } } + /** + * @param tuples list of gameId and half move index to request from + * @return list of moves for each gameId, position and uci + */ + suspend fun fetchNewMovesForGamesAndIndexes(tuples: List>): List { + if (tuples.isEmpty()) { + return emptyList() + } + + fun conditionForPair(gameId: String, moveIndex: Int) = + GAME_MOVE.GAME_ID.eq(gameId).and(GAME_MOVE.POSITION.ge(moveIndex)) + + val conditionForPairs = + tuples + .map { (gameId, moveIndex) -> conditionForPair(gameId, moveIndex) } + .reduce { acc, condition -> acc.or(condition) } + + return dslContext + .select( + GAME_MOVE.GAME_ID, + GAME_MOVE.POSITION, + GAME_MOVE.UCI + ) + .from(GAME_MOVE) + .where(conditionForPairs) + .awaitMappedRecords() + } + suspend fun fetchRatingForUser(userId: String, timeControlCategory: TimeControlCategory, variant: Variant): Int? { return fetchRatingForUser(dslContext, userId, timeControlCategory, variant) } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt index fd599731c..6dd7dfd99 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt @@ -4,5 +4,6 @@ import io.elephantchess.model.GameId data class LatestGamesUpdateRequest( val gameIds: List, + val pvpMoveIndexes: Map = emptyMap(), val pvbMoveIndexes: Map = emptyMap() ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt index b1c45adfe..ea2c26204 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt @@ -741,6 +741,16 @@ class GameDataService( val pvpGames = pvpGameDaoService.fetchCurrentStatusAndFen(idsByType[PVP].orEmpty()) val pvbGames = pvbGameDaoService.fetchCurrentStatusAndFen(idsByType[PVB].orEmpty()) + val pvpNewMoves = if (request.pvpMoveIndexes.isNotEmpty()) { + val moveTuples = pvpGames + .filter { game -> request.pvpMoveIndexes.containsKey(game.id) } + .map { game -> game.id to request.pvpMoveIndexes[game.id]!! } + pvpGameDaoService.fetchNewMovesForGamesAndIndexes(moveTuples) + .groupBy { it.gameId } + } else { + emptyMap() + } + val pvbNewMoves = if (request.pvbMoveIndexes.isNotEmpty()) { val moveTuples = pvbGames .filter { game -> request.pvbMoveIndexes.containsKey(game.id) } @@ -752,11 +762,17 @@ class GameDataService( } val entries = pvpGames.map { game -> + val newMoves = pvpNewMoves[game.id] + ?.sortedBy { it.position } + ?.map { it.uci } + ?: emptyList() LatestGamesUpdateResponse.Entry( gameId = GameId(PVP, game.id), status = game.gameStatus, fen = game.currentFen, - lastUpdated = game.lastUpdated.toEpochMilliseconds() + lastUpdated = game.lastUpdated.toEpochMilliseconds(), + moveIndex = game.currentHalfMoveIndex, + newMoves = newMoves ) } + pvbGames.map { game -> val newMoves = pvbNewMoves[game.id] diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 67fb34d8e..063e853f3 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -133,12 +133,17 @@ class LiveGamesViewer { let totalGames = 0; let liveGames = 0; + const pvpMoveIndexes = {}; + thumbs + .filter((t) => t.metadata.gameId.type === GameType.PVP && t.currentMoveIndex != null) + .forEach((t) => { pvpMoveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); + const pvbMoveIndexes = {}; thumbs .filter((t) => t.metadata.gameId.type === GameType.PVB && t.currentMoveIndex != null) .forEach((t) => { pvbMoveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); - this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, pvbMoveIndexes, (updates) => { + this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, pvpMoveIndexes, pvbMoveIndexes, (updates) => { updates.forEach((update) => { const thumb = thumbs.find((t) => t.metadata.gameId.toString() === update.gameId.toString()); if (thumb != null) { diff --git a/webapp/src/main/resources/public/js/lobby/lobby-client.js b/webapp/src/main/resources/public/js/lobby/lobby-client.js index 99dc5e05a..311396df2 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -69,12 +69,14 @@ class LobbyClient { /** * @param gameIds {Array} + * @param pvpMoveIndexes {Object} * @param pvbMoveIndexes {Object} * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>)} */ - fetchLatestGamesUpdate(gameIds, pvbMoveIndexes, cb) { + fetchLatestGamesUpdate(gameIds, pvpMoveIndexes, pvbMoveIndexes, cb) { const body = { gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})), + pvpMoveIndexes: pvpMoveIndexes, pvbMoveIndexes: pvbMoveIndexes, }; postAndHandle('/api/lobby/latest-games-update', body, (json) => { From 6bfe85734a2a983132b12816b6e69529f1ff737d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:51:46 +0000 Subject: [PATCH 04/14] merge pvpMoveIndexes/pvbMoveIndexes into single moveIndexes map --- .../dto/lobby/LatestGamesUpdateRequest.kt | 3 +-- .../servicelayer/services/GameDataService.kt | 12 ++++++------ .../resources/public/js/lobby/live-games-viewer.js | 13 ++++--------- .../main/resources/public/js/lobby/lobby-client.js | 8 +++----- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt index 6dd7dfd99..6dcc737e8 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/lobby/LatestGamesUpdateRequest.kt @@ -4,6 +4,5 @@ import io.elephantchess.model.GameId data class LatestGamesUpdateRequest( val gameIds: List, - val pvpMoveIndexes: Map = emptyMap(), - val pvbMoveIndexes: Map = emptyMap() + val moveIndexes: Map = emptyMap() ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt index ea2c26204..362e925a8 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt @@ -741,20 +741,20 @@ class GameDataService( val pvpGames = pvpGameDaoService.fetchCurrentStatusAndFen(idsByType[PVP].orEmpty()) val pvbGames = pvbGameDaoService.fetchCurrentStatusAndFen(idsByType[PVB].orEmpty()) - val pvpNewMoves = if (request.pvpMoveIndexes.isNotEmpty()) { + val pvpNewMoves = if (request.moveIndexes.isNotEmpty()) { val moveTuples = pvpGames - .filter { game -> request.pvpMoveIndexes.containsKey(game.id) } - .map { game -> game.id to request.pvpMoveIndexes[game.id]!! } + .filter { game -> request.moveIndexes.containsKey(game.id) } + .map { game -> game.id to request.moveIndexes[game.id]!! } pvpGameDaoService.fetchNewMovesForGamesAndIndexes(moveTuples) .groupBy { it.gameId } } else { emptyMap() } - val pvbNewMoves = if (request.pvbMoveIndexes.isNotEmpty()) { + val pvbNewMoves = if (request.moveIndexes.isNotEmpty()) { val moveTuples = pvbGames - .filter { game -> request.pvbMoveIndexes.containsKey(game.id) } - .map { game -> game.id to request.pvbMoveIndexes[game.id]!! } + .filter { game -> request.moveIndexes.containsKey(game.id) } + .map { game -> game.id to request.moveIndexes[game.id]!! } pvbGameDaoService.fetchNewMovesForGamesAndIndexes(moveTuples) .groupBy { it.botGameId } } else { diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 063e853f3..2202963df 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -133,17 +133,12 @@ class LiveGamesViewer { let totalGames = 0; let liveGames = 0; - const pvpMoveIndexes = {}; + const moveIndexes = {}; thumbs - .filter((t) => t.metadata.gameId.type === GameType.PVP && t.currentMoveIndex != null) - .forEach((t) => { pvpMoveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); + .filter((t) => t.currentMoveIndex != null) + .forEach((t) => { moveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); - const pvbMoveIndexes = {}; - thumbs - .filter((t) => t.metadata.gameId.type === GameType.PVB && t.currentMoveIndex != null) - .forEach((t) => { pvbMoveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); - - this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, pvpMoveIndexes, pvbMoveIndexes, (updates) => { + this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, moveIndexes, (updates) => { updates.forEach((update) => { const thumb = thumbs.find((t) => t.metadata.gameId.toString() === update.gameId.toString()); if (thumb != null) { diff --git a/webapp/src/main/resources/public/js/lobby/lobby-client.js b/webapp/src/main/resources/public/js/lobby/lobby-client.js index 311396df2..e0faf7a20 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -69,15 +69,13 @@ class LobbyClient { /** * @param gameIds {Array} - * @param pvpMoveIndexes {Object} - * @param pvbMoveIndexes {Object} + * @param moveIndexes {Object} * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>)} */ - fetchLatestGamesUpdate(gameIds, pvpMoveIndexes, pvbMoveIndexes, cb) { + fetchLatestGamesUpdate(gameIds, moveIndexes, cb) { const body = { gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})), - pvpMoveIndexes: pvpMoveIndexes, - pvbMoveIndexes: pvbMoveIndexes, + moveIndexes: moveIndexes, }; postAndHandle('/api/lobby/latest-games-update', body, (json) => { const items = []; From 7301045145ab4abe8a551cfffc0be441ff3575df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:46:08 +0000 Subject: [PATCH 05/14] Rename gameID to gameId in GameDataService --- .../io/elephantchess/servicelayer/services/GameDataService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt index 362e925a8..9ab0af944 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt @@ -736,7 +736,7 @@ class GameDataService( suspend fun fetchLatestGamesUpdate(request: LatestGamesUpdateRequest): LatestGamesUpdateResponse { val idsByType = request .gameIds - .groupBy({ gameID -> gameID.type }, { gameId -> gameId.id }) + .groupBy({ gameId -> gameId.type }, { gameId -> gameId.id }) val pvpGames = pvpGameDaoService.fetchCurrentStatusAndFen(idsByType[PVP].orEmpty()) val pvbGames = pvbGameDaoService.fetchCurrentStatusAndFen(idsByType[PVB].orEmpty()) From e74598e445546ce0eab08ec55356a66fe5dd4170 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:47:26 +0000 Subject: [PATCH 06/14] Inline moveIndexes assignment in live-games-viewer.js --- .../main/resources/public/js/lobby/live-games-viewer.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 2202963df..42439ff5b 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -133,10 +133,11 @@ class LiveGamesViewer { let totalGames = 0; let liveGames = 0; - const moveIndexes = {}; - thumbs - .filter((t) => t.currentMoveIndex != null) - .forEach((t) => { moveIndexes[t.metadata.gameId.id] = t.currentMoveIndex; }); + const moveIndexes = Object.fromEntries( + thumbs + .filter((t) => t.currentMoveIndex != null) + .map((t) => [t.metadata.gameId.id, t.currentMoveIndex]) + ); this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, moveIndexes, (updates) => { updates.forEach((update) => { From 0b311007f34420e6917997a8506727ebda7186bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:02:04 +0000 Subject: [PATCH 07/14] Move lobby live games update from HTTP polling to WebSocket session --- .../dto/ws/LiveGamesSubscription.kt | 13 ++ .../servicelayer/services/LobbyService.kt | 67 ++++++++++- .../services/ws/LiveGamesWebSocketSession.kt | 69 +++++++++++ .../webapp/routing/api/LobbyRouting.kt | 27 +++-- .../webapp/server/ApiServiceRoutingModule.kt | 2 + .../public/js/lobby/live-games-viewer.js | 111 ++++++++---------- .../resources/public/js/lobby/lobby-client.js | 27 ----- .../main/resources/public/js/ws/live-games.js | 84 +++++++++++++ .../src/main/resources/templates/lobby.html | 1 + 9 files changed, 301 insertions(+), 100 deletions(-) create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/LiveGamesSubscription.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt create mode 100644 webapp/src/main/resources/public/js/ws/live-games.js diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/LiveGamesSubscription.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/LiveGamesSubscription.kt new file mode 100644 index 000000000..85de7c22f --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/LiveGamesSubscription.kt @@ -0,0 +1,13 @@ +package io.elephantchess.servicelayer.dto.ws + +import io.elephantchess.model.GameId + +/** + * Sent by a lobby client over the live-games WebSocket to declare which games it + * is currently displaying and wants to receive updates for. The move index per + * game is tracked server-side (in [io.elephantchess.servicelayer.services.ws.LiveGamesWebSocketSession]), + * so the client only needs to send the game ids. + */ +data class LiveGamesSubscription( + val gameIds: List = emptyList() +) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt index 2b9d14272..e0ebb9dae 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt @@ -1,13 +1,38 @@ package io.elephantchess.servicelayer.services import io.elephantchess.db.services.UpcomingEventDaoService +import io.elephantchess.model.GameId import io.elephantchess.servicelayer.dto.lobby.GetUpcomingEventsResponse import io.elephantchess.servicelayer.dto.lobby.GetUpcomingEventsResponse.UpcomingEvent +import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateResponse +import io.elephantchess.servicelayer.services.ws.LiveGamesWebSocketSession +import io.elephantchess.servicelayer.utils.ops.launchAtFixedRate +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.ChannelResult +import kotlin.time.Duration.Companion.seconds class LobbyService( - private val upcomingEventDaoService: UpcomingEventDaoService + private val upcomingEventDaoService: UpcomingEventDaoService, + private val gameDataService: GameDataService, + refresherScope: CoroutineScope, + private val logger: KLogger, ) { + private val sessionsRefresh = 1.seconds + private val liveGamesSessions = mutableListOf() + + private val refreshJob = launchAtFixedRate( + scope = refresherScope, + initialDelay = sessionsRefresh, + period = sessionsRefresh, + action = { refreshLiveGamesSessions() } + ) + + fun cancel() { + refreshJob.cancel() + } + suspend fun listUpcomingEvents(): GetUpcomingEventsResponse { return upcomingEventDaoService .listUpcomingEventsForLobby() @@ -24,4 +49,44 @@ class LobbyService( } } + fun startLiveGamesSession( + sendCb: (LatestGamesUpdateResponse) -> ChannelResult, + ): String { + val session = LiveGamesWebSocketSession(sendCb) + liveGamesSessions.add(session) + logger.debug { "created $session" } + return session.sessionId + } + + fun handleLiveGamesSubscription(sessionId: String, gameIds: List) { + liveGamesSessions + .find { it.sessionId == sessionId } + ?.updateSubscription(gameIds) + } + + fun closeLiveGamesSession(sessionId: String) { + liveGamesSessions + .find { it.sessionId == sessionId } + ?.markAsClosed() + } + + private suspend fun refreshLiveGamesSessions() { + if (liveGamesSessions.isNotEmpty()) { + // TODO: not very optimized: lobby sessions watch mostly the same games -> some + // information is fetched once per session instead of being batched across sessions + liveGamesSessions.forEach { session -> + val request = session.currentRequest() + if (request.gameIds.isNotEmpty()) { + session.update(gameDataService.fetchLatestGamesUpdate(request)) + } + } + + // remove the sessions that are not active anymore + liveGamesSessions.removeIf { session -> + if (session.isClosed) logger.debug { "removing $session" } + session.isClosed + } + } + } + } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt new file mode 100644 index 000000000..b34091eff --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt @@ -0,0 +1,69 @@ +package io.elephantchess.servicelayer.services.ws + +import io.elephantchess.model.GameEventType +import io.elephantchess.model.GameId +import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateRequest +import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateResponse +import kotlinx.coroutines.channels.ChannelResult + +/** + * WebSocket session for a lobby client watching the live games thumbnails. + * + * The client declares which games it watches via [updateSubscription]; the last + * known move index per game is tracked here (not sent by the client), so the + * refresher can request only the new moves and the session only pushes entries + * that actually changed (new moves or a status change). + */ +class LiveGamesWebSocketSession( + private val sendCb: (LatestGamesUpdateResponse) -> ChannelResult, +) : WebSocketSession() { + + private var subscribedGameIds: List = emptyList() + private val moveIndexes = mutableMapOf() + private val lastStatuses = mutableMapOf() + + fun updateSubscription(gameIds: List) { + subscribedGameIds = gameIds + + // forget tracking for games that are not watched anymore + val watchedIds = gameIds.map { it.id }.toSet() + moveIndexes.keys.retainAll(watchedIds) + lastStatuses.keys.retainAll(watchedIds) + } + + fun currentRequest(): LatestGamesUpdateRequest = + LatestGamesUpdateRequest( + gameIds = subscribedGameIds, + moveIndexes = moveIndexes.toMap() + ) + + override fun update(update: LatestGamesUpdateResponse) { + val changedEntries = update.entries.filter { entry -> + val id = entry.gameId.id + val firstTime = id !in lastStatuses + val statusChanged = lastStatuses[id] != entry.status + firstTime || statusChanged || entry.newMoves.isNotEmpty() + } + + if (changedEntries.isNotEmpty()) { + val result = sendCb(LatestGamesUpdateResponse(changedEntries)) + if (result.isClosed) { + markAsClosed() + return + } else if (result.isFailure) { + logger.error { "failed to send data to live games session $sessionId" } + return + } + } + + // advance the tracked indexes/statuses so the next request only asks for newer moves + update.entries.forEach { entry -> + entry.moveIndex?.let { moveIndexes[entry.gameId.id] = it } + lastStatuses[entry.gameId.id] = entry.status + } + } + + override fun toString() = + "${javaClass.simpleName}{sessionId=$sessionId, games=${subscribedGameIds.size}}" + +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/LobbyRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/LobbyRouting.kt index 07a5488fc..7f885447f 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/LobbyRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/LobbyRouting.kt @@ -1,24 +1,31 @@ package io.elephantchess.webapp.routing.api -import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateRequest -import io.elephantchess.servicelayer.services.GameDataService +import io.elephantchess.servicelayer.dto.ws.LiveGamesSubscription import io.elephantchess.servicelayer.services.LobbyService import io.elephantchess.servicelayer.utils.ops.koin -import io.ktor.server.request.* +import io.elephantchess.webapp.ops.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.server.websocket.* -fun Route.lobbyRoutes() { - val lobbyService by koin() - val gameDataService by koin() +private val lobbyService by koin() +fun Route.lobbyRoutes() { route("/api/lobby") { get("/upcoming-events") { call.respond(lobbyService.listUpcomingEvents()) } - post("/latest-games-update") { - val request = call.receive() - call.respond(gameDataService.fetchLatestGamesUpdate(request)) - } + } +} + +fun Route.lobbyWsRoutes() { + webSocket("ws/lobby/live-games") { + var sessionId: String? = null + + handleBidirectionalWebSocketSession( + { lobbyService.startLiveGamesSession { update -> sendWs(update) }.also { sessionId = it } }, + { subscription -> sessionId?.let { lobbyService.handleLiveGamesSubscription(it, subscription.gameIds) } }, + { lobbyService.closeLiveGamesSession(it) } + ) } } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt index da01c650c..1093ea6df 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt @@ -9,6 +9,7 @@ import io.elephantchess.webapp.routing.api.databaseRoutes import io.elephantchess.webapp.routing.api.gameDataRoutes import io.elephantchess.webapp.routing.api.globalPageRoutes import io.elephantchess.webapp.routing.api.lobbyRoutes +import io.elephantchess.webapp.routing.api.lobbyWsRoutes import io.elephantchess.webapp.routing.api.puzzleRoutes import io.elephantchess.webapp.routing.api.pvpGameRoutes import io.elephantchess.webapp.routing.api.pvpGameWsRoutes @@ -45,6 +46,7 @@ fun Application.apiServiceModule() { globalPageRoutes() userRoutes() lobbyRoutes() + lobbyWsRoutes() supporterRoutes() adminConsoleRoutes() integrationRoutes() diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 42439ff5b..3ce6a653e 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -35,6 +35,11 @@ class LiveGamesViewer { */ #pvbThumbs = []; + /** + * @type {LiveGamesWebSocketSession} + */ + #wsSession; + #totalRefresh = 0; /** @@ -42,10 +47,13 @@ class LiveGamesViewer { */ constructor(settingsGui) { this.#settingsGui = settingsGui; + this.#wsSession = new LiveGamesWebSocketSession((updates) => this.#applyUpdates(updates)); this.#initThumbs(); + // periodically reload the latest games to discover new ones (the individual + // move updates are pushed in real time over the WebSocket) setInterval(() => { - this.#refreshGames(); + this.#completeRefreshIfNeeded(); }, 1_000); } @@ -89,6 +97,7 @@ class LiveGamesViewer { for (let i = 0; i < gameItemsDto.length; i++) { this.#pvpThumbs[i].render(gameItemsDto[i], 'lobby'); } + this.#updateSubscription(); }); } @@ -97,6 +106,7 @@ class LiveGamesViewer { for (let i = 0; i < gameItemsDto.length; i++) { this.#pvbThumbs[i].render(gameItemsDto[i], 'lobby'); } + this.#updateSubscription(); }); } @@ -107,60 +117,54 @@ class LiveGamesViewer { return this.#pvpThumbs.concat(this.#pvbThumbs); } - #refreshGames() { - const thumbs = this.#allThumbs().filter((t) => t.metadata != null); - - if (thumbs.length === 0) { - return; - } - - const gameIdsToUpdate = thumbs + /** + * Declare to the server which games we are watching. The server pushes + * individual move/status updates for those games over the WebSocket. + */ + #updateSubscription() { + const gameIds = this.#allThumbs() .filter((thumb) => this.#shouldRefresh(thumb)) .map((thumb) => thumb.metadata.gameId); - // do a complete refresh/re-initialization of the thumbs every 1 min, - // expect if we're already watching live games - const doCompleteRefreshIfNeeded = (mustSkipCompleteRefresh) => { - if (this.#totalRefresh % 60 === 0 && !mustSkipCompleteRefresh) { + this.#wsSession.subscribe(gameIds); + } + + /** + * Apply the updates pushed over the WebSocket to the matching thumbs. + * + * @param updates {Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>} + */ + #applyUpdates(updates) { + const thumbs = this.#allThumbs().filter((t) => t.metadata != null); + updates.forEach((update) => { + const thumb = thumbs.find((t) => t.metadata.gameId.toString() === update.gameId.toString()); + if (thumb != null) { + thumb.refresh(update); + } + }); + } + + /** + * Reload the latest games (and re-subscribe) every minute, unless we're + * already watching mostly live games, to avoid interrupting their animations. + */ + #completeRefreshIfNeeded() { + if (this.#totalRefresh % 60 === 0) { + const thumbs = this.#allThumbs().filter((t) => t.metadata != null); + const liveGames = thumbs.filter((t) => t.metadata.isLive()).length; + const mustSkipCompleteRefresh = thumbs.length > 0 && liveGames / thumbs.length > 0.5; + + if (!mustSkipCompleteRefresh) { this.#loadLatestPvpGames(); this.#loadLatestPvbGames(); } - - this.#totalRefresh++; - }; - - if (gameIdsToUpdate.length > 0) { - let totalGames = 0; - let liveGames = 0; - - const moveIndexes = Object.fromEntries( - thumbs - .filter((t) => t.currentMoveIndex != null) - .map((t) => [t.metadata.gameId.id, t.currentMoveIndex]) - ); - - this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, moveIndexes, (updates) => { - updates.forEach((update) => { - const thumb = thumbs.find((t) => t.metadata.gameId.toString() === update.gameId.toString()); - if (thumb != null) { - thumb.refresh(update); - totalGames++; - if (this.#isUpdateLive(thumb, update)) { - liveGames++; - } - } - }); - - const mustSkipCompleteRefresh = totalGames > 0 && liveGames / totalGames > 0.5; - doCompleteRefreshIfNeeded(mustSkipCompleteRefresh); - }); - } else { - doCompleteRefreshIfNeeded(false); } + + this.#totalRefresh++; } /** - * Whether the given thumb represents a game that should be polled for updates. + * Whether the given thumb represents a game that should be watched for updates. * * @param thumb {GameThumb} * @returns {boolean} @@ -183,21 +187,4 @@ class LiveGamesViewer { return false; } - /** - * Mirrors {@link GameMetadataDto#isLive} but uses the freshly fetched - * status / lastUpdated from an update payload (lastUpdated must be < 1 min). - * - * @param thumb {GameThumb} - * @param update {{status: string, lastUpdated: number}} - * @returns {boolean} - */ - #isUpdateLive(thumb, update) { - if (thumb.metadata == null) { - return false; - } - const isPvb = thumb.metadata.gameId.type === GameType.PVB; - const inProgress = isStatusInProgress(update.status) || (isPvb && update.status === GameEventType.CREATED); - return inProgress && (new Date().getTime() - update.lastUpdated) <= 60_000; - } - } diff --git a/webapp/src/main/resources/public/js/lobby/lobby-client.js b/webapp/src/main/resources/public/js/lobby/lobby-client.js index e0faf7a20..26108a207 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -67,33 +67,6 @@ class LobbyClient { }); } - /** - * @param gameIds {Array} - * @param moveIndexes {Object} - * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>)} - */ - fetchLatestGamesUpdate(gameIds, moveIndexes, cb) { - const body = { - gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})), - moveIndexes: moveIndexes, - }; - postAndHandle('/api/lobby/latest-games-update', body, (json) => { - const items = []; - for (let i = 0; i < json.entries.length; i++) { - const entry = json.entries[i]; - items.push({ - gameId: new GameId(entry.gameId.type, entry.gameId.id), - status: entry.status, - fen: entry.fen, - lastUpdated: Number(entry.lastUpdated), - moveIndex: entry.moveIndex ?? null, - newMoves: entry.newMoves ?? [], - }); - } - cb(items); - }); - } - /** * @param cb {function([UpcomingEventDto])} */ diff --git a/webapp/src/main/resources/public/js/ws/live-games.js b/webapp/src/main/resources/public/js/ws/live-games.js new file mode 100644 index 000000000..5b51a71e9 --- /dev/null +++ b/webapp/src/main/resources/public/js/ws/live-games.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Encelade SRL + * Copyright (C) 2026 elephantchess.io + * Copyright (C) 2026 BenoƮt Vleminckx (benckx) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * WebSocket session feeding the lobby live games thumbnails. + * + * The client declares which games it watches (via {@link subscribe}); the server + * tracks the last known move index per game and pushes back only the entries that + * changed (new moves or a status change). + */ +class LiveGamesWebSocketSession { + + #socketHandle; + #onUpdate; + + /** + * @type {GameId[]} + */ + #gameIds = []; + + /** + * @param onUpdate {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, moveIndex: number|null, newMoves: string[]}>)} + */ + constructor(onUpdate) { + this.#onUpdate = onUpdate; + + this.#socketHandle = openReconnectingWebSocket({ + endpoint: 'lobby/live-games', + logLabel: 'live-games', + // available to everyone (including guests), no parameters required + buildParams: () => new Map(), + onOpen: () => { + // (re)send the current subscription on every (re)connect + this.#sendSubscription(); + }, + onMessage: (e) => { + const json = JSON.parse(e.data); + const items = json.entries.map((entry) => ({ + gameId: new GameId(entry.gameId.type, entry.gameId.id), + status: entry.status, + fen: entry.fen, + lastUpdated: Number(entry.lastUpdated), + moveIndex: entry.moveIndex ?? null, + newMoves: entry.newMoves ?? [], + })); + this.#onUpdate(items); + }, + }); + } + + /** + * @param gameIds {GameId[]} + */ + subscribe(gameIds) { + this.#gameIds = gameIds; + this.#sendSubscription(); + } + + #sendSubscription() { + const socket = this.#socketHandle.getSocket(); + if (socket != null && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ + gameIds: this.#gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})), + })); + } + } + +} diff --git a/webapp/src/main/resources/templates/lobby.html b/webapp/src/main/resources/templates/lobby.html index b7b3661c9..eea9412f5 100644 --- a/webapp/src/main/resources/templates/lobby.html +++ b/webapp/src/main/resources/templates/lobby.html @@ -7,6 +7,7 @@ + From fee2111c403387de4e1ced75537b3946c982fdbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:03:43 +0000 Subject: [PATCH 08/14] Simplify lobby complete-refresh to a 60s interval --- .../public/js/lobby/live-games-viewer.js | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js index 3ce6a653e..bd8953c49 100644 --- a/webapp/src/main/resources/public/js/lobby/live-games-viewer.js +++ b/webapp/src/main/resources/public/js/lobby/live-games-viewer.js @@ -40,8 +40,6 @@ class LiveGamesViewer { */ #wsSession; - #totalRefresh = 0; - /** * @param settingsGui {SettingsGui} */ @@ -54,7 +52,7 @@ class LiveGamesViewer { // move updates are pushed in real time over the WebSocket) setInterval(() => { this.#completeRefreshIfNeeded(); - }, 1_000); + }, 60_000); } #initThumbs() { @@ -145,22 +143,18 @@ class LiveGamesViewer { } /** - * Reload the latest games (and re-subscribe) every minute, unless we're - * already watching mostly live games, to avoid interrupting their animations. + * Reload the latest games (and re-subscribe), unless we're already watching + * mostly live games, to avoid interrupting their animations. */ #completeRefreshIfNeeded() { - if (this.#totalRefresh % 60 === 0) { - const thumbs = this.#allThumbs().filter((t) => t.metadata != null); - const liveGames = thumbs.filter((t) => t.metadata.isLive()).length; - const mustSkipCompleteRefresh = thumbs.length > 0 && liveGames / thumbs.length > 0.5; - - if (!mustSkipCompleteRefresh) { - this.#loadLatestPvpGames(); - this.#loadLatestPvbGames(); - } - } + const thumbs = this.#allThumbs().filter((t) => t.metadata != null); + const liveGames = thumbs.filter((t) => t.metadata.isLive()).length; + const mustSkipCompleteRefresh = thumbs.length > 0 && liveGames / thumbs.length > 0.5; - this.#totalRefresh++; + if (!mustSkipCompleteRefresh) { + this.#loadLatestPvpGames(); + this.#loadLatestPvbGames(); + } } /** From 9bc6cc60e53d64e35f1d28ebab65cd5cc0f3642c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:35:00 +0000 Subject: [PATCH 09/14] Cancel LobbyService refresher job during shutdown --- .../servicelayer/utils/ShutdownHandler.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt index bd33829b5..1021cddf1 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt @@ -2,6 +2,7 @@ package io.elephantchess.servicelayer.utils import io.elephantchess.engines.EnginePool import io.elephantchess.servicelayer.batch.definitions.BatchesScheduler +import io.elephantchess.servicelayer.services.LobbyService import io.elephantchess.servicelayer.services.PlayerVsBotGameService import io.elephantchess.servicelayer.services.PlayerVsPlayerGameService import io.elephantchess.servicelayer.services.PuzzleCache @@ -26,6 +27,7 @@ class ShutdownHandler( private val enginePool: EnginePool, private val batchesScheduler: BatchesScheduler, private val siteMapService: SiteMapService, + private val lobbyService: LobbyService, private val coroutineScope: CoroutineScope, ) { @@ -82,6 +84,14 @@ class ShutdownHandler( logger.error(e) { "error cancelling batch scheduler" } } + try { + // cancel LobbyService + logger.info { "cancelling LobbyService..." } + lobbyService.cancel() + } catch (e: Exception) { + logger.error(e) { "error cancelling batch LobbyService" } + } + try { // close engine pool logger.info { "closing engine pool..." } From caceb8e033ec725dfde17e7ed9fe15d143cc5d69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:35:31 +0000 Subject: [PATCH 10/14] Fix LobbyService shutdown error message wording --- .../io/elephantchess/servicelayer/utils/ShutdownHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt index 1021cddf1..63c833ee1 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/utils/ShutdownHandler.kt @@ -89,7 +89,7 @@ class ShutdownHandler( logger.info { "cancelling LobbyService..." } lobbyService.cancel() } catch (e: Exception) { - logger.error(e) { "error cancelling batch LobbyService" } + logger.error(e) { "error cancelling LobbyService" } } try { From 3583129f3ae06f8fb7c7bafe30068a3c5e2d0913 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:14:00 +0000 Subject: [PATCH 11/14] Batch live games fetch across lobby sessions --- .../servicelayer/services/LobbyService.kt | 26 +++++++++--- .../services/ws/LiveGamesWebSocketSession.kt | 42 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt index e0ebb9dae..b048aa9c7 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt @@ -4,7 +4,9 @@ import io.elephantchess.db.services.UpcomingEventDaoService import io.elephantchess.model.GameId import io.elephantchess.servicelayer.dto.lobby.GetUpcomingEventsResponse import io.elephantchess.servicelayer.dto.lobby.GetUpcomingEventsResponse.UpcomingEvent +import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateRequest import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateResponse +import io.elephantchess.servicelayer.services.ws.LiveGamesBatchUpdate import io.elephantchess.servicelayer.services.ws.LiveGamesWebSocketSession import io.elephantchess.servicelayer.utils.ops.launchAtFixedRate import io.github.oshai.kotlinlogging.KLogger @@ -72,13 +74,25 @@ class LobbyService( private suspend fun refreshLiveGamesSessions() { if (liveGamesSessions.isNotEmpty()) { - // TODO: not very optimized: lobby sessions watch mostly the same games -> some - // information is fetched once per session instead of being batched across sessions - liveGamesSessions.forEach { session -> - val request = session.currentRequest() - if (request.gameIds.isNotEmpty()) { - session.update(gameDataService.fetchLatestGamesUpdate(request)) + // Lobby sessions watch mostly the same games, so fetch everything once: collect + // all watched games and, per game, the lowest tracked move index across sessions + // (so the single fetch covers every move any session may still need). Each session + // then slices out only the moves it should actually receive. + val requests = liveGamesSessions.map { session -> session.currentRequest() } + val allGameIds = requests.flatMap { request -> request.gameIds }.distinct() + + if (allGameIds.isNotEmpty()) { + val batchMoveIndexes = mutableMapOf() + requests.forEach { request -> + request.moveIndexes.forEach { (gameId, index) -> + batchMoveIndexes.merge(gameId, index, ::minOf) + } } + + val request = LatestGamesUpdateRequest(allGameIds, batchMoveIndexes) + val response = gameDataService.fetchLatestGamesUpdate(request) + val batchUpdate = LiveGamesBatchUpdate(response, batchMoveIndexes) + liveGamesSessions.forEach { session -> session.update(batchUpdate) } } // remove the sessions that are not active anymore diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt index b34091eff..07b809744 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt @@ -6,6 +6,16 @@ import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateRequest import io.elephantchess.servicelayer.dto.lobby.LatestGamesUpdateResponse import kotlinx.coroutines.channels.ChannelResult +/** + * Batched update handed to every live games session: the [response] is fetched once + * for the union of all watched games (using the lowest tracked move index per game, + * see [batchMoveIndexes]) so each session can slice out only the moves it still needs. + */ +data class LiveGamesBatchUpdate( + val response: LatestGamesUpdateResponse, + val batchMoveIndexes: Map, +) + /** * WebSocket session for a lobby client watching the live games thumbnails. * @@ -16,7 +26,7 @@ import kotlinx.coroutines.channels.ChannelResult */ class LiveGamesWebSocketSession( private val sendCb: (LatestGamesUpdateResponse) -> ChannelResult, -) : WebSocketSession() { +) : WebSocketSession() { private var subscribedGameIds: List = emptyList() private val moveIndexes = mutableMapOf() @@ -37,8 +47,13 @@ class LiveGamesWebSocketSession( moveIndexes = moveIndexes.toMap() ) - override fun update(update: LatestGamesUpdateResponse) { - val changedEntries = update.entries.filter { entry -> + override fun update(update: LiveGamesBatchUpdate) { + val watchedIds = subscribedGameIds.map { it.id }.toSet() + val entries = update.response.entries + .filter { entry -> entry.gameId.id in watchedIds } + .map { entry -> sliceForSession(entry, update.batchMoveIndexes) } + + val changedEntries = entries.filter { entry -> val id = entry.gameId.id val firstTime = id !in lastStatuses val statusChanged = lastStatuses[id] != entry.status @@ -57,12 +72,31 @@ class LiveGamesWebSocketSession( } // advance the tracked indexes/statuses so the next request only asks for newer moves - update.entries.forEach { entry -> + entries.forEach { entry -> entry.moveIndex?.let { moveIndexes[entry.gameId.id] = it } lastStatuses[entry.gameId.id] = entry.status } } + /** + * The batched moves start at [LiveGamesBatchUpdate.batchMoveIndexes] (the lowest index + * any session needed), so drop the moves this session has already seen. On the first + * update for a game (no tracked index yet) no moves are animated: the client loads the fen. + */ + private fun sliceForSession( + entry: LatestGamesUpdateResponse.Entry, + batchMoveIndexes: Map, + ): LatestGamesUpdateResponse.Entry { + val tracked = moveIndexes[entry.gameId.id] + val batchFrom = batchMoveIndexes[entry.gameId.id] + val newMoves = when { + tracked == null -> emptyList() + batchFrom == null -> entry.newMoves + else -> entry.newMoves.drop(tracked - batchFrom) + } + return entry.copy(newMoves = newMoves) + } + override fun toString() = "${javaClass.simpleName}{sessionId=$sessionId, games=${subscribedGameIds.size}}" From 31e78b86196d604962cd8bd0220e4748a3eeb840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:14:46 +0000 Subject: [PATCH 12/14] Guard against negative drop count in move slicing --- .../servicelayer/services/ws/LiveGamesWebSocketSession.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt index 07b809744..3fa2cb849 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt @@ -92,7 +92,7 @@ class LiveGamesWebSocketSession( val newMoves = when { tracked == null -> emptyList() batchFrom == null -> entry.newMoves - else -> entry.newMoves.drop(tracked - batchFrom) + else -> entry.newMoves.drop(maxOf(0, tracked - batchFrom)) } return entry.copy(newMoves = newMoves) } From d97e70e79444f7ec9c400e0815738eeea12500ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:19:00 +0000 Subject: [PATCH 13/14] Remove closed live games sessions at start of refresh --- .../servicelayer/services/LobbyService.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt index b048aa9c7..52a2d98ea 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/LobbyService.kt @@ -73,6 +73,12 @@ class LobbyService( } private suspend fun refreshLiveGamesSessions() { + // remove the sessions that are not active anymore before doing any work + liveGamesSessions.removeIf { session -> + if (session.isClosed) logger.debug { "removing $session" } + session.isClosed + } + if (liveGamesSessions.isNotEmpty()) { // Lobby sessions watch mostly the same games, so fetch everything once: collect // all watched games and, per game, the lowest tracked move index across sessions @@ -94,12 +100,6 @@ class LobbyService( val batchUpdate = LiveGamesBatchUpdate(response, batchMoveIndexes) liveGamesSessions.forEach { session -> session.update(batchUpdate) } } - - // remove the sessions that are not active anymore - liveGamesSessions.removeIf { session -> - if (session.isClosed) logger.debug { "removing $session" } - session.isClosed - } } } From 936e1b2ab15f41d19db433809ec4741d25f176d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:44:49 +0000 Subject: [PATCH 14/14] Parse UCI move strings to HalfMove before animating lobby thumbs --- webapp/src/main/resources/public/js/widgets/game-thumb.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/src/main/resources/public/js/widgets/game-thumb.js b/webapp/src/main/resources/public/js/widgets/game-thumb.js index c0cbeff66..abb5d827b 100644 --- a/webapp/src/main/resources/public/js/widgets/game-thumb.js +++ b/webapp/src/main/resources/public/js/widgets/game-thumb.js @@ -88,7 +88,7 @@ class GameThumb { currentMoveIndex = null; /** - * @type {string[]} + * @type {HalfMove[]} */ #moveQueue = []; @@ -383,7 +383,8 @@ class GameThumb { */ refresh(update) { if (update.newMoves != null && update.newMoves.length > 0) { - this.#moveQueue = this.#moveQueue.concat(update.newMoves); + const parsedMoves = update.newMoves.map(uci => HalfMove.parseUci(uci)); + this.#moveQueue = this.#moveQueue.concat(parsedMoves); if (!this.#isProcessingMoveQueue) { this.#processMoveQueue(); }