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 25bffd2a4..9b1970aa4 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 @@ -228,6 +228,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-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt index 1a40fbf03..30a9f87ef 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 e3d279feb..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 @@ -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 moveIndexes: 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/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/GameDataService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/GameDataService.kt index cf2203568..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,24 +736,56 @@ 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()) + val pvpNewMoves = if (request.moveIndexes.isNotEmpty()) { + val moveTuples = pvpGames + .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.moveIndexes.isNotEmpty()) { + val moveTuples = pvbGames + .filter { game -> request.moveIndexes.containsKey(game.id) } + .map { game -> game.id to request.moveIndexes[game.id]!! } + pvbGameDaoService.fetchNewMovesForGamesAndIndexes(moveTuples) + .groupBy { it.botGameId } + } else { + emptyMap() + } + 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] + ?.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-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..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 @@ -1,13 +1,40 @@ 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.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 +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 +51,56 @@ 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() { + // 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 + // (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) } + } + } + } + } 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..3fa2cb849 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/LiveGamesWebSocketSession.kt @@ -0,0 +1,103 @@ +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 + +/** + * 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. + * + * 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: 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 + 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 + 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(maxOf(0, tracked - batchFrom)) + } + return entry.copy(newMoves = newMoves) + } + + override fun toString() = + "${javaClass.simpleName}{sessionId=$sessionId, games=${subscribedGameIds.size}}" + +} 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..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 @@ -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 LobbyService" } + } + try { // close engine pool logger.info { "closing engine pool..." } 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 23f35c3cc..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 @@ -35,18 +35,24 @@ class LiveGamesViewer { */ #pvbThumbs = []; - #totalRefresh = 0; + /** + * @type {LiveGamesWebSocketSession} + */ + #wsSession; /** * @param settingsGui {SettingsGui} */ 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(); - }, 1_000); + this.#completeRefreshIfNeeded(); + }, 60_000); } #initThumbs() { @@ -78,6 +84,7 @@ class LiveGamesViewer { elementId: boardElementId, showCoordinates: false, mini: true, + playSounds: false, }); this.#settingsGui.addBoardGui(boardGui); return new GameThumb(div, boardGui); @@ -88,6 +95,7 @@ class LiveGamesViewer { for (let i = 0; i < gameItemsDto.length; i++) { this.#pvpThumbs[i].render(gameItemsDto[i], 'lobby'); } + this.#updateSubscription(); }); } @@ -96,6 +104,7 @@ class LiveGamesViewer { for (let i = 0; i < gameItemsDto.length; i++) { this.#pvbThumbs[i].render(gameItemsDto[i], 'lobby'); } + this.#updateSubscription(); }); } @@ -106,54 +115,50 @@ 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.#loadLatestPvpGames(); - this.#loadLatestPvbGames(); + 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), unless we're already watching + * mostly live games, to avoid interrupting their animations. + */ + #completeRefreshIfNeeded() { + 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 (gameIdsToUpdate.length > 0) { - let totalGames = 0; - let liveGames = 0; - - this.#client.fetchLatestGamesUpdate(gameIdsToUpdate, (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); + if (!mustSkipCompleteRefresh) { + this.#loadLatestPvpGames(); + this.#loadLatestPvbGames(); } } /** - * 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} @@ -176,21 +181,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 51587861d..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,29 +67,6 @@ class LobbyClient { }); } - /** - * @param gameIds {Array} - * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number}>)} - */ - fetchLatestGamesUpdate(gameIds, cb) { - const body = { - gameIds: gameIds.map((gameId) => ({type: gameId.type, id: gameId.id})) - }; - 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), - }); - } - cb(items); - }); - } - /** * @param cb {function([UpcomingEventDto])} */ 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..abb5d827b 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 {HalfMove[]} + */ + #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,22 @@ 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) { + const parsedMoves = update.newMoves.map(uci => HalfMove.parseUci(uci)); + this.#moveQueue = this.#moveQueue.concat(parsedMoves); + 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 +411,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). 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 @@ +