From 5171d1822fe1f3ccc1bf1be5415a888a5d66af8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 07:12:40 +0000 Subject: [PATCH 1/4] Initial plan From 4aab0550fd4e06d7a882885913be767433084e5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 07:18:16 +0000 Subject: [PATCH 2/4] feat: update live game viewer to show outcome (blue star) and player online status - Extend LatestGamesUpdateResponse.Entry with outcome, isRedOnline, isBlackOnline - Fetch OUTCOME, INVITER, INVITEE, INVITER_COLOR for PvP games in fetchCurrentStatusAndFen - Fetch OUTCOME, USER_ID, USER_COLOR for PvB games in fetchCurrentStatusAndFen - Compute online status in fetchLatestGamesUpdate service method - Update LobbyClient.fetchLatestGamesUpdate JS to pass new fields - Update GameThumb.refresh() to update outcome (blue star) and player online indicators Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/e74c01a0-a636-4a7a-bbb9-b5ca38fdf6cd Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../db/services/PlayerVsBotGameDaoService.kt | 5 ++- .../services/PlayerVsPlayerGameDaoService.kt | 6 +++- .../dto/lobby/LatestGamesUpdateResponse.kt | 6 +++- .../servicelayer/services/GameDataService.kt | 20 +++++++++-- .../resources/public/js/lobby/lobby-client.js | 5 ++- .../resources/public/js/widgets/game-thumb.js | 36 ++++++++++++++++++- 6 files changed, 71 insertions(+), 7 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 92e2a5d3d..08314f0c4 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 @@ -195,7 +195,10 @@ class PlayerVsBotGameDaoService(private val dslContext: DSLContext) { BOT_GAME.ID, BOT_GAME.GAME_STATUS, BOT_GAME.CURRENT_FEN, - BOT_GAME.LAST_UPDATED + BOT_GAME.LAST_UPDATED, + BOT_GAME.OUTCOME, + BOT_GAME.USER_ID, + BOT_GAME.USER_COLOR, ) .from(BOT_GAME) .where(BOT_GAME.ID.`in`(gameIds)) 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 7dcdc968e..01b4eacb2 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 @@ -357,7 +357,11 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { GAME.ID, GAME.GAME_STATUS, GAME.CURRENT_FEN, - GAME.LAST_UPDATED + GAME.LAST_UPDATED, + GAME.OUTCOME, + GAME.INVITER, + GAME.INVITEE, + GAME.INVITER_COLOR, ) .from(GAME) .where(GAME.ID.`in`(gameIds)) 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..686d7c5ec 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 @@ -2,6 +2,7 @@ package io.elephantchess.servicelayer.dto.lobby import io.elephantchess.model.GameEventType import io.elephantchess.model.GameId +import io.elephantchess.model.Outcome data class LatestGamesUpdateResponse(val entries: List) { @@ -9,7 +10,10 @@ data class LatestGamesUpdateResponse(val entries: List) { val gameId: GameId, val status: GameEventType, val fen: String, - val lastUpdated : Long + val lastUpdated: Long, + val outcome: Outcome? = null, + val isRedOnline: Boolean = false, + val isBlackOnline: Boolean = false, ) } 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 ac2b8334c..5c9df8245 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 @@ -667,19 +667,35 @@ class GameDataService( val pvpGames = pvpGameDaoService.fetchCurrentStatusAndFen(idsByType[PVP].orEmpty()) val pvbGames = pvbGameDaoService.fetchCurrentStatusAndFen(idsByType[PVB].orEmpty()) + val pvpUserIds = pvpGames.flatMap { game -> listOf(game.redUserId(), game.blackUserId()) }.filterNotNull() + val pvbUserIds = pvbGames.mapNotNull { game -> game.userId } + val allUserIds = (pvpUserIds + pvbUserIds).distinct() + val lastOnlineByUserId = if (allUserIds.isNotEmpty()) userDaoService.fetchLastOnline(allUserIds) else emptyMap() + val isOnlineLimit = Clock.System.now().minusSeconds(15L) + val entries = pvpGames.map { game -> + val redUserId = game.redUserId() + val blackUserId = game.blackUserId() LatestGamesUpdateResponse.Entry( gameId = GameId(PVP, game.id), status = game.gameStatus, fen = game.currentFen, - lastUpdated = game.lastUpdated.toEpochMilliseconds() + lastUpdated = game.lastUpdated.toEpochMilliseconds(), + outcome = game.outcome, + isRedOnline = lastOnlineByUserId[redUserId]?.isAfter(isOnlineLimit) == true, + isBlackOnline = lastOnlineByUserId[blackUserId]?.isAfter(isOnlineLimit) == true, ) } + pvbGames.map { game -> + val userId = game.userId + val isUserOnline = lastOnlineByUserId[userId]?.isAfter(isOnlineLimit) == true LatestGamesUpdateResponse.Entry( gameId = GameId(PVB, game.id), status = game.gameStatus, fen = game.currentFen, - lastUpdated = game.lastUpdated.toEpochMilliseconds() + lastUpdated = game.lastUpdated.toEpochMilliseconds(), + outcome = game.outcome, + isRedOnline = if (game.userColor == RED) isUserOnline else true, + isBlackOnline = if (game.userColor == BLACK) isUserOnline else true, ) } 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 2497abc2c..5558cc076 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -69,7 +69,7 @@ class LobbyClient { /** * @param gameIds {Array} - * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number}>)} + * @param cb {function(Array<{gameId: GameId, status: string, fen: string, lastUpdated: number, outcome: string|null, isRedOnline: boolean, isBlackOnline: boolean}>)} */ fetchLatestGamesUpdate(gameIds, cb) { const body = { @@ -84,6 +84,9 @@ class LobbyClient { status: entry.status, fen: entry.fen, lastUpdated: Number(entry.lastUpdated), + outcome: entry.outcome ?? null, + isRedOnline: entry.isRedOnline === true, + isBlackOnline: entry.isBlackOnline === true, }); } 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 1b168eb6a..c108549a1 100644 --- a/webapp/src/main/resources/public/js/widgets/game-thumb.js +++ b/webapp/src/main/resources/public/js/widgets/game-thumb.js @@ -350,7 +350,7 @@ 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, outcome: string|null, isRedOnline: boolean, isBlackOnline: boolean}} */ refresh(update) { this.boardGui.loadFen(update.fen, true); @@ -368,6 +368,40 @@ class GameThumb { if (lastUpdatedEl != null && update.lastUpdated > 0) { lastUpdatedEl.innerHTML = formatTimestampToRelativeTime(update.lastUpdated); } + + // outcome (blue star) + const redPlayerWinsElement = this.#findFirst('red-player-wins'); + const blackPlayerWinsElement = this.#findFirst('black-player-wins'); + if (redPlayerWinsElement != null && blackPlayerWinsElement != null) { + redPlayerWinsElement.innerHTML = ''; + blackPlayerWinsElement.innerHTML = ''; + switch (update.outcome) { + case Outcome.RED_WINS: + redPlayerWinsElement.innerHTML = WINNER_BLUE_STAR_HTML; + break; + case Outcome.BLACK_WINS: + blackPlayerWinsElement.innerHTML = WINNER_BLUE_STAR_HTML; + break; + default: + break; + } + } + + // online status + const redPlayerStatusIndicator = this.#findFirst('red-player-status-indicator'); + const blackPlayerStatusIndicator = this.#findFirst('black-player-status-indicator'); + if (redPlayerStatusIndicator != null && blackPlayerStatusIndicator != null) { + if (update.isRedOnline) { + redPlayerStatusIndicator.classList.add(IS_ONLINE_CSS_CLASS); + } else { + redPlayerStatusIndicator.classList.remove(IS_ONLINE_CSS_CLASS); + } + if (update.isBlackOnline) { + blackPlayerStatusIndicator.classList.add(IS_ONLINE_CSS_CLASS); + } else { + blackPlayerStatusIndicator.classList.remove(IS_ONLINE_CSS_CLASS); + } + } } /** From 075414270293bdfdbb5eb5ae4fd2381b5e845a84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 07:20:03 +0000 Subject: [PATCH 3/4] refactor: extract ONLINE_STATUS_THRESHOLD_SECONDS constant in GameDataService Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/e74c01a0-a636-4a7a-bbb9-b5ca38fdf6cd Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .../elephantchess/servicelayer/services/GameDataService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 5c9df8245..09a94b755 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 @@ -496,7 +496,7 @@ class GameDataService( val userIds = games.flatMap { game -> listOf(game.inviter, game.invitee) }.distinct().filterNotNull() val lastOnlineByUserId = userDaoService.fetchLastOnline(userIds) - val isOnlineLimit = Clock.System.now().minusSeconds(15L) + val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) val selectedGames = if (distinctByUsers) distinctByUserId(games) else games return selectedGames @@ -548,7 +548,7 @@ class GameDataService( val userIds = gameRecords.map { game -> game.userId }.distinct().filterNotNull() val lastOnlineByUserId = userDaoService.fetchLastOnline(userIds) - val isOnlineLimit = Clock.System.now().minusSeconds(15L) + val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) return gameRecords .take(requestedLimit) @@ -671,7 +671,7 @@ class GameDataService( val pvbUserIds = pvbGames.mapNotNull { game -> game.userId } val allUserIds = (pvpUserIds + pvbUserIds).distinct() val lastOnlineByUserId = if (allUserIds.isNotEmpty()) userDaoService.fetchLastOnline(allUserIds) else emptyMap() - val isOnlineLimit = Clock.System.now().minusSeconds(15L) + val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) val entries = pvpGames.map { game -> val redUserId = game.redUserId() @@ -706,6 +706,7 @@ class GameDataService( const val DEFAULT_ANALYSIS_DEPTH = 20 const val MIN_MOVE_INDEX = 6 + const val ONLINE_STATUS_THRESHOLD_SECONDS = 15L } From 4d05aa7376a82122f4046b017043924abd272e21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 09:20:15 +0000 Subject: [PATCH 4/4] Merge origin/master into copilot/update-live-game-viewer-outcome Resolve conflict in GameDataService: switch fetchLatestGamesUpdate from userDaoService.fetchLastOnline() to userService.areOnline() to align with the approach used in the rest of the service after master was updated. Also removes the now-unused ONLINE_STATUS_THRESHOLD_SECONDS constant. Agent-Logs-Url: https://github.com/benckx/elephantchess/sessions/0727fae2-2a15-47f7-9e69-77289df8fb37 Co-authored-by: benckx <8626080+benckx@users.noreply.github.com> --- .github/dependabot.yml | 2 +- CONTRIBUTING.md | 9 + README.md | 58 +- build.gradle | 388 ---- build.gradle.kts | 409 ++++ gradle/wrapper/gradle-wrapper.properties | 2 +- .../scripts/CreateVietnameseTestUsers.kt | 60 + .../scripts/game/CreateListOfGamesToJoin.kt | 99 +- .../io/elephantchess/scripts/game/Utils.kt | 5 +- settings.gradle | 16 - settings.gradle.kts | 23 + utils/src/main/resources/bad-words.txt | 2 + .../liquibase-changelog-generation.xml | 75 + .../main/resources/liquibase-changelog.xml | 95 + .../codegen/OffsetDateTimeInstantConverter.kt | 2 +- .../db/model/UserSessionRecord.kt | 1 + .../GameChatTypingStatusDaoService.kt | 50 + .../db/services/PlayerVsBotGameDaoService.kt | 62 +- .../services/PlayerVsPlayerGameDaoService.kt | 37 + .../db/services/PuzzleResultDaoService.kt | 10 +- .../db/services/ReferenceGameDaoService.kt | 132 +- .../db/services/ReferencePlayerDaoService.kt | 10 +- .../db/services/UserDaoService.kt | 66 +- .../db/services/UserSessionDaoService.kt | 40 + .../io/elephantchess/db/utils/JooqOps.kt | 10 +- .../io/elephantchess/db/utils/RecordOps.kt | 4 + .../KtorHtmlBuilderTagResolver.kt | 20 + .../KtorHtmlBuilderTagResolverTest.kt | 34 + .../elephantchess/servicelayer/KoinModule.kt | 1 + .../dto/database/DatabaseGameSummary.kt | 24 + .../dto/database/MyDbSearchesResponse.kt | 19 + .../dto/user/DeleteUserSessionsRequest.kt | 3 + .../dto/user/DeleteUserSessionsResponse.kt | 3 + .../dto/user/EmailAddressSettingsResponse.kt | 2 +- .../dto/user/EmailValidityStatus.kt | 43 + .../servicelayer/dto/user/SignUpRequest.kt | 3 +- .../dto/user/UserSessionsSettingsResponse.kt | 19 + .../dto/ws/PlayerVsPlayerInput.kt | 3 +- .../dto/ws/PlayerVsPlayerUpdate.kt | 7 + .../servicelayer/services/DatabaseService.kt | 92 +- .../servicelayer/services/GameDataService.kt | 158 +- .../servicelayer/services/MailService.kt | 71 +- .../services/PlayerVsPlayerGameService.kt | 54 +- .../servicelayer/services/UserService.kt | 156 +- .../services/admin/AdminFeedService.kt | 2 +- .../EmailConfirmationLinkTagResolver.kt | 19 + .../services/resolvers/ResolverUtils.kt | 6 +- .../ws/PlayerVsPlayerWebSocketSession.kt | 8 +- .../mail_templates/email_confirmation.html | 11 + .../mail_templates/new_user_notification.html | 3 +- .../servicelayer/services/UserServiceTest.kt | 511 ++++- .../services/resolvers/ResolverUtilsTest.kt | 17 + .../elephantchess/webapp/WebAppKoinModule.kt | 2 + .../webapp/ops/ValidationRoutingOps.kt | 9 + .../rendering/BoardGuiExampleRenderer.kt | 7 +- .../webapp/rendering/ChangelogPageRenderer.kt | 58 + .../webapp/rendering/DatabasePageRenderer.kt | 178 +- .../webapp/rendering/DatabaseTagResolvers.kt | 8 +- .../webapp/rendering/FaqPageRenderer.kt | 55 + .../webapp/rendering/TagResolvers.kt | 40 +- .../rendering/UserProfilePageRenderer.kt | 48 +- ...icationRoutes.kt => EmailSettingRoutes.kt} | 29 +- .../webapp/routing/api/DatabaseRouting.kt | 6 + .../webapp/routing/api/GameDataRouting.kt | 21 +- .../webapp/routing/api/UserRouting.kt | 43 +- .../routing/html/HtmlDatabaseRouting.kt | 6 + .../webapp/routing/html/HtmlRouting.kt | 42 +- .../webapp/server/ApiServiceRoutingModule.kt | 1 - .../main/resources/modals/import-moves.html | 2 +- webapp/src/main/resources/modals/signup.html | 6 + .../main/resources/public/css/about-faq.css | 40 + .../src/main/resources/public/css/board.css | 4 + .../public/css/common/nice-table.css | 88 + .../css/database/database-game-viewer.css | 18 +- .../public/css/database/database.css | 51 +- .../src/main/resources/public/css/lobby.css | 304 ++- .../resources/public/css/my-db-searches.css | 55 + .../resources/public/css/new-game-modal.css | 2 +- .../resources/public/css/player-vs-player.css | 33 + .../resources/public/css/style-reactive.css | 98 +- .../src/main/resources/public/css/style.css | 212 +- .../resources/public/css/user-profile.css | 12 + .../resources/public/css/user-settings.css | 65 + .../resources/public/dist/0.0.3/board-gui.js | 1735 +++++++++++++++ .../resources/public/dist/0.0.3/board.css | 414 ++++ .../resources/public/dist/0.0.3/xiangqi.js | 1405 +++++++++++++ .../resources/public/dist/0.0.4/board-gui.js | 1855 +++++++++++++++++ .../resources/public/dist/0.0.4/board.css | 418 ++++ .../resources/public/dist/0.0.4/xiangqi.js | 1405 +++++++++++++ .../public/images/icons/data-search.png | Bin 0 -> 22139 bytes .../resources/public/images/icons/link.png | Bin 0 -> 11789 bytes .../resources/public/images/icons/more.png | Bin 0 -> 4637 bytes .../public/images/icons/os/android.png | Bin 0 -> 12824 bytes .../public/images/icons/os/apple.png | Bin 0 -> 12650 bytes .../public/images/icons/os/linux.png | Bin 0 -> 39186 bytes .../public/images/icons/os/windows.png | Bin 0 -> 6027 bytes .../resources/public/images/icons/sunbed.png | Bin 0 -> 10501 bytes .../resources/public/images/icons/sunset.png | Bin 0 -> 27479 bytes .../public/images/icons/trophy-football.png | Bin 0 -> 16871 bytes .../resources/public/js/about/changelog.js | 36 + .../analysis-board/engine-analysis-widget.js | 61 + .../main/resources/public/js/browse-games.js | 34 +- .../resources/public/js/browse-pvp-games.js | 36 +- .../js/database/database-game-viewer.js | 177 +- .../public/js/database/database-search.js | 67 +- .../public/js/infinite-scroll-page.js | 8 +- .../resources/public/js/lobby/lobby-client.js | 2 +- .../main/resources/public/js/lobby/lobby.js | 186 +- .../js/modal-handlers/signup-modal-handler.js | 5 + .../public/js/modules/parser-common.js | 1 + .../public/js/modules/parser-iccs.js | 51 + .../js/modules/time-control-categories.js | 2 + .../main/resources/public/js/modules/ui.js | 25 +- .../main/resources/public/js/modules/user.js | 2 +- .../js/player-vs-bot/player-vs-bot-page.js | 23 +- .../player-vs-player-controller.js | 16 +- .../player-vs-player/player-vs-player-page.js | 58 +- .../main/resources/public/js/simple-board.js | 18 +- .../js/user-profile/user-profile-games.js | 94 + .../public/js/user-profile/user-profile.js | 8 + .../js/user-settings/user-sessions-page.js | 82 + .../js/user-settings/user-sessions-widget.js | 277 +++ .../public/js/user-settings/user-settings.js | 58 +- .../public/js/userdata/my-db-searches-dto.js | 108 + .../public/js/userdata/my-db-searches.js | 203 ++ .../js/widgets/analysis-summary-report.js | 13 +- .../resources/public/js/widgets/board-gui.js | 269 ++- .../public/js/widgets/chat-box-widget.js | 103 + .../js/widgets/database-autocompletion.js | 7 + .../public/js/widgets/drop-down-menu.js | 96 +- .../public/js/widgets/eval-line-chart.js | 153 ++ .../public/js/widgets/move-tree-widget.js | 78 +- .../public/js/widgets/settings-manager.js | 428 +++- .../main/resources/templates/about/about.html | 20 +- .../resources/templates/about/changelog.html | 355 +++- .../main/resources/templates/about/faq.html | 41 +- .../resources/templates/analysis_board.html | 2 + .../resources/templates/browse_pvb_games.html | 4 +- .../resources/templates/browse_pvp_games.html | 4 +- .../database/browse_db_event_games.html | 2 +- .../database/browse_db_player_games.html | 2 +- .../templates/database/database_event.html | 3 +- .../database/database_events_list.html | 1 + .../database/database_game_viewer.html | 14 +- .../database/database_players_list.html | 1 + .../email_confirmation_error.html | 20 + .../email_confirmation/email_confirmed.html | 15 + .../src/main/resources/templates/lobby.html | 17 +- .../resources/templates/player_vs_bot.html | 1 + .../templates/player_vs_player_game.html | 5 + .../resources/templates/simple_board.html | 3 + .../unsubscribe/unsubscribed_from_all.html | 2 +- .../unsubscribed_from_newsletter.html | 2 +- .../templates/user_browse_pvp_games.html | 33 + .../resources/templates/user_profile.html | 20 +- .../resources/templates/user_sessions.html | 50 + .../resources/templates/user_settings.html | 62 +- .../templates/userdata/my_db_searches.html | 33 + .../analysis_summary_report.html | 1 + .../resources/web_fragments/board_js.html | 1 + .../resources/web_fragments/chat_box.html | 1 + .../faq_import_moves_formats_examples.html | 21 + .../main/resources/web_fragments/footer.html | 2 +- .../web_fragments/head_description.txt | 2 +- .../logged_as_guest_message.html | 4 + .../web_fragments/settings_info_box.html | 177 +- .../main/resources/web_fragments/top_bar.html | 2 +- 167 files changed, 14050 insertions(+), 1389 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 build.gradle create mode 100644 build.gradle.kts create mode 100644 scripts/src/main/kotlin/io/elephantchess/scripts/CreateVietnameseTestUsers.kt delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts create mode 100644 webapp-dao/src/main/kotlin/io/elephantchess/db/services/GameChatTypingStatusDaoService.kt create mode 100644 webapp-html-renderer/src/main/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolver.kt create mode 100644 webapp-html-renderer/src/test/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolverTest.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/DatabaseGameSummary.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/MyDbSearchesResponse.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsRequest.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsResponse.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailValidityStatus.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserSessionsSettingsResponse.kt create mode 100644 webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/EmailConfirmationLinkTagResolver.kt create mode 100644 webapp-service-layer/src/main/resources/mail_templates/email_confirmation.html create mode 100644 webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtilsTest.kt create mode 100644 webapp/src/main/kotlin/io/elephantchess/webapp/rendering/ChangelogPageRenderer.kt create mode 100644 webapp/src/main/kotlin/io/elephantchess/webapp/rendering/FaqPageRenderer.kt rename webapp/src/main/kotlin/io/elephantchess/webapp/routing/{EmailNotificationRoutes.kt => EmailSettingRoutes.kt} (69%) create mode 100644 webapp/src/main/resources/public/css/common/nice-table.css create mode 100644 webapp/src/main/resources/public/css/my-db-searches.css create mode 100644 webapp/src/main/resources/public/dist/0.0.3/board-gui.js create mode 100644 webapp/src/main/resources/public/dist/0.0.3/board.css create mode 100644 webapp/src/main/resources/public/dist/0.0.3/xiangqi.js create mode 100644 webapp/src/main/resources/public/dist/0.0.4/board-gui.js create mode 100644 webapp/src/main/resources/public/dist/0.0.4/board.css create mode 100644 webapp/src/main/resources/public/dist/0.0.4/xiangqi.js create mode 100644 webapp/src/main/resources/public/images/icons/data-search.png create mode 100644 webapp/src/main/resources/public/images/icons/link.png create mode 100644 webapp/src/main/resources/public/images/icons/more.png create mode 100644 webapp/src/main/resources/public/images/icons/os/android.png create mode 100644 webapp/src/main/resources/public/images/icons/os/apple.png create mode 100644 webapp/src/main/resources/public/images/icons/os/linux.png create mode 100644 webapp/src/main/resources/public/images/icons/os/windows.png create mode 100644 webapp/src/main/resources/public/images/icons/sunbed.png create mode 100644 webapp/src/main/resources/public/images/icons/sunset.png create mode 100644 webapp/src/main/resources/public/images/icons/trophy-football.png create mode 100644 webapp/src/main/resources/public/js/about/changelog.js create mode 100644 webapp/src/main/resources/public/js/modules/parser-iccs.js create mode 100644 webapp/src/main/resources/public/js/user-profile/user-profile-games.js create mode 100644 webapp/src/main/resources/public/js/user-settings/user-sessions-page.js create mode 100644 webapp/src/main/resources/public/js/user-settings/user-sessions-widget.js create mode 100644 webapp/src/main/resources/public/js/userdata/my-db-searches-dto.js create mode 100644 webapp/src/main/resources/public/js/userdata/my-db-searches.js create mode 100644 webapp/src/main/resources/public/js/widgets/eval-line-chart.js create mode 100644 webapp/src/main/resources/templates/email_confirmation/email_confirmation_error.html create mode 100644 webapp/src/main/resources/templates/email_confirmation/email_confirmed.html create mode 100644 webapp/src/main/resources/templates/user_browse_pvp_games.html create mode 100644 webapp/src/main/resources/templates/user_sessions.html create mode 100644 webapp/src/main/resources/templates/userdata/my_db_searches.html diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a86c77c96..dff8e7ce3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: labels: - "dependencies" ignore: - # Netty version is pinned to match io.ktor:ktor-server-netty in build.gradle. + # Netty version is pinned to match io.ktor:ktor-server-netty in build.gradle.kts. # Bump it manually when upgrading Ktor. - dependency-name: "io.netty:*" groups: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..4f91c0672 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +So far I'm the only one working on this project. It was just recently made public. So I'm not sure yet how the +contribution process might look like in the future. + +If you want to contribute, best is to first join our [Discord server](https://discord.gg/WEGDqnWXNg) to discuss your +ideas. + +The current [roadmap](https://github.com/users/benckx/projects/2/views/1) reflects the current priorities of the +project. Feel free to create issues to report bugs or request feature changes. Feel free to comment and give your +opinion the existing issues, or even to fork and open MRs. diff --git a/README.md b/README.md index 765185d6b..e13cd6f37 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,14 @@ [elephantchess.io](https://elephantchess.io) is a web application to play and study [Chinese chess](https://en.wikipedia.org/wiki/Xiangqi) (or xiangqi 象棋). -Feel free to create issues to report bugs or request feature changes. The -current [roadmap](https://github.com/users/benckx/projects/2/views/1) on this repo reflects the current priorities of -the project. Feel free to comment and give your opinion the existing issues. - By default, the project is under GPL-3.0 license. Libraries (like the Kotlin xiangqi-core or the JavaScript [board-gui](https://elephantchess.io/about/developers/board-gui-example)) are under LGPL-3.0 license to allow for a more permissive use (i.e. to re-use the libraries in a commercial application). We have a little [Discord server](https://discord.gg/WEGDqnWXNg) for open discussion. +[![Build](https://github.com/benckx/elephantchess/actions/workflows/build.yml/badge.svg)](https://github.com/benckx/elephantchess/actions/workflows/build.yml) + ## Features The webapp offers the following features: @@ -42,8 +40,8 @@ The webapp offers the following features: ## Glossary -- The engines are AI (or bots) used for the Play-against-bot (PvB) or Analysis Board features. In the chess communinity, - the term "engine" is prefered over AI or bot. +- The engines are AI (or bots) used for the Play-against-bot (PvB) or Analysis Board features. In the chess community, + the term "engine" is preferred over AI or bot. - In chess in general, the "columns" of the board are named "files" and the "rows" are named "ranks". - The Forsyth–Edwards Notation (or FEN) is a standard notation to represent chess positions as a single line of text. In Chinese chess, the starting position is encoded as @@ -60,17 +58,10 @@ The webapp offers the following features: from that position with a search depth of 10. - The UCI move notation is similar to the algebraic notation, except that the ranks are 0-based. Therefore, any move can be encoded as 4 characters. For example, move h3e3 is encoded in UCI as h2e2. -- The WXF notation is the traditional Chinese chess notation (e.g. `C2=5`), widely in use. It's not as convinient - technically because move can be ambiguous and the files are numbered from the right to the left relative to the player - and (so it's not the same numbers for the red and black players). Therefore it's not used in code. - -## Contribute - -So far I'm the only one working on this project. It was just recently made public. So I'm not sure yet how the -contribution process might look like. - -Best is to join our [Discord server](https://discord.gg/WEGDqnWXNg) to discuss first how you would like to contribute, -or to open or comment on the GitHub issues. +- The WXF notation is the traditional Chinese chess notation (e.g. `C2=5`), widely in use. It's not as convenient + technically because move can be ambiguous and the files are numbered from the right to the left relative to the + player (so it's not the same numbers for the red and black players). Therefore, it's not used in code, expect for + labeling moves in the GUI. # Run Locally @@ -255,22 +246,23 @@ Except for KTor, Koin, a Kubernetes client, Apache Commons, the project has few ## Gradle Modules -If you check the `settings.gradle` file, you will see that the project is made of several modules: +If you check the `settings.gradle.kts` file, you will see that the project is made of several modules: ``` -include('utils') -include('engine-api') -include('xiangqi-core') -include('xiangqi-core-test-utils') -include('seven-kingdoms-core') -include('seven-kingdoms-core-test-utils') -include('webapp-config') -include('webapp-dao') -include('webapp-dao-migration') -include('webapp-html-renderer') -include('webapp-model-common') -include('webapp-service-layer') -include('webapp') +include("utils") +include("engine-api") +include("xiangqi-core") +include("xiangqi-core-test-utils") +include("seven-kingdoms-core") +include("seven-kingdoms-core-test-utils") +include("webapp-config") +include("webapp-dao") +include("webapp-dao-migration") +include("webapp-html-renderer") +include("webapp-model-common") +include("webapp-service-layer") +include("webapp") +include("scripts") ``` ### utils @@ -588,7 +580,7 @@ development. [![](https://www.jitpack.io/v/benckx/elephantchess.svg)](https://www.jitpack.io/#benckx/elephantchess) -At the moment, you can use the libraries via JitPack. You only need to add the JitPack repository to your build.gradle +At the moment, you can use the libraries via JitPack. You only need to add the JitPack repository to your build file: ```Groovy @@ -692,7 +684,7 @@ Under `webapp/src/main/resources/public/js` there's usually a sub-folder for eac - etc. Complex app like PvP is usually organized with a **page** which updates the GUI, a **controller** which connects to -WebSockets and/or calls REST endpoints, and sometimes a DTO file +WebSockets and/or calls REST endpoints, sometimes a DTO file and/or a separate REST client file. ## JavaScript Libraries diff --git a/build.gradle b/build.gradle deleted file mode 100644 index f0492bc69..000000000 --- a/build.gradle +++ /dev/null @@ -1,388 +0,0 @@ -import liquibase.Contexts -import liquibase.Liquibase -import liquibase.database.core.H2Database -import liquibase.database.jvm.JdbcConnection -import liquibase.resource.DirectoryResourceAccessor -import org.h2.Driver -import org.jooq.codegen.GenerationTool -import org.jooq.meta.jaxb.* - -import java.sql.Connection -import java.sql.Statement - -buildscript { - repositories { - mavenCentral() - google() - } - dependencies { - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - classpath "org.jooq:jooq-codegen:$jooqVersion" - classpath "com.h2database:h2:$h2Version" - classpath "org.liquibase:liquibase-core:$liquibaseVersion" - } -} - -plugins { - id "com.github.ben-manes.versions" version "0.54.0" - id "org.jetbrains.kotlin.jvm" version "$kotlinVersion" - id "com.adarshr.test-logger" version "4.0.0" - id "org.jetbrains.kotlin.plugin.serialization" version "$kotlinVersion" -} - -allprojects { - apply plugin: 'kotlin' - apply plugin: 'com.adarshr.test-logger' - apply plugin: 'org.jetbrains.kotlin.plugin.serialization' - - kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of("21")) - } - } - - repositories { - google() - mavenCentral() - mavenLocal() - } - - dependencies { - implementation "io.github.oshai:kotlin-logging-jvm:8.0.02" - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" - implementation "ch.qos.logback:logback-classic:$logbackVersion" - testImplementation "org.jetbrains.kotlin:kotlin-test" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoVersion" - testImplementation "org.junit.jupiter:junit-jupiter-params:6.0.3" - } - - def nettyVersion = '4.2.12.Final' // aligned with io.ktor:ktor-server-netty - configurations.configureEach { - resolutionStrategy { - force "org.apache.commons:commons-lang3:$commonLang3Version" - force 'org.checkerframework:checker-qual:4.1.0' - force "io.netty:netty-buffer:$nettyVersion" - force "io.netty:netty-codec:$nettyVersion" - force "io.netty:netty-codec-base:$nettyVersion" - force "io.netty:netty-codec-compression:$nettyVersion" - force "io.netty:netty-codec-dns:$nettyVersion" - force "io.netty:netty-codec-http:$nettyVersion" - force "io.netty:netty-codec-http2:$nettyVersion" - force "io.netty:netty-codec-socks:$nettyVersion" - force "io.netty:netty-common:$nettyVersion" - force "io.netty:netty-handler:$nettyVersion" - force "io.netty:netty-handler-proxy:$nettyVersion" - force "io.netty:netty-resolver:$nettyVersion" - force "io.netty:netty-resolver-dns:$nettyVersion" - force "io.netty:netty-transport:$nettyVersion" - force "io.netty:netty-transport-classes-epoll:$nettyVersion" - force "io.netty:netty-transport-classes-kqueue:$nettyVersion" - force "io.netty:netty-transport-native-epoll:$nettyVersion" - force "io.netty:netty-transport-native-kqueue:$nettyVersion" - force "io.netty:netty-transport-native-unix-common:$nettyVersion" -// force "com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion" -// force "com.fasterxml.jackson.core:jackson-core:$jacksonVersion" - } - } - - test { - useJUnitPlatform() - minHeapSize = '1G' - maxHeapSize = '1G' - failOnNoDiscoveredTests = false - } -} - -tasks.register('dao-code-gen') { - doLast { - Connection conn = new Driver().connect("jdbc:h2:mem:test", null) - - Statement stmt = conn.createStatement() - stmt.execute("drop all OBJECTS") - stmt.execute("set schema PUBLIC") - stmt.close() - - def resourceAccessor = new DirectoryResourceAccessor(new File("${project.projectDir}/webapp-dao-migration/src/main/resources")) - def db = new H2Database() - db.setConnection(new JdbcConnection(conn)) - - def liquibase = new Liquibase("liquibase-changelog-generation.xml", resourceAccessor, db) - liquibase.update(new Contexts()) - conn.commit() - - def jdbc = new Jdbc() - .withDriver('org.h2.Driver') - .withUrl('jdbc:h2:mem:test') - - // mostly to use replace string field by enums - Map forcedTypeMap = [:] - new File('jooq-type-mappings.txt').eachLine { line -> - def parts = line.split(',') - def columnName = parts[0].trim() - def typeName = parts[1].trim() - forcedTypeMap.put(columnName, typeName) - } - - def forcedTypes = forcedTypeMap.collect { key, value -> - return new ForcedType() - .withIncludeExpression(key) - .withUserType(value) - .withEnumConverter(true) - } - - // Map every TIMESTAMP WITH TIME ZONE column to kotlin.time.Instant via a custom converter - // so generated POJOs/DAOs carry Instant fields instead of java.time.OffsetDateTime. - forcedTypes << new ForcedType() - .withIncludeTypes("(?i:TIMESTAMP\\s+WITH\\s+TIME\\s+ZONE|TIMESTAMP\\([0-9]+\\)\\s+WITH\\s+TIME\\s+ZONE|TIMESTAMPTZ)") - .withUserType("kotlin.time.Instant") - .withConverter("io.elephantchess.db.codegen.OffsetDateTimeInstantConverter") - - def database = new Database() - .withExcludes("DATABASECHANGELOG|DATABASECHANGELOGLOCK") - .withInputSchema("PUBLIC") - .withForcedTypes(forcedTypes) - - def generate = new Generate() - .withPojos(true) - .withDaos(true) // TODO: remove records - - def target = new Target() - .withPackageName('io.elephantchess.db.dao.codegen') - .withDirectory("${project.projectDir}/webapp-dao/build/jooq") - - def generator = new Generator() - .withDatabase(database) - .withGenerate(generate) - .withTarget(target) - - GenerationTool.generate(new Configuration().withJdbc(jdbc).withGenerator(generator)) - - conn.close() - } -} - -compileKotlin.dependsOn(tasks."dao-code-gen") - -// modules that are published as libraries (e.g. via JitPack) -def publishableModules = [ - 'engine-api', - 'xiangqi-core', - 'xiangqi-core-test-utils', - 'seven-kingdoms-core', - 'seven-kingdoms-core-test-utils', -] -configure(publishableModules.collect { project(":$it") }) { - apply plugin: 'maven-publish' - - group = 'io.elephantchess' - version = rootProject.hasProperty('publishVersion') ? rootProject.publishVersion : '1.0.0-SNAPSHOT' - - java { - withJavadocJar() - withSourcesJar() - } - - publishing { - publications { - maven(MavenPublication) { - from components.java - } - } - } -} - -project(":engine-api") { - dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" - testImplementation project(":xiangqi-core") - } -} - -project(":xiangqi-core") { - dependencies { - testImplementation project(":xiangqi-core-test-utils") - } -} - -project(":xiangqi-core-test-utils") { - // no project dependencies; only relies on common deps from allprojects -} - -project(":seven-kingdoms-core") { - dependencies { - testImplementation project(":seven-kingdoms-core-test-utils") - } -} - -project(":seven-kingdoms-core-test-utils") { - dependencies { - implementation project(":seven-kingdoms-core") - api "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" - api "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" - } -} - -project(":webapp-dao-migration") { - dependencies { - implementation "org.liquibase:liquibase-core:$liquibaseVersion" - implementation "org.postgresql:postgresql:$postgresVersion" // JDBC driver - } -} - -project(":webapp-dao") { - sourceSets { - main { - java { - srcDirs "$buildDir/jooq" - } - } - } - - dependencies { - implementation project(":utils") - implementation project(":webapp-dao-migration") - implementation project(":seven-kingdoms-core") - implementation project(":webapp-config") - implementation project(":webapp-model-common") - implementation project(":xiangqi-core") - implementation project(":engine-api") - implementation "org.jooq:jooq:$jooqVersion" - implementation "org.jooq:jooq-kotlin:$jooqVersion" - implementation "org.jooq:jooq-kotlin-coroutines:$jooqVersion" - implementation "org.liquibase:liquibase-core:$liquibaseVersion" // needed to pass ResourceAccessor - implementation "org.postgresql:r2dbc-postgresql:1.1.1.RELEASE" - implementation "io.r2dbc:r2dbc-pool:1.0.2.RELEASE" - } -} - -project(":webapp-html-renderer") { - dependencies { - implementation project(":utils") - implementation "org.jsoup:jsoup:$jsoupVersion" - } -} - -project(":webapp-service-layer") { - dependencies { - api project(":utils") - api project(":webapp-config") - api project(":webapp-model-common") - api project(":webapp-html-renderer") - implementation project(":seven-kingdoms-core") - implementation project(":webapp-dao") - api "io.insert-koin:koin-core:$koinCoreVersion" - api project(":xiangqi-core") - api project(":engine-api") - api "org.apache.commons:commons-lang3:$commonLang3Version" - implementation "org.jsoup:jsoup:$jsoupVersion" - implementation "commons-validator:commons-validator:1.10.1" - implementation "com.auth0:java-jwt:4.5.2" - implementation "com.sun.mail:javax.mail:$javaxMailVersion" - implementation "io.github.reactivecircus.cache4k:cache4k:$cache4kVersion" - implementation "io.fabric8:kubernetes-client:7.6.1" - implementation "org.jooq:jooq:$jooqVersion" - implementation "io.ktor:ktor-client-core:$kTorVersion" - implementation "io.ktor:ktor-client-cio:$kTorVersion" - implementation "io.ktor:ktor-client-content-negotiation:$kTorVersion" - implementation "io.ktor:ktor-client-logging:$kTorVersion" - implementation "io.ktor:ktor-serialization-jackson:$kTorVersion" - implementation "io.ktor:ktor-serialization-kotlinx-json:$kTorVersion" - testImplementation project(":xiangqi-core-test-utils") - testImplementation project(":seven-kingdoms-core-test-utils") - testImplementation "org.apache.commons:commons-rng-simple:1.7" - testImplementation "org.apache.commons:commons-text:1.15.0" - testImplementation "com.h2database:h2:$h2Version" - testImplementation "org.liquibase:liquibase-core:$liquibaseVersion" - testImplementation "org.testcontainers:postgresql:$testContainerVersion" - } -} - -project(":webapp-config") { - dependencies { - implementation "commons-cli:commons-cli:1.11.0" - } -} - -project(":webapp") { - dependencies { - implementation project(":webapp-service-layer") - implementation "io.ktor:ktor-server-core:$kTorVersion" - implementation "io.ktor:ktor-server-netty:$kTorVersion" - implementation "io.ktor:ktor-server-status-pages:$kTorVersion" - implementation "io.ktor:ktor-server-default-headers:$kTorVersion" - implementation "io.ktor:ktor-server-content-negotiation:$kTorVersion" - implementation "io.ktor:ktor-server-caching-headers:$kTorVersion" - implementation "io.ktor:ktor-serialization-jackson:$kTorVersion" - implementation "io.ktor:ktor-serialization-kotlinx-json:$kTorVersion" - implementation "io.ktor:ktor-server-websockets:$kTorVersion" - implementation "io.ktor:ktor-server-default-headers:$kTorVersion" - implementation "io.ktor:ktor-server-compression:$kTorVersion" - implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" - implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" - implementation "io.github.reactivecircus.cache4k:cache4k:$cache4kVersion" - } - - jar { - manifest { - attributes "Main-Class": "io.elephantchess.webapp.MainKt" - } - - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - - from { - configurations.runtimeClasspath.collect { - it.isDirectory() ? it : zipTree(it) - } - } - } -} - -project(":scripts") { - dependencies { - implementation project(":utils") - implementation project(":seven-kingdoms-core") - implementation project(":seven-kingdoms-core-test-utils") - implementation project(":webapp-model-common") - implementation project(":webapp-dao") - implementation project(":webapp-service-layer") - implementation project(":webapp-config") - implementation project(":webapp") - implementation "com.google.guava:guava:$guavaVersion" - implementation project(":xiangqi-core") - implementation project(":engine-api") - implementation "io.ktor:ktor-client-core:$kTorVersion" - implementation "io.ktor:ktor-client-cio:$kTorVersion" - implementation "io.ktor:ktor-client-logging:$kTorVersion" - implementation "io.ktor:ktor-client-content-negotiation:$kTorVersion" - implementation "com.opencsv:opencsv:$openCsvVersion" - implementation "com.sun.mail:javax.mail:$javaxMailVersion" - implementation "org.apache.logging.log4j:log4j-core:2.26.0" - implementation "org.mockito.kotlin:mockito-kotlin:$mockitoVersion" - } - - tasks.register('minify', JavaExec) { - group = 'deployment' - description = 'Minify JS and CSS assets' - mainClass = 'io.elephantchess.scripts.minification.MinifyKt' - classpath = sourceSets.main.runtimeClasspath - workingDir = rootProject.projectDir - } - - tasks.register('removeMinified', JavaExec) { - group = 'deployment' - description = 'Remove all minified JS and CSS files' - mainClass = 'io.elephantchess.scripts.minification.RemoveMinifiedKt' - classpath = sourceSets.main.runtimeClasspath - workingDir = rootProject.projectDir - } - - tasks.register('liquibaseGeneration', JavaExec) { - group = 'deployment' - description = 'Generate liquibase changelog for DB code generation' - mainClass = 'io.elephantchess.scripts.LiquibaseGenerationKt' - classpath = sourceSets.main.runtimeClasspath - workingDir = rootProject.projectDir - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..cf32644b7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,409 @@ +import liquibase.Contexts +import liquibase.Liquibase +import liquibase.database.core.H2Database +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.DirectoryResourceAccessor +import org.gradle.jvm.tasks.Jar +import org.h2.Driver +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jooq.codegen.GenerationTool +import org.jooq.meta.jaxb.* +import org.jooq.meta.jaxb.Target +import java.sql.Connection + +val kotlinVersion: String by project +val coroutineVersion: String by project +val kTorVersion: String by project +val koinCoreVersion: String by project +val postgresVersion: String by project +val liquibaseVersion: String by project +val jooqVersion: String by project +val h2Version: String by project +val jacksonVersion: String by project +val jsoupVersion: String by project +val mockitoVersion: String by project +val cache4kVersion: String by project +val commonLang3Version: String by project +val javaxMailVersion: String by project +val openCsvVersion: String by project +val testContainerVersion: String by project +val guavaVersion: String by project +val logbackVersion: String by project + +fun DependencyHandlerScope.api(dependencyNotation: Any) = add("api", dependencyNotation) + +fun DependencyHandlerScope.implementation(dependencyNotation: Any) = add("implementation", dependencyNotation) + +fun DependencyHandlerScope.testImplementation(dependencyNotation: Any) = add("testImplementation", dependencyNotation) + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath("org.jooq:jooq-codegen:${project.property("jooqVersion")}") + classpath("com.h2database:h2:${project.property("h2Version")}") + classpath("org.liquibase:liquibase-core:${project.property("liquibaseVersion")}") + } +} + +plugins { + id("com.github.ben-manes.versions") version "0.54.0" + id("org.jetbrains.kotlin.jvm") version "2.3.21" apply false + id("com.adarshr.test-logger") version "4.0.0" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "com.adarshr.test-logger") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + + extensions.configure { + jvmToolchain(21) + } + + repositories { + mavenCentral() + mavenLocal() + } + + dependencies { + implementation("io.github.oshai:kotlin-logging-jvm:8.0.02") + implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion") + implementation("ch.qos.logback:logback-classic:$logbackVersion") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion") + testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoVersion") + testImplementation("org.junit.jupiter:junit-jupiter-params:6.0.3") + } + + val nettyVersion = "4.2.12.Final" + configurations.configureEach { + resolutionStrategy { + force("org.apache.commons:commons-lang3:$commonLang3Version") + force("org.checkerframework:checker-qual:4.1.0") + force("io.netty:netty-buffer:$nettyVersion") + force("io.netty:netty-codec:$nettyVersion") + force("io.netty:netty-codec-base:$nettyVersion") + force("io.netty:netty-codec-compression:$nettyVersion") + force("io.netty:netty-codec-dns:$nettyVersion") + force("io.netty:netty-codec-http:$nettyVersion") + force("io.netty:netty-codec-http2:$nettyVersion") + force("io.netty:netty-codec-socks:$nettyVersion") + force("io.netty:netty-common:$nettyVersion") + force("io.netty:netty-handler:$nettyVersion") + force("io.netty:netty-handler-proxy:$nettyVersion") + force("io.netty:netty-resolver:$nettyVersion") + force("io.netty:netty-resolver-dns:$nettyVersion") + force("io.netty:netty-transport:$nettyVersion") + force("io.netty:netty-transport-classes-epoll:$nettyVersion") + force("io.netty:netty-transport-classes-kqueue:$nettyVersion") + force("io.netty:netty-transport-native-epoll:$nettyVersion") + force("io.netty:netty-transport-native-kqueue:$nettyVersion") + force("io.netty:netty-transport-native-unix-common:$nettyVersion") + } + } + + tasks.withType().configureEach { + useJUnitPlatform() + minHeapSize = "1G" + maxHeapSize = "1G" + failOnNoDiscoveredTests = false + } +} + +val daoCodeGen = tasks.register("dao-code-gen") { + doLast { + val conn: Connection = Driver().connect("jdbc:h2:mem:test", null) + + conn.createStatement().use { stmt -> + stmt.execute("drop all OBJECTS") + stmt.execute("set schema PUBLIC") + } + + val resourceAccessor = DirectoryResourceAccessor( + project.layout.projectDirectory.dir("webapp-dao-migration/src/main/resources").asFile, + ) + val db = H2Database().apply { + setConnection(JdbcConnection(conn)) + } + + val liquibase = Liquibase("liquibase-changelog-generation.xml", resourceAccessor, db) + liquibase.update(Contexts()) + conn.commit() + + val jdbc = Jdbc() + .withDriver("org.h2.Driver") + .withUrl("jdbc:h2:mem:test") + + val forcedTypeMap = linkedMapOf() + file("jooq-type-mappings.txt").forEachLine { line -> + val parts = line.split(",") + val columnName = parts[0].trim() + val typeName = parts[1].trim() + forcedTypeMap[columnName] = typeName + } + + val forcedTypes = forcedTypeMap + .map { (key, value) -> + ForcedType() + .withIncludeExpression(key) + .withUserType(value) + .withEnumConverter(true) + } + .toMutableList() + + forcedTypes += ForcedType() + .withIncludeTypes("(?i:TIMESTAMP\\s+WITH\\s+TIME\\s+ZONE|TIMESTAMP\\([0-9]+\\)\\s+WITH\\s+TIME\\s+ZONE|TIMESTAMPTZ)") + .withUserType("kotlin.time.Instant") + .withConverter("io.elephantchess.db.codegen.OffsetDateTimeInstantConverter") + + val database = Database() + .withExcludes("DATABASECHANGELOG|DATABASECHANGELOGLOCK") + .withInputSchema("PUBLIC") + .withForcedTypes(forcedTypes) + + val generate = Generate() + .withPojos(true) + .withDaos(true) + + val target = Target() + .withPackageName("io.elephantchess.db.dao.codegen") + .withDirectory("${project.projectDir}/webapp-dao/build/jooq") + + val generator = Generator() + .withDatabase(database) + .withGenerate(generate) + .withTarget(target) + + GenerationTool.generate( + Configuration() + .withJdbc(jdbc) + .withGenerator(generator), + ) + + conn.close() + } +} + +project(":webapp-dao").tasks.named("compileKotlin") { + dependsOn(daoCodeGen) +} + +val publishableModules = listOf( + "engine-api", + "xiangqi-core", + "xiangqi-core-test-utils", + "seven-kingdoms-core", + "seven-kingdoms-core-test-utils", +) + +configure(publishableModules.map { project(":$it") }) { + apply(plugin = "maven-publish") + + group = "io.elephantchess" + version = rootProject.findProperty("publishVersion") as String? ?: "1.0.0-SNAPSHOT" + + extensions.configure { + withJavadocJar() + withSourcesJar() + } + + extensions.configure { + publications { + create("maven") { + from(components["java"]) + } + } + } +} + +project(":engine-api") { + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion") + testImplementation(project(":xiangqi-core")) + } +} + +project(":xiangqi-core") { + dependencies { + testImplementation(project(":xiangqi-core-test-utils")) + } +} + +project(":seven-kingdoms-core") { + dependencies { + testImplementation(project(":seven-kingdoms-core-test-utils")) + } +} + +project(":seven-kingdoms-core-test-utils") { + dependencies { + implementation(project(":seven-kingdoms-core")) + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + } +} + +project(":webapp-dao-migration") { + dependencies { + implementation("org.liquibase:liquibase-core:$liquibaseVersion") + implementation("org.postgresql:postgresql:$postgresVersion") + } +} + +project(":webapp-dao") { + the()["main"].java.srcDir(layout.buildDirectory.dir("jooq")) + + dependencies { + implementation(project(":utils")) + implementation(project(":webapp-dao-migration")) + implementation(project(":seven-kingdoms-core")) + implementation(project(":webapp-config")) + implementation(project(":webapp-model-common")) + implementation(project(":xiangqi-core")) + implementation(project(":engine-api")) + implementation("org.jooq:jooq:$jooqVersion") + implementation("org.jooq:jooq-kotlin:$jooqVersion") + implementation("org.jooq:jooq-kotlin-coroutines:$jooqVersion") + implementation("org.liquibase:liquibase-core:$liquibaseVersion") + implementation("org.postgresql:r2dbc-postgresql:1.1.1.RELEASE") + implementation("io.r2dbc:r2dbc-pool:1.0.2.RELEASE") + } +} + +project(":webapp-html-renderer") { + dependencies { + implementation(project(":utils")) + implementation("io.ktor:ktor-server-html-builder:$kTorVersion") + implementation("org.jsoup:jsoup:$jsoupVersion") + } +} + +project(":webapp-service-layer") { + dependencies { + api(project(":utils")) + api(project(":webapp-config")) + api(project(":webapp-model-common")) + api(project(":webapp-html-renderer")) + implementation(project(":seven-kingdoms-core")) + implementation(project(":webapp-dao")) + api("io.insert-koin:koin-core:$koinCoreVersion") + api(project(":xiangqi-core")) + api(project(":engine-api")) + api("org.apache.commons:commons-lang3:$commonLang3Version") + implementation("org.jsoup:jsoup:$jsoupVersion") + implementation("commons-validator:commons-validator:1.10.1") + implementation("com.auth0:java-jwt:4.5.2") + implementation("com.sun.mail:javax.mail:$javaxMailVersion") + implementation("io.github.reactivecircus.cache4k:cache4k:$cache4kVersion") + implementation("io.fabric8:kubernetes-client:7.7.0") + implementation("org.jooq:jooq:$jooqVersion") + implementation("io.ktor:ktor-client-core:$kTorVersion") + implementation("io.ktor:ktor-client-cio:$kTorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$kTorVersion") + implementation("io.ktor:ktor-client-logging:$kTorVersion") + implementation("io.ktor:ktor-serialization-jackson:$kTorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$kTorVersion") + implementation("io.ktor:ktor-server-html-builder:$kTorVersion") + testImplementation(project(":xiangqi-core-test-utils")) + testImplementation(project(":seven-kingdoms-core-test-utils")) + testImplementation("org.apache.commons:commons-rng-simple:1.7") + testImplementation("org.apache.commons:commons-text:1.15.0") + testImplementation("com.h2database:h2:$h2Version") + testImplementation("org.liquibase:liquibase-core:$liquibaseVersion") + testImplementation("org.testcontainers:postgresql:$testContainerVersion") + } +} + +project(":webapp-config") { + dependencies { + implementation("commons-cli:commons-cli:1.11.0") + } +} + +project(":webapp") { + dependencies { + implementation(project(":webapp-service-layer")) + implementation("io.ktor:ktor-server-core:$kTorVersion") + implementation("io.ktor:ktor-server-netty:$kTorVersion") + implementation("io.ktor:ktor-server-status-pages:$kTorVersion") + implementation("io.ktor:ktor-server-default-headers:$kTorVersion") + implementation("io.ktor:ktor-server-content-negotiation:$kTorVersion") + implementation("io.ktor:ktor-server-caching-headers:$kTorVersion") + implementation("io.ktor:ktor-server-html-builder:$kTorVersion") + implementation("io.ktor:ktor-serialization-jackson:$kTorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$kTorVersion") + implementation("io.ktor:ktor-server-websockets:$kTorVersion") + implementation("io.ktor:ktor-server-compression:$kTorVersion") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation("io.github.reactivecircus.cache4k:cache4k:$cache4kVersion") + } + + tasks.named("jar") { + manifest { + attributes["Main-Class"] = "io.elephantchess.webapp.MainKt" + } + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from( + configurations["runtimeClasspath"].map { + if (it.isDirectory) it else zipTree(it) + }, + ) + } +} + +project(":scripts") { + dependencies { + implementation(project(":utils")) + implementation(project(":seven-kingdoms-core")) + implementation(project(":seven-kingdoms-core-test-utils")) + implementation(project(":webapp-model-common")) + implementation(project(":webapp-dao")) + implementation(project(":webapp-service-layer")) + implementation(project(":webapp-config")) + implementation(project(":webapp")) + implementation("com.google.guava:guava:$guavaVersion") + implementation(project(":xiangqi-core")) + implementation(project(":engine-api")) + implementation("io.ktor:ktor-client-core:$kTorVersion") + implementation("io.ktor:ktor-client-cio:$kTorVersion") + implementation("io.ktor:ktor-client-logging:$kTorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$kTorVersion") + implementation("com.opencsv:opencsv:$openCsvVersion") + implementation("com.sun.mail:javax.mail:$javaxMailVersion") + implementation("org.apache.logging.log4j:log4j-core:2.26.0") + implementation("org.mockito.kotlin:mockito-kotlin:$mockitoVersion") + } + + val sourceSets = the() + + tasks.register("minify") { + group = "deployment" + description = "Minify JS and CSS assets" + mainClass.set("io.elephantchess.scripts.minification.MinifyKt") + classpath = sourceSets["main"].runtimeClasspath + workingDir = rootProject.projectDir + } + + tasks.register("removeMinified") { + group = "deployment" + description = "Remove all minified JS and CSS files" + mainClass.set("io.elephantchess.scripts.minification.RemoveMinifiedKt") + classpath = sourceSets["main"].runtimeClasspath + workingDir = rootProject.projectDir + } + + tasks.register("liquibaseGeneration") { + group = "deployment" + description = "Generate liquibase changelog for DB code generation" + mainClass.set("io.elephantchess.scripts.LiquibaseGenerationKt") + classpath = sourceSets["main"].runtimeClasspath + workingDir = rootProject.projectDir + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b52fb7e71..df6a6ad76 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/scripts/src/main/kotlin/io/elephantchess/scripts/CreateVietnameseTestUsers.kt b/scripts/src/main/kotlin/io/elephantchess/scripts/CreateVietnameseTestUsers.kt new file mode 100644 index 000000000..290470a2a --- /dev/null +++ b/scripts/src/main/kotlin/io/elephantchess/scripts/CreateVietnameseTestUsers.kt @@ -0,0 +1,60 @@ +package io.elephantchess.scripts + +import io.elephantchess.db.services.UserDaoService +import io.elephantchess.servicelayer.dto.user.SignUpRequest +import io.elephantchess.servicelayer.services.UserService +import kotlinx.coroutines.runBlocking +import org.koin.core.component.inject +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object CreateVietnameseTestUsers : KoinScriptInit() { + + private const val LOCALHOST_BASE_URL = "http://localhost:8080" + private const val DEFAULT_PASSWORD = "password" + + private val userService by inject() + private val userDaoService by inject() + + private data class UserSeed( + val username: String, + val email: String, + ) + + private val usersToCreate = listOf( + UserSeed(username = "nguyễn", email = "vietuser1@example.com"), + UserSeed(username = "trần", email = "vietuser2@example.com"), + UserSeed(username = "Đặng_123", email = "vietuser3@example.com"), + UserSeed(username = "phạm-văn", email = "vietuser4@example.com"), + ) + + @JvmStatic + fun main(args: Array) { + runBlocking { + usersToCreate.forEach { user -> + val exists = userDaoService.existsForUsername(user.username) + if (!exists) { + val either = userService.signUp(SignUpRequest(user.username, user.email, DEFAULT_PASSWORD)) + if (either.isRight()) { + println("Created user ${user.username}") + println(profileUrl(user.username)) + } else { + val errorMessage = either.left().errors + .ifEmpty { listOf("User creation failed with no validation errors provided") } + .joinToString("; ") + println("ERROR: Failed to create user ${user.username}: $errorMessage") + } + } else { + println("User ${user.username} already exists") + println(profileUrl(user.username)) + } + } + } + } + + private fun profileUrl(username: String): String { +// val encodedUsername = URLEncoder.encode(username, StandardCharsets.UTF_8).replace("+", "%20") + return "$LOCALHOST_BASE_URL/@/$username" + } + +} diff --git a/scripts/src/main/kotlin/io/elephantchess/scripts/game/CreateListOfGamesToJoin.kt b/scripts/src/main/kotlin/io/elephantchess/scripts/game/CreateListOfGamesToJoin.kt index 9406d67e4..d2d81ddc5 100644 --- a/scripts/src/main/kotlin/io/elephantchess/scripts/game/CreateListOfGamesToJoin.kt +++ b/scripts/src/main/kotlin/io/elephantchess/scripts/game/CreateListOfGamesToJoin.kt @@ -11,7 +11,7 @@ import io.elephantchess.servicelayer.services.PlayerVsPlayerGameService import io.elephantchess.xiangqi.Color import kotlinx.coroutines.runBlocking import org.koin.core.component.inject -import java.util.* +import kotlin.random.Random import kotlin.system.exitProcess import kotlin.time.Duration import kotlin.time.Duration.Companion.days @@ -20,6 +20,70 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds private const val testUsers = 10 +private const val weirdTimeControlsCount = 20 + +private val standardTimeControls: List> = + listOf( + 1.minutes to null, + 1.minutes to 1.seconds, + 2.minutes to 1.seconds, + 3.minutes to null, + 3.minutes to 2.seconds, + 5.minutes to 2.seconds, + 5.minutes to 5.seconds, + 5.minutes to null, + 10.minutes to null, + 10.minutes to 5.seconds, + 15.minutes to 10.seconds, + 20.minutes to 10.seconds, + 30.minutes to null, + 30.minutes to 20.seconds, + 45.minutes to 15.seconds, + 1.hours to null, + 1.hours to 30.seconds, + 1.hours to 1.minutes, + 2.hours to null, + 1.days to null, + 2.days to null, + 3.days to null, + 7.days to null, + ) + +private fun Random.generateWeirdTimeControls(): List> = + List(weirdTimeControlsCount) { + if (nextBoolean()) { + randomWeirdLiveTimeControl() + } else { + randomWeirdCorrespondenceTimeControl() + } + } + +private fun Random.randomWeirdLiveTimeControl(): Pair { + val base = + when (nextInt(4)) { + 0 -> nextInt(45, 180).seconds + 1 -> nextInt(3, 25).minutes + nextInt(60).seconds + 2 -> nextInt(25, 90).minutes + nextInt(60).seconds + else -> nextInt(1, 6).hours + nextInt(60).minutes + nextInt(60).seconds + } + + val increment = + when (nextInt(6)) { + 0 -> null + 1 -> nextInt(1, 4).seconds + 2 -> nextInt(4, 11).seconds + 3 -> nextInt(11, 31).seconds + 4 -> nextInt(31, 90).seconds + else -> nextInt(1, 4).minutes + } + + return base to increment +} + +private fun Random.randomWeirdCorrespondenceTimeControl(): Pair { + val base = nextInt(1, 15).days + nextInt(24).hours + nextInt(60).minutes + return base to null +} object CreateListOfGamesToJoin : KoinScriptInit() { @@ -28,37 +92,20 @@ object CreateListOfGamesToJoin : KoinScriptInit() { @JvmStatic fun main(args: Array): Unit = runBlocking { - // set up users (1..testUsers).forEach { i -> createTestUserIfNotExists(i) } - val timeControls: List> = - listOf( - 1.minutes to null, - 1.minutes to 1.seconds, - 2.minutes to 1.seconds, - 3.minutes to null, - 5.minutes to 2.seconds, - 5.minutes to null, - 10.minutes to null, - 15.minutes to 10.seconds, - 30.minutes to null, - 1.hours to null, - 1.hours to 1.minutes, - 2.hours to null, - 1.days to null, - 3.days to null, - 7.days to null, - ) - // create games - val rnd = Random() - repeat(30) { - val i = Random().nextInt(testUsers) + 1 - val user = userDaoService.findByUserName("test$i")!! + val rnd = Random.Default + val weirdTimeControls = rnd.generateWeirdTimeControls() + val timeControls = standardTimeControls + weirdTimeControls + + repeat(50) { + val i = rnd.nextInt(testUsers) + 1 + val user = userDaoService.findByEmail("test$i@protonmail.com")!! val hasColor = rnd.nextBoolean() val color = if (hasColor) null else Color.random() - val timeControl = timeControls.random() + val timeControl = timeControls.random(rnd) val timeControlMode = if (timeControl.first.inWholeSeconds < 1.days.inWholeSeconds) { TimeControlMode.GAME_TIME diff --git a/scripts/src/main/kotlin/io/elephantchess/scripts/game/Utils.kt b/scripts/src/main/kotlin/io/elephantchess/scripts/game/Utils.kt index 53c1b6261..9fe6a7878 100644 --- a/scripts/src/main/kotlin/io/elephantchess/scripts/game/Utils.kt +++ b/scripts/src/main/kotlin/io/elephantchess/scripts/game/Utils.kt @@ -6,6 +6,7 @@ import io.elephantchess.db.utils.awaitExecute import io.elephantchess.db.utils.fixed import io.elephantchess.servicelayer.dto.user.SignUpRequest import io.elephantchess.servicelayer.services.UserService +import io.elephantchess.utils.safeRandomAlphaNumericString import org.jooq.DSLContext import org.jooq.impl.DSL import org.jooq.kotlin.coroutines.transactionCoroutine @@ -20,8 +21,8 @@ object Utils : KoinComponent { private val dslContext by inject() suspend fun createTestUserIfNotExists(i: Int, randomizeRatings: Boolean = true) { - val username = "test$i" - val email = "test$i@gmail.com" + val username = "test$i-pretty-long-name-${safeRandomAlphaNumericString(5, 10)}".take(30) + val email = "test$i@protonmail.com" val password = "password$i" val userExists = userDaoService.existsForEmail(email) diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 110df0412..000000000 --- a/settings.gradle +++ /dev/null @@ -1,16 +0,0 @@ -rootProject.name = 'elephantchess.io' - -include('utils') -include('engine-api') -include('xiangqi-core') -include('xiangqi-core-test-utils') -include('seven-kingdoms-core') -include('seven-kingdoms-core-test-utils') -include('webapp-config') -include('webapp-dao') -include('webapp-dao-migration') -include('webapp-html-renderer') -include('webapp-model-common') -include('webapp-service-layer') -include('webapp') -include('scripts') diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..57a23e193 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "elephantchess.io" + +include("utils") +include("engine-api") +include("xiangqi-core") +include("xiangqi-core-test-utils") +include("seven-kingdoms-core") +include("seven-kingdoms-core-test-utils") +include("webapp-config") +include("webapp-dao") +include("webapp-dao-migration") +include("webapp-html-renderer") +include("webapp-model-common") +include("webapp-service-layer") +include("webapp") +include("scripts") diff --git a/utils/src/main/resources/bad-words.txt b/utils/src/main/resources/bad-words.txt index 2f2a81ccc..5590f9ce2 100644 --- a/utils/src/main/resources/bad-words.txt +++ b/utils/src/main/resources/bad-words.txt @@ -1,4 +1,6 @@ # found: https://www.cs.cmu.edu/~biglou/resources/bad-words.txt +# obviously not all of those words are offensive per se, +# but it's better to avoid them in generated guest id abbo abo abortion diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml index a05271e6e..dea36f17c 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2208,4 +2208,79 @@ + + + + + + + update_time IS NULL + + + + + + + + + Delete persisted "empty" searches (all criteria null/blank) that were stored before the + empty-criteria guard was added. + + + + (player_name IS NULL OR player_name = '') + AND player_id IS NULL + AND player_color IS NULL + AND (event_name IS NULL OR event_name = '') + AND event_id IS NULL + AND search_start IS NULL + AND search_end IS NULL + AND (fen IS NULL OR fen = '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add email confirmation fields to user table (separate from automated email_verification) + + + + + + diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml index 0a203b71c..2f376dedb 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2360,5 +2360,100 @@ + DATE '2024-04-28'; + + -- Pikafish games created on 2024-04-28 keep engine_version = NULL (unknown). + ]]> + + + + + + + + update_time IS NULL + + + + + + + + + Delete persisted "empty" searches (all criteria null/blank) that were stored before the + empty-criteria guard was added. + + + + (player_name IS NULL OR player_name = '') + AND player_id IS NULL + AND player_color IS NULL + AND (event_name IS NULL OR event_name = '') + AND event_id IS NULL + AND search_start IS NULL + AND search_end IS NULL + AND (fen IS NULL OR fen = '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add email confirmation fields to user table (separate from automated email_verification) + + + + + diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/codegen/OffsetDateTimeInstantConverter.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/codegen/OffsetDateTimeInstantConverter.kt index 28529703d..5d1bd695c 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/codegen/OffsetDateTimeInstantConverter.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/codegen/OffsetDateTimeInstantConverter.kt @@ -15,7 +15,7 @@ import kotlin.time.toKotlinInstant * pins the absolute instant, so no zone assumption is needed. We always write back at * [ZoneOffset.UTC] for consistency. * - * Wired by `build.gradle`'s `dao-code-gen` task as a forced type for every `timestamptz` column. + * Wired by `build.gradle.kts`'s `dao-code-gen` task as a forced type for every `timestamptz` column. */ class OffsetDateTimeInstantConverter : AbstractConverter( OffsetDateTime::class.java, diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/model/UserSessionRecord.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/model/UserSessionRecord.kt index 8b77db566..809296c95 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/model/UserSessionRecord.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/model/UserSessionRecord.kt @@ -3,6 +3,7 @@ package io.elephantchess.db.model import kotlin.time.Instant data class UserSessionRecord( + val id: Int? = null, val userId: String?, val remoteAddress: String, val userAgent: String, diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/GameChatTypingStatusDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/GameChatTypingStatusDaoService.kt new file mode 100644 index 000000000..5d7f29d73 --- /dev/null +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/GameChatTypingStatusDaoService.kt @@ -0,0 +1,50 @@ +package io.elephantchess.db.services + +import io.elephantchess.db.dao.codegen.Tables.GAME_CHAT_TYPING_STATUS +import io.elephantchess.db.dao.codegen.tables.pojos.GameChatTypingStatus +import io.elephantchess.db.utils.awaitExecute +import io.elephantchess.db.utils.awaitMappedRecords +import io.elephantchess.db.utils.fixed +import io.elephantchess.db.utils.isAfter +import org.jooq.DSLContext +import org.jooq.impl.DSL +import kotlin.time.Clock +import kotlin.time.Instant + +class GameChatTypingStatusDaoService(private val dslContext: DSLContext) { + + suspend fun upsertTypingStatus(gameId: String, userId: String) { + val now = Clock.System.now() + + // insert, or update on conflict + dslContext + .insertInto(GAME_CHAT_TYPING_STATUS.fixed()) + .set(GAME_CHAT_TYPING_STATUS.GAME_ID.fixed(), gameId) + .set(GAME_CHAT_TYPING_STATUS.USER_ID.fixed(), userId) + .set(GAME_CHAT_TYPING_STATUS.TYPED_AT.fixed(), now) + .onConflict(GAME_CHAT_TYPING_STATUS.GAME_ID, GAME_CHAT_TYPING_STATUS.USER_ID) + .doUpdate() + .set(GAME_CHAT_TYPING_STATUS.TYPED_AT, DSL.excluded(GAME_CHAT_TYPING_STATUS.TYPED_AT)) + .awaitExecute() + } + + /** + * Returns a map keyed by gameId where each value is the list of [GameChatTypingStatus] + * for that game. Only entries newer than [cutOff] (i.e. typed after that + * instant) are returned, so stale typing events never leak to callers. + */ + suspend fun fetchForGameIds( + gameIds: List, + cutOff: Instant, + ): Map> { + if (gameIds.isEmpty()) return emptyMap() + + return dslContext + .selectFrom(GAME_CHAT_TYPING_STATUS) + .where(GAME_CHAT_TYPING_STATUS.GAME_ID.`in`(gameIds)) + .and(GAME_CHAT_TYPING_STATUS.TYPED_AT.isAfter(cutOff)) + .awaitMappedRecords() + .groupBy { it.gameId!! } + } + +} 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 08314f0c4..76174ab13 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 @@ -20,6 +20,7 @@ import io.elephantchess.model.GameEventType.* import io.elephantchess.model.Outcome import io.elephantchess.xiangqi.Color import io.github.oshai.kotlinlogging.KotlinLogging +import org.jooq.Condition import org.jooq.DSLContext import org.jooq.impl.DSL import org.jooq.kotlin.coroutines.transactionCoroutine @@ -64,29 +65,55 @@ class PlayerVsBotGameDaoService(private val dslContext: DSLContext) { .awaitMappedRecords() } - suspend fun listLastGamesByIdentifiedUsers( + suspend fun listLatestGamesByIdentifiedUsers( limit: Int, minMoveIndex: Int? = null, - beforeTs: Long? = null + beforeTs: Long? = null, + excludeAutoResigned: Boolean = false, + distinctByUsers: Boolean = false ): List { - var query = - dslContext - .select() - .from(BOT_GAME) - .where(BOT_GAME.USER_ID.isNotNull) + val conditions = mutableListOf() + conditions += BOT_GAME.USER_ID.isNotNull if (minMoveIndex != null) { - query = query.and(BOT_GAME.CURRENT_HALF_MOVE_INDEX.ge(minMoveIndex)) + conditions += BOT_GAME.CURRENT_HALF_MOVE_INDEX.ge(minMoveIndex) } if (beforeTs != null) { - query = query.and(BOT_GAME.LAST_UPDATED.isBeforeEpochMillis(beforeTs)) + conditions += BOT_GAME.LAST_UPDATED.isBeforeEpochMillis(beforeTs) } - return query - .orderBy(BOT_GAME.LAST_UPDATED.desc()) - .limit(limit) - .awaitMappedRecords() + if (excludeAutoResigned) { + conditions += BOT_GAME.GAME_STATUS.ne(AUTO_RESIGNED) + } + + return if (distinctByUsers) { + val rn = DSL.rowNumber() + .over(DSL.partitionBy(BOT_GAME.USER_ID).orderBy(BOT_GAME.LAST_UPDATED.desc())) + .`as`("rn") + + val sub = dslContext + .select(BOT_GAME.asterisk(), rn) + .from(BOT_GAME) + .where(conditions) + .asTable("t") + + dslContext + .select(sub.asterisk()) + .from(sub) + .where(sub.field("rn", Int::class.java)!!.eq(1)) + .orderBy(sub.field(BOT_GAME.LAST_UPDATED)!!.desc()) + .limit(limit) + .awaitMappedRecords() + } else { + dslContext + .select() + .from(BOT_GAME) + .where(conditions) + .orderBy(BOT_GAME.LAST_UPDATED.desc()) + .limit(limit) + .awaitMappedRecords() + } } suspend fun listPreAnalysisToDelete(limit: Duration): List> { @@ -408,4 +435,13 @@ class PlayerVsBotGameDaoService(private val dslContext: DSLContext) { } } + suspend fun transferFromGuestToUser(guestUserId: String, newUserId: String) { + dslContext + .update(BOT_GAME) + .set(BOT_GAME.USER_ID, newUserId) + .set(BOT_GAME.GUEST_USER_ID, guestUserId) + .where(BOT_GAME.USER_ID.eq(guestUserId)) + .awaitExecute() + } + } 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 01b4eacb2..92c653fd3 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 @@ -121,6 +121,24 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { .awaitMappedRecords() } + suspend fun listLastGamesByUserId(userId: String, limit: Int, minMoveIndex: Int, beforeTs: Long?): List { + var sql = dslContext + .select() + .from(GAME) + .where(isPlaying(userId)) + .and(GAME.CURRENT_HALF_MOVE_INDEX.ge(minMoveIndex)) + .and(GAME.CONTAINS_ERRORS.eq(false)) + + if (beforeTs != null) { + sql = sql.and(GAME.LAST_UPDATED.isBeforeEpochMillis(beforeTs)) + } + + return sql + .orderBy(GAME.LAST_UPDATED.desc()) + .limit(limit) + .awaitMappedRecords() + } + suspend fun listPotentiallyFlaggedGames(): List { val now = Clock.System.now() val games = mutableListOf() @@ -439,6 +457,7 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { } suspend fun fetchGameStates(gameIds: List): Map { + // TODO: Flux.from() -> can we not use our shortcut thingy? return Flux.from( dslContext .select( @@ -1013,4 +1032,22 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { } } + suspend fun transferFromGuestToUser(guestUserId: String, newUserId: String) { + dslContext.transactionCoroutine { cfg -> + val transaction = DSL.using(cfg) + + transaction.update(GAME) + .set(GAME.INVITER, newUserId) + .set(GAME.GUEST_USER_ID, guestUserId) + .where(GAME.INVITER.eq(guestUserId)) + .awaitExecute() + + transaction.update(GAME) + .set(GAME.INVITEE, newUserId) + .set(GAME.GUEST_USER_ID, guestUserId) + .where(GAME.INVITEE.eq(guestUserId)) + .awaitExecute() + } + } + } diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PuzzleResultDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PuzzleResultDaoService.kt index fca122c06..1fdfb9d56 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PuzzleResultDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PuzzleResultDaoService.kt @@ -3,7 +3,6 @@ package io.elephantchess.db.services import io.elephantchess.db.dao.codegen.Tables.* import io.elephantchess.db.dao.codegen.tables.daos.PuzzleResultDao import io.elephantchess.db.dao.codegen.tables.pojos.PuzzleResult -import io.elephantchess.db.dao.codegen.tables.pojos.PuzzleResultAnonymous import io.elephantchess.db.model.PlayedPuzzleRecord import io.elephantchess.db.utils.* import io.elephantchess.model.PuzzleOutcome @@ -275,4 +274,13 @@ class PuzzleResultDaoService(private val dslContext: DSLContext) { } + suspend fun transferFromGuestToUser(guestUserId: String, newUserId: String) { + dslContext + .update(PUZZLE_RESULT) + .set(PUZZLE_RESULT.USER_ID, newUserId) + .set(PUZZLE_RESULT.GUEST_USER_ID, guestUserId) + .where(PUZZLE_RESULT.USER_ID.eq(guestUserId)) + .awaitExecute() + } + } diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferenceGameDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferenceGameDaoService.kt index 610c41ed6..5869e1775 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferenceGameDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferenceGameDaoService.kt @@ -61,7 +61,7 @@ class ReferenceGameDaoService(private val dslContext: DSLContext) { .awaitRecords() } - suspend fun findByEventId(eventId: String) : List { + suspend fun findByEventId(eventId: String): List { return dslContext .select() .from(REFERENCE_GAME) @@ -365,25 +365,103 @@ class ReferenceGameDaoService(private val dslContext: DSLContext) { limit: Int, numberOfResults: Int ) { - val record = ReferenceGameSearchQuery() - record.queryId = generateId() - record.userId = userId - record.searchStart = searchStart - record.searchEnd = searchEnd - record.playerName = playerName - record.playerId = playerId - record.playerColor = playerColor - record.eventName = eventName - record.eventId = eventId - record.fen = fen - record.offset = offset - record.limit = limit - record.numberOfResults = numberOfResults - record.queryTime = Clock.System.now() + // Only track the first page (offset null or 0) to avoid duplicate entries for pagination + if (offset != null && offset > 0) { + return + } - dslContext.transactionCoroutine { cfg -> - ReferenceGameSearchQueryDao(cfg).insertReactive(record) + val now = Clock.System.now() + + // Sanitize player name the same way the JS client does: strip parenthetical substrings + // (e.g. Chinese characters like "(陈松顺)") so "Chen Songshun (陈松顺)" and + // "Chen Songshun" are treated as the same search entry. + val sanitizedPlayerName = sanitizePlayerName(playerName) + + val existingId = dslContext + .select(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID) + .from(REFERENCE_GAME_SEARCH_QUERY) + .where(REFERENCE_GAME_SEARCH_QUERY.USER_ID.eq(userId)) + .and( + if (sanitizedPlayerName != null) { + lower(REFERENCE_GAME_SEARCH_QUERY.PLAYER_NAME).eq(sanitizedPlayerName.lowercase()) + } else { + REFERENCE_GAME_SEARCH_QUERY.PLAYER_NAME.isNull + } + ) + .and( + if (playerId != null) REFERENCE_GAME_SEARCH_QUERY.PLAYER_ID.eq(playerId) + else REFERENCE_GAME_SEARCH_QUERY.PLAYER_ID.isNull + ) + .and( + if (playerColor != null) REFERENCE_GAME_SEARCH_QUERY.PLAYER_COLOR.eq(playerColor) + else REFERENCE_GAME_SEARCH_QUERY.PLAYER_COLOR.isNull + ) + .and( + if (eventName != null) REFERENCE_GAME_SEARCH_QUERY.EVENT_NAME.eq(eventName) + else REFERENCE_GAME_SEARCH_QUERY.EVENT_NAME.isNull + ) + .and( + if (eventId != null) REFERENCE_GAME_SEARCH_QUERY.EVENT_ID.eq(eventId) + else REFERENCE_GAME_SEARCH_QUERY.EVENT_ID.isNull + ) + .and( + if (searchStart != null) REFERENCE_GAME_SEARCH_QUERY.SEARCH_START.eq(searchStart) + else REFERENCE_GAME_SEARCH_QUERY.SEARCH_START.isNull + ) + .and( + if (searchEnd != null) REFERENCE_GAME_SEARCH_QUERY.SEARCH_END.eq(searchEnd) + else REFERENCE_GAME_SEARCH_QUERY.SEARCH_END.isNull + ) + .and( + if (fen != null) REFERENCE_GAME_SEARCH_QUERY.FEN.eq(fen) + else REFERENCE_GAME_SEARCH_QUERY.FEN.isNull + ) + .awaitSingleValue() + + if (existingId != null) { + dslContext + .update(REFERENCE_GAME_SEARCH_QUERY) + .set(REFERENCE_GAME_SEARCH_QUERY.UPDATE_TIME.fixed(), now) + .set(REFERENCE_GAME_SEARCH_QUERY.NUMBER_OF_RESULTS.fixed(), numberOfResults) + .where(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID.eq(existingId)) + .awaitExecute() + return } + + val queryId = generateId() + dslContext + .insertInto(REFERENCE_GAME_SEARCH_QUERY.fixed()) + .set(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID.fixed(), queryId) + .set(REFERENCE_GAME_SEARCH_QUERY.USER_ID.fixed(), userId) + .set(REFERENCE_GAME_SEARCH_QUERY.QUERY_TIME.fixed(), now) + .set(REFERENCE_GAME_SEARCH_QUERY.UPDATE_TIME.fixed(), now) + .set(REFERENCE_GAME_SEARCH_QUERY.SEARCH_START.fixed(), searchStart) + .set(REFERENCE_GAME_SEARCH_QUERY.SEARCH_END.fixed(), searchEnd) + .set(REFERENCE_GAME_SEARCH_QUERY.PLAYER_NAME.fixed(), sanitizedPlayerName) + .set(REFERENCE_GAME_SEARCH_QUERY.PLAYER_ID.fixed(), playerId) + .set(REFERENCE_GAME_SEARCH_QUERY.PLAYER_COLOR.fixed(), playerColor) + .set(REFERENCE_GAME_SEARCH_QUERY.EVENT_NAME.fixed(), eventName) + .set(REFERENCE_GAME_SEARCH_QUERY.EVENT_ID.fixed(), eventId) + .set(REFERENCE_GAME_SEARCH_QUERY.FEN.fixed(), fen) + .set(REFERENCE_GAME_SEARCH_QUERY.OFFSET.fixed(), offset) + .set(REFERENCE_GAME_SEARCH_QUERY.LIMIT.fixed(), limit) + .set(REFERENCE_GAME_SEARCH_QUERY.NUMBER_OF_RESULTS.fixed(), numberOfResults) + .awaitExecute() + } + + suspend fun listUserSearches(userId: String, beforeTime: Instant?, limit: Int): List { + val condition = REFERENCE_GAME_SEARCH_QUERY.USER_ID.eq(userId) + .let { cond -> + if (beforeTime != null) cond.and(REFERENCE_GAME_SEARCH_QUERY.UPDATE_TIME.lessThan(beforeTime)) + else cond + } + + return dslContext + .selectFrom(REFERENCE_GAME_SEARCH_QUERY) + .where(condition) + .orderBy(REFERENCE_GAME_SEARCH_QUERY.UPDATE_TIME.desc()) + .limit(limit) + .awaitMappedRecords() } suspend fun listLatestDatabaseSearches(limit: Int): List { @@ -412,4 +490,22 @@ class ReferenceGameDaoService(private val dslContext: DSLContext) { } } + suspend fun transferFromGuestToUser(guestUserId: String, newUserId: String) { + dslContext + .update(REFERENCE_GAME_SEARCH_QUERY) + .set(REFERENCE_GAME_SEARCH_QUERY.USER_ID, newUserId) + .set(REFERENCE_GAME_SEARCH_QUERY.GUEST_USER_ID, guestUserId) + .where(REFERENCE_GAME_SEARCH_QUERY.USER_ID.eq(guestUserId)) + .awaitExecute() + } + + companion object { + private val PARENTHETICAL_REGEX = Regex("""\s*\([^)]*\)\s*""") + private val WHITESPACE_REGEX = Regex("""\s+""") + + fun sanitizePlayerName(playerName: String?): String? = + playerName?.replace(PARENTHETICAL_REGEX, "")?.replace(WHITESPACE_REGEX, " ")?.trim() + ?.takeIf { it.isNotEmpty() } + } + } diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt index 7cf07102f..fc3e6c0bd 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/ReferencePlayerDaoService.kt @@ -476,7 +476,10 @@ class ReferencePlayerDaoService(private val dslContext: DSLContext) { REFERENCE_PLAYER.CHINESE_NAME ) .from(REFERENCE_PLAYER) - .where(lower(REFERENCE_PLAYER.CANONICAL_NAME).startsWith(startsWith.lowercase())) + .where( + lower(REFERENCE_PLAYER.CANONICAL_NAME).startsWith(startsWith.lowercase()) + .or(REFERENCE_PLAYER.CHINESE_NAME.startsWith(startsWith)) + ) .and(REFERENCE_PLAYER.IS_VISIBLE.eq(true)) .orderBy(REFERENCE_PLAYER.CANONICAL_NAME) .limit(limit) @@ -493,7 +496,10 @@ class ReferencePlayerDaoService(private val dslContext: DSLContext) { REFERENCE_PLAYER.CHINESE_NAME ) .from(REFERENCE_PLAYER) - .where(lower(REFERENCE_PLAYER.CANONICAL_NAME).contains(contains.lowercase())) + .where( + lower(REFERENCE_PLAYER.CANONICAL_NAME).contains(contains.lowercase()) + .or(REFERENCE_PLAYER.CHINESE_NAME.contains(contains)) + ) .and(REFERENCE_PLAYER.IS_VISIBLE.eq(true)) .orderBy(REFERENCE_PLAYER.CANONICAL_NAME) .limit(limit) diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt index 398d7a957..94bc388dc 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt @@ -12,6 +12,7 @@ import io.elephantchess.model.TimeControlCategory import io.elephantchess.model.UserType import io.elephantchess.model.UserType.AUTHENTICATED import io.elephantchess.utils.safeRandomAlphaNumericString +import io.github.oshai.kotlinlogging.KLogger import org.jooq.DSLContext import org.jooq.Record2 import org.jooq.TableField @@ -23,7 +24,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant -class UserDaoService(private val dslContext: DSLContext) { +class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { suspend fun save(user: User): String { dslContext.transactionCoroutine { cfg -> @@ -88,7 +89,7 @@ class UserDaoService(private val dslContext: DSLContext) { .firstOrNull() } - suspend fun updateProfileSettings(userId: String, description: String, country: String) { + suspend fun updateProfileSettings(userId: String, description: String, country: String?) { dslContext.transactionCoroutine { cfg -> DSL .using(cfg) @@ -303,6 +304,46 @@ class UserDaoService(private val dslContext: DSLContext) { .awaitSingleMappedRecord() } + suspend fun listManuallyConfirmedEmailAddresses(): List { + return dslContext + .select(USER.EMAIL) + .from(USER) + .where(USER.EMAIL.isNotNull) + .and(USER.EMAIL_CONFIRMED_AT.isNotNull) + .awaitMappedRecords() + } + + suspend fun findByEmailConfirmationCode(code: String): User? { + return dslContext + .select() + .from(USER) + .where(USER.EMAIL_CONFIRMATION_CODE.eq(code)) + .awaitSingleMappedRecord() + } + + suspend fun markEmailConfirmed(userId: String, confirmedAt: Instant) { + dslContext.transactionCoroutine { cfg -> + DSL + .using(cfg) + .update(USER) + .set(USER.EMAIL_CONFIRMED_AT.fixed(), confirmedAt) + .where(USER.ID.eq(userId)) + .awaitExecute() + } + } + + suspend fun updateEmailConfirmationCode(userId: String, code: String, createdAt: Instant) { + dslContext.transactionCoroutine { cfg -> + DSL + .using(cfg) + .update(USER) + .set(USER.EMAIL_CONFIRMATION_CODE, code) + .set(USER.EMAIL_CONFIRMATION_CODE_CREATED_AT.fixed(), createdAt) + .where(USER.ID.eq(userId)) + .awaitExecute() + } + } + suspend fun existsById(userId: String): Boolean { return dslContext .selectCount() @@ -341,6 +382,27 @@ class UserDaoService(private val dslContext: DSLContext) { } } + suspend fun transferRatingsFromGuest(guestUserId: String, newUserId: String) { + val guestUser = findById(guestUserId) + if (guestUser == null) { + logger.warn { "transferRatingsFromGuest: guest user $guestUserId not found — ratings not transferred to $newUserId" } + return + } + dslContext.transactionCoroutine { cfg -> + DSL.using(cfg) + .update(USER) + .set(USER.PUZZLE_RATING, guestUser.puzzleRating) + .set(USER.GAME_RATING_BULLET, guestUser.gameRatingBullet) + .set(USER.GAME_RATING_BLITZ, guestUser.gameRatingBlitz) + .set(USER.GAME_RATING_RAPID, guestUser.gameRatingRapid) + .set(USER.GAME_RATING_CLASSICAL, guestUser.gameRatingClassical) + .set(USER.GAME_RATING_SEVERAL_DAYS, guestUser.gameRatingSeveralDays) + .set(USER.GAME_RATING_CORRESPONDENCE, guestUser.gameRatingCorrespondence) + .where(USER.ID.eq(newUserId)) + .awaitExecute() + } + } + suspend fun updateLastOnline(userIds: List) { if (userIds.isNotEmpty()) { dslContext.transactionCoroutine { cfg -> diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserSessionDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserSessionDaoService.kt index 2cf302f8e..54fb0c8f1 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserSessionDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserSessionDaoService.kt @@ -108,6 +108,45 @@ class UserSessionDaoService( .map { mapToRecord(it) } } + suspend fun listAuthenticatedSessionsForUser(userId: String, limit: Int, offset: Int = 0): List { + return dslContext + .select() + .from(USER_SESSION) + .where(USER_SESSION.USER_ID.eq(userId)) + .orderBy(USER_SESSION.LAST_UPDATED.desc()) + .limit(limit) + .offset(offset) + .awaitMappedRecords() + .map { mapToRecord(it) } + } + + suspend fun countAuthenticatedSessionsForUser(userId: String): Int { + return dslContext + .selectCount() + .from(USER_SESSION) + .where(USER_SESSION.USER_ID.eq(userId)) + .awaitSingleValue() ?: 0 + } + + suspend fun deleteAuthenticatedSessionsForUser(userId: String, sessionIds: Collection): Int { + if (sessionIds.isEmpty()) { + return 0 + } + + return dslContext + .deleteFrom(USER_SESSION) + .where(USER_SESSION.USER_ID.eq(userId)) + .and(USER_SESSION.ID.`in`(sessionIds)) + .awaitExecute() + } + + suspend fun deleteAllAuthenticatedSessionsForUser(userId: String): Int { + return dslContext + .deleteFrom(USER_SESSION) + .where(USER_SESSION.USER_ID.eq(userId)) + .awaitExecute() + } + suspend fun findByUserAgent(userAgent: String): List { val records = mutableListOf() @@ -162,6 +201,7 @@ class UserSessionDaoService( fun mapToRecord(userSession: UserSession): UserSessionRecord { return UserSessionRecord( + id = userSession.id, userId = userSession.userId, remoteAddress = userSession.remoteAddress, userAgent = userSession.userAgent, diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/JooqOps.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/JooqOps.kt index 55ead2c5e..a2f98d2b1 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/JooqOps.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/JooqOps.kt @@ -24,7 +24,7 @@ fun Table.fixed(): Table { * Workaround for inserts and updates */ fun Field.fixed(): Field { - return DSL.field(name.lowercase()) + return DSL.field(DSL.quotedName(name.lowercase())) } fun Field.eqIgnoreCaseTrimmed(value: String): Condition = @@ -45,11 +45,11 @@ fun Field.isOlderThan(duration: Duration): Field { return isBefore(limit) } -fun Field.isBeforeEpochMillis(timestampMillis: Long): Field { +fun Field.isBeforeEpochMillis(timestampMillis: Long): Condition { return isBefore(Instant.fromEpochMilliseconds(timestampMillis)) } -fun Field.isWithin(duration: Duration): Field { +fun Field.isWithin(duration: Duration): Condition { val limit = Clock.System.now() - duration return isAfter(limit) } @@ -122,11 +122,11 @@ fun diffInSeconds(f1: Field, f2: Field): Field { return DSL.extract(f1, DatePart.EPOCH).minus(DSL.extract(f2, DatePart.EPOCH)) } -private fun Field.isAfter(timestamp: Instant): Field { +fun Field.isAfter(timestamp: Instant): Condition { return greaterThan(timestamp) } -private fun Field.isBefore(timestamp: Instant): Field { +fun Field.isBefore(timestamp: Instant): Condition { return lessThan(timestamp) } diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/RecordOps.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/RecordOps.kt index 7e3f120d4..608538df4 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/RecordOps.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/utils/RecordOps.kt @@ -207,6 +207,10 @@ private fun Game.userIdByColor(color: Color): String? { } } +fun BotGame.prettyEngineName() : String { + return "$engine (${depth})" +} + fun SevenKingdomsGame.minColorPerPlayer(): Int { return when (minPlayers) { 4 -> 1 diff --git a/webapp-html-renderer/src/main/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolver.kt b/webapp-html-renderer/src/main/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolver.kt new file mode 100644 index 000000000..2b1df0d9b --- /dev/null +++ b/webapp-html-renderer/src/main/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolver.kt @@ -0,0 +1,20 @@ +package io.elephantchess.htmlrenderer + +import kotlinx.html.TagConsumer +import kotlinx.html.stream.appendHTML + +/** + * Resolves a template tag by rendering HTML content with the Ktor/kotlinx.html DSL. + */ +class KtorHtmlBuilderTagResolver( + override val tagName: String, + private val htmlBuilder: TagConsumer.() -> Unit, +) : TagResolver { + + override suspend fun resolveContent() = listOf( + buildString { + appendHTML().run(htmlBuilder) + } + ) + +} diff --git a/webapp-html-renderer/src/test/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolverTest.kt b/webapp-html-renderer/src/test/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolverTest.kt new file mode 100644 index 000000000..5a53e98ac --- /dev/null +++ b/webapp-html-renderer/src/test/kotlin/io/elephantchess/htmlrenderer/KtorHtmlBuilderTagResolverTest.kt @@ -0,0 +1,34 @@ +package io.elephantchess.htmlrenderer + +import kotlinx.coroutines.test.runTest +import kotlinx.html.a +import kotlinx.html.p +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class KtorHtmlBuilderTagResolverTest { + + @Test + fun `resolves html content from builder`() = runTest { + val url = "https://elephantchess.io/game?id=42" + val resolver = KtorHtmlBuilderTagResolver("game_link") { + a(href = url) { +url } + } + + val resolved = resolver.resolveContent() + assertEquals(listOf("""$url"""), resolved) + assertFalse(resolved.single().endsWith("\n")) + } + + @Test + fun `resolves non-anchor html fragments`() = runTest { + val resolver = KtorHtmlBuilderTagResolver("paragraph") { + p { +"Hello from builder" } + } + + // kotlinx.html keeps a trailing newline for this block-level tag. + assertEquals(listOf("

Hello from builder

\n"), resolver.resolveContent()) + } + +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt index 2b976fdf5..b6e29dc74 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt @@ -64,6 +64,7 @@ private fun daoModule() = module { singleAuto() singleAuto() singleAuto() + singleAuto() singleAuto() singleAuto() singleAuto() diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/DatabaseGameSummary.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/DatabaseGameSummary.kt new file mode 100644 index 000000000..a9e7bd10f --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/DatabaseGameSummary.kt @@ -0,0 +1,24 @@ +package io.elephantchess.servicelayer.dto.database + +import io.elephantchess.model.AnalysisStatus +import io.elephantchess.model.Outcome +import java.time.LocalDate + +/** + * Lightweight summary of a database (reference) game, used to populate + * the title / meta description / server-rendered content of the database + * game viewer page. + */ +data class DatabaseGameSummary( + val gameId: String, + val redPlayerCanonicalName: String?, + val redPlayerChineseName: String?, + val blackPlayerCanonicalName: String?, + val blackPlayerChineseName: String?, + val eventId: String?, + val eventName: String?, + val date: LocalDate?, + val outcome: Outcome?, + val finalFen: String?, + val analysisStatus: AnalysisStatus?, +) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/MyDbSearchesResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/MyDbSearchesResponse.kt new file mode 100644 index 000000000..bd7744da6 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/database/MyDbSearchesResponse.kt @@ -0,0 +1,19 @@ +package io.elephantchess.servicelayer.dto.database + +import io.elephantchess.xiangqi.Color + +data class MyDbSearchesResponse(val entries: List) { + + data class Entry( + val queryId: String, + val updateTime: Long, + val playerName: String?, + val playerColor: Color?, + val eventName: String?, + val searchStart: String?, + val searchEnd: String?, + val fen: String?, + val numberOfResults: Int, + ) + +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsRequest.kt new file mode 100644 index 000000000..9d12286c9 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsRequest.kt @@ -0,0 +1,3 @@ +package io.elephantchess.servicelayer.dto.user + +data class DeleteUserSessionsRequest(val sessionIds: List) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsResponse.kt new file mode 100644 index 000000000..8bb516665 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/DeleteUserSessionsResponse.kt @@ -0,0 +1,3 @@ +package io.elephantchess.servicelayer.dto.user + +data class DeleteUserSessionsResponse(val deletedCount: Int) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailAddressSettingsResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailAddressSettingsResponse.kt index 0cd17cd9e..f0e126239 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailAddressSettingsResponse.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailAddressSettingsResponse.kt @@ -2,5 +2,5 @@ package io.elephantchess.servicelayer.dto.user data class EmailAddressSettingsResponse( val email: String, - val isValid: Boolean?, + val validityStatus: EmailValidityStatus, ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailValidityStatus.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailValidityStatus.kt new file mode 100644 index 000000000..d6bc5d290 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/EmailValidityStatus.kt @@ -0,0 +1,43 @@ +package io.elephantchess.servicelayer.dto.user + +/** + * Describes how the email address of a user has been (or has not been) validated. + * + * The check is layered: + * 1. If the user clicked the confirmation link sent at signup, the address is considered + * [MANUALLY_CONFIRMED] (the strongest signal). + * 2. Otherwise, we fall back to the automated verification (external service / bounce list) + * and report one of [AUTOMATED_VALID], [AUTOMATED_BOUNCED] or [AUTOMATED_INVALID]. + * 3. If we have no information at all, the status is [UNKNOWN]. + */ +enum class EmailValidityStatus { + + /** + * The user clicked the confirmation link we sent them at signup. + */ + MANUALLY_CONFIRMED, + + /** + * The external automated verification reported the address as valid. + */ + AUTOMATED_VALID, + + /** + * An email previously sent to this address bounced. + */ + AUTOMATED_BOUNCED, + + /** + * The external automated verification reported the address as invalid (other than a bounce). + */ + AUTOMATED_INVALID, + + /** + * No information available yet. + */ + UNKNOWN; + + val isValid: Boolean + get() = this == MANUALLY_CONFIRMED || this == AUTOMATED_VALID + +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/SignUpRequest.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/SignUpRequest.kt index 77a2ce0dd..5f73342b0 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/SignUpRequest.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/SignUpRequest.kt @@ -3,5 +3,6 @@ package io.elephantchess.servicelayer.dto.user data class SignUpRequest( val username: String, val email: String, - val password: String + val password: String, + val transferGuestData: Boolean = false, ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserSessionsSettingsResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserSessionsSettingsResponse.kt new file mode 100644 index 000000000..65f5e8e03 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserSessionsSettingsResponse.kt @@ -0,0 +1,19 @@ +package io.elephantchess.servicelayer.dto.user + +data class UserSessionsSettingsResponse( + val entries: List, + val total: Int, +) { + data class Entry( + val id: Int, + val os: String, + val agentName: String, + val countryCode: String?, + val countryName: String?, + val region: String?, + val city: String?, + val remoteAddress: String, + val created: Long, + val updated: Long, + ) +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerInput.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerInput.kt index 38ca7a6b6..c848a35d6 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerInput.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerInput.kt @@ -1,5 +1,6 @@ package io.elephantchess.servicelayer.dto.ws data class PlayerVsPlayerInput( - val message: String, + val message: String? = null, + val isTyping: Boolean = false, ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerUpdate.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerUpdate.kt index 7b7b9a303..c14cb8e88 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerUpdate.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/ws/PlayerVsPlayerUpdate.kt @@ -14,6 +14,13 @@ data class PlayerVsPlayerUpdate( val ratingUpdate: RatingUpdate? = null, val timeRemaining: TimeRemaining? = null, val chatMessages: List = emptyList(), + val typingUsers: List = emptyList(), +) + +data class TypingUser( + val userId: String, + val username: String, + val typedAt: Long, ) data class HasJoined( diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt index 623381710..067a2763e 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/DatabaseService.kt @@ -96,6 +96,7 @@ class DatabaseService( } } + suspend fun search( dateStart: String?, dateEnd: String?, @@ -182,20 +183,31 @@ class DatabaseService( ) } - referenceGameDaoService.persistSearch( - userId = userId, - searchStart = dateStartParsed, - searchEnd = dateEndParsed, - playerName = playerName, - playerId = playerIds.firstOrNull(), - playerColor = playerColor, - eventName = eventName, - eventId = eventIds.firstOrNull(), - fen = fen, - offset = offset, - limit = SEARCH_RESULT_LIMIT, - numberOfResults = entries.size - ) + val hasAnyCriteria = dateStartParsed != null || + dateEndParsed != null || + !playerName.isNullOrBlank() || + resolvedPlayerIds.isNotEmpty() || + playerColor != null || + !eventName.isNullOrBlank() || + resolvedEventIds.isNotEmpty() || + !abridgedFen.isNullOrBlank() + + if (hasAnyCriteria) { + referenceGameDaoService.persistSearch( + userId = userId, + searchStart = dateStartParsed, + searchEnd = dateEndParsed, + playerName = playerName, + playerId = playerIds.firstOrNull(), + playerColor = playerColor, + eventName = eventName, + eventId = eventIds.firstOrNull(), + fen = fen, + offset = offset, + limit = SEARCH_RESULT_LIMIT, + numberOfResults = entries.size + ) + } return ReferenceGameSearchResult(entries) } @@ -482,6 +494,25 @@ class DatabaseService( return referencePlayerDaoService.searchByNames(names = nameVariations, excludedPlayerId = player.id) } + suspend fun fetchGameSummary(gameId: String): DatabaseGameSummary? { + val record = referenceGameDaoService.findById(gameId) ?: return null + val redPlayer = record.redPlayer?.let { referencePlayerDaoService.findPlayer(it) } + val blackPlayer = record.blackPlayer?.let { referencePlayerDaoService.findPlayer(it) } + return DatabaseGameSummary( + gameId = record.id, + redPlayerCanonicalName = redPlayer?.canonicalName, + redPlayerChineseName = redPlayer?.chineseName, + blackPlayerCanonicalName = blackPlayer?.canonicalName, + blackPlayerChineseName = blackPlayer?.chineseName, + eventId = record.event, + eventName = idToEventName(record.event), + date = record.date, + outcome = record.outcome, + finalFen = record.finalFen, + analysisStatus = record.analysisStatus, + ) + } + suspend fun fetchEventName(eventId: String): String? { return referenceEventDaoService.findEventName(eventId) } @@ -627,6 +658,39 @@ class DatabaseService( return ValidatedResponse.Valid(Unit) } + suspend fun listUserSearches(userId: String, beforeTime: Long?): MyDbSearchesResponse { + val beforeInstant = beforeTime?.let { kotlin.time.Instant.fromEpochMilliseconds(it) } + val records = referenceGameDaoService.listUserSearches(userId, beforeInstant, 30) + val resolvedPlayers = records + .mapNotNull { entry -> entry.playerName } + .distinct() + .mapNotNull { name -> resolvePlayerByName(name)?.let { name to it } } + .toMap() + + return records + .map { record -> + val prettyName = + record.playerName?.let { name -> + resolvedPlayers[name] + ?.let { player -> formatWithChineseName(player.canonicalName, player.chineseName) } + ?: name + } + + MyDbSearchesResponse.Entry( + queryId = record.queryId, + updateTime = record.updateTime.toEpochMilliseconds(), + playerName = prettyName, + playerColor = record.playerColor, + eventName = record.eventName, + searchStart = record.searchStart?.toString(), + searchEnd = record.searchEnd?.toString(), + fen = record.fen, + numberOfResults = record.numberOfResults ?: 0, + ) + } + .let { mapped -> MyDbSearchesResponse(mapped) } + } + suspend fun findEditedPlayersLatestVersions(userId: String): ListUserEdits { return referencePlayerDaoService .findEditedPlayersByEditorId(userId) 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 09a94b755..504d06561 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 @@ -53,6 +53,7 @@ class GameDataService( private val referencePlayerDaoService: ReferencePlayerDaoService, private val referenceEventDaoService: ReferenceEventDaoService, private val userDaoService: UserDaoService, + private val userService: UserService, private val userCache: UserCache, private val logger: KLogger, ) { @@ -122,10 +123,30 @@ class GameDataService( ?.let { record -> val userId = record.userId val userName = fetchUserName(userId) - val redPlayerId = if (record.userColor == RED) userId else null - val redPlayerName = if (record.userColor == RED) userName else null - val blackPlayerId = if (record.userColor == BLACK) userId else null - val blackPlayerName = if (record.userColor == BLACK) userName else null + var blackPlayerId: String? = null + var blackPlayerName: String? = null + var redPlayerId: String? = null + var redPlayerName: String? = null + val engineName = record.prettyEngineName() + + when (record.userColor) { + RED -> { + redPlayerId = userId + redPlayerName = userName + blackPlayerName = engineName + } + + BLACK -> { + redPlayerName = engineName + blackPlayerId = userId + blackPlayerName = userName + } + + else -> { + // should not happen, but in case of data inconsistency, we don't want to fail the whole request + logger.warn { "Game $gameId has invalid user color ${record.userColor}" } + } + } GameMetadataDto( gameId = gameId, @@ -495,60 +516,93 @@ class GameDataService( ) val userIds = games.flatMap { game -> listOf(game.inviter, game.invitee) }.distinct().filterNotNull() - val lastOnlineByUserId = userDaoService.fetchLastOnline(userIds) - val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) + val onlineUserIds = userService.areOnline(userIds).onlineUserIds val selectedGames = if (distinctByUsers) distinctByUserId(games) else games return selectedGames .take(actualLimit) - .map { gameRecord -> - val redUserId = gameRecord.redUserId()!! - val blackUserId = gameRecord.blackUserId()!! - - GameMetadataDto( - gameId = GameId(PVP, gameRecord.id), - redPlayerId = redUserId, - redPlayerName = fetchUserName(redUserId), - redPlayerRating = gameRecord.redPlayerRating(), - redUserType = userCache.fetchUserType(redUserId), - isRedOnline = lastOnlineByUserId[redUserId]?.isAfter(isOnlineLimit) == true, - blackPlayerId = blackUserId, - blackPlayerName = fetchUserName(blackUserId), - blackPlayerRating = gameRecord.blackPlayerRating(), - blackUserType = userCache.fetchUserType(blackUserId), - isBlackOnline = lastOnlineByUserId[blackUserId]?.isAfter(isOnlineLimit) == true, - userColor = null, - finalFen = gameRecord.currentFen, - status = gameRecord.gameStatus, - outcome = gameRecord.outcome, - lastUpdated = gameRecord.lastUpdated.toEpochMilliseconds() - ) + .map { mapPvpGameToDto(it, onlineUserIds) } + .let { entries -> + ListLastGamesResponse(entries) } + } + + suspend fun listLastPvpGamesByUsername( + username: String, + requestedLimit: Int, + beforeTs: Long? + ): ListLastGamesResponse { + val user = userDaoService.findByUserName(username) + ?: throw NotFoundException("User $username could not be found") + return listLastPvpGamesByUserId( + userId = user.id, + requestedLimit = requestedLimit, + beforeTs = beforeTs + ) + } + + suspend fun listLastPvpGamesByUserId( + userId: String, + requestedLimit: Int, + beforeTs: Long? + ): ListLastGamesResponse { + val games = pvpGameDaoService.listLastGamesByUserId( + userId = userId, + limit = requestedLimit, + minMoveIndex = MIN_MOVE_INDEX, + beforeTs = beforeTs + ) + + val userIds = games.flatMap { game -> listOf(game.inviter, game.invitee) }.distinct().filterNotNull() + val onlineUserIds = userService.areOnline(userIds).onlineUserIds + + return games + .map { mapPvpGameToDto(it, onlineUserIds) } .let { entries -> ListLastGamesResponse(entries) } } - suspend fun listLastPvbGames( + private suspend fun mapPvpGameToDto(gameRecord: Game, onlineUserIds: Set): GameMetadataDto { + val redUserId = gameRecord.redUserId()!! + val blackUserId = gameRecord.blackUserId()!! + return GameMetadataDto( + gameId = GameId(PVP, gameRecord.id), + redPlayerId = redUserId, + redPlayerName = fetchUserName(redUserId), + redPlayerRating = gameRecord.redPlayerRating(), + redUserType = userCache.fetchUserType(redUserId), + isRedOnline = onlineUserIds.contains(redUserId), + blackPlayerId = blackUserId, + blackPlayerName = fetchUserName(blackUserId), + blackPlayerRating = gameRecord.blackPlayerRating(), + blackUserType = userCache.fetchUserType(blackUserId), + isBlackOnline = onlineUserIds.contains(blackUserId), + userColor = null, + finalFen = gameRecord.currentFen, + status = gameRecord.gameStatus, + outcome = gameRecord.outcome, + lastUpdated = gameRecord.lastUpdated.toEpochMilliseconds() + ) + } + + suspend fun listLatestPvbGames( requestedLimit: Int, distinctByUsers: Boolean = true, - beforeTs: Long? = null + beforeTs: Long? = null, + excludeAutoResigned: Boolean ): ListLastGamesResponse { - val actualLimit = if (distinctByUsers) requestedLimit * 20 else requestedLimit val gameRecords = pvbGameDaoService - .listLastGamesByIdentifiedUsers(actualLimit, minMoveIndex = MIN_MOVE_INDEX, beforeTs = beforeTs) - .let { games -> - if (distinctByUsers) { - games.distinctBy { game -> game.userId } - } else { - games - } - } - .sortedByDescending { game -> game.lastUpdated } + .listLatestGamesByIdentifiedUsers( + limit = requestedLimit, + minMoveIndex = MIN_MOVE_INDEX, + beforeTs = beforeTs, + excludeAutoResigned = excludeAutoResigned, + distinctByUsers = distinctByUsers + ) val userIds = gameRecords.map { game -> game.userId }.distinct().filterNotNull() - val lastOnlineByUserId = userDaoService.fetchLastOnline(userIds) - val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) + val onlineUserIds = userService.areOnline(userIds).onlineUserIds return gameRecords .take(requestedLimit) @@ -561,7 +615,7 @@ class GameDataService( val blackPlayerName: String? val blackPlayerRating: Int? val blackUserType: UserType? - val isUserOnline = lastOnlineByUserId[record.userId]?.isAfter(isOnlineLimit) == true + val isUserOnline = onlineUserIds.contains(record.userId) if (record.userColor == RED) { redUserId = record.userId @@ -569,13 +623,13 @@ class GameDataService( redPlayerRating = null redUserType = userCache.fetchUserType(record.userId) blackUserId = null - blackPlayerName = record.engine.toString() - blackPlayerRating = record.depth + blackPlayerName = record.prettyEngineName() + blackPlayerRating = null blackUserType = null } else { redUserId = null - redPlayerName = record.engine.toString() - redPlayerRating = record.depth + redPlayerName = record.prettyEngineName() + redPlayerRating = null redUserType = null blackUserId = record.userId blackPlayerName = fetchUserName(record.userId) @@ -670,8 +724,7 @@ class GameDataService( val pvpUserIds = pvpGames.flatMap { game -> listOf(game.redUserId(), game.blackUserId()) }.filterNotNull() val pvbUserIds = pvbGames.mapNotNull { game -> game.userId } val allUserIds = (pvpUserIds + pvbUserIds).distinct() - val lastOnlineByUserId = if (allUserIds.isNotEmpty()) userDaoService.fetchLastOnline(allUserIds) else emptyMap() - val isOnlineLimit = Clock.System.now().minusSeconds(ONLINE_STATUS_THRESHOLD_SECONDS) + val onlineUserIds = if (allUserIds.isNotEmpty()) userService.areOnline(allUserIds).onlineUserIds else emptySet() val entries = pvpGames.map { game -> val redUserId = game.redUserId() @@ -682,12 +735,12 @@ class GameDataService( fen = game.currentFen, lastUpdated = game.lastUpdated.toEpochMilliseconds(), outcome = game.outcome, - isRedOnline = lastOnlineByUserId[redUserId]?.isAfter(isOnlineLimit) == true, - isBlackOnline = lastOnlineByUserId[blackUserId]?.isAfter(isOnlineLimit) == true, + isRedOnline = onlineUserIds.contains(redUserId), + isBlackOnline = onlineUserIds.contains(blackUserId), ) } + pvbGames.map { game -> val userId = game.userId - val isUserOnline = lastOnlineByUserId[userId]?.isAfter(isOnlineLimit) == true + val isUserOnline = onlineUserIds.contains(userId) LatestGamesUpdateResponse.Entry( gameId = GameId(PVB, game.id), status = game.gameStatus, @@ -706,7 +759,6 @@ class GameDataService( const val DEFAULT_ANALYSIS_DEPTH = 20 const val MIN_MOVE_INDEX = 6 - const val ONLINE_STATUS_THRESHOLD_SECONDS = 15L } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt index c159ea5ef..4913f1b6a 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt @@ -11,10 +11,12 @@ import io.elephantchess.htmlrenderer.SimpleValueTagResolver import io.elephantchess.htmlrenderer.TagResolver import io.elephantchess.model.EmailVerifierService.EMAIL_LIST_VERIFY import io.elephantchess.servicelayer.clients.EmailListVerifyClient +import io.elephantchess.servicelayer.dto.user.EmailValidityStatus import io.elephantchess.servicelayer.model.Email import io.elephantchess.servicelayer.model.MatchingEmail import io.elephantchess.servicelayer.model.UserId import io.elephantchess.servicelayer.services.UserService.Companion.PASSWORD_RECOVERY_TIMEOUT_HOURS +import io.elephantchess.servicelayer.services.resolvers.EmailConfirmationLinkTagResolver import io.elephantchess.servicelayer.services.resolvers.GameLinkTagResolver import io.elephantchess.servicelayer.services.resolvers.RecoveryLinkTagResolver import io.elephantchess.servicelayer.services.resolvers.UnsubscribeTagResolver @@ -58,25 +60,38 @@ class MailService( private val mailScope by lazy { CoroutineScope(Dispatchers.IO) } suspend fun isEmailAddressValid(email: String): Boolean { - return getEmailValidityStatus(email) == true + return getEmailValidityDetails(email).isValid } /** - * null means not checked yet + * Returns the full [EmailValidityStatus] for the given email. + * + * The check is layered: if a user owns this address and has clicked the confirmation + * link sent at signup, the address is considered [EmailValidityStatus.MANUALLY_CONFIRMED] + * (the strongest signal). Otherwise, we fall back to the automated verification. */ - suspend fun getEmailValidityStatus(email: String): Boolean? { + suspend fun getEmailValidityDetails(email: String): EmailValidityStatus { + val user = userDaoService.findByEmail(email) + if (user?.emailConfirmedAt != null) { + return EmailValidityStatus.MANUALLY_CONFIRMED + } + if (emailVerificationDaoService.hasEmailBounced(email)) { - return false + return EmailValidityStatus.AUTOMATED_BOUNCED } val automatedVerifications = emailVerificationDaoService.findAutomatedVerifications(email, emailValidityDuration) return when (automatedVerifications.size) { - 0 -> null + 0 -> EmailValidityStatus.UNKNOWN else -> { val result = automatedVerifications.last().result - result == "ok" || result == "ok_for_all" + if (result == "ok" || result == "ok_for_all") { + EmailValidityStatus.AUTOMATED_VALID + } else { + EmailValidityStatus.AUTOMATED_INVALID + } } } } @@ -145,22 +160,27 @@ class MailService( /** * List all emails that: - * - have been validated automatically by the external service + * - have a valid status (manually confirmed by the user, or automatically validated by the + * external service) * - have not bounced * - consent to receive the newsletter (implicit) */ suspend fun listNewsLetterRecipientEmails(): List { val automaticallyValidated = emailVerificationDaoService.listAllAutomaticallyValidatedEmails(emailValidityDuration) + val manuallyConfirmed = userDaoService.listManuallyConfirmedEmailAddresses().toSet() val bounced = emailVerificationDaoService.listBouncedEmails() val newsletterRecipients = userDaoService .listNewsletterEmailAddresses() - .filter { emailAddress -> automaticallyValidated.contains(emailAddress) } + .filter { emailAddress -> + manuallyConfirmed.contains(emailAddress) || automaticallyValidated.contains(emailAddress) + } .filterNot { emailAddress -> bounced.contains(emailAddress) } .map { emailAddress -> emailAddress.trim() } logger.info { - "found ${automaticallyValidated.size} valid e-mail addresses, " + + "found ${automaticallyValidated.size} automatically validated e-mail addresses, " + + "${manuallyConfirmed.size} manually confirmed e-mail addresses, " + "${bounced.size} bounced e-mail addresses, " + "${newsletterRecipients.size} total newsletter recipients" } @@ -209,15 +229,37 @@ class MailService( ) } - suspend fun sendNewUserNotification(user: User) { + suspend fun sendNewUserNotification(user: User, guestTransferred: Boolean) { resolveAndSend( recipient = ADMIN_GMAIL_EMAIL, subject = "new user", templateName = "new_user_notification", resolvers = listOf( SimpleValueTagResolver("username", user.handle), - SimpleValueTagResolver("email", user.email) - ) + SimpleValueTagResolver("email", user.email), + SimpleValueTagResolver("guest_transferred", if (guestTransferred) "yes" else "no") + ), + skipRecipientValidityCheck = true + ) + } + + suspend fun sendEmailConfirmation(recipient: String, code: String, showWelcomeMessage: Boolean) { + val subject = if (showWelcomeMessage) { + "Welcome to elephantchess - email address confirmation" + } else { + "elephantchess - email address confirmation" + } + + resolveAndSend( + recipient = recipient, + subject = subject, + templateName = "email_confirmation", + resolvers = listOf( + EmailConfirmationLinkTagResolver(webHost, code) + ), + // The whole point of this email is to flip the recipient's status to MANUALLY_CONFIRMED, + // so we must not gate it on the address already being known-valid (it never is at signup). + skipRecipientValidityCheck = true, ) } @@ -326,6 +368,7 @@ class MailService( templateName: String, resolvers: List = listOf(), copyToAdmin: Boolean = false, + skipRecipientValidityCheck: Boolean = false, ) { fun sendSafeAsync(email: Email) { mailScope.launch { @@ -338,11 +381,11 @@ class MailService( } if (!sendMailNotifications) { - logger.debug { "not sending e-mail '$subject' because emails are disabled" } + logger.info { "not sending e-mail '$subject' because emails are disabled" } return } - if (!isEmailAddressValid(recipient)) { + if (!skipRecipientValidityCheck && !isEmailAddressValid(recipient)) { logger.debug { "not sending e-mail '$subject' because recipient $recipient is not valid" } return } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt index af3bb2a30..aa6b09ede 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt @@ -7,6 +7,7 @@ import io.elephantchess.db.dao.codegen.tables.pojos.Game import io.elephantchess.db.dao.codegen.tables.pojos.GameChatMessage import io.elephantchess.db.model.TimeControlRecord import io.elephantchess.db.services.ChatMessageDaoService +import io.elephantchess.db.services.GameChatTypingStatusDaoService import io.elephantchess.db.services.PlayerVsPlayerGameDaoService import io.elephantchess.db.services.UserDaoService import io.elephantchess.db.utils.* @@ -62,13 +63,14 @@ class PlayerVsPlayerGameService( private val mailService: MailService, private val pvpGameDaoService: PlayerVsPlayerGameDaoService, private val chatMessageDaoService: ChatMessageDaoService, + private val gameChatTypingStatusDaoService: GameChatTypingStatusDaoService, private val userCache: UserCache, private val discordService: DiscordService, private val logger: KLogger, refresherScope: CoroutineScope ) { - private val sessionsRefresh = 2.seconds + private val sessionsRefresh = 1.seconds private val perpetualCheckRules by lazy { defaultPerpetualCheckingRules } private val gamesToPlaySessions = mutableListOf() @@ -140,9 +142,14 @@ class PlayerVsPlayerGameService( private suspend fun refreshPlayerVsPlayerSessions() { val allGameIds = playerVsPlayerSessions.map { session -> session.gameId }.distinct() val stateMap = pvpGameDaoService.fetchGameStates(allGameIds) + + // chat val chatIndexes = chatMessageDaoService.currentIndexes(allGameIds) + val chatTypingStatusCutOff = Clock.System.now() - TYPING_FRESHNESS_WINDOW + val chatTypingStatusMap = gameChatTypingStatusDaoService.fetchForGameIds(allGameIds, chatTypingStatusCutOff) - // not very optimized: 2 sessions about the same game -> some information will be fetched 2x from the db + // TODO: not very optimized: 2 sessions about the same game -> some information will be fetched 2x from the db + // DAO access could be cached in Map and stuff playerVsPlayerSessions .forEach { session -> val gameId = session.gameId @@ -191,12 +198,28 @@ class PlayerVsPlayerGameService( ) } + // typing: collect all fresh typing events in this game from other users. + // Stale entries are filtered out at the DAO layer (see + // [TypingStatusDaoService.fetchTypingStatuses]), and clients are + // responsible for de-duplicating / hiding the indicator on their side. + val typingUsers = chatTypingStatusMap[gameId] + ?.filter { entry -> entry.userId != session.userId.id } + ?.map { entry -> + TypingUser( + userId = entry.userId, + username = userCache.fetchUsernameOrDefault(entry.userId), + typedAt = entry.typedAt.toEpochMilliseconds(), + ) + } + .orEmpty() + val shouldUpdate = session.currentStatus() != status || newMove != null || hasJoinedEvent != null || timeRemaining != null || - chatMessages.isNotEmpty() + chatMessages.isNotEmpty() || + typingUsers.isNotEmpty() // update session if (shouldUpdate) { @@ -214,6 +237,7 @@ class PlayerVsPlayerGameService( ratingUpdate = fetchRatingUpdateIfNecessaryWs(session.gameId, status), timeRemaining = timeRemaining, chatMessages = chatMessages, + typingUsers = typingUsers, ) ) } @@ -418,6 +442,7 @@ class PlayerVsPlayerGameService( val session = PlayerVsPlayerWebSocketSession( gameId = gameId, + userId = userId, status = gameState.gameEventType, moveIndex = gameState.index, chatIndex = chatMessageDaoService.currentIndex(gameId), @@ -434,11 +459,15 @@ class PlayerVsPlayerGameService( val isAllowedToChat = gamePlayersStatus.status == CREATED || gamePlayersStatus.isPlaying(userId.id) if (isAllowedToChat) { - chatMessageDaoService.insertChat( - gameId = gameId, - userId = userId.id, - content = input.message.trim().take(MESSAGE_LENGTH_LIMIT) - ) + if (input.message != null) { + chatMessageDaoService.insertChat( + gameId = gameId, + userId = userId.id, + content = input.message.trim().take(MESSAGE_LENGTH_LIMIT) + ) + } else if (input.isTyping) { + gameChatTypingStatusDaoService.upsertTypingStatus(gameId, userId.id) + } } else { logger.warn { "user $userId is not allowed to send message in game $gameId" } } @@ -928,6 +957,7 @@ class PlayerVsPlayerGameService( /** * Only fetch the necessary fields to proceed to the basic requests validation */ + // TODO: should we explicitly index the columns fetches in this call? private suspend fun fetchPlayersAndStatus(gameId: String) = pvpGameDaoService.fetchPlayersAndStatus(gameId) ?: throw NotFoundException("Game $gameId not found") @@ -1061,6 +1091,14 @@ class PlayerVsPlayerGameService( val NOTIFICATIONS_OFFLINE_FOR = 2.minutes const val MESSAGE_LENGTH_LIMIT = 200 + /** + * Maximum age of a typing event we are willing to surface to a client. + * Older entries are considered stale (e.g. persisted in the DB from a + * previous session) and are filtered out so that, on refresh/reconnect, + * we do not re-trigger an "is typing…" indicator for stale events. + */ + val TYPING_FRESHNESS_WINDOW = 2.seconds + fun formatMillis(millis: Long): String { val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt() val minutes = TimeUnit.MILLISECONDS.toMinutes(millis).toInt() - hours * 60 diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt index 0663faa08..29ce8ff36 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt @@ -4,7 +4,12 @@ import io.elephantchess.config.AppConfig import io.elephantchess.db.dao.codegen.tables.pojos.PasswordRecoveryAttempt import io.elephantchess.db.dao.codegen.tables.pojos.User import io.elephantchess.db.services.PasswordRecoveryAttemptsDaoService +import io.elephantchess.db.services.PlayerVsBotGameDaoService +import io.elephantchess.db.services.PlayerVsPlayerGameDaoService +import io.elephantchess.db.services.PuzzleResultDaoService +import io.elephantchess.db.services.ReferenceGameDaoService import io.elephantchess.db.services.UserDaoService +import io.elephantchess.db.services.UserSessionDaoService import io.elephantchess.db.utils.* import io.elephantchess.model.UserType import io.elephantchess.servicelayer.dto.ContactFormRequest @@ -23,6 +28,7 @@ import io.github.oshai.kotlinlogging.KLogger import kotlinx.coroutines.CoroutineScope import org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric import java.time.LocalDate +import java.util.UUID import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec import kotlin.time.Clock @@ -32,6 +38,11 @@ class UserService( appConfig: AppConfig, private val passwordRecoveryRequestDaoService: PasswordRecoveryAttemptsDaoService, private val userDaoService: UserDaoService, + private val userSessionDaoService: UserSessionDaoService, + private val playerVsPlayerGameDaoService: PlayerVsPlayerGameDaoService, + private val playerVsBotGameDaoService: PlayerVsBotGameDaoService, + private val puzzleResultDaoService: PuzzleResultDaoService, + private val referenceGameDaoService: ReferenceGameDaoService, private val userSessionService: UserSessionService, private val tokenManager: TokenManager, private val mailService: MailService, @@ -40,6 +51,11 @@ class UserService( private val logger: KLogger, ) { + private fun normalizeCountry(country: String?): String? = + country + ?.trim() + ?.takeUnless { it.isBlank() || it.equals("none", ignoreCase = true) } + @Volatile private var onlineUserIds: Set = emptySet() @@ -83,7 +99,7 @@ class UserService( } } - suspend fun signUp(request: SignUpRequest): ValidatedResponse { + suspend fun signUp(request: SignUpRequest, guestUserId: String? = null): ValidatedResponse { val errors = validateSignUpRequest(request) val now = Clock.System.now() @@ -98,9 +114,23 @@ class UserService( user.lastOnline = now user.userType = UserType.AUTHENTICATED user.puzzleRating = PUZZLE_START_RATING + user.emailConfirmationCode = UUID.randomUUID().toString() + user.emailConfirmationCodeCreatedAt = now userDaoService.save(user) - mailService.sendNewUserNotification(user) + val guestTransferred = + if (guestUserId != null) { + transferGuestData(guestUserId, user.id) + true + } else { + false + } + + // email + mailService.sendNewUserNotification(user, guestTransferred) + mailService.sendEmailConfirmation(user.email, user.emailConfirmationCode, showWelcomeMessage = true) mailService.verifyEmailAddressAsync(user.email) + + // result ValidatedResponse.Valid( SignUpResponse( userId = user.id, @@ -116,6 +146,51 @@ class UserService( } } + /** + * Confirms the email address associated with the given code, which was sent to the user by email at signup. + * Returns true if the code matches a user (whether it was already confirmed or not) and has not expired. + * + * The code is valid for [EMAIL_CONFIRMATION_CODE_EXPIRY_HOURS] hour(s) after it was generated; after that the + * user must request a new one from the settings page. Already-confirmed users remain confirmed regardless + * of code expiration (the check is idempotent). + * + * The result is stored in [User.emailConfirmedAt], which is separate from the automated verification + * results stored in the `email_verification` table. + */ + suspend fun confirmEmail(code: String): Boolean { + if (code.isBlank()) { + return false + } + val user = userDaoService.findByEmailConfirmationCode(code) ?: return false + if (user.emailConfirmedAt == null) { + val createdAt = user.emailConfirmationCodeCreatedAt + if (createdAt == null || createdAt.plusHours(EMAIL_CONFIRMATION_CODE_EXPIRY_HOURS) + .isBefore(Clock.System.now()) + ) { + logger.info { "email confirmation code expired for user ${user.id}" } + return false + } + userDaoService.markEmailConfirmed(user.id, Clock.System.now()) + logger.info { "email confirmed for user ${user.id}" } + } + return true + } + + /** + * Regenerates the email confirmation code (with a fresh creation timestamp) and re-sends the + * confirmation email. No-op for users whose email is already manually confirmed. + */ + suspend fun resendEmailConfirmation(userId: String) { + val user = userDaoService.findById(userId) ?: throw NotFoundException("User not found: $userId") + if (user.emailConfirmedAt != null) { + logger.debug { "email already confirmed for user $userId, skipping resend" } + return + } + val newCode = UUID.randomUUID().toString() + userDaoService.updateEmailConfirmationCode(userId, newCode, Clock.System.now()) + mailService.sendEmailConfirmation(user.email, newCode, showWelcomeMessage = false) + } + suspend fun obtainGuestUserToken(): ObtainAnonymousTokenResponse { val id = userDaoService.createGuestUser() logger.debug { "created guest #$id" } @@ -235,6 +310,16 @@ class UserService( } } + /** + * Throws NotFoundException if user with given username does not exist + */ + suspend fun validateUserExists(username: String) { + val userExists = userDaoService.existsForUsername(username) + if (!userExists) { + throw NotFoundException("User $username could not be found") + } + } + suspend fun fetchProfile(username: String): UserProfile { // TODO: only fetch relevant fields val user = userDaoService.findByUserName(username) @@ -244,7 +329,7 @@ class UserService( UserProfile( userId = user.id, username = user.handle, - country = user.country, + country = normalizeCountry(user.country), profileDescription = user.description, puzzleRating = user.puzzleRating ) @@ -263,9 +348,6 @@ class UserService( } } - suspend fun fetchDescriptionByUserName(username: String) = - userDaoService.fetchDescriptionByUsername(username) - suspend fun updateProfileSettings(userId: String, request: ProfileSettingsDto) { // max 2 consecutive line breaks fun removeSuperfluousLineBreaks(input: String): String { @@ -278,7 +360,8 @@ class UserService( } val description = stripHtml(removeSuperfluousLineBreaks(request.description)) - userDaoService.updateProfileSettings(userId, description, request.country) + val country = normalizeCountry(request.country) + userDaoService.updateProfileSettings(userId, description, country) } suspend fun fetchNotificationsSettings(userId: String): NotificationsSettingsDto { @@ -315,10 +398,51 @@ class UserService( return EmailAddressSettingsResponse( email = email, - isValid = mailService.getEmailValidityStatus(email), + validityStatus = mailService.getEmailValidityDetails(email), + ) + } + + suspend fun fetchUserSessions(userId: String, limit: Int, offset: Int = 0): UserSessionsSettingsResponse { + val total = userSessionDaoService.countAuthenticatedSessionsForUser(userId) + val entries = + userSessionDaoService + .listAuthenticatedSessionsForUser(userId, limit, offset) + .map { record -> + UserSessionsSettingsResponse.Entry( + id = record.id!!, + os = record.operatingSystemName, + agentName = record.agentName, + countryCode = record.countryCode, + countryName = record.countryName, + region = record.region, + city = record.city, + remoteAddress = record.remoteAddress, + created = record.created!!.toEpochMilliseconds(), + updated = record.lastUpdated!!.toEpochMilliseconds(), + ) + } + + return UserSessionsSettingsResponse( + entries = entries, + total = total, ) } + suspend fun deleteUserSessions(userId: String, request: DeleteUserSessionsRequest): DeleteUserSessionsResponse { + val deletedCount = + userSessionDaoService.deleteAuthenticatedSessionsForUser( + userId = userId, + sessionIds = request.sessionIds.toSet() + ) + + return DeleteUserSessionsResponse(deletedCount) + } + + suspend fun deleteAllUserSessions(userId: String): DeleteUserSessionsResponse { + val deletedCount = userSessionDaoService.deleteAllAuthenticatedSessionsForUser(userId) + return DeleteUserSessionsResponse(deletedCount) + } + fun isOnline(userId: String): Boolean { return onlineUserIds.contains(userId) } @@ -356,7 +480,7 @@ class UserService( } if (!isValidUsername(request.username)) { - errors += "Username must be alphanumeric (can also include _ or -)" + errors += "Username must contain only letters, numbers, _ or -" } if (!isEmailFormatValid(request.email)) { @@ -377,6 +501,15 @@ class UserService( return errors } + private suspend fun transferGuestData(guestUserId: String, newUserId: String) { + logger.debug { "transferring data from guest $guestUserId to new user $newUserId" } + playerVsPlayerGameDaoService.transferFromGuestToUser(guestUserId, newUserId) + playerVsBotGameDaoService.transferFromGuestToUser(guestUserId, newUserId) + puzzleResultDaoService.transferFromGuestToUser(guestUserId, newUserId) + referenceGameDaoService.transferFromGuestToUser(guestUserId, newUserId) + userDaoService.transferRatingsFromGuest(guestUserId, newUserId) + } + private fun hash(password: CharArray): ByteArray { val spec = PBEKeySpec(password, salt, 65536, 128) return secretKeyFactory.generateSecret(spec).encoded!! @@ -393,6 +526,7 @@ class UserService( companion object { const val PASSWORD_RECOVERY_TIMEOUT_HOURS = 1L + const val EMAIL_CONFIRMATION_CODE_EXPIRY_HOURS = 1L const val SALT_ALGO = "PBKDF2WithHmacSHA1" const val USERNAME_MIN_LENGTH = 4 @@ -416,10 +550,10 @@ class UserService( } /** - * Allowed characters: alphanumeric, underscore and dashes + * Allowed characters: latin letters (including combining marks), numbers, underscore and dashes */ private fun isValidUsername(chars: String): Boolean = - chars.matches("^[a-zA-Z0-9_-]*$".toRegex()) + chars.matches("^[\\p{IsLatin}\\p{M}\\p{N}_-]*$".toRegex()) private fun isEmailFormatValid(chars: String): Boolean = chars.matches(EMAIL_REGEX) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminFeedService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminFeedService.kt index c29043874..d0304ddc8 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminFeedService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/admin/AdminFeedService.kt @@ -101,7 +101,7 @@ class AdminFeedService( suspend fun listLastBotGames(): ListBotGamesResponse { val entries = pvbGameDaoService - .listLastGamesByIdentifiedUsers(80) + .listLatestGamesByIdentifiedUsers(80) .map { gameRecord -> mapBotGameToDto(gameRecord) } return ListBotGamesResponse(entries) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/EmailConfirmationLinkTagResolver.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/EmailConfirmationLinkTagResolver.kt new file mode 100644 index 000000000..7ce64f2f9 --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/EmailConfirmationLinkTagResolver.kt @@ -0,0 +1,19 @@ +package io.elephantchess.servicelayer.services.resolvers + +import io.elephantchess.htmlrenderer.TagResolver +import java.net.URLEncoder + +class EmailConfirmationLinkTagResolver( + private val webHost: String, + private val code: String, +) : TagResolver { + + override val tagName = "email_confirmation_link" + + override suspend fun resolveContent(): List { + val encodedCode = URLEncoder.encode(code, "UTF-8") + val url = "${webHost}/email/confirm?code=$encodedCode" + return listOf(makeAnchor(url)) + } + +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtils.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtils.kt index bed4ae2cf..384748205 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtils.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtils.kt @@ -1,5 +1,9 @@ package io.elephantchess.servicelayer.services.resolvers +import kotlinx.html.a +import kotlinx.html.stream.createHTML + +/** Builds an HTML anchor where both href and label are [url]. */ fun makeAnchor(url: String): String { - return "$url" + return createHTML().a(href = url) { +url } } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/PlayerVsPlayerWebSocketSession.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/PlayerVsPlayerWebSocketSession.kt index dcc88923f..0eae2a9ed 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/PlayerVsPlayerWebSocketSession.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/ws/PlayerVsPlayerWebSocketSession.kt @@ -2,6 +2,7 @@ package io.elephantchess.servicelayer.services.ws import io.elephantchess.model.GameEventType import io.elephantchess.servicelayer.dto.ws.PlayerVsPlayerUpdate +import io.elephantchess.servicelayer.model.UserId import kotlinx.coroutines.channels.ChannelResult import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -9,6 +10,7 @@ import kotlin.time.Instant class PlayerVsPlayerWebSocketSession( val gameId: String, + val userId: UserId, private var status: GameEventType, private var moveIndex: Int, private var chatIndex: Int, @@ -64,7 +66,11 @@ class PlayerVsPlayerWebSocketSession( mustUpdate = true } - // push update + if (update.typingUsers.isNotEmpty()) { + mustUpdate = true + } + + // push update if (mustUpdate) { val result = sendCb(update) if (result.isClosed) { diff --git a/webapp-service-layer/src/main/resources/mail_templates/email_confirmation.html b/webapp-service-layer/src/main/resources/mail_templates/email_confirmation.html new file mode 100644 index 000000000..bfbaa437e --- /dev/null +++ b/webapp-service-layer/src/main/resources/mail_templates/email_confirmation.html @@ -0,0 +1,11 @@ +{{email_css}} +Thanks for signing up and welcome to elephantchess.io! +

+Please confirm your email address by clicking the following link: {{email_confirmation_link}}. +The link is valid for 1 hour. +

+You don't have to do it, but it might help for example if you need to reset your password in the future. +

+{{update_mail_settings}} +

+If you did not sign up, please contact elephantchess: {{contact_link}} diff --git a/webapp-service-layer/src/main/resources/mail_templates/new_user_notification.html b/webapp-service-layer/src/main/resources/mail_templates/new_user_notification.html index 141792e01..7508eaf5e 100644 --- a/webapp-service-layer/src/main/resources/mail_templates/new_user_notification.html +++ b/webapp-service-layer/src/main/resources/mail_templates/new_user_notification.html @@ -1,3 +1,4 @@ {{email_css}} new user: {{username}}
-email: {{email}} +email: {{email}}
+guest transferred: {{guest_transferred}} diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserServiceTest.kt index 9b02424bd..bfbd0b8dc 100644 --- a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserServiceTest.kt +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserServiceTest.kt @@ -1,18 +1,64 @@ package io.elephantchess.servicelayer.services +import io.elephantchess.db.dao.codegen.Tables.* +import io.elephantchess.db.dao.codegen.tables.pojos.BotGame +import io.elephantchess.db.dao.codegen.tables.pojos.BotGameStatusEvent +import io.elephantchess.db.model.UserSessionRecord +import io.elephantchess.db.services.PlayerVsBotGameDaoService +import io.elephantchess.db.services.UserDaoService +import io.elephantchess.db.services.UserSessionDaoService +import io.elephantchess.db.utils.awaitExecute +import io.elephantchess.db.utils.awaitSingleValue +import io.elephantchess.db.utils.minusHours +import io.elephantchess.model.Engine +import io.elephantchess.model.GameEventType +import io.elephantchess.model.PuzzleAlgo +import io.elephantchess.model.PuzzleOutcome +import io.elephantchess.model.TimeControlMode +import io.elephantchess.model.UserType.GUEST import io.elephantchess.servicelayer.dto.ValidatedResponse +import io.elephantchess.servicelayer.dto.game.CreateGameRequest +import io.elephantchess.servicelayer.dto.user.DeleteUserSessionsRequest +import io.elephantchess.servicelayer.dto.user.EmailValidityStatus +import io.elephantchess.servicelayer.dto.user.ProfileSettingsDto import io.elephantchess.servicelayer.dto.user.SignUpRequest import io.elephantchess.servicelayer.dto.user.UserLoginRequest import io.elephantchess.servicelayer.exceptions.UnauthorizedException +import io.elephantchess.servicelayer.model.UserId import io.elephantchess.servicelayer.model.VerifiedToken +import io.elephantchess.xiangqi.Board.Companion.DEFAULT_START_FEN +import io.elephantchess.xiangqi.Color.RED import kotlinx.coroutines.test.runTest import org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric +import org.jooq.DSLContext import org.koin.core.component.inject import kotlin.test.* +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes class UserServiceTest : ServiceTest() { private val tokenManager by inject() + private val userDaoService by inject() + private val userSessionDaoService by inject() + private val pvpGameService by inject() + private val pvbGameDaoService by inject() + private val dslContext by inject() + + @AfterTest + fun afterEach() = runTest { + listOf( + GAME_MOVE, GAME_STATUS_EVENT, GAME, + BOT_GAME_MOVE, BOT_GAME_STATUS_EVENT, BOT_GAME, + REFERENCE_GAME_SEARCH_QUERY, + USER_SESSION, PUZZLE_RESULT, USER, PUZZLE + ) + .forEach { table -> + dslContext + .deleteFrom(table) + .awaitExecute() + } + } @Test fun hashTest01() = runTest { @@ -62,7 +108,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Email '$email' should be rejected") - assertTrue(result.left().errors.contains("Invalid email format"), "Should contain 'Invalid email format' error for '$email'") + assertTrue( + result.left().errors.contains("Invalid email format"), + "Should contain 'Invalid email format' error for '$email'" + ) } } @@ -83,7 +132,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Email '$email' should be rejected") - assertTrue(result.left().errors.contains("Invalid email format"), "Should contain 'Invalid email format' error for '$email'") + assertTrue( + result.left().errors.contains("Invalid email format"), + "Should contain 'Invalid email format' error for '$email'" + ) } } @@ -116,7 +168,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Username 'abc' should be rejected (too short)") - assertTrue(result.left().errors.contains("Username must be between 4 and 30 char."), "Should contain username length error") + assertTrue( + result.left().errors.contains("Username must be between 4 and 30 char."), + "Should contain username length error" + ) } @Test @@ -128,7 +183,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Username with 31 chars should be rejected (too long)") - assertTrue(result.left().errors.contains("Username must be between 4 and 30 char."), "Should contain username length error") + assertTrue( + result.left().errors.contains("Username must be between 4 and 30 char."), + "Should contain username length error" + ) } @Test @@ -138,7 +196,8 @@ class UserServiceTest : ServiceTest() { "user@name", // @ symbol "user.name", // dot "user!name", // exclamation mark - "用户名称", // non-ASCII characters + "用户名称", // Chinese characters + "user🙂name", // emoji ) for (username in invalidUsernames) { @@ -149,7 +208,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Username '$username' should be rejected") - assertTrue(result.left().errors.contains("Username must be alphanumeric (can also include _ or -)"), "Should contain invalid characters error for '$username'") + assertTrue( + result.left().errors.contains("Username must contain only letters, numbers, _ or -"), + "Should contain invalid characters error for '$username'" + ) } } @@ -161,6 +223,9 @@ class UserServiceTest : ServiceTest() { "user_name", // with underscore "user-name", // with dash "User_Name-123", // mixed case with underscore and dash + "nguyễn", // vietnamese letters + "trần", // vietnamese letters with diacritics + "Đặng_123", // vietnamese letters with underscore and digits "a".repeat(30), // maximum length ) @@ -184,7 +249,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Password 'abc' should be rejected (too short)") - assertTrue(result.left().errors.contains("Password must be between 4 and 50 char."), "Should contain password length error") + assertTrue( + result.left().errors.contains("Password must be between 4 and 50 char."), + "Should contain password length error" + ) } @Test @@ -196,7 +264,10 @@ class UserServiceTest : ServiceTest() { ) val result = userService.validateSignUp(request) assertIs>(result, "Password with 51 chars should be rejected (too long)") - assertTrue(result.left().errors.contains("Password must be between 4 and 50 char."), "Should contain password length error") + assertTrue( + result.left().errors.contains("Password must be between 4 and 50 char."), + "Should contain password length error" + ) } @Test @@ -218,4 +289,428 @@ class UserServiceTest : ServiceTest() { } } + @Test + fun `fetchEmailAddressSettings should reflect the email validity status`() = runTest { + val (request, userId) = signUpTestUser() + + // Before confirmation, no automated check has run, so status is UNKNOWN. + val before = userService.fetchEmailAddressSettings(userId) + assertEquals(request.email, before.email) + assertEquals(EmailValidityStatus.UNKNOWN, before.validityStatus) + + // After the user clicks the confirmation link, the email is MANUALLY_CONFIRMED. + val code = userDaoService.findById(userId)!!.emailConfirmationCode + assertTrue(userService.confirmEmail(code)) + + val after = userService.fetchEmailAddressSettings(userId) + assertEquals(EmailValidityStatus.MANUALLY_CONFIRMED, after.validityStatus) + } + + @Test + fun `updateProfileSettings should unset country when none is selected`() = runTest { + val (request, userId) = signUpTestUser() + + userService.updateProfileSettings(userId, ProfileSettingsDto(description = "", country = "be")) + userService.updateProfileSettings(userId, ProfileSettingsDto(description = "", country = "none")) + + val profile = userService.fetchProfile(request.username) + assertNull(profile.country) + + val storedCountry: String? = dslContext.select(USER.COUNTRY) + .from(USER) + .where(USER.ID.eq(userId)) + .awaitSingleValue() + assertNull(storedCountry) + } + + @Test + fun `signUp should generate an email confirmation code and confirmEmail should mark the email as confirmed`() = runTest { + val (_, userId) = signUpTestUser() + + // a confirmation code is generated at signup and the email is not yet confirmed + val userAfterSignup = userDaoService.findById(userId)!! + assertNotNull(userAfterSignup.emailConfirmationCode, "Confirmation code should be generated at signup") + assertNull(userAfterSignup.emailConfirmedAt, "Email should not be confirmed yet") + + // confirming with an unknown code does nothing + assertFalse(userService.confirmEmail("unknown-code")) + assertNull(userDaoService.findById(userId)!!.emailConfirmedAt) + + // confirming with a blank code does nothing + assertFalse(userService.confirmEmail("")) + assertNull(userDaoService.findById(userId)!!.emailConfirmedAt) + + // confirming with the right code marks the email as confirmed + assertTrue(userService.confirmEmail(userAfterSignup.emailConfirmationCode)) + val userAfterConfirmation = userDaoService.findById(userId)!! + assertNotNull(userAfterConfirmation.emailConfirmedAt, "Email should be confirmed") + + // confirming again is idempotent + val firstConfirmedAt = userAfterConfirmation.emailConfirmedAt + assertTrue(userService.confirmEmail(userAfterSignup.emailConfirmationCode)) + assertEquals(firstConfirmedAt, userDaoService.findById(userId)!!.emailConfirmedAt) + } + + @Test + fun `confirmEmail should reject an expired confirmation code`() = runTest { + val (_, userId) = signUpTestUser() + val userAfterSignup = userDaoService.findById(userId)!! + val code = userAfterSignup.emailConfirmationCode + + // simulate that the code was generated more than 1h ago + userDaoService.updateEmailConfirmationCode(userId, code, Clock.System.now().minusHours(2L)) + + assertFalse(userService.confirmEmail(code), "Expired code should be rejected") + assertNull(userDaoService.findById(userId)!!.emailConfirmedAt) + + // after a resend, a new code is generated with a fresh timestamp and confirmation works again + userService.resendEmailConfirmation(userId) + val refreshed = userDaoService.findById(userId)!! + assertNotEquals(code, refreshed.emailConfirmationCode, "Resend should generate a new code") + assertTrue(userService.confirmEmail(refreshed.emailConfirmationCode)) + assertNotNull(userDaoService.findById(userId)!!.emailConfirmedAt) + } + + @Test + fun `resendEmailConfirmation should be a no-op for already-confirmed users`() = runTest { + val (_, userId) = signUpTestUser() + val originalCode = userDaoService.findById(userId)!!.emailConfirmationCode + assertTrue(userService.confirmEmail(originalCode)) + + userService.resendEmailConfirmation(userId) + + // code is unchanged since the user is already confirmed + assertEquals(originalCode, userDaoService.findById(userId)!!.emailConfirmationCode) + } + + @Test + fun `fetchUserSessions should return latest sessions with total count`() = runTest { + val userId = signUpTestUser().second + + repeat(6) { i -> + userSessionDaoService.createOrUpdate( + UserSessionRecord( + userId = userId, + remoteAddress = "1.2.3.${i + 1}", + userAgent = "agent-$i", + operatingSystemName = "os-$i", + agentName = "browser-$i", + ) + ) + } + + val result = userService.fetchUserSessions(userId, limit = 5) + + assertEquals(6, result.total) + assertEquals(5, result.entries.size) + } + + @Test + fun `deleteUserSessions should only delete selected sessions`() = runTest { + val userId = signUpTestUser().second + + repeat(3) { i -> + userSessionDaoService.createOrUpdate( + UserSessionRecord( + userId = userId, + remoteAddress = "2.3.4.${i + 1}", + userAgent = "del-agent-$i", + operatingSystemName = "del-os-$i", + agentName = "del-browser-$i", + ) + ) + } + + val sessionsBeforeDelete = userService.fetchUserSessions(userId, limit = 10) + val selectedSessionIds = sessionsBeforeDelete.entries.take(2).map { it.id } + + val deleteResult = userService.deleteUserSessions(userId, DeleteUserSessionsRequest(selectedSessionIds)) + + assertEquals(2, deleteResult.deletedCount) + assertEquals(1, userService.fetchUserSessions(userId, limit = 10).entries.size) + } + + @Test + fun `signUp with guestUserId should transfer PvP games to new user`() = runTest { + val guestId = UserId(GUEST, userService.obtainGuestUserToken().id) + userService.refreshIsOnlineCache() + + val createGameRequest = CreateGameRequest( + inviterColor = RED, + isRated = false, + timeControlBase = 30.minutes.inWholeSeconds.toInt(), + timeControlIncrement = null, + timeControlMode = TimeControlMode.GAME_TIME, + allowGuests = true, + alwaysVisibleInLobby = false, + privateInvite = false + ) + val gameResponse = pvpGameService.createGame(guestId, createGameRequest) + val gameId = gameResponse.gameId + + // Verify the game is initially owned by the guest + val inviterBefore = dslContext.select(GAME.INVITER) + .from(GAME) + .where(GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(guestId.id, inviterBefore) + + // Sign up and pass the verified guest ID (simulating routing-level token extraction) + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "xfer$i", + email = "xfer$i@example.com", + password = "password", + transferGuestData = true + ) + val newUserId = userService.signUp(request, guestUserId = guestId.id).right().userId + + // The PvP game should now be owned by the new authenticated user + val inviterAfter = dslContext.select(GAME.INVITER) + .from(GAME) + .where(GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(newUserId, inviterAfter) + + // The original guest user ID should be recorded on the game row + val guestUserIdAfter = dslContext.select(GAME.GUEST_USER_ID) + .from(GAME) + .where(GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(guestId.id, guestUserIdAfter) + } + + @Test + fun `signUp with guestUserId should transfer PvB games to new user`() = runTest { + val guestId = UserId(GUEST, userService.obtainGuestUserToken().id) + + // Create a PvB game owned by the guest directly via the DAO (avoids running the engine) + val gameId = randomAlphanumeric(12) + val now = Clock.System.now() + + val gameRecord = BotGame().apply { + id = gameId + userId = guestId.id + userColor = RED + engine = Engine.FAIRYSTOCKFISH + engineVersion = "11.2" + depth = 4 + startFen = null + gameStatus = GameEventType.CREATED + currentFen = DEFAULT_START_FEN + currentHalfMoveIndex = 0 + created = now + lastUpdated = now + } + val statusRecord = BotGameStatusEvent().apply { + botGameId = gameId + eventType = GameEventType.CREATED + eventTime = now + } + pvbGameDaoService.insertGame(gameRecord, statusRecord) + + // Verify the bot game is initially owned by the guest + val userIdBefore = dslContext.select(BOT_GAME.USER_ID) + .from(BOT_GAME) + .where(BOT_GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(guestId.id, userIdBefore) + + // Sign up and pass the verified guest ID + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "pvbxfer$i", + email = "pvbxfer$i@example.com", + password = "password", + transferGuestData = true + ) + val newUserId = userService.signUp(request, guestUserId = guestId.id).right().userId + + // The PvB game should now be owned by the new authenticated user + val userIdAfter = dslContext.select(BOT_GAME.USER_ID) + .from(BOT_GAME) + .where(BOT_GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(newUserId, userIdAfter) + + // The original guest user ID should be recorded on the bot game row + val guestUserIdAfter = dslContext.select(BOT_GAME.GUEST_USER_ID) + .from(BOT_GAME) + .where(BOT_GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(guestId.id, guestUserIdAfter) + } + + @Test + fun `signUp without guestUserId should not transfer any data`() = runTest { + val guestId = UserId(GUEST, userService.obtainGuestUserToken().id) + userService.refreshIsOnlineCache() + + val createGameRequest = CreateGameRequest( + inviterColor = RED, + isRated = false, + timeControlBase = 30.minutes.inWholeSeconds.toInt(), + timeControlIncrement = null, + timeControlMode = TimeControlMode.GAME_TIME, + allowGuests = true, + alwaysVisibleInLobby = false, + privateInvite = false + ) + val gameResponse = pvpGameService.createGame(guestId, createGameRequest) + val gameId = gameResponse.gameId + + // Sign up without passing a guest user ID (no transfer) + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "noxfer$i", + email = "noxfer$i@example.com", + password = "password" + ) + userService.signUp(request) + + // The game should still belong to the guest + val inviterAfter = dslContext.select(GAME.INVITER) + .from(GAME) + .where(GAME.ID.eq(gameId)) + .awaitSingleValue() + assertEquals(guestId.id, inviterAfter) + + val guestUserId = dslContext.select(GAME.GUEST_USER_ID) + .from(GAME) + .where(GAME.ID.eq(gameId)) + .awaitSingleValue() + assertNull(guestUserId, "Guest user ID should be null since no transfer occurred") + } + + @Test + fun `signUp with guestUserId should transfer puzzle rating to new user`() = runTest { + val guestTokenResponse = userService.obtainGuestUserToken() + val guestId = UserId(GUEST, guestTokenResponse.id) + + // Verify the guest starts with the default puzzle rating + val guestRatingBefore = dslContext.select(USER.PUZZLE_RATING) + .from(USER) + .where(USER.ID.eq(guestId.id)) + .awaitSingleValue() + assertEquals(800, guestRatingBefore) + + // Simulate the guest improving their puzzle rating + dslContext.update(USER) + .set(USER.PUZZLE_RATING, 950) + .where(USER.ID.eq(guestId.id)) + .awaitExecute() + + // Sign up and transfer guest data + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "elouser$i", + email = "elouser$i@example.com", + password = "password", + transferGuestData = true + ) + val newUserId = userService.signUp(request, guestUserId = guestId.id).right().userId + + // The new user should have the guest's puzzle rating + val newUserRating = dslContext.select(USER.PUZZLE_RATING) + .from(USER) + .where(USER.ID.eq(newUserId)) + .awaitSingleValue() + assertEquals(950, newUserRating) + } + + @Test + fun `signUp with guestUserId should transfer puzzle results to new user`() = runTest { + val guestId = UserId(GUEST, userService.obtainGuestUserToken().id) + + // Insert a minimal puzzle row (required by the puzzle_result FK) + val puzzleId = "testpzl1" + dslContext.insertInto(PUZZLE) + .set(PUZZLE.ID, puzzleId) + .set(PUZZLE.REF_GAME_SOURCE, "test") + .set(PUZZLE.REF_GAME_SOURCE_ID, "testSource1") + .set(PUZZLE.ALGORITHM, PuzzleAlgo.FIND_PATH_TO_MATE) + .set(PUZZLE.PLAYER_COLOR, RED) + .set(PUZZLE.START_FEN, DEFAULT_START_FEN) + .set(PUZZLE.INITIAL_RATING, 1000) + .set(PUZZLE.RATING, 1000) + .awaitExecute() + + // Insert a puzzle result for the guest + dslContext.insertInto(PUZZLE_RESULT) + .set(PUZZLE_RESULT.PUZZLE_ID, puzzleId) + .set(PUZZLE_RESULT.USER_ID, guestId.id) + .set(PUZZLE_RESULT.OUTCOME, PuzzleOutcome.SOLVED) + .set(PUZZLE_RESULT.PUZZLE_RATING_FROM, 1000) + .set(PUZZLE_RESULT.PUZZLE_RATING_TO, 1005) + .set(PUZZLE_RESULT.PLAYER_RATING_FROM, 800) + .set(PUZZLE_RESULT.PLAYER_RATING_TO, 810) + .awaitExecute() + + // Sign up and transfer guest data + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "pzlxfer$i", + email = "pzlxfer$i@example.com", + password = "password", + transferGuestData = true + ) + val newUserId = userService.signUp(request, guestUserId = guestId.id).right().userId + + // The puzzle result should now belong to the new user + val userIdAfter = dslContext.select(PUZZLE_RESULT.USER_ID) + .from(PUZZLE_RESULT) + .where(PUZZLE_RESULT.PUZZLE_ID.eq(puzzleId)) + .awaitSingleValue() + assertEquals(newUserId, userIdAfter) + + // The original guest user ID should be recorded on the puzzle result row + val guestUserIdAfter = dslContext.select(PUZZLE_RESULT.GUEST_USER_ID) + .from(PUZZLE_RESULT) + .where(PUZZLE_RESULT.PUZZLE_ID.eq(puzzleId)) + .awaitSingleValue() + assertEquals(guestId.id, guestUserIdAfter) + } + + @Test + fun `signUp with guestUserId should transfer reference game searches to new user`() = runTest { + val guestId = UserId(GUEST, userService.obtainGuestUserToken().id) + + // Insert a reference game search query owned by the guest + val queryId = randomAlphanumeric(12) + val now = Clock.System.now() + dslContext.insertInto(REFERENCE_GAME_SEARCH_QUERY) + .set(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID, queryId) + .set(REFERENCE_GAME_SEARCH_QUERY.USER_ID, guestId.id) + .set(REFERENCE_GAME_SEARCH_QUERY.QUERY_TIME, now) + .set(REFERENCE_GAME_SEARCH_QUERY.UPDATE_TIME, now) + .set(REFERENCE_GAME_SEARCH_QUERY.PLAYER_NAME, "Hu Ronghua") + .set(REFERENCE_GAME_SEARCH_QUERY.LIMIT, 20) + .set(REFERENCE_GAME_SEARCH_QUERY.NUMBER_OF_RESULTS, 5) + .awaitExecute() + + // Sign up and transfer guest data + val i = randomAlphanumeric(8) + val request = SignUpRequest( + username = "srchxfer$i", + email = "srchxfer$i@example.com", + password = "password", + transferGuestData = true + ) + val newUserId = userService.signUp(request, guestUserId = guestId.id).right().userId + + // The search query should now belong to the new user + val userIdAfter = dslContext.select(REFERENCE_GAME_SEARCH_QUERY.USER_ID) + .from(REFERENCE_GAME_SEARCH_QUERY) + .where(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID.eq(queryId)) + .awaitSingleValue() + assertEquals(newUserId, userIdAfter) + + // The original guest user ID should be recorded on the search query row + val guestUserIdAfter = dslContext.select(REFERENCE_GAME_SEARCH_QUERY.GUEST_USER_ID) + .from(REFERENCE_GAME_SEARCH_QUERY) + .where(REFERENCE_GAME_SEARCH_QUERY.QUERY_ID.eq(queryId)) + .awaitSingleValue() + assertEquals(guestId.id, guestUserIdAfter) + } + } diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtilsTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtilsTest.kt new file mode 100644 index 000000000..cf93d5f07 --- /dev/null +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/resolvers/ResolverUtilsTest.kt @@ -0,0 +1,17 @@ +package io.elephantchess.servicelayer.services.resolvers + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ResolverUtilsTest { + + @Test + fun `makeAnchor renders anchor using html builder`() { + val url = "https://elephantchess.io/game?id=42" + val anchor = makeAnchor(url) + assertEquals("""$url""", anchor) + assertFalse(anchor.endsWith("\n")) + } + +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/WebAppKoinModule.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/WebAppKoinModule.kt index 2564f9d81..0ea166e0a 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/WebAppKoinModule.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/WebAppKoinModule.kt @@ -77,6 +77,8 @@ private fun pageRendererModule(eagerAllowed: Boolean) = module { singleAuto(eager = eagerAllowed) singleAuto(eager = eagerAllowed) singleAuto(eager = eagerAllowed) + singleAuto(eager = eagerAllowed) + singleAuto(eager = eagerAllowed) } private fun listFragmentTags(): List { diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/ops/ValidationRoutingOps.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/ops/ValidationRoutingOps.kt index 095c306a9..d10a76481 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/ops/ValidationRoutingOps.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/ops/ValidationRoutingOps.kt @@ -8,6 +8,7 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +// FIXME: what's the point of this beside naming? suspend inline fun RoutingContext.handleValidatedResponse( handler: (T) -> ValidatedResponse ) { @@ -25,3 +26,11 @@ suspend inline fun RoutingCo call.respond(NotAcceptable, either.left()) } } + +suspend inline fun RoutingContext.handleEither(either: GenericEither) { + if (either.isRight()) { + call.respond(Created, either.right()) + } else { + call.respond(NotAcceptable, either.left()) + } +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/BoardGuiExampleRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/BoardGuiExampleRenderer.kt index 94a4391b2..dee0874ba 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/BoardGuiExampleRenderer.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/BoardGuiExampleRenderer.kt @@ -37,11 +37,6 @@ class BoardGuiExampleRenderer(private val simplePageRenderer: SimplePageRenderer .let(::escapeHtml) } - private fun escapeHtml(s: String): String = - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - private fun boardGuiAssetsTagResolver(useCdn: Boolean): TagResolver = CallbackTagResolver("board_gui_assets") { var distPath = "/dist/$DIST_VERSION" @@ -57,7 +52,7 @@ class BoardGuiExampleRenderer(private val simplePageRenderer: SimplePageRenderer companion object { - const val DIST_VERSION = "0.0.2" + const val DIST_VERSION = "0.0.4" } } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/ChangelogPageRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/ChangelogPageRenderer.kt new file mode 100644 index 000000000..ccfad06b8 --- /dev/null +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/ChangelogPageRenderer.kt @@ -0,0 +1,58 @@ +package io.elephantchess.webapp.rendering + +import io.elephantchess.htmlrenderer.TagResolver +import io.elephantchess.utils.ResourceUtils.resourceAsString + +class ChangelogPageRenderer(private val simplePageRenderer: SimplePageRenderer) { + + suspend fun renderChangelogPage(): String = + simplePageRenderer.renderTemplate(CHANGELOG_TEMPLATE_NAME, listOf(changelogTocTagResolver())) + + private fun changelogTocTagResolver(): TagResolver = + CallbackTagResolver("changelog_toc") { + val html = resourceAsString(CHANGELOG_TEMPLATE_PATH) + ?: throw IllegalStateException("Changelog template not found at $CHANGELOG_TEMPLATE_PATH") + + renderChangelogToc(html) + } + + companion object { + + const val CHANGELOG_TEMPLATE_NAME = "about/changelog" + const val CHANGELOG_TEMPLATE_PATH = "/templates/$CHANGELOG_TEMPLATE_NAME.html" + + // Match `

...

`, capturing the year-month id. + val H1_MONTH_WITH_ID_REGEX = + Regex("""]*\bid\s*=\s*"(\d{4})-(\d{2})"[^>]*>[\s\S]*?""", RegexOption.IGNORE_CASE) + + /** + * Parses the changelog HTML and returns the table of content as an HTML fragment, grouped + * by quarter. Each TOC entry is a quarter label (e.g. "Q2 2026") that links to the first + * month section belonging to that quarter in document order (i.e. the most recent month + * of the quarter, since the changelog is reverse-chronological). + */ + fun renderChangelogToc(html: String): String { + val quarters = linkedMapOf, String>() // (year, quarter) -> first month id + H1_MONTH_WITH_ID_REGEX.findAll(html).forEach { match -> + val year = match.groupValues[1].toInt() + val month = match.groupValues[2].toInt() + if (month in 1..12) { + val quarter = (month - 1) / 3 + 1 + val key = year to quarter + val id = "${match.groupValues[1]}-${match.groupValues[2]}" + quarters.putIfAbsent(key, id) + } + } + + if (quarters.isEmpty()) return "" + val items = quarters.entries.joinToString("\n") { (key, id) -> + val (year, quarter) = key + val label = "Q$quarter $year" + """
  • ${escapeHtml(label)}
  • """ + } + return """""" + } + + } + +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabasePageRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabasePageRenderer.kt index 8e1ea10e0..4cf970d16 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabasePageRenderer.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabasePageRenderer.kt @@ -1,12 +1,22 @@ package io.elephantchess.webapp.rendering import io.elephantchess.htmlrenderer.HtmlRenderer +import io.elephantchess.htmlrenderer.KtorHtmlBuilderTagResolver import io.elephantchess.htmlrenderer.SimpleValueTagResolver import io.elephantchess.htmlrenderer.TagResolver +import io.elephantchess.model.AnalysisStatus +import io.elephantchess.model.Outcome import io.elephantchess.servicelayer.dto.database.* import io.elephantchess.utils.cropToFirstNWords import io.elephantchess.utils.formatWithChineseName import io.github.reactivecircus.cache4k.Cache +import io.ktor.http.* +import kotlinx.html.a +import kotlinx.html.li +import kotlinx.html.meta +import kotlinx.html.p +import kotlinx.html.style +import kotlinx.html.unsafe import kotlin.time.Duration.Companion.hours class DatabasePageRenderer(private val htmlRenderer: HtmlRenderer) { @@ -30,36 +40,43 @@ class DatabasePageRenderer(private val htmlRenderer: HtmlRenderer) { fetchEditorsUsername: suspend () -> List, ): String { val description = edit.profileText + val editors = fetchEditorsUsername() val playerNameEncodedResolver = SimpleValueTagResolver("player_name_encoded", databasePlayer.urlName) val playerIdResolver = SimpleValueTagResolver("player_id", databasePlayer.id) - val descriptionResolver = CallbackTagResolver("player_profile_description") { - description?.let { formatNewLinesToHtmlParagraphs(it) } + val descriptionResolver = KtorHtmlBuilderTagResolver("player_profile_description") { + description?.toParagraphs()?.forEach { p { unsafe { +it } } } } - val sourcesResolver = CallbackTagResolver("player_profile_sources") { - edit.sources - .sortedBy { it.index } - .joinToString("") { source -> - """
  • ${source.title}
  • """ + val sourcesResolver = KtorHtmlBuilderTagResolver("player_profile_sources") { + edit.sources.sortedBy { it.index }.forEach { source -> + li { + a(href = source.url, target = "_blank") { + rel = "noopener noreferrer" + +source.title + } } + } } - val styleResolver = CallbackTagResolver("player_profile_description_style") { + val styleResolver = KtorHtmlBuilderTagResolver("player_profile_description_style") { if (description == null) { - "" - } else { - "" + style { + unsafe { + +"#player-profile-description { display: none; }" + } + } } } - val authorMeta = CallbackTagResolver("author_meta") { - val editors = fetchEditorsUsername() + val authorMeta = KtorHtmlBuilderTagResolver("author_meta") { if (editors.isNotEmpty()) { - meta("author", editors.sorted().joinToString(", ")) - } else { - "" + val names = editors.sortedBy { it.lowercase() }.joinToString(", ") + meta { + name = "author" + content = names + } } } @@ -255,8 +272,137 @@ class DatabasePageRenderer(private val htmlRenderer: HtmlRenderer) { } } + suspend fun renderGamePage(summary: DatabaseGameSummary, orientation: String? = null): String { + fun formatName(canonicalName: String?, chineseName: String?): String { + return if (!canonicalName.isNullOrBlank()) { + formatWithChineseName(canonicalName, chineseName) + } else { + "" + } + } + + val redDisplay = formatName(summary.redPlayerCanonicalName, summary.redPlayerChineseName) + val blackDisplay = formatName(summary.blackPlayerCanonicalName, summary.blackPlayerChineseName) + + val title = "$redDisplay vs. $blackDisplay" + + val outcomeText = when (summary.outcome) { + Outcome.RED_WINS -> "$redDisplay won (red)" + Outcome.BLACK_WINS -> "$blackDisplay won (black)" + Outcome.DRAW -> "draw" + null -> null + } + + val description = buildString { + append("Chinese chess (Xiangqi) game between $redDisplay and $blackDisplay") + if (!summary.eventName.isNullOrBlank()) append(" at ${summary.eventName}") + summary.date?.let { append(", played on $it") } + append(".") + outcomeText?.let { append(" Outcome: $it.") } + when (summary.analysisStatus) { + AnalysisStatus.COMPLETED -> + append(" Full engine analysis available.") + + AnalysisStatus.PARTIALLY_COMPLETED -> + append(" Partial engine analysis available.") + + else -> { + // not analyzed yet: don't mention it + } + } + } + + fun playerLink(canonicalName: String?, displayName: String, colorClass: String): String { + val escapedName = escapeHtml(displayName) + return if (canonicalName.isNullOrBlank()) { + """$escapedName""" + } else { + val urlName = canonicalName.replace(" ", "_").encodeURLPath() + """$escapedName""" + } + } + + val winnerStar = + """winner""" + val redWinnerSuffix = if (summary.outcome == Outcome.RED_WINS) " $winnerStar" else "" + val blackWinnerSuffix = if (summary.outcome == Outcome.BLACK_WINS) " $winnerStar" else "" + + val playersInfoHtml = buildString { + append( + """
    ${ + playerLink( + summary.redPlayerCanonicalName, + redDisplay, + "red-color" + ) + }$redWinnerSuffix
    """ + ) + append( + """
    ${ + playerLink( + summary.blackPlayerCanonicalName, + blackDisplay, + "black-color" + ) + }$blackWinnerSuffix
    """ + ) + } + + val dateInfoHtml = summary.date?.toString() ?: "" + + val eventInfoHtml = summary.eventName?.let { name -> + val escapedName = escapeHtml(name) + val eventId = summary.eventId + if (!eventId.isNullOrBlank()) { + """$escapedName""" + } else { + escapedName + } + } ?: "" + + // PGN-friendly fields (also surfaced as body data-* attributes so the JS + // does not need to fetch metadata to render the board or build the PGN download). + // Each resolver below renders the full `data-x="..."` attribute when the value + // is present, or an empty string when it isn't, so we don't pollute the body + // tag with empty attributes like `data-pgn-date=""`. + val pgnResult = when (summary.outcome) { + Outcome.RED_WINS -> "1-0" + Outcome.BLACK_WINS -> "0-1" + Outcome.DRAW -> "1/2-1/2" + null -> null + } + val pgnDate = summary.date?.let { d -> + "%04d.%02d.%02d".format(d.year, d.monthValue, d.dayOfMonth) + } + + return htmlRenderer.renderHtml( + templatePath = "/templates/database/database_game_viewer.html", + specificTagResolvers = listOf( + SimpleValueTagResolver("page_title", title), + SimpleValueTagResolver("description_meta", descriptionMeta(description)), + SimpleValueTagResolver("players_info", playersInfoHtml), + SimpleValueTagResolver("game_date_info", dateInfoHtml), + SimpleValueTagResolver("game_event_info", eventInfoHtml), + SimpleValueTagResolver("body_data_attrs", buildString { + append(dataAttr("game-id", summary.gameId)) + append(dataAttr("orientation", orientation)) + append(dataAttr("final-fen", summary.finalFen)) + append(dataAttr("pgn-red-player", summary.redPlayerCanonicalName)) + append(dataAttr("pgn-black-player", summary.blackPlayerCanonicalName)) + append(dataAttr("pgn-event", summary.eventName)) + append(dataAttr("pgn-date", pgnDate)) + append(dataAttr("pgn-result", pgnResult)) + }), + ), + canonicalPath = "/database/game?id=${summary.gameId}" + ) + } + private companion object { + fun dataAttr(name: String, value: String?): String = + if (value.isNullOrBlank()) "" else """ data-$name="${escapeHtmlAttr(value)}"""" + fun descriptionMeta(edit: DatabasePlayerProfileEdit): TagResolver { return CallbackTagResolver("description_meta") { val profileText = edit.profileText diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabaseTagResolvers.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabaseTagResolvers.kt index 46f3ad06b..6f2ab9329 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabaseTagResolvers.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/DatabaseTagResolvers.kt @@ -23,7 +23,7 @@ fun eventsTableTagResolver( } buildString { - append("""""") + append("""
    """) append("") append("") append("") @@ -48,7 +48,7 @@ fun playersTableTagResolver( playerStandings: PlayersListResponse ) = CallbackTagResolver("players_table") { buildString { - append("""
    EventDate
    """) + append("""
    """) append("") append("") append("") @@ -117,7 +117,7 @@ fun eventGamesListTagResolver( ) val lines = mutableListOf() - lines += """
    PlayerW
    """ + lines += """
    """ lines += "" for (game in sortedGames) { lines += """() lines += """

    Standings

    """ - lines += """
    redoutcomeblack
    """ + lines += """
    """ lines += "" for ((index, entry) in sortedScores.withIndex()) { val (triple, stats) = entry diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/FaqPageRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/FaqPageRenderer.kt new file mode 100644 index 000000000..825186693 --- /dev/null +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/FaqPageRenderer.kt @@ -0,0 +1,55 @@ +package io.elephantchess.webapp.rendering + +import io.elephantchess.htmlrenderer.TagResolver +import io.elephantchess.utils.ResourceUtils.resourceAsString + +class FaqPageRenderer(private val simplePageRenderer: SimplePageRenderer) { + + suspend fun renderFaqPage(): String = + simplePageRenderer.renderTemplate(FAQ_TEMPLATE_NAME, listOf(faqTocTagResolver())) + + private fun faqTocTagResolver(): TagResolver = + CallbackTagResolver("faq_toc") { + val html = resourceAsString(FAQ_TEMPLATE_PATH) + ?: throw IllegalStateException("FAQ template not found at $FAQ_TEMPLATE_PATH") + + renderFaqToc(html) + } + + private companion object { + + const val FAQ_TEMPLATE_NAME = "about/faq" + const val FAQ_TEMPLATE_PATH = "/templates/$FAQ_TEMPLATE_NAME.html" + + // Match `

    inner

    ` across lines, capturing id and inner content. + val H1_WITH_ID_REGEX = Regex("""]*\bid\s*=\s*"([^"]+)"[^>]*>([\s\S]*?)""", RegexOption.IGNORE_CASE) + val INNER_TAG_REGEX = Regex("<[^>]+>") + val WHITESPACE_REGEX = Regex("\\s+") + + /** + * Parses the FAQ HTML and returns the table of content as an HTML fragment. + */ + fun renderFaqToc(html: String): String { + val entries = H1_WITH_ID_REGEX.findAll(html) + .map { match -> + val id = match.groupValues[1].trim() + val title = match.groupValues[2] + .replace(INNER_TAG_REGEX, "") + .replace(WHITESPACE_REGEX, " ") + .trim() + + id to title + } + .filter { (id, title) -> id.isNotEmpty() && title.isNotEmpty() } + .toList() + + if (entries.isEmpty()) return "" + val items = entries.joinToString("\n") { (id, title) -> + """
  • ${escapeHtml(title)}
  • """ + } + return """""" + } + + } + +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/TagResolvers.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/TagResolvers.kt index 4226e69be..69c07bdd9 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/TagResolvers.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/TagResolvers.kt @@ -8,27 +8,43 @@ import java.time.LocalDate import java.time.ZoneOffset.UTC import java.time.format.DateTimeFormatter.ofPattern import java.util.Locale +import kotlinx.html.meta +import kotlinx.html.stream.createHTML import kotlin.math.floor private const val COST_IN_EUR_PER_DAY = 4 private val supportedCurrencies = setOf("USD", "EUR", "GBP") -fun formatNewLinesToHtmlParagraphs(str: String): String { - return str +/** + * HTML-escapes a string so it can be safely inlined as text content. + */ +fun escapeHtml(s: String): String = + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + +/** + * HTML-escapes a string so it can be safely used as an attribute value. + */ +fun escapeHtmlAttr(s: String): String = + escapeHtml(s).replace("\"", """) + +fun String.toParagraphs(): List { + return this .split("\n\n") - .filter { it.trim().isNotEmpty() } - .joinToString("") { "

    ${it.trim()}

    " } + .map { it.trim() } + .filter { it.isNotEmpty() } } -fun meta(name: String, content: String) = - """""" - fun descriptionMeta(description: String): String { - return meta( - "description", description - .replace("\n", " ") - .replace("\r", "") - ) + return createHTML().meta { + name = "description" + content = stripHtml( + description + .replace("\n", " ") + .replace("\r", "") + ) + } } fun latestSupporterTagResolver(latestSupporter: LatestSupporter?): TagResolver { diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt index 298332b7d..cb03a0302 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt @@ -1,10 +1,16 @@ package io.elephantchess.webapp.rendering import io.elephantchess.htmlrenderer.HtmlRenderer +import io.elephantchess.htmlrenderer.KtorHtmlBuilderTagResolver import io.elephantchess.htmlrenderer.SimpleValueTagResolver import io.elephantchess.htmlrenderer.TagResolver import io.elephantchess.servicelayer.dto.user.UserProfile import io.elephantchess.utils.cropToFirstNWords +import io.ktor.http.encodeURLPath +import kotlinx.html.div +import kotlinx.html.id +import kotlinx.html.img +import kotlinx.html.p class UserProfilePageRenderer(private val htmlRenderer: HtmlRenderer) { @@ -15,6 +21,7 @@ class UserProfilePageRenderer(private val htmlRenderer: HtmlRenderer) { return htmlRenderer.renderHtml( templatePath = "/templates/user_profile.html", + canonicalPath = "/@/${username.encodeURLPath()}", specificTagResolvers = listOf( noIndexMeta(description), SimpleValueTagResolver("user_id", userProfile.userId), @@ -48,33 +55,40 @@ class UserProfilePageRenderer(private val htmlRenderer: HtmlRenderer) { } private fun flagPanelTagResolver(countryCode: String?): TagResolver { - return CallbackTagResolver("flag_header_panel") { - if (countryCode != null) { - """
    - |$countryCode - |
    """.trimMargin() - } else { - "" + return KtorHtmlBuilderTagResolver("flag_header_panel") { + if (!countryCode.isNullOrBlank() && !countryCode.equals("none", ignoreCase = true)) { + div("profile-header-panel") { + id = "flag-header-panel" + attributes["data-country-code"] = countryCode + img(alt = countryCode, src = "/images/flags/$countryCode.svg", classes = "flag-icons") { + id = "profile-flag" + } + } } } } private fun descriptionDivTagResolver(username: String, description: String?): TagResolver { - return CallbackTagResolver("user_profile_description") { - if (description != null) { - buildString { - append("""
    """) - append(formatNewLinesToHtmlParagraphs(description)) - append("""
    """) + return KtorHtmlBuilderTagResolver("user_profile_description") { + if (!description.isNullOrBlank()) { + div { + id = "profile-description" + description.toParagraphs().forEach { p { +it } } } } else { - buildString { - append("""
    """) - append("$username has not filled their description yet.") - append("""
    """) + div("empty-block-placeholder") { + id = "profile-description" + +"$username has not filled their description yet." } } } } + suspend fun renderUserBrowsePvpGames(username: String): String { + return htmlRenderer.renderHtml( + "/templates/user_browse_pvp_games.html", + specificTagResolvers = listOf(SimpleValueTagResolver("username", username)) + ) + } + } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailNotificationRoutes.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailSettingRoutes.kt similarity index 69% rename from webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailNotificationRoutes.kt rename to webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailSettingRoutes.kt index 588edae05..fcb6cb5be 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailNotificationRoutes.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/EmailSettingRoutes.kt @@ -3,6 +3,7 @@ package io.elephantchess.webapp.routing import io.elephantchess.htmlrenderer.SimpleValueTagResolver import io.elephantchess.servicelayer.exceptions.BadRequestException import io.elephantchess.servicelayer.services.MailService +import io.elephantchess.servicelayer.services.UserService import io.elephantchess.servicelayer.utils.obfuscateEmail import io.elephantchess.servicelayer.utils.ops.koin import io.elephantchess.webapp.rendering.SimplePageRenderer @@ -12,11 +13,33 @@ import io.ktor.server.response.* import io.ktor.server.routing.* private val simplePageRenderer by koin() -private val mailService by koin() + +fun Application.emailSettingUpdatePages() { + emailConfirmationRoutes() + newsletterUnsubscriptionRoutes() +} + +// renders a public page when the user clicks the confirmation link sent to them by email at signup. +private fun Application.emailConfirmationRoutes() { + val userService by koin() + routing { + get("/email/confirm") { + val code = call.parameters["code"].orEmpty() + val confirmed = userService.confirmEmail(code) + val template = if (confirmed) { + "email_confirmation/email_confirmed" + } else { + "email_confirmation/email_confirmation_error" + } + call.respondText(simplePageRenderer.renderTemplateNoCache(template), ContentType.Text.Html) + } + } +} + // when sending the newsletter, there is a link to unsubscribe, which contains a unique code that identifies // the user and the type of unsubscription (newsletter only or all notifications). -fun Application.newsletterUnsubscriptionRoutes() { +private fun Application.newsletterUnsubscriptionRoutes() { routing { get("/unsubscribe") { val code = call.parameters["code"] ?: throw BadRequestException("code not provided") @@ -26,6 +49,8 @@ fun Application.newsletterUnsubscriptionRoutes() { } private suspend fun processAndRenderUnsubscription(code: String): String { + val mailService by koin() + return when (val matching = mailService.unsubscribeFromEmailNotifications(code)) { null -> simplePageRenderer.renderTemplateNoCache("unsubscribe/unsubscription_error") else -> { diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/DatabaseRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/DatabaseRouting.kt index 903859142..8cff108f7 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/DatabaseRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/DatabaseRouting.kt @@ -77,6 +77,12 @@ fun Route.databaseRoutes() { } } } + get("/list-user-searches") { + val continuation = call.request.queryParameters["continuation"]?.toLongOrNull() + requireIdentification { verifiedToken -> + databaseService.listUserSearches(verifiedToken.userId, continuation) + } + } get("/search") { requireIdentification { verifiedToken -> val dateStart = call.request.queryParameters["dateStart"] diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/GameDataRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/GameDataRouting.kt index 9f74b80b0..f5b22dc76 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/GameDataRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/GameDataRouting.kt @@ -45,12 +45,29 @@ fun Route.gameDataRoutes() { ) } } + get("/list-latest-pvp-games-by-user") { + // distinctByUsers is ignored here: filtering by username already scopes results to one user + paginationParams { limit, continuation, _ -> + val username = call.parameters["username"] + ?: throw BadRequestException("username parameter is required") + + gameDataService.listLastPvpGamesByUsername( + username = username, + requestedLimit = limit, + beforeTs = continuation + ) + } + } get("/list-latest-pvb-games") { paginationParams { limit, continuation, distinctByUsers -> - gameDataService.listLastPvbGames( + // false by default + val excludeAutoResigned = (call.parameters["excludeAutoResigned"] ?: "false").toBoolean() + + gameDataService.listLatestPvbGames( requestedLimit = limit, distinctByUsers = distinctByUsers, - beforeTs = continuation + beforeTs = continuation, + excludeAutoResigned = excludeAutoResigned ) } } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt index e585964c0..0e4f70144 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt @@ -2,6 +2,7 @@ package io.elephantchess.webapp.routing.api import io.elephantchess.servicelayer.dto.ContactFormRequest import io.elephantchess.servicelayer.dto.user.* +import io.elephantchess.servicelayer.model.GuestToken import io.elephantchess.servicelayer.services.GlobalAnalyticsService import io.elephantchess.servicelayer.services.UserProfileAnalyticsService import io.elephantchess.servicelayer.services.UserService @@ -41,8 +42,15 @@ private fun Route.loginAndSignUpRoutes() { } } post("/signup") { - handleValidatedResponse { request -> - userService.signUp(request) + requireIdentificationWithBody { verifiedToken, request -> + val guestUserId = + if (request.transferGuestData) { + (verifiedToken as? GuestToken)?.userId + } else { + null + } + + handleEither(userService.signUp(request, guestUserId)) } } get("/obtain-guest-user-token") { @@ -143,6 +151,37 @@ private fun Route.userSettingsRoutes() { userService.fetchEmailAddressSettings(verifiedToken.userId) } } + post("/email-address/resend-confirmation") { + requireAuthentication { verifiedToken -> + userService.resendEmailConfirmation(verifiedToken.userId) + } + } + get("/sessions") { + requireAuthentication { verifiedToken -> + val limit = + call.request.queryParameters["limit"] + ?.toIntOrNull() + ?.coerceAtLeast(1) + ?: 10 + val offset = + call.request.queryParameters["offset"] + ?.toIntOrNull() + ?.coerceAtLeast(0) + ?: 0 + + userService.fetchUserSessions(verifiedToken.userId, limit, offset) + } + } + post("/sessions/delete") { + requireAuthenticationWithBody { verifiedToken, request -> + userService.deleteUserSessions(verifiedToken.userId, request) + } + } + post("/sessions/delete-all") { + requireAuthentication { verifiedToken -> + userService.deleteAllUserSessions(verifiedToken.userId) + } + } } } diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlDatabaseRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlDatabaseRouting.kt index 01562a76d..a9ba0360f 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlDatabaseRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlDatabaseRouting.kt @@ -112,6 +112,12 @@ internal fun Routing.databasePages() { call.respondHtml(databasePageRenderer.renderBrowseEventGamesPage(eventId, eventName, round)) } + get("/database/game") { + val gameId = call.parameters["id"] ?: throw BadRequestException("id query parameter not provided") + val summary = databaseService.fetchGameSummary(gameId) ?: throw NotFoundException("Game not found") + val orientation = call.request.queryParameters["orientation"] + call.respondHtml(databasePageRenderer.renderGamePage(summary, orientation)) + } } private suspend fun RoutingContext.resolveDatabasePlayer(render: suspend (DatabasePlayer) -> String) { diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlRouting.kt index e21d3dd9c..fd06f1174 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/html/HtmlRouting.kt @@ -5,10 +5,8 @@ import io.elephantchess.servicelayer.services.KofiService import io.elephantchess.servicelayer.services.UserService import io.elephantchess.servicelayer.utils.ops.koin import io.elephantchess.webapp.ops.* -import io.elephantchess.webapp.rendering.ModalRenderer -import io.elephantchess.webapp.rendering.SimplePageRenderer -import io.elephantchess.webapp.rendering.UserProfilePageRenderer -import io.elephantchess.webapp.rendering.latestSupporterTagResolver +import io.elephantchess.webapp.rendering.* +import io.elephantchess.webapp.routing.emailSettingUpdatePages import io.github.oshai.kotlinlogging.KotlinLogging.logger import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.http.HttpStatusCode.Companion.NotFound @@ -20,7 +18,7 @@ private val logger = logger {} internal val simplePageRenderer by koin() -private val publicPageMapping = mapOf( +private val simplePublicPageMapping = mapOf( "/401" to "401", "/403" to "403", "/404" to "404", @@ -29,7 +27,6 @@ private val publicPageMapping = mapOf( "/board" to "simple_board", "/database" to "database/database_search", "/database/search" to "database/database_search", - "/database/game" to "database/database_game_viewer", "/browse/player-vs-player" to "browse_pvp_games", "/browse/player-vs-bot" to "browse_pvb_games", "/analysis" to "analysis_board", @@ -37,15 +34,13 @@ private val publicPageMapping = mapOf( "/recovery" to "password_recovery1", "/recovery/finalize" to "password_recovery2", "/about" to "about/about", - "/about/changelog" to "about/changelog", - "/about/faq" to "about/faq", "/contact" to "contact_form", "/7k/game" to "seven_kingdoms/seven_kingdoms_game", "/7k/playground" to "seven_kingdoms/seven_kingdoms_playground", "/7k/about" to "seven_kingdoms/seven_kingdoms_about", ) -private val publicPageMappingWithSupporterBanner = mapOf( +private val simplePublicPageMappingWithSupporterBanner = mapOf( "/" to "lobby", "/playbot" to "player_vs_bot", ) @@ -60,12 +55,14 @@ private val identificationRequiredPagesMapping = mapOf( "/userdata/games" to "userdata/my_games", "/userdata/botgames" to "userdata/my_bot_games", "/userdata/analysis" to "userdata/my_analysis", - "/userdata/puzzles" to "userdata/my_played_puzzles" + "/userdata/puzzles" to "userdata/my_played_puzzles", + "/userdata/db-searches" to "userdata/my_db_searches", ) // only available authenticated users private val authenticatedRequiredPagesMapping = mapOf( "/user/settings" to "user_settings", + "/user/settings/sessions" to "user_sessions", ) private val adminPagesMapping = mapOf( @@ -98,11 +95,13 @@ fun Application.htmlRoutingModule() { userProfile() modals() databasePages() + aboutPages() + emailSettingUpdatePages() } } private fun Routing.simpleMappings() { - publicPageMapping.forEach { (path, templateName) -> + simplePublicPageMapping.forEach { (path, templateName) -> logger.info { "[public] mapping html file $templateName to path $path" } get(path) { call.respond(simplePageRenderer.renderTemplateHtml(templateName)) @@ -143,7 +142,7 @@ private fun Routing.simpleMappings() { private fun Routing.simpleMappingsWithSupporterBanner() { val kofiService by koin() - publicPageMappingWithSupporterBanner.forEach { (path, templateName) -> + simplePublicPageMappingWithSupporterBanner.forEach { (path, templateName) -> logger.info { "[public] mapping html file $templateName to path $path (with supporter banner)" } get(path) { val latestTipper = kofiService.fetchLatestSupporter() @@ -168,6 +167,25 @@ private fun Route.userProfile() { val userProfileResponse = userService.fetchProfile(username) call.respondHtml(renderer.renderUserProfile(userProfileResponse)) } + get("/@/{username}/browse-pvp-games") { + val username = call.parameters["username"] + ?: throw BadRequestException("username not provided") + + userService.validateUserExists(username) + call.respondHtml(renderer.renderUserBrowsePvpGames(username)) + } +} + +private fun Route.aboutPages() { + val changelogPageRenderer by koin() + val faqPageRenderer by koin() + + get("/about/faq") { + call.respondHtml(faqPageRenderer.renderFaqPage()) + } + get("/about/changelog") { + call.respondHtml(changelogPageRenderer.renderChangelogPage()) + } } private fun Route.modals() { 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 e30c37c64..da01c650c 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/server/ApiServiceRoutingModule.kt @@ -47,7 +47,6 @@ fun Application.apiServiceModule() { lobbyRoutes() supporterRoutes() adminConsoleRoutes() - newsletterUnsubscriptionRoutes() integrationRoutes() } } diff --git a/webapp/src/main/resources/modals/import-moves.html b/webapp/src/main/resources/modals/import-moves.html index 57e01c4a7..91e614bb6 100644 --- a/webapp/src/main/resources/modals/import-moves.html +++ b/webapp/src/main/resources/modals/import-moves.html @@ -4,7 +4,7 @@ about -

    Import PGN, WXF, UCI or Algebraic

    +

    Import PGN, WXF, UCI, ICCS or Algebraic

    diff --git a/webapp/src/main/resources/modals/signup.html b/webapp/src/main/resources/modals/signup.html index 086fbc939..960ad419b 100644 --- a/webapp/src/main/resources/modals/signup.html +++ b/webapp/src/main/resources/modals/signup.html @@ -33,6 +33,12 @@

    Sign Up

    +
    #PlayerWDLScore
    ...
    + * + * Modifiers: + * .nice-table-striped : zebra-stripes the body rows + * .nice-table-hover : highlights rows on hover + */ + +.nice-table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + border: 1px solid #d0d0d0; + background-color: #f5f0eb; + border-radius: 10px; + margin-top: 10px; + margin-bottom: 44px; + overflow: hidden; +} + +.nice-table td, +.nice-table th { + display: table-cell; + padding: 8px 12px; + border-bottom: 1px solid #e0dcd8; + border-right: 1px solid #e0dcd8; +} + +.nice-table td:last-child, +.nice-table th:last-child { + border-right: none; +} + +.nice-table tr:last-child td { + border-bottom: none; +} + +.nice-table th { + background-color: #d5d5d5; + font-weight: bold; + padding: 10px 12px; +} + +.nice-table-striped tbody tr:nth-child(even), +.nice-table-striped > tr:nth-child(even) { + background-color: #f0ebe6; +} + +.nice-table-hover tbody tr:hover { + background-color: #ece9e4; +} + +@media (max-width: 1000px) { + .nice-table { + max-width: 100%; + border-radius: 0; + margin-top: 20px; + margin-bottom: 60px; + } + + .nice-table td, + .nice-table th { + padding: 14px 10px; + font-size: 30px; + } +} diff --git a/webapp/src/main/resources/public/css/database/database-game-viewer.css b/webapp/src/main/resources/public/css/database/database-game-viewer.css index 6bfb4f13f..07fa8f0bb 100644 --- a/webapp/src/main/resources/public/css/database/database-game-viewer.css +++ b/webapp/src/main/resources/public/css/database/database-game-viewer.css @@ -18,5 +18,21 @@ */ #game-event-info { - margin-top: 6px; + margin-top: 12px; +} + +/* + * Player name links inside #players-info: override the default link color + * (and the :visited link color, which has specificity 0,1,1 and would beat + * a plain .red-color / .black-color rule) so that the red / black player + * names actually appear in their respective colors. + */ +#players-info a.red-color, +#players-info a.red-color:visited { + color: rgba(222, 7, 7); +} + +#players-info a.black-color, +#players-info a.black-color:visited { + color: rgba(0, 0, 0); } diff --git a/webapp/src/main/resources/public/css/database/database.css b/webapp/src/main/resources/public/css/database/database.css index d6e98cfe5..d80b9e98a 100644 --- a/webapp/src/main/resources/public/css/database/database.css +++ b/webapp/src/main/resources/public/css/database/database.css @@ -17,59 +17,18 @@ * along with this program. If not, see . */ +/* + * Database-specific overrides on top of the shared `.nice-table` styles + * (see /css/common/nice-table.css). + */ + .database-table { - border-collapse: separate; - border-spacing: 0; table-layout: fixed; - width: 100%; max-width: 600px; - border: 1px solid #d0d0d0; - background-color: #f5f0eb; - border-radius: 10px; - margin-top: 10px; - margin-bottom: 44px; - overflow: hidden; -} - -.database-table td, -.database-table th { - display: table-cell; - padding: 8px 12px; - border-bottom: 1px solid #e0dcd8; - border-right: 1px solid #e0dcd8; -} - -.database-table td:last-child, -.database-table th:last-child { - border-right: none; -} - -.database-table tr:last-child td { - border-bottom: none; -} - -.database-table th { - background-color: #d5d5d5; - font-weight: bold; - padding: 10px 12px; -} - -.database-table tr:nth-child(even) { - background-color: #f0ebe6; } - @media (max-width: 1000px) { .database-table { max-width: 100%; - border-radius: 0; - margin-top: 20px; - margin-bottom: 60px; - } - - .database-table td, - .database-table th { - padding: 14px 10px; - font-size: 30px; } } diff --git a/webapp/src/main/resources/public/css/lobby.css b/webapp/src/main/resources/public/css/lobby.css index c4ca0e8ad..a8e30638a 100644 --- a/webapp/src/main/resources/public/css/lobby.css +++ b/webapp/src/main/resources/public/css/lobby.css @@ -50,13 +50,15 @@ h1 { } .flex-games-to-join-list-container { + flex: 2 1 800px; min-width: 400px; - max-width: 850px; + max-width: unset; } .flex-new-game-logo-container { + flex: 1 1 320px; min-width: 350px; - max-width: 500px; + max-width: unset; } #games-to-join-list-container { @@ -69,12 +71,18 @@ h1 { align-items: start; border: 2px solid #323232; border-radius: 3px; - box-shadow: rgba(67, 71, 85, 0.27) 0px 0px 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em; + box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em; } #games-to-join-list-table-container { width: 100%; - padding: 12px 14px; + padding: 14px 16px; +} + +#games-to-join-list { + display: flex; + flex-direction: column; + gap: 10px; } .discrete-messages { @@ -91,97 +99,153 @@ h1 { margin-top: 30px; } -table#games-to-join-list-table { - border-collapse: separate; - border-spacing: 1px; +.game-to-join-item { + display: flex; + align-items: center; + background-color: #ece5d8; + border: 1px solid #565656; + border-radius: 3px; + min-height: 64px; + padding: 6px 10px; + box-shadow: rgba(0, 0, 0, 0.12) 0 1px 3px, rgba(0, 0, 0, 0.2) 0 1px 2px; } -table#games-to-join-list-table th { - text-align: left; - font-weight: normal; - padding: 0; - font-size: 14px; - color: #434343; +.game-to-join-variant-pane, +.game-to-join-time-control-pane, +.game-to-join-middle-pane, +.game-to-join-right-pane, +.game-to-join-rating-pane { + display: flex; + align-items: center; } -/* col spanner exists so bottom line solid pattern isn't broken */ -table#games-to-join-list-table tr.col-spanner, -table#games-to-join-list-table tr.col-spanner td { - border-bottom: 1px solid #474747; - height: 8px; +.game-to-join-variant-pane { + width: 34px; + justify-content: center; } -table#games-to-join-list-table tr td { - border-bottom: 1px dashed #605f5f; - height: 42px; +.game-to-join-variant-symbol { + font-size: 26px; + color: #343434; } -table#games-to-join-list-table tr.last-row td.last-row { - border-bottom-width: 0; +.game-to-join-time-control-pane { + width: 80px; + flex-direction: column; + justify-content: center; + gap: 4px; } -table#games-to-join-list-table td { - font-size: 16px; - vertical-align: middle; +.game-to-join-middle-pane { + min-width: 0; + flex: 1; + flex-direction: column; + align-items: flex-start; + margin-left: 10px; } -table#games-to-join-list-table td.online-cell, -table#games-to-join-list-table th.online-cell { - width: 22px; +.game-to-join-right-pane { + justify-content: flex-end; + margin-left: 12px; + margin-right: 8px; } -table#games-to-join-list-table td.online-cell { - padding-left: 10px; +.game-to-join-rating-pane { + justify-content: center; + margin-right: 20px; } -table#games-to-join-list-table td.username-cell, -table#games-to-join-list-table th.username-cell { - width: 150px; - max-width: 150px; +.game-to-join-opponent-line { + display: flex; + align-items: center; + gap: 4px; } -table#games-to-join-list-table td.rating-cell, -table#games-to-join-list-table th.rating-cell { - width: 82px; +.game-to-join-opponent-line .username-cell { + max-width: 280px; + font-size: 15px; } -table#games-to-join-list-table td.color-cell, -table#games-to-join-list-table th.color-cell { - width: 82px; +.game-to-join-opponent-line .rating-cell { + color: #555; + font-size: 14px; } -table#games-to-join-list-table td.time-control-icons-cell, -table#games-to-join-list-table th.time-control-icons-cell { - width: 34px; +.game-to-join-metadata-line { + display: flex; + align-items: center; + flex-wrap: nowrap; + margin-top: 4px; + gap: 6px; } -table#games-to-join-list-table td.time-control-cell, -table#games-to-join-list-table th.time-control-cell { - width: 130px; - max-width: 130px; +.game-to-join-custom-time-line { + color: #555; font-size: 13px; + margin-top: 5px; } -table#games-to-join-list-table td.rating-mode-cell, -table#games-to-join-list-table th.rating-mode-cell { - width: 90px; +.game-to-join-metadata-item { + display: flex; + align-items: center; + gap: 3px; + color: #3a3a3a; + font-size: 14px; } -table#games-to-join-list-table td.separator-cell, -table#games-to-join-list-table th.separator-cell { - width: 40px; +.game-to-join-item .join-button-cell { + text-align: right; } -table#games-to-join-list-table td.join-button-cell, -table#games-to-join-list-table th.join-button-cell { - width: 110px; +.game-to-join-rating-mode-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #3a3a3a; + font-size: 14px; text-align: center; } -input[type=button].join-buttons { - width: 90px; - height: 25px; - font-size: 12px; +.game-to-join-rating-mode-icon { + width: 24px; + height: 24px; + object-fit: contain; +} + +.game-to-join-time-icon-cell { + display: flex; + justify-content: center; +} + +.game-to-join-item .time-control-icons { + width: 22px; +} + +.game-to-join-time-duration-cell { + color: #3a3a3a; + font-size: 13px; + line-height: 1; + white-space: nowrap; +} + +.game-to-join-online-cell { + width: 14px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 0; +} + +.game-to-join-online-cell .online-status-indicator { + margin-right: 3px; +} + +.join-buttons { + height: 34px; + font-size: 13px; + padding: 0 15px; } .no-game-to-join-message { @@ -236,7 +300,7 @@ img#kofi-logo { opacity: 82%; } -input[type="button"]#create-new-game-button { +#create-new-game-button { width: 250px; height: 45px; margin-top: 50px; @@ -308,6 +372,14 @@ iframe { width: 100%; } + #games-to-join-list-table-container { + padding: 0; + } + + #games-to-join-list { + gap: 0; + } + .flex-lobby-element { margin-bottom: 56px; margin-left: 30px; @@ -331,11 +403,11 @@ iframe { margin-top: 44px; } - input[type="button"]#create-new-game-button { - margin-top: 18px; - margin-bottom: 18px; + #create-new-game-button { + margin-top: 0 !important; + margin-bottom: 0 !important; width: 380px; - height: 80px; + height: 90px; } #no-game-to-join-message { @@ -359,47 +431,82 @@ iframe { font-size: 26px; } - .time-control-icons { - width: 30px; - margin-right: 8px; + .game-to-join-item .time-control-icons { + width: 38px; + } + + .game-to-join-variant-pane { + width: 58px; + } + + .game-to-join-variant-symbol { + font-size: 48px; } #games-to-join-list-container { - height: 550px; + height: 642px; + } + + .game-to-join-item { + border-radius: 0; + min-height: 140px; + padding: 10px 12px; + } + + .game-to-join-time-control-pane { + width: 118px; } - table#games-to-join-list-table th { + .game-to-join-middle-pane { + margin-left: 10px; + } + + .game-to-join-right-pane { + margin-left: 12px; + margin-right: 8px; + } + + .game-to-join-rating-pane { + width: 84px; + margin-left: 6px; + } + + .game-to-join-opponent-line { + min-width: 0; + } + + .game-to-join-opponent-line .username-cell { + max-width: unset; font-size: 24px; + overflow: hidden; + text-overflow: ellipsis; } - table#games-to-join-list-table td { - font-size: 36px; + .game-to-join-opponent-line .rating-cell { + flex: 0 0 auto; + font-size: 24px; } - table#games-to-join-list-table th.time-control-cell, - table#games-to-join-list-table td.time-control-cell { - font-size: 30px; - width: 100px; - max-width: 100px; + .game-to-join-metadata-item { + font-size: 26px; } - table#games-to-join-list-table td.rating-cell, - table#games-to-join-list-table th.rating-cell { - width: 105px; + .game-to-join-custom-time-line { + font-size: 24px; } - table#games-to-join-list-table td.color-cell, - table#games-to-join-list-table th.color-cell { - width: 105px; + .game-to-join-time-duration-cell { + font-size: 24px; } - table#games-to-join-list-table td.join-button-cell, - table#games-to-join-list-table th.join-button-cell { - width: 90px; + .game-to-join-rating-mode-cell { + font-size: 24px; + gap: 2px; } - table#games-to-join-list-table tr td { - height: 76px; + .game-to-join-rating-mode-icon { + width: 36px; + height: 36px; } #online-users-and-rating { @@ -407,16 +514,23 @@ iframe { font-size: 28px; } - input[type=button].join-buttons { - width: 70px; - height: 68px; - font-size: 28px; + .join-buttons { + min-width: 110px !important; + max-width: 120px !important; + height: 80px; + font-size: 25px; + padding: 0 !important; + margin: 0; + box-sizing: border-box; } - table#games-to-join-list-table tr td .online-status-indicator { + .game-to-join-item .online-status-indicator { width: 16px; height: 16px; - border-radius: 6px; + min-width: 16px; + min-height: 16px; + flex: 0 0 16px; + border-radius: 50%; margin-right: 6px; } diff --git a/webapp/src/main/resources/public/css/my-db-searches.css b/webapp/src/main/resources/public/css/my-db-searches.css new file mode 100644 index 000000000..b24fe8c3f --- /dev/null +++ b/webapp/src/main/resources/public/css/my-db-searches.css @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +.my-game-item { + height: 90px; +} + +.my-game-item .default-text { + /* Allow long FEN strings to wrap inside the middle pane instead of overflowing. */ + overflow-wrap: anywhere; + word-break: break-word; +} + +@media (max-width: 1000px) { + /* On mobile the my-game-item from my-user-data-items.css is bumped to 120px + and font-size 26px, but the search summary has multi-line content + (player, event, date range, FEN) so we need more room and a readable + text size for the .default-text summary. */ + .my-game-item { + height: auto; + min-height: 140px; + padding-top: 10px; + padding-bottom: 10px; + } + + .my-game-item .left-pane { + /* Slightly narrower icon column so the summary has more breathing room. */ + width: 14%; + } + + .my-game-item .middle-pane { + padding-right: 8px; + } + + .my-game-item .default-text { + font-size: 22px; + line-height: 1.25; + } +} diff --git a/webapp/src/main/resources/public/css/new-game-modal.css b/webapp/src/main/resources/public/css/new-game-modal.css index 1f1cf9d11..0ca256136 100644 --- a/webapp/src/main/resources/public/css/new-game-modal.css +++ b/webapp/src/main/resources/public/css/new-game-modal.css @@ -116,7 +116,7 @@ width: 20px; } -#create-new-game-modal input[type=button].create-game-buttons { +#create-new-game-modal .create-game-buttons { width: 150px; margin-bottom: 0; } diff --git a/webapp/src/main/resources/public/css/player-vs-player.css b/webapp/src/main/resources/public/css/player-vs-player.css index d6194ca07..3651a1758 100644 --- a/webapp/src/main/resources/public/css/player-vs-player.css +++ b/webapp/src/main/resources/public/css/player-vs-player.css @@ -89,6 +89,39 @@ tr#outcome-row { border-top-color: #4d4d4d; } +.mini-counter-box { + position: fixed; + bottom: 8px; + right: 8px; + z-index: 400; + width: 300px; + box-shadow: rgba(0, 0, 0, 0.4) 0 4px 10px; + border-radius: 4px; + overflow: hidden; + opacity: 1; + transition: opacity 0.2s ease-in-out; +} + +.mini-counter-box.mini-counter-box-hidden { + opacity: 0; + pointer-events: none; +} + +.mini-counter-box .mini-time-counter { + font-size: 46px; + padding-top: 4px; + padding-bottom: 2px; + box-sizing: border-box; +} + +.mini-counter-box .red-counter { + background-color: rgb(255 168 168); +} + +.mini-counter-box .black-counter { + background-color: rgb(171 171 171); +} + .move-tree-container-smaller { height: 270px; } diff --git a/webapp/src/main/resources/public/css/style-reactive.css b/webapp/src/main/resources/public/css/style-reactive.css index 4dc4d2846..d3a969897 100644 --- a/webapp/src/main/resources/public/css/style-reactive.css +++ b/webapp/src/main/resources/public/css/style-reactive.css @@ -44,6 +44,7 @@ #user-profile-icon { display: block; + margin-right: 0; } @@ -79,10 +80,12 @@ .main-container, .main-container-text, + .main-container-text-extended-smaller, .main-container-text-extended, .main-container-text-extended-larger, .main-container-text-error-message { width: 96%; + min-width: 0; } .standard-data-table .user-rating-delta-value-box, @@ -92,6 +95,10 @@ font-size: 18px; } + textarea { + font-size: 28px; + } + input[type="checkbox"], input[type="radio"] { -ms-transform: scale(2); /* IE 9 */ @@ -333,8 +340,8 @@ } #user-profile-icon img { - width: 30px; - height: 30px; + width: 50px; + height: 50px; } #handle-menu-container { @@ -351,39 +358,39 @@ font-size: 30px; } - input[type=button].app-buttons, - input[type=button].app-buttons-red, - input[type=button].app-buttons-bigger, - input[type=button].app-buttons-much-bigger { + .app-buttons, + .app-buttons-red, + .app-buttons-bigger, + .app-buttons-much-bigger { border-radius: 6px; } /* use the color of :hover (since there is no hover on mobile) */ - input[type=button].app-buttons, - input[type=button].app-buttons-bigger, - input[type=button].app-buttons-much-bigger { + .app-buttons, + .app-buttons-bigger, + .app-buttons-much-bigger { background-color: #077d01; } - input[type=button].app-buttons-disabled, - input[type=button].app-buttons-disabled:hover { + .app-buttons-disabled, + .app-buttons-disabled:hover { background-color: #848484; } - input[type=button].app-buttons, - input[type=button].app-buttons-red { + .app-buttons, + .app-buttons-red { font-size: 30px; height: 55px; min-width: 180px; } - input[type=button].app-buttons-bigger { + .app-buttons-bigger { font-size: 34px; height: 60px; min-width: 200px; } - input[type=button].app-buttons-much-bigger { + .app-buttons-much-bigger { font-size: 36px; height: 66px; margin-top: 30px; @@ -391,7 +398,7 @@ min-width: 220px; } - input[type=button].app-buttons-smaller { + .app-buttons-smaller { font-size: 20px; height: 54px; } @@ -509,8 +516,41 @@ width: 68px; } + #settings-info-box-container #advanced-settings-toggle { + font-size: 24px; + } + + #settings-info-box-container #advanced-settings-box { + position: fixed; + top: 50%; + left: 50%; + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + transform: translate(-50%, -50%); + z-index: 1202; + overflow-y: auto; + border-radius: 8px; + padding: 76px 34px 34px; + } + + #settings-info-box-container #advanced-settings-close-button { + display: block; + position: absolute; + top: 20px; + right: 24px; + width: 44px; + height: 44px; + border: 0; + background: transparent; + color: inherit; + font-size: 40px; + line-height: 1; + cursor: pointer; + } + #settings-info-box img#flip-board-button, #settings-info-box img#show-coordinates-button, + #settings-info-box img#colorblind-friendly-black-pieces-button, #settings-info-box img#show-analytics-arrows { width: 55px; } @@ -529,6 +569,32 @@ width: 88px; } + #settings-info-box-container #advanced-settings-box .advanced-setting-row { + margin: 10px 0; + } + + #settings-info-box-container #advanced-settings-box .advanced-setting-icon { + width: 34px; + margin-right: 12px; + } + + #settings-info-box-container #advanced-settings-box .advanced-setting-title { + font-size: 32px; + } + + #settings-info-box-container #advanced-settings-box .advanced-setting-description { + font-size: 20px; + } + + #settings-info-box-container #advanced-settings-box .advanced-setting-options { + gap: 8px; + font-size: 30px; + } + + #settings-info-box-container #advanced-settings-box .advanced-setting-options input[type=radio] { + transform: scale(1.8); + } + #settings-info-box .setting-option-double-item { width: 140px; } diff --git a/webapp/src/main/resources/public/css/style.css b/webapp/src/main/resources/public/css/style.css index afea6ce4d..b77301850 100644 --- a/webapp/src/main/resources/public/css/style.css +++ b/webapp/src/main/resources/public/css/style.css @@ -471,6 +471,12 @@ span.sub-text a, span.sub-text a:visited { background-color: #b2d7f6; } +.drop-down-menu-item-link, +.drop-down-menu-item-link:visited { + color: #2b2b2b; + text-decoration: none; +} + .drop-down-menu-item-top-separator { border-top: 1px solid rgba(43, 43, 43, 0.67); } @@ -786,28 +792,26 @@ a.top-bar-menu-items:hover img.top-bar-menu-items-icons { } #user-profile-icon { - display: none; + display: block; + margin-right: 12px; + margin-top: 5px; } #user-profile-icon img { - width: 18px; - height: 18px; - opacity: 60%; -} - -#user-profile-icon img:hover { - opacity: 80%; + width: 20px; + height: 20px; + opacity: 50%; } #login-side input { float: right; } -input[type=button].app-buttons, -input[type=button].app-buttons-red, -input[type=button].app-buttons-smaller, -input[type=button].app-buttons-bigger, -input[type=button].app-buttons-much-bigger { +.app-buttons, +.app-buttons-red, +.app-buttons-smaller, +.app-buttons-bigger, +.app-buttons-much-bigger { padding: 5px 20px; text-decoration: none; margin: 4px 2px; @@ -819,68 +823,68 @@ input[type=button].app-buttons-much-bigger { height: 28px; } -input[type=button].app-buttons, -input[type=button].app-buttons-smaller, -input[type=button].app-buttons-bigger, -input[type=button].app-buttons-much-bigger { +.app-buttons, +.app-buttons-smaller, +.app-buttons-bigger, +.app-buttons-much-bigger { background-color: #054101; color: white; } -input[type=button].app-buttons:hover, -input[type=button].app-buttons-smaller, -input[type=button].app-buttons-bigger:hover, -input[type=button].app-buttons-much-bigger:hover { +.app-buttons:hover, +.app-buttons-smaller, +.app-buttons-bigger:hover, +.app-buttons-much-bigger:hover { background-color: #077d01; cursor: pointer; } -input[type=button].app-buttons-red, -input[type=button].app-buttons-bigger-red { +.app-buttons-red, +.app-buttons-bigger-red { background-color: #a80202; color: white; } -input[type=button].app-buttons-red:hover, -input[type=button].app-buttons-bigger-red:hover { +.app-buttons-red:hover, +.app-buttons-bigger-red:hover { background-color: #ce0707; cursor: pointer; } -input[type=button].app-buttons-smaller { +.app-buttons-smaller { font-size: 13px; height: 26px; margin-top: 2px; margin-bottom: 2px; } -input[type=button].app-buttons-bigger { +.app-buttons-bigger { font-size: 16px; height: 32px; margin-top: 14px; margin-bottom: 14px; } -input[type=button].app-buttons-much-bigger { +.app-buttons-much-bigger { font-size: 20px; height: 40px; margin-top: 22px; margin-bottom: 22px; } -input[type=button].log-buttons { +.log-buttons { width: 96px; min-width: 96px; display: block; margin-left: 10px; } -input[type=button].app-buttons-disabled, -input[type=button].app-buttons-disabled:hover { +.app-buttons-disabled, +.app-buttons-disabled:hover { background-color: #848484; } -input[type=button].app-buttons-disabled:hover { +.app-buttons-disabled:hover { cursor: unset; } @@ -1031,6 +1035,7 @@ input[type=password].incorrect-data { .main-container, .main-container-text, +.main-container-text-extended-smaller, .main-container-text-extended, .main-container-text-extended-larger, .main-container-text-error-message { @@ -1039,6 +1044,7 @@ input[type=password].incorrect-data { } .main-container-text, +.main-container-text-extended-smaller, .main-container-text-extended, .main-container-text-extended-larger, .main-container-text-error-message { @@ -1052,6 +1058,12 @@ input[type=password].incorrect-data { max-width: 950px; } +.main-container-text-extended-smaller { + width: 70%; + min-width: 600px; + max-width: 1200px; +} + .main-container-text-extended { width: 86%; min-width: 600px; @@ -1281,6 +1293,8 @@ table.info-box-sub-table td.values { display: flex; align-items: center; justify-content: center; + position: relative; + overflow: visible; } #settings-info-box { @@ -1288,6 +1302,7 @@ table.info-box-sub-table td.values { align-items: center; justify-content: space-between; height: 40px; + position: relative; } #settings-info-box .setting-option-item, @@ -1321,7 +1336,8 @@ table.info-box-sub-table td.values { } #settings-info-box img#flip-board-button, -#settings-info-box img#show-coordinates-button { +#settings-info-box img#show-coordinates-button, +#settings-info-box img#colorblind-friendly-black-pieces-button { width: 24px; } @@ -1329,6 +1345,116 @@ table.info-box-sub-table td.values { width: 30px; } +#settings-info-box-container #advanced-settings-toggle { + position: absolute; + right: 12px; + bottom: 6px; + color: #3b3b3b; + text-decoration: underline; + font-size: 14px; + font-style: italic; + line-height: 1; + z-index: 1201; +} + +#settings-info-box-container #advanced-settings-toggle:hover { + cursor: pointer; +} + +#settings-info-box-container #advanced-settings-box { + display: none; + position: absolute; + top: calc(100% + 10px); + left: -5px; + width: calc(100% + 10px); + z-index: 1200; + background: #e0d2bd; + border: 2px solid #393939; + border-radius: 6px; + box-shadow: rgba(0, 0, 0, 0.55) 0 4px 12px; + padding: 12px 19px; + box-sizing: border-box; +} + +#settings-info-box-container #advanced-settings-close-button { + display: none; +} + +#settings-info-box-container #advanced-settings-box.advanced-settings-box-open { + display: block; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-row { + display: flex; + align-items: flex-start; + width: 100%; + margin: 6px 0; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-icon { + width: 20px; + margin-right: 8px; + margin-top: 2px; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-content { + flex: 1; + min-width: 0; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-text { + text-align: left; + margin-bottom: 4px; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-title { + color: #3b3b3b; + font-size: 14px; + font-weight: 600; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-description { + color: #50483d; + font-size: 13px; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-options { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + font-size: 14px; + color: #2f2f2f; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-options label { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-options input[type=radio] { + cursor: pointer; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-options .advanced-setting-option-disabled, +#settings-info-box-container #advanced-settings-box .advanced-setting-options .advanced-setting-option-disabled input[type=radio] { + color: #9a9a9a; + cursor: not-allowed; + opacity: 0.6; +} + +#settings-info-box-container #advanced-settings-box .advanced-setting-warning { + margin-top: 6px; + padding: 4px 8px; + font-size: 12px; + color: #8a5a00; + background-color: #fff7e0; + border: 1px solid #f0d68b; + border-radius: 3px; +} + #settings-info-box #show-analytics-arrows-item { display: none; } @@ -1376,18 +1502,33 @@ table.info-box-sub-table td.values { margin-bottom: 4px; } +#eval-line-chart-container { + margin-top: 20px; + margin-bottom: 6px; +} + #chat-box-container { border: 1px solid black; margin-bottom: 15px; height: 270px; box-shadow: rgba(67, 71, 85, 0.27) 0px 0px 0.25em, rgba(90, 125, 188, 0.05) 0px 0.25em 1em; background-color: #ece8e3; + display: flex; + flex-direction: column; } #chat-box-messages-container { overflow-y: scroll; overflow-x: hidden; - height: 230px; + flex: 1; +} + +#chat-box-typing-indicator { + padding: 2px 8px; + font-size: 14px; + font-style: italic; + color: #555; + min-height: 0; } #chat-box-send-container { @@ -1669,7 +1810,6 @@ img#navigation-end { } - /* bar indicators */ .bar-indicator { @@ -2068,7 +2208,7 @@ img#navigation-end { .time-control-icons { width: 22px; - opacity: 78%; + opacity: 80%; } .time-control-icons-larger { diff --git a/webapp/src/main/resources/public/css/user-profile.css b/webapp/src/main/resources/public/css/user-profile.css index 66806da3f..d1ef2da84 100644 --- a/webapp/src/main/resources/public/css/user-profile.css +++ b/webapp/src/main/resources/public/css/user-profile.css @@ -19,6 +19,8 @@ .main-container-text { margin-top: 0; + /* when profile is empty (no puzzle, no game), the footer looks huge */ + min-height: 800px; } h1 { @@ -137,6 +139,12 @@ h2 { width: 90%; } +.browse-more-link-container { + width: 100%; + margin-top: 10px; + margin-bottom: 20px; +} + @media (max-width: 1000px) { h2 { font-size: 36px; @@ -199,4 +207,8 @@ h2 { .puzzle-chart-container { width: 98%; } + + .main-container-text { + min-height: unset; + } } diff --git a/webapp/src/main/resources/public/css/user-settings.css b/webapp/src/main/resources/public/css/user-settings.css index f816f0975..cb1cc2c8f 100644 --- a/webapp/src/main/resources/public/css/user-settings.css +++ b/webapp/src/main/resources/public/css/user-settings.css @@ -48,6 +48,10 @@ margin-left: 13px; } +#resend-email-confirmation-container { + margin: 0 15px 15px 13px; +} + #email-address { font-size: 18px; height: 30px; @@ -57,6 +61,51 @@ padding-right: 3px; } +#sessions-actions-container { + margin: 10px 0 12px 12px; +} + +#sessions-actions-container input[type="button"] { + margin-left: 12px; +} + +#user-sessions-table-container { + overflow-x: auto; +} + +#user-sessions-table { + margin-left: 8px; +} + +#user-sessions-table th, +#user-sessions-table td { + white-space: nowrap; +} + + +.session-country-cell, +.session-os-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.session-os-icon { + width: 20px; + height: 20px; + object-fit: contain; + text-align: center; +} + +#user-sessions-table .select-cell { + width: 26px; +} + +#user-sessions-empty-message, +#all-sessions-link { + margin-left: 12px; +} + .hidden { display: none; } @@ -91,4 +140,20 @@ padding-left: 6px; padding-right: 6px; } + + #sessions-actions-container { + font-size: 30px; + margin-left: 10px; + } + + + #sessions-actions-container input[type="button"] { + margin-left: 0; + margin-top: 10px; + } + + .session-os-icon { + width: 34px; + height: 34px; + } } diff --git a/webapp/src/main/resources/public/dist/0.0.3/board-gui.js b/webapp/src/main/resources/public/dist/0.0.3/board-gui.js new file mode 100644 index 000000000..b9fa5b945 --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.3/board-gui.js @@ -0,0 +1,1735 @@ +/* + * Copyright (C) 2026 Encelade SRL + * Copyright (C) 2026 elephantchess.io + * Copyright (C) 2026 Benoît Vleminckx (benckx) + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +// Small DOM helpers and constants, inlined here so this file is usable on its +// own (only requires xiangqi.js, which provides the `Color` enum). Previously +// imported from utils.js / ui.js. For helpers that may also be defined by +// those modules when loaded as part of the full webapp, we only define them +// if the host page hasn't already loaded those modules, to avoid clobbering +// existing definitions. +if (typeof buildImg === 'undefined') { + // eslint-disable-next-line no-var + var buildImg = function (src, className = null) { + const img = document.createElement('img'); + img.src = src; + if (className != null) img.className = className; + return img; + }; +} + +if (typeof htmlCollectionToArray === 'undefined') { + // eslint-disable-next-line no-var + var htmlCollectionToArray = function (htmlCollection) { + const arr = []; + for (let i = 0; i < htmlCollection.length; i++) { + arr.push(htmlCollection[i]); + } + return arr; + }; +} + +if (typeof buildDivWithClass === 'undefined') { + // eslint-disable-next-line no-var + var buildDivWithClass = function (className) { + const div = document.createElement('div'); + div.className = className; + return div; + }; +} + +// Mapping from FEN piece char to piece image file name. Lives here (rather +// than in ui.js) so that board-gui.js can be used standalone. +const pieceImageNames = new Map(); +pieceImageNames.set('K', 'red_general.png'); +pieceImageNames.set('k', 'black_general.png'); +pieceImageNames.set('A', 'red_advisor.png'); +pieceImageNames.set('a', 'black_advisor.png'); +pieceImageNames.set('B', 'red_elephant.png'); +pieceImageNames.set('b', 'black_elephant.png'); +pieceImageNames.set('N', 'red_horse.png'); +pieceImageNames.set('n', 'black_horse.png'); +pieceImageNames.set('C', 'red_cannon.png'); +pieceImageNames.set('c', 'black_cannon.png'); +pieceImageNames.set('R', 'red_chariot.png'); +pieceImageNames.set('r', 'black_chariot.png'); +pieceImageNames.set('P', 'red_soldier.png'); +pieceImageNames.set('p', 'black_soldier.png'); + +const diagonalDescending = ''; +const diagonalRising = ''; + +const diagonalDescendingMini = ''; +const diagonalRisingMini = ''; + +const PRIMARY_ARROW_COLOR = 'rgb(27, 181, 29)'; +const SECONDARY_ARROW_COLOR = 'rgb(62, 56, 219)'; + +const ARROW_MARKERS = ` + + + + + + + +`; + +const bottomRightCrosshairs = [ + new Position(0, 8), new Position(6, 8), + new Position(1, 7), new Position(3, 7), new Position(5, 7), new Position(7, 7), + new Position(1, 4), new Position(3, 4), new Position(5, 4), new Position(7, 4), + new Position(0, 3), new Position(6, 3), +]; + +const bottomLeftCrosshairs = [ + new Position(1, 8), new Position(7, 8), + new Position(0, 7), new Position(2, 7), new Position(4, 7), new Position(6, 7), + new Position(0, 4), new Position(2, 4), new Position(4, 4), new Position(6, 4), + new Position(1, 3), new Position(7, 3), +]; + +const topRightCrosshairs = [ + new Position(0, 7), new Position(6, 7), + new Position(1, 6), new Position(3, 6), new Position(5, 6), new Position(7, 6), + new Position(1, 3), new Position(3, 3), new Position(5, 3), new Position(7, 3), + new Position(0, 2), new Position(6, 2), +]; + +const topLeftCrosshairs = [ + new Position(1, 7), new Position(7, 7), + new Position(0, 6), new Position(2, 6), new Position(4, 6), new Position(6, 6), + new Position(0, 3), new Position(2, 3), new Position(4, 3), new Position(6, 3), + new Position(1, 2), new Position(7, 2) +]; + +const EngineArrowType = Object.freeze({ + PRIMARY: 'PRIMARY', + SECONDARY: 'SECONDARY' +}); + +/** + * Orientation of the board coordinate labels. + * Use `null` (not part of this enum) to hide the coordinates entirely. + */ +const CoordinatesOrientation = Object.freeze({ + WXF: 'WXF', + UCI: 'UCI', +}); + +/** + * Visual style of the board pieces. Used to pick the corresponding image folder + * (under `${assetsBaseUrl}/images/pieces//`). + */ +const PieceStyleSetting = Object.freeze({ + TRADITIONAL: 'TRADITIONAL', + ROMANIZED_ROUNDED: 'ROMANIZED_ROUNDED', + DEFAULT: 'TRADITIONAL', +}); + +/** + * @typedef {Object} BoardGuiOptions + * @property {string} [elementId] - id of the container element + * @property {boolean} [showCoordinates] - whether to reserve space for file/rank coordinates + * @property {string|null} [coordinatesOrientation] - one of {@link CoordinatesOrientation} or `null` to + * hide the labels (space is still reserved when + * `showCoordinates` is true). The caller is in + * charge of resolving any user preference (e.g. cookies). + * @property {boolean} [mini] - whether this is a mini (thumb) board + * @property {boolean} [forceRenderChecks] - render checks even on mini boards + * @property {boolean} [svg] - enable the SVG overlay (used for engine arrows) + * @property {string} [assetsBaseUrl] - base URL prepended to every static asset path + * (images, audio). Default: `https://elephantchess.io`. + * Pass an empty string to use relative paths (e.g. when + * serving the assets from the current host on localhost). + * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece + * image folder. + */ + +/** @type {Readonly>} */ +const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({ + elementId: 'board-container', + showCoordinates: true, + coordinatesOrientation: 'WXF', + mini: false, + forceRenderChecks: false, + svg: false, + assetsBaseUrl: 'https://cdn.elephantchess.io/static', + pieceStyle: PieceStyleSetting.DEFAULT, +}); + +class BoardGui { + + #board = new Board(); + #boardContainer; + #currentShowingLegalMovesFor = null; + #selectedPiecePosition = null; + #flippedRed = true; // if true, oriented toward the RED player + #afterMoveListeners = []; + #afterDrawPositionsListeners = []; + #afterFlipListeners = []; + + /** @type {HTMLAudioElement} */ + #clickSound; + + // when false, player won't be able to make a move + #isPlayerMoveEnabled = true; + + /** + * @type {Readonly>} + */ + #options; + + /** + * @type {HalfMove|null} + */ + #primaryEngineArrow = null; + + /** + * @type {HalfMove|null} + */ + #secondaryEngineArrow = null; + + #isSafari = false; + + /** + * @param {BoardGuiOptions} [options] + */ + constructor(options = {}) { + this.#isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + this.#options = Object.freeze({...DEFAULT_BOARD_GUI_OPTIONS, ...options}); + + this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`); + + this.#boardContainer = document.getElementById(this.#options.elementId); + this.#drawBoard(); + this.#drawPieces(); // FIXME: useful? + + const boardGui = this; + + document + .getElementsByTagName('html') + .item(0) + .addEventListener('click', (e) => { + if (!boardGui.#boardContainer.contains(e.target)) { + boardGui.#hideAllPiecePlaceHolders(); + } + }); + + if (this.#options.svg) { + window.onresize = function () { + boardGui.#renderSvg(); + }; + } + + this.#forceSafariLayoutRefresh(); + } + + /** + * Draw the position described by {@code fen}. + * + * When {@code animate} is true, the transition from the currently displayed + * position to the target position is animated: unchanged pieces are left in + * place, pieces of the same type are paired between the current and target + * positions and slide from their source square to their destination square, + * leftover current pieces fade out, and target pieces fade in - all + * simultaneously. This works for any diff size (not just a single move), + * similar to what lichess does. + * + * @param fen {string} + * @param animate {boolean} + */ + loadFen(fen, animate = false) { + if (this.isInPlaceHolderMode()) { + console.warn('Can not load FEN when in placeholder mode'); + return; + } + + if (animate) { + this.#drawPositionAnimated(fen); + } else { + this.#drawPositionNoAnimation(fen); + } + } + + #drawPositionNoAnimation(targetFen) { + this.#hideAllPiecePlaceHolders(); + this.#hideHighlightedLastMove(); + this.#unDrawAllPieces(); + this.#board.loadFen(targetFen); + this.#drawPieces(); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#forceSafariLayoutRefresh(); + } + + /** + * Compute the target position (via a temporary board so we don't mutate the + * current {@link Board} model before we're ready) and diff it against the + * currently displayed pieces to build the animation plan. + * + * @param targetFen {string} + */ + #drawPositionAnimated(targetFen) { + this.#hideAllPiecePlaceHolders(); + this.#hideHighlightedLastMove(); + + // compute the target piece layout via a temp board (no DOM side-effects, + // and no mutation of the real model yet) + const targetBoard = new Board(); + targetBoard.loadFen(targetFen); + const targetPieces = targetBoard.listPiecePositions(); + + this.#animatePiecesTo(targetPieces, 200, () => { + // commit the new board state in the model + this.#board.loadFen(targetFen); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#afterDrawPositionsListeners.forEach(listener => listener()); + }); + } + + outputFen() { + return this.#board.outputFen(); + } + + /** + * Returns a copy of the underlying (non-GUI) {@link Board}, useful when + * callers need to inspect/query the board model without touching the DOM. + * + * A copy is returned rather than the internal instance to preserve + * encapsulation (so callers can't mutate the BoardGui's state directly). + * + * @return {Board} + */ + get board() { + return this.#board.copy(); + } + + /** + * Change which color is to play next (doesn't alter the pieces on the board). + * + * @param color {string} + */ + forceColorToPlay(color) { + this.#board.forceColorToPlay(color); + } + + get isPlayerMoveEnabled() { + return this.#isPlayerMoveEnabled; + } + + set isPlayerMoveEnabled(value) { + if (value) { + this.enablePlayerMove(); + } else { + this.disablePlayerMove(); + } + } + + disablePlayerMove() { + this.#isPlayerMoveEnabled = false; + this.#hideAllPiecePlaceHolders(); + this.#hideAllDraggableCursors(); + } + + enablePlayerMove() { + this.#isPlayerMoveEnabled = true; + this.#resetDraggableCursors(); + } + + /** + * @param listener {function} + */ + addAfterMoveListener(listener) { + this.#afterMoveListeners.push(listener); + } + + /** + * @param listener {function} + */ + addAfterDrawPositionsListener(listener) { + this.#afterDrawPositionsListeners.push(listener); + } + + /** + * @param listener {function(string)} + */ + addAfterFlipListener(listener) { + this.#afterFlipListeners.push(listener); + } + + clearAllAfterMovesListeners() { + this.#afterMoveListeners = []; + } + + /** + * @param move {HalfMove} + * @param highLastMove {boolean} if true, the last move will be highlighted with a green dashed circle + * @param afterMoveCallback {function} + */ + registerOpponentMove(move, highLastMove, afterMoveCallback) { + this.#hideHighlightedLastMove(); + this.#animateMoveViaDiff(move, () => { + if (highLastMove) { + this.highlightLastMove(move); + } + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#clickSound + .play() + .catch(e => { + // ignored, spam error in console in dev + }); + if (afterMoveCallback != null) { + afterMoveCallback() + } + }); + } + + /** + * Animate the DOM pieces from the currently displayed position (as + * described by {@link #board}) to {@code targetPieces}. Does NOT mutate the + * underlying board model; the caller is expected to update the model in + * {@code onDone}. + * + * Diff strategy (mirrors lichess/chessground): + * - pieces of the same pieceChar on the same square: leave image alone + * - current-board misplacements and target-board misplacements of the + * same pieceChar: paired greedily by shortest distance and animated + * with a CSS transform transition + * - unpaired leftover current-board misplacements: faded out + * - unpaired leftover target-board misplacements: faded in + * + * @param targetPieces {PieceAtPosition[]} + * @param durationMs {number} + * @param onDone {function} + */ + #animatePiecesTo(targetPieces, durationMs, onDone) { + const currentPieces = this.#board.listPiecePositions(); + + const posKey = p => `${p.toUci()}`; + const currentMap = new Map(currentPieces.map(pp => [posKey(pp.position), pp])); + const targetMap = new Map(targetPieces.map(pp => [posKey(pp.position), pp])); + + // pieces currently drawn on the board that don't match the target + // position (either the target expects a different piece on that + // square, or no piece at all). Each will be either animated to a + // square in targetBoardMisplacements or faded out. + /** @type {PieceAtPosition[]} */ + const currentBoardMisplacements = []; + for (const [positionKey, currentPp] of currentMap) { + const targetPp = targetMap.get(positionKey); + if (targetPp && targetPp.piece.pieceChar === currentPp.piece.pieceChar) { + // same piece on same square: leave image alone + targetMap.delete(positionKey); + } else { + currentBoardMisplacements.push(currentPp); + } + } + + // pieces required by the target position that aren't already correctly + // drawn on the current board (the square is empty or holds a different + // piece). Each will be either the destination of an animated move from + // currentBoardMisplacements or faded in as a new image. + /** @type {PieceAtPosition[]} */ + const targetBoardMisplacements = Array.from(targetMap.values()); + + // pair movers greedily by closest same-pieceChar distance + const calculateDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + const candidatePairs = []; + currentBoardMisplacements.forEach((c, i) => { + targetBoardMisplacements.forEach((t, j) => { + if (t.piece.pieceChar === c.piece.pieceChar) { + candidatePairs.push({i, j, distance: calculateDistance(c.position, t.position)}); + } + }); + }); + candidatePairs.sort((a, b) => a.distance - b.distance); + + // indices into currentBoardMisplacements / targetBoardMisplacements that + // have already been claimed by an animated move, so we don't pair the + // same piece twice while walking the distance-sorted candidate list. + /** @type {Set} */ + const usedCurrent = new Set(); + /** @type {Set} */ + const usedTarget = new Set(); + const animatedMoves = []; + for (const {i, j} of candidatePairs) { + if (!usedCurrent.has(i) && !usedTarget.has(j)) { + usedCurrent.add(i); + usedTarget.add(j); + animatedMoves.push({ + from: currentBoardMisplacements[i].position, + to: targetBoardMisplacements[j].position, + pieceChar: currentBoardMisplacements[i].piece.pieceChar, + }); + } + } + const toRemove = currentBoardMisplacements.filter((_, i) => !usedCurrent.has(i)); + const toAdd = targetBoardMisplacements.filter((_, j) => !usedTarget.has(j)); + + // nothing to animate: bail out early + if (animatedMoves.length === 0 && toRemove.length === 0 && toAdd.length === 0) { + onDone(); + return; + } + + const transitionStr = `transform ${durationMs}ms ease, opacity ${durationMs}ms ease`; + const getSquareRect = pos => document + .getElementById(this.#positionToElementId('square', pos)) + .getBoundingClientRect(); + + const cleanups = []; + + // animate movers: translate the source-square image toward the target square + animatedMoves.forEach(animatedMove => { + const img = document.getElementById(this.#positionToElementId('image', animatedMove.from)); + if (img == null) return; + const fromRect = getSquareRect(animatedMove.from); + const toRect = getSquareRect(animatedMove.to); + const dx = toRect.left - fromRect.left; + const dy = toRect.top - fromRect.top; + + // rename id so it doesn't conflict with drawPieceAt on the target square + img.id = `animating-${Math.random().toString(36).slice(2)}`; + img.style.transition = transitionStr; + // must stay above .crosshair-square (z-index: 80) and resting + // pieces (z-index: 100) so the moving piece isn't painted under + // crosshairs of neighboring squares it flies over + img.style.zIndex = '110'; + img.style.pointerEvents = 'none'; + + // trigger on next frame so the transition actually runs + requestAnimationFrame(() => { + img.style.transform = `translate(${dx}px, ${dy}px)`; + }); + + cleanups.push(() => img.remove()); + }); + + // fade out leftover current pieces (captures / removals) + toRemove.forEach(rm => { + const img = document.getElementById(this.#positionToElementId('image', rm.position)); + if (img == null) return; + img.id = `fading-${Math.random().toString(36).slice(2)}`; + img.style.transition = transitionStr; + img.style.pointerEvents = 'none'; + requestAnimationFrame(() => { + img.style.opacity = '0'; + }); + cleanups.push(() => img.remove()); + }); + + // fade in leftover target pieces (newly placed on a previously empty square) + const addedImages = []; + toAdd.forEach(add => { + this.#drawPieceAt(add.piece.pieceChar, add.position); + const img = document.getElementById(this.#positionToElementId('image', add.position)); + if (img != null) { + img.style.opacity = '0'; + img.style.transition = transitionStr; + addedImages.push(img); + requestAnimationFrame(() => { + img.style.opacity = '1'; + }); + } + }); + + setTimeout(() => { + // drop transient animation images + cleanups.forEach(c => c()); + + // draw the final image at each move's target square + animatedMoves.forEach(mv => { + const existing = document.getElementById(this.#positionToElementId('image', mv.to)); + if (existing != null) existing.remove(); + this.#drawPieceAt(mv.pieceChar, mv.to); + }); + + // clear transient styles on faded-in images + addedImages.forEach(img => { + img.style.transition = ''; + img.style.opacity = ''; + }); + + onDone(); + this.#forceSafariLayoutRefresh(); + }, durationMs + 20); + } + + /** + * @param move {HalfMove} + * @param animate {boolean} + */ + registerMoveIfLegal(move, animate = true) { + if (!this.#board.isLegalMove(move)) { + console.log(move + ' is not a legal move'); + return; + } + + const afterCommit = () => { + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + for (let i = 0; i < this.#afterMoveListeners.length; i++) { + this.#afterMoveListeners[i](move); + } + }; + + if (animate) { + // the diff-based animator will commit the model via its onDone + this.#animateMoveViaDiff(move, afterCommit); + } else { + const piece = this.#board.getPieceAt(move.from); + this.#board.registerMove(move); + this.#drawMove(piece.pieceChar, move); + afterCommit(); + } + } + + /** + * Animate a single move by delegating to the generic diff-based animator + * {@link #animatePiecesTo}. The target position is computed on a copy of + * the current board so the real model is only mutated once the animation + * completes. This replaces the old xiangqi-shape-aware setInterval based + * {@code #animateMove}. + * + * @param move {HalfMove} + * @param onDone {function} + */ + #animateMoveViaDiff(move, onDone) { + const targetBoard = this.#board.copy(); + targetBoard.registerMove(move); + const targetPieces = targetBoard.listPiecePositions(); + this.#animatePiecesTo(targetPieces, 200, () => { + this.#board.registerMove(move); + onDone(); + }); + } + + /** + * + * @param pieceChar {string} + * @param position {Position} + * @param enforcePlacementRules {boolean} + */ + addPieceAt(pieceChar, position, enforcePlacementRules) { + this.#board.addPieceAt(pieceChar, position, enforcePlacementRules); + this.#unDrawPiece(position); + this.#drawPieceAt(pieceChar, position); + } + + /** + * @param position {Position} + */ + removePieceFrom(position) { + this.#board.removePieceFrom(position); + this.#unDrawPiece(position); + } + + // FIXME: feels like this could be private? encapsulated? + updateHighlightedChecks() { + this.#hideAllCheckHighlights(); + + if (!this.#options.mini || this.#options.forceRenderChecks) { + const redGeneral = this.#board.findGeneral(Color.RED); + const blackGeneral = this.#board.findGeneral(Color.BLACK); + + // generals may be absent (e.g. when flip() is called before any + // FEN has been loaded, on an empty board); nothing to highlight. + if (redGeneral == null || blackGeneral == null) { + return; + } + + if (this.#board.isInCheck(Color.RED)) { + if (this.#board.isCheckmate(Color.RED)) { + this.disablePlayerMove(); + this.#hideHighlightedLastMove(); + this.#highlightCheckMate(redGeneral.position); + } else { + this.#highlightCheck(redGeneral.position); + } + } + + if (this.#board.isInCheck(Color.BLACK)) { + if (this.#board.isCheckmate(Color.BLACK)) { + this.disablePlayerMove(); + this.#hideHighlightedLastMove(); + this.#highlightCheckMate(blackGeneral.position); + } else { + this.#highlightCheck(blackGeneral.position); + } + } + } + } + + /** + * @param move {HalfMove} + */ + highlightLastMove(move) { + this.#highlightWithClass(move.from, 'highlighted-last-move'); + this.#highlightWithClass(move.to, 'highlighted-last-move'); + } + + #hideAllCheckHighlights() { + Position + .getAll() + .map(position => this.#locateLegalMovePlaceHolderAt(position)) + .map(placeHolder => placeHolder.classList) + .forEach(classList => classList.remove('highlighted-check', 'highlighted-checkmate')); + } + + #hideHighlightedLastMove() { + Position + .getAll() + .map(position => this.#locateLegalMovePlaceHolderAt(position)) + .map(placeHolder => placeHolder.classList) + .forEach(classList => classList.remove('highlighted-last-move')); + } + + /** + * @param position {Position} + */ + #highlightCheck(position) { + this.#highlightWithClass(position, 'highlighted-check'); + } + + /** + * @param position {Position} + */ + #highlightCheckMate(position) { + this.#highlightWithClass(position, 'highlighted-checkmate'); + } + + /** + * @param position {Position} + * @param className {string} + */ + #highlightWithClass(position, className) { + this.#locateLegalMovePlaceHolderAt(position).classList.add(className); + } + + /** + * @returns {boolean} + */ + isInPlaceHolderMode() { + return this.#boardContainer.classList.contains('board-container-placeholder'); + } + + enablePlaceholderMode() { + this.#boardContainer.classList.add('board-container-placeholder'); + } + + disablePlaceholderMode() { + this.#boardContainer.classList.remove('board-container-placeholder'); + } + + /** + * Clear the SVG layer, which atm only render analytics arrows. + */ + clearSvg() { + if (this.#options.svg) { + const svg = document.getElementById('board-svg'); + svg.innerHTML = ''; + svg.innerHTML += ARROW_MARKERS; + } + } + + /** + * @param move {HalfMove} + * @param type {string} + */ + addEngineArrow(move, type) { + if (this.#options.svg) { + switch (type) { + case EngineArrowType.PRIMARY: + this.#primaryEngineArrow = move; + break; + case EngineArrowType.SECONDARY: + this.#secondaryEngineArrow = move; + break; + } + + this.#renderSvg(); + } + } + + #renderSvg() { + if (this.#options.svg) { + this.clearSvg(); + const svg = document.getElementById('board-svg'); + if (this.#primaryEngineArrow != null) { + svg.append(...this.#buildMoveArrow(this.#primaryEngineArrow, EngineArrowType.PRIMARY)); + } + if (this.#secondaryEngineArrow != null) { + svg.append(...this.#buildMoveArrow(this.#secondaryEngineArrow, EngineArrowType.SECONDARY)); + } + } + } + + /** + * @param move {HalfMove} + * @param type {string} + * @returns {SVGGeometryElement[]} + */ + #buildMoveArrow(move, type) { + function isSmallMove(move) { + return (move.isVertical() && Math.abs(move.to.y - move.from.y) <= 1) || + (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1) + } + + function isKnightMove(move) { + const dx = Math.abs(move.to.x - move.from.x); + const dy = Math.abs(move.to.y - move.from.y); + return (dx === 1 && dy === 2) || (dx === 2 && dy === 1); + } + + const boardBounds = this.#boardContainer.getBoundingClientRect(); + + const square1 = document.getElementById(this.#positionToElementId('square', move.from)); + const square2 = document.getElementById(this.#positionToElementId('square', move.to)); + const bound1 = square1.getBoundingClientRect(); + const bound2 = square2.getBoundingClientRect(); + + const x1 = (bound1.left + bound1.width / 2) - boardBounds.left; + const y1 = (bound1.top + bound1.height / 2) - boardBounds.top; + const x2 = (bound2.left + bound2.width / 2) - boardBounds.left; + const y2 = (bound2.top + bound2.height / 2) - boardBounds.top; + + // stroke width scales with the square size so the arrow looks consistent across viewports + // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered) + const strokeWidth = Math.min(16, bound1.width * 0.16); + + let pathD, midpointX, midpointY; + + if (isKnightMove(move)) { + // draw an elbowed arrow matching the horse's actual movement: + // segment 1: orthogonal (straight line, 1 square) — the horse's "leg" + // segment 2: diagonal (1 square) — the horse's diagonal step + const sourceReduction = bound1.width * 0.40; + // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the + // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly + // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth. + const destReduction = strokeWidth * 1.9; + const dxPx = x2 - x1; + const dyPx = y2 - y1; + + // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty) + function shortenToward(px, py, tx, ty, r) { + const dx = tx - px; + const dy = ty - py; + const d = Math.sqrt(dx * dx + dy * dy); + return [px + (dx / d) * r, py + (dy / d) * r]; + } + + let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY; + + const dyBoardSquares = Math.abs(move.to.y - move.from.y); + + if (dyBoardSquares === 2) { + // vertical-dominant: horse moves 2 squares vertically then 1 diagonally + // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement + elbowX = x1; + elbowY = y1 + dyPx / 2; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } else { + // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally + // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement + elbowX = x1 + dxPx / 2; + elbowY = y1; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } + + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = elbowX; + midpointY = elbowY; + } else { + const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + + // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top) + let reduction; + + // we can not reduce by as much when the arrow is already very small + // otherwise, the direction of the arrow flips and points the wrong way + if (isSmallMove(move)) { + // we remove 40% a square length + reduction = bound1.width * 0.40; + } else { + // we remove 50% a square length + reduction = bound1.width * 0.50; + } + + const reducedDist = dist - reduction; + const ratio = reducedDist / dist; + + const x1Prime = x2 + ratio * (x1 - x2); + const y1Prime = y2 + ratio * (y1 - y2); + const x2Prime = x1 + ratio * (x2 - x1); + const y2Prime = y1 + ratio * (y2 - y1); + + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = (x1Prime + x2Prime) / 2; + midpointY = (y1Prime + y2Prime) / 2; + } + + const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + arrowPath.setAttribute('d', pathD); + arrowPath.style.strokeWidth = `${strokeWidth}px`; + arrowPath.style.fill = 'none'; + arrowPath.style.strokeLinejoin = 'round'; + + switch (type) { + case EngineArrowType.PRIMARY: + arrowPath.style.stroke = PRIMARY_ARROW_COLOR; + arrowPath.style.markerEnd = 'url(#head-primary)'; + break; + case EngineArrowType.SECONDARY: + arrowPath.style.stroke = SECONDARY_ARROW_COLOR; + arrowPath.style.markerEnd = 'url(#head-secondary)'; + break; + } + + // draw number circle + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute('r', (bound1.width * 0.20).toString()); + circle.setAttribute('cx', midpointX.toString()); + circle.setAttribute('cy', midpointY.toString()); + circle.setAttribute('fill', 'white'); + circle.setAttribute('stroke-width', (bound1.width * 0.04).toString()); + + switch (type) { + case EngineArrowType.PRIMARY: + circle.setAttribute('stroke', PRIMARY_ARROW_COLOR); + break; + case EngineArrowType.SECONDARY: + circle.setAttribute('stroke', SECONDARY_ARROW_COLOR); + break; + } + + // draw number text + const numberLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); + numberLabel.setAttribute('x', midpointX.toString()); + numberLabel.setAttribute('y', midpointY.toString()); + numberLabel.setAttribute('text-anchor', 'middle'); + numberLabel.setAttribute('dominant-baseline', 'middle'); + numberLabel.setAttribute('font-size', (bound1.width * 0.25).toString() + 'px'); + numberLabel.setAttribute('font-weight', 'bold'); + + switch (type) { + case EngineArrowType.PRIMARY: + numberLabel.setAttribute('fill', PRIMARY_ARROW_COLOR); + numberLabel.innerHTML = '1'; + break; + case EngineArrowType.SECONDARY: + numberLabel.setAttribute('fill', SECONDARY_ARROW_COLOR); + numberLabel.innerHTML = '2'; + break; + } + + return [arrowPath, circle, numberLabel]; + } + + #handlePieceHolderClickEvent(e, position) { + let onlyClickedSquareAndNoImage = true; + + let images = document.getElementsByClassName('piece-image'); + for (let i = 0; i < images.length; i++) { + if (images[i].contains(e.target)) { + onlyClickedSquareAndNoImage = false; + break; + } + } + + if (onlyClickedSquareAndNoImage) { + this.#clickedOnSquare(position); + } + } + + #clickedOnPiece(position) { + if (this.#isPlayerMoveEnabled) { + let board = this.#board; + let selected = this.#selectedPiecePosition; + + if (selected == null && board.isAllowedToPlayPieceAt(position)) { + // user is selecting the piece + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } else if (Position.areEquals(selected, position)) { + // if the piece user just clicked on is the same as the one he had selected before, + // then user is un-selecting the piece + this.#hideAllPiecePlaceHolders(); + } else if (selected != null && board.containOppositeColors(selected, position)) { + // user is capturing the piece he clicked on + this.registerMoveIfLegal(new HalfMove(selected, position)); + this.#hideAllPiecePlaceHolders(); + } else if (selected != null && board.containSameColors(selected, position) && board.isAllowedToPlayPieceAt(position)) { + // user is selecting another piece + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } + } + } + + #clickedOnSquare(position) { + if (this.#isPlayerMoveEnabled) { + if (this.#selectedPiecePosition != null) { + this.registerMoveIfLegal(new HalfMove(this.#selectedPiecePosition, position)); + this.#hideAllPiecePlaceHolders(); + } + } + } + + /** + * @param pieceChar {string} + * @param move {HalfMove} + */ + #drawMove(pieceChar, move) { + this.#unDrawPiece(move.from); + this.#unDrawPiece(move.to); + this.#drawPieceAt(pieceChar, move.to); + } + + #drawBoard() { + /** + * @param positionType {string} - one of 'bottom-right', 'bottom-left', 'top-right', 'top-left' + * @param drawingPosition {Position} + * @param positions {Position[]} + * @returns {HTMLDivElement|null} + */ + function drawCrosshairForPositionType(positionType, drawingPosition, positions) { + const crosshairToDraw = positions.find(crosshair => Position.areEquals(crosshair, drawingPosition)); + if (crosshairToDraw != null) { + const crosshair = document.createElement('div'); + crosshair.classList.add('crosshair-square', `crosshair-square-${positionType}`); + return crosshair; + } else { + return null; + } + } + + /** + * @param drawingPosition {Position} + * @returns {HTMLDivElement[]} + */ + function drawCrosshairs(drawingPosition) { + const crosshairTypes = [ + {type: 'bottom-right', positions: bottomRightCrosshairs}, + {type: 'bottom-left', positions: bottomLeftCrosshairs}, + {type: 'top-right', positions: topRightCrosshairs}, + {type: 'top-left', positions: topLeftCrosshairs} + ]; + + return crosshairTypes + .map(({type, positions}) => drawCrosshairForPositionType(type, drawingPosition, positions)) + .filter(crosshair => crosshair !== null); + } + + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + // draw rows + let row = document.createElement('div'); + row.className = 'rows'; + + for (let x = 0; x < BOARD_WIDTH; x++) { + let renderPosition; + if (this.#flippedRed) { + renderPosition = new Position(x, y); + } else { + renderPosition = new Position(BOARD_WIDTH - x - 1, BOARD_HEIGHT - y - 1); + } + + const pieceHolder = document.createElement('div'); + pieceHolder.id = this.#positionToElementId('square', renderPosition); + pieceHolder.className = 'piece-holder'; + pieceHolder.addEventListener('click', (e) => { + this.#handlePieceHolderClickEvent(e, renderPosition) + }); + + // drag and drop listeners + pieceHolder.addEventListener('dragover', (e) => e.preventDefault()); + pieceHolder.addEventListener('dragenter', (e) => e.preventDefault()); + pieceHolder.addEventListener('drop', (e) => { + this.#handlePieceHolderDropEvent(e, renderPosition); + }); + + const legalMovePlaceHolder = document.createElement('div'); + legalMovePlaceHolder.id = this.#positionToElementId('legal_move', renderPosition); + pieceHolder.appendChild(legalMovePlaceHolder); + + if (y === 5) { + if (x === 0) { + const river = buildDivWithClass('large-river'); + + if (!this.#options.mini) { + const riverOfTheChuContainer = buildDivWithClass('river-of-the-chu'); + riverOfTheChuContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/river-of-the-chu-brown.png`)); + + const borderOfTheHanContainer = buildDivWithClass('border-of-the-han'); + borderOfTheHanContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/border-of-the-han-brown.png`)); + + river.append( + riverOfTheChuContainer, + borderOfTheHanContainer + ); + } + + pieceHolder.append(river); + } + } else { + // draw row of visible squares + if (x < BOARD_WIDTH - 1 && y > 0) { + const visibleSquare = buildDivWithClass('visible-square'); + pieceHolder.appendChild(visibleSquare); + + if ((x === 3 && y === 2) || (x === 4 && y === 1) || (x === 3 && y === 9) || (x === 4 && y === 8)) { + if (this.#options.mini) { + visibleSquare.innerHTML = diagonalDescendingMini; + } else { + visibleSquare.innerHTML = diagonalDescending; + } + } + if ((x === 3 && y === 1) || (x === 4 && y === 2) || (x === 3 && y === 8) || (x === 4 && y === 9)) { + if (this.#options.mini) { + visibleSquare.innerHTML = diagonalRisingMini; + } else { + visibleSquare.innerHTML = diagonalRising; + } + } + + if (x === BOARD_WIDTH - 2) { + visibleSquare.classList.add('visible-square-last-file'); + } + + // draw crosshairs + drawCrosshairs(new Position(x, y)) + .forEach(element => visibleSquare.append(element)); + } + } + row.appendChild(pieceHolder); + } + this.#boardContainer.appendChild(row); + } + + // add bottom border to last row of visible squares + if (this.#flippedRed) { + for (let x = 0; x < BOARD_WIDTH - 1; x++) { + const position = new Position(x, 1); + const square = document.getElementById(this.#positionToElementId('square', position)); + square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row'); + } + } else { + for (let x = BOARD_WIDTH - 1; x > 0; x--) { + const position = new Position(x, BOARD_HEIGHT - 2); + const square = document.getElementById(this.#positionToElementId('square', position)); + square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row'); + } + } + + this.#drawCoordinates(); + + // TODO: hacky + if (this.#isSafari) { + let rows = document.getElementsByClassName('rows'); + for (let i = 0; i < rows.length; i++) { + rows[i].classList.add('safari-rows'); + } + + document.getElementById(this.#options.elementId).classList.add('safari-board-container'); + } + + if (this.#options.svg) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = 'board-svg'; + this.#boardContainer.append(svg); + } + + this.#forceSafariLayoutRefresh(); + } + + // https://stackoverflow.com/questions/9628507/how-can-i-tell-google-translate-to-not-translate-a-section-of-a-website + #drawCoordinates() { + function buildFileDiv(label, cssClass, visible) { + let span = document.createElement('span'); + span.classList.add('coordinates-labels', 'notranslate'); + span.innerHTML = label; + if (visible) { + span.style.visibility = 'visible'; + } else { + span.style.visibility = 'hidden'; + } + + let div = document.createElement('div'); + div.className = cssClass; + div.appendChild(span); + return div; + } + + if (this.#options.showCoordinates) { + const orientation = this.#options.coordinatesOrientation; + const isSettingEnabled = orientation !== null; + // when the user has disabled coordinates we still reserve the space (hidden labels), + // so we must pick an arbitrary orientation for the (invisible) labels: + const isWfxOriented = orientation !== CoordinatesOrientation.UCI; + + // draw top file coordinates + if (isWfxOriented) { + let topCoordinatesY = BOARD_HEIGHT - 1; + if (!this.#flippedRed) { + topCoordinatesY = 0; + } + + for (let x = 0; x < BOARD_WIDTH; x++) { + // actual text + let label = '?'; + if (this.#flippedRed) { + label = (x + 1).toString(); + } else { + label = (BOARD_WIDTH - x).toString(); + } + + let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled); + let squareId = this.#positionToElementId('square', new Position(x, topCoordinatesY)); + document.getElementById(squareId).appendChild(div); + } + } + + // draw bottom file coordinates + let bottomCoordinatesY = 0; + if (!this.#flippedRed) { + bottomCoordinatesY = BOARD_HEIGHT - 1; + } + for (let x = 0; x < BOARD_WIDTH; x++) { + // actual text + let label = '?'; + if (isWfxOriented) { + if (this.#flippedRed) { + label = (BOARD_WIDTH - x).toString(); + } else { + label = (x + 1).toString(); + } + } else { + label = UCI_LETTER[x]; + } + + let div = buildFileDiv(label, 'file-coordinates-bottom', isSettingEnabled); + let squareId = this.#positionToElementId('square', new Position(x, bottomCoordinatesY)); + document.getElementById(squareId).appendChild(div); + } + + // draw right-side row coordinates + if (!isWfxOriented) { + let rightCoordinatesX = BOARD_WIDTH - 1; + if (!this.#flippedRed) { + rightCoordinatesX = 0; + } + for (let y = 0; y < BOARD_HEIGHT; y++) { + let span = document.createElement('span'); + span.classList.add('coordinates-labels', 'notranslate'); + span.innerHTML = (y + 1).toString(); + if (isSettingEnabled) { + span.style.visibility = 'visible'; + } else { + span.style.visibility = 'hidden'; + } + + let div = document.createElement('div'); + div.className = 'rows-coordinates-right'; + div.appendChild(span); + let squareId = this.#positionToElementId('square', new Position(rightCoordinatesX, y)); + document.getElementById(squareId).appendChild(div); + } + } + } + } + + #drawPieces() { + this.#board + .listPiecePositions() + .forEach(piecePosition => this.#drawPiece(piecePosition)); + } + + /** + * Resolves the URL of the image for a given piece char, using this board's + * configured `assetsBaseUrl` and `pieceStyle` options. Exposed so external + * widgets that share this board's visual identity (e.g. the position + * editor's piece palette) can use the exact same image set. + * + * @param pieceChar {string} + * @return {string} + */ + getPieceImageSource(pieceChar) { + const style = this.#options.pieceStyle.toLowerCase(); + return `${this.#options.assetsBaseUrl}/images/pieces/${style}/${pieceImageNames.get(pieceChar)}`; + } + + /** + * @param piecePosition {PieceAtPosition} + */ + #drawPiece(piecePosition) { + this.#drawPieceAt(piecePosition.piece.pieceChar, piecePosition.position); + } + + /** + * @param pieceChar {string} + * @param position {Position} + */ + #drawPieceAt(pieceChar, position) { + let square = document.getElementById(this.#positionToElementId('square', position)); + let img = document.createElement('img'); + img.id = this.#positionToElementId('image', position); + img.className = 'piece-image'; + img.setAttribute('src', this.getPieceImageSource(pieceChar)); + img.addEventListener('click', () => this.#clickedOnPiece(position)); + img.addEventListener('dragstart', (e) => this.#dragStart(e, position)); + img.addEventListener('dragend', () => this.#dragEnd()); + square.prepend(img); + } + + /** + * Forces Safari to recalculate layout and positioning of board elements. + * + * Safari has known issues with layout calculations, particularly with CSS transforms, + * absolute positioning, and element positioning after DOM updates. This method applies + * various techniques to force Safari to trigger reflows and recalculate element positions. + * + * The method uses multiple approaches: + * - Hardware acceleration triggers via translateZ(0) transforms + * - Forced reflows by accessing offsetHeight + * - Position property manipulation on squares and piece images + * + * Called automatically after board state changes like moves, flips, and piece placement + * to ensure visual elements are correctly positioned in Safari. + * + * @private + */ + #forceSafariLayoutRefresh() { + if (this.#isSafari) { + setTimeout(() => { + const container = this.#boardContainer; + + // force container reflow + container.style.transform = 'translateZ(0)'; + container.offsetHeight; + container.style.transform = ''; + + // force reflow on all squares + container + .querySelectorAll('.piece-holder') + .forEach(square => { + square.style.position = 'relative'; + square.offsetHeight; + square.style.position = ''; + }); + + // force reflow on all piece images + container + .querySelectorAll('.piece-image') + .forEach(piece => { + piece.style.transform = 'translateZ(0)'; + piece.offsetHeight; + piece.style.transform = ''; + }); + }, 0); + } + } + + /** + * @param e {DragEvent} + * @param position {Position} + */ + #dragStart(e, position) { + console.log('drag start'); + + if (this.isPlayerMoveEnabled && this.#board.getColorAt(position) === this.#board.getColorToPlay()) { + e.dataTransfer.setData('text/plain', position.toUci()); + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } else { + e.preventDefault(); + } + } + + #dragEnd() { + console.log('drag end'); + + this.#hideAllPiecePlaceHolders(); + } + + /** + * @param e {DragEvent} + * @param to {Position} + */ + #handlePieceHolderDropEvent(e, to) { + const uci = e.dataTransfer.getData('text/plain'); + const from = Position.parseUci(uci); + const move = new HalfMove(from, to); + this.registerMoveIfLegal(move, false); + this.#hideAllPiecePlaceHolders(); + } + + #unDrawAllPieces() { + Position.getAll().forEach(position => this.#unDrawPiece(position)); + } + + /** + * @param position {Position} + */ + #unDrawPiece(position) { + const square = document.getElementById(this.#positionToElementId('square', position)); + // NB: getElementsByClassName returns a live HTMLCollection; convert to a + // static array so removing elements doesn't skip siblings. + htmlCollectionToArray(square.getElementsByClassName('piece-image')) + .forEach(img => square.removeChild(img)); + } + + #hideAllPiecePlaceHolders() { + Position.getAll().forEach(position => { + // in case when the board is a mini board overview on an infinite scroll page + // the placeholder can be null if the miniboard has been discarded + // which would throw a bunch of errors in the console if we don't check for nullability + const placeHolder = this.#locateLegalMovePlaceHolderAt(position); + if (placeHolder != null) { + placeHolder.classList.remove( + 'legal-move-place-holder', + 'selected-piece', + 'possible-capture', + 'highlighted-last-move' + ); + } + }); + + this.#selectedPiecePosition = null; + this.#currentShowingLegalMovesFor = null; + } + + #showSelectedPositionAndLegalMovesPlaceHolders(position) { + // remove previous placeholders + this.#hideAllPiecePlaceHolders(); + + // show selected piece + this.#locateLegalMovePlaceHolderAt(position).classList.add('selected-piece'); + this.#selectedPiecePosition = position; + + // show legal moves and possible captures + this.#showLegalMovesPlaceHolders(position); + } + + /** + * @param position {Position} + */ + #showLegalMovesPlaceHolders(position) { + this + .#board + .listLegalMovesFrom(position) + .map(move => move.to) + .forEach(targetPosition => { + let placeHolder = this.#locateLegalMovePlaceHolderAt(targetPosition); + if (this.#board.containOppositeColors(position, targetPosition)) { + placeHolder.classList.add('possible-capture'); + } else { + placeHolder.classList.add('legal-move-place-holder'); + } + }); + + this.#currentShowingLegalMovesFor = position; + } + + highlightDebugMove(move, color) { + let from = this.#locateLegalMovePlaceHolderAt(move.from); + let to = this.#locateLegalMovePlaceHolderAt(move.to); + from.classList.add('highlighted-debug'); + from.style.backgroundColor = color; + to.classList.add('highlighted-debug'); + to.style.backgroundColor = color; + } + + hideAllDebugHighlight() { + Position.getAll().forEach(position => { + let element = this.#locateLegalMovePlaceHolderAt(position); + element.classList.remove('highlighted-debug'); + element.style.backgroundColor = null; + }); + } + + highlightDynamicMove(move) { + this.#hideAllPiecePlaceHolders(); + let from = this.#locateLegalMovePlaceHolderAt(move.from); + let to = this.#locateLegalMovePlaceHolderAt(move.to); + from.classList.add('highlighted-move'); + to.classList.add('highlighted-move'); + } + + hideAllHighlightedDynamicMoves() { + Position.getAll().forEach(position => { + let element = this.#locateLegalMovePlaceHolderAt(position); + element.classList.remove('highlighted-move'); + }); + } + + clearBoard() { + this.#unDrawAllPieces(); + this.#hideAllPiecePlaceHolders(); + this.#board.clearBoard(); + } + + /** + * @param color {string|null} + */ + flipToColor(color) { + if (color != null && ((this.#flippedRed && color === Color.BLACK) || (!this.#flippedRed && color === Color.RED))) { + this.flip(); + } + } + + flip() { + this.#boardContainer.innerHTML = ''; + this.#flippedRed = !this.#flippedRed; + this.#drawBoard(); + this.#drawPieces(); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + + const newColor = this.#flippedRed ? Color.RED : Color.BLACK; + this.#afterFlipListeners.forEach(listener => listener(newColor)); + this.#forceSafariLayoutRefresh(); + } + + /** + * + * @param moveFormat {string} + */ + updateMoveFormat(moveFormat) { + // easy solution: complete redraw (TODO: can more subtle) + this.#boardContainer.innerHTML = ''; + this.#drawBoard(); + this.#drawPieces(); + } + + reRenderPieces() { + this.#unDrawAllPieces(); + this.#drawPieces(); + } + + /** + * Update the piece style and re-render the pieces. Needed because the + * options object is otherwise frozen and the piece style can be changed at + * runtime by the user (via the settings menu). + * + * @param pieceStyle {string} one of {@link PieceStyleSetting} + */ + updatePieceStyle(pieceStyle) { + if (this.#options.pieceStyle === pieceStyle) { + return; + } + this.#options = Object.freeze({...this.#options, pieceStyle}); + this.reRenderPieces(); + } + + /** + * @return {boolean} + */ + toggleShowCoordinates() { + let areVisible = this.#areCoordinatesVisible(); + this.#renderShowCoordinatesSetting(!areVisible); + return !areVisible; + } + + /** + * @param show {boolean} + */ + #renderShowCoordinatesSetting(show) { + let labels = document.getElementsByClassName('coordinates-labels'); + for (let label of labels) { + label.style.visibility = show ? 'visible' : 'hidden'; + } + } + + #areCoordinatesVisible() { + let labels = document.getElementsByClassName('coordinates-labels'); + for (let label of labels) { + if (label.style.visibility === 'hidden') { + return false; + } + } + return true; + } + + #hideAllDraggableCursors() { + this.#board + .listPiecePositions() + .forEach(pieceAtPosition => { + const imageId = this.#positionToElementId('image', pieceAtPosition.position); + const image = document.getElementById(imageId); + // image may be momentarily absent while an animation is in + // flight (source-square images are renamed to 'animating-xxx' + // until the animation commits). In that case there's nothing + // to update here; the animation's onDone will re-run cursor + // bookkeeping via #resetDraggableCursors(). + if (image != null) { + image.classList.remove('piece-image-can-move'); + } + }); + } + + #resetDraggableCursors() { + const isMated = () => { + try { + return this.#board.isMated(); + } catch (e) { + // FIXME: happens when re-loading a game where it's your turn to play and enablePlayerMove is called + console.warn('error while checking if mated'); + return false; + } + } + + this.#hideAllDraggableCursors(); + + if (this.isPlayerMoveEnabled && !isMated()) { + const colorToPlay = this.#board.getColorToPlay(); + + this.#board + .listPiecePositions() + .filter(piecePosition => colorToPlay === piecePosition.pieceColor) + .map(piecePosition => piecePosition.position) + .filter(position => this.#board.listLegalMovesFrom(position).length > 0) + .forEach(position => { + const imageId = this.#positionToElementId('image', position); + const image = document.getElementById(imageId); + // see #hideAllDraggableCursors: image may be absent + // mid-animation (ID temporarily renamed). + if (image != null) { + image.classList.add('piece-image-can-move'); + } + }); + } + } + + /** + * @param position {Position} + * @returns {HTMLElement} + */ + #locateLegalMovePlaceHolderAt(position) { + return document.getElementById(this.#positionToElementId('legal_move', position)); + } + + /** + * @param position {Position} + * @param prefix {string} + * @returns {string} + */ + #positionToElementId(prefix, position) { + return boardPositionToElementId(this.#options.elementId, prefix.toLowerCase(), position); + } + +} + +/** + * + * @param boardId {string} + * @param prefix {string} + * @param position {Position} + */ +function boardPositionToElementId(boardId, prefix, position) { + return `${boardId}-${prefix.toLowerCase()}-${position.x}-${position.y}`; +} + +/** + * @param elementId {string} + * @returns {Position} + */ +function parsePositionFromElementId(elementId) { + const split = elementId.split('-'); + const x = Number(split[split.length - 2]); + const y = Number(split[split.length - 1]); + return new Position(x, y); +} + +/** + * Adds a miniature board that appears on hover for an element + * + * @param element {HTMLElement} - The element to attach hover listeners to + * @param gameId {string} - Unique identifier for this miniboard + * @param fen {string} - FEN string representing the board position + * @param playerColor {string} - Color to flip the board to + * @param lazy {boolean} - If true, only create the board on first mouseenter (default: false) + * @returns {HTMLElement|null} - The created miniboard div (null if lazy and not yet created) + */ +function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) { + const LEFT_MARGIN = 12; + const MINI_BOARD_HEIGHT = 256 / 0.9; + + const miniBoardId = `mini-board-overview-${gameId}`; + let miniBoardDiv = null; + + function createBoard() { + if (miniBoardDiv) return; + + miniBoardDiv = document.createElement('div'); + miniBoardDiv.id = miniBoardId; + miniBoardDiv.classList.add( + 'board-container', + 'mini-board-container', + 'mini-board-overview' + ); + + document.body.appendChild(miniBoardDiv); + + const options = { + elementId: miniBoardId, + showCoordinates: false, + mini: true, + forceRenderChecks: true, + }; + + const boardGui = new BoardGui(options); + boardGui.loadFen(fen); + boardGui.flipToColor(playerColor); + boardGui.updateHighlightedChecks(); + } + + // Create board immediately if not lazy + if (!lazy) { + createBoard(); + } + + // listeners + function showMiniboard() { + // Create board on first hover if lazy + if (!miniBoardDiv) { + createBoard(); + } + + const gameItemRect = element.getBoundingClientRect(); + const left = gameItemRect.right + LEFT_MARGIN + window.scrollX; + const top = gameItemRect.top + window.scrollY + (gameItemRect.height / 2) - (MINI_BOARD_HEIGHT / 2); + miniBoardDiv.style.top = `${top}px`; + miniBoardDiv.style.left = `${left}px`; + miniBoardDiv.style.display = 'block'; + } + + function hideMiniboard() { + if (miniBoardDiv) { + miniBoardDiv.style.display = 'none'; + } + } + + element.addEventListener('mouseenter', showMiniboard); + element.addEventListener('mouseleave', hideMiniboard); + + return miniBoardDiv; +} diff --git a/webapp/src/main/resources/public/dist/0.0.3/board.css b/webapp/src/main/resources/public/dist/0.0.3/board.css new file mode 100644 index 000000000..08a524a80 --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.3/board.css @@ -0,0 +1,414 @@ +/* + * 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 . + */ + +:root { + --crosshair-margin: 5%; +} + +#board-svg { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + z-index: 110; + pointer-events: none; +} + +.board-container { + margin: auto; + aspect-ratio: 0.9; + background-color: #876e59; + padding: 4%; + text-align: initial; + touch-action: manipulation; + position: relative; + /* Matches the shared .basic-box / .info-box rule in style.css so board.css + renders identically when used standalone (as a library). */ + box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em; + border: 2px solid #323232; + border-radius: 3px; +} + +.safari-board-container { + display: flex; + display: -webkit-box; +} + +.board-outer-container { + position: relative; + width: 80%; +} + +.board-container-placeholder { + background-color: #808080; +} + +.board-container-placeholder .visible-square, +.board-container-placeholder .large-river { + background-color: #dbd8d8; +} + +.board-container-placeholder .piece-image { + filter: grayscale(90%); +} + +.board-container-placeholder .coordinates-labels { + color: #dbd8d8; +} + +a.board-outer-container-link-mask { + position: absolute; + aspect-ratio: 0.9; + z-index: 150; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + top: 0; + left: 0; + width: 100%; +} + +.rows { + height: 10%; +} + +/* TODO: could be replaced by '.safari-board .rows' */ +.safari-rows { + height: 9%; + display: inline-block; + vertical-align: top; +} + +.piece-holder { + float: left; + position: relative; + height: 100%; + aspect-ratio: 1; +} + +.piece-holder .piece-image { + width: 95%; + height: 95%; + margin: 2.5%; + position: absolute; + border-radius: 100%; + box-shadow: rgba(0, 0, 0, 0.24) 0px 2px 5px; + z-index: 100; +} + +.piece-holder .piece-image-can-move { + cursor: move; +} + +.piece-holder .piece-image-moving { + z-index: 110; +} + +.crosshair-square { + position: absolute; + border: 2px solid rgba(10, 10, 10, 0.8); + /*background-color: rgba(0, 0, 227, 0.47);*/ + width: 19%; + height: 19%; + z-index: 80; + box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +.crosshair-square-bottom-right { + bottom: var(--crosshair-margin); + right: var(--crosshair-margin); + border-top-width: 0; + border-left-width: 0; +} + +.crosshair-square-bottom-left { + bottom: var(--crosshair-margin); + left: var(--crosshair-margin); + border-top-width: 0; + border-right-width: 0; +} + +.crosshair-square-top-right { + top: var(--crosshair-margin); + right: var(--crosshair-margin); + border-bottom-width: 0; + border-left-width: 0; +} + +.crosshair-square-top-left { + top: var(--crosshair-margin); + left: var(--crosshair-margin); + border-bottom-width: 0; + border-right-width: 0; +} + +/* 2px because it's the border size of visible-square */ +.safari-board-container .visible-square .crosshair-square-bottom-right, +.safari-board-container .visible-square .crosshair-square-top-right { + right: calc(var(--crosshair-margin) + 2px); +} + +.safari-board-container .visible-square-last-file .crosshair-square-bottom-right, +.safari-board-container .visible-square-last-file .crosshair-square-top-right { + right: var(--crosshair-margin); +} + +/* 1px because it's the border size of visible-square */ +.safari-mini-board-container .visible-square .crosshair-square-bottom-right, +.safari-mini-board-container .visible-square .crosshair-square-top-right { + right: calc(var(--crosshair-margin) + 1px); +} + +.safari-mini-board-container .visible-square-last-file .crosshair-square-bottom-right, +.safari-mini-board-container .visible-square-last-file .crosshair-square-top-right { + right: var(--crosshair-margin); +} + +.visible-square, .large-river { + background-color: #ead1af; + height: 100%; + margin: 50%; + aspect-ratio: 1; + border: 2px solid rgba(10, 10, 10, 0.8); + border-right-width: 0; + border-bottom-width: 0; + position: absolute; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +.large-river { + aspect-ratio: 8; + height: 100%; +} + +.visible-square-last-file, +.large-river { + border-right-width: 2px; +} + +.visible-square-last-row { + border-bottom-width: 2px; +} + +.river-of-the-chu, +.border-of-the-han { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 48%; +} + +.river-of-the-chu { + float: left; +} + +.border-of-the-han { + float: right; +} + +.river-of-the-chu img, +.border-of-the-han img { + opacity: 60%; + height: 70%; +} + +.mini-board-container .piece-image { + box-shadow: none; +} + +.mini-board-container .visible-square, +.mini-board-container .large-river { + border-width: 1px; + border-bottom-width: 0; + border-right-width: 0; +} + +.mini-board-container .visible-square-last-row { + border-bottom-width: 1px; +} + +.mini-board-container .visible-square-last-file, +.mini-board-container .large-river { + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-bottom-right { + border-bottom-width: 1px; + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-bottom-left { + border-bottom-width: 1px; + border-left-width: 1px; +} + +.mini-board-container .crosshair-square-top-right { + border-top-width: 1px; + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-top-left { + border-top-width: 1px; + border-left-width: 1px; +} + +.mini-board-overview { + display: none; + position: absolute; + z-index: 700; + max-width: 236px; + width: 236px; + padding: 8px; +} + +.safari-mini-board-container .safari-rows { + height: 10%; +} + +.safari-mini-board-container { + height: 306px; + width: 276px; +} + +.file-coordinates-top, +.file-coordinates-bottom, +.rows-coordinates-right { + font-size: 12px; + color: #ead1af; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} + +.file-coordinates-bottom { + margin-top: 100%; + margin-left: 50%; +} + +.file-coordinates-top { + margin-top: -25%; + margin-left: 40%; +} + +.rows-coordinates-right { + margin-left: 65%; + width: 100%; + height: 100%; +} + +.legal-move-place-holder, +.selected-piece, +.possible-capture, +.highlighted-debug, +.highlighted-move, +.highlighted-check, +.highlighted-checkmate, +.highlighted-last-move { + position: absolute; + border-radius: 100%; + z-index: 10; +} + +.selected-piece, +.possible-capture, +.highlighted-debug, +.highlighted-check { + width: 110%; + height: 110%; + margin: -5%; +} + +.highlighted-checkmate, +.highlighted-move { + width: 120%; + height: 120%; + margin: -10%; +} + +.legal-move-place-holder { + width: 75%; + height: 75%; + margin: 12.5%; +} + +.highlighted-last-move { + width: 115%; + height: 115%; + margin: -7.5%; +} + +.legal-move-place-holder { + background-color: rgba(0, 64, 255, 0.4); +} + +.selected-piece { + background-color: rgba(40, 229, 27, 0.7); +} + +.possible-capture { + background-color: blueviolet; +} + +.highlighted-check { + background-color: orange; +} + +.highlighted-checkmate { + background-color: red; +} + +.highlighted-move { + background-color: rgba(247, 247, 17, 0.79); +} + +.highlighted-last-move { + border: 5px dashed rgba(13, 98, 7, 0.7); + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +/* reactive: hide river/han decorations on narrower viewports */ +@media (max-width: 1400px) { + .river-of-the-chu, + .border-of-the-han { + display: none; + } +} + +/* reactive: enlarge board on small viewports */ +@media (max-width: 1000px) { + .board-outer-container-link-mask, + .board-outer-container { + width: 90%; + } + + /* x2 compared to normal */ + .safari-mini-board-container { + height: 612px; + width: 552px; + } +} diff --git a/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js b/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js new file mode 100644 index 000000000..ce9ab435b --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js @@ -0,0 +1,1405 @@ +/* + * Copyright (C) 2026 Encelade SRL + * Copyright (C) 2026 elephantchess.io + * Copyright (C) 2026 Benoît Vleminckx (benckx) + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +const BOARD_WIDTH = 9; +const BOARD_HEIGHT = 10; +const UCI_LETTER = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']; +const PIECES_CHARS = ['c', 'r', 'n', 'b', 'a', 'k', 'p']; + +const DEFAULT_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 0'; + +// Piece color enum. Lives here (rather than in enums.js) so that xiangqi.js +// can be used standalone (together with board-gui.js) without pulling in the +// rest of the webapp's modules. +const Color = Object.freeze({ + RED: 'RED', + BLACK: 'BLACK' +}); + +/** + * @param char {string} + * @return {boolean} + */ +function isRedPiece(char) { + return char.length === 1 && char.toUpperCase() === char; +} + +/** + * @param char {string} + * @return {boolean} + */ +function isBlackPiece(char) { + return char.length === 1 && char.toLowerCase() === char; +} + +/** + * @param char {string} + * @return {string} + */ +function charToPieceColor(char) { + if (char.length !== 1) { + throw new Error('Not a single char: ' + char); + } else { + if (isRedPiece(char)) { + return Color.RED; + } else if (isBlackPiece(char)) { + return Color.BLACK; + } else { + throw new Error('Illegal piece ' + char); + } + } +} + +function reverseColor(color) { + switch (color.toUpperCase()) { + case Color.RED: + return Color.BLACK; + case Color.BLACK: + return Color.RED; + default: + throw Error('Illegal argument color ' + color); + } +} + +function colorToUci(color) { + switch (color.toUpperCase()) { + case Color.RED: + return 'w'; + case Color.BLACK: + return 'b'; + default: + throw Error('Illegal argument color ' + color); + } +} + +/** + * Reset full count to 0 + */ +function resetFenFullMovesCount(fen) { + let split = fen.split(' '); + return split.slice(0, split.length - 1).join(' ') + ' 0'; +} + +function validateStartFen(fen) { + let board = new Board(); + board.loadFen(fen); + + if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) { + throw new Error('Start position is checkmate'); + } else if (board.isStalemate(Color.RED) || board.isStalemate(Color.BLACK)) { + throw new Error('Start position is stalemate'); + } +} + +/** + * Add full moves count and convert to single string + * @param {string[]} movesAsPgn + */ +function toSingleLinePgn(movesAsPgn) { + let pgn = ''; + for (let i = 0; i < movesAsPgn.length; i++) { + let move = movesAsPgn[i]; + if (i % 2 === 0) { + pgn += (Math.floor(i / 2) + 1) + '. '; + } + pgn += move + ' '; + } + return pgn; +} + +/** + * https://en.wikipedia.org/wiki/Xiangqi#System_3 + * + * @param {HalfMove[]} moves + * @param {boolean} renderCheckIndicators + * @param {string} startFen + * @return {string[]} + */ +function translateMovesToPgn(moves, renderCheckIndicators = true, startFen = DEFAULT_START_FEN) { + let movesAsPgn = []; + + let board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + let pieceFrom = board.getPieceAt(move.from); + let pieceTarget = board.getPieceAt(move.to); + let newPosition = move.to.toAlgebraic(); + + let capture = ''; + if (pieceTarget != null) { + capture = 'x'; + } + let pieceLetter = pieceFrom.pieceChar.toUpperCase(); + let formerPosition = UCI_LETTER[move.from.x]; + let sameFile = move.from.x === move.to.x; + if (pieceLetter === 'K' || sameFile) { + formerPosition = ''; + } + + board.registerMove(move); + + let checkIndicator = ''; + if (renderCheckIndicators) { + if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) { + checkIndicator += '#'; + } else if (board.isInCheck(Color.RED) || board.isInCheck(Color.BLACK)) { + checkIndicator += '+'; + } + } + + let moveString = `${pieceLetter}${formerPosition}${capture}${newPosition}${checkIndicator}`; + movesAsPgn.push(moveString); + }); + + return movesAsPgn; +} + +/** + * @param moves {HalfMove[]} + * @param startFen {string} + * @return {string[]} + */ +function translateMovesToPrefixedAlgebraic(moves, startFen = DEFAULT_START_FEN) { + const formattedMoves = []; + const board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + const pieceChar = board.getPieceAt(move.from).pieceChar.toUpperCase(); + const moveString = `${pieceChar} ${move.toAlgebraic()}`; + board.registerMove(move); + formattedMoves.push(moveString); + }); + + return formattedMoves; +} + +/** + * @param moves {HalfMove[]} + * @param horizontalSeparator {string} can be '=' or '.' + * @param startFen {string} + * @return {string[]} + */ +function translateMovesToWxf(moves, horizontalSeparator, startFen) { + + /** + * File number in WXF format starts at 1 from the right hand of the player + * + * @return {number} + */ + function fileAsWxf(color, file) { + switch (color) { + case Color.RED: + return BOARD_WIDTH - file; + case Color.BLACK: + return file + 1; + default: + throw new Error('Illegal color ' + color); + } + } + + /** + * @param color {string} + * @param move {HalfMove} + * @return {string} e.g. Direction indicator sign (+ or -) + */ + function verticalMoveDirectionChar(color, move) { + if ((color === Color.BLACK && move.from.y > move.to.y) || (color === Color.RED && move.from.y < move.to.y)) { + return '+'; + } else { + return '-'; + } + } + + /** + * @param color {string} + * @param move {HalfMove} + * @return {string} e.g. Direction indicator sign (+ or -) followed by distance + */ + function verticalMove(color, move) { + let direction = verticalMoveDirectionChar(color, move); + let distance = Math.abs(move.from.y - move.to.y); + return `${direction}${distance}`; + } + + /** + * File number or sign (+ or -) + * + * @param board {Board} + * @param move {HalfMove} + * @return {string} + */ + function fileNumberOrSign(board, move) { + let physicalPiece = board.getPieceAt(move.from); + let color = physicalPiece.color; + + let allSimilarPiecesOnFile = board.listPiecePositions().filter(pieceAtPosition => { + return pieceAtPosition.piece.pieceChar === physicalPiece.pieceChar && pieceAtPosition.position.x === move.from.x + }); + + if (allSimilarPiecesOnFile.length === 2) { + let currentFileY; + let otherFileY; + if (move.from.y === allSimilarPiecesOnFile[0].position.y) { + currentFileY = allSimilarPiecesOnFile[0].position.y; + otherFileY = allSimilarPiecesOnFile[1].position.y; + } else { + currentFileY = allSimilarPiecesOnFile[1].position.y; + otherFileY = allSimilarPiecesOnFile[0].position.y; + } + + let isOnTop = (currentFileY > otherFileY && color === Color.RED) + || (currentFileY < otherFileY && color === Color.BLACK); + + return isOnTop ? '+' : '-'; + } + + return fileAsWxf(color, move.from.x).toString(); + } + + let formattedMoves = []; + let board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + let physicalPiece = board.getPieceAt(move.from); + if (physicalPiece == null) { + board.printBoard(); + throw new Error(`no piece at ${move.from.toAlgebraic()}`); + } + let color = physicalPiece.color; + let pieceType = physicalPiece.pieceChar.toUpperCase(); + + let moveStr = ''; + switch (pieceType) { + case 'P': + case 'C': + case 'R': + case 'K': + if (move.isHorizontal()) { + let fileStr = fileNumberOrSign(board, move); + let newFile = fileAsWxf(color, move.to.x); + moveStr = `${fileStr}${horizontalSeparator}${newFile}`; + } else { + moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`; + } + break; + case 'B': + case 'A': + case 'N': + let currentFileStr = fileNumberOrSign(board, move); + let direction = verticalMoveDirectionChar(color, move); + let newFile = fileAsWxf(color, move.to.x); + moveStr = `${currentFileStr}${direction}${newFile}`; + break; + } + + let pieceCharacter; + + switch (pieceType) { + case 'N': + pieceCharacter = 'H'; + break; + case 'B': + pieceCharacter = 'E'; + break; + default: + pieceCharacter = pieceType; + break; + } + + formattedMoves.push(`${pieceCharacter}${moveStr}`); + board.registerMove(move); + }); + + return formattedMoves; +} + +/** + * + * Generic version of the functions above + * + * @param moves {HalfMove[]} + * @param moveFormat {string} + * @param startFen {string} + * @return {string[]} + */ +function translateMovesFormat(moves, moveFormat, startFen) { + switch (moveFormat) { + case MoveFormatSetting.WXF_DOT: + return translateMovesToWxf(moves, '.', startFen); + case MoveFormatSetting.WXF_EQUALS: + return translateMovesToWxf(moves, '=', startFen); + case MoveFormatSetting.PGN: + return translateMovesToPgn(moves, false, startFen); + case MoveFormatSetting.ALGEBRAIC_EN: + return translateMovesToPrefixedAlgebraic(moves, startFen); + default: + throw new Error('Unsupported move format ' + moveFormat); + } +} + +/** + * + * Mainly for debugging purposes + * + * @param allMoves {HalfMove[]} + * @param format {string} + * @param startFen {string} + * @return {string[]} + */ +function safeTranslateMovesFormat(allMoves, format, startFen) { + try { + return translateMovesFormat(allMoves, format, startFen); + } catch (e) { + console.error(e); + console.info(`startFen ${startFen}`); + console.info(`moves [${allMoves.length}] ${allMoves.map(move => move.toAlgebraic())}`); + return allMoves.map(move => move.toAlgebraic()); + } +} + +/** + * + * @param moves {HalfMove[]} + * @param moveFormat {string} + * @param startFen {string} + * @return {null|string} + */ +function translateMovesFormatTakeLast(moves, moveFormat, startFen) { + let translated = translateMovesFormat(moves, moveFormat, startFen); + if (translated.length > 0) { + return translated[translated.length - 1]; + } else { + return null; + } +} + +/** + * @param {HalfMove[]} moves + * @return {string} + */ +function exportMovesToPgnLine(moves) { + return toSingleLinePgn(translateMovesToPgn(moves)); +} + +/** + * @param moves {HalfMove[]} + * @param startFen {string} + * @return {string} + */ +function calculateFen(moves, startFen = DEFAULT_START_FEN) { + const board = new Board(); + board.loadFen(startFen); + moves.forEach(move => board.registerMove(move)); + return board.outputFen(); +} + +class Position { + + static redGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), 0); + static blackGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), BOARD_HEIGHT - 1); + + constructor(x, y) { + this.x = x; + this.y = y; + } + + get file() { + return UCI_LETTER[this.x]; + } + + /** + * @return {number} + */ + get rank() { + return this.y; + } + + existsOnBoard() { + return this.x >= 0 && + this.y >= 0 && + this.x < BOARD_WIDTH && + this.y < BOARD_HEIGHT; + } + + isInRedPalace() { + return this.x >= 3 && + this.x <= 5 && + this.y <= 2; + } + + isInBlackPalace() { + return this.x >= 3 && + this.x <= 5 && + this.y >= BOARD_HEIGHT - 3; + } + + getTop() { + return new Position(this.x, this.y + 1); + } + + getBottom() { + return new Position(this.x, this.y - 1); + } + + getLeft() { + return new Position(this.x - 1, this.y); + } + + getRight() { + return new Position(this.x + 1, this.y); + } + + /** + * @return {Position[]} + */ + getAllTop() { + let squares = []; + for (let y = this.y + 1; y < BOARD_HEIGHT; y++) { + squares.push(new Position(this.x, y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllBottom() { + let squares = []; + for (let y = this.y - 1; y >= 0; y--) { + squares.push(new Position(this.x, y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllLeft() { + let squares = []; + for (let x = this.x - 1; x >= 0; x--) { + squares.push(new Position(x, this.y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllRight() { + let squares = []; + for (let x = this.x + 1; x < BOARD_WIDTH; x++) { + squares.push(new Position(x, this.y)); + } + return squares; + } + + getTopLeft() { + return new Position(this.x - 1, this.y + 1); + } + + getTopRight() { + return new Position(this.x + 1, this.y + 1); + } + + getBottomLeft() { + return new Position(this.x - 1, this.y - 1); + } + + getBottomRight() { + return new Position(this.x + 1, this.y - 1); + } + + isEqualsTo(other) { + return this.x === other.x && this.y === other.y; + } + + /** + * 0-based + */ + toUci() { + return this.file + this.y; + } + + /** + * 1-based + */ + toAlgebraic() { + return this.file + (this.y + 1); + } + + toString() { + return this.toUci(); + } + + /** + * @return {Position[]} + */ + static getAll() { + let positions = []; + for (let x = 0; x < BOARD_WIDTH; x++) { + for (let y = 0; y < BOARD_HEIGHT; y++) { + positions.push(new Position(x, y)); + } + } + return positions; + } + + static areEquals(p1, p2) { + return p1 != null && p2 != null && p1.isEqualsTo(p2); + } + + /** + * @param uci {string} + * @return {Position} + */ + static parseUci(uci) { + if (uci.length !== 2) { + throw new Error('Incorrect position UCI: ' + uci); + } + let x = UCI_LETTER.indexOf(uci[0]) + if (x < 0) { + throw new Error('Incorrect position UCI: ' + uci); + } + if (x >= BOARD_WIDTH) { + throw new Error('Incorrect position UCI: ' + uci); + } + return new Position(x, Number(uci[1])); + } + +} + +class PhysicalPiece { + + #pieceChar; + #initPosition; + + /** + * @param pieceChar {string} + * @param initPosition {Position} + */ + constructor(pieceChar, initPosition) { + if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) { + throw new Error('Invalid piece ' + pieceChar); + } + + this.#pieceChar = pieceChar; + this.#initPosition = initPosition; + } + + /** + * @return {string} + */ + get pieceChar() { + return this.#pieceChar; + } + + get color() { + return charToPieceColor(this.#pieceChar); + } + + /** + * @param other {PhysicalPiece} + * @return {boolean} + */ + isEqualsTo(other) { + return other != null && + this.#pieceChar === other.#pieceChar && + this.#initPosition.isEqualsTo(other.#initPosition); + } + + toString() { + return `${this.#pieceChar}{${this.#initPosition.toAlgebraic()}}`; + } + +} + +class PieceAtPosition { + + /** + * @param piece {PhysicalPiece} + * @param position {Position} + */ + constructor(piece, position) { + this.piece = piece; + this.position = position; + } + + /** + * @return {string} + */ + get pieceColor() { + return this.piece.color; + } + + isColor(color) { + return this.pieceColor === color; + } + + /** + * @param other {PieceAtPosition} + */ + isEqualsTo(other) { + return other != null && + this.piece.pieceChar === other.piece.pieceChar && + this.position.isEqualsTo(other.position); + } + + toString() { + return this.piece.pieceChar + ' at ' + this.position.toAlgebraic(); + } + +} + +class HalfMove { + + /** + * @type {Position} + */ + from; + + /** + * @type {Position} + */ + to; + + constructor(from, to) { + this.from = from; + this.to = to; + } + + toUci() { + return this.from.toUci() + this.to.toUci(); + } + + toAlgebraic() { + return this.from.toAlgebraic() + this.to.toAlgebraic(); + } + + isHorizontal() { + return this.from.y === this.to.y; + } + + isVertical() { + return this.from.x === this.to.x; + } + + toString() { + return this.toAlgebraic(); + } + + /** + * @param uci {string} + * @return {HalfMove} + */ + static parseUci(uci) { + if (uci.length !== 4) { + throw new Error('Incorrect move UCI: ' + uci); + } + let from = Position.parseUci(uci.substring(0, 2)); + let to = Position.parseUci(uci.substring(2, 4)); + return new HalfMove(from, to); + } + + /** + * @param movesAsUci {string[]} + */ + static parseUciMultipleMoves(movesAsUci) { + return movesAsUci.map(moveAsUci => HalfMove.parseUci(moveAsUci)); + } + + static areEquals(m1, m2) { + return m1 != null && m2 != null && Position.areEquals(m1.from, m2.from) && Position.areEquals(m1.to, m2.to); + } + + static uciToAlgebraic(uci) { + return HalfMove.parseUci(uci).toAlgebraic(); + } + +} + +class Board { + + #content = []; + #redToPlay = true; + #enforceColorTurn = true; + #fullMovesCounts = 0; + + constructor() { + for (let x = 0; x < BOARD_WIDTH; x++) { + this.#content.push([null]); + for (let y = 0; y < BOARD_HEIGHT; y++) { + this.#content[x][y] = null; + } + } + } + + // TODO: update "full moves count" accordingly + loadFen(fen) { + const split = fen.split(' '); + const positionsFen = split[0]; + const gameStateFen = split[1]; + const fenLines = positionsFen.trim().split("/") + if (fenLines.length !== BOARD_HEIGHT) { + throw new Error('Invalid FEN: wrong number of component'); + } + this.clearBoard(); + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + this.#loadFenLine(fenLines[BOARD_HEIGHT - 1 - y], y); + } + + switch (gameStateFen.toLowerCase().trim()[0]) { + case 'w': + case 'r': + this.#redToPlay = true; + break; + case 'b': + this.#redToPlay = false; + break; + default: + throw new Error('Invalid FEN: can not determine which side plays next'); + } + } + + /** + * @param fenLine {string} + * @param y {number} + */ + #loadFenLine(fenLine, y) { + let x = 0 + for (let c = 0; fenLine.length - 1 && x < BOARD_WIDTH; c++) { + const char = fenLine.charAt(c); + if (char >= '0' && char <= '9') { + x += Number(char); + } else { + this.#content[x][y] = new PhysicalPiece(char, new Position(x, y)); + x++; + } + } + } + + outputFen() { + let ranks = []; + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + ranks.push(this.#rankToFen(y)); + } + return ranks.join('/') + ' ' + colorToUci(this.getColorToPlay()) + ' - - 0 ' + this.#fullMovesCounts.toString(); + } + + #rankToFen(rank) { + let rankPieces = Position + .getAll() + .filter(position => position.y === rank) + .map(position => this.getPieceAt(position)); + + let count = 0 + let result = ''; + rankPieces.forEach(piece => { + if (piece == null) { + count += 1; + } else { + if (count > 0) { + result += count.toString(); + count = 0; + } + result = result.concat(piece.pieceChar); + } + }); + if (count > 0) { + result += count.toString(); + } + + return result; + } + + /** + * @param position {Position} + * @return {PhysicalPiece|null} + */ + getPieceAt(position) { + return this.#content[position.x][position.y]; + } + + /** + * @param position {Position} + */ + removePieceFrom(position) { + this.#content[position.x][position.y] = null; + } + + /** + * + * @param pieceChar {string} + * @param position {Position} + * @param enforcePlacementRules {boolean} + */ + addPieceAt(pieceChar, position, enforcePlacementRules) { + if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) { + throw new Error('Invalid char: ' + pieceChar); + } + + // TODO: enforce rules (elephant, king, etc.) + + const physicalPiece = new PhysicalPiece(pieceChar, position); + this.#setPieceAt(physicalPiece, position); + } + + /** + * @param piece {PhysicalPiece} + * @param position {Position} + */ + #setPieceAt(piece, position) { + this.#content[position.x][position.y] = piece; + } + + /** + * @return {PieceAtPosition[]} + */ + listPiecePositions() { + return Position + .getAll() + .filter(position => this.#hasPieceAt(position)) + .map(position => new PieceAtPosition(this.getPieceAt(position), position)); + } + + /** + * Find all positions where a piece of type {@param piece} is located + * (i.e. max 5 for pawns, max 2 for knights, etc.) + * + * @param pieceChar {string} + * @return {Position[]} + */ + listPositionsForPiece(pieceChar) { + let result = []; + Position.getAll().forEach(position => { + let physicalPiece = this.getPieceAt(position); + if (physicalPiece != null && physicalPiece.pieceChar === pieceChar) { + result.push(position); + } + }); + return result; + } + + clearBoard() { + this.#fullMovesCounts = 0; + Position.getAll().forEach(position => this.removePieceFrom(position)); + } + + /** + * @return {boolean} true if the piece at {@param position} matches the color that has to play now, + * or if "enforcedColorTurn" is disabled + */ + isAllowedToPlayPieceAt(position) { + if (this.#enforceColorTurn) { + return this.#hasPieceAt(position) && this.isColorToPlay(this.getPieceAt(position)); + } else { + // FIXME: here we don't check it's not empty? doesn't seem consistent + return true; + } + } + + getColorToPlay() { + if (this.#redToPlay) { + return Color.RED; + } else { + return Color.BLACK; + } + } + + /** + * @param piece {PhysicalPiece} + * @return {boolean} + */ + isColorToPlay(piece) { + return (piece.color === Color.RED && this.#redToPlay) || (piece.color === Color.BLACK && !this.#redToPlay); + } + + /** + * @param color {string} + */ + forceColorToPlay(color) { + this.#redToPlay = (color === Color.RED); + } + + /** + * Return captured piece, or null if no piece was taken + */ + registerMove(move) { + let piece = this.getPieceAt(move.from); + if (piece == null) { + throw new Error('No piece to move at ' + move.from); + } else if (this.#enforceColorTurn && !this.isColorToPlay(piece)) { + throw new Error(`It is ${this.getColorToPlay()}'s turn to play`); + } else { + if (this.#isPossibleMove(move)) { + return this.#registerMove(piece, move) + } else { + throw new Error(`Move ${move} is not possible for ${piece.pieceChar}`); + } + } + } + + /** + * @param piece {PhysicalPiece} + * @param move {HalfMove} + * @return {PhysicalPiece|null} captured piece, or null if no piece was taken + */ + #registerMove(piece, move) { + let targetPiece = this.getPieceAt(move.to); + this.removePieceFrom(move.from); + this.#setPieceAt(piece, move.to); + this.#redToPlay = !this.#redToPlay; + if (piece.color === Color.BLACK) { + this.#fullMovesCounts++; + } + return targetPiece; + } + + /** + * @param color {string} + * @return {boolean} + */ + isInCheck(color) { + let generalPosition = this.findGeneral(color).position; + return this + .#listAllMovesForColor(reverseColor(color)) + .map(move => move.to) + .some(position => position.isEqualsTo(generalPosition)); + } + + isCheckmate(color) { + return color === this.getColorToPlay() && this.isInCheck(color) && this.#allMovesAreIllegal(color); + } + + isStalemate(color) { + return color === this.getColorToPlay() && !this.isInCheck(color) && this.#allMovesAreIllegal(color); + } + + #allMovesAreIllegal(color) { + return this.#listAllMovesForColor(color).every(move => this.#isMoveIllegal(move, color)); + } + + isMated() { + return this.isCheckmate(Color.RED) || + this.isCheckmate(Color.BLACK) || + this.isStalemate(Color.RED) || + this.isStalemate(Color.BLACK); + } + + /** + * @param color + * @return {PieceAtPosition|null} + */ + findGeneral(color) { + return this + .listPiecePositions() + .find(piecePosition => + piecePosition.piece.pieceChar.toUpperCase() === 'K' && piecePosition.isColor(color) + ); + } + + #isMoveIllegal(move, color) { + let boardCopy = this.copy(); + if (boardCopy.#isPossibleMove(move)) { + boardCopy.registerMove(move); + return boardCopy.#areGeneralsFacing() || boardCopy.isInCheck(color); + } else { + return true; + } + } + + #areGeneralsFacing() { + let red = this.findGeneral(Color.RED); + let black = this.findGeneral(Color.BLACK); + if (red == null || black == null) { + return false; + } else { + let redGeneralPosition = red.position; + let blackGeneralPosition = black.position; + return red.position.x === black.position.x && this.#noPieceOnFileBetween(redGeneralPosition, blackGeneralPosition); + } + } + + #noPieceOnFileBetween(p1, p2) { + if (p1.x !== p2.x) { + return false; + } else { + for (let y = p1.y + 1; y < p2.y; y++) { + let position = new Position(p1.x, y); + if (this.#hasPieceAt(position)) { + return false; + } + } + } + return true; + } + + #listAllMovesForColor(color) { + return this + .listPiecePositions() + .filter(piecePosition => piecePosition.isColor(color)) + .flatMap(piecePosition => this.#listAllMovesFrom(piecePosition.position)); + } + + #isPossibleMove(move) { + return this + .#listAllMovesFromAsPositions(move.from) + .some(targetPosition => Position.areEquals(move.to, targetPosition)); + } + + isLegalMove(move) { + let color = this.getColorAt(move.from); + if (color == null) { + return false; + } else { + return !this.#isMoveIllegal(move, color); + } + } + + /** + * @returns {HalfMove[]} + */ + listLegalMovesFrom(position) { + let color = this.getColorAt(position); + if (color == null) { + return []; + } else { + return this.#listAllMovesFrom(position).filter(move => !this.#isMoveIllegal(move, color)); + } + } + + /** + * Lists every legal move available to the given color (defaults to the + * color whose turn it is to play). + * + * @param color {string} optional, defaults to {@link getColorToPlay} + * @returns {HalfMove[]} + */ + listAllLegalMoves(color = this.getColorToPlay()) { + const moves = []; + for (let x = 0; x < BOARD_WIDTH; x++) { + for (let y = 0; y < BOARD_HEIGHT; y++) { + const pos = new Position(x, y); + if (this.getColorAt(pos) === color) { + moves.push(...this.listLegalMovesFrom(pos)); + } + } + } + return moves; + } + + /** + * @returns Array of {@class HalfMove} one can go to from {@param from}. Includes illegal moves (e.g. that would put player in check) + */ + #listAllMovesFrom(from) { + return this + .#listAllMovesFromAsPositions(from) + .map(to => new HalfMove(from, to)); + } + + /** + * @returns {Position[]} one can go to from {@param position}. Includes illegal moves (e.g. that would put player in check) + */ + #listAllMovesFromAsPositions(position) { + switch (this.getPieceAt(position).pieceChar.toLowerCase()) { + case 'c': + return this.#listMovesForCannon(position); + case 'r': + return this.#listMovesForChariot(position); + case 'n': + return this.#listMovesForHorse(position); + case 'b': + return this.#listMovesForElephant(position); + case 'a': + return this.#listMovesForAdvisor(position); + case 'k': + return this.#listMovesForGeneral(position); + case 'p': + return this.#listMovesForSoldier(position); + default: + throw new Error('Not implemented for ' + this.getPieceAt(position).pieceChar) + } + } + + #listMovesForCannon(position) { + let result = []; + result = result.concat(this.#filterMovesForCannon(position, position.getAllTop())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllRight())); + return result; + } + + #filterMovesForCannon(position, allTargetPosition) { + let result = []; + let foundPivot = false; + + for (let i = 0; i < allTargetPosition.length; i++) { + let targetPosition = allTargetPosition[i]; + if (!foundPivot) { + if (!this.#hasPieceAt(targetPosition)) { + result.push(targetPosition); + } else { + foundPivot = true; + } + } else { + if (this.containOppositeColors(position, targetPosition)) { + result.push(targetPosition); + return result; + } else if (this.containSameColors(position, targetPosition)) { + return result; + } + } + } + + return result; + } + + #listMovesForChariot(position) { + let result = []; + result = result.concat(this.#filterMovesForChariot(position, position.getAllTop())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllRight())); + return result; + } + + #filterMovesForChariot(position, allTargetPositions) { + let result = []; + + for (let i = 0; i < allTargetPositions.length; i++) { + let targetPosition = allTargetPositions[i]; + if (!this.#hasPieceAt(targetPosition)) { + result.push(targetPosition); + } else if (this.containOppositeColors(position, targetPosition)) { + result.push(targetPosition); + return result; + } else if (this.containSameColors(position, targetPosition)) { + return result; + } + } + + return result; + } + + #listMovesForHorse(position) { + let result = []; + let top = position.getTop(); + let bottom = position.getBottom(); + let left = position.getLeft(); + let right = position.getRight(); + if (top.existsOnBoard() && !this.#hasPieceAt(top)) { + result.push(top.getTopLeft()); + result.push(top.getTopRight()); + } + if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) { + result.push(bottom.getBottomLeft()); + result.push(bottom.getBottomRight()); + } + if (left.existsOnBoard() && !this.#hasPieceAt(left)) { + result.push(left.getTopLeft()); + result.push(left.getBottomLeft()); + } + if (right.existsOnBoard() && !this.#hasPieceAt(right)) { + result.push(right.getTopRight()); + result.push(right.getBottomRight()); + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && !this.containSameColors(targetPosition, position) + ); + } + + #listMovesForElephant(position) { + let result = []; + let topLeft = position.getTopLeft(); + let topRight = position.getTopRight(); + let bottomRight = position.getBottomRight(); + let bottomLeft = position.getBottomLeft(); + + if (topLeft.existsOnBoard() && !this.#hasPieceAt(topLeft)) { + result.push(topLeft.getTopLeft()); + } + if (topRight.existsOnBoard() && !this.#hasPieceAt(topRight)) { + result.push(topRight.getTopRight()); + } + if (bottomRight.existsOnBoard() && !this.#hasPieceAt(bottomRight)) { + result.push(bottomRight.getBottomRight()); + } + if (bottomLeft.existsOnBoard() && !this.#hasPieceAt(bottomLeft)) { + result.push(bottomLeft.getBottomLeft()); + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && + !this.containSameColors(position, targetPosition) && + !this.#areOnOppositeSidesOfTheRiver(position, targetPosition) + ); + } + + #listMovesForAdvisor(position) { + let result = []; + result.push(position.getTopLeft()); + result.push(position.getTopRight()); + result.push(position.getBottomRight()); + result.push(position.getBottomLeft()); + return this.#filterMovesInItsPalace(position, result); + } + + #listMovesForGeneral(position) { + let result = []; + result.push(position.getTop()); + result.push(position.getBottom()); + result.push(position.getLeft()); + result.push(position.getRight()); + return this.#filterMovesInItsPalace(position, result); + } + + #filterMovesInItsPalace(position, allTargetPosition) { + if (this.#hasRedPieceAt(position)) { + return allTargetPosition.filter(target => + target.existsOnBoard() && target.isInRedPalace() && !this.#hasRedPieceAt(target) + ); + } else if (this.#hasBlackPieceAt(position)) { + return allTargetPosition.filter(target => + target.existsOnBoard() && target.isInBlackPalace() && !this.#hasBlackPieceAt(target) + ); + } else { + console.error("we should never pass here") + return []; + } + } + + #listMovesForSoldier(position) { + let result = []; + if (this.#hasRedPieceAt(position)) { + // is red + result.push(position.getTop()); + if (this.#areOnOppositeSidesOfTheRiver(position, Position.redGeneralStartingPosition)) { + result.push(position.getLeft()); + result.push(position.getRight()); + } + } else if (this.#hasBlackPieceAt(position)) { + // is black + result.push(position.getBottom()); + if (this.#areOnOppositeSidesOfTheRiver(position, Position.blackGeneralStartingPosition)) { + result.push(position.getLeft()); + result.push(position.getRight()); + } + } else { + console.warn("we should never pass here") + return []; + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition) + ); + } + + /** + * @param position {Position} + * @returns {null|string} + */ + getColorAt(position) { + let piece = this.getPieceAt(position); + if (piece != null) { + return piece.color; + } else { + return null; + } + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasPieceAt(position) { + return this.#content[position.x][position.y] != null; + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasRedPieceAt(position) { + return this.#hasPieceOfColorAt(Color.RED, position); + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasBlackPieceAt(position) { + return this.#hasPieceOfColorAt(Color.BLACK, position); + } + + #hasPieceOfColorAt(color, position) { + let piece = this.getPieceAt(position); + return piece != null && piece.color === color; + } + + #areOnOppositeSidesOfTheRiver(p1, p2) { + return (p1.y <= 4 && p2.y >= 5) || (p2.y <= 4 && p1.y >= 5); + } + + containSameColors(p1, p2) { + return (this.#hasRedPieceAt(p1) && this.#hasRedPieceAt(p2)) + || (this.#hasBlackPieceAt(p1) && this.#hasBlackPieceAt(p2)); + } + + containOppositeColors(p1, p2) { + return (this.#hasRedPieceAt(p1) && this.#hasBlackPieceAt(p2)) + || (this.#hasRedPieceAt(p2) && this.#hasBlackPieceAt(p1)); + } + + printBoardToLines() { + let lines = []; + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + let row = ''; + for (let x = 0; x < BOARD_WIDTH; x++) { + let piece = this.getPieceAt(new Position(x, y)); + if (piece == null) { + row += ' '; + } else { + row += piece.pieceChar + ' '; + } + } + lines.push(row); + } + return lines; + } + + printBoard() { + let oneLine = ''; + this.printBoardToLines().forEach(line => oneLine += line + '\n'); + console.log(oneLine); + } + + /** + * @return {Board} + */ + copy() { + let copy = new Board(); + copy.loadFen(this.outputFen()); + return copy + } + +} diff --git a/webapp/src/main/resources/public/dist/0.0.4/board-gui.js b/webapp/src/main/resources/public/dist/0.0.4/board-gui.js new file mode 100644 index 000000000..19affafc5 --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.4/board-gui.js @@ -0,0 +1,1855 @@ +/* + * Copyright (C) 2026 Encelade SRL + * Copyright (C) 2026 elephantchess.io + * Copyright (C) 2026 Benoît Vleminckx (benckx) + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +// Small DOM helpers and constants, inlined here so this file is usable on its +// own (only requires xiangqi.js, which provides the `Color` enum). Previously +// imported from utils.js / ui.js. For helpers that may also be defined by +// those modules when loaded as part of the full webapp, we only define them +// if the host page hasn't already loaded those modules, to avoid clobbering +// existing definitions. +if (typeof buildImg === 'undefined') { + // eslint-disable-next-line no-var + var buildImg = function (src, className = null) { + const img = document.createElement('img'); + img.src = src; + if (className != null) img.className = className; + return img; + }; +} + +if (typeof htmlCollectionToArray === 'undefined') { + // eslint-disable-next-line no-var + var htmlCollectionToArray = function (htmlCollection) { + const arr = []; + for (let i = 0; i < htmlCollection.length; i++) { + arr.push(htmlCollection[i]); + } + return arr; + }; +} + +if (typeof buildDivWithClass === 'undefined') { + // eslint-disable-next-line no-var + var buildDivWithClass = function (className) { + const div = document.createElement('div'); + div.className = className; + return div; + }; +} + +// Mapping from FEN piece char to piece image file name. Lives here (rather +// than in ui.js) so that board-gui.js can be used standalone. +const pieceImageNames = new Map(); +pieceImageNames.set('K', 'red_general.png'); +pieceImageNames.set('k', 'black_general.png'); +pieceImageNames.set('A', 'red_advisor.png'); +pieceImageNames.set('a', 'black_advisor.png'); +pieceImageNames.set('B', 'red_elephant.png'); +pieceImageNames.set('b', 'black_elephant.png'); +pieceImageNames.set('N', 'red_horse.png'); +pieceImageNames.set('n', 'black_horse.png'); +pieceImageNames.set('C', 'red_cannon.png'); +pieceImageNames.set('c', 'black_cannon.png'); +pieceImageNames.set('R', 'red_chariot.png'); +pieceImageNames.set('r', 'black_chariot.png'); +pieceImageNames.set('P', 'red_soldier.png'); +pieceImageNames.set('p', 'black_soldier.png'); + +const diagonalDescending = ''; +const diagonalRising = ''; + +const diagonalDescendingMini = ''; +const diagonalRisingMini = ''; + +const PRIMARY_ARROW_COLOR = 'rgb(27, 181, 29)'; +const SECONDARY_ARROW_COLOR = 'rgb(62, 56, 219)'; + +const ARROW_MARKERS = ` + + + + + + + +`; + +const bottomRightCrosshairs = [ + new Position(0, 8), new Position(6, 8), + new Position(1, 7), new Position(3, 7), new Position(5, 7), new Position(7, 7), + new Position(1, 4), new Position(3, 4), new Position(5, 4), new Position(7, 4), + new Position(0, 3), new Position(6, 3), +]; + +const bottomLeftCrosshairs = [ + new Position(1, 8), new Position(7, 8), + new Position(0, 7), new Position(2, 7), new Position(4, 7), new Position(6, 7), + new Position(0, 4), new Position(2, 4), new Position(4, 4), new Position(6, 4), + new Position(1, 3), new Position(7, 3), +]; + +const topRightCrosshairs = [ + new Position(0, 7), new Position(6, 7), + new Position(1, 6), new Position(3, 6), new Position(5, 6), new Position(7, 6), + new Position(1, 3), new Position(3, 3), new Position(5, 3), new Position(7, 3), + new Position(0, 2), new Position(6, 2), +]; + +const topLeftCrosshairs = [ + new Position(1, 7), new Position(7, 7), + new Position(0, 6), new Position(2, 6), new Position(4, 6), new Position(6, 6), + new Position(0, 3), new Position(2, 3), new Position(4, 3), new Position(6, 3), + new Position(1, 2), new Position(7, 2) +]; + +const EngineArrowType = Object.freeze({ + PRIMARY: 'PRIMARY', + SECONDARY: 'SECONDARY' +}); + +/** + * Orientation of the board coordinate labels. + * Use `null` (not part of this enum) to hide the coordinates entirely. + */ +const CoordinatesOrientation = Object.freeze({ + WXF: 'WXF', + UCI: 'UCI', +}); + +// Chinese numerals for files 1..9, used to label files in WXF mode when the +// {@link FileNumbersStyle} setting calls for Chinese numerals on that side. +const CHINESE_FILE_DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九']; + +/** + * How file numbers are rendered around the board in WXF mode. Only affects the + * file-number labels; the UCI orientation (a..i letters) is unaffected. + */ +const FileNumbersStyle = Object.freeze({ + /** Arabic numerals (1..9) on both sides of the board. */ + ARABIC_BOTH: 'ARABIC_BOTH', + /** Chinese numerals (一..九) on both sides of the board. */ + CHINESE_BOTH: 'CHINESE_BOTH', + /** Chinese numerals on red's side; Arabic numerals on black's side (default). */ + CHINESE_RED_ONLY: 'CHINESE_RED_ONLY', + DEFAULT: 'CHINESE_RED_ONLY', +}); + +/** + * Visual style of the board pieces. Used to pick the corresponding image folder + * (under `${assetsBaseUrl}/images/pieces//`). + */ +const PieceStyleSetting = Object.freeze({ + TRADITIONAL: 'TRADITIONAL', + ROMANIZED_ROUNDED: 'ROMANIZED_ROUNDED', + DEFAULT: 'TRADITIONAL', +}); + +/** + * @typedef {Object} BoardGuiOptions + * @property {string} [elementId] - id of the container element + * @property {boolean} [showCoordinates] - whether to reserve space for file/rank coordinates + * @property {string|null} [coordinatesOrientation] - one of {@link CoordinatesOrientation} or `null` to + * hide the labels (space is still reserved when + * `showCoordinates` is true). The caller is in + * charge of resolving any user preference (e.g. cookies). + * @property {boolean} [mini] - whether this is a mini (thumb) board + * @property {boolean} [forceRenderChecks] - render checks even on mini boards + * @property {boolean} [svg] - enable the SVG overlay (used for engine arrows) + * @property {string} [assetsBaseUrl] - base URL prepended to every static asset path + * (images, audio). Default: `https://elephantchess.io`. + * Pass an empty string to use relative paths (e.g. when + * serving the assets from the current host on localhost). + * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece + * image folder. + * @property {boolean} [colorblindFriendlyBlackPieces] - if true, black piece images get an invert + * CSS filter for improved contrast. + * @property {string} [fileNumbersStyle] - one of {@link FileNumbersStyle}; selects how + * file numbers are rendered in WXF mode. + */ + +/** @type {Readonly>} */ +const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({ + elementId: 'board-container', + showCoordinates: true, + coordinatesOrientation: 'WXF', + mini: false, + forceRenderChecks: false, + svg: false, + assetsBaseUrl: 'https://cdn.elephantchess.io/static', + pieceStyle: PieceStyleSetting.DEFAULT, + colorblindFriendlyBlackPieces: false, + fileNumbersStyle: FileNumbersStyle.DEFAULT, +}); + +class BoardGui { + + #board = new Board(); + #boardContainer; + #currentShowingLegalMovesFor = null; + #selectedPiecePosition = null; + #flippedRed = true; // if true, oriented toward the RED player + #afterMoveListeners = []; + #afterDrawPositionsListeners = []; + #afterFlipListeners = []; + + /** @type {HTMLAudioElement} */ + #clickSound; + + // when false, player won't be able to make a move + #isPlayerMoveEnabled = true; + + /** + * @type {Readonly>} + */ + #options; + + /** + * @type {HalfMove|null} + */ + #primaryEngineArrow = null; + + /** + * @type {HalfMove|null} + */ + #secondaryEngineArrow = null; + + #isSafari = false; + + /** + * @param {BoardGuiOptions} [options] + */ + constructor(options = {}) { + this.#isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + this.#options = Object.freeze({...DEFAULT_BOARD_GUI_OPTIONS, ...options}); + + this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`); + + this.#boardContainer = document.getElementById(this.#options.elementId); + this.#renderColorblindFriendlyBlackPiecesSetting(this.#options.colorblindFriendlyBlackPieces); + this.#drawBoard(); + this.#drawPieces(); // FIXME: useful? + + const boardGui = this; + + document + .getElementsByTagName('html') + .item(0) + .addEventListener('click', (e) => { + if (!boardGui.#boardContainer.contains(e.target)) { + boardGui.#hideAllPiecePlaceHolders(); + } + }); + + if (this.#options.svg) { + window.onresize = function () { + boardGui.#renderSvg(); + }; + } + + this.#forceSafariLayoutRefresh(); + } + + /** + * Draw the position described by {@code fen}. + * + * When {@code animate} is true, the transition from the currently displayed + * position to the target position is animated: unchanged pieces are left in + * place, pieces of the same type are paired between the current and target + * positions and slide from their source square to their destination square, + * leftover current pieces fade out, and target pieces fade in - all + * simultaneously. This works for any diff size (not just a single move), + * similar to what lichess does. + * + * @param fen {string} + * @param animate {boolean} + */ + loadFen(fen, animate = false) { + if (this.isInPlaceHolderMode()) { + console.warn('Can not load FEN when in placeholder mode'); + return; + } + + if (animate) { + this.#drawPositionAnimated(fen); + } else { + this.#drawPositionNoAnimation(fen); + } + } + + #drawPositionNoAnimation(targetFen) { + this.#hideAllPiecePlaceHolders(); + this.#hideHighlightedLastMove(); + this.#unDrawAllPieces(); + this.#board.loadFen(targetFen); + this.#drawPieces(); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#forceSafariLayoutRefresh(); + } + + /** + * Compute the target position (via a temporary board so we don't mutate the + * current {@link Board} model before we're ready) and diff it against the + * currently displayed pieces to build the animation plan. + * + * @param targetFen {string} + */ + #drawPositionAnimated(targetFen) { + this.#hideAllPiecePlaceHolders(); + this.#hideHighlightedLastMove(); + + // compute the target piece layout via a temp board (no DOM side-effects, + // and no mutation of the real model yet) + const targetBoard = new Board(); + targetBoard.loadFen(targetFen); + const targetPieces = targetBoard.listPiecePositions(); + + this.#animatePiecesTo(targetPieces, 200, () => { + // commit the new board state in the model + this.#board.loadFen(targetFen); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#afterDrawPositionsListeners.forEach(listener => listener()); + }); + } + + outputFen() { + return this.#board.outputFen(); + } + + /** + * Returns a copy of the underlying (non-GUI) {@link Board}, useful when + * callers need to inspect/query the board model without touching the DOM. + * + * A copy is returned rather than the internal instance to preserve + * encapsulation (so callers can't mutate the BoardGui's state directly). + * + * @return {Board} + */ + get board() { + return this.#board.copy(); + } + + /** + * Change which color is to play next (doesn't alter the pieces on the board). + * + * @param color {string} + */ + forceColorToPlay(color) { + this.#board.forceColorToPlay(color); + } + + get isPlayerMoveEnabled() { + return this.#isPlayerMoveEnabled; + } + + set isPlayerMoveEnabled(value) { + if (value) { + this.enablePlayerMove(); + } else { + this.disablePlayerMove(); + } + } + + disablePlayerMove() { + this.#isPlayerMoveEnabled = false; + this.#hideAllPiecePlaceHolders(); + this.#hideAllDraggableCursors(); + } + + enablePlayerMove() { + this.#isPlayerMoveEnabled = true; + this.#resetDraggableCursors(); + } + + /** + * @param listener {function} + */ + addAfterMoveListener(listener) { + this.#afterMoveListeners.push(listener); + } + + /** + * @param listener {function} + */ + addAfterDrawPositionsListener(listener) { + this.#afterDrawPositionsListeners.push(listener); + } + + /** + * @param listener {function(string)} + */ + addAfterFlipListener(listener) { + this.#afterFlipListeners.push(listener); + } + + clearAllAfterMovesListeners() { + this.#afterMoveListeners = []; + } + + /** + * @param move {HalfMove} + * @param highLastMove {boolean} if true, the last move will be highlighted with a green dashed circle + * @param afterMoveCallback {function} + */ + registerOpponentMove(move, highLastMove, afterMoveCallback) { + this.#hideHighlightedLastMove(); + this.#animateMoveViaDiff(move, () => { + if (highLastMove) { + this.highlightLastMove(move); + } + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + this.#clickSound + .play() + .catch(e => { + // ignored, spam error in console in dev + }); + if (afterMoveCallback != null) { + afterMoveCallback() + } + }); + } + + /** + * Animate the DOM pieces from the currently displayed position (as + * described by {@link #board}) to {@code targetPieces}. Does NOT mutate the + * underlying board model; the caller is expected to update the model in + * {@code onDone}. + * + * Diff strategy (mirrors lichess/chessground): + * - pieces of the same pieceChar on the same square: leave image alone + * - current-board misplacements and target-board misplacements of the + * same pieceChar: paired greedily by shortest distance and animated + * with a CSS transform transition + * - unpaired leftover current-board misplacements: faded out + * - unpaired leftover target-board misplacements: faded in + * + * @param targetPieces {PieceAtPosition[]} + * @param durationMs {number} + * @param onDone {function} + */ + #animatePiecesTo(targetPieces, durationMs, onDone) { + const currentPieces = this.#board.listPiecePositions(); + + const posKey = p => `${p.toUci()}`; + const currentMap = new Map(currentPieces.map(pp => [posKey(pp.position), pp])); + const targetMap = new Map(targetPieces.map(pp => [posKey(pp.position), pp])); + + // pieces currently drawn on the board that don't match the target + // position (either the target expects a different piece on that + // square, or no piece at all). Each will be either animated to a + // square in targetBoardMisplacements or faded out. + /** @type {PieceAtPosition[]} */ + const currentBoardMisplacements = []; + for (const [positionKey, currentPp] of currentMap) { + const targetPp = targetMap.get(positionKey); + if (targetPp && targetPp.piece.pieceChar === currentPp.piece.pieceChar) { + // same piece on same square: leave image alone + targetMap.delete(positionKey); + } else { + currentBoardMisplacements.push(currentPp); + } + } + + // pieces required by the target position that aren't already correctly + // drawn on the current board (the square is empty or holds a different + // piece). Each will be either the destination of an animated move from + // currentBoardMisplacements or faded in as a new image. + /** @type {PieceAtPosition[]} */ + const targetBoardMisplacements = Array.from(targetMap.values()); + + // pair movers greedily by closest same-pieceChar distance + const calculateDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + const candidatePairs = []; + currentBoardMisplacements.forEach((c, i) => { + targetBoardMisplacements.forEach((t, j) => { + if (t.piece.pieceChar === c.piece.pieceChar) { + candidatePairs.push({i, j, distance: calculateDistance(c.position, t.position)}); + } + }); + }); + candidatePairs.sort((a, b) => a.distance - b.distance); + + // indices into currentBoardMisplacements / targetBoardMisplacements that + // have already been claimed by an animated move, so we don't pair the + // same piece twice while walking the distance-sorted candidate list. + /** @type {Set} */ + const usedCurrent = new Set(); + /** @type {Set} */ + const usedTarget = new Set(); + const animatedMoves = []; + for (const {i, j} of candidatePairs) { + if (!usedCurrent.has(i) && !usedTarget.has(j)) { + usedCurrent.add(i); + usedTarget.add(j); + animatedMoves.push({ + from: currentBoardMisplacements[i].position, + to: targetBoardMisplacements[j].position, + pieceChar: currentBoardMisplacements[i].piece.pieceChar, + }); + } + } + const toRemove = currentBoardMisplacements.filter((_, i) => !usedCurrent.has(i)); + const toAdd = targetBoardMisplacements.filter((_, j) => !usedTarget.has(j)); + + // nothing to animate: bail out early + if (animatedMoves.length === 0 && toRemove.length === 0 && toAdd.length === 0) { + onDone(); + return; + } + + const transitionStr = `transform ${durationMs}ms ease, opacity ${durationMs}ms ease`; + const getSquareRect = pos => document + .getElementById(this.#positionToElementId('square', pos)) + .getBoundingClientRect(); + + const cleanups = []; + + // animate movers: translate the source-square image toward the target square + animatedMoves.forEach(animatedMove => { + const img = document.getElementById(this.#positionToElementId('image', animatedMove.from)); + if (img == null) return; + const fromRect = getSquareRect(animatedMove.from); + const toRect = getSquareRect(animatedMove.to); + const dx = toRect.left - fromRect.left; + const dy = toRect.top - fromRect.top; + + // rename id so it doesn't conflict with drawPieceAt on the target square + img.id = `animating-${Math.random().toString(36).slice(2)}`; + img.style.transition = transitionStr; + // must stay above .crosshair-square (z-index: 80) and resting + // pieces (z-index: 100) so the moving piece isn't painted under + // crosshairs of neighboring squares it flies over + img.style.zIndex = '110'; + img.style.pointerEvents = 'none'; + + // trigger on next frame so the transition actually runs + requestAnimationFrame(() => { + img.style.transform = `translate(${dx}px, ${dy}px)`; + }); + + cleanups.push(() => img.remove()); + }); + + // fade out leftover current pieces (captures / removals) + toRemove.forEach(rm => { + const img = document.getElementById(this.#positionToElementId('image', rm.position)); + if (img == null) return; + img.id = `fading-${Math.random().toString(36).slice(2)}`; + img.style.transition = transitionStr; + img.style.pointerEvents = 'none'; + requestAnimationFrame(() => { + img.style.opacity = '0'; + }); + cleanups.push(() => img.remove()); + }); + + // fade in leftover target pieces (newly placed on a previously empty square) + const addedImages = []; + toAdd.forEach(add => { + this.#drawPieceAt(add.piece.pieceChar, add.position); + const img = document.getElementById(this.#positionToElementId('image', add.position)); + if (img != null) { + img.style.opacity = '0'; + img.style.transition = transitionStr; + addedImages.push(img); + requestAnimationFrame(() => { + img.style.opacity = '1'; + }); + } + }); + + setTimeout(() => { + // drop transient animation images + cleanups.forEach(c => c()); + + // draw the final image at each move's target square + animatedMoves.forEach(mv => { + const existing = document.getElementById(this.#positionToElementId('image', mv.to)); + if (existing != null) existing.remove(); + this.#drawPieceAt(mv.pieceChar, mv.to); + }); + + // clear transient styles on faded-in images + addedImages.forEach(img => { + img.style.transition = ''; + img.style.opacity = ''; + }); + + onDone(); + this.#forceSafariLayoutRefresh(); + }, durationMs + 20); + } + + /** + * @param move {HalfMove} + * @param animate {boolean} + */ + registerMoveIfLegal(move, animate = true) { + if (!this.#board.isLegalMove(move)) { + console.log(move + ' is not a legal move'); + return; + } + + const afterCommit = () => { + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + for (let i = 0; i < this.#afterMoveListeners.length; i++) { + this.#afterMoveListeners[i](move); + } + }; + + if (animate) { + // the diff-based animator will commit the model via its onDone + this.#animateMoveViaDiff(move, afterCommit); + } else { + const piece = this.#board.getPieceAt(move.from); + this.#board.registerMove(move); + this.#drawMove(piece.pieceChar, move); + afterCommit(); + } + } + + /** + * Animate a single move by delegating to the generic diff-based animator + * {@link #animatePiecesTo}. The target position is computed on a copy of + * the current board so the real model is only mutated once the animation + * completes. This replaces the old xiangqi-shape-aware setInterval based + * {@code #animateMove}. + * + * @param move {HalfMove} + * @param onDone {function} + */ + #animateMoveViaDiff(move, onDone) { + const targetBoard = this.#board.copy(); + targetBoard.registerMove(move); + const targetPieces = targetBoard.listPiecePositions(); + this.#animatePiecesTo(targetPieces, 200, () => { + this.#board.registerMove(move); + onDone(); + }); + } + + /** + * + * @param pieceChar {string} + * @param position {Position} + * @param enforcePlacementRules {boolean} + */ + addPieceAt(pieceChar, position, enforcePlacementRules) { + this.#board.addPieceAt(pieceChar, position, enforcePlacementRules); + this.#unDrawPiece(position); + this.#drawPieceAt(pieceChar, position); + } + + /** + * @param position {Position} + */ + removePieceFrom(position) { + this.#board.removePieceFrom(position); + this.#unDrawPiece(position); + } + + // FIXME: feels like this could be private? encapsulated? + updateHighlightedChecks() { + this.#hideAllCheckHighlights(); + + if (!this.#options.mini || this.#options.forceRenderChecks) { + const redGeneral = this.#board.findGeneral(Color.RED); + const blackGeneral = this.#board.findGeneral(Color.BLACK); + + // generals may be absent (e.g. when flip() is called before any + // FEN has been loaded, on an empty board); nothing to highlight. + if (redGeneral == null || blackGeneral == null) { + return; + } + + if (this.#board.isInCheck(Color.RED)) { + if (this.#board.isCheckmate(Color.RED)) { + this.disablePlayerMove(); + this.#hideHighlightedLastMove(); + this.#highlightCheckMate(redGeneral.position); + } else { + this.#highlightCheck(redGeneral.position); + } + } + + if (this.#board.isInCheck(Color.BLACK)) { + if (this.#board.isCheckmate(Color.BLACK)) { + this.disablePlayerMove(); + this.#hideHighlightedLastMove(); + this.#highlightCheckMate(blackGeneral.position); + } else { + this.#highlightCheck(blackGeneral.position); + } + } + } + } + + /** + * @param move {HalfMove} + */ + highlightLastMove(move) { + this.#highlightWithClass(move.from, 'highlighted-last-move'); + this.#highlightWithClass(move.to, 'highlighted-last-move'); + } + + #hideAllCheckHighlights() { + Position + .getAll() + .map(position => this.#locateLegalMovePlaceHolderAt(position)) + .map(placeHolder => placeHolder.classList) + .forEach(classList => classList.remove('highlighted-check', 'highlighted-checkmate')); + } + + #hideHighlightedLastMove() { + Position + .getAll() + .map(position => this.#locateLegalMovePlaceHolderAt(position)) + .map(placeHolder => placeHolder.classList) + .forEach(classList => classList.remove('highlighted-last-move')); + } + + /** + * @param position {Position} + */ + #highlightCheck(position) { + this.#highlightWithClass(position, 'highlighted-check'); + } + + /** + * @param position {Position} + */ + #highlightCheckMate(position) { + this.#highlightWithClass(position, 'highlighted-checkmate'); + } + + /** + * @param position {Position} + * @param className {string} + */ + #highlightWithClass(position, className) { + this.#locateLegalMovePlaceHolderAt(position).classList.add(className); + } + + /** + * @returns {boolean} + */ + isInPlaceHolderMode() { + return this.#boardContainer.classList.contains('board-container-placeholder'); + } + + enablePlaceholderMode() { + this.#boardContainer.classList.add('board-container-placeholder'); + } + + disablePlaceholderMode() { + this.#boardContainer.classList.remove('board-container-placeholder'); + } + + /** + * Clear the SVG layer, which atm only render analytics arrows. + */ + clearSvg() { + if (this.#options.svg) { + const svg = document.getElementById('board-svg'); + svg.innerHTML = ''; + svg.innerHTML += ARROW_MARKERS; + } + } + + /** + * @param move {HalfMove} + * @param type {string} + */ + addEngineArrow(move, type) { + if (this.#options.svg) { + switch (type) { + case EngineArrowType.PRIMARY: + this.#primaryEngineArrow = move; + break; + case EngineArrowType.SECONDARY: + this.#secondaryEngineArrow = move; + break; + } + + this.#renderSvg(); + } + } + + #renderSvg() { + if (this.#options.svg) { + this.clearSvg(); + const svg = document.getElementById('board-svg'); + if (this.#primaryEngineArrow != null) { + svg.append(...this.#buildMoveArrow(this.#primaryEngineArrow, EngineArrowType.PRIMARY)); + } + if (this.#secondaryEngineArrow != null) { + svg.append(...this.#buildMoveArrow(this.#secondaryEngineArrow, EngineArrowType.SECONDARY)); + } + } + } + + /** + * @param move {HalfMove} + * @param type {string} + * @returns {SVGGeometryElement[]} + */ + #buildMoveArrow(move, type) { + function isSmallMove(move) { + return (move.isVertical() && Math.abs(move.to.y - move.from.y) <= 1) || + (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1) + } + + function isKnightMove(move) { + const dx = Math.abs(move.to.x - move.from.x); + const dy = Math.abs(move.to.y - move.from.y); + return (dx === 1 && dy === 2) || (dx === 2 && dy === 1); + } + + const boardBounds = this.#boardContainer.getBoundingClientRect(); + + const square1 = document.getElementById(this.#positionToElementId('square', move.from)); + const square2 = document.getElementById(this.#positionToElementId('square', move.to)); + const bound1 = square1.getBoundingClientRect(); + const bound2 = square2.getBoundingClientRect(); + + const x1 = (bound1.left + bound1.width / 2) - boardBounds.left; + const y1 = (bound1.top + bound1.height / 2) - boardBounds.top; + const x2 = (bound2.left + bound2.width / 2) - boardBounds.left; + const y2 = (bound2.top + bound2.height / 2) - boardBounds.top; + + // stroke width scales with the square size so the arrow looks consistent across viewports + // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered) + const strokeWidth = Math.min(16, bound1.width * 0.16); + + let pathD, midpointX, midpointY; + + if (isKnightMove(move)) { + // draw an elbowed arrow matching the horse's actual movement: + // segment 1: orthogonal (straight line, 1 square) — the horse's "leg" + // segment 2: diagonal (1 square) — the horse's diagonal step + const sourceReduction = bound1.width * 0.40; + // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the + // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly + // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth. + const destReduction = strokeWidth * 1.9; + const dxPx = x2 - x1; + const dyPx = y2 - y1; + + // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty) + function shortenToward(px, py, tx, ty, r) { + const dx = tx - px; + const dy = ty - py; + const d = Math.sqrt(dx * dx + dy * dy); + return [px + (dx / d) * r, py + (dy / d) * r]; + } + + let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY; + + const dyBoardSquares = Math.abs(move.to.y - move.from.y); + + if (dyBoardSquares === 2) { + // vertical-dominant: horse moves 2 squares vertically then 1 diagonally + // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement + elbowX = x1; + elbowY = y1 + dyPx / 2; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } else { + // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally + // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement + elbowX = x1 + dxPx / 2; + elbowY = y1; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } + + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = elbowX; + midpointY = elbowY; + } else { + const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + + // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top) + let reduction; + + // we can not reduce by as much when the arrow is already very small + // otherwise, the direction of the arrow flips and points the wrong way + if (isSmallMove(move)) { + // we remove 40% a square length + reduction = bound1.width * 0.40; + } else { + // we remove 50% a square length + reduction = bound1.width * 0.50; + } + + const reducedDist = dist - reduction; + const ratio = reducedDist / dist; + + const x1Prime = x2 + ratio * (x1 - x2); + const y1Prime = y2 + ratio * (y1 - y2); + const x2Prime = x1 + ratio * (x2 - x1); + const y2Prime = y1 + ratio * (y2 - y1); + + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = (x1Prime + x2Prime) / 2; + midpointY = (y1Prime + y2Prime) / 2; + } + + const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + arrowPath.setAttribute('d', pathD); + arrowPath.style.strokeWidth = `${strokeWidth}px`; + arrowPath.style.fill = 'none'; + arrowPath.style.strokeLinejoin = 'round'; + + switch (type) { + case EngineArrowType.PRIMARY: + arrowPath.style.stroke = PRIMARY_ARROW_COLOR; + arrowPath.style.markerEnd = 'url(#head-primary)'; + break; + case EngineArrowType.SECONDARY: + arrowPath.style.stroke = SECONDARY_ARROW_COLOR; + arrowPath.style.markerEnd = 'url(#head-secondary)'; + break; + } + + // draw number circle + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute('r', (bound1.width * 0.20).toString()); + circle.setAttribute('cx', midpointX.toString()); + circle.setAttribute('cy', midpointY.toString()); + circle.setAttribute('fill', 'white'); + circle.setAttribute('stroke-width', (bound1.width * 0.04).toString()); + + switch (type) { + case EngineArrowType.PRIMARY: + circle.setAttribute('stroke', PRIMARY_ARROW_COLOR); + break; + case EngineArrowType.SECONDARY: + circle.setAttribute('stroke', SECONDARY_ARROW_COLOR); + break; + } + + // draw number text + const numberLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); + numberLabel.setAttribute('x', midpointX.toString()); + numberLabel.setAttribute('y', midpointY.toString()); + numberLabel.setAttribute('text-anchor', 'middle'); + numberLabel.setAttribute('dominant-baseline', 'middle'); + numberLabel.setAttribute('font-size', (bound1.width * 0.25).toString() + 'px'); + numberLabel.setAttribute('font-weight', 'bold'); + + switch (type) { + case EngineArrowType.PRIMARY: + numberLabel.setAttribute('fill', PRIMARY_ARROW_COLOR); + numberLabel.innerHTML = '1'; + break; + case EngineArrowType.SECONDARY: + numberLabel.setAttribute('fill', SECONDARY_ARROW_COLOR); + numberLabel.innerHTML = '2'; + break; + } + + return [arrowPath, circle, numberLabel]; + } + + #handlePieceHolderClickEvent(e, position) { + let onlyClickedSquareAndNoImage = true; + + let images = document.getElementsByClassName('piece-image'); + for (let i = 0; i < images.length; i++) { + if (images[i].contains(e.target)) { + onlyClickedSquareAndNoImage = false; + break; + } + } + + if (onlyClickedSquareAndNoImage) { + this.#clickedOnSquare(position); + } + } + + #clickedOnPiece(position) { + if (this.#isPlayerMoveEnabled) { + let board = this.#board; + let selected = this.#selectedPiecePosition; + + if (selected == null && board.isAllowedToPlayPieceAt(position)) { + // user is selecting the piece + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } else if (Position.areEquals(selected, position)) { + // if the piece user just clicked on is the same as the one he had selected before, + // then user is un-selecting the piece + this.#hideAllPiecePlaceHolders(); + } else if (selected != null && board.containOppositeColors(selected, position)) { + // user is capturing the piece he clicked on + this.registerMoveIfLegal(new HalfMove(selected, position)); + this.#hideAllPiecePlaceHolders(); + } else if (selected != null && board.containSameColors(selected, position) && board.isAllowedToPlayPieceAt(position)) { + // user is selecting another piece + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } + } + } + + #clickedOnSquare(position) { + if (this.#isPlayerMoveEnabled) { + if (this.#selectedPiecePosition != null) { + this.registerMoveIfLegal(new HalfMove(this.#selectedPiecePosition, position)); + this.#hideAllPiecePlaceHolders(); + } + } + } + + /** + * @param pieceChar {string} + * @param move {HalfMove} + */ + #drawMove(pieceChar, move) { + this.#unDrawPiece(move.from); + this.#unDrawPiece(move.to); + this.#drawPieceAt(pieceChar, move.to); + } + + #drawBoard() { + /** + * @param positionType {string} - one of 'bottom-right', 'bottom-left', 'top-right', 'top-left' + * @param drawingPosition {Position} + * @param positions {Position[]} + * @returns {HTMLDivElement|null} + */ + function drawCrosshairForPositionType(positionType, drawingPosition, positions) { + const crosshairToDraw = positions.find(crosshair => Position.areEquals(crosshair, drawingPosition)); + if (crosshairToDraw != null) { + const crosshair = document.createElement('div'); + crosshair.classList.add('crosshair-square', `crosshair-square-${positionType}`); + return crosshair; + } else { + return null; + } + } + + /** + * @param drawingPosition {Position} + * @returns {HTMLDivElement[]} + */ + function drawCrosshairs(drawingPosition) { + const crosshairTypes = [ + {type: 'bottom-right', positions: bottomRightCrosshairs}, + {type: 'bottom-left', positions: bottomLeftCrosshairs}, + {type: 'top-right', positions: topRightCrosshairs}, + {type: 'top-left', positions: topLeftCrosshairs} + ]; + + return crosshairTypes + .map(({type, positions}) => drawCrosshairForPositionType(type, drawingPosition, positions)) + .filter(crosshair => crosshair !== null); + } + + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + // draw rows + let row = document.createElement('div'); + row.className = 'rows'; + + for (let x = 0; x < BOARD_WIDTH; x++) { + let renderPosition; + if (this.#flippedRed) { + renderPosition = new Position(x, y); + } else { + renderPosition = new Position(BOARD_WIDTH - x - 1, BOARD_HEIGHT - y - 1); + } + + const pieceHolder = document.createElement('div'); + pieceHolder.id = this.#positionToElementId('square', renderPosition); + pieceHolder.className = 'piece-holder'; + pieceHolder.addEventListener('click', (e) => { + this.#handlePieceHolderClickEvent(e, renderPosition) + }); + + // drag and drop listeners + pieceHolder.addEventListener('dragover', (e) => e.preventDefault()); + pieceHolder.addEventListener('dragenter', (e) => e.preventDefault()); + pieceHolder.addEventListener('drop', (e) => { + this.#handlePieceHolderDropEvent(e, renderPosition); + }); + + const legalMovePlaceHolder = document.createElement('div'); + legalMovePlaceHolder.id = this.#positionToElementId('legal_move', renderPosition); + pieceHolder.appendChild(legalMovePlaceHolder); + + if (y === 5) { + if (x === 0) { + const river = buildDivWithClass('large-river'); + + if (!this.#options.mini) { + const riverOfTheChuContainer = buildDivWithClass('river-of-the-chu'); + riverOfTheChuContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/river-of-the-chu-brown.png`)); + + const borderOfTheHanContainer = buildDivWithClass('border-of-the-han'); + borderOfTheHanContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/border-of-the-han-brown.png`)); + + river.append( + riverOfTheChuContainer, + borderOfTheHanContainer + ); + } + + pieceHolder.append(river); + } + } else { + // draw row of visible squares + if (x < BOARD_WIDTH - 1 && y > 0) { + const visibleSquare = buildDivWithClass('visible-square'); + pieceHolder.appendChild(visibleSquare); + + if ((x === 3 && y === 2) || (x === 4 && y === 1) || (x === 3 && y === 9) || (x === 4 && y === 8)) { + if (this.#options.mini) { + visibleSquare.innerHTML = diagonalDescendingMini; + } else { + visibleSquare.innerHTML = diagonalDescending; + } + } + if ((x === 3 && y === 1) || (x === 4 && y === 2) || (x === 3 && y === 8) || (x === 4 && y === 9)) { + if (this.#options.mini) { + visibleSquare.innerHTML = diagonalRisingMini; + } else { + visibleSquare.innerHTML = diagonalRising; + } + } + + if (x === BOARD_WIDTH - 2) { + visibleSquare.classList.add('visible-square-last-file'); + } + + // draw crosshairs + drawCrosshairs(new Position(x, y)) + .forEach(element => visibleSquare.append(element)); + } + } + row.appendChild(pieceHolder); + } + this.#boardContainer.appendChild(row); + } + + // add bottom border to last row of visible squares + if (this.#flippedRed) { + for (let x = 0; x < BOARD_WIDTH - 1; x++) { + const position = new Position(x, 1); + const square = document.getElementById(this.#positionToElementId('square', position)); + square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row'); + } + } else { + for (let x = BOARD_WIDTH - 1; x > 0; x--) { + const position = new Position(x, BOARD_HEIGHT - 2); + const square = document.getElementById(this.#positionToElementId('square', position)); + square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row'); + } + } + + this.#drawCoordinates(); + + // TODO: hacky + if (this.#isSafari) { + let rows = document.getElementsByClassName('rows'); + for (let i = 0; i < rows.length; i++) { + rows[i].classList.add('safari-rows'); + } + + document.getElementById(this.#options.elementId).classList.add('safari-board-container'); + } + + if (this.#options.svg) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.id = 'board-svg'; + this.#boardContainer.append(svg); + } + + this.#forceSafariLayoutRefresh(); + } + + // https://stackoverflow.com/questions/9628507/how-can-i-tell-google-translate-to-not-translate-a-section-of-a-website + #drawCoordinates() { + function buildFileDiv(label, cssClass, visible) { + let span = document.createElement('span'); + span.classList.add('coordinates-labels', 'notranslate'); + span.innerHTML = label; + if (visible) { + span.style.visibility = 'visible'; + } else { + span.style.visibility = 'hidden'; + } + + let div = document.createElement('div'); + div.className = cssClass; + div.appendChild(span); + return div; + } + + if (this.#options.showCoordinates) { + const orientation = this.#options.coordinatesOrientation; + const isSettingEnabled = orientation !== null; + // when the user has disabled coordinates we still reserve the space (hidden labels), + // so we must pick an arbitrary orientation for the (invisible) labels: + const isWfxOriented = orientation !== CoordinatesOrientation.UCI; + + const fileNumbersStyle = this.#options.fileNumbersStyle; + // For a given side ('red' | 'black'), should we render Chinese numerals? + const isChineseOnSide = (side) => { + switch (fileNumbersStyle) { + case FileNumbersStyle.ARABIC_BOTH: + return false; + case FileNumbersStyle.CHINESE_BOTH: + return true; + case FileNumbersStyle.CHINESE_RED_ONLY: + default: + return side === 'red'; + } + }; + + // draw top file coordinates + if (isWfxOriented) { + let topCoordinatesY = BOARD_HEIGHT - 1; + if (!this.#flippedRed) { + topCoordinatesY = 0; + } + // top row shows black's side when red is at the bottom, and red's side when flipped + const topSide = this.#flippedRed ? 'black' : 'red'; + const topChinese = isChineseOnSide(topSide); + + for (let x = 0; x < BOARD_WIDTH; x++) { + // actual text + let label; + if (this.#flippedRed) { + // top row: file 1 is on the right (black perspective) + label = topChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString(); + } else { + // top row: file 1 is on the left (board flipped, red on top) + label = topChinese + ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1] + : (BOARD_WIDTH - x).toString(); + } + + let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled); + let squareId = this.#positionToElementId('square', new Position(x, topCoordinatesY)); + document.getElementById(squareId).appendChild(div); + } + } + + // draw bottom file coordinates + let bottomCoordinatesY = 0; + if (!this.#flippedRed) { + bottomCoordinatesY = BOARD_HEIGHT - 1; + } + // bottom row shows red's side when red is at the bottom, and black's side when flipped + const bottomSide = this.#flippedRed ? 'red' : 'black'; + const bottomChinese = isChineseOnSide(bottomSide); + for (let x = 0; x < BOARD_WIDTH; x++) { + // actual text + let label; + if (isWfxOriented) { + if (this.#flippedRed) { + // bottom row: file 1 is on the left (red perspective) + label = bottomChinese + ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1] + : (BOARD_WIDTH - x).toString(); + } else { + // bottom row: file 1 is on the right (board flipped, black at bottom) + label = bottomChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString(); + } + } else { + label = UCI_LETTER[x]; + } + + let div = buildFileDiv(label, 'file-coordinates-bottom', isSettingEnabled); + let squareId = this.#positionToElementId('square', new Position(x, bottomCoordinatesY)); + document.getElementById(squareId).appendChild(div); + } + + // draw right-side row coordinates + if (!isWfxOriented) { + let rightCoordinatesX = BOARD_WIDTH - 1; + if (!this.#flippedRed) { + rightCoordinatesX = 0; + } + for (let y = 0; y < BOARD_HEIGHT; y++) { + let span = document.createElement('span'); + span.classList.add('coordinates-labels', 'notranslate'); + span.innerHTML = (y + 1).toString(); + if (isSettingEnabled) { + span.style.visibility = 'visible'; + } else { + span.style.visibility = 'hidden'; + } + + let div = document.createElement('div'); + div.className = 'rows-coordinates-right'; + div.appendChild(span); + let squareId = this.#positionToElementId('square', new Position(rightCoordinatesX, y)); + document.getElementById(squareId).appendChild(div); + } + } + } + } + + #drawPieces() { + this.#board + .listPiecePositions() + .forEach(piecePosition => this.#drawPiece(piecePosition)); + } + + /** + * Resolves the URL of the image for a given piece char, using this board's + * configured `assetsBaseUrl` and `pieceStyle` options. Exposed so external + * widgets that share this board's visual identity (e.g. the position + * editor's piece palette) can use the exact same image set. + * + * @param pieceChar {string} + * @return {string} + */ + getPieceImageSource(pieceChar) { + const style = this.#options.pieceStyle.toLowerCase(); + return `${this.#options.assetsBaseUrl}/images/pieces/${style}/${pieceImageNames.get(pieceChar)}`; + } + + /** + * @param piecePosition {PieceAtPosition} + */ + #drawPiece(piecePosition) { + this.#drawPieceAt(piecePosition.piece.pieceChar, piecePosition.position); + } + + /** + * @param pieceChar {string} + * @param position {Position} + */ + #drawPieceAt(pieceChar, position) { + const square = document.getElementById(this.#positionToElementId('square', position)); + const img = document.createElement('img'); + img.id = this.#positionToElementId('image', position); + img.className = 'piece-image'; + if (isBlackPiece(pieceChar)) { + img.classList.add('piece-image-black'); + } + img.setAttribute('src', this.getPieceImageSource(pieceChar)); + img.addEventListener('click', () => this.#clickedOnPiece(position)); + img.addEventListener('dragstart', (e) => this.#dragStart(e, position)); + img.addEventListener('dragend', () => this.#dragEnd()); + square.prepend(img); + } + + /** + * Forces Safari to recalculate layout and positioning of board elements. + * + * Safari has known issues with layout calculations, particularly with CSS transforms, + * absolute positioning, and element positioning after DOM updates. This method applies + * various techniques to force Safari to trigger reflows and recalculate element positions. + * + * The method uses multiple approaches: + * - Hardware acceleration triggers via translateZ(0) transforms + * - Forced reflows by accessing offsetHeight + * - Position property manipulation on squares and piece images + * + * Called automatically after board state changes like moves, flips, and piece placement + * to ensure visual elements are correctly positioned in Safari. + * + * @private + */ + #forceSafariLayoutRefresh() { + if (this.#isSafari) { + setTimeout(() => { + const container = this.#boardContainer; + + // force container reflow + container.style.transform = 'translateZ(0)'; + container.offsetHeight; + container.style.transform = ''; + + // force reflow on all squares + container + .querySelectorAll('.piece-holder') + .forEach(square => { + square.style.position = 'relative'; + square.offsetHeight; + square.style.position = ''; + }); + + // force reflow on all piece images + container + .querySelectorAll('.piece-image') + .forEach(piece => { + piece.style.transform = 'translateZ(0)'; + piece.offsetHeight; + piece.style.transform = ''; + }); + }, 0); + } + } + + /** + * @param e {DragEvent} + * @param position {Position} + */ + #dragStart(e, position) { + console.log('drag start'); + + if (this.isPlayerMoveEnabled && this.#board.getColorAt(position) === this.#board.getColorToPlay()) { + e.dataTransfer.setData('text/plain', position.toUci()); + this.#showSelectedPositionAndLegalMovesPlaceHolders(position); + } else { + e.preventDefault(); + } + } + + #dragEnd() { + console.log('drag end'); + + this.#hideAllPiecePlaceHolders(); + } + + /** + * @param e {DragEvent} + * @param to {Position} + */ + #handlePieceHolderDropEvent(e, to) { + const uci = e.dataTransfer.getData('text/plain'); + const from = Position.parseUci(uci); + const move = new HalfMove(from, to); + this.registerMoveIfLegal(move, false); + this.#hideAllPiecePlaceHolders(); + } + + #unDrawAllPieces() { + Position.getAll().forEach(position => this.#unDrawPiece(position)); + } + + /** + * @param position {Position} + */ + #unDrawPiece(position) { + const square = document.getElementById(this.#positionToElementId('square', position)); + // NB: getElementsByClassName returns a live HTMLCollection; convert to a + // static array so removing elements doesn't skip siblings. + htmlCollectionToArray(square.getElementsByClassName('piece-image')) + .forEach(img => square.removeChild(img)); + } + + #hideAllPiecePlaceHolders() { + Position.getAll().forEach(position => { + // in case when the board is a mini board overview on an infinite scroll page + // the placeholder can be null if the miniboard has been discarded + // which would throw a bunch of errors in the console if we don't check for nullability + const placeHolder = this.#locateLegalMovePlaceHolderAt(position); + if (placeHolder != null) { + placeHolder.classList.remove( + 'legal-move-place-holder', + 'selected-piece', + 'possible-capture', + 'highlighted-last-move' + ); + } + }); + + this.#selectedPiecePosition = null; + this.#currentShowingLegalMovesFor = null; + } + + #showSelectedPositionAndLegalMovesPlaceHolders(position) { + // remove previous placeholders + this.#hideAllPiecePlaceHolders(); + + // show selected piece + this.#locateLegalMovePlaceHolderAt(position).classList.add('selected-piece'); + this.#selectedPiecePosition = position; + + // show legal moves and possible captures + this.#showLegalMovesPlaceHolders(position); + } + + /** + * @param position {Position} + */ + #showLegalMovesPlaceHolders(position) { + this + .#board + .listLegalMovesFrom(position) + .map(move => move.to) + .forEach(targetPosition => { + let placeHolder = this.#locateLegalMovePlaceHolderAt(targetPosition); + if (this.#board.containOppositeColors(position, targetPosition)) { + placeHolder.classList.add('possible-capture'); + } else { + placeHolder.classList.add('legal-move-place-holder'); + } + }); + + this.#currentShowingLegalMovesFor = position; + } + + highlightDebugMove(move, color) { + let from = this.#locateLegalMovePlaceHolderAt(move.from); + let to = this.#locateLegalMovePlaceHolderAt(move.to); + from.classList.add('highlighted-debug'); + from.style.backgroundColor = color; + to.classList.add('highlighted-debug'); + to.style.backgroundColor = color; + } + + hideAllDebugHighlight() { + Position.getAll().forEach(position => { + let element = this.#locateLegalMovePlaceHolderAt(position); + element.classList.remove('highlighted-debug'); + element.style.backgroundColor = null; + }); + } + + highlightDynamicMove(move) { + this.#hideAllPiecePlaceHolders(); + let from = this.#locateLegalMovePlaceHolderAt(move.from); + let to = this.#locateLegalMovePlaceHolderAt(move.to); + from.classList.add('highlighted-move'); + to.classList.add('highlighted-move'); + } + + hideAllHighlightedDynamicMoves() { + Position.getAll().forEach(position => { + let element = this.#locateLegalMovePlaceHolderAt(position); + element.classList.remove('highlighted-move'); + }); + } + + clearBoard() { + this.#unDrawAllPieces(); + this.#hideAllPiecePlaceHolders(); + this.#board.clearBoard(); + } + + /** + * @param color {string|null} + */ + flipToColor(color) { + if (color != null && ((this.#flippedRed && color === Color.BLACK) || (!this.#flippedRed && color === Color.RED))) { + this.flip(); + } + } + + /** + * Returns the color currently shown at the bottom of the board. + * + * @return {string} + */ + get bottomColor() { + return this.#flippedRed ? Color.RED : Color.BLACK; + } + + flip() { + this.#boardContainer.innerHTML = ''; + this.#flippedRed = !this.#flippedRed; + this.#drawBoard(); + this.#drawPieces(); + this.updateHighlightedChecks(); + this.#resetDraggableCursors(); + + const newColor = this.#flippedRed ? Color.RED : Color.BLACK; + this.#afterFlipListeners.forEach(listener => listener(newColor)); + this.#forceSafariLayoutRefresh(); + } + + /** + * + * @param moveFormat {string} + */ + updateMoveFormat(moveFormat) { + // easy solution: complete redraw (TODO: can more subtle) + this.#boardContainer.innerHTML = ''; + this.#drawBoard(); + this.#drawPieces(); + } + + reRenderPieces() { + this.#unDrawAllPieces(); + this.#drawPieces(); + } + + /** + * Update the piece style and re-render the pieces. Needed because the + * options object is otherwise frozen and the piece style can be changed at + * runtime by the user (via the settings menu). + * + * @param pieceStyle {string} one of {@link PieceStyleSetting} + */ + updatePieceStyle(pieceStyle) { + if (this.#options.pieceStyle === pieceStyle) { + return; + } + this.#options = Object.freeze({...this.#options, pieceStyle}); + this.reRenderPieces(); + } + + /** + * @param enabled {boolean} + */ + setColorblindFriendlyBlackPiecesEnabled(enabled) { + if (this.#options.colorblindFriendlyBlackPieces === enabled) { + return; + } + this.#options = Object.freeze({...this.#options, colorblindFriendlyBlackPieces: enabled}); + this.#renderColorblindFriendlyBlackPiecesSetting(enabled); + } + + /** + * Change which numeral system is used for file coordinates in WXF mode. + * + * @param fileNumbersStyle {string} one of {@link FileNumbersStyle} + */ + setFileNumbersStyle(fileNumbersStyle) { + if (this.#options.fileNumbersStyle === fileNumbersStyle) { + return; + } + this.#options = Object.freeze({...this.#options, fileNumbersStyle}); + this.#redrawCoordinates(); + } + + /** + * Change the orientation (WXF numerals vs UCI letters) of the board coordinates. + * + * @param coordinatesOrientation {string|null} one of {@link CoordinatesOrientation} or + * `null` to keep the labels hidden + */ + setCoordinatesOrientation(coordinatesOrientation) { + if (this.#options.coordinatesOrientation === coordinatesOrientation) { + return; + } + this.#options = Object.freeze({...this.#options, coordinatesOrientation}); + this.#redrawCoordinates(); + } + + #redrawCoordinates() { + // remove existing file-coordinate (top + bottom) and right-side rank labels + // (rank labels only exist in UCI mode but the selector is harmless if absent) + document.querySelectorAll(`#${this.#options.elementId} .file-coordinates-top, + #${this.#options.elementId} .file-coordinates-bottom, + #${this.#options.elementId} .rows-coordinates-right`) + .forEach(el => el.remove()); + this.#drawCoordinates(); + } + + /** + * @return {boolean} + */ + toggleShowCoordinates() { + let areVisible = this.#areCoordinatesVisible(); + this.#renderShowCoordinatesSetting(!areVisible); + return !areVisible; + } + + /** + * @param show {boolean} + */ + #renderShowCoordinatesSetting(show) { + let labels = document.getElementsByClassName('coordinates-labels'); + for (let label of labels) { + label.style.visibility = show ? 'visible' : 'hidden'; + } + } + + /** + * @param enabled {boolean} + */ + #renderColorblindFriendlyBlackPiecesSetting(enabled) { + this.#boardContainer.classList.toggle('colorblind-friendly-black-pieces', enabled); + } + + #areCoordinatesVisible() { + let labels = document.getElementsByClassName('coordinates-labels'); + for (let label of labels) { + if (label.style.visibility === 'hidden') { + return false; + } + } + return true; + } + + #hideAllDraggableCursors() { + this.#board + .listPiecePositions() + .forEach(pieceAtPosition => { + const imageId = this.#positionToElementId('image', pieceAtPosition.position); + const image = document.getElementById(imageId); + // image may be momentarily absent while an animation is in + // flight (source-square images are renamed to 'animating-xxx' + // until the animation commits). In that case there's nothing + // to update here; the animation's onDone will re-run cursor + // bookkeeping via #resetDraggableCursors(). + if (image != null) { + image.classList.remove('piece-image-can-move'); + } + }); + } + + #resetDraggableCursors() { + const isMated = () => { + try { + return this.#board.isMated(); + } catch (e) { + // FIXME: happens when re-loading a game where it's your turn to play and enablePlayerMove is called + console.warn('error while checking if mated'); + return false; + } + } + + this.#hideAllDraggableCursors(); + + if (this.isPlayerMoveEnabled && !isMated()) { + const colorToPlay = this.#board.getColorToPlay(); + + this.#board + .listPiecePositions() + .filter(piecePosition => colorToPlay === piecePosition.pieceColor) + .map(piecePosition => piecePosition.position) + .filter(position => this.#board.listLegalMovesFrom(position).length > 0) + .forEach(position => { + const imageId = this.#positionToElementId('image', position); + const image = document.getElementById(imageId); + // see #hideAllDraggableCursors: image may be absent + // mid-animation (ID temporarily renamed). + if (image != null) { + image.classList.add('piece-image-can-move'); + } + }); + } + } + + /** + * @param position {Position} + * @returns {HTMLElement} + */ + #locateLegalMovePlaceHolderAt(position) { + return document.getElementById(this.#positionToElementId('legal_move', position)); + } + + /** + * @param position {Position} + * @param prefix {string} + * @returns {string} + */ + #positionToElementId(prefix, position) { + return boardPositionToElementId(this.#options.elementId, prefix.toLowerCase(), position); + } + +} + +/** + * + * @param boardId {string} + * @param prefix {string} + * @param position {Position} + */ +function boardPositionToElementId(boardId, prefix, position) { + return `${boardId}-${prefix.toLowerCase()}-${position.x}-${position.y}`; +} + +/** + * @param elementId {string} + * @returns {Position} + */ +function parsePositionFromElementId(elementId) { + const split = elementId.split('-'); + const x = Number(split[split.length - 2]); + const y = Number(split[split.length - 1]); + return new Position(x, y); +} + +/** + * Adds a miniature board that appears on hover for an element. + * Uses {@link createWebappBoardGui} so piece images are served from the + * correct base URL (local server in dev, CDN in production). + * + * @param element {HTMLElement} - The element to attach hover listeners to + * @param gameId {string} - Unique identifier for this miniboard + * @param fen {string} - FEN string representing the board position + * @param playerColor {string} - Color to flip the board to + * @param lazy {boolean} - If true, only create the board on first mouseenter (default: false) + * @returns {HTMLElement|null} - The created miniboard div (null if lazy and not yet created) + */ +function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) { + const LEFT_MARGIN = 12; + const MINI_BOARD_HEIGHT = 256 / 0.9; + + const miniBoardId = `mini-board-overview-${gameId}`; + let miniBoardDiv = null; + + function createBoard() { + if (miniBoardDiv) return; + + miniBoardDiv = document.createElement('div'); + miniBoardDiv.id = miniBoardId; + miniBoardDiv.classList.add( + 'board-container', + 'mini-board-container', + 'mini-board-overview' + ); + + document.body.appendChild(miniBoardDiv); + + const boardGui = createWebappBoardGui({ + elementId: miniBoardId, + showCoordinates: false, + mini: true, + forceRenderChecks: true, + }); + boardGui.loadFen(fen); + boardGui.flipToColor(playerColor); + boardGui.updateHighlightedChecks(); + } + + // Create board immediately if not lazy + if (!lazy) { + createBoard(); + } + + // listeners + function showMiniboard() { + // Create board on first hover if lazy + if (!miniBoardDiv) { + createBoard(); + } + + const gameItemRect = element.getBoundingClientRect(); + const left = gameItemRect.right + LEFT_MARGIN + window.scrollX; + const top = gameItemRect.top + window.scrollY + (gameItemRect.height / 2) - (MINI_BOARD_HEIGHT / 2); + miniBoardDiv.style.top = `${top}px`; + miniBoardDiv.style.left = `${left}px`; + miniBoardDiv.style.display = 'block'; + } + + function hideMiniboard() { + if (miniBoardDiv) { + miniBoardDiv.style.display = 'none'; + } + } + + element.addEventListener('mouseenter', showMiniboard); + element.addEventListener('mouseleave', hideMiniboard); + + return miniBoardDiv; +} diff --git a/webapp/src/main/resources/public/dist/0.0.4/board.css b/webapp/src/main/resources/public/dist/0.0.4/board.css new file mode 100644 index 000000000..3750030e1 --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.4/board.css @@ -0,0 +1,418 @@ +/* + * 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 . + */ + +:root { + --crosshair-margin: 5%; +} + +#board-svg { + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + z-index: 110; + pointer-events: none; +} + +.board-container { + margin: auto; + aspect-ratio: 0.9; + background-color: #876e59; + padding: 4%; + text-align: initial; + touch-action: manipulation; + position: relative; + /* Matches the shared .basic-box / .info-box rule in style.css so board.css + renders identically when used standalone (as a library). */ + box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em; + border: 2px solid #323232; + border-radius: 3px; +} + +.safari-board-container { + display: flex; + display: -webkit-box; +} + +.board-outer-container { + position: relative; + width: 80%; +} + +.board-container-placeholder { + background-color: #808080; +} + +.board-container-placeholder .visible-square, +.board-container-placeholder .large-river { + background-color: #dbd8d8; +} + +.board-container-placeholder .piece-image { + filter: grayscale(90%); +} + +.board-container-placeholder .coordinates-labels { + color: #dbd8d8; +} + +a.board-outer-container-link-mask { + position: absolute; + aspect-ratio: 0.9; + z-index: 150; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + top: 0; + left: 0; + width: 100%; +} + +.rows { + height: 10%; +} + +/* TODO: could be replaced by '.safari-board .rows' */ +.safari-rows { + height: 9%; + display: inline-block; + vertical-align: top; +} + +.piece-holder { + float: left; + position: relative; + height: 100%; + aspect-ratio: 1; +} + +.piece-holder .piece-image { + width: 95%; + height: 95%; + margin: 2.5%; + position: absolute; + border-radius: 100%; + box-shadow: rgba(0, 0, 0, 0.24) 0px 2px 5px; + z-index: 100; +} + +.board-container.colorblind-friendly-black-pieces .piece-image-black { + filter: invert(1); +} + +.piece-holder .piece-image-can-move { + cursor: move; +} + +.piece-holder .piece-image-moving { + z-index: 110; +} + +.crosshair-square { + position: absolute; + border: 2px solid rgba(10, 10, 10, 0.8); + /*background-color: rgba(0, 0, 227, 0.47);*/ + width: 19%; + height: 19%; + z-index: 80; + box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +.crosshair-square-bottom-right { + bottom: var(--crosshair-margin); + right: var(--crosshair-margin); + border-top-width: 0; + border-left-width: 0; +} + +.crosshair-square-bottom-left { + bottom: var(--crosshair-margin); + left: var(--crosshair-margin); + border-top-width: 0; + border-right-width: 0; +} + +.crosshair-square-top-right { + top: var(--crosshair-margin); + right: var(--crosshair-margin); + border-bottom-width: 0; + border-left-width: 0; +} + +.crosshair-square-top-left { + top: var(--crosshair-margin); + left: var(--crosshair-margin); + border-bottom-width: 0; + border-right-width: 0; +} + +/* 2px because it's the border size of visible-square */ +.safari-board-container .visible-square .crosshair-square-bottom-right, +.safari-board-container .visible-square .crosshair-square-top-right { + right: calc(var(--crosshair-margin) + 2px); +} + +.safari-board-container .visible-square-last-file .crosshair-square-bottom-right, +.safari-board-container .visible-square-last-file .crosshair-square-top-right { + right: var(--crosshair-margin); +} + +/* 1px because it's the border size of visible-square */ +.safari-mini-board-container .visible-square .crosshair-square-bottom-right, +.safari-mini-board-container .visible-square .crosshair-square-top-right { + right: calc(var(--crosshair-margin) + 1px); +} + +.safari-mini-board-container .visible-square-last-file .crosshair-square-bottom-right, +.safari-mini-board-container .visible-square-last-file .crosshair-square-top-right { + right: var(--crosshair-margin); +} + +.visible-square, .large-river { + background-color: #ead1af; + height: 100%; + margin: 50%; + aspect-ratio: 1; + border: 2px solid rgba(10, 10, 10, 0.8); + border-right-width: 0; + border-bottom-width: 0; + position: absolute; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +.large-river { + aspect-ratio: 8; + height: 100%; +} + +.visible-square-last-file, +.large-river { + border-right-width: 2px; +} + +.visible-square-last-row { + border-bottom-width: 2px; +} + +.river-of-the-chu, +.border-of-the-han { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 48%; +} + +.river-of-the-chu { + float: left; +} + +.border-of-the-han { + float: right; +} + +.river-of-the-chu img, +.border-of-the-han img { + opacity: 60%; + height: 70%; +} + +.mini-board-container .piece-image { + box-shadow: none; +} + +.mini-board-container .visible-square, +.mini-board-container .large-river { + border-width: 1px; + border-bottom-width: 0; + border-right-width: 0; +} + +.mini-board-container .visible-square-last-row { + border-bottom-width: 1px; +} + +.mini-board-container .visible-square-last-file, +.mini-board-container .large-river { + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-bottom-right { + border-bottom-width: 1px; + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-bottom-left { + border-bottom-width: 1px; + border-left-width: 1px; +} + +.mini-board-container .crosshair-square-top-right { + border-top-width: 1px; + border-right-width: 1px; +} + +.mini-board-container .crosshair-square-top-left { + border-top-width: 1px; + border-left-width: 1px; +} + +.mini-board-overview { + display: none; + position: absolute; + z-index: 700; + max-width: 236px; + width: 236px; + padding: 8px; +} + +.safari-mini-board-container .safari-rows { + height: 10%; +} + +.safari-mini-board-container { + height: 306px; + width: 276px; +} + +.file-coordinates-top, +.file-coordinates-bottom, +.rows-coordinates-right { + font-size: 12px; + color: #ead1af; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} + +.file-coordinates-bottom { + margin-top: 100%; + margin-left: 50%; +} + +.file-coordinates-top { + margin-top: -25%; + margin-left: 40%; +} + +.rows-coordinates-right { + margin-left: 65%; + width: 100%; + height: 100%; +} + +.legal-move-place-holder, +.selected-piece, +.possible-capture, +.highlighted-debug, +.highlighted-move, +.highlighted-check, +.highlighted-checkmate, +.highlighted-last-move { + position: absolute; + border-radius: 100%; + z-index: 10; +} + +.selected-piece, +.possible-capture, +.highlighted-debug, +.highlighted-check { + width: 110%; + height: 110%; + margin: -5%; +} + +.highlighted-checkmate, +.highlighted-move { + width: 120%; + height: 120%; + margin: -10%; +} + +.legal-move-place-holder { + width: 75%; + height: 75%; + margin: 12.5%; +} + +.highlighted-last-move { + width: 115%; + height: 115%; + margin: -7.5%; +} + +.legal-move-place-holder { + background-color: rgba(0, 64, 255, 0.4); +} + +.selected-piece { + background-color: rgba(40, 229, 27, 0.7); +} + +.possible-capture { + background-color: blueviolet; +} + +.highlighted-check { + background-color: orange; +} + +.highlighted-checkmate { + background-color: red; +} + +.highlighted-move { + background-color: rgba(247, 247, 17, 0.79); +} + +.highlighted-last-move { + border: 5px dashed rgba(13, 98, 7, 0.7); + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +/* reactive: hide river/han decorations on narrower viewports */ +@media (max-width: 1400px) { + .river-of-the-chu, + .border-of-the-han { + display: none; + } +} + +/* reactive: enlarge board on small viewports */ +@media (max-width: 1000px) { + .board-outer-container-link-mask, + .board-outer-container { + width: 90%; + } + + /* x2 compared to normal */ + .safari-mini-board-container { + height: 612px; + width: 552px; + } +} diff --git a/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js b/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js new file mode 100644 index 000000000..ce9ab435b --- /dev/null +++ b/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js @@ -0,0 +1,1405 @@ +/* + * Copyright (C) 2026 Encelade SRL + * Copyright (C) 2026 elephantchess.io + * Copyright (C) 2026 Benoît Vleminckx (benckx) + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +const BOARD_WIDTH = 9; +const BOARD_HEIGHT = 10; +const UCI_LETTER = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']; +const PIECES_CHARS = ['c', 'r', 'n', 'b', 'a', 'k', 'p']; + +const DEFAULT_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 0'; + +// Piece color enum. Lives here (rather than in enums.js) so that xiangqi.js +// can be used standalone (together with board-gui.js) without pulling in the +// rest of the webapp's modules. +const Color = Object.freeze({ + RED: 'RED', + BLACK: 'BLACK' +}); + +/** + * @param char {string} + * @return {boolean} + */ +function isRedPiece(char) { + return char.length === 1 && char.toUpperCase() === char; +} + +/** + * @param char {string} + * @return {boolean} + */ +function isBlackPiece(char) { + return char.length === 1 && char.toLowerCase() === char; +} + +/** + * @param char {string} + * @return {string} + */ +function charToPieceColor(char) { + if (char.length !== 1) { + throw new Error('Not a single char: ' + char); + } else { + if (isRedPiece(char)) { + return Color.RED; + } else if (isBlackPiece(char)) { + return Color.BLACK; + } else { + throw new Error('Illegal piece ' + char); + } + } +} + +function reverseColor(color) { + switch (color.toUpperCase()) { + case Color.RED: + return Color.BLACK; + case Color.BLACK: + return Color.RED; + default: + throw Error('Illegal argument color ' + color); + } +} + +function colorToUci(color) { + switch (color.toUpperCase()) { + case Color.RED: + return 'w'; + case Color.BLACK: + return 'b'; + default: + throw Error('Illegal argument color ' + color); + } +} + +/** + * Reset full count to 0 + */ +function resetFenFullMovesCount(fen) { + let split = fen.split(' '); + return split.slice(0, split.length - 1).join(' ') + ' 0'; +} + +function validateStartFen(fen) { + let board = new Board(); + board.loadFen(fen); + + if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) { + throw new Error('Start position is checkmate'); + } else if (board.isStalemate(Color.RED) || board.isStalemate(Color.BLACK)) { + throw new Error('Start position is stalemate'); + } +} + +/** + * Add full moves count and convert to single string + * @param {string[]} movesAsPgn + */ +function toSingleLinePgn(movesAsPgn) { + let pgn = ''; + for (let i = 0; i < movesAsPgn.length; i++) { + let move = movesAsPgn[i]; + if (i % 2 === 0) { + pgn += (Math.floor(i / 2) + 1) + '. '; + } + pgn += move + ' '; + } + return pgn; +} + +/** + * https://en.wikipedia.org/wiki/Xiangqi#System_3 + * + * @param {HalfMove[]} moves + * @param {boolean} renderCheckIndicators + * @param {string} startFen + * @return {string[]} + */ +function translateMovesToPgn(moves, renderCheckIndicators = true, startFen = DEFAULT_START_FEN) { + let movesAsPgn = []; + + let board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + let pieceFrom = board.getPieceAt(move.from); + let pieceTarget = board.getPieceAt(move.to); + let newPosition = move.to.toAlgebraic(); + + let capture = ''; + if (pieceTarget != null) { + capture = 'x'; + } + let pieceLetter = pieceFrom.pieceChar.toUpperCase(); + let formerPosition = UCI_LETTER[move.from.x]; + let sameFile = move.from.x === move.to.x; + if (pieceLetter === 'K' || sameFile) { + formerPosition = ''; + } + + board.registerMove(move); + + let checkIndicator = ''; + if (renderCheckIndicators) { + if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) { + checkIndicator += '#'; + } else if (board.isInCheck(Color.RED) || board.isInCheck(Color.BLACK)) { + checkIndicator += '+'; + } + } + + let moveString = `${pieceLetter}${formerPosition}${capture}${newPosition}${checkIndicator}`; + movesAsPgn.push(moveString); + }); + + return movesAsPgn; +} + +/** + * @param moves {HalfMove[]} + * @param startFen {string} + * @return {string[]} + */ +function translateMovesToPrefixedAlgebraic(moves, startFen = DEFAULT_START_FEN) { + const formattedMoves = []; + const board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + const pieceChar = board.getPieceAt(move.from).pieceChar.toUpperCase(); + const moveString = `${pieceChar} ${move.toAlgebraic()}`; + board.registerMove(move); + formattedMoves.push(moveString); + }); + + return formattedMoves; +} + +/** + * @param moves {HalfMove[]} + * @param horizontalSeparator {string} can be '=' or '.' + * @param startFen {string} + * @return {string[]} + */ +function translateMovesToWxf(moves, horizontalSeparator, startFen) { + + /** + * File number in WXF format starts at 1 from the right hand of the player + * + * @return {number} + */ + function fileAsWxf(color, file) { + switch (color) { + case Color.RED: + return BOARD_WIDTH - file; + case Color.BLACK: + return file + 1; + default: + throw new Error('Illegal color ' + color); + } + } + + /** + * @param color {string} + * @param move {HalfMove} + * @return {string} e.g. Direction indicator sign (+ or -) + */ + function verticalMoveDirectionChar(color, move) { + if ((color === Color.BLACK && move.from.y > move.to.y) || (color === Color.RED && move.from.y < move.to.y)) { + return '+'; + } else { + return '-'; + } + } + + /** + * @param color {string} + * @param move {HalfMove} + * @return {string} e.g. Direction indicator sign (+ or -) followed by distance + */ + function verticalMove(color, move) { + let direction = verticalMoveDirectionChar(color, move); + let distance = Math.abs(move.from.y - move.to.y); + return `${direction}${distance}`; + } + + /** + * File number or sign (+ or -) + * + * @param board {Board} + * @param move {HalfMove} + * @return {string} + */ + function fileNumberOrSign(board, move) { + let physicalPiece = board.getPieceAt(move.from); + let color = physicalPiece.color; + + let allSimilarPiecesOnFile = board.listPiecePositions().filter(pieceAtPosition => { + return pieceAtPosition.piece.pieceChar === physicalPiece.pieceChar && pieceAtPosition.position.x === move.from.x + }); + + if (allSimilarPiecesOnFile.length === 2) { + let currentFileY; + let otherFileY; + if (move.from.y === allSimilarPiecesOnFile[0].position.y) { + currentFileY = allSimilarPiecesOnFile[0].position.y; + otherFileY = allSimilarPiecesOnFile[1].position.y; + } else { + currentFileY = allSimilarPiecesOnFile[1].position.y; + otherFileY = allSimilarPiecesOnFile[0].position.y; + } + + let isOnTop = (currentFileY > otherFileY && color === Color.RED) + || (currentFileY < otherFileY && color === Color.BLACK); + + return isOnTop ? '+' : '-'; + } + + return fileAsWxf(color, move.from.x).toString(); + } + + let formattedMoves = []; + let board = new Board(); + board.loadFen(startFen); + + moves.forEach(move => { + let physicalPiece = board.getPieceAt(move.from); + if (physicalPiece == null) { + board.printBoard(); + throw new Error(`no piece at ${move.from.toAlgebraic()}`); + } + let color = physicalPiece.color; + let pieceType = physicalPiece.pieceChar.toUpperCase(); + + let moveStr = ''; + switch (pieceType) { + case 'P': + case 'C': + case 'R': + case 'K': + if (move.isHorizontal()) { + let fileStr = fileNumberOrSign(board, move); + let newFile = fileAsWxf(color, move.to.x); + moveStr = `${fileStr}${horizontalSeparator}${newFile}`; + } else { + moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`; + } + break; + case 'B': + case 'A': + case 'N': + let currentFileStr = fileNumberOrSign(board, move); + let direction = verticalMoveDirectionChar(color, move); + let newFile = fileAsWxf(color, move.to.x); + moveStr = `${currentFileStr}${direction}${newFile}`; + break; + } + + let pieceCharacter; + + switch (pieceType) { + case 'N': + pieceCharacter = 'H'; + break; + case 'B': + pieceCharacter = 'E'; + break; + default: + pieceCharacter = pieceType; + break; + } + + formattedMoves.push(`${pieceCharacter}${moveStr}`); + board.registerMove(move); + }); + + return formattedMoves; +} + +/** + * + * Generic version of the functions above + * + * @param moves {HalfMove[]} + * @param moveFormat {string} + * @param startFen {string} + * @return {string[]} + */ +function translateMovesFormat(moves, moveFormat, startFen) { + switch (moveFormat) { + case MoveFormatSetting.WXF_DOT: + return translateMovesToWxf(moves, '.', startFen); + case MoveFormatSetting.WXF_EQUALS: + return translateMovesToWxf(moves, '=', startFen); + case MoveFormatSetting.PGN: + return translateMovesToPgn(moves, false, startFen); + case MoveFormatSetting.ALGEBRAIC_EN: + return translateMovesToPrefixedAlgebraic(moves, startFen); + default: + throw new Error('Unsupported move format ' + moveFormat); + } +} + +/** + * + * Mainly for debugging purposes + * + * @param allMoves {HalfMove[]} + * @param format {string} + * @param startFen {string} + * @return {string[]} + */ +function safeTranslateMovesFormat(allMoves, format, startFen) { + try { + return translateMovesFormat(allMoves, format, startFen); + } catch (e) { + console.error(e); + console.info(`startFen ${startFen}`); + console.info(`moves [${allMoves.length}] ${allMoves.map(move => move.toAlgebraic())}`); + return allMoves.map(move => move.toAlgebraic()); + } +} + +/** + * + * @param moves {HalfMove[]} + * @param moveFormat {string} + * @param startFen {string} + * @return {null|string} + */ +function translateMovesFormatTakeLast(moves, moveFormat, startFen) { + let translated = translateMovesFormat(moves, moveFormat, startFen); + if (translated.length > 0) { + return translated[translated.length - 1]; + } else { + return null; + } +} + +/** + * @param {HalfMove[]} moves + * @return {string} + */ +function exportMovesToPgnLine(moves) { + return toSingleLinePgn(translateMovesToPgn(moves)); +} + +/** + * @param moves {HalfMove[]} + * @param startFen {string} + * @return {string} + */ +function calculateFen(moves, startFen = DEFAULT_START_FEN) { + const board = new Board(); + board.loadFen(startFen); + moves.forEach(move => board.registerMove(move)); + return board.outputFen(); +} + +class Position { + + static redGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), 0); + static blackGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), BOARD_HEIGHT - 1); + + constructor(x, y) { + this.x = x; + this.y = y; + } + + get file() { + return UCI_LETTER[this.x]; + } + + /** + * @return {number} + */ + get rank() { + return this.y; + } + + existsOnBoard() { + return this.x >= 0 && + this.y >= 0 && + this.x < BOARD_WIDTH && + this.y < BOARD_HEIGHT; + } + + isInRedPalace() { + return this.x >= 3 && + this.x <= 5 && + this.y <= 2; + } + + isInBlackPalace() { + return this.x >= 3 && + this.x <= 5 && + this.y >= BOARD_HEIGHT - 3; + } + + getTop() { + return new Position(this.x, this.y + 1); + } + + getBottom() { + return new Position(this.x, this.y - 1); + } + + getLeft() { + return new Position(this.x - 1, this.y); + } + + getRight() { + return new Position(this.x + 1, this.y); + } + + /** + * @return {Position[]} + */ + getAllTop() { + let squares = []; + for (let y = this.y + 1; y < BOARD_HEIGHT; y++) { + squares.push(new Position(this.x, y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllBottom() { + let squares = []; + for (let y = this.y - 1; y >= 0; y--) { + squares.push(new Position(this.x, y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllLeft() { + let squares = []; + for (let x = this.x - 1; x >= 0; x--) { + squares.push(new Position(x, this.y)); + } + return squares; + } + + /** + * @return {Position[]} + */ + getAllRight() { + let squares = []; + for (let x = this.x + 1; x < BOARD_WIDTH; x++) { + squares.push(new Position(x, this.y)); + } + return squares; + } + + getTopLeft() { + return new Position(this.x - 1, this.y + 1); + } + + getTopRight() { + return new Position(this.x + 1, this.y + 1); + } + + getBottomLeft() { + return new Position(this.x - 1, this.y - 1); + } + + getBottomRight() { + return new Position(this.x + 1, this.y - 1); + } + + isEqualsTo(other) { + return this.x === other.x && this.y === other.y; + } + + /** + * 0-based + */ + toUci() { + return this.file + this.y; + } + + /** + * 1-based + */ + toAlgebraic() { + return this.file + (this.y + 1); + } + + toString() { + return this.toUci(); + } + + /** + * @return {Position[]} + */ + static getAll() { + let positions = []; + for (let x = 0; x < BOARD_WIDTH; x++) { + for (let y = 0; y < BOARD_HEIGHT; y++) { + positions.push(new Position(x, y)); + } + } + return positions; + } + + static areEquals(p1, p2) { + return p1 != null && p2 != null && p1.isEqualsTo(p2); + } + + /** + * @param uci {string} + * @return {Position} + */ + static parseUci(uci) { + if (uci.length !== 2) { + throw new Error('Incorrect position UCI: ' + uci); + } + let x = UCI_LETTER.indexOf(uci[0]) + if (x < 0) { + throw new Error('Incorrect position UCI: ' + uci); + } + if (x >= BOARD_WIDTH) { + throw new Error('Incorrect position UCI: ' + uci); + } + return new Position(x, Number(uci[1])); + } + +} + +class PhysicalPiece { + + #pieceChar; + #initPosition; + + /** + * @param pieceChar {string} + * @param initPosition {Position} + */ + constructor(pieceChar, initPosition) { + if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) { + throw new Error('Invalid piece ' + pieceChar); + } + + this.#pieceChar = pieceChar; + this.#initPosition = initPosition; + } + + /** + * @return {string} + */ + get pieceChar() { + return this.#pieceChar; + } + + get color() { + return charToPieceColor(this.#pieceChar); + } + + /** + * @param other {PhysicalPiece} + * @return {boolean} + */ + isEqualsTo(other) { + return other != null && + this.#pieceChar === other.#pieceChar && + this.#initPosition.isEqualsTo(other.#initPosition); + } + + toString() { + return `${this.#pieceChar}{${this.#initPosition.toAlgebraic()}}`; + } + +} + +class PieceAtPosition { + + /** + * @param piece {PhysicalPiece} + * @param position {Position} + */ + constructor(piece, position) { + this.piece = piece; + this.position = position; + } + + /** + * @return {string} + */ + get pieceColor() { + return this.piece.color; + } + + isColor(color) { + return this.pieceColor === color; + } + + /** + * @param other {PieceAtPosition} + */ + isEqualsTo(other) { + return other != null && + this.piece.pieceChar === other.piece.pieceChar && + this.position.isEqualsTo(other.position); + } + + toString() { + return this.piece.pieceChar + ' at ' + this.position.toAlgebraic(); + } + +} + +class HalfMove { + + /** + * @type {Position} + */ + from; + + /** + * @type {Position} + */ + to; + + constructor(from, to) { + this.from = from; + this.to = to; + } + + toUci() { + return this.from.toUci() + this.to.toUci(); + } + + toAlgebraic() { + return this.from.toAlgebraic() + this.to.toAlgebraic(); + } + + isHorizontal() { + return this.from.y === this.to.y; + } + + isVertical() { + return this.from.x === this.to.x; + } + + toString() { + return this.toAlgebraic(); + } + + /** + * @param uci {string} + * @return {HalfMove} + */ + static parseUci(uci) { + if (uci.length !== 4) { + throw new Error('Incorrect move UCI: ' + uci); + } + let from = Position.parseUci(uci.substring(0, 2)); + let to = Position.parseUci(uci.substring(2, 4)); + return new HalfMove(from, to); + } + + /** + * @param movesAsUci {string[]} + */ + static parseUciMultipleMoves(movesAsUci) { + return movesAsUci.map(moveAsUci => HalfMove.parseUci(moveAsUci)); + } + + static areEquals(m1, m2) { + return m1 != null && m2 != null && Position.areEquals(m1.from, m2.from) && Position.areEquals(m1.to, m2.to); + } + + static uciToAlgebraic(uci) { + return HalfMove.parseUci(uci).toAlgebraic(); + } + +} + +class Board { + + #content = []; + #redToPlay = true; + #enforceColorTurn = true; + #fullMovesCounts = 0; + + constructor() { + for (let x = 0; x < BOARD_WIDTH; x++) { + this.#content.push([null]); + for (let y = 0; y < BOARD_HEIGHT; y++) { + this.#content[x][y] = null; + } + } + } + + // TODO: update "full moves count" accordingly + loadFen(fen) { + const split = fen.split(' '); + const positionsFen = split[0]; + const gameStateFen = split[1]; + const fenLines = positionsFen.trim().split("/") + if (fenLines.length !== BOARD_HEIGHT) { + throw new Error('Invalid FEN: wrong number of component'); + } + this.clearBoard(); + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + this.#loadFenLine(fenLines[BOARD_HEIGHT - 1 - y], y); + } + + switch (gameStateFen.toLowerCase().trim()[0]) { + case 'w': + case 'r': + this.#redToPlay = true; + break; + case 'b': + this.#redToPlay = false; + break; + default: + throw new Error('Invalid FEN: can not determine which side plays next'); + } + } + + /** + * @param fenLine {string} + * @param y {number} + */ + #loadFenLine(fenLine, y) { + let x = 0 + for (let c = 0; fenLine.length - 1 && x < BOARD_WIDTH; c++) { + const char = fenLine.charAt(c); + if (char >= '0' && char <= '9') { + x += Number(char); + } else { + this.#content[x][y] = new PhysicalPiece(char, new Position(x, y)); + x++; + } + } + } + + outputFen() { + let ranks = []; + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + ranks.push(this.#rankToFen(y)); + } + return ranks.join('/') + ' ' + colorToUci(this.getColorToPlay()) + ' - - 0 ' + this.#fullMovesCounts.toString(); + } + + #rankToFen(rank) { + let rankPieces = Position + .getAll() + .filter(position => position.y === rank) + .map(position => this.getPieceAt(position)); + + let count = 0 + let result = ''; + rankPieces.forEach(piece => { + if (piece == null) { + count += 1; + } else { + if (count > 0) { + result += count.toString(); + count = 0; + } + result = result.concat(piece.pieceChar); + } + }); + if (count > 0) { + result += count.toString(); + } + + return result; + } + + /** + * @param position {Position} + * @return {PhysicalPiece|null} + */ + getPieceAt(position) { + return this.#content[position.x][position.y]; + } + + /** + * @param position {Position} + */ + removePieceFrom(position) { + this.#content[position.x][position.y] = null; + } + + /** + * + * @param pieceChar {string} + * @param position {Position} + * @param enforcePlacementRules {boolean} + */ + addPieceAt(pieceChar, position, enforcePlacementRules) { + if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) { + throw new Error('Invalid char: ' + pieceChar); + } + + // TODO: enforce rules (elephant, king, etc.) + + const physicalPiece = new PhysicalPiece(pieceChar, position); + this.#setPieceAt(physicalPiece, position); + } + + /** + * @param piece {PhysicalPiece} + * @param position {Position} + */ + #setPieceAt(piece, position) { + this.#content[position.x][position.y] = piece; + } + + /** + * @return {PieceAtPosition[]} + */ + listPiecePositions() { + return Position + .getAll() + .filter(position => this.#hasPieceAt(position)) + .map(position => new PieceAtPosition(this.getPieceAt(position), position)); + } + + /** + * Find all positions where a piece of type {@param piece} is located + * (i.e. max 5 for pawns, max 2 for knights, etc.) + * + * @param pieceChar {string} + * @return {Position[]} + */ + listPositionsForPiece(pieceChar) { + let result = []; + Position.getAll().forEach(position => { + let physicalPiece = this.getPieceAt(position); + if (physicalPiece != null && physicalPiece.pieceChar === pieceChar) { + result.push(position); + } + }); + return result; + } + + clearBoard() { + this.#fullMovesCounts = 0; + Position.getAll().forEach(position => this.removePieceFrom(position)); + } + + /** + * @return {boolean} true if the piece at {@param position} matches the color that has to play now, + * or if "enforcedColorTurn" is disabled + */ + isAllowedToPlayPieceAt(position) { + if (this.#enforceColorTurn) { + return this.#hasPieceAt(position) && this.isColorToPlay(this.getPieceAt(position)); + } else { + // FIXME: here we don't check it's not empty? doesn't seem consistent + return true; + } + } + + getColorToPlay() { + if (this.#redToPlay) { + return Color.RED; + } else { + return Color.BLACK; + } + } + + /** + * @param piece {PhysicalPiece} + * @return {boolean} + */ + isColorToPlay(piece) { + return (piece.color === Color.RED && this.#redToPlay) || (piece.color === Color.BLACK && !this.#redToPlay); + } + + /** + * @param color {string} + */ + forceColorToPlay(color) { + this.#redToPlay = (color === Color.RED); + } + + /** + * Return captured piece, or null if no piece was taken + */ + registerMove(move) { + let piece = this.getPieceAt(move.from); + if (piece == null) { + throw new Error('No piece to move at ' + move.from); + } else if (this.#enforceColorTurn && !this.isColorToPlay(piece)) { + throw new Error(`It is ${this.getColorToPlay()}'s turn to play`); + } else { + if (this.#isPossibleMove(move)) { + return this.#registerMove(piece, move) + } else { + throw new Error(`Move ${move} is not possible for ${piece.pieceChar}`); + } + } + } + + /** + * @param piece {PhysicalPiece} + * @param move {HalfMove} + * @return {PhysicalPiece|null} captured piece, or null if no piece was taken + */ + #registerMove(piece, move) { + let targetPiece = this.getPieceAt(move.to); + this.removePieceFrom(move.from); + this.#setPieceAt(piece, move.to); + this.#redToPlay = !this.#redToPlay; + if (piece.color === Color.BLACK) { + this.#fullMovesCounts++; + } + return targetPiece; + } + + /** + * @param color {string} + * @return {boolean} + */ + isInCheck(color) { + let generalPosition = this.findGeneral(color).position; + return this + .#listAllMovesForColor(reverseColor(color)) + .map(move => move.to) + .some(position => position.isEqualsTo(generalPosition)); + } + + isCheckmate(color) { + return color === this.getColorToPlay() && this.isInCheck(color) && this.#allMovesAreIllegal(color); + } + + isStalemate(color) { + return color === this.getColorToPlay() && !this.isInCheck(color) && this.#allMovesAreIllegal(color); + } + + #allMovesAreIllegal(color) { + return this.#listAllMovesForColor(color).every(move => this.#isMoveIllegal(move, color)); + } + + isMated() { + return this.isCheckmate(Color.RED) || + this.isCheckmate(Color.BLACK) || + this.isStalemate(Color.RED) || + this.isStalemate(Color.BLACK); + } + + /** + * @param color + * @return {PieceAtPosition|null} + */ + findGeneral(color) { + return this + .listPiecePositions() + .find(piecePosition => + piecePosition.piece.pieceChar.toUpperCase() === 'K' && piecePosition.isColor(color) + ); + } + + #isMoveIllegal(move, color) { + let boardCopy = this.copy(); + if (boardCopy.#isPossibleMove(move)) { + boardCopy.registerMove(move); + return boardCopy.#areGeneralsFacing() || boardCopy.isInCheck(color); + } else { + return true; + } + } + + #areGeneralsFacing() { + let red = this.findGeneral(Color.RED); + let black = this.findGeneral(Color.BLACK); + if (red == null || black == null) { + return false; + } else { + let redGeneralPosition = red.position; + let blackGeneralPosition = black.position; + return red.position.x === black.position.x && this.#noPieceOnFileBetween(redGeneralPosition, blackGeneralPosition); + } + } + + #noPieceOnFileBetween(p1, p2) { + if (p1.x !== p2.x) { + return false; + } else { + for (let y = p1.y + 1; y < p2.y; y++) { + let position = new Position(p1.x, y); + if (this.#hasPieceAt(position)) { + return false; + } + } + } + return true; + } + + #listAllMovesForColor(color) { + return this + .listPiecePositions() + .filter(piecePosition => piecePosition.isColor(color)) + .flatMap(piecePosition => this.#listAllMovesFrom(piecePosition.position)); + } + + #isPossibleMove(move) { + return this + .#listAllMovesFromAsPositions(move.from) + .some(targetPosition => Position.areEquals(move.to, targetPosition)); + } + + isLegalMove(move) { + let color = this.getColorAt(move.from); + if (color == null) { + return false; + } else { + return !this.#isMoveIllegal(move, color); + } + } + + /** + * @returns {HalfMove[]} + */ + listLegalMovesFrom(position) { + let color = this.getColorAt(position); + if (color == null) { + return []; + } else { + return this.#listAllMovesFrom(position).filter(move => !this.#isMoveIllegal(move, color)); + } + } + + /** + * Lists every legal move available to the given color (defaults to the + * color whose turn it is to play). + * + * @param color {string} optional, defaults to {@link getColorToPlay} + * @returns {HalfMove[]} + */ + listAllLegalMoves(color = this.getColorToPlay()) { + const moves = []; + for (let x = 0; x < BOARD_WIDTH; x++) { + for (let y = 0; y < BOARD_HEIGHT; y++) { + const pos = new Position(x, y); + if (this.getColorAt(pos) === color) { + moves.push(...this.listLegalMovesFrom(pos)); + } + } + } + return moves; + } + + /** + * @returns Array of {@class HalfMove} one can go to from {@param from}. Includes illegal moves (e.g. that would put player in check) + */ + #listAllMovesFrom(from) { + return this + .#listAllMovesFromAsPositions(from) + .map(to => new HalfMove(from, to)); + } + + /** + * @returns {Position[]} one can go to from {@param position}. Includes illegal moves (e.g. that would put player in check) + */ + #listAllMovesFromAsPositions(position) { + switch (this.getPieceAt(position).pieceChar.toLowerCase()) { + case 'c': + return this.#listMovesForCannon(position); + case 'r': + return this.#listMovesForChariot(position); + case 'n': + return this.#listMovesForHorse(position); + case 'b': + return this.#listMovesForElephant(position); + case 'a': + return this.#listMovesForAdvisor(position); + case 'k': + return this.#listMovesForGeneral(position); + case 'p': + return this.#listMovesForSoldier(position); + default: + throw new Error('Not implemented for ' + this.getPieceAt(position).pieceChar) + } + } + + #listMovesForCannon(position) { + let result = []; + result = result.concat(this.#filterMovesForCannon(position, position.getAllTop())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft())); + result = result.concat(this.#filterMovesForCannon(position, position.getAllRight())); + return result; + } + + #filterMovesForCannon(position, allTargetPosition) { + let result = []; + let foundPivot = false; + + for (let i = 0; i < allTargetPosition.length; i++) { + let targetPosition = allTargetPosition[i]; + if (!foundPivot) { + if (!this.#hasPieceAt(targetPosition)) { + result.push(targetPosition); + } else { + foundPivot = true; + } + } else { + if (this.containOppositeColors(position, targetPosition)) { + result.push(targetPosition); + return result; + } else if (this.containSameColors(position, targetPosition)) { + return result; + } + } + } + + return result; + } + + #listMovesForChariot(position) { + let result = []; + result = result.concat(this.#filterMovesForChariot(position, position.getAllTop())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft())); + result = result.concat(this.#filterMovesForChariot(position, position.getAllRight())); + return result; + } + + #filterMovesForChariot(position, allTargetPositions) { + let result = []; + + for (let i = 0; i < allTargetPositions.length; i++) { + let targetPosition = allTargetPositions[i]; + if (!this.#hasPieceAt(targetPosition)) { + result.push(targetPosition); + } else if (this.containOppositeColors(position, targetPosition)) { + result.push(targetPosition); + return result; + } else if (this.containSameColors(position, targetPosition)) { + return result; + } + } + + return result; + } + + #listMovesForHorse(position) { + let result = []; + let top = position.getTop(); + let bottom = position.getBottom(); + let left = position.getLeft(); + let right = position.getRight(); + if (top.existsOnBoard() && !this.#hasPieceAt(top)) { + result.push(top.getTopLeft()); + result.push(top.getTopRight()); + } + if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) { + result.push(bottom.getBottomLeft()); + result.push(bottom.getBottomRight()); + } + if (left.existsOnBoard() && !this.#hasPieceAt(left)) { + result.push(left.getTopLeft()); + result.push(left.getBottomLeft()); + } + if (right.existsOnBoard() && !this.#hasPieceAt(right)) { + result.push(right.getTopRight()); + result.push(right.getBottomRight()); + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && !this.containSameColors(targetPosition, position) + ); + } + + #listMovesForElephant(position) { + let result = []; + let topLeft = position.getTopLeft(); + let topRight = position.getTopRight(); + let bottomRight = position.getBottomRight(); + let bottomLeft = position.getBottomLeft(); + + if (topLeft.existsOnBoard() && !this.#hasPieceAt(topLeft)) { + result.push(topLeft.getTopLeft()); + } + if (topRight.existsOnBoard() && !this.#hasPieceAt(topRight)) { + result.push(topRight.getTopRight()); + } + if (bottomRight.existsOnBoard() && !this.#hasPieceAt(bottomRight)) { + result.push(bottomRight.getBottomRight()); + } + if (bottomLeft.existsOnBoard() && !this.#hasPieceAt(bottomLeft)) { + result.push(bottomLeft.getBottomLeft()); + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && + !this.containSameColors(position, targetPosition) && + !this.#areOnOppositeSidesOfTheRiver(position, targetPosition) + ); + } + + #listMovesForAdvisor(position) { + let result = []; + result.push(position.getTopLeft()); + result.push(position.getTopRight()); + result.push(position.getBottomRight()); + result.push(position.getBottomLeft()); + return this.#filterMovesInItsPalace(position, result); + } + + #listMovesForGeneral(position) { + let result = []; + result.push(position.getTop()); + result.push(position.getBottom()); + result.push(position.getLeft()); + result.push(position.getRight()); + return this.#filterMovesInItsPalace(position, result); + } + + #filterMovesInItsPalace(position, allTargetPosition) { + if (this.#hasRedPieceAt(position)) { + return allTargetPosition.filter(target => + target.existsOnBoard() && target.isInRedPalace() && !this.#hasRedPieceAt(target) + ); + } else if (this.#hasBlackPieceAt(position)) { + return allTargetPosition.filter(target => + target.existsOnBoard() && target.isInBlackPalace() && !this.#hasBlackPieceAt(target) + ); + } else { + console.error("we should never pass here") + return []; + } + } + + #listMovesForSoldier(position) { + let result = []; + if (this.#hasRedPieceAt(position)) { + // is red + result.push(position.getTop()); + if (this.#areOnOppositeSidesOfTheRiver(position, Position.redGeneralStartingPosition)) { + result.push(position.getLeft()); + result.push(position.getRight()); + } + } else if (this.#hasBlackPieceAt(position)) { + // is black + result.push(position.getBottom()); + if (this.#areOnOppositeSidesOfTheRiver(position, Position.blackGeneralStartingPosition)) { + result.push(position.getLeft()); + result.push(position.getRight()); + } + } else { + console.warn("we should never pass here") + return []; + } + + return result.filter(targetPosition => + targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition) + ); + } + + /** + * @param position {Position} + * @returns {null|string} + */ + getColorAt(position) { + let piece = this.getPieceAt(position); + if (piece != null) { + return piece.color; + } else { + return null; + } + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasPieceAt(position) { + return this.#content[position.x][position.y] != null; + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasRedPieceAt(position) { + return this.#hasPieceOfColorAt(Color.RED, position); + } + + /** + * @param position {Position} + * @return {boolean} + */ + #hasBlackPieceAt(position) { + return this.#hasPieceOfColorAt(Color.BLACK, position); + } + + #hasPieceOfColorAt(color, position) { + let piece = this.getPieceAt(position); + return piece != null && piece.color === color; + } + + #areOnOppositeSidesOfTheRiver(p1, p2) { + return (p1.y <= 4 && p2.y >= 5) || (p2.y <= 4 && p1.y >= 5); + } + + containSameColors(p1, p2) { + return (this.#hasRedPieceAt(p1) && this.#hasRedPieceAt(p2)) + || (this.#hasBlackPieceAt(p1) && this.#hasBlackPieceAt(p2)); + } + + containOppositeColors(p1, p2) { + return (this.#hasRedPieceAt(p1) && this.#hasBlackPieceAt(p2)) + || (this.#hasRedPieceAt(p2) && this.#hasBlackPieceAt(p1)); + } + + printBoardToLines() { + let lines = []; + for (let y = BOARD_HEIGHT - 1; y >= 0; y--) { + let row = ''; + for (let x = 0; x < BOARD_WIDTH; x++) { + let piece = this.getPieceAt(new Position(x, y)); + if (piece == null) { + row += ' '; + } else { + row += piece.pieceChar + ' '; + } + } + lines.push(row); + } + return lines; + } + + printBoard() { + let oneLine = ''; + this.printBoardToLines().forEach(line => oneLine += line + '\n'); + console.log(oneLine); + } + + /** + * @return {Board} + */ + copy() { + let copy = new Board(); + copy.loadFen(this.outputFen()); + return copy + } + +} diff --git a/webapp/src/main/resources/public/images/icons/data-search.png b/webapp/src/main/resources/public/images/icons/data-search.png new file mode 100644 index 0000000000000000000000000000000000000000..15397ea04f94b2ec0c4ef3b4624c965d58601075 GIT binary patch literal 22139 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

    `_YUT#!&r$aXWHo@`0c~5yB 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 literal 0 HcmV?d00001 diff --git a/webapp/src/main/resources/public/images/icons/link.png b/webapp/src/main/resources/public/images/icons/link.png new file mode 100644 index 0000000000000000000000000000000000000000..83e42268c7b129e9f896f9961b01dbeac1fc6ece GIT binary patch literal 11789 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 literal 0 HcmV?d00001 diff --git a/webapp/src/main/resources/public/images/icons/more.png b/webapp/src/main/resources/public/images/icons/more.png new file mode 100644 index 0000000000000000000000000000000000000000..330374223f84428f3fdfb01db93ddf9b73a4fde5 GIT binary patch literal 4637 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 literal 0 HcmV?d00001 diff --git a/webapp/src/main/resources/public/images/icons/os/apple.png b/webapp/src/main/resources/public/images/icons/os/apple.png new file mode 100644 index 0000000000000000000000000000000000000000..f5ac140784ceff9676f1ae9aa5de0ffd0bf619a4 GIT binary patch literal 12650 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 SXO3Ynl6O1qy1Uc+-2VWt&H4ra literal 0 HcmV?d00001 diff --git a/webapp/src/main/resources/public/images/icons/os/linux.png b/webapp/src/main/resources/public/images/icons/os/linux.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf18dccf091470cec2731d272270eb9150e3d2f GIT binary patch literal 39186 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 Jly31k`9E(nUnc+n literal 0 HcmV?d00001 diff --git a/webapp/src/main/resources/public/images/icons/sunset.png b/webapp/src/main/resources/public/images/icons/sunset.png new file mode 100644 index 0000000000000000000000000000000000000000..2c8f8a563e00116a751422490a7c89a713beb8ef GIT binary patch literal 27479 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. + */ + +class ChangelogPage extends BasePage { + + constructor() { + super(); + document.querySelectorAll('.anchor-copy-link').forEach((el) => { + el.addEventListener('click', () => { + const target = el.dataset.anchorTarget; + if (!target) return; + const link = `${getFullHost()}${window.location.pathname}#${target}`; + copyTextToClipboardAndNotify(link, 'Link copied to clipboard!'); + }); + }); + } + +} + +window.onload = () => new ChangelogPage(); diff --git a/webapp/src/main/resources/public/js/analysis-board/engine-analysis-widget.js b/webapp/src/main/resources/public/js/analysis-board/engine-analysis-widget.js index df4240d49..0f59d3334 100644 --- a/webapp/src/main/resources/public/js/analysis-board/engine-analysis-widget.js +++ b/webapp/src/main/resources/public/js/analysis-board/engine-analysis-widget.js @@ -48,11 +48,16 @@ class EngineAnalysisWidget { #startFen = DEFAULT_START_FEN; #useDefaultFen = true; + #movesUpToSelection = []; + #evalBarContainer = document.getElementById('eval-bar-container'); #depthSpan = document.getElementById('engine-depth'); #enginePvDiv = document.getElementById('engine-pv'); #engineRawLine = document.getElementById('engine-raw-line'); + #pvMiniBoardGui = null; + #pvMiniBoardDiv = null; + /** * @param analysisCache {AnalysisCache} * @param boardGui {BoardGui} @@ -78,6 +83,7 @@ class EngineAnalysisWidget { * @param movesUpToSelection {HalfMove[]} */ update(movesUpToSelection) { + this.#movesUpToSelection = movesUpToSelection; const fen = this.#moveTreeWidget.getFenAtSelection(); const selectedNode = this.#moveTreeWidget.selectedNode; @@ -133,6 +139,11 @@ class EngineAnalysisWidget { this.#boardGui.highlightDynamicMove(move); }); } + + // show mini board with the resulting position + const pvMoves = this.#findAllMovesBefore(e.target.id); + const resultFen = calculateFen([...this.#movesUpToSelection, ...pvMoves], this.#startFen); + this.#showPvMiniBoard(e, resultFen); }); enginePvMoveDiv.addEventListener('mouseout', () => { @@ -145,6 +156,9 @@ class EngineAnalysisWidget { if (HIGHLIGHT_PV_MOVES) { this.#boardGui.hideAllHighlightedDynamicMoves(); } + + // hide mini board + this.#hidePvMiniBoard(); }); }); } else { @@ -207,6 +221,53 @@ class EngineAnalysisWidget { this.#evalBarContainer.append(indicator.render()); } + #ensurePvMiniBoard() { + if (this.#pvMiniBoardDiv) return; + + const miniBoardId = 'engine-pv-mini-board'; + this.#pvMiniBoardDiv = document.createElement('div'); + this.#pvMiniBoardDiv.id = miniBoardId; + this.#pvMiniBoardDiv.classList.add( + 'board-container', + 'mini-board-container', + 'mini-board-overview' + ); + document.body.appendChild(this.#pvMiniBoardDiv); + + this.#pvMiniBoardGui = createWebappBoardGui({ + elementId: miniBoardId, + showCoordinates: false, + mini: true, + forceRenderChecks: true, + }); + this.#pvMiniBoardGui.flipToColor(this.#boardGui.bottomColor); + + // keep mini board orientation in sync with the main board + this.#boardGui.addAfterFlipListener(color => { + this.#pvMiniBoardGui.flipToColor(color); + }); + } + + /** + * @param mouseEvent {MouseEvent} + * @param fen {string} + */ + #showPvMiniBoard(mouseEvent, fen) { + this.#ensurePvMiniBoard(); + this.#pvMiniBoardGui.loadFen(fen); + + const CURSOR_OFFSET = 16; + this.#pvMiniBoardDiv.style.top = `${mouseEvent.pageY + CURSOR_OFFSET}px`; + this.#pvMiniBoardDiv.style.left = `${mouseEvent.pageX}px`; + this.#pvMiniBoardDiv.style.display = 'block'; + } + + #hidePvMiniBoard() { + if (this.#pvMiniBoardDiv) { + this.#pvMiniBoardDiv.style.display = 'none'; + } + } + /** * * @param engineMoves {HalfMove[]} diff --git a/webapp/src/main/resources/public/js/browse-games.js b/webapp/src/main/resources/public/js/browse-games.js index 4ed7db16b..a4c4871ef 100644 --- a/webapp/src/main/resources/public/js/browse-games.js +++ b/webapp/src/main/resources/public/js/browse-games.js @@ -17,7 +17,9 @@ * along with this program. If not, see . */ -const INIT_ELEMENTS_COUNT = 12; +const DEFAULT_DISPLAY = ''; +const FIRST_ROW_SIZE = 3; +const PRE_RENDERED_THUMBS_COUNT = 6; class BrowseGamesPage extends InfiniteScrollPage { @@ -36,6 +38,13 @@ class BrowseGamesPage extends InfiniteScrollPage { #renderedCount = 0; + /** + * Number of pre-rendered game thumb divs present in the DOM. + * Derived from the template (e.g. `{{game_thumb}}[[iterations:N; ...]]`). + * @type {number} + */ + #initElementsCount = 0; + /** * @type {string} */ @@ -55,6 +64,7 @@ class BrowseGamesPage extends InfiniteScrollPage { // initialize board GUIs for pre-rendered game thumbs this.#initThumbDivs = getElementsByClassNameArray(`${gameType}-game-thumb`); + this.#initElementsCount = this.#initThumbDivs.length; this.#initThumbDivs.forEach((thumbDiv, i) => { const boardId = `last-${gameType}-game-board-${i}`; const boardElement = document.getElementById(boardId); @@ -101,6 +111,9 @@ class BrowseGamesPage extends InfiniteScrollPage { */ showNoItem(value) { this.#noGamesMessage.style.display = value ? 'block' : 'none'; + if (value) { + this.#setSecondThumbRowVisibility(false); + } } /** @@ -108,7 +121,7 @@ class BrowseGamesPage extends InfiniteScrollPage { */ additionalParameters() { const params = new Map(); - params.set('limit', INIT_ELEMENTS_COUNT.toString()); + params.set('limit', '12'); params.set('distinctByUsers', 'false'); return params; } @@ -117,8 +130,10 @@ class BrowseGamesPage extends InfiniteScrollPage { * @param entries {GameMetadataDto[]} */ addEntries(entries) { + const isInitialBatch = this.#renderedCount === 0; entries.forEach((entry) => { - if (this.#renderedCount < INIT_ELEMENTS_COUNT) { + if (this.#renderedCount < this.#initElementsCount) { + this.#initThumbDivs[this.#renderedCount].style.display = DEFAULT_DISPLAY; // Use pre-rendered thumbs for first batch this.#initThumbs[this.#renderedCount].render( entry, @@ -132,6 +147,10 @@ class BrowseGamesPage extends InfiniteScrollPage { this.#renderedCount++; } }); + + if (isInitialBatch) { + this.#setSecondThumbRowVisibility(this.#renderedCount > FIRST_ROW_SIZE); + } } /** @@ -149,4 +168,13 @@ class BrowseGamesPage extends InfiniteScrollPage { ); } + /** + * @param show {boolean} + */ + #setSecondThumbRowVisibility(show) { + for (let i = FIRST_ROW_SIZE; i < Math.min(PRE_RENDERED_THUMBS_COUNT, this.#initThumbDivs.length); i++) { + this.#initThumbDivs[i].style.display = show ? DEFAULT_DISPLAY : 'none'; + } + } + } diff --git a/webapp/src/main/resources/public/js/browse-pvp-games.js b/webapp/src/main/resources/public/js/browse-pvp-games.js index d6ca37d57..92eff5f13 100644 --- a/webapp/src/main/resources/public/js/browse-pvp-games.js +++ b/webapp/src/main/resources/public/js/browse-pvp-games.js @@ -17,4 +17,38 @@ * along with this program. If not, see . */ -window.onload = () => new BrowseGamesPage('pvp'); +class BrowsePlayerVsPlayerOfUserPage extends BrowseGamesPage { + + /** + * @param username {string} + */ + constructor(username) { + super('pvp'); + // Use a regular property (not a private field) so it is set before any + // base-class call to additionalParameters() that happens during super(). + // Note: super() runs before subclass field initializers, so private + // fields declared on the subclass are not yet available there. + this.username = username; + } + + baseUrl() { + return '/api/game-data/list-latest-pvp-games-by-user'; + } + + additionalParameters() { + const params = super.additionalParameters(); + params.set('username', this.username ?? document.body.dataset.username); + return params; + } +} + +window.onload = () => { + const username = document.body.dataset.username; + if (username) { + // browse PvP for a single user + new BrowsePlayerVsPlayerOfUserPage(username); + } else { + // general PvP browse + new BrowseGamesPage('pvp'); + } +}; diff --git a/webapp/src/main/resources/public/js/database/database-game-viewer.js b/webapp/src/main/resources/public/js/database/database-game-viewer.js index d72ac3a42..609ef9869 100644 --- a/webapp/src/main/resources/public/js/database/database-game-viewer.js +++ b/webapp/src/main/resources/public/js/database/database-game-viewer.js @@ -24,31 +24,6 @@ class DatabaseGameViewerPage extends BasePage { */ #gameDataClient; - /** - * @type {GameMetadataDto|null} - */ - #gameMetadata; - - /** - * @type {HTMLDivElement} - */ - #playersInfo = document.getElementById('players-info'); - - /** - * @type {HTMLDivElement} - */ - #gameStatusInfo = document.getElementById('game-status-info'); - - /** - * @type {HTMLDivElement} - */ - #gameDateInfo = document.getElementById('game-date-info'); - - /** - * @type {HTMLDivElement} - */ - #gameEventInfo = document.getElementById('game-event-info'); - #boardGui = createWebappBoardGui(); /** @@ -78,130 +53,66 @@ class DatabaseGameViewerPage extends BasePage { } #loadGameData() { - const idParam = getQueryParam('id'); - const orientationParam = getQueryParam('orientation'); - - if (idParam != null) { - let gameId = new GameId(GameType.DB, idParam); - this.#gameDataClient = new GameDataClient(gameId); - this.#gameDataClient.fetchMetadata(metadata => { - // display metadata - this.#gameMetadata = metadata; - this.#boardGui.loadFen(metadata.finalFen); - if (orientationParam != null) { - this.#boardGui.flipToColor(orientationParam); - } - this.#playersInfo.append(this.#playerInfo(metadata)); - this.#gameStatusInfo.innerText = this.#formatOutcome(metadata); - if (metadata.lastUpdated != null) { - this.#gameDateInfo.innerText = formatTimestampDefaultDateFormatNoTime(metadata.lastUpdated); - } else { - this.#gameDateInfo.innerText = ''; - } - - if (metadata.eventId != null && metadata.eventName != null) { - this.#gameEventInfo.innerHTML = ''; - this.#gameEventInfo.append( - buildLink( - `/database/event?id=${metadata.eventId}`, - metadata.eventName - ) - ); - } else if (metadata.eventName != null) { - this.#gameEventInfo.innerText = metadata.eventName; - } else { - this.#gameEventInfo.style.display = 'none'; - } - - ['analyze-button-left-side', 'analyze-button-right-side'] - .forEach((id) => { - document.getElementById(id) - .addEventListener('click', () => { - window.open(gameId.analysisUrl, '_self'); - }); - }); - }); + const ds = document.body.dataset; + const idParam = ds.gameId; + const orientationParam = ds.orientation; - this.#gameDataClient.fetchMoves(moves => { - this.#moveTreeWidget.setMoves(moves); - renderAnalysisSummaryReportGeneric( - this.#gameMetadata.gameId, - this.#moveTreeWidget.getMainBranchNodes(), - ); - }); - } else { + if (idParam == null || idParam === '') { window.open('/', '_self'); + return; } - } - /** - * @param metadata {GameMetadataDto} - * @return {HTMLDivElement} - */ - #playerInfo(metadata) { - const red = buildSimpleSpan(''); - const black = buildSimpleSpan(''); - if (metadata.redPlayerName != null && metadata.redPlayerName !== '') { - red.innerHTML = ''; - red.append( - buildLink(`/database/player/${encodePlayerNameForUrl(metadata.redPlayerName)}`, metadata.redPlayerName) - ); + const gameId = new GameId(GameType.DB, idParam); + this.#gameDataClient = new GameDataClient(gameId); + + // players-info, game-date-info, game-event-info and the page title / meta + // description are rendered server-side. The board's final FEN, game id and + // orientation are all read from body data-* attributes, so we don't need to + // fetch metadata dynamically — we only need to fetch the move list. + const finalFen = ds.finalFen; + if (finalFen != null && finalFen !== '') { + this.#boardGui.loadFen(finalFen); } - if (metadata.blackPlayerName != null && metadata.blackPlayerName !== '') { - black.innerHTML = ''; - black.append( - buildLink(`/database/player/${encodePlayerNameForUrl(metadata.blackPlayerName)}`, metadata.blackPlayerName) - ); + if (orientationParam != null && orientationParam !== '') { + this.#boardGui.flipToColor(orientationParam); } - const div = document.createElement('div'); - div.append(red); - div.append(buildSimpleSpan(' vs. ')); - div.append(black); - return div; - } + ['analyze-button-left-side', 'analyze-button-right-side'] + .forEach((id) => { + document.getElementById(id) + .addEventListener('click', () => { + window.open(gameId.analysisUrl, '_self'); + }); + }); - /** - * @param metadata {GameMetadataDto} - * @returns {string} - */ - #formatOutcome(metadata) { - switch (metadata.outcome) { - case Outcome.RED_WINS: - if (metadata.redPlayerName != null && metadata.redPlayerName !== '') { - return `${metadata.redPlayerName} victory (Red)`; - } else { - return 'Red wins'; - } - case Outcome.BLACK_WINS: - if (metadata.blackPlayerName != null && metadata.blackPlayerName !== '') { - return `${metadata.blackPlayerName} victory (Black)`; - } else { - return 'Black wins'; - } - case Outcome.DRAW: - return 'Draw'; - default: - return '--'; - } + this.#gameDataClient.fetchMoves(moves => { + this.#moveTreeWidget.setMoves(moves); + renderAnalysisSummaryReportGeneric( + gameId, + this.#moveTreeWidget.getMainBranchNodes(), + DEFAULT_START_FEN, + this.#moveTreeWidget + ); + }); } /** + * Build the PGN metadata Map from the body data-* attributes (populated + * server-side). Avoids the extra metadata HTTP fetch. + * * @return {Map} */ #buildPgnMetadata() { - let metadata = new Map(); - - // site metadata + const metadata = new Map(); metadata.set('Site', window.location.href); - // game metadata - if (this.#gameMetadata != null) { - this - .#gameMetadata - .buildPgnMetadata() - .forEach((value, key) => metadata.set(key, value)); - } + const ds = document.body.dataset; + if (ds.pgnRedPlayer) metadata.set('White', ds.pgnRedPlayer); + if (ds.pgnBlackPlayer) metadata.set('Black', ds.pgnBlackPlayer); + if (ds.pgnEvent) metadata.set('Event', ds.pgnEvent); + if (ds.pgnDate) metadata.set('Date', ds.pgnDate); + if (ds.pgnResult) metadata.set('Result', ds.pgnResult); + metadata.set('Variant', 'Xiangqi'); return metadata; } diff --git a/webapp/src/main/resources/public/js/database/database-search.js b/webapp/src/main/resources/public/js/database/database-search.js index 8082aea1d..32d55179a 100644 --- a/webapp/src/main/resources/public/js/database/database-search.js +++ b/webapp/src/main/resources/public/js/database/database-search.js @@ -142,6 +142,71 @@ class DatabaseSearchPage extends InfiniteScrollPage { document.getElementById('player-color-any-label'), 'When searching for a specific player, select this to not filter by playing color.' ); + + this.#initFromUrlParams(); + } + + /** + * Pre-fills the search form from URL query parameters and auto-triggers the search if any params are present. + * This allows linking directly to a search from e.g. "My DB Searches". + */ + #initFromUrlParams() { + const params = new URLSearchParams(window.location.search); + const playerName = params.get('playerName'); + const playerColor = params.get('playerColor'); + const eventName = params.get('eventName'); + const dateStart = params.get('dateStart'); + const dateEnd = params.get('dateEnd'); + const fen = params.get('fen'); + + if (!playerName && !eventName && !dateStart && !dateEnd && !fen) { + return; + } + + if (playerName) { + this.#playerSearchField.setInputFieldValue(playerName); + this.#enablePlayerColorRadioButtons(true); + } + if (playerColor) { + const validColors = ['red', 'black', 'both']; + const normalizedColor = playerColor.toLowerCase(); + if (validColors.includes(normalizedColor)) { + const radioInput = document.querySelector(`input[name="player-color"][value="${normalizedColor}"]`); + if (radioInput) { + radioInput.checked = true; + } + } + } + if (eventName) { + this.#eventSearchField.setInputFieldValue(eventName); + } + if (dateStart || dateEnd) { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + const startDate = (dateStart && datePattern.test(dateStart)) ? new Date(dateStart) : null; + const endDate = (dateEnd && datePattern.test(dateEnd)) ? new Date(dateEnd) : null; + const startValid = startDate && !isNaN(startDate.getTime()); + const endValid = endDate && !isNaN(endDate.getTime()); + if (startValid && endValid) { + this.#dateRangePicker.setDates(startDate, endDate); + } else if (startValid) { + this.#dateRangePicker.setDates(startDate, null); + } else if (endValid) { + this.#dateRangePicker.setDates(null, endDate); + } + } + if (fen) { + this.#fenSearchField.value = fen; + } + + // auto-trigger the search + this.#setButtonEnabled(false); + this.#currentPlayerName = playerName; + this.#currentPlayerIds = []; + this.#currentEventName = eventName; + this.#currentEventIds = []; + this.#currentInterval = this.#dateRangePicker.getDates(DATE_FORMAT); + this.#currentFen = fen; + this.fetchItems(); } /** @@ -250,7 +315,7 @@ class DatabaseSearchPage extends InfiniteScrollPage { // left pane const databaseIcon = document.createElement('img'); databaseIcon.className = 'time-control-icons'; - databaseIcon.src = '/images/icons/database.png'; + databaseIcon.src = '/images/icons/data-search.png'; databaseIcon.alt = 'Database Game'; leftPane.append(wrapInDiv(databaseIcon)); diff --git a/webapp/src/main/resources/public/js/infinite-scroll-page.js b/webapp/src/main/resources/public/js/infinite-scroll-page.js index a7c94bc75..19c3c6f84 100644 --- a/webapp/src/main/resources/public/js/infinite-scroll-page.js +++ b/webapp/src/main/resources/public/js/infinite-scroll-page.js @@ -152,14 +152,18 @@ class InfiniteScrollPage extends BasePage { (json) => { const entries = []; json.entries.forEach(entry => entries.push(this.deserializeJsonEntry(entry))); + const wasFirstPage = this.#continuation == null; this.addEntries(entries); - if (this.#continuation == null && entries.length === 0) { + const isEmptyFirstPage = wasFirstPage && entries.length === 0; + if (isEmptyFirstPage) { this.showNoItem(true); } this.#continuation = entries.length > 0 ? this.extractToken(entries[entries.length - 1]) : null; this.#lastPageFound = entries.length === 0; this.#updateIsLoading(false); - this.#updateEndOfPaginationMessage(this.#lastPageFound); + // suppress "no more items" when the list was empty from the + // start: the "no items" message already conveys that. + this.#updateEndOfPaginationMessage(this.#lastPageFound && !isEmptyFirstPage); }, (responseText) => this.fetchItemsErrorCb(responseText) ); 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 5558cc076..c90dccea9 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby-client.js +++ b/webapp/src/main/resources/public/js/lobby/lobby-client.js @@ -58,7 +58,7 @@ class LobbyClient { * @param cb {function(Array)} */ listLastPvbGames(limit, cb) { - getAndHandle('/api/game-data/list-latest-pvb-games?limit=' + limit, (json) => { + getAndHandle('/api/game-data/list-latest-pvb-games?limit=' + limit + '&excludeAutoResigned=true', (json) => { const items = []; for (let i = 0; i < json.entries.length; i++) { items.push(new GameMetadataDto(json.entries[i])); diff --git a/webapp/src/main/resources/public/js/lobby/lobby.js b/webapp/src/main/resources/public/js/lobby/lobby.js index 3d8008ffa..9fd30abb2 100644 --- a/webapp/src/main/resources/public/js/lobby/lobby.js +++ b/webapp/src/main/resources/public/js/lobby/lobby.js @@ -26,6 +26,7 @@ const YOUTUBE_EMBED = ` /** * @return {HTMLInputElement} */ +// TODO: move to utils.js function makeAppButton(id, value) { const button = document.createElement('input'); button.type = 'button'; @@ -35,11 +36,36 @@ function makeAppButton(id, value) { return button; } +const KNOWN_TIME_CONTROL_IDS = new Set( + timeControlCategories.flatMap((category) => category.timeControls.map((timeControl) => timeControl.id)) +); + +const RATING_MODE_ICONS = { + rated: '/images/icons/trophy-football.png', + casual: '/images/icons/sunset.png', +}; + +/** + * @param entry {GameToPlayDto} + * @returns {string} + */ +function timeControlIconSource(entry) { + return `${ICON_PATH}/${timeControlCategoryIconMap.get(entry.timeControlCategory)}` +} + +/** + * @param entry {GameToPlayDto} + * @returns {boolean} + */ +function isCustomTimeControl(entry) { + return entry.timeControl != null && !KNOWN_TIME_CONTROL_IDS.has(entry.timeControl.id); +} + class LobbyPage extends BasePage { #client = new LobbyClient(); - #gameToJoinListTable = document.getElementById('games-to-join-list-table'); + #gameToJoinList = document.getElementById('games-to-join-list'); #createNewGameButton = document.getElementById('create-new-game-button'); // no game to join message links @@ -93,7 +119,7 @@ class LobbyPage extends BasePage { addEventListener('scroll', (_) => this.#renderYouTubeEmbed()); addEventListener('resize', (_) => this.#renderYouTubeEmbed()); - const settingsGui = new SettingsGui(this.#puzzleBoardGui, null); + const settingsGui = new SettingsGui(this.#puzzleBoardGui, null, false, false); new LiveGamesViewer(settingsGui); } @@ -118,9 +144,7 @@ class LobbyPage extends BasePage { * @param entries {GameToPlayDto[]} */ #renderGameList(entries) { - const table = this.#gameToJoinListTable; - const body = table.getElementsByTagName('tbody').item(0); - body.innerHTML = ''; + this.#gameToJoinList.innerHTML = ''; entries.sort(sortByOnline); // TODO: sort by rating most similar to user's @@ -129,99 +153,139 @@ class LobbyPage extends BasePage { this.#noGameToJoinMessage.classList.add('no-game-to-join-message-visible'); } else { this.#noGameToJoinMessage.classList.remove('no-game-to-join-message-visible'); - let row = body.insertRow(); - row.classList.add('col-spanner'); - let cell = row.insertCell(); - cell.setAttribute('colspan', '7'); } - entries.forEach((entry, i) => { - // html elements - const row = body.insertRow(); + entries.forEach((entry) => { + // skeleton + const variantPane = buildDivWithClass('game-to-join-variant-pane'); + const timeControlPane = buildDivWithClass('game-to-join-time-control-pane'); + const middlePane = buildDivWithClass('game-to-join-middle-pane'); + const ratingPane = buildDivWithClass('game-to-join-rating-pane'); + const rightPane = buildDivWithClass('game-to-join-right-pane'); + + const item = buildDivWithClass('game-to-join-item'); + item.append(variantPane); + item.append(timeControlPane); + item.append(middlePane); + item.append(ratingPane); + item.append(rightPane); + + // variant + variantPane.append( + buildSpan( + '象', + 'game-to-join-variant-symbol', + 'Xiangqi (Chinese chess)' + ) + ); + + // opponent + const opponentLine = buildDivWithClass('game-to-join-opponent-line'); + const metadataLine = buildDivWithClass('game-to-join-metadata-line'); + const customTimeLine = buildDivWithClass('game-to-join-custom-time-line'); - // is online indicator - const isOnlineCell = row.insertCell(); - isOnlineCell.className = 'online-cell'; + middlePane.append(opponentLine); + middlePane.append(metadataLine); + middlePane.append(customTimeLine); - const isOnlineIndicator = document.createElement('div'); - isOnlineIndicator.className = 'online-status-indicator'; + const isOnlineIndicator = buildDivWithClass('online-status-indicator'); if (entry.isOpponentOnline) { isOnlineIndicator.classList.add(IS_ONLINE_CSS_CLASS); } - isOnlineCell.append(isOnlineIndicator); + opponentLine.append(isOnlineIndicator); - // username and rating - const usernameCell = row.insertCell(); + // time control + const hasCustomTimeControl = isCustomTimeControl(entry); + let timeControlLabel; + if (hasCustomTimeControl) { + timeControlLabel = 'Custom'; + } else if (entry.timeControl != null) { + timeControlLabel = entry.timeControl.printShort(' +'); + } else { + timeControlLabel = '--' + } + + const timeControlIcon = buildImg(timeControlIconSource(entry), 'time-control-icons'); + const timeControlIconCell = buildDivWithClass('game-to-join-time-icon-cell') + timeControlIconCell.append(timeControlIcon); + + const timeControlDurationCell = buildDivWithClass('game-to-join-time-duration-cell'); + timeControlDurationCell.innerText = timeControlLabel; + + timeControlPane.append(timeControlIconCell); + timeControlPane.append(timeControlDurationCell); + + if (hasCustomTimeControl && entry.timeControl != null) { + customTimeLine.innerText = entry.timeControl.printShort(' +'); + } + + // opponent (username and rating) + // FIXME: class 'crop-text-ellipsis' doesn't actually making ellipsis but prevent line break + // attributes should be move to 'username-cell' (it actually works on mobile but not desktop for some reason) + const usernameCell = document.createElement('div'); usernameCell.classList.add('username-cell', 'crop-text-ellipsis'); + opponentLine.append(usernameCell); usernameCell.append( buildUsernameSpan( entry.opponentUserId, entry.opponentUsername, - entry.opponentUserType + entry.opponentUserType, + null, + false ) ); - const ratingCell = row.insertCell(); + const ratingCell = document.createElement('div'); ratingCell.className = 'rating-cell'; - ratingCell.innerText = entry.opponentRating.toString(); - - // color - const colorCell = row.insertCell(); - colorCell.className = 'color-cell'; - colorCell.append(buildColorSpan(entry.opponentColor)); - - // time control - const timeControlIconCell = row.insertCell(); - const timeControlCell = row.insertCell(); - timeControlIconCell.className = 'time-control-icons-cell'; - timeControlCell.classList.add('time-control-cell', 'crop-text-ellipsis'); - - const imageName = timeControlCategoryIconMap.get(entry.timeControlCategory); - const img = document.createElement('img'); - img.className = 'time-control-icons'; - img.src = `${ICON_PATH}/${imageName}`; - timeControlIconCell.append(img); - - let timeControlLabel = '--'; - if (entry.timeControl != null) { - timeControlLabel = entry.timeControl.printShort(' +'); + ratingCell.innerText = ` (${entry.opponentRating})`; + opponentLine.append(ratingCell); + + // opponent (color and rating) + const colorCell = document.createElement('div'); + colorCell.className = 'game-to-join-metadata-item color-cell'; + const colorSpan = buildColorSpan(entry.opponentColor); + if (colorSpan.classList.contains('any-color')) { + colorSpan.innerText = 'Any color'; + colorSpan.title = 'Colors will be assigned randomly at the start of the game'; + } else if (entry.opponentColor === Color.RED) { + colorSpan.title = 'This player picked Red, you would play Black'; + } else if (entry.opponentColor === Color.BLACK) { + colorSpan.title = 'This player picked Black, you would play Red'; } - timeControlCell.innerText = timeControlLabel; + colorCell.append(colorSpan); + metadataLine.append(colorCell); + // metadataLine.append(ratingCell); // rating mode - const ratingModeCell = row.insertCell(); - ratingModeCell.className = 'rating-mode-cell'; + const ratingModeCell = document.createElement('div'); + ratingModeCell.className = 'game-to-join-rating-mode-cell'; + ratingPane.append(ratingModeCell); + + const ratingModeIcon = document.createElement('img'); + ratingModeIcon.className = 'game-to-join-rating-mode-icon'; + ratingModeCell.append(ratingModeIcon); const ratingModeSpan = document.createElement('span'); ratingModeCell.append(ratingModeSpan); if (entry.isRated) { ratingModeSpan.innerText = 'Rated'; + ratingModeIcon.src = RATING_MODE_ICONS.rated; } else { ratingModeSpan.innerText = 'Casual'; + ratingModeIcon.src = RATING_MODE_ICONS.casual; } - // some space - const separatorCell = row.insertCell(); - separatorCell.className = 'separator-cell'; - // join button - const joinButtonCell = row.insertCell(); + const joinButtonCell = document.createElement('div'); joinButtonCell.className = 'join-button-cell'; + rightPane.append(joinButtonCell); const joinButton = makeAppButton(`join-game-button-${entry.gameId}`, 'join'); joinButtonCell.append(joinButton); joinButton.classList.add('join-buttons'); joinButton.addEventListener('click', () => this.#handleClickJoinButton(entry)); - - // remove border for last row - // FIXME: this can be done in CSS - if (i === entries.length - 1) { - row.classList.add('last-row'); - for (let i = 0; i < row.cells.length; i++) { - row.cells[i].classList.add('last-row'); - } - } + this.#gameToJoinList.append(item); }); } diff --git a/webapp/src/main/resources/public/js/modal-handlers/signup-modal-handler.js b/webapp/src/main/resources/public/js/modal-handlers/signup-modal-handler.js index 825997444..b8ee24a36 100644 --- a/webapp/src/main/resources/public/js/modal-handlers/signup-modal-handler.js +++ b/webapp/src/main/resources/public/js/modal-handlers/signup-modal-handler.js @@ -123,6 +123,11 @@ class SignUpModalHandler extends ModalHandler { 'password': password }; + const transferCheckbox = document.getElementById('signup-transfer-games-checkbox'); + if (isUserIdentifiedAsGuest() && transferCheckbox && transferCheckbox.checked) { + body['transferGuestData'] = true; + } + const handler = new ValidationResponseHandler( (json) => { eraseAllIdentificationCookies(); diff --git a/webapp/src/main/resources/public/js/modules/parser-common.js b/webapp/src/main/resources/public/js/modules/parser-common.js index a9bea81d6..2b895d3ff 100644 --- a/webapp/src/main/resources/public/js/modules/parser-common.js +++ b/webapp/src/main/resources/public/js/modules/parser-common.js @@ -328,6 +328,7 @@ function parseToMoves(input) { new PgnParser(input, false), new PgnParser(input, true), new UciMoveParser(input), + new IccsParser(input), new AlgebraicParser(input), new WxfParser(input) ]; diff --git a/webapp/src/main/resources/public/js/modules/parser-iccs.js b/webapp/src/main/resources/public/js/modules/parser-iccs.js new file mode 100644 index 000000000..9f12cd33e --- /dev/null +++ b/webapp/src/main/resources/public/js/modules/parser-iccs.js @@ -0,0 +1,51 @@ +/* + * 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 . + */ + +class IccsParser extends UciMoveParser { + + get name() { + return 'ICCS'; + } + + tokenize() { + function isMetadata(line) { + return line.startsWith('['); + } + + function isEmpty(line) { + return line.trim() === ''; + } + + function isResult(line) { + let trimmed = line.trim(); + return trimmed === '*' || trimmed === '1-0' || trimmed === '0-1' || trimmed === '1/2-1/2'; + } + + function mustSkip(line) { + return isMetadata(line) || isEmpty(line) || isResult(line); + } + + let lines = this.input.split('\n'); + let tokensLine = lines.filter(line => !mustSkip(line)).join(' '); + return new SimpleTokenParser(tokensLine) + .tokenize() + .map(token => new ParsingToken(token.move.replaceAll('-', '').toLowerCase(), token.annotation)); + } + +} diff --git a/webapp/src/main/resources/public/js/modules/time-control-categories.js b/webapp/src/main/resources/public/js/modules/time-control-categories.js index ca9435a9b..dc873ef30 100644 --- a/webapp/src/main/resources/public/js/modules/time-control-categories.js +++ b/webapp/src/main/resources/public/js/modules/time-control-categories.js @@ -359,6 +359,7 @@ timeControlCategories.push( timeControlCategories.push( new TimeControlCategory('Rapid', [ new TimeControl(TimeControlDuration.ofMinutes(10)), + new TimeControl(TimeControlDuration.ofMinutes(15)), new TimeControl(TimeControlDuration.ofMinutes(15), TimeControlDuration.ofSeconds(10)), new TimeControl(TimeControlDuration.ofMinutes(30)) ]) @@ -375,6 +376,7 @@ timeControlCategories.push( timeControlCategories.push( new TimeControlCategory('Several days', [ new TimeControl(TimeControlDuration.ofDays(1)), + new TimeControl(TimeControlDuration.ofDays(2)), new TimeControl(TimeControlDuration.ofDays(3)), new TimeControl(TimeControlDuration.ofDays(7)), ]) diff --git a/webapp/src/main/resources/public/js/modules/ui.js b/webapp/src/main/resources/public/js/modules/ui.js index 68b48f448..a2c14c72e 100644 --- a/webapp/src/main/resources/public/js/modules/ui.js +++ b/webapp/src/main/resources/public/js/modules/ui.js @@ -127,6 +127,21 @@ function buildSimpleSpan(text) { return spanElement; } +/** + * @returns {HTMLSpanElement} + */ +function buildSpan(innerText, className = null, title = null) { + const span = document.createElement('span'); + span.innerText = innerText; + if (className != null) { + span.classList.add(className); + } + if (title != null) { + span.title = title; + } + return span; +} + /** * @param src {string} * @param className {string|null} @@ -232,9 +247,10 @@ function buildGuestUserSpan(id, maxLength = null) { * @param username {string|null} * @param userType {string} * @param maxLength {number|null} + * @param removeGuestPrefix {boolean} whether to remove the "guest" prefix for guest users (e.g. "guest #1234" becomes "1234") * @returns {HTMLElement} */ -function buildUsernameSpan(userId, username, userType, maxLength = null) { +function buildUsernameSpan(userId, username, userType, maxLength = null, removeGuestPrefix = false) { switch (userType) { case UserType.AUTHENTICATED: if (username != null) { @@ -243,6 +259,13 @@ function buildUsernameSpan(userId, username, userType, maxLength = null) { break; case UserType.GUEST: if (userId != null) { + if (removeGuestPrefix) { + const span = document.createElement('span'); + span.className = 'guest-name'; + const rawId = String(userId).replace(/^guest\s+/i, '').trim(); + span.innerText = maxLength != null ? cropText(rawId, maxLength) : rawId; + return span; + } return buildGuestUserSpan(userId, maxLength); } break; diff --git a/webapp/src/main/resources/public/js/modules/user.js b/webapp/src/main/resources/public/js/modules/user.js index 18fa10e91..8b7aefbbe 100644 --- a/webapp/src/main/resources/public/js/modules/user.js +++ b/webapp/src/main/resources/public/js/modules/user.js @@ -51,7 +51,7 @@ function getCookie(name) { } function eraseCookie(name) { - document.cookie = name + '=; Max-Age=-99999999;'; + document.cookie = name + '=; Max-Age=-99999999; path=/'; } /** diff --git a/webapp/src/main/resources/public/js/player-vs-bot/player-vs-bot-page.js b/webapp/src/main/resources/public/js/player-vs-bot/player-vs-bot-page.js index ed158d0c5..e2da28f17 100644 --- a/webapp/src/main/resources/public/js/player-vs-bot/player-vs-bot-page.js +++ b/webapp/src/main/resources/public/js/player-vs-bot/player-vs-bot-page.js @@ -181,11 +181,7 @@ class PlayerVsBotPage extends BasePage { this.#cancelButton.addEventListener('click', (e) => { if (isInfoBoxButtonEnabled(e)) { - this.#controller.cancel(() => { - this.#boardGui.disablePlayerMove(); - this.#updateOutcomeLabel(); - this.#updateButtonsEnabled(); - }); + this.#handleClickedCancelButton(); } }); @@ -224,6 +220,22 @@ class PlayerVsBotPage extends BasePage { UI.showConfirmationModal(span, yesCallback, yesButtonText, noCallback, noButtonText); } + #handleClickedCancelButton() { + const span = document.createElement('span'); + span.innerText = 'Are you sure you want to cancel this game?'; + const yesCallback = () => { + this.#controller.cancel(() => { + this.#boardGui.disablePlayerMove(); + this.#updateOutcomeLabel(); + this.#updateButtonsEnabled(); + }); + }; + const yesButtonText = 'yes'; + const noCallback = () => UI.hideModal(null); + const noButtonText = 'no'; + UI.showConfirmationModal(span, yesCallback, yesButtonText, noCallback, noButtonText); + } + /** * @param isUserVictory {boolean} */ @@ -364,6 +376,7 @@ class PlayerVsBotPage extends BasePage { new GameId(GameType.PVB, this.#controller.gameId), this.#moveTreeWidget.getMainBranchNodes(), this.#controller.startFen(), + this.#moveTreeWidget ); } } diff --git a/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-controller.js b/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-controller.js index 767af16ba..e47f2fe2a 100644 --- a/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-controller.js +++ b/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-controller.js @@ -65,6 +65,7 @@ class GameController { #updateClocksCallback = () => console.log('update clocks'); #fetchMovesCallback = (moves) => console.log('fetch moves ' + moves); #receivedChatMessages = (chatMessages, acks) => console.log('received chat messages ' + chatMessages); + #opponentTypingCallback = (typingUsers) => console.log('opponents are typing: ' + JSON.stringify(typingUsers)); /** * @param gameId {string} @@ -82,6 +83,7 @@ class GameController { * @param updateClocksCallback {function()} * @param fetchMovesCallback {function(HalfMove[])} * @param receivedChatMessages {function(ChatMessageDto[], number[])} + * @param opponentTypingCallback {function(Array>)} Map of userId→username for users currently typing */ constructor( gameId, @@ -98,7 +100,9 @@ class GameController { drawDeclinedCallback, updateClocksCallback, fetchMovesCallback, - receivedChatMessages + receivedChatMessages, + opponentTypingCallback = (typingUsers) => { + } ) { this.#gameId = gameId; this.#inviteeJoinedCallback = inviteeJoinedCallback; @@ -114,6 +118,7 @@ class GameController { this.#updateClocksCallback = updateClocksCallback; this.#fetchMovesCallback = fetchMovesCallback; this.#receivedChatMessages = receivedChatMessages; + this.#opponentTypingCallback = opponentTypingCallback; this.#client = new GameClient(gameId); this.#client.getData(gameDto => { @@ -255,6 +260,9 @@ class GameController { this.#updateClocksCallback(); } this.#handleReceivedChatMessages(chatMessages); + if (Array.isArray(json.typingUsers) && json.typingUsers.length > 0) { + this.#opponentTypingCallback(json.typingUsers); + } } }); @@ -426,6 +434,12 @@ class GameController { } } + sendTypingEvent() { + if (this.#webSocket != null) { + this.#webSocket.send(JSON.stringify({"isTyping": true})); + } + } + /** * @param userId {string|null} */ diff --git a/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-page.js b/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-page.js index 9b93a4042..bc5947ea1 100644 --- a/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-page.js +++ b/webapp/src/main/resources/public/js/player-vs-player/player-vs-player-page.js @@ -70,6 +70,9 @@ class PlayGamePage extends BasePage { #topCounter = document.getElementById('top-counter'); #bottomCounter = document.getElementById('bottom-counter'); + #miniCounterBox = document.getElementById('mini-counter-box'); + #miniTopCounter = document.getElementById('mini-top-counter'); + #miniBottomCounter = document.getElementById('mini-bottom-counter'); /** * @type {HTMLElement[]} @@ -111,6 +114,9 @@ class PlayGamePage extends BasePage { this.#moveTreeWidget.addNavigationListener(() => this.#handleNavigationEvent()); new SettingsGui(this.#boardGui, this.#moveTreeWidget); + // hide the mobile mini timer when the main counter box is on screen + this.#setUpMiniCounterVisibility(); + // handle params const params = new URLSearchParams(window.location.search); const gameIdParam = params.get('id'); @@ -201,6 +207,9 @@ class PlayGamePage extends BasePage { }, (chatMessages, acks) => { this.#handleChatMessages(chatMessages, acks); + }, + (typingUsers) => { + this.#chatBoxWidget.notifyTypingUsers(typingUsers); } ); @@ -231,9 +240,7 @@ class PlayGamePage extends BasePage { this.#cancelButton.addEventListener('click', (e) => { if (isInfoBoxButtonEnabled(e)) { - this.#gameController.cancel(() => { - this.#updateBoardMaskMessage(); - }); + this.#handleCancelButtonClick(); } }); @@ -284,6 +291,12 @@ class PlayGamePage extends BasePage { this.#chatBoxWidget.addInputLosesFocusListener(() => { this.#moveTreeWidget.enableKeyboardNavigation(); }); + + this.#chatBoxWidget.addInputTypingListener(() => { + if (this.#gameController != null) { + this.#gameController.sendTypingEvent(); + } + }); } #updateGui() { @@ -660,16 +673,40 @@ class PlayGamePage extends BasePage { case Color.RED: this.#topCounter.classList.add('black-counter'); this.#bottomCounter.classList.add('red-counter'); + this.#miniTopCounter.classList.add('black-counter'); + this.#miniBottomCounter.classList.add('red-counter'); break; case Color.BLACK: this.#topCounter.classList.add('red-counter'); this.#bottomCounter.classList.add('black-counter'); + this.#miniTopCounter.classList.add('red-counter'); + this.#miniBottomCounter.classList.add('black-counter'); break; default: throw new Error('Incorrect color: ' + color); } } + #setUpMiniCounterVisibility() { + const mainCounterBox = document.getElementById('counter-box'); + if (mainCounterBox == null || this.#miniCounterBox == null) { + return; + } + if (typeof IntersectionObserver === 'undefined') { + return; + } + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.#miniCounterBox.classList.add('mini-counter-box-hidden'); + } else { + this.#miniCounterBox.classList.remove('mini-counter-box-hidden'); + } + }); + }); + observer.observe(mainCounterBox); + } + /** * @param value {boolean} */ @@ -767,6 +804,17 @@ class PlayGamePage extends BasePage { UI.showConfirmationModal(text, yesCallback, yesButtonText, noCallback, noButtonText); } + #handleCancelButtonClick() { + const text = buildSimpleSpan('Are you sure you want to cancel this game?'); + const yesCallback = () => this.#gameController.cancel(() => { + this.#updateBoardMaskMessage(); + }); + const yesButtonText = 'yes'; + const noCallback = () => UI.hideModal(null); + const noButtonText = 'no'; + UI.showConfirmationModal(text, yesCallback, yesButtonText, noCallback, noButtonText); + } + #handleDrawPropositionReceived() { const text = buildSimpleSpan('Your opponent has proposed a draw. Do you accept?'); const yesCallback = () => this.#gameController.respondToDrawProposition(true); @@ -793,7 +841,9 @@ class PlayGamePage extends BasePage { if (this.#gameController.isGameFinished()) { renderAnalysisSummaryReportGeneric( new GameId(GameType.PVP, this.#gameController.gameId), - this.#moveTreeWidget.getMainBranchNodes() + this.#moveTreeWidget.getMainBranchNodes(), + DEFAULT_START_FEN, + this.#moveTreeWidget ); } } diff --git a/webapp/src/main/resources/public/js/simple-board.js b/webapp/src/main/resources/public/js/simple-board.js index 6b5d7b7bb..5b78af235 100644 --- a/webapp/src/main/resources/public/js/simple-board.js +++ b/webapp/src/main/resources/public/js/simple-board.js @@ -23,7 +23,23 @@ class SimpleBoardPage extends BasePage { super(); const boardGui = createWebappBoardGui(); boardGui.loadFen(DEFAULT_START_FEN); - new SettingsGui(boardGui, null); + + const moveTreeWidget = new MoveTreeWidget({containerId: 'move-tree-container'}); + moveTreeWidget.addNavigationPanel({ + containerId: 'mobile-navigation-panel', + isDownloadButtonEnabled: true + }); + moveTreeWidget.addNavigationPanel({ + containerId: 'move-history-navigation-panel', + isDownloadButtonEnabled: true + }); + moveTreeWidget.boardWidget = boardGui; + + boardGui.addAfterMoveListener((move) => { + moveTreeWidget.addMoveAtTheEnd(move); + }); + + new SettingsGui(boardGui, moveTreeWidget); } } diff --git a/webapp/src/main/resources/public/js/user-profile/user-profile-games.js b/webapp/src/main/resources/public/js/user-profile/user-profile-games.js new file mode 100644 index 000000000..80f88d44f --- /dev/null +++ b/webapp/src/main/resources/public/js/user-profile/user-profile-games.js @@ -0,0 +1,94 @@ +/* + * 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 . + */ + +/** + * Loads the latest PvP games of a given user and renders them into the + * pre-rendered {@code .pvp-game-thumb} divs of the user profile page. + * + * Uses the {@code /api/game-data/list-latest-pvp-games-by-user} endpoint. + */ +class UserProfileGames { + + #username; + #section = document.getElementById('latest-games-section'); + + /** + * @type {HTMLDivElement[]} + */ + #thumbDivs = getElementsByClassNameArray('pvp-game-thumb'); + + /** + * @type {GameThumb[]} + */ + #thumbs = []; + + /** + * @param username {string} + */ + constructor(username) { + this.#username = username; + + this.#thumbs = this.#thumbDivs.map((div, i) => { + const boardId = `last-pvp-game-board-${i}`; + const boardGui = createWebappBoardGui({ + elementId: boardId, + showCoordinates: false, + mini: true, + }); + return new GameThumb(div, boardGui); + }); + + this.#fetchGames(); + } + + #fetchGames() { + const limit = this.#thumbs.length; + const url = `/api/game-data/list-latest-pvp-games-by-user` + + `?limit=${limit}` + + `&username=${encodeURIComponent(this.#username)}`; + + getAndHandle(url, (json) => { + const entries = (json.entries || []).map((entry) => new GameMetadataDto(entry)); + this.#renderEntries(entries); + }); + } + + /** + * @param entries {GameMetadataDto[]} + */ + #renderEntries(entries) { + if (entries.length === 0) { + // No games to show: keep the whole "Latest Games" section hidden. + return; + } + + if (this.#section != null) { + this.#section.style.display = 'block'; + } + + for (let i = 0; i < this.#thumbs.length; i++) { + if (i < entries.length) { + this.#thumbs[i].render(entries[i], 'user_profile'); + } else { + // Hide unused pre-rendered thumbs when fewer games than slots + this.#thumbDivs[i].style.display = 'none'; + } + } + } +} diff --git a/webapp/src/main/resources/public/js/user-profile/user-profile.js b/webapp/src/main/resources/public/js/user-profile/user-profile.js index 5c57adb47..e24af1af7 100644 --- a/webapp/src/main/resources/public/js/user-profile/user-profile.js +++ b/webapp/src/main/resources/public/js/user-profile/user-profile.js @@ -22,6 +22,7 @@ const NO_LABEL_DAYS = 50; class UserProfilePage extends BasePage { #userId = document.querySelector('body').dataset.userId; + #username = document.querySelector('body').dataset.username; #client = new UserProfileClient(this.#userId); #statusIndicator = document.getElementById('status-indicator'); #puzzleStatsSection = document.getElementById('puzzle-stats-section'); @@ -29,6 +30,7 @@ class UserProfilePage extends BasePage { constructor() { super(); this.#fetchGamesRatings(); + this.#fetchLatestPvpGames(); this.#fetchPuzzlesStatsSummary(); this.#fetchPuzzlesStatsRating(); this.#fetchPuzzlesStatsNumbers(); @@ -50,6 +52,12 @@ class UserProfilePage extends BasePage { }); } + #fetchLatestPvpGames() { + if (this.#username) { + new UserProfileGames(this.#username); + } + } + #fetchPuzzlesStatsSummary() { this.#client.fetchPuzzleStatsSummary(summaryDto => { document.getElementById('summary-data-rating').innerText = formatNumber(summaryDto.rating); diff --git a/webapp/src/main/resources/public/js/user-settings/user-sessions-page.js b/webapp/src/main/resources/public/js/user-settings/user-sessions-page.js new file mode 100644 index 000000000..9e60c2618 --- /dev/null +++ b/webapp/src/main/resources/public/js/user-settings/user-sessions-page.js @@ -0,0 +1,82 @@ +/* + * 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 . + */ + +const SESSIONS_PAGE_SIZE = 50; + +class UserSessionsPage extends InfiniteScrollPage { + + #widget; + #offset = 0; + + constructor() { + super(); + this.#widget = new UserSessionsWidget({ + onDelete: () => this.#reload(), + }); + this.#widget.clear(); + this.fetchItems(); + } + + shouldFetchNextPage() { + const rows = this.#widget.table.tBodies[0]?.querySelectorAll('tr'); + if (!rows || rows.length === 0) return false; + return isInViewport(rows[rows.length - 1]); + } + + baseUrl() { + return USER_SESSIONS_URL; + } + + deserializeJsonEntry(jsonEntry) { + return jsonEntry; + } + + extractToken(_entry) { + return this.#offset.toString(); + } + + showNoItem(value) { + const emptyMessage = document.getElementById('user-sessions-empty-message'); + if (emptyMessage != null) { + emptyMessage.classList.toggle('hidden', !value); + } + } + + additionalParameters() { + const params = new Map(); + params.set('limit', SESSIONS_PAGE_SIZE.toString()); + params.set('offset', this.#offset.toString()); + return params; + } + + addEntries(entries) { + this.#widget.appendEntries(entries); + this.#offset += entries.length; + } + + #reload() { + this.#offset = 0; + this.resetPagination(); + this.#widget.clear(); + this.fetchItems(); + } + +} + +window.onload = () => new UserSessionsPage(); diff --git a/webapp/src/main/resources/public/js/user-settings/user-sessions-widget.js b/webapp/src/main/resources/public/js/user-settings/user-sessions-widget.js new file mode 100644 index 000000000..5076ac186 --- /dev/null +++ b/webapp/src/main/resources/public/js/user-settings/user-sessions-widget.js @@ -0,0 +1,277 @@ +/* + * 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 . + */ + +const USER_SESSIONS_URL = '/api/user/settings/sessions'; +const DELETE_USER_SESSIONS_URL = USER_SESSIONS_URL + '/delete'; +const DELETE_ALL_USER_SESSIONS_URL = USER_SESSIONS_URL + '/delete-all'; +const MAX_CHARS_IN_CELL = 16; + +class UserSessionsWidget { + + #table; + #actionsContainer; + #deleteButton; + #deleteAllButton; + #emptyMessage; + #allSessionsLink; + #limit; + #fetchAll; + #selectable; + #onUpdate; + #onDelete; + + constructor(options = {}) { + this.#table = document.getElementById(options.tableId || 'user-sessions-table'); + this.#actionsContainer = document.getElementById(options.actionsContainerId || 'sessions-actions-container'); + this.#deleteButton = document.getElementById(options.deleteButtonId || 'delete-sessions-button'); + this.#deleteAllButton = document.getElementById(options.deleteAllButtonId || 'delete-all-sessions-button'); + this.#emptyMessage = document.getElementById(options.emptyMessageId || 'user-sessions-empty-message'); + this.#allSessionsLink = document.getElementById(options.allSessionsLinkId || 'all-sessions-link'); + this.#limit = options.limit || 5; + this.#fetchAll = options.fetchAll === true; + this.#selectable = options.selectable !== false; + this.#onUpdate = options.onUpdate || (() => { + }); + this.#onDelete = options.onDelete || null; + + if (this.#deleteButton != null) { + this.#deleteButton.addEventListener('click', () => this.#deleteSelectedSessions()); + } + if (this.#deleteAllButton != null) { + this.#deleteAllButton.addEventListener('click', () => this.#confirmDeleteAllSessions()); + UI.preloadModal(Modals.CONFIRMATION); + } + } + + /** + * The underlying element. Exposed so external paginators can + * inspect rendered rows (e.g. `InfiniteScrollPage.shouldFetchNextPage`). + * @returns {HTMLTableElement} + */ + get table() { + return this.#table; + } + + fetchAndRender() { + if (!this.#fetchAll) { + getAndHandle(`${USER_SESSIONS_URL}?limit=${this.#limit}`, (json) => this.#render(json)); + return; + } + + getAndHandle(`${USER_SESSIONS_URL}?limit=1`, (json) => { + const total = Number(json.total || 0); + const allLimit = total > 0 ? total : 1; + getAndHandle(`${USER_SESSIONS_URL}?limit=${allLimit}`, (allJson) => this.#render(allJson)); + }); + } + + /** + * Clear the table body and reset the widget's footer/actions UI to its + * empty state. Intended for paginators that drive rendering externally. + */ + clear() { + emptyTable(this.#table); + if (this.#actionsContainer != null) { + this.#actionsContainer.classList.add('hidden'); + } + if (this.#emptyMessage != null) { + this.#emptyMessage.classList.add('hidden'); + } + if (this.#allSessionsLink != null) { + this.#allSessionsLink.classList.add('hidden'); + } + } + + /** + * Append rows for the given entries to the table body, without clearing + * what is already rendered. Toggles the "actions" toolbar on the first + * batch that contains rows. + * @param entries {Array} + */ + appendEntries(entries) { + if (!entries || entries.length === 0) return; + + const tbody = this.#table.tBodies[0] || this.#table.appendChild(document.createElement('tbody')); + entries.forEach(entry => this.#appendRow(tbody, entry)); + + if (this.#actionsContainer != null && this.#selectable) { + this.#actionsContainer.classList.remove('hidden'); + } + if (this.#emptyMessage != null) { + this.#emptyMessage.classList.add('hidden'); + } + } + + #render(json) { + const entries = json.entries || []; + const total = Number(json.total || 0); + + this.clear(); + this.appendEntries(entries); + + const hasEntries = entries.length > 0; + if (this.#emptyMessage != null) { + this.#emptyMessage.classList.toggle('hidden', hasEntries); + } + if (this.#allSessionsLink != null) { + this.#allSessionsLink.classList.toggle('hidden', total <= entries.length); + } + + this.#onUpdate(entries, total); + } + + #appendRow(tbody, entry) { + const row = tbody.insertRow(); + if (this.#selectable) { + const checkboxCell = row.insertCell(); + checkboxCell.className = 'select-cell'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.sessionId = entry.id.toString(); + checkbox.className = 'user-session-checkbox'; + checkboxCell.append(checkbox); + } + + row.insertCell().append(this.#buildOsCellContent(entry.os)); + row.insertCell().innerText = entry.agentName; + row.insertCell().append(this.#buildCountryCellContent(entry.countryCode, entry.countryName)); + this.#insertCroppableCell(row, entry.region); + this.#insertCroppableCell(row, entry.city); + row.insertCell().innerText = entry.remoteAddress; + row.insertCell().innerText = formatTimestampToDateTime(entry.created); + row.insertCell().innerText = formatTimestampToDateTime(entry.updated); + } + + #insertCroppableCell(row, value) { + const cell = row.insertCell(); + cell.innerText = cropText(value, MAX_CHARS_IN_CELL); + if (value != null && value.length > MAX_CHARS_IN_CELL) { + cell.id = randomId(); + addToolTip(cell, value); + } + } + + #buildCountryCellContent(countryCode, countryName) { + const container = document.createElement('div'); + container.className = 'session-country-cell'; + + if (countryCode != null && countryCode !== '-' && typeof buildFlagIconImg === 'function') { + try { + container.append(buildFlagIconImg(countryCode)); + } catch (e) { + console.warn('Could not render country flag icon', e); + } + } + + const countryLabelText = + countryName + || (countryCode != null && typeof getCountryName === 'function' ? getCountryName(countryCode) : countryCode); + + const countryLabel = document.createElement('span'); + countryLabel.innerText = cropText(countryLabelText, MAX_CHARS_IN_CELL); + if (countryLabelText != null && countryLabelText.length > MAX_CHARS_IN_CELL) { + countryLabel.id = randomId(); + addToolTip(countryLabel, countryLabelText); + } + container.append(countryLabel); + return container; + } + + #buildOsCellContent(osName) { + const container = document.createElement('div'); + container.className = 'session-os-cell'; + + const iconSrc = this.#mapOsNameToIcon(osName); + let icon; + if (iconSrc != null) { + icon = document.createElement('img'); + icon.src = iconSrc; + icon.alt = osName || 'unknown operating system'; + } else { + icon = document.createElement('span'); + icon.innerText = '❔'; + icon.setAttribute('role', 'img'); + icon.setAttribute('aria-label', osName || 'unknown operating system'); + } + icon.className = 'session-os-icon'; + icon.title = osName; + + const label = document.createElement('span'); + label.innerText = osName; + + container.append(icon, label); + return container; + } + + #mapOsNameToIcon(osName) { + const lower = (osName || '').toLowerCase(); + const base = '/images/icons/os/'; + if (lower.startsWith('android')) return base + 'android.png'; + if (lower.startsWith('linux')) return base + 'linux.png'; + if (lower === 'mac' || lower.startsWith('mac os') || lower.startsWith('macos') || lower.startsWith('ios')) return base + 'apple.png'; + if (lower.startsWith('windows')) return base + 'windows.png'; + return null; + } + + + #deleteSelectedSessions() { + const selectedIds = Array.from(this.#table.querySelectorAll('.user-session-checkbox:checked')) + .map(checkbox => Number(checkbox.dataset.sessionId)); + + if (selectedIds.length === 0) { + UI.pushErrorNotification('No sessions deleted.', 2_500); + return; + } + + postAndHandle(DELETE_USER_SESSIONS_URL, {sessionIds: selectedIds}, (json) => { + this.#handleDeleteResponse(json); + }); + } + + #confirmDeleteAllSessions() { + const message = buildSimpleSpan( + 'Are you sure you want to delete all your sessions?' + ); + UI.showConfirmationModal( + message, + () => this.#deleteAllSessions(), + 'delete all', + () => UI.hideModal(null), + 'cancel' + ); + } + + #deleteAllSessions() { + postAndHandle(DELETE_ALL_USER_SESSIONS_URL, null, (json) => { + this.#handleDeleteResponse(json); + }); + } + + #handleDeleteResponse(json) { + const deletedCount = json.deletedCount || 0; + UI.pushInfoNotification(`${deletedCount} session(s) deleted.`, 2_500); + if (this.#onDelete != null) { + this.#onDelete(deletedCount); + } else { + this.fetchAndRender(); + } + } + +} diff --git a/webapp/src/main/resources/public/js/user-settings/user-settings.js b/webapp/src/main/resources/public/js/user-settings/user-settings.js index 4dc0dc8ba..5befc12cc 100644 --- a/webapp/src/main/resources/public/js/user-settings/user-settings.js +++ b/webapp/src/main/resources/public/js/user-settings/user-settings.js @@ -22,6 +22,7 @@ const UI_NOTIFICATION_TIMEOUT = 2_500; const USER_SETTINGS_API = '/api/user/settings'; const PROFILE_URL = USER_SETTINGS_API + '/profile'; const EMAIL_SETTINGS_URL = USER_SETTINGS_API + '/email-address'; +const RESEND_EMAIL_CONFIRMATION_URL = EMAIL_SETTINGS_URL + '/resend-confirmation'; const USERNAME_MAX_DESCRIPTION_LENGTH = 1_000; @@ -40,6 +41,9 @@ class UserSettingsPage extends BasePage { // TODO: email address section #emailAddressField = document.getElementById('email-address'); + // sessions section + #sessionsWidget = new UserSessionsWidget({limit: 8, selectable: false}); + constructor() { super(); @@ -61,11 +65,14 @@ class UserSettingsPage extends BasePage { // email address section this.#fetchEmailAddressSettings(); + + // sessions section + this.#sessionsWidget.fetchAndRender(); } #fetchProfileSettings() { getAndHandle(PROFILE_URL, json => { - this.#descriptionField.value = json.description; + this.#descriptionField.value = json.description ?? ''; this.#updateDescriptionCharacterCounter(); if (json.country != null) { let countryName = getCountryName(json.country) @@ -84,10 +91,12 @@ class UserSettingsPage extends BasePage { }); } - // TODO: 'none' option selected #updateProfileSettings() { let description = this.#descriptionField.value; let country = this.#countryField.value; + if (country === 'none') { + country = ''; + } let body = {'description': description, 'country': country}; postAndHandle(PROFILE_URL, body, () => { UI.pushInfoNotification('Profile settings successfully updated!', UI_NOTIFICATION_TIMEOUT); @@ -100,18 +109,43 @@ class UserSettingsPage extends BasePage { #fetchEmailAddressSettings() { getAndHandle(EMAIL_SETTINGS_URL, json => { - let email = json.email; - let isValid = json.isValid; - - this.#emailAddressField.value = email; - let elementId = 'email-validity-unknown'; - if (isValid === true) { - elementId = 'email-valid'; - } else if (isValid === false) { - elementId = 'email-invalid'; + this.#emailAddressField.value = json.email; + let elementId; + switch (json.validityStatus) { + case 'MANUALLY_CONFIRMED': + elementId = 'email-manually-confirmed'; + break; + case 'AUTOMATED_VALID': + elementId = 'email-automated-valid'; + break; + case 'AUTOMATED_BOUNCED': + elementId = 'email-automated-bounced'; + break; + case 'AUTOMATED_INVALID': + elementId = 'email-automated-invalid'; + break; + default: + elementId = 'email-validity-unknown'; } - document.getElementById(elementId).classList.remove('hidden'); + + // The resend button is shown for any status except MANUALLY_CONFIRMED, since manual + // confirmation is the strongest signal and a resend would not change anything. + if (json.validityStatus !== 'MANUALLY_CONFIRMED') { + const container = document.getElementById('resend-email-confirmation-container'); + const button = document.getElementById('resend-email-confirmation-button'); + container.classList.remove('hidden'); + button.addEventListener('click', () => this.#resendEmailConfirmation()); + } + }); + } + + #resendEmailConfirmation() { + const button = document.getElementById('resend-email-confirmation-button'); + button.disabled = true; + postAndHandle(RESEND_EMAIL_CONFIRMATION_URL, null, () => { + UI.pushInfoNotification('Confirmation email sent!', UI_NOTIFICATION_TIMEOUT); + button.disabled = false; }); } diff --git a/webapp/src/main/resources/public/js/userdata/my-db-searches-dto.js b/webapp/src/main/resources/public/js/userdata/my-db-searches-dto.js new file mode 100644 index 000000000..3a793dd7b --- /dev/null +++ b/webapp/src/main/resources/public/js/userdata/my-db-searches-dto.js @@ -0,0 +1,108 @@ +/* + * 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 . + */ + +class MyDbSearchEntryDto { + + #queryId; + #updateTime; + #playerName; + #playerColor; + #eventName; + #searchStart; + #searchEnd; + #fen; + #numberOfResults; + + constructor(json) { + this.#queryId = json.queryId; + this.#updateTime = json.updateTime; + this.#playerName = json.playerName; + this.#playerColor = json.playerColor; + this.#eventName = json.eventName; + this.#searchStart = json.searchStart; + this.#searchEnd = json.searchEnd; + this.#fen = json.fen; + this.#numberOfResults = json.numberOfResults; + } + + /** + * @returns {string} + */ + get queryId() { + return this.#queryId; + } + + /** + * @returns {number} + */ + get updateTime() { + return this.#updateTime; + } + + /** + * @returns {string|null} + */ + get playerName() { + return this.#playerName; + } + + /** + * @returns {string|null} + */ + get playerColor() { + return this.#playerColor; + } + + /** + * @returns {string|null} + */ + get eventName() { + return this.#eventName; + } + + /** + * @returns {string|null} + */ + get searchStart() { + return this.#searchStart; + } + + /** + * @returns {string|null} + */ + get searchEnd() { + return this.#searchEnd; + } + + /** + * @returns {string|null} + */ + get fen() { + return this.#fen; + } + + /** + * @returns {number} + */ + get numberOfResults() { + return this.#numberOfResults; + } + + +} diff --git a/webapp/src/main/resources/public/js/userdata/my-db-searches.js b/webapp/src/main/resources/public/js/userdata/my-db-searches.js new file mode 100644 index 000000000..83c024495 --- /dev/null +++ b/webapp/src/main/resources/public/js/userdata/my-db-searches.js @@ -0,0 +1,203 @@ +/* + * 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 . + */ + +class MyDbSearchesPage extends InfiniteScrollPage { + + /** + * @type {HTMLDivElement} + */ + #itemsDiv = document.getElementById('my-game-items'); + + /** + * @type {HTMLDivElement} + */ + #noSearchesMessage = document.getElementById('no-searches-message'); + + constructor() { + super(); + this.fetchItems(); + } + + baseUrl() { + return '/api/database/list-user-searches'; + } + + deserializeJsonEntry(jsonEntry) { + return new MyDbSearchEntryDto(jsonEntry); + } + + /** + * @param entry {MyDbSearchEntryDto} + */ + extractToken(entry) { + return entry.updateTime.toString(); + } + + showNoItem(value) { + this.#noSearchesMessage.style.display = value ? 'block' : 'none'; + } + + /** + * @param entries {MyDbSearchEntryDto[]} + */ + addEntries(entries) { + entries.forEach(entry => { + const leftPane = buildDivWithClass('left-pane'); + const middlePane = buildDivWithClass('middle-pane'); + const rightPane = buildDivWithClass('right-pane'); + + const item = document.createElement('a'); + item.className = 'my-game-item'; + item.setAttribute('href', this.#buildRepeatSearchUrl(entry)); + + item.append(leftPane, middlePane, rightPane); + this.#itemsDiv.append(item); + + // left pane: database icon + const dbIcon = document.createElement('img'); + dbIcon.className = 'icon'; + dbIcon.src = '/images/icons/data-search.png'; + dbIcon.alt = 'DB Search'; + leftPane.append(wrapInDiv(dbIcon)); + + // middle pane: search summary + const summaryDiv = document.createElement('div'); + summaryDiv.className = 'default-text'; + summaryDiv.append(this.#buildSearchSummaryElement(entry)); + middlePane.append(summaryDiv); + + let numberOfResultsStr; + if (entry.numberOfResults >= 20) { + numberOfResultsStr = '20+ results'; + } else if (entry.numberOfResults > 0) { + numberOfResultsStr = `${entry.numberOfResults} result${entry.numberOfResults > 1 ? 's' : ''}`; + } else { + numberOfResultsStr = 'No results'; + } + + const resultsDiv = document.createElement('div'); + resultsDiv.className = 'game-status'; + resultsDiv.innerText = numberOfResultsStr; + middlePane.append(resultsDiv); + + // right pane: last used time + const lastUsedDiv = document.createElement('div'); + lastUsedDiv.className = 'last-modified'; + lastUsedDiv.id = `last-used-${entry.queryId}`; + setRelativeTimeAndToolTip(lastUsedDiv, entry.updateTime); + rightPane.append(lastUsedDiv); + + // mini board overview when a FEN was searched + if (entry.fen !== null && entry.fen.trim().length > 0) { + const orientation = entry.playerColor === Color.BLACK ? Color.BLACK : Color.RED; + const fen = entry.fen.trim(); + // Stored FENs may be abridged (board layout only); complete them so loadFen accepts them. + const fullFen = fen.includes(' ') ? fen : `${fen} w - - 0 1`; + try { + addMiniboardDiv(item, entry.queryId, fullFen, orientation); + } catch (e) { + // A malformed FEN should not break the rest of the list or pagination. + console.warn(`could not render mini-board for query ${entry.queryId}:`, e); + } + } + }); + } + + /** + * Builds the URL to repeat the given search on the database search page. + * @param entry {MyDbSearchEntryDto} + * @returns {string} + */ + #buildRepeatSearchUrl(entry) { + const params = new URLSearchParams(); + if (entry.playerName !== null) { + // strip any bracketed content (e.g. Chinese name suffix) added for display + const sanitizedPlayerName = entry.playerName.replace(/\s*\([^)]*\)\s*/g, ' ').trim(); + if (sanitizedPlayerName.length > 0) { + params.set('playerName', sanitizedPlayerName); + } + } + if (entry.playerColor !== null) { + params.set('playerColor', entry.playerColor); + } + if (entry.eventName !== null && entry.eventName.trim().length > 0) { + params.set('eventName', entry.eventName); + } + if (entry.searchStart !== null) { + params.set('dateStart', entry.searchStart); + } + if (entry.searchEnd !== null) { + params.set('dateEnd', entry.searchEnd); + } + if (entry.fen !== null) { + params.set('fen', entry.fen); + } + const queryString = params.toString(); + return `/database/search${queryString ? '?' + queryString : ''}`; + } + + /** + * Builds a human-readable summary of the search parameters as a DOM element. + * The player name is rendered with a color class when a player color is set. + * @param entry {MyDbSearchEntryDto} + * @returns {HTMLSpanElement} + */ + #buildSearchSummaryElement(entry) { + const container = document.createElement('span'); + const parts = []; + + if (entry.playerName !== null && entry.playerName.trim().length > 0) { + const playerSpan = document.createElement('span'); + playerSpan.innerText = entry.playerName; + if (entry.playerColor === Color.RED) { + playerSpan.classList.add('red-color'); + } else if (entry.playerColor === Color.BLACK) { + playerSpan.classList.add('black-color'); + } + parts.push(playerSpan); + } + if (entry.eventName !== null && entry.eventName.trim().length > 0) { + parts.push(entry.eventName); + } + if (entry.searchStart !== null || entry.searchEnd !== null) { + const start = entry.searchStart ?? '...'; + const end = entry.searchEnd ?? '...'; + parts.push(`${start} → ${end}`); + } + if (entry.fen !== null && entry.fen.trim().length > 0) { + parts.push(`FEN: ${entry.fen}`); + } + + if (parts.length === 0) { + container.innerText = 'All games'; + return container; + } + + parts.forEach((part, index) => { + if (index > 0) { + container.append(document.createElement('br')); + } + container.append(part); + }); + return container; + } + +} + +window.onload = () => new MyDbSearchesPage(); diff --git a/webapp/src/main/resources/public/js/widgets/analysis-summary-report.js b/webapp/src/main/resources/public/js/widgets/analysis-summary-report.js index 5f7000488..7de8ccff8 100644 --- a/webapp/src/main/resources/public/js/widgets/analysis-summary-report.js +++ b/webapp/src/main/resources/public/js/widgets/analysis-summary-report.js @@ -21,9 +21,11 @@ * @param gameId {GameId} * @param nodes {MoveTreeNode[]} * @param startFen {string} + * @param moveTreeWidget {MoveTreeWidget|null} optional widget on which annotation symbols (??, ?!, etc.) will be + * applied to the move history once analysis data is fetched */ -function renderAnalysisSummaryReportGeneric(gameId, nodes, startFen = DEFAULT_START_FEN) { - if (startFen != null) { +function renderAnalysisSummaryReportGeneric(gameId, nodes, startFen = DEFAULT_START_FEN, moveTreeWidget = null) { + if (startFen == null) { startFen = DEFAULT_START_FEN; } @@ -45,6 +47,10 @@ function renderAnalysisSummaryReportGeneric(gameId, nodes, startFen = DEFAULT_ST gameMetadata.blackPlayerName, gameMetadata.outcome ); + + if (moveTreeWidget != null) { + moveTreeWidget.applyAnnotationSymbolsFromCache(analysisMap); + } }); }); } @@ -160,6 +166,9 @@ function renderAnalysisSummaryReport( row.cells.item(3).innerText = (counterBlack.get(symbolType) || 0).toString() } + const evalChart = new EvalLineChart('eval-line-chart-container', nodes, analysisMap, startFen); + evalChart.render(); + if (redPlayerName != null) { document .getElementById('analysis-summary-red-player-name') diff --git a/webapp/src/main/resources/public/js/widgets/board-gui.js b/webapp/src/main/resources/public/js/widgets/board-gui.js index 0a84486f6..e822f3432 100644 --- a/webapp/src/main/resources/public/js/widgets/board-gui.js +++ b/webapp/src/main/resources/public/js/widgets/board-gui.js @@ -129,7 +129,31 @@ const EngineArrowType = Object.freeze({ */ const CoordinatesOrientation = Object.freeze({ WXF: 'WXF', - UCI: 'UCI', + ALGEBRAIC: 'ALGEBRAIC', +}); + +// Chinese numerals for files 1..9, used to label files in WXF mode when the +// {@link FileNumbersStyle} setting calls for Chinese numerals on that side. +const CHINESE_FILE_DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九']; + +/** + * How file numbers are rendered around the board in WXF mode. Only affects the + * file-number labels; the algebraic orientation (a..i letters) is unaffected. + */ +const FileNumbersStyle = Object.freeze({ + /** Arabic numerals (1..9) on both sides of the board. */ + ARABIC_BOTH: 'ARABIC_BOTH', + /** Chinese numerals (一..九) on both sides of the board. */ + CHINESE_BOTH: 'CHINESE_BOTH', + /** Chinese numerals on red's side; Arabic numerals on black's side (default). */ + CHINESE_RED_ONLY: 'CHINESE_RED_ONLY', + /** Chinese numerals on black's side; Arabic numerals on red's side. */ + CHINESE_BLACK_ONLY: 'CHINESE_BLACK_ONLY', + /** Chinese numerals on the bottom (lower) side of the screen; Arabic on the top side. */ + CHINESE_LOWER_ONLY: 'CHINESE_LOWER_ONLY', + /** Chinese numerals on the top side of the screen; Arabic on the bottom side. */ + CHINESE_TOP_ONLY: 'CHINESE_TOP_ONLY', + DEFAULT: 'CHINESE_RED_ONLY', }); /** @@ -159,6 +183,10 @@ const PieceStyleSetting = Object.freeze({ * serving the assets from the current host on localhost). * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece * image folder. + * @property {boolean} [colorblindFriendlyBlackPieces] - if true, black piece images get an invert + * CSS filter for improved contrast. + * @property {string} [fileNumbersStyle] - one of {@link FileNumbersStyle}; selects how + * file numbers are rendered in WXF mode. */ /** @type {Readonly>} */ @@ -171,6 +199,8 @@ const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({ svg: false, assetsBaseUrl: 'https://cdn.elephantchess.io/static', pieceStyle: PieceStyleSetting.DEFAULT, + colorblindFriendlyBlackPieces: false, + fileNumbersStyle: FileNumbersStyle.DEFAULT, }); class BoardGui { @@ -217,6 +247,7 @@ class BoardGui { this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`); this.#boardContainer = document.getElementById(this.#options.elementId); + this.#renderColorblindFriendlyBlackPiecesSetting(this.#options.colorblindFriendlyBlackPieces); this.#drawBoard(); this.#drawPieces(); // FIXME: useful? @@ -793,6 +824,12 @@ class BoardGui { (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1) } + function isKnightMove(move) { + const dx = Math.abs(move.to.x - move.from.x); + const dy = Math.abs(move.to.y - move.from.y); + return (dx === 1 && dy === 2) || (dx === 2 && dy === 1); + } + const boardBounds = this.#boardContainer.getBoundingClientRect(); const square1 = document.getElementById(this.#positionToElementId('square', move.from)); @@ -804,36 +841,92 @@ class BoardGui { const y1 = (bound1.top + bound1.height / 2) - boardBounds.top; const x2 = (bound2.left + bound2.width / 2) - boardBounds.left; const y2 = (bound2.top + bound2.height / 2) - boardBounds.top; - const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); - // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top) - let reduction; + // stroke width scales with the square size so the arrow looks consistent across viewports + // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered) + const strokeWidth = Math.min(16, bound1.width * 0.16); + + let pathD, midpointX, midpointY; + + if (isKnightMove(move)) { + // draw an elbowed arrow matching the horse's actual movement: + // segment 1: orthogonal (straight line, 1 square) — the horse's "leg" + // segment 2: diagonal (1 square) — the horse's diagonal step + const sourceReduction = bound1.width * 0.40; + // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the + // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly + // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth. + const destReduction = strokeWidth * 1.9; + const dxPx = x2 - x1; + const dyPx = y2 - y1; + + // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty) + function shortenToward(px, py, tx, ty, r) { + const dx = tx - px; + const dy = ty - py; + const d = Math.sqrt(dx * dx + dy * dy); + return [px + (dx / d) * r, py + (dy / d) * r]; + } + + let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY; + + const dyBoardSquares = Math.abs(move.to.y - move.from.y); + + if (dyBoardSquares === 2) { + // vertical-dominant: horse moves 2 squares vertically then 1 diagonally + // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement + elbowX = x1; + elbowY = y1 + dyPx / 2; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } else { + // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally + // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement + elbowX = x1 + dxPx / 2; + elbowY = y1; + + [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction); + [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction); + } - // we can not reduce by as much when the arrow is already very small - // otherwise, the direction of the arrow flips and points the wrong way - if (isSmallMove(move)) { - // we remove 40% a square length - reduction = bound1.width * 0.40; + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = elbowX; + midpointY = elbowY; } else { - // we remove 50% a square length - reduction = bound1.width * 0.50; - } + const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); - const reducedDist = dist - reduction; - const ratio = reducedDist / dist; + // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top) + let reduction; - const x1Prime = x2 + ratio * (x1 - x2); - const y1Prime = y2 + ratio * (y1 - y2); - const x2Prime = x1 + ratio * (x2 - x1); - const y2Prime = y1 + ratio * (y2 - y1); + // we can not reduce by as much when the arrow is already very small + // otherwise, the direction of the arrow flips and points the wrong way + if (isSmallMove(move)) { + // we remove 40% a square length + reduction = bound1.width * 0.40; + } else { + // we remove 50% a square length + reduction = bound1.width * 0.50; + } + + const reducedDist = dist - reduction; + const ratio = reducedDist / dist; + + const x1Prime = x2 + ratio * (x1 - x2); + const y1Prime = y2 + ratio * (y1 - y2); + const x2Prime = x1 + ratio * (x2 - x1); + const y2Prime = y1 + ratio * (y2 - y1); - const p1 = `M${Math.round(x1Prime)},${Math.round(y1Prime)}`; - const p2 = `L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`; + midpointX = (x1Prime + x2Prime) / 2; + midpointY = (y1Prime + y2Prime) / 2; + } const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - arrowPath.setAttribute('d', `${p1} ${p2}`); - arrowPath.style.strokeWidth = `13px`; + arrowPath.setAttribute('d', pathD); + arrowPath.style.strokeWidth = `${strokeWidth}px`; arrowPath.style.fill = 'none'; + arrowPath.style.strokeLinejoin = 'round'; switch (type) { case EngineArrowType.PRIMARY: @@ -847,8 +940,6 @@ class BoardGui { } // draw number circle - const midpointX = (x1Prime + x2Prime) / 2; - const midpointY = (y1Prime + y2Prime) / 2; const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute('r', (bound1.width * 0.20).toString()); circle.setAttribute('cx', midpointX.toString()); @@ -1125,7 +1216,28 @@ class BoardGui { const isSettingEnabled = orientation !== null; // when the user has disabled coordinates we still reserve the space (hidden labels), // so we must pick an arbitrary orientation for the (invisible) labels: - const isWfxOriented = orientation !== CoordinatesOrientation.UCI; + const isWfxOriented = orientation !== CoordinatesOrientation.ALGEBRAIC; + + const fileNumbersStyle = this.#options.fileNumbersStyle; + // For a given side ('red' | 'black') and its screen position ('top' | 'bottom'), + // should we render Chinese numerals? + const isChineseOnSide = (side, position) => { + switch (fileNumbersStyle) { + case FileNumbersStyle.ARABIC_BOTH: + return false; + case FileNumbersStyle.CHINESE_BOTH: + return true; + case FileNumbersStyle.CHINESE_BLACK_ONLY: + return side === 'black'; + case FileNumbersStyle.CHINESE_LOWER_ONLY: + return position === 'bottom'; + case FileNumbersStyle.CHINESE_TOP_ONLY: + return position === 'top'; + case FileNumbersStyle.CHINESE_RED_ONLY: + default: + return side === 'red'; + } + }; // draw top file coordinates if (isWfxOriented) { @@ -1133,14 +1245,21 @@ class BoardGui { if (!this.#flippedRed) { topCoordinatesY = 0; } + // top row shows black's side when red is at the bottom, and red's side when flipped + const topSide = this.#flippedRed ? 'black' : 'red'; + const topChinese = isChineseOnSide(topSide, 'top'); for (let x = 0; x < BOARD_WIDTH; x++) { // actual text - let label = '?'; + let label; if (this.#flippedRed) { - label = (x + 1).toString(); + // top row: file 1 is on the right (black perspective) + label = topChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString(); } else { - label = (BOARD_WIDTH - x).toString(); + // top row: file 1 is on the left (board flipped, red on top) + label = topChinese + ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1] + : (BOARD_WIDTH - x).toString(); } let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled); @@ -1154,14 +1273,21 @@ class BoardGui { if (!this.#flippedRed) { bottomCoordinatesY = BOARD_HEIGHT - 1; } + // bottom row shows red's side when red is at the bottom, and black's side when flipped + const bottomSide = this.#flippedRed ? 'red' : 'black'; + const bottomChinese = isChineseOnSide(bottomSide, 'bottom'); for (let x = 0; x < BOARD_WIDTH; x++) { // actual text - let label = '?'; + let label; if (isWfxOriented) { if (this.#flippedRed) { - label = (BOARD_WIDTH - x).toString(); + // bottom row: file 1 is on the left (red perspective) + label = bottomChinese + ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1] + : (BOARD_WIDTH - x).toString(); } else { - label = (x + 1).toString(); + // bottom row: file 1 is on the right (board flipped, black at bottom) + label = bottomChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString(); } } else { label = UCI_LETTER[x]; @@ -1230,10 +1356,13 @@ class BoardGui { * @param position {Position} */ #drawPieceAt(pieceChar, position) { - let square = document.getElementById(this.#positionToElementId('square', position)); - let img = document.createElement('img'); + const square = document.getElementById(this.#positionToElementId('square', position)); + const img = document.createElement('img'); img.id = this.#positionToElementId('image', position); img.className = 'piece-image'; + if (isBlackPiece(pieceChar)) { + img.classList.add('piece-image-black'); + } img.setAttribute('src', this.getPieceImageSource(pieceChar)); img.addEventListener('click', () => this.#clickedOnPiece(position)); img.addEventListener('dragstart', (e) => this.#dragStart(e, position)); @@ -1436,6 +1565,15 @@ class BoardGui { } } + /** + * Returns the color currently shown at the bottom of the board. + * + * @return {string} + */ + get bottomColor() { + return this.#flippedRed ? Color.RED : Color.BLACK; + } + flip() { this.#boardContainer.innerHTML = ''; this.#flippedRed = !this.#flippedRed; @@ -1480,6 +1618,54 @@ class BoardGui { this.reRenderPieces(); } + /** + * @param enabled {boolean} + */ + setColorblindFriendlyBlackPiecesEnabled(enabled) { + if (this.#options.colorblindFriendlyBlackPieces === enabled) { + return; + } + this.#options = Object.freeze({...this.#options, colorblindFriendlyBlackPieces: enabled}); + this.#renderColorblindFriendlyBlackPiecesSetting(enabled); + } + + /** + * Change which numeral system is used for file coordinates in WXF mode. + * + * @param fileNumbersStyle {string} one of {@link FileNumbersStyle} + */ + setFileNumbersStyle(fileNumbersStyle) { + if (this.#options.fileNumbersStyle === fileNumbersStyle) { + return; + } + this.#options = Object.freeze({...this.#options, fileNumbersStyle}); + this.#redrawCoordinates(); + } + + /** + * Change the orientation (WXF numerals vs algebraic letters) of the board coordinates. + * + * @param coordinatesOrientation {string|null} one of {@link CoordinatesOrientation} or + * `null` to keep the labels hidden + */ + setCoordinatesOrientation(coordinatesOrientation) { + if (this.#options.coordinatesOrientation === coordinatesOrientation) { + return; + } + this.#options = Object.freeze({...this.#options, coordinatesOrientation}); + this.#redrawCoordinates(); + } + + #redrawCoordinates() { + // remove existing file-coordinate (top + bottom) and right-side rank labels + // (rank labels only exist in algebraic mode but the selector is harmless if absent) + document.querySelectorAll(`#${this.#options.elementId} .file-coordinates-top, + #${this.#options.elementId} .file-coordinates-bottom, + #${this.#options.elementId} .rows-coordinates-right`) + .forEach(el => el.remove()); + this.#drawCoordinates(); + } + /** * @return {boolean} */ @@ -1499,6 +1685,13 @@ class BoardGui { } } + /** + * @param enabled {boolean} + */ + #renderColorblindFriendlyBlackPiecesSetting(enabled) { + this.#boardContainer.classList.toggle('colorblind-friendly-black-pieces', enabled); + } + #areCoordinatesVisible() { let labels = document.getElementsByClassName('coordinates-labels'); for (let label of labels) { @@ -1600,7 +1793,9 @@ function parsePositionFromElementId(elementId) { } /** - * Adds a miniature board that appears on hover for an element + * Adds a miniature board that appears on hover for an element. + * Uses {@link createWebappBoardGui} so piece images are served from the + * correct base URL (local server in dev, CDN in production). * * @param element {HTMLElement} - The element to attach hover listeners to * @param gameId {string} - Unique identifier for this miniboard @@ -1629,14 +1824,12 @@ function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) { document.body.appendChild(miniBoardDiv); - const options = { + const boardGui = createWebappBoardGui({ elementId: miniBoardId, showCoordinates: false, mini: true, forceRenderChecks: true, - }; - - const boardGui = new BoardGui(options); + }); boardGui.loadFen(fen); boardGui.flipToColor(playerColor); boardGui.updateHighlightedChecks(); diff --git a/webapp/src/main/resources/public/js/widgets/chat-box-widget.js b/webapp/src/main/resources/public/js/widgets/chat-box-widget.js index 87232424c..26e729366 100644 --- a/webapp/src/main/resources/public/js/widgets/chat-box-widget.js +++ b/webapp/src/main/resources/public/js/widgets/chat-box-widget.js @@ -19,6 +19,18 @@ const UPDATE_TIMESTAMP_INTERVAL = 4_000; +/** + * How long the "is typing…" indicator stays visible after the last typing event. + */ +const DISPLAY_IS_TYPING_FOR = 1_500; + +/** + * Cool-down (ms) after a chat message is added during which incoming typing + * notifications are ignored - this avoids flickering the indicator for typing + * events that were already in flight when the message was sent. + */ +const TYPING_COOL_DOWN_AFTER_CHAT = 1_500; + class ChatBoxMessage { #message; @@ -84,6 +96,7 @@ class ChatBoxWidget { #messagesContainer = document.getElementById('chat-box-messages-container'); #input = document.getElementById('chat-box-input'); #sendButton = document.getElementById('chat-box-send-message-button'); + #typingIndicator = document.getElementById('chat-box-typing-indicator'); /** * userId to color @@ -101,6 +114,30 @@ class ChatBoxWidget { */ #inputLosesFocusListeners = []; + /** + * @type {function()[]} + */ + #inputTypingListeners = []; + + /** + * Debounce timer for the input typing event. + * @type {number|null} + */ + #typingDebounceTimer = null; + + /** + * Timer that hides the "is typing…" indicator after [DISPLAY_IS_TYPING_FOR]. + * @type {number|null} + */ + #hideIsTypingTimerId = null; + + /** + * Timestamp (ms) until which incoming typing notifications are ignored + * (cool-down right after a chat message is added). + * @type {number} + */ + #typingCoolDownUntil = 0; + /** * @param sendMessageCb {function(string): void} */ @@ -126,6 +163,16 @@ class ChatBoxWidget { this.#inputLosesFocusListeners.forEach((listener) => listener()); }); + this.#input.addEventListener('input', () => { + if (this.#input.value.length === 0) { + return; + } + clearTimeout(this.#typingDebounceTimer); + this.#typingDebounceTimer = setTimeout(() => { + this.#inputTypingListeners.forEach((listener) => listener()); + }, 200); + }); + // update relative time of messages every 5 seconds setInterval(() => { this.#timestampsToUpdate.forEach((timestamp, id) => { @@ -179,6 +226,57 @@ class ChatBoxWidget { this.#inputLosesFocusListeners.push(listener); } + /** + * @param listener {function()} + */ + addInputTypingListener(listener) { + this.#inputTypingListeners.push(listener); + } + + /** + * Display (or refresh) the "is typing…" indicator for the given users. + * Typing events arriving within the cool-down window after a chat message + * was added are silently ignored, so the indicator doesn't flicker for + * stale events that were in-flight when the message arrived. + * + * @param typingUsers {{userId: string, username: string, typedAt: *}[]} + */ + notifyTypingUsers(typingUsers) { + // ignore typing events within the cool-down window after a chat message + if (Date.now() < this.#typingCoolDownUntil) { + return; + } + + if (typingUsers == null || typingUsers.length === 0) { + this.#hideIsTypingIndicator(); + return; + } + + const names = typingUsers.map((user) => user.username); + let label; + if (names.length === 1) { + label = `${names[0]} is typing…`; + } else { + const allButLast = names.slice(0, -1).join(', '); + label = `${allButLast} and ${names[names.length - 1]} are typing…`; + } + + this.#typingIndicator.innerText = label; + this.#typingIndicator.style.display = 'block'; + + clearTimeout(this.#hideIsTypingTimerId); + this.#hideIsTypingTimerId = setTimeout(() => { + this.#hideIsTypingIndicator(); + }, DISPLAY_IS_TYPING_FOR); + } + + #hideIsTypingIndicator() { + clearTimeout(this.#hideIsTypingTimerId); + this.#hideIsTypingTimerId = null; + this.#typingIndicator.style.display = 'none'; + this.#typingIndicator.innerText = ''; + } + /** * * @param colorMapping {Map} @@ -201,6 +299,11 @@ class ChatBoxWidget { * @param message {ChatBoxMessage} */ addMessage(message) { + // a message means the author is no longer typing: hide the indicator + // and start a cool-down to ignore in-flight typing notifications + this.#hideIsTypingIndicator(); + this.#typingCoolDownUntil = Date.now() + TYPING_COOL_DOWN_AFTER_CHAT; + const timestampId = randomId(); const timestamp = document.createElement('div'); diff --git a/webapp/src/main/resources/public/js/widgets/database-autocompletion.js b/webapp/src/main/resources/public/js/widgets/database-autocompletion.js index 745006afe..f6b66ee1d 100644 --- a/webapp/src/main/resources/public/js/widgets/database-autocompletion.js +++ b/webapp/src/main/resources/public/js/widgets/database-autocompletion.js @@ -82,6 +82,13 @@ class AutocompleteSearchField { return this.#inputField.value; } + /** + * @param value {string} + */ + setInputFieldValue(value) { + this.#inputField.value = value; + } + /** * @returns {string} */ diff --git a/webapp/src/main/resources/public/js/widgets/drop-down-menu.js b/webapp/src/main/resources/public/js/widgets/drop-down-menu.js index e98703640..4a0e24176 100644 --- a/webapp/src/main/resources/public/js/widgets/drop-down-menu.js +++ b/webapp/src/main/resources/public/js/widgets/drop-down-menu.js @@ -93,6 +93,47 @@ class DropDownMenu { return this.addItem(label, [], cb); } + /** + * @param label {string} + * @param optionalCssClasses {string[]} + * @param href {string} + * @return {HTMLAnchorElement} + */ + addItemWithHref(label, optionalCssClasses, href) { + let item = document.createElement('a'); + item.href = href; + + let defaultCssClasses = this.defaultCssClasses(); + for (let i = 0; i < defaultCssClasses.length; i++) { + item.classList.add(defaultCssClasses[i]); + } + for (let i = 0; i < optionalCssClasses.length; i++) { + item.classList.add(optionalCssClasses[i]); + } + item.classList.add('drop-down-menu-item-link'); + item.innerText = label; + item.addEventListener('click', (e) => { + if (this.isEnabled(item)) { + this.hide(); + } else { + e.preventDefault(); + } + }); + + this.#container.append(item); + this.#items.push(item); + return item; + } + + /** + * @param label {string} + * @param href {string} + * @return {HTMLAnchorElement} + */ + addSimpleItemWithHref(label, href) { + return this.addItemWithHref(label, [], href); + } + /** * @param label {string} * @param cb {function} @@ -102,6 +143,15 @@ class DropDownMenu { return this.addItem(label, ['drop-down-menu-item-top-separator'], cb); } + /** + * @param label {string} + * @param href {string} + * @return {HTMLAnchorElement} + */ + addItemWithTopSeparatorAndHref(label, href) { + return this.addItemWithHref(label, ['drop-down-menu-item-top-separator'], href); + } + defaultCssClasses() { return ['drop-down-menu-item']; } @@ -151,33 +201,18 @@ class UserNameDropDownMenu extends DropDownMenu { switch (user.userType) { case UserType.AUTHENTICATED: - this.addSimpleItem('Profile', () => { - window.open('/@/' + user.username, '_self'); - }); - this.addSimpleItem('Settings', () => { - window.open('/user/settings', '_self'); - }); - this.addItemWithTopSeparator('My Games', () => { - window.open('/userdata/games', '_self'); - }); - this.addSimpleItem('My Bot Games', () => { - window.open('/userdata/botgames', '_self'); - }); - this.addSimpleItem('My Puzzles', () => { - window.open('/userdata/puzzles', '_self'); - }); - this.addSimpleItem('My Analysis', () => { - window.open('/userdata/analysis', '_self'); - }); + this.addSimpleItemWithHref('Profile', '/@/' + user.username); + this.addSimpleItemWithHref('Settings', '/user/settings'); + this.addItemWithTopSeparatorAndHref('My Games', '/userdata/games'); + this.addSimpleItemWithHref('My Bot Games', '/userdata/botgames'); + this.addSimpleItemWithHref('My Puzzles', '/userdata/puzzles'); + this.addSimpleItemWithHref('My Analysis', '/userdata/analysis'); + this.addSimpleItemWithHref('My DB Searches', '/userdata/db-searches'); if (user.isEditor) { - this.addItemWithTopSeparator('My Edits', () => { - window.open('/userdata/db-edits', '_self'); - }); + this.addItemWithTopSeparatorAndHref('My Edits', '/userdata/db-edits'); } if (user.isAdmin) { - this.addItemWithTopSeparator('Admin', () => { - window.open('/admin', '_self'); - }); + this.addItemWithTopSeparatorAndHref('Admin', '/admin'); } this.addItem('Logout', ['drop-down-menu-item-top-separator', 'drop-down-menu-item-logout'], () => { eraseAllIdentificationCookies(); @@ -185,15 +220,10 @@ class UserNameDropDownMenu extends DropDownMenu { }); break; case UserType.GUEST: - this.addSimpleItem('My Games', () => { - window.open('/userdata/games', '_self'); - }); - this.addSimpleItem('My Bot Games', () => { - window.open('/userdata/botgames', '_self'); - }); - this.addSimpleItem('My Puzzles', () => { - window.open('/userdata/puzzles', '_self'); - }); + this.addSimpleItemWithHref('My Games', '/userdata/games'); + this.addSimpleItemWithHref('My Bot Games', '/userdata/botgames'); + this.addSimpleItemWithHref('My Puzzles', '/userdata/puzzles'); + this.addSimpleItemWithHref('My DB Searches', '/userdata/db-searches'); this.addItem('Login', ['drop-down-menu-item-top-separator', 'drop-down-menu-item-authenticate'], () => { showLoginModal(); }); diff --git a/webapp/src/main/resources/public/js/widgets/eval-line-chart.js b/webapp/src/main/resources/public/js/widgets/eval-line-chart.js new file mode 100644 index 000000000..10d401c8a --- /dev/null +++ b/webapp/src/main/resources/public/js/widgets/eval-line-chart.js @@ -0,0 +1,153 @@ +/* + * 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 . + */ + +/** + * Line chart showing the engine evaluation history over the course of a game. + * Positive values indicate a Red advantage, negative values a Black advantage. + */ +class EvalLineChart extends ApexChartWidget { + + /** + * @param containerId {string} + * @param nodes {MoveTreeNode[]} + * @param analysisMap {Map} + * @param startFen {string} + */ + constructor(containerId, nodes, analysisMap, startFen) { + super(containerId); + + if (!document.getElementById(containerId)) { + return; + } + + const evalData = []; + const categories = []; + + // Add eval of the starting position + const startFenKey = resetFenFullMovesCount(startFen); + const startInfoLine = analysisMap.get(startFenKey); + if (startInfoLine != null && startInfoLine.eval != null) { + evalData.push(parseFloat(startInfoLine.eval.toFixed(1))); + categories.push('Start'); + } + + // Add eval for each played move + nodes.forEach(node => { + const infoLine = analysisMap.get(node.fenKey); + if (infoLine != null && infoLine.eval != null) { + evalData.push(parseFloat(infoLine.eval.toFixed(1))); + const moveNum = node.fullMoveCount; + const isRedMove = node.position % 2 === 0; + categories.push(`${moveNum} (${isRedMove ? 'r' : 'b'})`); + } + }); + + if (evalData.length > 1) { + this.chartOptions = { + series: [ + {name: 'Advantage', data: evalData} + ], + chart: { + type: 'line', + height: 150, + toolbar: {show: false}, + animations: {enabled: false}, + background: 'transparent' + }, + colors: ['#022e7d'], + stroke: { + curve: 'smooth', + width: [2] + }, + dataLabels: {enabled: false}, + xaxis: { + categories: categories, + tickAmount: Math.min(8, categories.length - 1), + tooltip: {enabled: false}, + labels: { + style: {colors: '#555555', fontSize: '10px'}, + rotate: 0, + formatter: (val) => (typeof val === 'string' && val.endsWith('(b)')) ? '' : (typeof val === 'string' ? val.replace('(r)', '') : val) + }, + axisBorder: {color: '#888'}, + axisTicks: {color: '#888'} + }, + yaxis: { + min: -100, + max: 100, + tickAmount: 4, + labels: { + style: {colors: '#555555', fontSize: '10px'}, + formatter: (val) => (val > 0 ? '+' : '') + Math.round(val) + } + }, + grid: { + borderColor: '#888', + xaxis: {lines: {show: false}}, + yaxis: {lines: {show: true}} + }, + annotations: { + yaxis: [ + { + y: 0, + y2: 100, + fillColor: '#D32F2F', + opacity: 0.08, + borderColor: 'transparent' + }, + { + y: -100, + y2: 0, + fillColor: '#222222', + opacity: 0.25, + borderColor: 'transparent' + }, + { + y: 0, + borderColor: '#555', + strokeDashArray: 3, + borderWidth: 1 + } + ] + }, + tooltip: { + theme: 'dark', + // Custom tooltip: display Black advantage as a positive value (e.g. "+0.9" instead of "-0.9"). + custom: ({dataPointIndex}) => { + const cat = categories[dataPointIndex]; + const val = evalData[dataPointIndex]; + const isPositive = val >= 0; + const color = '#022e7d'; + const label = isPositive ? 'Red advantage' : 'Black advantage'; + const display = '+' + Math.abs(val).toFixed(1); + return '
    ' + cat + '
    ' + + '
    ' + + '' + + label + ': ' + display + '' + + '
    '; + } + }, + legend: {show: false} + }; + + this.enableRender(); + } + } + +} diff --git a/webapp/src/main/resources/public/js/widgets/move-tree-widget.js b/webapp/src/main/resources/public/js/widgets/move-tree-widget.js index 3a52addf2..bfc687e24 100644 --- a/webapp/src/main/resources/public/js/widgets/move-tree-widget.js +++ b/webapp/src/main/resources/public/js/widgets/move-tree-widget.js @@ -1074,6 +1074,10 @@ class NavigationPanelGui { }); this.#downloadButtonContainer.addEventListener('click', (e) => { + // skip if the click already happened on the inner (its own handler + browser default already triggers the download) + if (e.target.closest('a') === link) { + return; + } if (isButtonEnabled(e.target)) { link.click(); } @@ -1662,32 +1666,7 @@ class MoveTreeWidget { break; case MoveNodeEvalFormat.ANNOTATION_SYMBOLS: this.#moveTree.getAllNodesMatchingFenKey(fenKey).forEach((node) => { - const nodeFenKey = node.fenKey; - const previousNodeFenKey = node.hasPrevious() ? node.previous.fenKey : this.#startFen; - - if (analysisCache.has(previousNodeFenKey) && analysisCache.has(nodeFenKey)) { - const previousNodeData = analysisCache.get(previousNodeFenKey); - const engineBestMoveData = findAnalysisDataFromEngineBestMove(analysisCache, previousNodeData); - if (engineBestMoveData != null) { - const currentNodeData = analysisCache.get(nodeFenKey); - const symbolEnumValue = calculateAnnotationValue(engineBestMoveData, currentNodeData); - if (symbolEnumValue != null) { - const cssClass = `${symbolEnumValue.toLowerCase()}-annotation-label`; - node.eval = moveAnnotationEnumToSymbol(symbolEnumValue); - node.addEvalCssClass(cssClass); - this.#updateEvalPlaceholder(node); - } else { - node.eval = null; - node.clearEvalCssClasses(); - this.#updateEvalPlaceholder(node); - } - } else { - node.eval = null; - node.clearEvalCssClasses(); - this.#updateEvalPlaceholder(node); - } - } - + this.#applyAnnotationSymbolToNode(node, analysisCache); }); break; default: @@ -1697,6 +1676,53 @@ class MoveTreeWidget { this.#analysisCache = new Map(analysisCache); } + /** + * Apply annotation symbols (??, ?!, etc.) to every node in the move tree, based on the provided analysis cache. + * This bypasses the `moveNodeEvalFormat` setting and is intended for pages that do not let the user toggle + * between raw eval and annotation symbols (PvP, PvB, database game viewer). + * + * @param analysisCache {Map} + */ + applyAnnotationSymbolsFromCache(analysisCache) { + this.#moveTree.getAllNodes().forEach((node) => { + this.#applyAnnotationSymbolToNode(node, analysisCache); + }); + this.#analysisCache = new Map(analysisCache); + } + + /** + * @param node {MoveTreeNode} + * @param analysisCache {Map} + */ + #applyAnnotationSymbolToNode(node, analysisCache) { + const nodeFenKey = node.fenKey; + const previousNodeFenKey = node.hasPrevious() ? node.previous.fenKey : this.#startFen; + + if (analysisCache.has(previousNodeFenKey) && analysisCache.has(nodeFenKey)) { + const previousNodeData = analysisCache.get(previousNodeFenKey); + const engineBestMoveData = findAnalysisDataFromEngineBestMove(analysisCache, previousNodeData); + if (engineBestMoveData != null) { + const currentNodeData = analysisCache.get(nodeFenKey); + const symbolEnumValue = calculateAnnotationValue(engineBestMoveData, currentNodeData); + if (symbolEnumValue != null) { + const cssClass = `${symbolEnumValue.toLowerCase()}-annotation-label`; + node.eval = moveAnnotationEnumToSymbol(symbolEnumValue); + node.clearEvalCssClasses(); + node.addEvalCssClass(cssClass); + this.#updateEvalPlaceholder(node); + } else { + node.eval = null; + node.clearEvalCssClasses(); + this.#updateEvalPlaceholder(node); + } + } else { + node.eval = null; + node.clearEvalCssClasses(); + this.#updateEvalPlaceholder(node); + } + } + } + /** * @param node {MoveTreeNode} */ diff --git a/webapp/src/main/resources/public/js/widgets/settings-manager.js b/webapp/src/main/resources/public/js/widgets/settings-manager.js index d7a4dc562..0971232bb 100644 --- a/webapp/src/main/resources/public/js/widgets/settings-manager.js +++ b/webapp/src/main/resources/public/js/widgets/settings-manager.js @@ -19,9 +19,33 @@ const PIECE_STYLE_SETTING = 'setting.piece.style'; const SHOW_COORDINATES_SETTING = 'setting.show.coordinates'; +const COLORBLIND_FRIENDLY_BLACK_PIECES_SETTING = 'setting.colorblind.friendly.black.pieces'; const MOVE_FORMAT_SETTING = 'setting.move.format'; const MOVE_NODE_EVAL_FORMAT = 'setting.move.node.eval.format'; const SHOW_ANALYTICS_ARROWS = 'setting.show.analytics.arrows'; +const COORDINATES_STYLE_SETTING = 'setting.coordinates.style'; + +/** + * User-facing style of the board coordinate labels. + * Combines the WXF orientation and (for WXF) the numeral system used for file labels. + */ +const CoordinatesStyle = Object.freeze({ + /** WXF: Arabic numerals (1..9) on both sides. */ + WXF_ARABIC: 'WXF_ARABIC', + /** WXF: Chinese numerals (一..九) on both sides. */ + WXF_CHINESE: 'WXF_CHINESE', + /** WXF: Chinese numerals on red's side; Arabic on black's side. */ + WXF_CHINESE_RED_ONLY: 'WXF_CHINESE_RED_ONLY', + /** WXF: Chinese numerals on black's side; Arabic on red's side. */ + WXF_CHINESE_BLACK_ONLY: 'WXF_CHINESE_BLACK_ONLY', + /** WXF: Chinese numerals on the bottom side of the screen only. */ + WXF_CHINESE_LOWER_ONLY: 'WXF_CHINESE_LOWER_ONLY', + /** WXF: Chinese numerals on the top side of the screen only. */ + WXF_CHINESE_TOP_ONLY: 'WXF_CHINESE_TOP_ONLY', + /** Algebraic: letters a..i for files and 1..10 for ranks. */ + ALGEBRAIC: 'ALGEBRAIC', + DEFAULT: 'WXF_CHINESE_RED_ONLY', +}); const MoveFormatSetting = Object.freeze({ WXF_DOT: 'WXF_DOT', @@ -80,6 +104,25 @@ class SettingsManager { setCookie(SHOW_COORDINATES_SETTING, value.toString(), CHROME_COOKIE_MAX_TTL); } + /** + * @return {boolean} + */ + get isColorblindFriendlyBlackPiecesEnabled() { + const cookieValue = getCookie(COLORBLIND_FRIENDLY_BLACK_PIECES_SETTING); + if (cookieValue === null) { + return false; + } else { + return cookieValue === 'true'; + } + } + + /** + * @param value {boolean} + */ + set isColorblindFriendlyBlackPiecesEnabled(value) { + setCookie(COLORBLIND_FRIENDLY_BLACK_PIECES_SETTING, value.toString(), CHROME_COOKIE_MAX_TTL); + } + /** * @return {string} */ @@ -138,6 +181,28 @@ class SettingsManager { setCookie(SHOW_ANALYTICS_ARROWS, value.toString(), CHROME_COOKIE_MAX_TTL); } + /** + * @return {string} one of {@link CoordinatesStyle} + */ + get coordinatesStyle() { + const cookieValue = getCookie(COORDINATES_STYLE_SETTING); + if (cookieValue !== null && Object.values(CoordinatesStyle).includes(cookieValue)) { + return cookieValue; + } + // backward-compat default: tie to the move format chosen by the user + // (PGN/Algebraic users get algebraic letters; WXF users get the default WXF flavor) + const isWxfMoveFormat = this.moveFormat === MoveFormatSetting.WXF_DOT + || this.moveFormat === MoveFormatSetting.WXF_EQUALS; + return isWxfMoveFormat ? CoordinatesStyle.DEFAULT : CoordinatesStyle.ALGEBRAIC; + } + + /** + * @param value {string} + */ + set coordinatesStyle(value) { + setCookie(COORDINATES_STYLE_SETTING, value, CHROME_COOKIE_MAX_TTL); + } + /** * Resolves the user's preference into a {@link CoordinatesOrientation} value * (or `null` if the user disabled the coordinates display). @@ -148,9 +213,34 @@ class SettingsManager { if (!this.isShowCoordinatesEnabled) { return null; } - const isWxf = this.moveFormat === MoveFormatSetting.WXF_DOT - || this.moveFormat === MoveFormatSetting.WXF_EQUALS; - return isWxf ? CoordinatesOrientation.WXF : CoordinatesOrientation.UCI; + return this.coordinatesStyle === CoordinatesStyle.ALGEBRAIC + ? CoordinatesOrientation.ALGEBRAIC + : CoordinatesOrientation.WXF; + } + + /** + * Resolves the user's preference into a {@link FileNumbersStyle} value + * (only meaningful when the orientation is WXF). + * + * @return {string} + */ + getFileNumbersStyle() { + switch (this.coordinatesStyle) { + case CoordinatesStyle.WXF_ARABIC: + return FileNumbersStyle.ARABIC_BOTH; + case CoordinatesStyle.WXF_CHINESE: + return FileNumbersStyle.CHINESE_BOTH; + case CoordinatesStyle.WXF_CHINESE_RED_ONLY: + return FileNumbersStyle.CHINESE_RED_ONLY; + case CoordinatesStyle.WXF_CHINESE_BLACK_ONLY: + return FileNumbersStyle.CHINESE_BLACK_ONLY; + case CoordinatesStyle.WXF_CHINESE_LOWER_ONLY: + return FileNumbersStyle.CHINESE_LOWER_ONLY; + case CoordinatesStyle.WXF_CHINESE_TOP_ONLY: + return FileNumbersStyle.CHINESE_TOP_ONLY; + default: + return FileNumbersStyle.DEFAULT; + } } } @@ -173,9 +263,11 @@ function buildWebappBoardGuiOptions(overrides = {}) { return { coordinatesOrientation: settingsManager.getCoordinatesOrientation(), pieceStyle: settingsManager.pieceStyle, + colorblindFriendlyBlackPieces: settingsManager.isColorblindFriendlyBlackPiecesEnabled, + fileNumbersStyle: settingsManager.getFileNumbersStyle(), // when developing locally, serve the assets from the local server // (otherwise default to the production CDN baked into BoardGui) - ...(isLocalHost ? { assetsBaseUrl: '' } : {}), + ...(isLocalHost ? {assetsBaseUrl: ''} : {}), ...overrides, }; } @@ -191,76 +283,10 @@ function createWebappBoardGui(overrides = {}) { return new BoardGui(buildWebappBoardGuiOptions(overrides)); } -let moveLabelMap = new Map([ - [MoveFormatSetting.WXF_DOT, 'WXF .'], - [MoveFormatSetting.WXF_EQUALS, 'WXF ='], - [MoveFormatSetting.PGN, 'PGN'], - [MoveFormatSetting.ALGEBRAIC_EN, 'Algebraic'], -]); - -class SelectMoveFormatMenu extends ContextualMenu { - - /** - * @type {BoardGui} - */ - #boardGui; - - /** - * @type {MoveTreeWidget} - */ - #moveTreeWidget; - - #settingsManager = new SettingsManager(); - - /** - * @type {function(string)[]} - */ - #listeners = []; - - /** - * @param boardGui {BoardGui} - * @param moveTreeWidget {MoveTreeWidget} - */ - constructor(boardGui, moveTreeWidget) { - super('select-move-format-menu'); - this.#boardGui = boardGui; - this.#moveTreeWidget = moveTreeWidget; - - // build menu items - let selectedMoveFormat = this.#settingsManager.moveFormat - moveLabelMap.forEach((value, key) => { - let label = value; - if (selectedMoveFormat === key) { - label = `✓ ${value}`; - } - - this.addSimpleItem(label, () => { - this.#updateMoveFormat(key); - }); - }); - } - - /** - * @param moveFormat {string} - */ - #updateMoveFormat(moveFormat) { - this.#settingsManager.moveFormat = moveFormat.toString(); - this.#boardGui.updateMoveFormat(moveFormat); - this.#moveTreeWidget.updateMoveFormat(moveFormat); - this.#listeners.forEach(listener => listener(moveFormat)); - } - - /** - * @param listener {function(string)} - */ - addListener(listener) { - this.#listeners.push(listener); - } - -} +let activeAdvancedSettingsEscapeListener = null; +let activeAdvancedSettingsCloseHandler = null; class SettingsGui { - #moveTreeWidget; #settingsManager = new SettingsManager(); #selectMoveFormatMenuListeners = []; @@ -280,17 +306,45 @@ class SettingsGui { */ #boardGuis = []; + // base settings #selectPieceStyleTraditional = document.getElementById('select-piece-style-traditional'); #selectPieceStyleRomanizedRounded = document.getElementById('select-piece-style-romanized-rounded'); - #moveFormat = document.getElementById('move-format-button'); - #showCoordinates = document.getElementById('show-coordinates-button'); #flipBoard = document.getElementById('flip-board-button'); - // optional + #advancedSettingsToggle = document.getElementById('advanced-settings-toggle'); + #advancedSettingsBox = document.getElementById('advanced-settings-box'); + #advancedSettingsCloseButton = document.getElementById('advanced-settings-close-button'); + #modalBackground = document.getElementById('modal-background'); + #advancedSettingsUsesModalBackground = false; + + // advanced settings + #advancedMoveFormatSettingItem = document.getElementById('advanced-move-format-setting-item'); + + #moveFormatRadioWxfDot = document.getElementById('move-format-radio-wxf-dot'); + #moveFormatRadioWxfEquals = document.getElementById('move-format-radio-wxf-equals'); + #moveFormatRadioPgn = document.getElementById('move-format-radio-pgn'); + #moveFormatRadioAlgebraic = document.getElementById('move-format-radio-algebraic'); + + #showCoordinatesEnabledRadio = document.getElementById('show-coordinates-enabled-radio'); + #showCoordinatesDisabledRadio = document.getElementById('show-coordinates-disabled-radio'); + + #coordinatesStyleWxfArabicRadio = document.getElementById('coordinates-style-wxf-arabic-radio'); + #coordinatesStyleWxfChineseRadio = document.getElementById('coordinates-style-wxf-chinese-radio'); + #coordinatesStyleWxfChineseRedOnlyRadio = document.getElementById('coordinates-style-wxf-chinese-red-only-radio'); + #coordinatesStyleWxfChineseBlackOnlyRadio = document.getElementById('coordinates-style-wxf-chinese-black-only-radio'); + #coordinatesStyleWxfChineseLowerOnlyRadio = document.getElementById('coordinates-style-wxf-chinese-lower-only-radio'); + #coordinatesStyleWxfChineseTopOnlyRadio = document.getElementById('coordinates-style-wxf-chinese-top-only-radio'); + #coordinatesStyleAlgebraicRadio = document.getElementById('coordinates-style-algebraic-radio'); + #coordinatesMoveFormatMismatchWarning = document.getElementById('coordinates-move-format-mismatch-warning'); + + #colorblindFriendlyBlackPiecesEnabledRadio = document.getElementById('colorblind-friendly-black-pieces-enabled-radio'); + #colorblindFriendlyBlackPiecesDisabledRadio = document.getElementById('colorblind-friendly-black-pieces-disabled-radio'); + + // optional (for Analysis Board) #showAnalyticsArrowsItem = document.getElementById('show-analytics-arrows-item'); #showAnalyticsArrowsImg = document.getElementById('show-analytics-arrows'); - // optional + // optional (for Analysis Board) #editPositionItem = document.getElementById('edit-position-item'); #editPositionButton = document.getElementById('edit-position-button'); @@ -298,8 +352,9 @@ class SettingsGui { * @param boardGui {BoardGui} * @param moveTreeWidget {MoveTreeWidget|null} * @param showArrowsOption {boolean} - whether to show arrows option (it's only for analysis board) + * @param showAdvancedSettingsLink {boolean} - whether to show "Advanced" settings trigger */ - constructor(boardGui, moveTreeWidget, showArrowsOption = false) { + constructor(boardGui, moveTreeWidget, showArrowsOption = false, showAdvancedSettingsLink = true) { this.#moveTreeWidget = moveTreeWidget; this.#boardGuis.push(boardGui); @@ -318,26 +373,221 @@ class SettingsGui { addToolTip(this.#selectPieceStyleRomanizedRounded, "Select 'Romanized Rounded' piece style"); // move format - // doesn't apply to mini boards, so no need loop over all boards for this one - this.#moveFormat.onclick = () => { + const isWxfMoveFormat = (mf) => mf === MoveFormatSetting.WXF_DOT || mf === MoveFormatSetting.WXF_EQUALS; + const isWxfCoordinatesStyle = (cs) => + cs === CoordinatesStyle.WXF_ARABIC + || cs === CoordinatesStyle.WXF_CHINESE + || cs === CoordinatesStyle.WXF_CHINESE_RED_ONLY + || cs === CoordinatesStyle.WXF_CHINESE_BLACK_ONLY + || cs === CoordinatesStyle.WXF_CHINESE_LOWER_ONLY + || cs === CoordinatesStyle.WXF_CHINESE_TOP_ONLY; + const updateCoordinatesMoveFormatMismatchWarning = () => { + const mf = this.#settingsManager.moveFormat; + const cs = this.#settingsManager.coordinatesStyle; + const mismatch = isWxfMoveFormat(mf) !== isWxfCoordinatesStyle(cs); + if (this.#coordinatesMoveFormatMismatchWarning != null) { + this.#coordinatesMoveFormatMismatchWarning.hidden = !mismatch; + } + }; + const applyMoveFormat = (moveFormat) => { + this.#settingsManager.moveFormat = moveFormat; if (this.#moveTreeWidget != null) { - const menu = new SelectMoveFormatMenu(boardGui, moveTreeWidget); - this.#selectMoveFormatMenuListeners.forEach(listener => menu.addListener(listener)); - menu.showAt(UI.cursorX, UI.cursorY); - DropDownMenuManager.getInstance().registerDropDownMenu(menu, [this.#moveFormat.id]); + // Move format only affects how moves are displayed in the move tree widget; + // it must not redraw the board (would clear transient overlays like check highlights). + this.#moveTreeWidget.updateMoveFormat(moveFormat); + this.#selectMoveFormatMenuListeners.forEach(listener => listener(moveFormat)); + } + updateCoordinatesMoveFormatMismatchWarning(); + } + this.#moveFormatRadioWxfDot.onchange = () => { + if (this.#moveFormatRadioWxfDot.checked) { + applyMoveFormat(MoveFormatSetting.WXF_DOT); + } + } + this.#moveFormatRadioWxfEquals.onchange = () => { + if (this.#moveFormatRadioWxfEquals.checked) { + applyMoveFormat(MoveFormatSetting.WXF_EQUALS); } } - addToolTip(this.#moveFormat, 'Select move format'); + this.#moveFormatRadioPgn.onchange = () => { + if (this.#moveFormatRadioPgn.checked) { + applyMoveFormat(MoveFormatSetting.PGN); + } + } + this.#moveFormatRadioAlgebraic.onchange = () => { + if (this.#moveFormatRadioAlgebraic.checked) { + applyMoveFormat(MoveFormatSetting.ALGEBRAIC_EN); + } + } + switch (this.#settingsManager.moveFormat) { + case MoveFormatSetting.WXF_EQUALS: + this.#moveFormatRadioWxfEquals.checked = true; + break; + case MoveFormatSetting.PGN: + this.#moveFormatRadioPgn.checked = true; + break; + case MoveFormatSetting.ALGEBRAIC_EN: + this.#moveFormatRadioAlgebraic.checked = true; + break; + default: + this.#moveFormatRadioWxfDot.checked = true; + break; + } + applyMoveFormat(this.#settingsManager.moveFormat); if (this.#moveTreeWidget == null) { - // .setting-option-item - this.#moveFormat.parentElement.parentElement.style.display = 'none'; + this.#advancedMoveFormatSettingItem.style.display = 'none'; } // show coordinates - this.#showCoordinates.onclick = () => { - this.#settingsManager.isShowCoordinatesEnabled = boardGui.toggleShowCoordinates(); + const updateShowCoordinatesRadios = (enabled) => { + this.#showCoordinatesEnabledRadio.checked = enabled; + this.#showCoordinatesDisabledRadio.checked = !enabled; + } + const setShowCoordinatesEnabled = (enabled) => { + if (this.#settingsManager.isShowCoordinatesEnabled === enabled) { + return; + } + this.#boardGuis.forEach(board => board.toggleShowCoordinates()); + this.#settingsManager.isShowCoordinatesEnabled = enabled; + } + updateShowCoordinatesRadios(this.#settingsManager.isShowCoordinatesEnabled); + this.#showCoordinatesEnabledRadio.onchange = () => { + if (this.#showCoordinatesEnabledRadio.checked) { + setShowCoordinatesEnabled(true); + } + } + this.#showCoordinatesDisabledRadio.onchange = () => { + if (this.#showCoordinatesDisabledRadio.checked) { + setShowCoordinatesEnabled(false); + } + } + + // colorblind-friendly black pieces + const updateColorblindRadios = (enabled) => { + this.#colorblindFriendlyBlackPiecesEnabledRadio.checked = enabled; + this.#colorblindFriendlyBlackPiecesDisabledRadio.checked = !enabled; + } + updateColorblindRadios(this.#settingsManager.isColorblindFriendlyBlackPiecesEnabled); + this.#colorblindFriendlyBlackPiecesEnabledRadio.onchange = () => { + if (this.#colorblindFriendlyBlackPiecesEnabledRadio.checked) { + this.#settingsManager.isColorblindFriendlyBlackPiecesEnabled = true; + this.#boardGuis.forEach(board => board.setColorblindFriendlyBlackPiecesEnabled(true)); + } + } + this.#colorblindFriendlyBlackPiecesDisabledRadio.onchange = () => { + if (this.#colorblindFriendlyBlackPiecesDisabledRadio.checked) { + this.#settingsManager.isColorblindFriendlyBlackPiecesEnabled = false; + this.#boardGuis.forEach(board => board.setColorblindFriendlyBlackPiecesEnabled(false)); + } + } + + // coordinates style (WXF flavors + Algebraic letters) + const coordinatesStyleRadios = { + [CoordinatesStyle.WXF_ARABIC]: this.#coordinatesStyleWxfArabicRadio, + [CoordinatesStyle.WXF_CHINESE]: this.#coordinatesStyleWxfChineseRadio, + [CoordinatesStyle.WXF_CHINESE_RED_ONLY]: this.#coordinatesStyleWxfChineseRedOnlyRadio, + [CoordinatesStyle.WXF_CHINESE_BLACK_ONLY]: this.#coordinatesStyleWxfChineseBlackOnlyRadio, + [CoordinatesStyle.WXF_CHINESE_LOWER_ONLY]: this.#coordinatesStyleWxfChineseLowerOnlyRadio, + [CoordinatesStyle.WXF_CHINESE_TOP_ONLY]: this.#coordinatesStyleWxfChineseTopOnlyRadio, + [CoordinatesStyle.ALGEBRAIC]: this.#coordinatesStyleAlgebraicRadio, + }; + const applyCoordinatesStyle = (style) => { + this.#settingsManager.coordinatesStyle = style; + const orientation = this.#settingsManager.getCoordinatesOrientation(); + const fileNumbersStyle = this.#settingsManager.getFileNumbersStyle(); + this.#boardGuis.forEach(board => { + board.setCoordinatesOrientation(orientation); + board.setFileNumbersStyle(fileNumbersStyle); + }); + updateCoordinatesMoveFormatMismatchWarning(); + }; + const currentCoordinatesStyle = coordinatesStyleRadios[this.#settingsManager.coordinatesStyle] + ? this.#settingsManager.coordinatesStyle + : CoordinatesStyle.DEFAULT; + coordinatesStyleRadios[currentCoordinatesStyle].checked = true; + Object.entries(coordinatesStyleRadios).forEach(([style, radio]) => { + radio.onchange = () => { + if (radio.checked) { + applyCoordinatesStyle(style); + } + }; + }); + + // when "show coordinates" is off, grey out the coordinates style options + const updateCoordinatesStyleEnabledState = () => { + const enabled = this.#settingsManager.isShowCoordinatesEnabled; + Object.values(coordinatesStyleRadios).forEach(radio => { + if (radio == null) { + return; + } + radio.disabled = !enabled; + const label = radio.closest('label'); + if (label != null) { + label.classList.toggle('advanced-setting-option-disabled', !enabled); + } + }); + }; + updateCoordinatesStyleEnabledState(); + this.#showCoordinatesEnabledRadio.addEventListener('change', updateCoordinatesStyleEnabledState); + this.#showCoordinatesDisabledRadio.addEventListener('change', updateCoordinatesStyleEnabledState); + updateCoordinatesMoveFormatMismatchWarning(); + + // advanced settings + const isMobileAdvancedSettingsLayout = () => window.matchMedia('(max-width: 1000px)').matches; + const closeAdvancedSettings = () => { + this.#advancedSettingsBox.classList.remove('advanced-settings-box-open'); + if (activeAdvancedSettingsCloseHandler === closeAdvancedSettings) { + activeAdvancedSettingsCloseHandler = null; + } + if (this.#advancedSettingsUsesModalBackground) { + this.#advancedSettingsUsesModalBackground = false; + this.#modalBackground.style.display = 'none'; + } + }; + const openAdvancedSettings = () => { + if (isMobileAdvancedSettingsLayout() && this.#modalBackground != null) { + const isModalBackgroundHidden = this.#modalBackground.style.display === '' + || this.#modalBackground.style.display === 'none'; + if (isModalBackgroundHidden) { + this.#advancedSettingsUsesModalBackground = true; + this.#modalBackground.style.display = 'flex'; + } + } + activeAdvancedSettingsCloseHandler = closeAdvancedSettings; + this.#advancedSettingsBox.classList.add('advanced-settings-box-open'); + }; + this.#advancedSettingsToggle.onclick = (e) => { + e.preventDefault(); + if (this.#advancedSettingsBox.classList.contains('advanced-settings-box-open')) { + closeAdvancedSettings(); + } else { + openAdvancedSettings(); + } + }; + this.#advancedSettingsCloseButton.onclick = (e) => { + e.preventDefault(); + closeAdvancedSettings(); + }; + if (activeAdvancedSettingsEscapeListener === null) { + activeAdvancedSettingsEscapeListener = (event) => { + if (event.key === 'Escape' && typeof activeAdvancedSettingsCloseHandler === 'function') { + activeAdvancedSettingsCloseHandler(); + } + }; + document.addEventListener('keydown', activeAdvancedSettingsEscapeListener); + } + if (!showAdvancedSettingsLink) { + this.#advancedSettingsToggle.style.display = 'none'; + this.#advancedSettingsBox.style.display = 'none'; + } else { + document.addEventListener('click', (event) => { + const isInsideAdvancedBox = this.#advancedSettingsBox.contains(event.target); + const isAdvancedToggle = this.#advancedSettingsToggle.contains(event.target); + if (!isInsideAdvancedBox && !isAdvancedToggle) { + closeAdvancedSettings(); + } + }); } - addToolTip(this.#showCoordinates, 'Show or hide board coordinates'); // flip board this.#flipBoard.onclick = () => { diff --git a/webapp/src/main/resources/templates/about/about.html b/webapp/src/main/resources/templates/about/about.html index c5717b89d..15eba1294 100644 --- a/webapp/src/main/resources/templates/about/about.html +++ b/webapp/src/main/resources/templates/about/about.html @@ -122,6 +122,14 @@

    Assets Credits

    It's now your turn to play notification sound by
    Rob Marion +
  • + Data search icon by Ndut_Design +
  • +
  • + Hamburger icons by feen - + Flaticon +
  • Trophy icons by Freepik
  • @@ -136,9 +144,15 @@

    Assets Credits

    target="_blank"> Poorna Chandra Ghanta
  • - Edit position icon by Tanah + Edit position icon by Tanah Basah
  • +
  • + Trophy icon by Freepik +
  • +
  • + Beach icons by monkik +
  • Flags icons https://flagicons.lipis.dev/
  • @@ -171,8 +185,8 @@

    Engines

    (dhbloo), Vincentzyx
  • - Fairy Stockfish 11.2 by Fabian + Fairy Stockfish 14.0.1 by Fabian Fichter
  • diff --git a/webapp/src/main/resources/templates/about/changelog.html b/webapp/src/main/resources/templates/about/changelog.html index 77964a396..4202d5d54 100644 --- a/webapp/src/main/resources/templates/about/changelog.html +++ b/webapp/src/main/resources/templates/about/changelog.html @@ -5,13 +5,208 @@ - + + {{body_init}}

    Changelog


    + {{changelog_toc}} +

    May 2026 + + Copy link + +

    +
    +

    2026-05-19

    +
      +
    • + Hide empty second game-thumbnails row on browse pages when first page contains 3 or fewer games + (issue #633) +
    • +
    • + Technical change: Ktor/kotlinx.html TagResolver support and migrate server-side renderer HTML snippets away + from raw strings + (issue #679) +
    • +
    • + Technical change (re-ordering routes) +
    • +
    +

    2026-05-18

    +
      +
    • + Replace lobby open-games table with responsive card list layout + (issue #601) +
    • +
    • + Fix check highlight loss on move format change; gate coordinates style on show-coordinates; add + Chinese-on-one-side coordinates options + (issue #662) +
    • +
    • + Render advanced settings as a centered modal on mobile + (issue #671) +
    • +
    +

    2026-05-17

    +
      +
    • + Add a "Coordinates" advanced setting to choose between Arabic numerals on both sides, Chinese numerals + on both sides, Chinese numerals on red side only (default), or UCI / Algebraic letters (a–i) + (issue #362) +
    • +
    • + Add colorblind-friendly black piece toggle and advanced settings overlay in the settings manager + (issue #606) +
    • +
    • + Add a "copy link" anchor icon next to each month heading on this changelog page + (issue #657) +
    • +
    • + Add a table of contents (clustered by quarter) to this changelog page + (issue #657) +
    • +
    • + Show move annotation symbols in move history on PvP, PvB and DB pages + (issue #366) +
    • +
    • + Fix user settings page layout on mobile + (issue #635) +
    • +
    • + Add email confirmation on sign up + (issue #28) +
    • +
    • + Server-side rendering of Database games +
    • +
    • + Distribution of Board GUI is bumped to 0.0.4 for the + colorblind-friendly mode and the coordinates style. +
    • +
    • + Fix profile settings unset/null handling for country flag and description + (issue #30) +
    • +
    • + Fix Logout requiring two clicks by deleting identification cookies with the same path used to set them + (issue #494) +
    • +
    +

    2026-05-16

    +
      +
    • + Add confirmation prompt before cancelling PvP and PvB games + (issue #341) +
    • +
    • + Add a sticky mini time counter in the bottom-right corner on mobile for PvP games + (issue #440) +
    • +
    • + Option to transfer guest activity (games, bot games, puzzles, database searches) and ratings to a newly + registered account on sign-up + (PR #610) +
    • +
    • + Show a mini board tooltip when hovering engine PV moves on the Analysis Board + (PR #614) +
    • +
    • + Eval line chart: replace two-color series with single blue line and add background bands and fix engine name + in analysis summary report + (issue #643) +
    • +
    • + Technical change (Gradle build to Kotlin DSL) + (issue #639) +
    • +
    • + Technical change (canonical URL on user profile) + (issue #641) +
    • +
    • + Technical changes (dependencies upgrades, refactor call "listLatestPvbGames") +
    • +
    +

    2026-05-15

    +
      +
    • + Add "user is typing" indicator to PvP chat + (issue #412) +
    • +
    • + Add eval history line chart to analysis summary report + (issue #169) +
    • +
    +

    2026-05-14

    +
      +
    • + Import ICCS format moves in the Analysis Board + (issue #182) +
    • +
    • + Draw an elbowed arrow for knight moves in the engine analysis + (issue #281). + Distribution of Board GUI is bumped to 0.0.3. +
    • +
    • + Add "My Databases Searches" view: list and re-run previous Database + searches (issue #488) +
    • +
    • + Adapt the user drop down menu so it uses links (i.e. HTML anchors) instead of click events, which makes it + possible to e.g. open the view in a new tab + (issue #535) +
    • +
    • + Add view of user sessions (IP, country, etc.) + (issue #622) +
    • +
    • + Allow Vietnamese characters in username + (issue #492) +
    • +
    • + Display user profiles with blank description + (issue #576) +
    • +
    • + Add move tree history on the simple board page + (issue #152) +
    • +
    • + Fixed issue where it would download the PGN twice +
    • +
    • + Upgrade Fairy Stockfish version to 14.0.1 (reverted, doesn't seem to work on the server) +
    • +
    +

    2026-05-13

    +
      +
    • + Exclude auto-resigned games from the "Latest Bot Games" section on the lobby (they remain visible when + browsing PvB games) + (issue #527) +
    • +
    • + Generate a table of contents at the top of the FAQ page + (issue #578) +
    • +
    • + Show latest PvP games on user profile page + (issue #197) +
    • +
    • + Decrease number of default game thumbs in all the "browse" pages +
    • +

    2026-05-12

    • @@ -114,6 +309,12 @@

      2026-05-01

      end (e.g. by disabling browser extensions or using a different network).
    +

    April 2026 + + Copy link + +

    +

    2026-04-30

    • @@ -203,6 +404,10 @@

      2026-04-12

      automatically when it happens.
    +

    March 2026 Copy link +

    +

    2026-03-23

    • @@ -227,12 +432,20 @@

      2026-03-04

      Technical changes (libraries upgrades)
    +

    February 2026 Copy link

    +

    2026-02-23

    • E-mail format validation fix
    +

    January 2026 Copy link

    +

    2026-01-30

    • @@ -539,6 +752,10 @@

      2026-01-05

      Technical changes (dynamic sitemap)
    +

    December 2025 Copy link

    +

    2025-12-29

    • @@ -630,6 +847,10 @@

      2025-12-02

      Technical changes (admin console)
    +

    November 2025 Copy link

    +

    2025-11-19

    +

    October 2025 Copy link

    +

    2025-10-28

    • @@ -808,6 +1033,10 @@

      2025-10-04

      DB engine version upgrade
    +

    September 2025 Copy link

    +

    2025-09-11

    +

    August 2025 Copy link +

    +

    2025-08-25

    • @@ -915,6 +1148,10 @@

      2025-08-02

      Technical changes (admin console)
    +

    July 2025 Copy link

    +

    2025-07-31

    +

    June 2025 Copy link

    +

    2025-06-29

    • @@ -1140,6 +1381,10 @@

      2025-06-22

      Technical changes (libraries upgrades)
    +

    May 2025 Copy link

    +

    2025-05-19

    • @@ -1202,6 +1447,10 @@

      2025-05-03

      (issue #57)
    +

    April 2025 Copy link +

    +

    2025-04-28

    • @@ -1242,6 +1491,10 @@

      2025-04-19

      Fix issue where "Cancel Game" button wasn't shown
    +

    March 2025 Copy link +

    +

    2025-03-24

    +

    February 2025 Copy link

    +

    2025-02-24

    • @@ -1407,6 +1664,10 @@

      2025-02-08

      Technical changes (logging library, libraries upgrades, naming)
    +

    January 2025 Copy link

    +

    2025-01-29

    +

    December 2024 Copy link

    +

    2024-12-31

    • @@ -2032,6 +2297,10 @@

      2024-12-01

      Technical changes (admin console)
    +

    November 2024 Copy link

    +

    2024-11-30

    • @@ -2139,12 +2408,20 @@

      2024-11-02

      Minor CSS changes
    +

    October 2024 Copy link

    +

    2024-10-12

    • Technical changes (libraries upgrades)
    +

    September 2024 Copy link

    +

    2024-09-04

    • @@ -2160,6 +2437,10 @@

      2024-09-02

      Fix CSS issue where the 2 Analyze buttons would be visible
    +

    August 2024 Copy link +

    +

    2024-08-31

    • @@ -2201,6 +2482,10 @@

      2024-08-03

      href="/analysis">Analysis Board. Still in beta.
    +

    June 2024 Copy link

    +

    2024-06-14

    • @@ -2222,6 +2507,10 @@

      2024-06-05

      Technical changes (admin console)
    +

    May 2024 Copy link

    +

    2024-05-25

    • @@ -2302,6 +2591,10 @@

      2024-05-01

      Cookie consent banner and related information in the FAQ page
    +

    April 2024 Copy link +

    +

    2024-04-29

    • @@ -2477,6 +2770,10 @@

      2024-04-01

      6 (previously there was no difference).
    +

    March 2024 Copy link +

    +

    2024-03-31

    • @@ -2538,6 +2835,10 @@

      2024-03-16

      Admin console page to monitor latest analyzed games
    +

    February 2024 Copy link

    +

    2024-02-25

    • @@ -2560,6 +2861,10 @@

      2024-02-10

      Technical bugfix (user session IP in the wrong format)
    +

    January 2024 Copy link

    +

    2024-01-02

    • @@ -2574,6 +2879,10 @@

      2024-01-02

      Minor style changes (remove bold style on table row hovering, 2 digits year format)
    +

    December 2023 Copy link

    +

    2023-12-29

    • @@ -2620,6 +2929,10 @@

      2023-12-03

      Technical changes (admin console)
    +

    November 2023 Copy link

    +

    2023-11-27

    • @@ -2686,6 +2999,10 @@

      2023-11-01

      Fix issue where password recovery wouldn't show any feedback when providing valid email
    +

    October 2023 Copy link

    +

    2023-10-29

    • @@ -2723,6 +3040,10 @@

      2023-10-01

      Technical changes regarding the batch jobs (no significant change for end users)
    +

    September 2023 Copy link

    +

    2023-09-24

    • @@ -2780,6 +3101,10 @@

      2023-09-02

      Fix bug when updating analysis in the Analysis Board
    +

    August 2023 Copy link +

    +

    2023-08-28

    • @@ -2941,6 +3266,10 @@

      2023-08-01

      Minor UI improvements
    +

    July 2023 Copy link

    +

    2023-07-31

    • @@ -3036,6 +3365,10 @@

      2023-07-21

      Increase the max length to 2000.
    +

    June 2023 Copy link

    +

    2023-06-17

    • @@ -3080,6 +3413,10 @@

      2023-06-03

      interesting to persist engine result data that don't contain a cp value.
    +

    May 2023 Copy link

    +

    2023-05-29

    • @@ -3197,6 +3534,10 @@

      2023-05-07

      version.
    +

    April 2023 Copy link +

    +

    2023-04-29 / 2023-04-30

    • @@ -3277,6 +3618,10 @@

      2023-04-01

      Key shortcut to go to next puzzle (Enter)
    +

    March 2023 Copy link +

    +

    2023-03-31

    • @@ -3470,6 +3815,10 @@

      2023-03-06

      (available only to logged users)
    +

    February 2023 Copy link

    +

    2023-02-11

    • @@ -3479,6 +3828,10 @@

      2023-02-11

      Configuration of the server
    +

    January 2023 Copy link

    +

    2023-01-23

    • diff --git a/webapp/src/main/resources/templates/about/faq.html b/webapp/src/main/resources/templates/about/faq.html index 3843b8bc7..db4735ad9 100644 --- a/webapp/src/main/resources/templates/about/faq.html +++ b/webapp/src/main/resources/templates/about/faq.html @@ -1,7 +1,7 @@ {{header_init}} - FAQ + Frequently Asked Questions (FAQ) about elephantchess.io @@ -12,8 +12,11 @@ {{body_init}}
      +

      Frequently Asked Questions

      +
      + {{faq_toc}} -

      Why Support the Project?

      +

      Why support the project?


      You can support the project on Ko-fi or @@ -25,12 +28,13 @@

      Why Support the Project?

      intending to keep it that way. It's updated on a weekly basis (sometimes even daily, but mostly on weekends). Since I started the project 3 years ago, I've sunken hundreds of hours of coding — just for the fun of making a nice platform for Xiangqi players around the world, and to bring - that wonderful game to a wider English-speaking audience. + the game to a wider English-speaking audience.

      - It currently costs around 100€ per month to run, and I'm also paying for ads to increase visibility. This is all - out of pocket (financed by my day job as a software developer). - This cost is likely to increase in the future, as the platform (hopefully) grows in popularity. + It currently costs around 100€ per month to run, and I'm also paying for ads to increase visibility and LLM for + increased productivy and progress. This is all out of pocket (financed by my day job as a software developer). This cost is likely to increase in the + future, as the platform (hopefully) grows in popularity.

      If just 25 people donated 4€ per month, that would fully cover the current cost! @@ -38,9 +42,13 @@

      Why Support the Project?

      A sustainable stream of income would, for example, allow to rent dedicated servers (as of now, we're using "shared CPU" servers, which sometimes makes the website unresponsive, luckily this doesn't happen too often). + Another example: the reason we are not able to run Fairy Stockfish 14.0.1 and are stuck with 11.2 is because we + are limited with memory on the current set-up. +

      +

      With more budget, I could also duplicate the server in Asia to improve response time from there. At some point, - I could also hire experts for specific tasks (data analysis, UI/UX improvements, better puzzle generation, - cheating detection, editors to improve Database player profiles, translators for internationalization, etc.) + I could also hire experts for specific tasks (data analysis, UI/UX improvements, puzzle algorithms, cheating + detection, editors to improve Database player profiles, translators for internationalization, etc.)

      My goal is to make the platform financially self-sustainable, so it wouldn't be dependent on my other activities @@ -68,11 +76,12 @@

      Why Support the Project?

      target="_blank">cool mug.

      - (Note that if you want to automatically appear as the latest supporter in the banner, you must use Ko-fi.) + (Note that if you want to automatically appear as the latest supporter in the banner, you must use Ko-fi and use + the same email address on both Ko-Fi and elephantchess.)

      -

      Why Sign Up?

      +

      Why sign up?


      You can use the webapp as a guest user. However, those sessions are tied to your device and are temporary (30 @@ -100,7 +109,7 @@

      Why Sign Up?

      -

      Our Cookie Policy

      +

      Our cookie policy


      Cookies are a tool to store information on your computer that a website can read and write when you visit. We @@ -143,7 +152,7 @@

      Our Cookie Policy

      -

      Our Privacy Policy

      +

      Our privacy policy


      We keep track of your session information (like the IP) to get information about where our users come from and @@ -162,7 +171,7 @@

      Our Privacy Policy

      -

      Why Did Pick a URL with .io?

      +

      Why did we pick a URL with .io at the end?


      The initial idea was to create an online xiangqi puzzle game. A lot of online games and tech websites use the @@ -172,7 +181,7 @@

      Why Did Pick a URL with .io?

      -

      How Do Analysis Summary Symbols Work?

      +

      How do analysis summary symbols work?


      Chess annotation symbols @@ -243,7 +252,7 @@

      How Do Analysis Summary Symbols Work?

      -

      Which Formats are Supported for Imports?

      +

      What formats are supported for imports?


      The Analysis Board has a feature to import moves in various formats.

      @@ -254,7 +263,7 @@

      Which Formats are Supported for Imports?

      {{faq_import_moves_formats_examples}}[[keepIndentation:false]] -

      What Is Perpetual Checking?

      +

      What is perpetual checking?


      Perpetual checking is a situation in which a player makes a series of moves giving check to the opponent diff --git a/webapp/src/main/resources/templates/analysis_board.html b/webapp/src/main/resources/templates/analysis_board.html index 71ed598f6..bab801385 100644 --- a/webapp/src/main/resources/templates/analysis_board.html +++ b/webapp/src/main/resources/templates/analysis_board.html @@ -8,10 +8,12 @@ + {{apex_charts}} {{board_js}} + diff --git a/webapp/src/main/resources/templates/browse_pvb_games.html b/webapp/src/main/resources/templates/browse_pvb_games.html index 073246c9b..11f6c4d2b 100644 --- a/webapp/src/main/resources/templates/browse_pvb_games.html +++ b/webapp/src/main/resources/templates/browse_pvb_games.html @@ -1,7 +1,7 @@ {{header_init}} - Chinese Chess (Xiangqi) Games Database – Player vs Bot + Browse PvB games @@ -20,7 +20,7 @@

      Latest Player-vs-Bot Games


      - {{game_thumb}}[[iterations:12; {gameType: 'pvb', parentClass: 'pvb-game-thumb'}]] + {{game_thumb}}[[iterations:6; {gameType: 'pvb', parentClass: 'pvb-game-thumb'}]]
    diff --git a/webapp/src/main/resources/templates/database/database_players_list.html b/webapp/src/main/resources/templates/database/database_players_list.html index f74e3fca9..fe47e1684 100644 --- a/webapp/src/main/resources/templates/database/database_players_list.html +++ b/webapp/src/main/resources/templates/database/database_players_list.html @@ -4,6 +4,7 @@ Chinese Chess (Xiangqi) Players Directory – elephantchess.io Database + diff --git a/webapp/src/main/resources/templates/email_confirmation/email_confirmation_error.html b/webapp/src/main/resources/templates/email_confirmation/email_confirmation_error.html new file mode 100644 index 000000000..0fd0cb52c --- /dev/null +++ b/webapp/src/main/resources/templates/email_confirmation/email_confirmation_error.html @@ -0,0 +1,20 @@ + + + {{header_init}} + Email confirmation failed + {{meta_no_index}} + + + +{{body_init}} +
    +
    + Not Found +
    + + Unknown, invalid or expired email confirmation code + +
    +{{footer}} + + diff --git a/webapp/src/main/resources/templates/email_confirmation/email_confirmed.html b/webapp/src/main/resources/templates/email_confirmation/email_confirmed.html new file mode 100644 index 000000000..99d77d737 --- /dev/null +++ b/webapp/src/main/resources/templates/email_confirmation/email_confirmed.html @@ -0,0 +1,15 @@ + + + {{header_init}} + Email confirmed + {{meta_no_index}} + + + +{{body_init}} +
    +

    Your email address has been successfully confirmed. Thank you!

    +
    +{{footer}} + + diff --git a/webapp/src/main/resources/templates/lobby.html b/webapp/src/main/resources/templates/lobby.html index 6e80bbcfa..3998ed8a1 100644 --- a/webapp/src/main/resources/templates/lobby.html +++ b/webapp/src/main/resources/templates/lobby.html @@ -20,22 +20,7 @@

    Lobby

    -
    -
    -
    -
    -
    +
    {{players_info}}
    +
    {{game_date_info}}
    +
    {{game_event_info}}
    - - - - - - - - - - - - - - -
    PlayerRatingColorTimeMode
    +

    There is no game to join at the moment. But you can diff --git a/webapp/src/main/resources/templates/player_vs_bot.html b/webapp/src/main/resources/templates/player_vs_bot.html index 37ac31eb9..e29d5cea5 100644 --- a/webapp/src/main/resources/templates/player_vs_bot.html +++ b/webapp/src/main/resources/templates/player_vs_bot.html @@ -3,6 +3,7 @@ {{header_init}} Play vs. Bot {{meta_no_index}} + {{apex_charts}} {{board_js}} diff --git a/webapp/src/main/resources/templates/player_vs_player_game.html b/webapp/src/main/resources/templates/player_vs_player_game.html index b819fde7d..acf7e9ae8 100644 --- a/webapp/src/main/resources/templates/player_vs_player_game.html +++ b/webapp/src/main/resources/templates/player_vs_player_game.html @@ -4,6 +4,7 @@ Game {{meta_no_index}} + {{apex_charts}} {{board_js}} @@ -16,6 +17,10 @@ {{body_init}} +

    +
    --:--
    +
    --:--
    +
    diff --git a/webapp/src/main/resources/templates/simple_board.html b/webapp/src/main/resources/templates/simple_board.html index 56a509c43..d6e8291f2 100644 --- a/webapp/src/main/resources/templates/simple_board.html +++ b/webapp/src/main/resources/templates/simple_board.html @@ -10,6 +10,7 @@ {{body_init}}
    +
    @@ -32,6 +33,8 @@
    {{settings_info_box}} +
    +
    {{logged_as_guest_message}} diff --git a/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_all.html b/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_all.html index e37a3673d..e5bf6589e 100644 --- a/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_all.html +++ b/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_all.html @@ -8,7 +8,7 @@ {{body_init}}
    - {{emailAddress}} has been successfully unsubscribed from all email notifications +

    {{emailAddress}} has been successfully unsubscribed from all email notifications

    {{footer}} diff --git a/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_newsletter.html b/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_newsletter.html index 28baa5629..e34a072bb 100644 --- a/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_newsletter.html +++ b/webapp/src/main/resources/templates/unsubscribe/unsubscribed_from_newsletter.html @@ -8,7 +8,7 @@ {{body_init}}
    - {{emailAddress}} has been successfully unsubscribed from the newsletter +

    {{emailAddress}} has been successfully unsubscribed from the newsletter

    {{footer}} diff --git a/webapp/src/main/resources/templates/user_browse_pvp_games.html b/webapp/src/main/resources/templates/user_browse_pvp_games.html new file mode 100644 index 000000000..38c323b2d --- /dev/null +++ b/webapp/src/main/resources/templates/user_browse_pvp_games.html @@ -0,0 +1,33 @@ + + + {{header_init}} + {{username}}'s Player-vs-Player Games + + + + + + + + + + +{{body_init}} +
    +

    {{username}}'s Player-vs-Player Games

    +
    +
    + {{game_thumb}}[[iterations:6; {gameType: 'pvp', parentClass: 'pvp-game-thumb'}]] +
    + + + +
    + + diff --git a/webapp/src/main/resources/templates/user_profile.html b/webapp/src/main/resources/templates/user_profile.html index f5f94e397..c2ffb610f 100644 --- a/webapp/src/main/resources/templates/user_profile.html +++ b/webapp/src/main/resources/templates/user_profile.html @@ -5,15 +5,20 @@ {{meta_no_index_conditional}} {{description_meta}} + {{apex_charts}} + + + + - + {{body_init}}
    @@ -79,6 +84,19 @@

    Game Stats

    +
    +

    Puzzle Stats

    diff --git a/webapp/src/main/resources/templates/user_sessions.html b/webapp/src/main/resources/templates/user_sessions.html new file mode 100644 index 000000000..7e7828351 --- /dev/null +++ b/webapp/src/main/resources/templates/user_sessions.html @@ -0,0 +1,50 @@ + + + {{header_init}} + Sessions + {{meta_no_index}} + + + + + + + + +{{body_init}} +
    +

    Sessions

    +
    + +
    + + + + + + + + + + + + + + + + +
    osagentcountryregioncityIPcreatedupdated
    +
    + + + +
    + + diff --git a/webapp/src/main/resources/templates/user_settings.html b/webapp/src/main/resources/templates/user_settings.html index 8adf76d69..1ff9212dd 100644 --- a/webapp/src/main/resources/templates/user_settings.html +++ b/webapp/src/main/resources/templates/user_settings.html @@ -3,14 +3,16 @@ {{header_init}} Settings {{meta_no_index}} + + {{body_init}} -
    +

    Profile Settings


    Description

    @@ -30,22 +32,39 @@

    Email Notifications Settings

    Email Address


    - diff --git a/webapp/src/main/resources/templates/userdata/my_db_searches.html b/webapp/src/main/resources/templates/userdata/my_db_searches.html new file mode 100644 index 000000000..1b883e0e1 --- /dev/null +++ b/webapp/src/main/resources/templates/userdata/my_db_searches.html @@ -0,0 +1,33 @@ + + + {{header_init}} + My Database Searches + {{meta_no_index}} + + + + + + + + + +{{body_init}} +
    +

    My Database Searches

    +
    +
    +
    +
    + Loading more searches... +
    +
    + No more searches to load. +
    +
    + You haven't searched the database yet. Go to the database to start searching. +
    + {{logged_as_guest_message}} +
    + + diff --git a/webapp/src/main/resources/web_fragments/analysis_summary_report.html b/webapp/src/main/resources/web_fragments/analysis_summary_report.html index 314c3ccea..b12a8f4b1 100644 --- a/webapp/src/main/resources/web_fragments/analysis_summary_report.html +++ b/webapp/src/main/resources/web_fragments/analysis_summary_report.html @@ -4,6 +4,7 @@ about
    +
    diff --git a/webapp/src/main/resources/web_fragments/board_js.html b/webapp/src/main/resources/web_fragments/board_js.html index c7fe2305e..6d5149f49 100644 --- a/webapp/src/main/resources/web_fragments/board_js.html +++ b/webapp/src/main/resources/web_fragments/board_js.html @@ -5,3 +5,4 @@ + diff --git a/webapp/src/main/resources/web_fragments/chat_box.html b/webapp/src/main/resources/web_fragments/chat_box.html index 8ec422903..5058ce653 100644 --- a/webapp/src/main/resources/web_fragments/chat_box.html +++ b/webapp/src/main/resources/web_fragments/chat_box.html @@ -1,6 +1,7 @@
    +
    diff --git a/webapp/src/main/resources/web_fragments/faq_import_moves_formats_examples.html b/webapp/src/main/resources/web_fragments/faq_import_moves_formats_examples.html index c6abda231..d59a47e96 100644 --- a/webapp/src/main/resources/web_fragments/faq_import_moves_formats_examples.html +++ b/webapp/src/main/resources/web_fragments/faq_import_moves_formats_examples.html @@ -82,6 +82,27 @@

    UCI

    38. c0a2 e4f4 39. i4i5 e7f7 40. d7f6 d8f8 41. a4a5 f8f6
    +

    ICCS

    +

    + Internet Chinese Chess Server coordinate notation. Similar to UCI, but uses uppercase files separated by a dash. +

    +
    +
    +[Game "Chinese Chess"]
    +[Event "Chinese National Champion"]
    +[Format "ICCS"]
    +1. H2-E2 B9-C7
    +2. H0-G2 H9-G7
    +3. I0-H0 I9-H9
    +4. B0-A2 G6-G5
    +5. B2-C2 A9-B9
    +6. A0-B0 B7-B3
    +7. H0-H4 H7-I7
    +8. H4-F4 H9-H8
    +9. A3-A4 H8-B8
    +10. G3-G4 G5-G4
    +    
    +

    WXF

    The World Xiangqi Federation move notation uses specific letters to represent each piece. This notation is widely diff --git a/webapp/src/main/resources/web_fragments/footer.html b/webapp/src/main/resources/web_fragments/footer.html index 7cf1d1b66..8622f4ce8 100644 --- a/webapp/src/main/resources/web_fragments/footer.html +++ b/webapp/src/main/resources/web_fragments/footer.html @@ -53,7 +53,7 @@

    Developers

    GitHub
  • - Board + Issues
  • diff --git a/webapp/src/main/resources/web_fragments/head_description.txt b/webapp/src/main/resources/web_fragments/head_description.txt index eaa7afe9f..0d166459b 100644 --- a/webapp/src/main/resources/web_fragments/head_description.txt +++ b/webapp/src/main/resources/web_fragments/head_description.txt @@ -1 +1 @@ -Play Chinese chess (Xiangqi) online for free. Create a game and share the link to play with a friend, join a game from the lobby, challenge a bot, solve puzzles, analyze your games and learn from your mistakes. No download. +Play Chinese chess (Xiangqi) online for free. Create a game and share the link to play with a friend, join a game from the lobby, challenge a bot, solve puzzles, analyze your games and learn from your mistakes. Colorblind mode available. diff --git a/webapp/src/main/resources/web_fragments/logged_as_guest_message.html b/webapp/src/main/resources/web_fragments/logged_as_guest_message.html index 2cdee0139..2fcb672d7 100644 --- a/webapp/src/main/resources/web_fragments/logged_as_guest_message.html +++ b/webapp/src/main/resources/web_fragments/logged_as_guest_message.html @@ -13,6 +13,10 @@ sign up is quick. Read more on the FAQ.

    +

    + You have the possibility to transfer the games played as guest on + sign up. +

    If you forgot your password, you can reset it.

    diff --git a/webapp/src/main/resources/web_fragments/settings_info_box.html b/webapp/src/main/resources/web_fragments/settings_info_box.html index 0ddbeecd1..d62a00c9a 100644 --- a/webapp/src/main/resources/web_fragments/settings_info_box.html +++ b/webapp/src/main/resources/web_fragments/settings_info_box.html @@ -14,22 +14,6 @@ loading="lazy">
    -
    -
    - Select move format -
    -
    -
    -
    - Show or hide board coordinates -
    -
    + + advanced + +
    + +
    + Move format setting icon +
    +
    +
    Move format
    +
    Notation shown in the move list.
    +
    +
    + + + + +
    +
    +
    +
    + Show coordinates setting icon +
    +
    +
    Show coordinates
    +
    Display board coordinates around the board edges.
    +
    +
    + + +
    +
    +
    +
    + Coordinates style setting icon +
    +
    +
    Coordinates
    +
    Numeral system used for board coordinates.
    +
    +
    + + + + + + + +
    + +
    +
    +
    + Colorblind black pieces setting icon +
    +
    +
    Colorblind black pieces
    +
    Increase contrast for black pieces.
    +
    +
    + + +
    +
    +
    +
    diff --git a/webapp/src/main/resources/web_fragments/top_bar.html b/webapp/src/main/resources/web_fragments/top_bar.html index 0ea4a120b..a91813810 100644 --- a/webapp/src/main/resources/web_fragments/top_bar.html +++ b/webapp/src/main/resources/web_fragments/top_bar.html @@ -81,7 +81,7 @@
    - profile + menu