From 2fe59d632618cc1da82745f4b8b24fa68f004599 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 6 Sep 2026 22:18:36 +0200 Subject: [PATCH 01/24] fix table formatting (#7256) --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f22df461f..5935bb540 100644 --- a/README.md +++ b/README.md @@ -149,15 +149,15 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`: -| Flag | Description | -| --- | --- | -| `-DWITH_SERVER=1` | Build Servatrice server | -| `-DWITH_CLIENT=0` | Don't build Cockatrice client | -| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | -| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | -| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | -| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | -| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | +| Flag | Description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `-DWITH_SERVER=1` | Build Servatrice server | +| `-DWITH_CLIENT=0` | Don't build Cockatrice client | +| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | +| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | +| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | +| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | +| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | # Run From 0f0e46a1776fd48e2db4e76fea4c9ecfcd28d66b Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 6 Sep 2026 22:19:13 +0200 Subject: [PATCH 02/24] [Oracle] Have space between size value and unit (#7254) --- oracle/src/pages.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 94e662ffe..1868e670e 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -260,7 +260,7 @@ bool LoadSetsPage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); @@ -343,7 +343,7 @@ void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total) progressBar->setMaximum(static_cast(total)); progressBar->setValue(static_cast(received)); } - progressLabel->setText(tr("Downloading (%1MB)").arg((int)received / (1024 * 1024))); + progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024))); } void LoadSetsPage::actDownloadFinishedSetsFile() @@ -743,4 +743,4 @@ void LoadSpoilersPage::retranslateUi() pathLabel->setText(tr("The spoiler database will be saved at the following location:") + "
" + SettingsCache::instance().getSpoilerCardDatabasePath()); defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)")); -} \ No newline at end of file +} From fb482f037e70685ffc59cd39595955667a869c5e Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 6 Sep 2026 23:13:34 +0200 Subject: [PATCH 03/24] 550 MB --> 600 MB (#7257) --- .github/workflows/desktop-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b631f32d3..6ca634389 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -152,7 +152,7 @@ jobs: env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} @@ -342,7 +342,7 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy steps: - name: "Checkout" From 8d30ac54f0434906d3bf2c3c36fc1c4d0fc9306b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:57:35 +0200 Subject: [PATCH 04/24] [Game] Prevent spectator duplication when replaying joined events (#7248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Game] Prevent spectator duplication when replaying joined events The spectator branch of eventJoin emitted spectatorJoined unconditionally even when the spectator was already present (e.g. replayed during a rewind). Guard it like the player branch and eventGameStateChanged, and make PlayerListWidget::addPlayer idempotent as defense in depth. * In resetChatAndPhase() (the rewound() handler), also clear all spectators from both PlayerManager and PlayerListWidget before the replay rebuilds from event 0. The forward replay then re-adds exactly the spectators whose join events fall within the new time range via eventGameStateChanged/eventJoin. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 5 +++-- cockatrice/src/game/player/player_manager.cpp | 8 ++++++++ cockatrice/src/game/player/player_manager.h | 3 +++ .../game_graphics/player/player_list_widget.cpp | 16 ++++++++++++++++ .../game_graphics/player/player_list_widget.h | 1 + .../src/interface/widgets/tabs/tab_game.cpp | 4 ++++ 6 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index bc68d4d7c..f146cdbb4 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -430,12 +430,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/, QString playerName = QString::fromStdString(playerInfo.user_info().name()); emit addPlayerToAutoCompleteList(playerName); - if (game->getPlayerManager()->getPlayers().contains(playerId)) { + PlayerManager *playerManager = game->getPlayerManager(); + if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) { return; } if (playerInfo.spectator()) { - game->getPlayerManager()->addSpectator(playerId, playerInfo); + playerManager->addSpectator(playerId, playerInfo); emit logJoinSpectator(playerName); emit spectatorJoined(playerInfo); } else { diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp index 6772d3ff1..8486efbeb 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/cockatrice/src/game/player/player_manager.cpp @@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const return player; } +void PlayerManager::clearSpectators() +{ + const QList spectatorIds = spectators.keys(); + for (int spectatorId : spectatorIds) { + removeSpectator(spectatorId); + } +} + void PlayerManager::onPlayerConceded(int playerId, bool conceded) { // Everything else cares about this diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h index 2f8b87af8..504e65396 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/cockatrice/src/game/player/player_manager.h @@ -100,6 +100,9 @@ public: emit spectatorRemoved(spectatorId, spectatorInfo); } + /** @brief Remove all spectators, emitting the removal signal for each. */ + void clearSpectators(); + [[nodiscard]] AbstractGame *getGame() const { return game; diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 4268e1019..13a077af8 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -92,6 +92,11 @@ void PlayerListWidget::retranslateUi() void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player) { + if (players.contains(player.player_id())) { + updatePlayerProperties(player); + return; + } + QTreeWidgetItem *newPlayer = new PlayerListTWI; players.insert(player.player_id(), newPlayer); updatePlayerProperties(player); @@ -176,6 +181,17 @@ void PlayerListWidget::removePlayer(int playerId) delete takeTopLevelItem(indexOfTopLevelItem(player)); } +void PlayerListWidget::clearSpectators() +{ + const QList playerIds = players.keys(); + for (int playerId : playerIds) { + QTreeWidgetItem *player = players.value(playerId, 0); + if (player && !player->data(1, Qt::UserRole).toBool()) { + removePlayer(playerId); + } + } +} + void PlayerListWidget::setActivePlayer(int playerId) { QMapIterator i(players); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h index a53cfa989..f2f0be5fd 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.h +++ b/cockatrice/src/game_graphics/player/player_list_widget.h @@ -66,6 +66,7 @@ public slots: void addPlayer(const ServerInfo_PlayerProperties &player); void removePlayer(int playerId); void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1); + void clearSpectators(); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 196ea4526..035ab1004 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -266,6 +266,10 @@ void TabGame::resetChatAndPhase() // reset phase markers game->getGameState()->setCurrentPhase(-1); + + // reset spectator state so the replay can rebuild it from the start + game->getPlayerManager()->clearSpectators(); + playerListWidget->clearSpectators(); } void TabGame::emitUserEvent() From b01e107908a5b54973a243b2205c3f491a31bb76 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:20:54 +0200 Subject: [PATCH 05/24] [Client] Show horizontal art for plane and siege cards in the profile banner (#7118) (#7250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the landscape orientation flag to rotate sideways-layout card art upright before cropping, so planes/sieges show their horizontal art as the server profile banner card instead of a rotated full card. - Curve cropCardArt around the card's landscapeOrientation flag with landscape-specific art margins (mirrors CardInfoPictureWidget) - Add shared CardArtUtils::rotateSidewaysLayoutArt helper and apply it to the playmat (game render and settings preview) and card info widget, replacing three duplicate 90-degree rotation blocks Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../player/player_graphics_item.cpp | 3 ++- .../widgets/cards/card_art_utils.cpp | 18 +++++++++++++ .../interface/widgets/cards/card_art_utils.h | 25 ++++++++++++++++++ .../cards/card_info_picture_widget.cpp | 8 ++---- .../playmat/playmat_settings_dialog.cpp | 3 ++- .../server/user/user_card_art_provider.cpp | 26 +++++++++++++------ .../server/user/user_card_art_provider.h | 3 ++- .../server/user/user_card_settings_dialog.cpp | 2 +- 9 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 cockatrice/src/interface/widgets/cards/card_art_utils.cpp create mode 100644 cockatrice/src/interface/widgets/cards/card_art_utils.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b6050a1bd..9e0331d69 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -166,6 +166,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp src/interface/widgets/cards/art_crop_attribution.cpp + src/interface/widgets/cards/card_art_utils.cpp src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 8bf2703e1..b3c25ffc1 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -3,6 +3,7 @@ #include "../../game/player/player_actions.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/widgets/cards/art_crop_attribution.h" +#include "../../interface/widgets/cards/card_art_utils.h" #include "../../interface/widgets/playmat/playmat_utils.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" @@ -442,7 +443,7 @@ void PlayerGraphicsItem::updatePlaymat() hasPlaymat = true; emit playmatChanged(true); } - playmatPixmap = fullRes; + playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); update(); } diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp new file mode 100644 index 000000000..b26b73593 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -0,0 +1,18 @@ +#include "card_art_utils.h" + +#include +#include + +namespace CardArtUtils +{ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) +{ + if (!card.getInfo().getUiAttributes().landscapeOrientation) { + return art; + } + + QTransform transform; + transform.rotate(90); + return art.transformed(transform, Qt::SmoothTransformation); +} +} // namespace CardArtUtils \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h new file mode 100644 index 000000000..5c331a12c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -0,0 +1,25 @@ +#ifndef CARD_ART_UTILS_H +#define CARD_ART_UTILS_H + +#include + +class ExactCard; + +namespace CardArtUtils +{ +/** + * @brief Rotates a card's art upright when its layout shows sideways. + * + * Sideways-layout cards (planes, sieges/battles, split cards) store their + * landscape artwork rotated 90° inside a portrait frame. Art-crop displays, + * playmat art, and the card-info picture must show such art upright before + * sampling or painting. Portrait cards are returned unchanged. + * + * @param art The card pixmap to orient. + * @param card The card describing the art orientation. + * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. + */ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); +} // namespace CardArtUtils + +#endif // CARD_ART_UTILS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 79ae087d7..de622bdc8 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -5,6 +5,7 @@ #include "../../../interface/card_picture_loader/card_picture_loader.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" #include "../../window_main.h" +#include "card_art_utils.h" #include #include @@ -193,12 +194,7 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) QPixmap transformedPixmap = resizedPixmap; // Default pixmap if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { - if (exactCard.getInfo().getUiAttributes().landscapeOrientation) { - // Rotate pixmap 90 degrees to the left - QTransform transform; - transform.rotate(90); - transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation); - } + transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard); } // Handle DPI scaling diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp index 57706cf93..9459c5ea9 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -2,6 +2,7 @@ #include "../../card_picture_loader/card_picture_loader.h" #include "../cards/art_crop_attribution.h" +#include "../cards/card_art_utils.h" #include "../utility/completer_utils.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -276,7 +277,7 @@ void PlaymatSettingsDialog::reloadPreview() return; } - currentPixmap = fullRes; + currentPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); preview->setAttribution(buildArtAttribution(card)); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index 3a1876fa1..2ba745715 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -1,6 +1,7 @@ #include "user_card_art_provider.h" #include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/card_art_utils.h" #include #include @@ -52,16 +53,25 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString processQueue(); } -QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes) +QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) { - const QSize sz = fullRes.size(); + QPixmap source = fullRes; + + // Sideways-layout cards (plane, siege/battle, split) store their landscape + // artwork rotated 90° inside a portrait frame. Rotate it upright first so + // the crop below lands on the horizontal art, mirroring the way + // CardInfoPictureWidget displays these cards. + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + source = CardArtUtils::rotateSidewaysLayoutArt(source, card); + + const QSize sz = source.size(); const int marginX = sz.width() * 0.07; - const int topMargin = sz.height() * 0.11; - const int bottomMargin = sz.height() * 0.45; + const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; + const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; - const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); + const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); - return fullRes.copy(foilRect.intersected(fullRes.rect())); + return source.copy(artRect.intersected(source.rect())); } void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) @@ -111,7 +121,7 @@ void UserCardArtProvider::processQueue() // Synchronous hit (already loaded/on disk) if (!fullRes.isNull()) { - insertIntoCache(key, cropCardArt(fullRes)); + insertIntoCache(key, cropCardArt(fullRes, card)); pending.remove(key); emit cardArtUpdated(userName); @@ -135,7 +145,7 @@ void UserCardArtProvider::processQueue() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (!fullRes.isNull()) { - self->insertIntoCache(key, self->cropCardArt(fullRes)); + self->insertIntoCache(key, self->cropCardArt(fullRes, card)); } self->pending.remove(key); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index 2592237c4..e8283a891 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -6,6 +6,7 @@ #include #include #include +#include class UserCardArtProvider : public QObject { @@ -16,7 +17,7 @@ public: void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; - static QPixmap cropCardArt(const QPixmap &fullRes); + static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); signals: void cardArtUpdated(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index 532112964..d49e3d540 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -560,7 +560,7 @@ void UserCardArtSettingsDialog::reloadPreview() return; } - currentPixmap = UserCardArtProvider::cropCardArt(fullRes); + currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); From 745e94f332a033231d6e0163e157c0feba0c221c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:26:52 +0200 Subject: [PATCH 06/24] [Replay] Skip damage animations when skipping backward in replays (#7249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/counter_state.cpp | 4 ++-- cockatrice/src/game/board/counter_state.h | 17 +++++++++++++++-- .../src/game/player/event_processing_options.h | 3 ++- .../src/game/player/player_event_handler.cpp | 7 ++++--- .../src/game/player/player_event_handler.h | 2 +- .../game_graphics/board/abstract_counter.cpp | 6 +++--- .../src/game_graphics/board/abstract_counter.h | 3 ++- .../player/player_graphics_item.cpp | 4 ++-- .../src/game_graphics/player/player_target.cpp | 7 ++++++- .../src/game_graphics/player/player_target.h | 2 +- .../interface/widgets/replay/replay_manager.cpp | 2 ++ 11 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp index 6da18b662..0970e4272 100644 --- a/cockatrice/src/game/board/counter_state.cpp +++ b/cockatrice/src/game/board/counter_state.cpp @@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent); } -void CounterState::setValue(int newValue) +void CounterState::setValue(int newValue, bool skipDamageAnimation) { if (newValue == value) { return; } int old = value; value = newValue; - emit valueChanged(old, newValue); + emit valueChanged(old, newValue, skipDamageAnimation); } \ No newline at end of file diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h index 0f2f16b55..4c7b34473 100644 --- a/cockatrice/src/game/board/counter_state.h +++ b/cockatrice/src/game/board/counter_state.h @@ -35,10 +35,23 @@ public: return value; } - void setValue(int newValue); + /** + * @brief Set the counter value. + * @param newValue The new value. + * @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views + * suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay + * rewinds. + */ + void setValue(int newValue, bool skipDamageAnimation = false); signals: - void valueChanged(int oldValue, int newValue); + /** + * @brief Emitted whenever the value changes. + * @param oldValue The previous value. + * @param newValue The new value. + * @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views. + */ + void valueChanged(int oldValue, int newValue, bool skipDamageAnimation); private: int id; diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h index 4c7663789..06238d77e 100644 --- a/cockatrice/src/game/player/event_processing_options.h +++ b/cockatrice/src/game/player/event_processing_options.h @@ -13,7 +13,8 @@ enum EventProcessingOption { SKIP_REVEAL_WINDOW = 0x0001, - SKIP_TAP_ANIMATION = 0x0002 + SKIP_TAP_ANIMATION = 0x0002, + SKIP_DAMAGE_ANIMATION = 0x0004 }; // Wrap it in a QFlags typedef diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index bc48298f7..277b8b1d4 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -262,14 +262,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event) player->addCounter(event.counter_info()); } -void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event) +void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options) { CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr); if (!ctr) { return; } int oldValue = ctr->getValue(); - ctr->setValue(event.value()); + const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION); + ctr->setValue(event.value(), skipDamageAnimation); emit logSetCounter(player, ctr->getName(), event.value(), oldValue); } @@ -625,7 +626,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type, eventCreateCounter(event.GetExtension(Event_CreateCounter::ext)); break; case GameEvent::SET_COUNTER: - eventSetCounter(event.GetExtension(Event_SetCounter::ext)); + eventSetCounter(event.GetExtension(Event_SetCounter::ext), options); break; case GameEvent::DEL_COUNTER: eventDelCounter(event.GetExtension(Event_DelCounter::ext)); diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 48ad85e88..300cacd08 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -153,7 +153,7 @@ public: void eventCreateCounter(const Event_CreateCounter &event); /// Set a player-level counter value. - void eventSetCounter(const Event_SetCounter &event); + void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options); /// Delete a player-level counter. void eventDelCounter(const Event_DelCounter &event); diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index e63117e13..4ba04804f 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -29,9 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state, { setAcceptHoverEvents(true); - connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) { + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { value = newValue; - onValueChanged(oldValue, newValue); + onValueChanged(oldValue, newValue, skipDamageAnimation); update(); }); @@ -230,7 +230,7 @@ void AbstractCounterDialog::changeValue(int diff) setTextValue(QString::number(curValue)); } -void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/) +void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/) { // Default: no feedback. Subclasses such as PlayerCounter override this to // flash the counter on meaningful changes (life gain/loss). diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h index 9ddcc6d58..67b5b4074 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -39,8 +39,9 @@ protected: * @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash). * * Called whenever the counter's value changes, before the item repaints. + * @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds). */ - virtual void onValueChanged(int oldValue, int newValue); + virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation); void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index b3c25ffc1..122ab83be 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -252,8 +252,8 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state) AbstractCounter *widget; if (state->getName() == "life") { widget = playerTarget->addCounter(state); - connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) { - if (newValue < oldValue) { + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { + if (newValue < oldValue && !skipDamageAnimation) { tableZoneGraphicsItem->triggerDamageShimmer(); } }); diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 910ee9c17..63c060b02 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -69,7 +69,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* } } -void PlayerCounter::onValueChanged(int oldValue, int newValue) +void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) { flashDelta = newValue - oldValue; if (flashDelta == 0) { @@ -81,6 +81,11 @@ void PlayerCounter::onValueChanged(int oldValue, int newValue) return; } + if (skipDamageAnimation) { + flashAlpha = 0.0; + return; + } + flashAlpha = 1.0; flashClock.start(); if (scene()) { diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index af0e9c8b7..1d06c6274 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -21,7 +21,7 @@ class PlayerCounter : public AbstractCounter, public IAnimatedItem { Q_OBJECT protected: - void onValueChanged(int oldValue, int newValue) override; + void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override; private: static constexpr qreal flashDurationMs = 450.0; diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index a2c1e0ff0..c51b96b6c 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -142,8 +142,10 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode) } // backwards skip => always skip tap animation + // backwards skip => always skip damage animation (battlefield shimmer / life counter flash) if (playbackMode == BACKWARD_SKIP) { options |= SKIP_TAP_ANIMATION; + options |= SKIP_DAMAGE_ANIMATION; } emit eventReplayed(replay->event_list(currentEvent), options); From 6cdeb0c428ab1d19b211742e246038051d41e687 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:29:17 +0200 Subject: [PATCH 07/24] [Client] Explain the buddy and ignore lists in the user tab (#2072) (#7258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/widgets/tabs/tab_account.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp index 410a48d40..dbcf50966 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp @@ -137,6 +137,11 @@ void TabAccount::retranslateUi() buddyList->retranslateUi(); ignoreList->retranslateUi(); userInfoBox->retranslateUi(); + + buddyList->setToolTip(tr("Buddies are marked with a star in chat, a sound plays when they join or leave the " + "server, and they can be invited to buddy-only games.")); + ignoreList->setToolTip(tr("Ignored users' chat messages are hidden from you, and they cannot send you private " + "messages or join your games.")); } void TabAccount::processListUsersResponse(const Response &response) From 9b0d62c152b7cd3e95112924904e07984c78d836 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:51:38 +0200 Subject: [PATCH 08/24] Close all zone views of a leaving player to avoid crashing (#4298) (#7270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a player left, GameScene::removePlayer iterated zoneViews while close() synchronously removed the current view from that list. A judge with several open views of the departing player (e.g. library and hand) only had the first one closed; the remaining views were skipped and left pointing at a player that was about to be deleted, crashing the client on the next access. - GameScene::removePlayer: iterate over a copy of zoneViews - GameScene::toggleZoneView: same fix for the identical iteration bug Co-authored-by: Lukas Brübach --- cockatrice/src/game_graphics/game_scene.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 457f1b3f7..17af7618b 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -221,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player) clearArrowsForPlayer(player->getPlayerInfo()->getId()); - for (ZoneViewWidget *zone : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy: otherwise a player with several open views (e.g. library and hand) + // only has the first one closed here and the remaining views are left + // pointing at a player that is about to be deleted. + const QList zoneViewCopy = zoneViews; + for (ZoneViewWidget *zone : zoneViewCopy) { if (zone->getPlayer() == player) { zone->close(); } @@ -664,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &items, */ void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) { - for (auto &view : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy to make sure every already-open matching view is closed. + const QList zoneViewCopy = zoneViews; + for (auto *view : zoneViewCopy) { ZoneViewZone *temp = view->getZone(); if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && qobject_cast(temp->getLogic())->getNumberCards() == numberCards) { From 8e0bdafb1483ae70e7d94af3ce0472a1352b7f75 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:14:15 +0200 Subject: [PATCH 09/24] [Client] Prevent contacting users on the ignore list (#7262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Prevent contacting users on the ignore list (#1249) The ignore list silences incoming messages, but a user on it was still reachable: the context menu's chat item stayed enabled, private messages could be sent, and a chat tab could be opened for an ignored user. Make ignored users uncontactable: disable the chat item for them, refuse to deliver messages typed in an open PM tab with one, and refuse to open a new private chat tab with an ignored user (with a hint on how to undo the ignore). * Update cockatrice/src/interface/widgets/tabs/tab_message.cpp Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --------- Co-authored-by: Lukas Brübach Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --- .../interface/widgets/server/user/user_context_menu.cpp | 2 +- cockatrice/src/interface/widgets/tabs/tab_message.cpp | 6 ++++++ cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 8d5d423f6..97a49c47d 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -439,7 +439,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, } } aDetails->setEnabled(true); - aChat->setEnabled(anotherUser && online); + aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); aReport->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9506d96f3..418843178 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -98,6 +98,12 @@ void TabMessage::closeEvent(QCloseEvent *event) void TabMessage::sendPrivateMessage(const QString &text) { + if (tabSupervisor->getUserListManager()->isUserIgnored(getUserName())) { + chatView->appendMessage(tr("You have ignored %1; your messages are not delivered.") + .arg(QString::fromStdString(otherUserInfo->name()))); + return; + } + Command_Message cmd; cmd.set_user_name(otherUserInfo->name()); cmd.set_message(text.toStdString()); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ed0ddaf06..1ebdad277 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1063,6 +1063,13 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } + if (focus && userListManager->isUserIgnored(receiverName)) { + QMessageBox::information( + this, tr("Ignored user"), + tr("You have ignored %1. Remove them from your ignore list to open a private chat.").arg(receiverName)); + return nullptr; + } + tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); From fff506dbbe18c6bde669b2254e886dc5fbd857bf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:14:39 +0200 Subject: [PATCH 10/24] [Client] Show confirmation when adding a user to the ignore list (#1875) (#7261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the UI confirmed that a user had been added to the ignore list, so the action felt ambiguous and could be repeated by accident. Show an information dialog when the server acknowledges the add-to-ignore command. Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_context_menu.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 97a49c47d..6b61d14ec 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -606,7 +606,15 @@ void UserContextMenu::execAddToIgnore(const QString &userName) Command_AddToList cmd; cmd.set_list("ignore"); cmd.set_user_name(userName.toStdString()); - client->sendCommand(client->prepareSessionCommand(cmd)); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, userName](const Response &response, const CommandContainer &, const QVariant &) { + if (response.response_code() == Response::RespOk) { + QMessageBox::information(static_cast(parent()), tr("Ignore list"), + tr("%1 has been added to your ignore list.").arg(userName)); + } + }); + client->sendCommand(pend); } void UserContextMenu::execRemoveFromIgnore(const QString &userName) From 760fe88fa3b0fa27f2cc373e426bc5ba30901255 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:20:46 +0200 Subject: [PATCH 11/24] [Client] Add setting to ignore all private messages (#7260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Add setting to ignore all private messages (#1250) * Early return --------- Co-authored-by: Lukas Brübach --- .../settings_page/messages_settings_page.cpp | 6 ++++++ .../widgets/settings_page/messages_settings_page.h | 1 + .../src/interface/widgets/tabs/tab_supervisor.cpp | 14 ++++++++++++++ .../interfaces/interface_chat_settings_provider.h | 1 + .../libcockatrice/settings/chat_settings.cpp | 10 ++++++++++ .../libcockatrice/settings/chat_settings.h | 2 ++ tests/settings/settings_defaults_test.cpp | 6 ++++++ 7 files changed, 40 insertions(+) diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index e4f24ab73..c161030d8 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -59,6 +59,10 @@ MessagesSettingsPage::MessagesSettingsPage() connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), &ChatSettings::setRoomHistory); + ignoreAllPrivateMessagesCheckBox.setChecked(SettingsCache::instance().chat().getIgnoreAllPrivateMessages()); + connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreAllPrivateMessages); + customAlertString = new QLineEdit(); customAlertString->setText(SettingsCache::instance().chat().getHighlightWords()); connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(), @@ -76,6 +80,7 @@ MessagesSettingsPage::MessagesSettingsPage() chatGrid->addWidget(&messagePopups, 5, 0); chatGrid->addWidget(&mentionPopups, 6, 0); chatGrid->addWidget(&roomHistory, 7, 0); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); @@ -256,6 +261,7 @@ void MessagesSettingsPage::retranslateUi() messagePopups.setText(tr("Enable desktop notifications for private messages")); mentionPopups.setText(tr("Enable desktop notification for mentions")); roomHistory.setText(tr("Enable room message history on join")); + ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); hexLabel.setText(tr("(Color is hexadecimal)")); hexHighlightLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index e98ae0592..436ebbad9 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -40,6 +40,7 @@ private: QCheckBox messagePopups; QCheckBox mentionPopups; QCheckBox roomHistory; + QCheckBox ignoreAllPrivateMessagesCheckBox; QGroupBox *chatGroupBox; QGroupBox *highlightGroupBox; QGroupBox *messageGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 1ebdad277..07b724b52 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1284,7 +1284,21 @@ void TabSupervisor::processGameEventContainer(const GameEventContainer &cont) void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event) { + // "Ignore all private messages" silences every PM, including messages to + // already-open tabs — unlike the unregistered/non-buddy filters below, + // which only apply when creating a new tab. Messages from moderators/admins + // are exempt to ensure warnings still reach users. QString senderName = QString::fromStdString(event.sender_name()); + if (SettingsCache::instance().chat().getIgnoreAllPrivateMessages()) { + const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName); + if (!onlineUserInfo) { + return; + } + const UserLevelFlags userLevel(onlineUserInfo->user_level()); + if (!userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) { + return; + } + } TabMessage *tab = messageTabs.value(senderName); if (!tab) { tab = messageTabs.value(QString::fromStdString(event.receiver_name())); diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h index cd9ad29e1..cdf2da5eb 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -20,6 +20,7 @@ public: [[nodiscard]] virtual bool getShowMessagePopup() const = 0; [[nodiscard]] virtual bool getShowMentionPopup() const = 0; [[nodiscard]] virtual bool getRoomHistory() const = 0; + [[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0; [[nodiscard]] virtual QString getHighlightWords() const = 0; }; diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp index 5e1102473..974173371 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -65,6 +65,11 @@ bool ChatSettings::getRoomHistory() const return getValue("roomHistory", QString(), QString(), true).toBool(); } +bool ChatSettings::getIgnoreAllPrivateMessages() const +{ + return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool(); +} + QString ChatSettings::getHighlightWords() const { return getValue("highlightWords").toString(); @@ -131,6 +136,11 @@ void ChatSettings::setRoomHistory(bool _roomHistory) setValue(_roomHistory, "roomHistory"); } +void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages) +{ + setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages"); +} + void ChatSettings::setHighlightWords(const QString &_highlightWords) { setValue(_highlightWords, "highlightWords"); diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h index 7cf4be3f6..9566671e2 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -23,6 +23,7 @@ public: [[nodiscard]] bool getShowMessagePopup() const override; [[nodiscard]] bool getShowMentionPopup() const override; [[nodiscard]] bool getRoomHistory() const override; + [[nodiscard]] bool getIgnoreAllPrivateMessages() const override; [[nodiscard]] QString getHighlightWords() const override; void setChatMention(bool _chatMention); @@ -37,6 +38,7 @@ public: void setShowMessagePopups(bool _showMessagePopups); void setShowMentionPopups(bool _showMentionPopups); void setRoomHistory(bool _roomHistory); + void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages); void setHighlightWords(const QString &_highlightWords); signals: diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 139656f27..eda778ae9 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -300,6 +300,12 @@ TEST_F(SettingsDefaultsTest, Chat_RoomHistory_Default) ASSERT_EQ(s.getRoomHistory(), true); } +TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false); +} + // --- PersonalSettings --- TEST_F(SettingsDefaultsTest, Personal_Lang_Default) From 36f998e46663432abe0bd4c8d564f3ea5c5c31ec Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:21:26 +0200 Subject: [PATCH 12/24] [Client] Fix pawn avatar cache key collision for players without a custom avatar (#4086) (#7264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player pawns are looked up in QPixmapCache under a key built from the rendered size, user level, and the avatar pixmap's cacheKey(). A null pixmap reports cacheKey() 0, so all players without a custom avatar collided: the first pawn rendered for a given size and user level was reused for the next one, showing the wrong player's pawn. Extend the key with the rendered height, the lowercased privlevel (matching UserLevelPixmapGenerator), and both pawn colors so that every visually distinct pawn gets its own cache entry. Co-authored-by: Lukas Brübach --- .../src/game_graphics/player/player_target.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 63c060b02..d6c28370d 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -137,8 +137,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect); QSize translatedSize = translatedRect.size().toSize(); QPixmap cachedPixmap; + // The key must cover everything the generated pawn depends on: the rendered + // size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is + // 0 for every null pixmap, so the default-pawn branch additionally needs the + // pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors + // in the key — otherwise two players without a custom avatar (and the same + // user level) would share one cached pawn. const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" + - QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey()); + QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) + + "_" + QString::number(fullPixmap.cacheKey()) + "_" + + QString::fromStdString(info->privlevel()).toLower() + "_" + + QString::fromStdString(info->pawn_colors().left_side()) + "_" + + QString::fromStdString(info->pawn_colors().right_side()); if (!QPixmapCache::find(cacheKey, &cachedPixmap)) { cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height()); From ebeae4865261e1749f83945f5be660f2f11b8b33 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:21:44 +0200 Subject: [PATCH 13/24] [Game] Preserve hand card order when restoring connection (#7266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a player restores connection to a game, the client rebuilds each zone from the cards the server sends in the game state. Non-coordinate zones (hand, piles, stack) report x == 0 on every card, so inserting each rebuilt card at that coordinate reversed the received order one card at a time. Append rebuilt cards in the order they arrive for zones without coordinates; coordinate-based zones (table) keep using x/y. Fixes #2759 Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_logic.cpp | 10 ++++++- .../card_zone_algorithms_test.cpp | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 45ba09aac..143df5c57 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -175,7 +175,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); auto *card = new CardItem(this); card->processCardInfo(cardInfo); - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + // Zones without coordinates (hand, piles, stack) preserve the order + // they arrive in on the server in the positions of their cards list. + // The x coordinate of such cards is always 0, so inserting at it + // would reverse the list on reconnect. Append instead. + if (zoneInfo.with_coords()) { + zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + } else { + zone->addCard(card, false, -1); + } } } if (zoneInfo.has_always_reveal_top_card()) { diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index cc098cae9..8fab42566 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -134,6 +134,35 @@ TEST_F(AddCardAlgorithmTest, MidListInsertionPreservesOrder) EXPECT_EQ(knownList.at(2), &b); } +// Reconnecting to a game rebuilds zones from a ServerInfo_Zone. Non-coordinate zones +// (hand, piles, stack) report x == 0 on every card, so inserting each rebuilt card at +// that index would reverse the received server order. Appending (-1) keeps it. +TEST_F(AddCardAlgorithmTest, RebuildInsertAtZeroReversesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &b, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &c, 0, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &c); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &a); +} + +TEST_F(AddCardAlgorithmTest, RebuildAppendPreservesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &b, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &c, -1, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &a); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &c); +} + TEST_F(AddCardAlgorithmTest, KeepAnnotationsFalsePassedThrough) { MockCard card; From 6e5c58069bf1b7a8d96f45b64f9a298e3a1b9a6f Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:52:06 -0700 Subject: [PATCH 14/24] [Settings] Make chat settings page scrollable (#7271) --- cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index 883cfcd03..4698b011f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -132,7 +132,7 @@ void DlgSettings::setupUi() pagesWidget->addWidget(makeScrollable(userInterfacePage)); pagesWidget->addWidget(makeScrollable(deckEditorPage)); pagesWidget->addWidget(makeScrollable(storagePage)); - pagesWidget->addWidget(messagesPage); + pagesWidget->addWidget(makeScrollable(messagesPage)); pagesWidget->addWidget(soundPage); pagesWidget->addWidget(shortcutsPage); From 030e4f58712176b1d384fb45abf964eac2fe1855 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:37:13 +0200 Subject: [PATCH 15/24] [Game] Stop duplicate attach log entries (#7273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix duplicate attach messages in the game log caused by a redundant child arrow in CardItem::drawAttachArrow. Unlike the sibling drawArrow, drawAttachArrow omitted the ``card == this`` guard when iterating selectedItems(). Because right-clicking a card to open the attach menu selects that card, it was always present in selectedItems(), producing a second arrow for the same source card. On release both arrows sent an identical Command_AttachCard, so the server broadcast two Event_AttachCard messages and the log rendered "attaches to" twice. Mirror the drawArrow skip condition so the active card is excluded and only one attach command is sent. Co-authored-by: Lukas Brübach --- cockatrice/src/game_graphics/board/card_item.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index c40c8c214..c2dc455cc 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -316,7 +316,7 @@ void CardItem::drawAttachArrow() for (const auto &item : scene()->selectedItems()) { CardItem *card = qgraphicsitem_cast(item); - if (card == nullptr) { + if (card == nullptr || card == this) { continue; } if (card->getZone() != state->getZone()) { From df3defa890f7bae1a39cd0a8dcf120a82d6705e5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:37:19 +0200 Subject: [PATCH 16/24] [PictureLoader] Leave failed pixmap null so solid color is shown instead. (#7274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/interface/card_picture_loader/card_picture_loader.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 2f46e7941..7daafb610 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -138,7 +138,8 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize QPixmap bigPixmap; if (QPixmapCache::find(key, &bigPixmap)) { if (bigPixmap.isNull()) { - getCardBackLoadingFailedPixmap(pixmap, size); + // Leave the pixmap null so callers fall back to a solid color + // instead of showing the card back. QDateTime failedAtTime = getInstance().failedAt.value(key); if (!failedAtTime.isValid() || failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) { From 69e8f80fa1d223e4a0d4a3bc308865544eb1cc78 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:22:53 +0200 Subject: [PATCH 17/24] [Oracle] Prefer higher-priority maintype when merging split cards (#7272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Oracle] Prefer higher-priority maintype when merging split cards Adventure cards (e.g. Bonecrusher Giant, Virtue of Knowledge) are stored as split cards in MTGJSON, with a Creature/permanent face and an Instant/Sorcery adventure face. When the two faces are merged into a single card, the code previously discarded the second face's maintype entirely, keeping whichever face was processed first. If the Instant/Sorcery adventure face appeared first, the merged card got maintype 'Instant' with tableRow 3. At runtime this made double-clicking the card on the stack send it to the graveyard instead of the table (PlayerActions::playCard). Fix the merge to follow the same priority order used by getMainCardType() (Planeswalker > Creature > Land > Sorcery > Instant > Artifact > Enchantment), so the permanent Creature type wins for adventure cards regardless of face order, matching the physical card and the reported expectations. Closes #4394 * Extract helper. * Simplify const --------- Co-authored-by: Lukas Brübach --- oracle/src/oracleimporter.cpp | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index d745b250c..6087ece58 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -96,16 +96,30 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) return true; } +/** + * The priority order used to pick a card's main type when a card has multiple + * types (e.g. "Artifact Creature") or multiple faces (e.g. split/adventure cards). + * A lower index means a higher priority. + */ +static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery", + "Instant", "Artifact", "Enchantment"}; + +/** + * Returns the priority (index) of the given main card type. Known types map to their + * position in {@link mainCardTypePriority()}, unknown types map to -1 (lowest priority). + */ +static int mainCardTypePriority(const QString &mainCardType) +{ + return MAIN_CARD_TYPE_PRIORITY.indexOf(mainCardType); +} + static QString getMainCardType(const QStringList &typeList) { if (typeList.isEmpty()) { return {}; } - static const QStringList typePriority = {"Planeswalker", "Creature", "Land", "Sorcery", - "Instant", "Artifact", "Enchantment"}; - - for (const auto &type : typePriority) { + for (const auto &type : MAIN_CARD_TYPE_PRIORITY) { if (typeList.contains(type)) { return type; } @@ -460,8 +474,15 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert(prop, thisCardPropertyValue); } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); - } else if (prop == "maintype") { // don't create maintypes with //es in them - continue; + } else if (prop == "maintype") { + // Use the same priority as getMainCardType() to pick the + // "best" type across faces — e.g. Creature over Instant + // for adventure cards like Bonecrusher Giant. + int currentPriority = mainCardTypePriority(originalPropertyValue); + int newPriority = mainCardTypePriority(thisCardPropertyValue); + if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { + properties.insert(prop, thisCardPropertyValue); + } } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); From 7d867b9745909381cfffb1267532369fb365aeaa Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:27:16 +0200 Subject: [PATCH 18/24] [Oracle/Client] Report card database download progress (#7253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Oracle/Client] Report card database download progress Card database updates ran invisibly: MTGJSON parsing spun an indeterminate bar on the UI thread and the set import blocked the window, while the onboarding wizard spawned `oracle -b` with no progress to show at all. - Add byte-level scan progress to `RawJson::scanSetRanges` via an optional callback, throttled to ~100 reports per scan. - Emit `OracleImporter::dataReadProgress` during the scan and import sets on a worker thread, driving the wizard's progress bar per set. - With `-b`, write machine-readable `PROGRESS ` lines to stdout for the download/scan/import stages; stderr keeps the log output. - Parse the oracle stdout in `MainWindow` and forward it to the onboarding wizard, giving the card database step a determinate bar with stage-specific status text. - Guard the async workers against the wizard being closed mid-run. - Add Google Test coverage for scan progress reporting. * [Oracle/Client] Harden oracle progress workers and quit prompt Address review feedback on the download-progress change: decompress and read sets files off the UI thread, cancel the load/import workers before the wizard can tear down the importer, and show an 'Extracting file...' status plus a clean 100% tail so the poll never looks stuck. Quitting Cockatrice while a card database update runs now asks for confirmation. * Show 100% for 500ms on complete. * Disable buttons on set import until done. * Clean up progress bar. * Drop wrapper around lambda --------- Co-authored-by: Lukas Brübach --- .../widgets/onboarding/first_run_wizard.cpp | 7 + .../widgets/onboarding/first_run_wizard.h | 3 + .../pages/card_database_setup_page.cpp | 20 + .../pages/card_database_setup_page.h | 1 + cockatrice/src/interface/window_main.cpp | 48 ++ cockatrice/src/interface/window_main.h | 7 + oracle/src/oracleimporter.cpp | 18 +- oracle/src/oracleimporter.h | 39 ++ oracle/src/oraclewizard.cpp | 18 + oracle/src/oraclewizard.h | 8 +- oracle/src/pages.cpp | 476 +++++++++++++----- oracle/src/pages.h | 35 +- oracle/src/pagetemplates.h | 8 + oracle/src/raw_json_scanner.cpp | 184 ++++--- oracle/src/raw_json_scanner.h | 15 +- tests/oracle/oracle_importer_test.cpp | 131 +++++ 16 files changed, 821 insertions(+), 197 deletions(-) diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 618ac6f26..4af02fe4f 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -182,6 +182,13 @@ void FirstRunWizard::onCardDatabaseUpdateFinished(bool success) } } +void FirstRunWizard::onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateProgress(stage, done, total); + } +} + void FirstRunWizard::finish() { accept(); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h index 2c186ef95..21d7b6e06 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -37,6 +37,9 @@ public slots: /** @brief Forwarded from MainWindow once the background card database update process exits. */ void onCardDatabaseUpdateFinished(bool success); + /** @brief Forwarded from MainWindow while the background card database update process runs. */ + void onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp index 12116de7a..50e8ff63d 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -179,6 +180,25 @@ void CardDatabaseSetupPage::onUpdateFinished(bool success) } } +void CardDatabaseSetupPage::onUpdateProgress(const QString &stage, qint64 done, qint64 total) +{ + if (state != State::Running) { + return; + } + progressBar->setRange(0, total > 0 ? static_cast(qMin(total, INT_MAX)) : 0); + progressBar->setValue(static_cast(qMin(done, INT_MAX))); + if (total > 0) { + const int percent = static_cast((100.0 * done) / total); + if (stage == QLatin1String("download")) { + statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("scan")) { + statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("import")) { + statusLabel->setText(tr("Importing cards (%1%)…").arg(percent)); + } + } +} + QString CardDatabaseSetupPage::nextButtonText() const { return state == State::NotStarted ? tr("Download") : QString(); diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h index 0461d11d5..870e759ea 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -30,6 +30,7 @@ public: void retranslateUi() override; void onUpdateFinished(bool success); + void onUpdateProgress(const QString &stage, qint64 done, qint64 total); signals: void updateRequested(); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4567991c8..43f1de9dd 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -682,6 +682,7 @@ void MainWindow::runFirstRunWizard() connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground); connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates); connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished); + connect(this, &MainWindow::cardDatabaseUpdateProgress, wizard, &FirstRunWizard::onCardDatabaseUpdateProgress); connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); @@ -843,6 +844,17 @@ void MainWindow::closeEvent(QCloseEvent *event) } bClosingDown = true; + if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { + if (QMessageBox::question(this, tr("Are you sure?"), + tr("A card database update is still running. Quitting now will cancel it.\n" + "Are you sure you want to quit?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + event->ignore(); + bClosingDown = false; + return; + } + } + if (!tabSupervisor->close()) { event->ignore(); bClosingDown = false; @@ -1057,11 +1069,45 @@ void MainWindow::createCardUpdateProcess(bool background) if (!background) { cardUpdateProcess->start(updaterCmd, QStringList()); } else { + cardUpdateOutputBuffer.clear(); + connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput); cardUpdateProcess->start(updaterCmd, QStringList("-b")); statusBar()->showMessage(tr("Card database update running.")); } } +void MainWindow::cardUpdateProgressOutput() +{ + if (!cardUpdateProcess) { + return; + } + cardUpdateOutputBuffer.append(cardUpdateProcess->readAllStandardOutput()); + while (true) { + const int newline = cardUpdateOutputBuffer.indexOf('\n'); + if (newline < 0) { + break; + } + const QByteArray line = cardUpdateOutputBuffer.left(newline).trimmed(); + cardUpdateOutputBuffer.remove(0, newline + 1); + // Protocol emitted by `oracle -b`: "PROGRESS " + if (!line.startsWith("PROGRESS ")) { + continue; + } + const QList parts = line.split(' '); + if (parts.size() != 4) { + continue; + } + bool doneOk = false; + bool totalOk = false; + const qint64 done = parts.at(2).toLongLong(&doneOk); + const qint64 total = parts.at(3).toLongLong(&totalOk); + if (!doneOk || !totalOk || done < 0 || total < 0) { + continue; + } + emit cardDatabaseUpdateProgress(QString::fromLatin1(parts.at(1)), done, total); + } +} + void MainWindow::exitCardDatabaseUpdate() { if (!cardUpdateProcess) { @@ -1109,6 +1155,8 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus) { + cardUpdateProgressOutput(); // drain any progress lines not yet parsed + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 920145552..08481fd36 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -68,6 +68,11 @@ signals: /** @brief Emitted after the background card-database update subprocess exits. */ void cardDatabaseUpdateFinished(bool success); + /** @brief Emitted while the background card-database update subprocess runs. + * @p stage is one of "download", "scan" or "import"; @p done/@p total + * are byte counts for the first two stages and set indices for "import". */ + void cardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -96,6 +101,7 @@ private slots: void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); + void cardUpdateProgressOutput(); void refreshShortcuts(); void cardDatabaseLoadingFailed(); void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames); @@ -159,6 +165,7 @@ private: LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; QProcess *cardUpdateProcess; + QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; GameReplay *replay; DlgTipOfTheDay *tip; diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 6087ece58..07aee0185 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -47,8 +47,16 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s bool OracleImporter::readSetsFromByteArray(QByteArray data) { + const RawJson::ScanProgressCallback progress = + progressReporting + ? [this]( + qsizetype bytesRead, + qsizetype + totalBytes) { emit dataReadProgress(static_cast(bytesRead), static_cast(totalBytes)); } + : RawJson::ScanProgressCallback{}; + RawJson::ScanError error; - const QList ranges = RawJson::scanSetRanges(data, &error); + const QList ranges = RawJson::scanSetRanges(data, &error, progress); if (error.isError()) { qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; @@ -572,6 +580,8 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); + importCancelled.storeRelease(0); + // Pre-allocate the cards hash to avoid rehashing during import. Keys are // distinct card names while raw ranges only count printings (AllPrintings // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact @@ -591,6 +601,12 @@ int OracleImporter::startImport() int setIndex = 0; for (const SetToDownload &curSetToParse : allSets) { + if (importCancelled.loadAcquire()) { + // The wizard was closed mid-import: stop at the next set boundary so + // the caller can wait for this future without processing every set. + break; + } + CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 8cb30ca40..748056772 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -3,6 +3,7 @@ #include "raw_json_scanner.h" +#include #include #include #include @@ -155,6 +156,21 @@ private: */ QByteArray rawSetsData; + /** + * Whether readSetsFromByteArray() should report scan progress via + * dataReadProgress. A background run routes that signal to stdout (for the + * hosting Cockatrice client to parse); the flag exists to skip the scanner + * instrumentation entirely when no consumer needs it. + */ + bool progressReporting = true; + + /** + * Atomic "please stop importing" flag. startImport() checks it between sets + * so a wizard being closed mid-import can be torn down without waiting for + * the whole import (or racing it). + */ + QAtomicInt importCancelled; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, @@ -167,12 +183,35 @@ signals: public: explicit OracleImporter(QObject *parent = nullptr); + /** + * @brief Controls whether readSetsFromByteArray() instruments the raw scan. + * + * When enabled (the default) the raw scanner reports progress via + * dataReadProgress(), which an interactive wizard shows on its progress bar + * and a background run routes to stdout for the hosting client. Switch it + * off only when nothing will consume scan progress. + */ + void setProgressReporting(bool enabled) + { + progressReporting = enabled; + } /** * Scans the given JSON document for set metadata. Takes the data by value so * the wizard can hand over its decompressed buffer without copying it. */ bool readSetsFromByteArray(QByteArray data); int startImport(); + /** + * @brief Requests an in-flight startImport() to stop at the next set boundary. + * + * Works by setting an atomic flag that startImport() polls between sets, so + * cancelImport() followed by a short waitForFinished() on the running future is + * safe the moment the wizard is about to be destroyed. + */ + void cancelImport() + { + importCancelled.storeRelease(1); + } bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); /** diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 11200dfc5..dd3ac6459 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -110,6 +110,24 @@ void OracleWizard::accept() QDialog::accept(); } +void OracleWizard::reject() +{ + // The wizard is being closed while a page may still run a worker on the + // importer. Ask it to stop before the wizard (and the importer child) is + // destroyed, so the worker thread never touches freed memory. + if (auto *active = dynamic_cast(currentPage())) { + active->cancelWork(); + } + QWizard::reject(); +} + +void OracleWizard::runInBackground() +{ + backgroundMode = true; + hide(); + currentPage()->initializePage(); +} + void OracleWizard::enableButtons() { button(QWizard::NextButton)->setDisabled(false); diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index 78427175c..9a509ce5e 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -23,6 +23,7 @@ class OracleWizard : public QWizard public: explicit OracleWizard(QWidget *parent = nullptr); void accept() override; + void reject() override; void enableButtons(); void disableButtons(); void retranslateUi(); @@ -52,12 +53,7 @@ public: } bool saveTokensToFile(const QString &fileName); - void runInBackground() - { - backgroundMode = true; - hide(); - currentPage()->initializePage(); - } + void runInBackground(); public: OracleImporter *importer; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 1868e670e..a10daadb3 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,14 +21,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include #include #ifdef HAS_LZMA @@ -53,6 +57,115 @@ #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json" #endif +/** + * @brief Emits one machine-readable background-run progress line to stdout. + * + * Used only in background mode, so the hosting Cockatrice client can parse these + * lines to drive a determinate progress bar. stderr stays reserved for + * human-readable log output. + */ +static void emitBackgroundProgress(const char *stage, qint64 done, qint64 total) +{ + QTextStream out(stdout); + out << "PROGRESS " << stage << ' ' << done << ' ' << total << '\n'; + out.flush(); +} + +namespace +{ + +/** + * @brief Decompresses and dispatches a sets-file payload on a worker thread. + * + * Iteratively unwraps xz/zip compression, then either hands the JSON to the + * importer (which reports scan progress via dataReadProgress) or returns the raw + * XML for the plain-XML path. Must never touch the wizard or the page: the caller + * consumes the returned LoadSetsResult on the UI thread in importFinished(). + */ +LoadSetsResult loadSetsData(const QPointer &importer, QByteArray data) +{ + LoadSetsResult result; + + while (true) { + if (data.startsWith(XZ_SIGNATURE)) { +#ifdef HAS_LZMA + QBuffer inBuffer(&data); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + inBuffer.open(QBuffer::ReadOnly); + outBuffer.open(QBuffer::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + result.errorMessage = LoadSetsPage::tr("Xz extraction failed."); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = + LoadSetsPage::tr("Sorry, this version of Oracle does not support xz compressed files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + + if (data.startsWith(ZIP_SIGNATURE)) { +#ifdef HAS_ZLIB + QBuffer inBuffer(&data); + UnZip uz; + const UnZip::ErrorCode openEc = uz.openArchive(&inBuffer); + if (openEc != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Failed to open Zip archive: %1.").arg(uz.formatError(openEc)); + result.offerUncompressedFallback = true; + return result; + } + if (uz.fileList().size() != 1) { + result.errorMessage = + LoadSetsPage::tr("Zip extraction failed: the Zip archive doesn't contain exactly one file."); + result.offerUncompressedFallback = true; + return result; + } + const QString fileName = uz.fileList().at(0); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + outBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode ec = uz.extractFile(fileName, &outBuffer); + uz.closeArchive(); + if (ec != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Zip extraction failed: %1.").arg(uz.formatError(ec)); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = LoadSetsPage::tr("Sorry, this version of Oracle does not support zipped files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + break; + } + + if (data.startsWith("<")) { + result.ok = true; + result.plainXml = true; + result.xmlData = std::move(data); + return result; + } + + if (data.startsWith("{")) { + result.ok = importer && importer->readSetsFromByteArray(std::move(data)); + return result; + } + + result.errorMessage = LoadSetsPage::tr("Failed to interpret downloaded data."); + return result; +} + +} // namespace + #define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml" #define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" @@ -182,6 +295,11 @@ LoadSetsPage::LoadSetsPage(QWidget *parent) : OracleWizardPage(parent) setLayout(layout); } +bool LoadSetsPage::isComplete() const +{ + return !loadActive; +} + void LoadSetsPage::initializePage() { urlLineEdit->setText(wizard()->settings->value("allsetsurl", ALLSETS_URL).toString()); @@ -279,18 +397,13 @@ bool LoadSetsPage::validatePage() return false; } - if (!setsFile.open(QIODevice::ReadOnly)) { - QMessageBox::critical(nullptr, tr("Error"), tr("Cannot open file '%1'.").arg(fileLineEdit->text())); - return false; - } - wizard()->disableButtons(); setEnabled(false); wizard()->setCardSourceUrl(setsFile.fileName()); wizard()->setCardSourceVersion("unknown"); - readSetsFromByteArray(setsFile.readAll()); + readSetsFromFile(setsFile.fileName()); } return false; @@ -339,6 +452,9 @@ void LoadSetsPage::downloadSetsFile(const QUrl &url) void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total) { + if (wizard()->backgroundMode) { + emitBackgroundProgress("download", received, total); + } if (total > 0) { progressBar->setMaximum(static_cast(total)); progressBar->setValue(static_cast(received)); @@ -384,106 +500,97 @@ void LoadSetsPage::actDownloadFinishedSetsFile() reply->deleteLater(); } -void LoadSetsPage::readSetsFromByteArray(QByteArray _data) +void LoadSetsPage::updateParsingProgress(int bytesRead, int totalBytes) { - // show an infinite progressbar + if (totalBytes <= 0) { + return; + } + progressBar->setRange(0, totalBytes); + progressBar->setValue(bytesRead); + const int percent = static_cast((100.0 * bytesRead) / totalBytes); + progressLabel->setText(tr("Parsing file (%1%)").arg(percent)); +} + +void LoadSetsPage::scanProgressToStdout(int bytesRead, int totalBytes) +{ + emitBackgroundProgress("scan", bytesRead, totalBytes); +} + +void LoadSetsPage::beginLoadSets(bool compressedFile) +{ + // Show an infinite progressbar while the worker decompresses; the scan + // steals the label via dataReadProgress as soon as it starts. progressBar->setMaximum(0); progressBar->setMinimum(0); progressBar->setValue(0); - progressLabel->setText(tr("Parsing file")); + progressLabel->setText(compressedFile ? tr("Extracting file...") : tr("Parsing file")); progressLabel->show(); progressBar->show(); + // Keep Next disabled (via completeChanged) until the worker reports in; + // updateButtonStates() re-evaluates button state whenever we re-enable. + loadActive = true; + emit completeChanged(); + wizard()->downloadedPlainXml = false; wizard()->xmlData.clear(); - readSetsFromByteArrayRef(_data); + + if (wizard()->backgroundMode) { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::scanProgressToStdout, + Qt::UniqueConnection); + } else { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::updateParsingProgress, + Qt::UniqueConnection); + } } -void LoadSetsPage::readSetsFromByteArrayRef(QByteArray &_data) +void LoadSetsPage::readSetsFromByteArray(QByteArray _data) { - // unzip the file if needed - if (_data.startsWith(XZ_SIGNATURE)) { -#ifdef HAS_LZMA - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - inBuffer->open(QBuffer::ReadOnly); - outBuffer->open(QBuffer::WriteOnly); - XzDecompressor xz; - if (!xz.decompress(inBuffer, outBuffer)) { - zipDownloadFailed(tr("Xz extraction failed.")); - return; + const bool compressed = _data.startsWith(XZ_SIGNATURE) || _data.startsWith(ZIP_SIGNATURE); + beginLoadSets(compressed); + + // Decompress and scan off the UI thread so a large download can't freeze the window. + const QPointer importer = wizard()->importer; + future = QtConcurrent::run( + [importer, data = std::move(_data)]() mutable { return loadSetsData(importer, std::move(data)); }); + watcher.setFuture(future); +} + +void LoadSetsPage::readSetsFromFile(const QString &fileName) +{ + // Peek at the header on the UI thread so the status text can distinguish + // "Extracting file..." from a plain JSON parse; the full read happens in the worker. + QFile headerFile(fileName); + bool compressed = false; + if (headerFile.open(QIODevice::ReadOnly)) { + const QByteArray header = headerFile.read(6); + compressed = header.startsWith(XZ_SIGNATURE) || header.startsWith(ZIP_SIGNATURE); + } + beginLoadSets(compressed); + + // Read, decompress and scan off the UI thread (a plain JSON can be hundreds + // of MB, so even the read itself must not block the window). + const QPointer importer = wizard()->importer; + future = QtConcurrent::run([importer, fileName]() mutable -> LoadSetsResult { + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + LoadSetsResult readError; + readError.errorMessage = LoadSetsPage::tr("Cannot open file '%1'.").arg(fileName); + return readError; } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support xz compressed files.")); + return loadSetsData(importer, file.readAll()); + }); + watcher.setFuture(future); +} - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith(ZIP_SIGNATURE)) { -#ifdef HAS_ZLIB - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - QString fileName; - UnZip::ErrorCode ec; - UnZip uz; - - ec = uz.openArchive(inBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Failed to open Zip archive: %1.").arg(uz.formatError(ec))); - return; - } - - if (uz.fileList().size() != 1) { - zipDownloadFailed(tr("Zip extraction failed: the Zip archive doesn't contain exactly one file.")); - return; - } - fileName = uz.fileList().at(0); - - outBuffer->open(QBuffer::ReadWrite); - ec = uz.extractFile(fileName, outBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Zip extraction failed: %1.").arg(uz.formatError(ec))); - uz.closeArchive(); - return; - } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support zipped files.")); - - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith("{")) { - // Start the computation. - jsonData = std::move(_data); - future = QtConcurrent::run([this] { return wizard()->importer->readSetsFromByteArray(std::move(jsonData)); }); - watcher.setFuture(future); - } else if (_data.startsWith("<")) { - // save xml file and don't do any processing - wizard()->downloadedPlainXml = true; - wizard()->xmlData = std::move(_data); - importFinished(); - } else { - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - QMessageBox::critical(this, tr("Error"), tr("Failed to interpret downloaded data.")); +void LoadSetsPage::cancelWork() +{ + // The scan is short-lived; just wait it out before the wizard (and its + // importer) can be torn down underneath the worker thread. + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); } } @@ -509,17 +616,65 @@ void LoadSetsPage::zipDownloadFailed(const QString &message) void LoadSetsPage::importFinished() { + loadActive = false; + emit completeChanged(); wizard()->enableButtons(); setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - if (wizard()->downloadedPlainXml || watcher.future().result()) { - wizard()->next(); - } else { - QMessageBox::critical(this, tr("Error"), - tr("The file was retrieved successfully, but it does not contain any sets data.")); + const LoadSetsResult result = watcher.result(); + + if (result.plainXml) { + wizard()->downloadedPlainXml = true; + wizard()->xmlData = result.xmlData; } + + if (wizard()->backgroundMode) { + progressLabel->hide(); + progressBar->hide(); + if (!result.errorMessage.isEmpty()) { + qWarning() << result.errorMessage; + } else if (!result.ok && !result.plainXml) { + qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); + } + emit readyToContinue(); + return; + } + + const auto fail = [this](const QString &message) { + progressLabel->hide(); + progressBar->hide(); + QMessageBox::critical(this, tr("Error"), message); + }; + + if (!result.errorMessage.isEmpty()) { + if (result.offerUncompressedFallback) { + zipDownloadFailed(result.errorMessage); + return; + } + fail(result.errorMessage); + return; + } + + if (!result.ok && !result.plainXml) { + fail(tr("The file was retrieved successfully, but it does not contain any sets data.")); + return; + } + + // Snap the bar to 100% and hold it there for a moment so the completed state + // is actually visible before the next page's own import progress takes over + // (a zero-length deferral can still fire before the repaint is delivered). + progressBar->setMaximum(1); + progressBar->setValue(1); + progressLabel->setText(tr("Parsing file (100%)")); + QTimer::singleShot(500, this, [this] { + if (wizard()->currentPage() == this) { + // Leave the page pristine: hide the completed load bar so a later + // Back from the save page doesn't show stale progress. + progressLabel->hide(); + progressBar->hide(); + wizard()->next(); + } + }); } SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) @@ -527,49 +682,113 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) pathLabel = new QLabel(this); saveLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressBar->hide(); + defaultPathCheckBox = new QCheckBox(this); messageLog = new QTextEdit(this); messageLog->setReadOnly(true); auto *layout = new QGridLayout(this); - layout->addWidget(messageLog, 0, 0); - layout->addWidget(saveLabel, 1, 0); - layout->addWidget(pathLabel, 2, 0); - layout->addWidget(defaultPathCheckBox, 3, 0); + layout->addWidget(progressBar, 0, 0); + layout->addWidget(messageLog, 1, 0); + layout->addWidget(saveLabel, 2, 0); + layout->addWidget(pathLabel, 3, 0); + layout->addWidget(defaultPathCheckBox, 4, 0); setLayout(layout); } +bool SaveSetsPage::isComplete() const +{ + return !importActive; +} + void SaveSetsPage::cleanupPage() { + cancelWork(); + disconnect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); + disconnect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished); wizard()->importer->clear(); - disconnect(wizard()->importer, &OracleImporter::setIndexChanged, nullptr, nullptr); } void SaveSetsPage::initializePage() { - messageLog->clear(); - retranslateUi(); if (wizard()->downloadedPlainXml) { messageLog->hide(); - } else { - messageLog->show(); - connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); - - int setsImported = wizard()->importer->startImport(); - - // JSON data no longer needed after CardInfo objects are built - wizard()->importer->releaseSetData(); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + progressBar->hide(); + if (wizard()->backgroundMode) { + emit readyToContinue(); } + return; + } + + messageLog->clear(); + messageLog->show(); + progressBar->show(); + + totalSets = wizard()->importer->getSets().size(); + progressBar->setRange(0, totalSets); + progressBar->setValue(0); + + connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress, + Qt::UniqueConnection); + connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + + wizard()->disableButtons(); + importActive = true; + emit completeChanged(); + + const QPointer importer = wizard()->importer; + importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); + importWatcher.setFuture(importFuture); +} + +void SaveSetsPage::cancelWork() +{ + if (!importActive) { + return; + } + // Ask the worker to stop at the next set boundary, then wait it out so the + // wizard (and the importer it owns) is never torn down under a running thread. + importActive = false; + emit completeChanged(); + wizard()->importer->cancelImport(); + importFuture.cancel(); + importWatcher.cancel(); + importFuture.waitForFinished(); +} + +void SaveSetsPage::importFinished() +{ + if (!importActive) { + return; + } + importActive = false; + emit completeChanged(); + + wizard()->enableButtons(); + + const int setsImported = importWatcher.result(); + const QPointer importer = wizard()->importer; + if (importer) { + importer->releaseSetData(); } if (wizard()->backgroundMode) { + if (setsImported == 0) { + qWarning() << tr("No set has been imported."); + } emit readyToContinue(); + return; + } + + progressBar->setValue(progressBar->maximum()); + + if (setsImported == 0) { + QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } } @@ -591,13 +810,28 @@ void SaveSetsPage::retranslateUi() setButtonText(QWizard::NextButton, tr("&Save")); } -void SaveSetsPage::updateTotalProgress(int cardsImported, int /* setIndex */, const QString &setName) +void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QString &setName) { + if (!importActive) { + return; + } if (setName.isEmpty()) { - messageLog->append("" + tr("Import finished: %1 cards.").arg(wizard()->importer->getCardList().size()) + - ""); + progressBar->setValue(progressBar->maximum()); + const int cardCount = wizard()->importer->getCardList().size(); + if (wizard()->backgroundMode) { + qInfo() << tr("Import finished: %1 cards.").arg(cardCount); + emitBackgroundProgress("import", totalSets, totalSets); + } else { + messageLog->append("" + tr("Import finished: %1 cards.").arg(cardCount) + ""); + } } else { - messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + progressBar->setValue(setIndex); + if (wizard()->backgroundMode) { + qInfo() << tr("%1: %2 cards imported").arg(setName).arg(cardsImported); + emitBackgroundProgress("import", setIndex, totalSets); + } else { + messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + } } messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum()); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 066cc2e1b..d0a9fcd43 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -3,8 +3,10 @@ #include "pagetemplates.h" +#include #include #include +#include #include #include #include @@ -57,19 +59,31 @@ protected: void initializePage() override; }; +/** @brief Result of a worker-thread sets-file load (read + decompress + dispatch). */ +struct LoadSetsResult +{ + bool ok = false; ///< JSON scan produced set data (or plain XML was handled) + bool plainXml = false; ///< input was a plain Cockatrice XML database + QByteArray xmlData; ///< raw XML for the plain-XML path + QString errorMessage; ///< set when the input could not be processed + bool offerUncompressedFallback = false; ///< decompression-only failure: offer the uncompressed URL +}; + class LoadSetsPage : public OracleWizardPage { Q_OBJECT public: explicit LoadSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; protected: void initializePage() override; bool validatePage() override; void readSetsFromByteArray(QByteArray _data); - void readSetsFromByteArrayRef(QByteArray &_data); + void readSetsFromFile(const QString &fileName); void downloadSetsFile(const QUrl &url); + void cancelWork() override; private: QRadioButton *urlRadioButton; @@ -81,15 +95,19 @@ private: QLabel *progressLabel; QProgressBar *progressBar; - QFutureWatcher watcher; - QFuture future; - QByteArray jsonData; + QFutureWatcher watcher; + QFuture future; + bool loadActive = false; + + void beginLoadSets(bool compressedFile = false); private slots: void actLoadSetsFile(); void actRestoreDefaultUrl(); void actDownloadProgressSetsFile(qint64 received, qint64 total); void actDownloadFinishedSetsFile(); + void updateParsingProgress(int bytesRead, int totalBytes); + void scanProgressToStdout(int bytesRead, int totalBytes); void importFinished(); void zipDownloadFailed(const QString &message); }; @@ -100,19 +118,28 @@ class SaveSetsPage : public OracleWizardPage public: explicit SaveSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; private: QTextEdit *messageLog; + QProgressBar *progressBar; QCheckBox *defaultPathCheckBox; QLabel *pathLabel; QLabel *saveLabel; + QFutureWatcher importWatcher; + QFuture importFuture; + int totalSets = 0; + bool importActive = false; + protected: void initializePage() override; void cleanupPage() override; bool validatePage() override; + void cancelWork() override; private slots: + void importFinished(); void updateTotalProgress(int cardsImported, int setIndex, const QString &setName); }; diff --git a/oracle/src/pagetemplates.h b/oracle/src/pagetemplates.h index 6e79c867e..ccad7ec62 100644 --- a/oracle/src/pagetemplates.h +++ b/oracle/src/pagetemplates.h @@ -20,6 +20,14 @@ public: } virtual void retranslateUi() = 0; + /** + * @brief Asks an active page to stop any background worker before the wizard + * (and its importer) can be torn down underneath it. Default is a no-op. + */ + virtual void cancelWork() + { + } + signals: void readyToContinue(); diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp index db972491c..8c4633598 100644 --- a/oracle/src/raw_json_scanner.cpp +++ b/oracle/src/raw_json_scanner.cpp @@ -1,5 +1,6 @@ #include "raw_json_scanner.h" +#include #include namespace @@ -11,6 +12,46 @@ namespace // reason and reports DeepNesting). constexpr int kMaxNestingDepth = 1024; +/** + * @brief Throttled byte-position reporting for scanSetRanges(). + * + * Threaded through the skip walk so progress can be reported without materializing + * the whole document. Reports are rate-limited so a GUI showing progress isn't + * flooded with interrupts: a callback is invoked at most ~100 times per scan + * regardless of element count. The closing stretch (the last ~1%) is reported + * more finely so a large document doesn't stall the progress bar on the final + * percent before the scan wraps up. + */ +struct ScanProgress +{ + const char *begin = nullptr; + qsizetype size = 0; + RawJson::ScanProgressCallback callback; + qsizetype step = 1; + qsizetype lastReported = 0; + + /** + * @brief Reports the scanner's absolute offset, unless within @p step bytes + * of the previous report and not yet at the end of the document. + */ + void report(const char *p) + { + if (!callback) { + return; + } + const qsizetype offset = p - begin; + if (offset == lastReported) { + return; // the final element often already sits exactly at the end + } + const qsizetype reportingStep = offset >= size - step ? std::max(1, step / 16) : step; + if (offset - lastReported < reportingStep && offset < size) { + return; + } + lastReported = offset; + callback(offset, size); + } +}; + inline bool isWhitespace(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; @@ -296,9 +337,9 @@ bool skipNumber(const char *&p, const char *end) return true; } -bool skipValue(const char *&p, const char *end, int depth); -bool skipObject(const char *&p, const char *end, int depth); -bool skipArray(const char *&p, const char *end, int depth); +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan); bool skipPrimitive(const char *&p, const char *end) { @@ -324,7 +365,7 @@ bool skipPrimitive(const char *&p, const char *end) return false; } -bool skipObject(const char *&p, const char *end, int depth) +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan) { if (depth <= 0) { return false; // nest deeper than the cap @@ -348,9 +389,10 @@ bool skipObject(const char *&p, const char *end, int depth) return false; } ++p; - if (!skipValue(p, end, depth - 1)) { + if (!skipValue(p, end, depth - 1, scan)) { return false; } + scan.report(p); p = skipWhitespace(p, end); if (p >= end) { return false; @@ -367,7 +409,7 @@ bool skipObject(const char *&p, const char *end, int depth) } } -bool skipArray(const char *&p, const char *end, int depth) +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan) { if (depth <= 0) { return false; // nest deeper than the cap @@ -379,9 +421,10 @@ bool skipArray(const char *&p, const char *end, int depth) return true; } for (;;) { - if (!skipValue(p, end, depth - 1)) { + if (!skipValue(p, end, depth - 1, scan)) { return false; } + scan.report(p); p = skipWhitespace(p, end); if (p >= end) { return false; @@ -398,7 +441,7 @@ bool skipArray(const char *&p, const char *end, int depth) } } -bool skipValue(const char *&p, const char *end, int depth) +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan) { p = skipWhitespace(p, end); if (p >= end) { @@ -407,10 +450,10 @@ bool skipValue(const char *&p, const char *end, int depth) const char c = *p; if (c == '{') { // pass depth through: skipObject consumes the single decrement for this level - return skipObject(p, end, depth); + return skipObject(p, end, depth, scan); } if (c == '[') { - return skipArray(p, end, depth); + return skipArray(p, end, depth, scan); } // a primitive is a leaf, so it never wastes a nesting level return skipPrimitive(p, end); @@ -422,7 +465,8 @@ bool skipValue(const char *&p, const char *end, int depth) * For each member invokes @p memberCallback with the key and the byte range of * its value. Advancing @p p is unaffected by the callback. */ -template bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback) +template +bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback, ScanProgress &scan) { if (depth <= 0) { return false; // nest deeper than the cap @@ -449,7 +493,7 @@ template bool forEachObjectMember(const char *&p, const char *end, ++p; const char *valueStart = skipWhitespace(p, end); const char *valueEnd = valueStart; - if (!skipValue(valueEnd, end, depth - 1)) { + if (!skipValue(valueEnd, end, depth - 1, scan)) { return false; } if (!memberCallback(key, valueStart, valueEnd)) { @@ -473,7 +517,7 @@ template bool forEachObjectMember(const char *&p, const char *end, } // Counts the direct elements of an array value; returns -1 if the array is malformed. -int countArrayElements(const char *p, const char *end, int depth) +int countArrayElements(const char *p, const char *end, int depth, ScanProgress &scan) { if (depth <= 0) { return -1; // nest deeper than the cap @@ -485,9 +529,10 @@ int countArrayElements(const char *p, const char *end, int depth) return 0; } for (;;) { - if (!skipValue(p, end, depth - 1)) { + if (!skipValue(p, end, depth - 1, scan)) { return -1; } + scan.report(p); ++count; p = skipWhitespace(p, end); if (p >= end) { @@ -509,7 +554,7 @@ int countArrayElements(const char *p, const char *end, int depth) namespace RawJson { -QList scanSetRanges(const QByteArray &json, ScanError *error) +QList scanSetRanges(const QByteArray &json, ScanError *error, const ScanProgressCallback &progress) { QList ranges; if (error) { @@ -529,6 +574,14 @@ QList scanSetRanges(const QByteArray &json, ScanError *error) return fail(QStringLiteral("empty JSON document")); } + // Throttle reports to ~100 per scan so a GUI thread unthrottling them never + // drowns under per-card interrupts, whatever the document size. + ScanProgress scan; + scan.begin = begin; + scan.size = end - begin; + scan.step = std::max(1, scan.size / 100); + scan.callback = progress; + const char *p = skipWhitespace(begin, end); if (p >= end || *p != '{') { return fail(QStringLiteral("top-level JSON must be an object")); @@ -545,55 +598,57 @@ QList scanSetRanges(const QByteArray &json, ScanError *error) return false; } const char *setP = valueStart; - const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1, - [&](const QString &setCode, const char *setStart, const char *setEnd) { - if (setStart >= setEnd || *setStart != '{') { - malformedSetData = true; - return false; - } - SetRange range; - range.dataRange.start = setStart - begin; - range.dataRange.length = setEnd - setStart; - range.code = setCode; + const bool ok = forEachObjectMember( + setP, valueEnd, kMaxNestingDepth - 1, + [&](const QString &setCode, const char *setStart, const char *setEnd) { + if (setStart >= setEnd || *setStart != '{') { + malformedSetData = true; + return false; + } + SetRange range; + range.dataRange.start = setStart - begin; + range.dataRange.length = setEnd - setStart; + range.code = setCode; - const char *memberP = setStart; - const bool metaOk = forEachObjectMember( - memberP, setEnd, kMaxNestingDepth - 2, - [&](const QString &field, const char *fs, const char *fe) { - if (field == QStringLiteral("code")) { - return decodeStringMember(fs, fe, range.code); - } - if (field == QStringLiteral("name")) { - return decodeStringMember(fs, fe, range.name); - } - if (field == QStringLiteral("type")) { - return decodeStringMember(fs, fe, range.type); - } - if (field == QStringLiteral("releaseDate")) { - return decodeStringMember(fs, fe, range.releaseDate); - } - if (field == QStringLiteral("cards")) { - if (fs >= fe) { - return false; - } - if (*fs != '[') { - // e.g. "cards": null — treat as an empty array, - // matching Qt's tolerance. - return true; - } - range.dataRange.cardCount = - countArrayElements(fs, fe, kMaxNestingDepth - 2); - return range.dataRange.cardCount >= 0; - } - return true; - }); - if (!metaOk) { - malformedSetData = true; - return false; - } - ranges.append(range); - return true; - }); + const char *memberP = setStart; + const bool metaOk = forEachObjectMember( + memberP, setEnd, kMaxNestingDepth - 2, + [&](const QString &field, const char *fs, const char *fe) { + if (field == QStringLiteral("code")) { + return decodeStringMember(fs, fe, range.code); + } + if (field == QStringLiteral("name")) { + return decodeStringMember(fs, fe, range.name); + } + if (field == QStringLiteral("type")) { + return decodeStringMember(fs, fe, range.type); + } + if (field == QStringLiteral("releaseDate")) { + return decodeStringMember(fs, fe, range.releaseDate); + } + if (field == QStringLiteral("cards")) { + if (fs >= fe) { + return false; + } + if (*fs != '[') { + // e.g. "cards": null — treat as an empty array, + // matching Qt's tolerance. + return true; + } + range.dataRange.cardCount = countArrayElements(fs, fe, kMaxNestingDepth - 2, scan); + return range.dataRange.cardCount >= 0; + } + return true; + }, + scan); + if (!metaOk) { + malformedSetData = true; + return false; + } + ranges.append(range); + return true; + }, + scan); if (!ok) { malformedSetData = true; return false; @@ -602,7 +657,7 @@ QList scanSetRanges(const QByteArray &json, ScanError *error) return true; }; - if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) { + if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback, scan)) { return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); } p = skipWhitespace(p, end); @@ -615,6 +670,7 @@ QList scanSetRanges(const QByteArray &json, ScanError *error) if (ranges.isEmpty()) { return fail(QStringLiteral("no sets found in \"data\"")); } + scan.report(end); return ranges; } diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h index f6e3a4647..086f2b3e3 100644 --- a/oracle/src/raw_json_scanner.h +++ b/oracle/src/raw_json_scanner.h @@ -4,6 +4,7 @@ #include #include #include +#include namespace RawJson { @@ -42,6 +43,13 @@ struct ScanError QString message; }; +/** + * @brief Optional progress callback receiving @c (bytesRead, totalBytes) while + * the document is walked. Invoked from the scanning thread; the caller decides + * how the throttled offsets are relayed to a GUI event loop. + */ +using ScanProgressCallback = std::function; + /** * @brief Scans a full MTGJSON document without materializing the JSON tree. * @@ -67,9 +75,14 @@ struct ScanError * @param error Out parameter. Set to an error ScanError when the document * cannot be parsed, otherwise left empty. Passing a null * pointer disables error reporting. + * @param progress Optional progress callback. When non-empty it is invoked as + * the scanner advances through the document, throttled to a + * tiny fraction of the total size. * @return The detected per-set ranges, or an empty list on failure. */ -QList scanSetRanges(const QByteArray &json, ScanError *error = nullptr); +QList scanSetRanges(const QByteArray &json, + ScanError *error = nullptr, + const ScanProgressCallback &progress = ScanProgressCallback()); } // namespace RawJson diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 3834be9ed..66c7bdbe1 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -741,6 +743,135 @@ TEST_F(OracleImporterTest, StartImportParsesSetsLazily) ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); } +// ============================================================================ +// Scan progress reporting tests +// ============================================================================ + +TEST(OracleScanProgress, ScanProgressReportsMonotonicBytesToTotal) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + card["text"] = "Some rules text used to bulk up the card payload."; + card["layout"] = "normal"; + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> reports; + RawJson::ScanError error; + const QList ranges = + RawJson::scanSetRanges(data, &error, [&reports](qsizetype bytesRead, qsizetype totalBytes) { + reports.append({bytesRead, totalBytes}); + }); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_FALSE(reports.isEmpty()); + ASSERT_GT(reports.size(), 1); + + qsizetype last = 0; + for (const auto &[bytesRead, totalBytes] : reports) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, last) << "scan progress must be monotonic"; + ASSERT_LE(bytesRead, totalBytes) << "scan progress must not overshoot the document size"; + last = bytesRead; + } + ASSERT_EQ(reports.constLast().first, data.size()) << "scan must end at 100%"; + ASSERT_LE(reports.size(), 160) << "scan reports must be throttled"; +} + +TEST(OracleScanProgress, ScanWithoutCallbackStillParses) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_EQ(ranges.first().code, "tst"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmitsScanProgress) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> emissions; + QObject::connect(importer, &OracleImporter::dataReadProgress, + [&emissions](int bytesRead, int totalBytes) { emissions.append({bytesRead, totalBytes}); }); + + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(emissions.isEmpty()); + for (const auto &[bytesRead, totalBytes] : emissions) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, 0); + ASSERT_LE(bytesRead, totalBytes); + } + ASSERT_EQ(emissions.constLast().first, data.size()); +} + +TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + int emissions = 0; + QObject::connect(importer, &OracleImporter::dataReadProgress, [&emissions](int, int) { ++emissions; }); + + importer->setProgressReporting(false); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(emissions, 0); + + importer->setProgressReporting(true); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_GT(emissions, 0); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); From d5d99e4dfb7903af98a2aff8a11d23fb79c3a470 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:18:56 +0200 Subject: [PATCH 19/24] [Server/Client/Protocol] Add developer staff role (#7211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server/Client/Protocol] Add developer staff role Introduce a Developer staff level (proto flag 32, DB admin bit 8) that sits between admin and moderator: no kick/ban/warn/report/admin powers, but gets server log access via a new developer command container family (GET_SERVER_STATS, VIEWLOG_HISTORY) and an idle-timeout exemption. - Protocol: IsDeveloper flag, developer_commands.proto envelope, Command_GetServerStats/Command_GetLogHistory, Response_GetServerStats, Command_AdjustMod.should_be_developer - Servatrice: fail-closed developer dispatcher, uptime snapshot handler, shared log history handler reuse, bit-8 DB mapping - Client: burgundy pawn/badge/labels/sort order, prepareDeveloperCommand, minimal Developer stats tab, log tab access, promote/demote actions Took 24 minutes Took 18 seconds * [Server/Client/Protocol] Address developer role review feedback Address ZeizaZach's review of the developer staff role: - Nudge the developer log query to exclude private chat and sender IPs (the ModeratorCommand path still sees everything). - Deduplicate Command_GetLogHistory into Command_ViewLogHistory, which now extends both ModeratorCommand (ext) and DeveloperCommand (dev_ext); the client picks the DeveloperCommand-scoped extension by extendee, and the server reads it via the extension number. - Pull the uptime snapshot SQL into Servatrice_DatabaseInterface as getLatestUptimeSnapshot() and widen the reported counters to 64-bit. - Document the admin bitfield (1 admin, 2 moderator, 4 judge, 8 developer) and add a server-side test for the developer command path. * Add missing trailing newline to user_context_menu.cpp * Remove stale includes of deleted command_get_log_history proto The Command_GetLogHistory message was folded into Command_ViewLogHistory, which deleted command_get_log_history.proto, but serversocketinterface still #included its generated header. Fresh CI builds fail on the missing file; local builds masked it by reusing a previously generated header. * [Server] Exclude chat rows when private-chat filter is bypassable A developer who omits log_location entirely — or sends only "chat" — leaves chatType, gameType, roomType all false, so getMessageLogHistory skips the target_type clause and returns every row, private messages included. When !allowPrivateChat the server now forces game+room when no surviving location was requested, guaranteeing the query always carries a target_type restriction. [Client] Demote mod+dev to moderator path in log-tab dispatch The developer command family is strictly weaker than the moderator one (no private chat, no sender_ip, ip filter ignored), so granting the developer bit to an existing moderator must not silently strip their capabilities. useDeveloperCommands is now true only when the user holds the developer bit and not the moderator bit. [Client] Hide the IP-address filter for developer log tab users The developer path ignores the ip_address query field server-side. Showing the field lets a developer type an IP and get results that are silently unfiltered by it rather than an empty result set — reads as a broken filter. Hide labelFindIPAddress/findIPAddress alongside the privateChat checkbox. * Developer pawn is silver. * [Client] Fix indentation of merged Card Art Rules / Developer tabs --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../src/interface/pixel_map_generator.cpp | 3 + .../widgets/server/user/user_context_menu.cpp | 35 ++++- .../widgets/server/user/user_context_menu.h | 2 + .../widgets/server/user/user_info_box.cpp | 2 + .../widgets/server/user/user_info_popup.cpp | 5 + .../widgets/server/user/user_list_painter.cpp | 10 +- .../widgets/server/user/user_list_widget.cpp | 6 +- .../interface/widgets/tabs/tab_developer.cpp | 122 ++++++++++++++++ .../interface/widgets/tabs/tab_developer.h | 43 ++++++ .../src/interface/widgets/tabs/tab_logs.cpp | 25 +++- .../src/interface/widgets/tabs/tab_logs.h | 3 +- .../interface/widgets/tabs/tab_moderation.cpp | 3 + .../interface/widgets/tabs/tab_supervisor.cpp | 55 ++++++- .../interface/widgets/tabs/tab_supervisor.h | 6 +- .../client/abstract/abstract_client.cpp | 21 +++ .../network/client/abstract/abstract_client.h | 1 + .../server/remote/server_protocolhandler.cpp | 38 ++++- .../server/remote/server_protocolhandler.h | 7 + .../libcockatrice/protocol/pb/CMakeLists.txt | 3 + .../protocol/pb/admin_commands.proto | 1 + .../pb/command_get_server_stats.proto | 8 + .../libcockatrice/protocol/pb/commands.proto | 2 + .../protocol/pb/developer_commands.proto | 8 + .../protocol/pb/moderator_commands.proto | 4 + .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_get_server_stats.proto | 19 +++ .../protocol/pb/serverinfo_user.proto | 1 + servatrice/servatrice.sql | 3 + .../src/servatrice_database_interface.cpp | 41 +++++- .../src/servatrice_database_interface.h | 15 ++ servatrice/src/serversocketinterface.cpp | 89 +++++++++++- servatrice/src/serversocketinterface.h | 8 +- tests/CMakeLists.txt | 7 + tests/server_developer_role_test.cpp | 137 ++++++++++++++++++ 35 files changed, 708 insertions(+), 27 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/tab_developer.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_developer.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto create mode 100644 tests/server_developer_role_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9e0331d69..63ccc4e9c 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -382,6 +382,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp + src/interface/widgets/tabs/tab_developer.cpp src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_logs.cpp diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 9b8c4bcdc..674f3dd4c 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -14,6 +14,7 @@ #define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff"; #define DEFAULT_COLOR_MODERATOR_RIGHT "#000000"; #define DEFAULT_COLOR_ADMIN "#ff2701"; +#define DEFAULT_COLOR_DEVELOPER "#B8B8B8" /** * Clamps an svg render size so that rendering does not exceed a multiple of the requested size. @@ -359,6 +360,8 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height, if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { colorLeft = DEFAULT_COLOR_ADMIN; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + colorLeft = DEFAULT_COLOR_DEVELOPER; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { colorLeft = DEFAULT_COLOR_MODERATOR_LEFT; colorRight = DEFAULT_COLOR_MODERATOR_RIGHT; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 6b61d14ec..0d2267a63 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -51,6 +51,8 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromMod = new QAction(QString(), this); aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); + aPromoteToDeveloper = new QAction(QString(), this); + aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); @@ -76,6 +78,8 @@ void UserContextMenu::retranslateUi() aDemoteFromMod->setText(tr("Dem&ote user from moderator")); aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); + aPromoteToDeveloper->setText(tr("Promote user to &developer")); + aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); } @@ -268,7 +272,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext); if (resp.response_code() == Response::RespOk) { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Success"), tr("Successfully promoted user.")); } else { @@ -276,7 +280,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const } } else { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to promote user.")); } else { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to demote user.")); @@ -437,6 +441,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos, (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { menu->addAction(aPromoteToJudge); } + + if (userLevel.testFlag(ServerInfo_User::IsDeveloper) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aDemoteFromDeveloper); + + } else if (userLevel.testFlag(ServerInfo_User::IsRegistered) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aPromoteToDeveloper); + } } aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); @@ -455,6 +468,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); + aPromoteToJudge->setEnabled(anotherUser); + aDemoteFromJudge->setEnabled(anotherUser); + aPromoteToDeveloper->setEnabled(anotherUser); + aDemoteFromDeveloper->setEnabled(anotherUser); QAction *actionClicked = menu->exec(pos); if (actionClicked == nullptr) { @@ -489,6 +506,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdjustMod(userName, actionClicked == aPromoteToMod); } else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) { execAdjustJudge(userName, actionClicked == aPromoteToJudge); + } else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) { + execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper); } else if (actionClicked == aBanHistory) { execBanHistory(userName); } else if (actionClicked == aWarnUser) { @@ -706,4 +725,14 @@ void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudg PendingCommand *pend = client->prepareAdminCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); client->sendCommand(pend); -} \ No newline at end of file +} + +void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_developer(shouldBeDeveloper); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index f1ce931f8..6abbc057a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -45,6 +45,7 @@ private: QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; + QAction *aPromoteToDeveloper, *aDemoteFromDeveloper; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; @@ -123,6 +124,7 @@ public: void execInvestigateUser(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); + void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); private: void execInvite(const QString &userName, const GameInviteOption &option); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 875bdfb05..3d89cecf5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -122,6 +122,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user) QString userLevelText; if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { userLevelText = tr("Administrator"); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + userLevelText = tr("Developer"); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { userLevelText = tr("Moderator"); } else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index fb610e814..8be76eea0 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -245,6 +245,9 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) if (level.testFlag(ServerInfo_User::IsAdmin)) { return QColor(245, 158, 11); } + if (level.testFlag(ServerInfo_User::IsDeveloper)) { + return QColor(185, 28, 28); + } if (level.testFlag(ServerInfo_User::IsModerator)) { return QColor(59, 130, 246); } @@ -300,6 +303,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } badge; if (level.testFlag(ServerInfo_User::IsAdmin)) { badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsDeveloper)) { + badge = {"DEV", QColor(185, 28, 28)}; } else if (level.testFlag(ServerInfo_User::IsModerator)) { badge = {"MOD", QColor(59, 130, 246)}; } else if (level.testFlag(ServerInfo_User::IsJudge)) { diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 5c65b090d..34a3d6ae1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -49,6 +49,8 @@ QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool onl if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + accentColor = QColor(185, 28, 28); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { accentColor = QColor(59, 130, 246); } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -299,6 +301,8 @@ QList UserListPainter::buildBadges(const UserLevelFlags if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + badges << Badge{"DEV", QColor(185, 28, 28)}; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { badges << Badge{"MOD", QColor(59, 130, 246)}; } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -385,9 +389,9 @@ void UserListPainter::paint(QPainter *painter, const QString userName = QString::fromStdString(userInfo.name()); const QString privLevel = QString::fromStdString(userInfo.privlevel()); const QColor accentColor = getAccentColor(userLevel, online); - const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) || - userLevel.testFlag(ServerInfo_User::IsModerator) || - userLevel.testFlag(ServerInfo_User::IsJudge); + const bool hasRole = + userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) || + userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge); const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); const int cardRight = getCardRight(option, rect); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index a8c99c979..1bb7c5288 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -234,9 +234,11 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt()); - // Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator + // Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer + // or a moderator static const QList userLevelWithNoOtherPrefOrder = { - ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; + ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp new file mode 100644 index 000000000..b8e6a8033 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,122 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#include "tab_developer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), client(_client) +{ + statsTable = new QTableWidget(0, 2); + statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + statsTable->setSelectionMode(QAbstractItemView::SingleSelection); + statsTable->horizontalHeader()->setStretchLastSection(true); + statsTable->verticalHeader()->setVisible(false); + + statusLabel = new QLabel; + + refreshButton = new QPushButton; + refreshButton->setAutoDefault(true); + connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft); + buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(statsTable); + mainLayout->addLayout(buttonLayout); + + auto *central = new QWidget; + central->setLayout(mainLayout); + setCentralWidget(central); + + retranslateUi(); +} + +void TabDeveloper::retranslateUi() +{ + refreshButton->setText(tr("Refresh server stats")); + statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";")); + if (statsTable->rowCount() == 0) { + statusLabel->clear(); + } +} + +QString TabDeveloper::formatBytes(quint64 bytes) +{ + const quint64 kib = 1024; + const quint64 mib = 1024 * kib; + const quint64 gib = 1024 * mib; + if (bytes >= gib) { + return tr("%1 GiB").arg(QString::number(bytes / static_cast(gib), 'f', 2)); + } + if (bytes >= mib) { + return tr("%1 MiB").arg(QString::number(bytes / static_cast(mib), 'f', 2)); + } + if (bytes >= kib) { + return tr("%1 KiB").arg(QString::number(bytes / static_cast(kib), 'f', 2)); + } + return tr("%1 bytes").arg(bytes); +} + +void TabDeveloper::appendStatRow(const QString &name, const QString &value) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + statsTable->setItem(row, 0, new QTableWidgetItem(name)); + statsTable->setItem(row, 1, new QTableWidgetItem(value)); +} + +void TabDeveloper::refreshClicked() +{ + Command_GetServerStats cmd; + PendingCommand *pend = client->prepareDeveloperCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse); + client->sendCommand(pend); +} + +void TabDeveloper::serverStatsResponse(const Response &resp) +{ + if (resp.response_code() != Response::RespOk) { + statusLabel->setText(tr("No server statistics available yet.")); + return; + } + + const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext); + + statsTable->setRowCount(0); + appendStatRow(tr("Registered users online"), QString::number(response.users_count())); + appendStatRow(tr("Moderators online"), QString::number(response.mods_count())); + appendStatRow(tr("Games running"), QString::number(response.games_count())); + appendStatRow(tr("Traffic sent (last tick)"), formatBytes(response.tx_bytes())); + appendStatRow(tr("Traffic received (last tick)"), formatBytes(response.rx_bytes())); + + const qint64 uptime = static_cast(response.uptime_secs()); + const int days = static_cast(uptime / 86400); + const int hours = static_cast((uptime % 86400) / 3600); + const int minutes = static_cast((uptime % 3600) / 60); + appendStatRow(tr("Server uptime"), days > 0 ? tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes) + : tr("%1h %2m").arg(hours).arg(minutes)); + + const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest())); + appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm")); + + statsTable->resizeColumnsToContents(); + statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm"))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h new file mode 100644 index 000000000..501b14e3e --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,43 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +class AbstractClient; +class QLabel; +class QPushButton; +class QTableWidget; +class Response; + +class TabDeveloper : public Tab +{ + Q_OBJECT +private: + AbstractClient *client; + QTableWidget *statsTable; + QPushButton *refreshButton; + QLabel *statusLabel; + + void appendStatRow(const QString &name, const QString &value); + static QString formatBytes(quint64 bytes); + +private slots: + void refreshClicked(); + void serverStatsResponse(const Response &resp); + +public: + explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Developer"); + } +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index e3678a903..f73d06b57 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -19,7 +19,8 @@ #include #include -TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) + : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); roomTable->setColumnCount(6); @@ -80,7 +81,9 @@ void TabLog::getClicked() if (!mainRoom->isChecked() && !gameRoom->isChecked() && !privateChat->isChecked()) { mainRoom->setChecked(true); gameRoom->setChecked(true); - privateChat->setChecked(true); + if (!canUseDeveloperCommands) { + privateChat->setChecked(true); + } } if (maximumResults->value() == 0) { @@ -117,7 +120,15 @@ void TabLog::getClicked() }; cmd.set_date_range(dateRange); cmd.set_maximum_results(maximumResults->value()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); + + PendingCommand *pend; + if (canUseDeveloperCommands) { + // Developers query logs through the developer command family. + pend = client->prepareDeveloperCommand(cmd); + } else { + pend = client->prepareModeratorCommand(cmd); + } + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } @@ -171,6 +182,14 @@ void TabLog::createDock() mainRoom = new QCheckBox(tr("Main Room")); gameRoom = new QCheckBox(tr("Game Room")); privateChat = new QCheckBox(tr("Private Chat")); + if (canUseDeveloperCommands) { + // Developers cannot query private conversations. + privateChat->setVisible(false); + // The developer family ignores the IP filter server-side, so showing + // the field would silently unfilter the result by it. Hide it. + labelFindIPAddress->setVisible(false); + findIPAddress->setVisible(false); + } pastDays = new QRadioButton(tr("Past X Days: ")); today = new QRadioButton(tr("Today")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index 5d164dc92..8e914ea64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -33,6 +33,7 @@ class TabLog : public Tab Q_OBJECT private: AbstractClient *client; + bool canUseDeveloperCommands; QLabel *labelFindUserName, *labelFindIPAddress, *labelFindGameName, *labelFindGameID, *labelMessage, *labelMaximum, *labelDescription; LineEditUnfocusable *findUsername, *findIPAddress, *findGameName, *findGameID, *findMessage; @@ -58,7 +59,7 @@ private slots: void restartLayout(); public: - TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client); + TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands = false); ~TabLog() override; void retranslateUi() override; [[nodiscard]] QString getTabText() const override diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 077b876d2..b9c37f1af 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -381,6 +381,9 @@ void TabModeration::moderatorLoginsResponse(const Response &response) if (login.user_level() & ServerInfo_User::IsAdmin) { levels << tr("Admin"); } + if (login.user_level() & ServerInfo_User::IsDeveloper) { + levels << tr("Developer"); + } if (login.user_level() & ServerInfo_User::IsModerator) { levels << tr("Moderator"); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 07b724b52..bb8ec719e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -15,6 +15,7 @@ #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" +#include "tab_developer.h" #include "tab_game.h" #include "tab_home.h" #include "tab_logs.h" @@ -119,7 +120,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), isLocalGame(false) + tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -205,6 +206,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabModeration->setCheckable(true); connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration); + aTabDeveloper = new QAction(this); + aTabDeveloper->setCheckable(true); + connect(aTabDeveloper, &QAction::triggered, this, &TabSupervisor::actTabDeveloper); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -247,6 +252,7 @@ void TabSupervisor::retranslateUi() aTabReport->setText(tr("Report Queue")); aTabModeration->setText(tr("Moderation")); aTabCardArtRules->setText(tr("Card Art Rules")); + aTabDeveloper->setText(tr("Developer")); // tabs QList tabs; @@ -259,6 +265,7 @@ void TabSupervisor::retranslateUi() tabs.append(tabReport); tabs.append(tabModeration); tabs.append(tabCardArtRules); + tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -528,6 +535,19 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) } } + if (userInfo->user_level() & ServerInfo_User::IsDeveloper) { + tabsMenu->addSeparator(); + tabsMenu->addAction(aTabDeveloper); + // Developers without moderation rights get log access through their + // own role. Moderators already have the Logs entry from above. + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + tabsMenu->addAction(aTabLog); + if (SettingsCache::instance().tabs().getTabLogOpen()) { + openTabLog(); + } + } + } + retranslateUi(); } @@ -540,6 +560,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -590,6 +611,9 @@ void TabSupervisor::stop() if (tabCardArtRules) { tabCardArtRules->close(); } + if (tabDeveloper) { + tabDeveloper->close(); + } } QList tabsToDelete; @@ -819,7 +843,13 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { - tabLog = new TabLog(this, client); + // Developers query logs through the developer command family, so tell the + // tab which family to use. The moderator family is strictly stronger, so a + // moderator who also holds the developer bit keeps the moderator path — the + // developer bit only selects the (narrowed) developer family on its own. + const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) && + !(userInfo->user_level() & ServerInfo_User::IsModerator); + tabLog = new TabLog(this, client, useDeveloperCommands); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { tabLog = nullptr; @@ -881,6 +911,27 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::actTabDeveloper(bool checked) +{ + if (checked && !tabDeveloper) { + openTabDeveloper(); + setCurrentWidget(tabDeveloper); + } else if (!checked && tabDeveloper) { + tabDeveloper->closeRequest(); + } +} + +void TabSupervisor::openTabDeveloper() +{ + tabDeveloper = new TabDeveloper(this, client); + myAddTab(tabDeveloper, aTabDeveloper); + connect(tabDeveloper, &QObject::destroyed, this, [this] { + tabDeveloper = nullptr; + aTabDeveloper->setChecked(false); + }); + aTabDeveloper->setChecked(true); +} + void TabSupervisor::updatePingTime(int value, int max) { if (!tabServer) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index b389bad3e..aec1d7418 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -45,6 +45,7 @@ class TabReport; class TabModeration; class TabAccount; class TabDeckEditor; +class TabDeveloper; class TabLog; class RoomEvent; class GameEventContainer; @@ -108,6 +109,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -117,7 +119,7 @@ private: QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, - *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration; + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration, *aTabDeveloper; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -207,6 +209,7 @@ private slots: void actTabLog(bool checked); void actTabReport(bool checked); void actTabModeration(bool checked); + void actTabDeveloper(bool checked); void openTabVisualDeckStorage(); void openTabHome(); @@ -218,6 +221,7 @@ private slots: void openTabCardArtRules(); void openTabLog(); void openTabReport(); + void openTabDeveloper(); void updateCurrent(int index); void updatePingTime(int value, int max); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index d6316deb3..687d93666 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -253,3 +253,24 @@ PendingCommand *AbstractClient::prepareAdminCommand(const ::google::protobuf::Me c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); return new PendingCommand(cont); } + +PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf::Message &cmd) +{ + CommandContainer cont; + DeveloperCommand *c = cont.add_developer_command(); + // A developer command message may also be usable through other command + // families, so select the extension scoped to DeveloperCommand rather than + // guessing by name. + const ::google::protobuf::Descriptor *cmdDescriptor = cmd.GetDescriptor(); + const ::google::protobuf::Descriptor *developerDescriptor = DeveloperCommand::descriptor(); + const ::google::protobuf::FieldDescriptor *developerExtension = nullptr; + for (int i = 0; i < cmdDescriptor->extension_count(); ++i) { + if (cmdDescriptor->extension(i)->containing_type() == developerDescriptor) { + developerExtension = cmdDescriptor->extension(i); + break; + } + } + Q_ASSERT(developerExtension != nullptr); + c->GetReflection()->MutableMessage(c, developerExtension)->CopyFrom(cmd); + return new PendingCommand(cont); +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 1ef9a31e4..af22a5c9d 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -173,6 +173,7 @@ public: static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId); static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareAdminCommand(const ::google::protobuf::Message &cmd); + static PendingCommand *prepareDeveloperCommand(const ::google::protobuf::Message &cmd); QMap clientFeatures; }; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 899df6529..8422d703d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -388,6 +388,33 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons return finalResponseCode; } +Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer(const CommandContainer &cont, + ResponseContainer &rc) +{ + if (!userInfo) { + return Response::RespLoginNeeded; + } + if (!(userInfo->user_level() & ServerInfo_User::IsDeveloper)) { + return Response::RespLoginNeeded; + } + + resetIdleTimer(); + + Response::ResponseCode finalResponseCode = Response::RespOk; + for (int i = cont.developer_command_size() - 1; i >= 0; --i) { + Response::ResponseCode resp = Response::RespInvalidCommand; + const DeveloperCommand &sc = cont.developer_command(i); + const int num = getPbExtension(sc); + logDebugMessage(getSafeDebugString(sc)); + + resp = processExtendedDeveloperCommand(num, sc, rc); + if (resp != Response::RespOk) { + finalResponseCode = resp; + } + } + return finalResponseCode; +} + void Server_ProtocolHandler::processCommandContainer(const CommandContainer &cont) { // Command processing must be disabled after prepareDestroy() has been called. @@ -410,6 +437,8 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con finalResponseCode = processModeratorCommandContainer(cont, responseContainer); } else if (cont.admin_command_size()) { finalResponseCode = processAdminCommandContainer(cont, responseContainer); + } else if (cont.developer_command_size()) { + finalResponseCode = processDeveloperCommandContainer(cont, responseContainer); } else { finalResponseCode = Response::RespInvalidCommand; } @@ -454,11 +483,12 @@ void Server_ProtocolHandler::pingClockTimeout() prepareDestroy(); } - // PrivLevel users, Moderators, and Admins are not subject to the server idle timeout policy + // PrivLevel users, Moderators, Admins, and Developers are not subject to the server idle timeout policy const bool hasPrivLevel = userInfo && QString::fromStdString(userInfo->privlevel()).toLower() != "none"; - const bool isModOrAdmin = - userInfo && (userInfo->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin)); - if (!hasPrivLevel && !isModOrAdmin) { + const bool isStaff = + userInfo && (userInfo->user_level() & + (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin | ServerInfo_User::IsDeveloper)); + if (!hasPrivLevel && !isStaff) { if ((server->getIdleClientTimeout() > 0) && (idleClientWarningSent)) { if (timeRunning - lastActionReceived > server->getIdleClientTimeout()) { prepareDestroy(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 0d05b91c8..d62213188 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -27,6 +27,7 @@ class CommandContainer; class SessionCommand; class ModeratorCommand; class AdminCommand; +class DeveloperCommand; class Command_Ping; class Command_Login; @@ -98,6 +99,12 @@ private: { return Response::RespFunctionNotAllowed; } + Response::ResponseCode processDeveloperCommandContainer(const CommandContainer &cont, ResponseContainer &rc); + virtual Response::ResponseCode + processExtendedDeveloperCommand(int /* cmdType */, const DeveloperCommand & /* cmd */, ResponseContainer & /* rc */) + { + return Response::RespFunctionNotAllowed; + } void resetIdleTimer(); private slots: diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index f22828f46..0791f8c14 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -25,6 +25,7 @@ set(PROTO_FILES command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto command_kick_from_game.proto @@ -71,6 +72,7 @@ set(PROTO_FILES context_ready_start.proto context_set_sideboard_lock.proto context_undo_draw.proto + developer_commands.proto event_add_to_list.proto event_attach_card.proto event_change_zone_properties.proto @@ -140,6 +142,7 @@ set(PROTO_FILES response_forgotpasswordrequest.proto response_get_admin_notes.proto response_get_games_of_user.proto + response_get_server_stats.proto response_get_user_info.proto response_join_room.proto response_list_users.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto index f8b34b3f8..f1f85e376 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto @@ -37,6 +37,7 @@ message Command_AdjustMod { required string user_name = 1; optional bool should_be_mod = 2; optional bool should_be_judge = 3; + optional bool should_be_developer = 4; } message Command_ResetUserPassword { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto new file mode 100644 index 000000000..33c56293b --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "developer_commands.proto"; + +message Command_GetServerStats { + extend DeveloperCommand { + optional Command_GetServerStats ext = 1000; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto index b6eaf6733..964407819 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto @@ -4,6 +4,7 @@ import "game_commands.proto"; import "room_commands.proto"; import "moderator_commands.proto"; import "admin_commands.proto"; +import "developer_commands.proto"; message CommandContainer { optional uint64 cmd_id = 1; @@ -16,4 +17,5 @@ message CommandContainer { repeated RoomCommand room_command = 102; repeated ModeratorCommand moderator_command = 103; repeated AdminCommand admin_command = 104; + repeated DeveloperCommand developer_command = 105; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto new file mode 100644 index 000000000..bed47d44c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +message DeveloperCommand { + enum DeveloperCommandType { + GET_SERVER_STATS = 1000; + VIEWLOG_HISTORY = 1001; + } + extensions 100 to max; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 685408830..4f1e80c27 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "developer_commands.proto"; message ModeratorCommand { enum ModeratorCommandType { BAN_FROM_SERVER = 1000; @@ -80,6 +81,9 @@ message Command_ViewLogHistory { extend ModeratorCommand { optional Command_ViewLogHistory ext = 1005; } + extend DeveloperCommand { + optional Command_ViewLogHistory dev_ext = 1001; + } optional string user_name = 1; // user that created message optional string ip_address = 2; // ip address of user that created message optional string game_name = 3; // client id of user that created the message diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 14ba737b5..42a42fcc0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -77,6 +77,7 @@ message Response { FORGOT_PASSWORD_REQUEST = 1016; // Response to password reset request PASSWORD_SALT = 1017; // Response containing password salt GET_ADMIN_NOTES = 1018; // Response with admin notes + GET_SERVER_STATS = 1019; // Response with server status statistics REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto new file mode 100644 index 000000000..fb8a0cae2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -0,0 +1,19 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_GetServerStats { + extend Response { + optional Response_GetServerStats ext = 1220; + } + + optional uint64 users_count = 1; + optional uint64 mods_count = 2; + optional uint64 games_count = 3; + + // Traffic recorded during the last status update tick + optional uint64 tx_bytes = 4; + optional uint64 rx_bytes = 5; + + optional uint64 uptime_secs = 6; + optional uint64 timest = 7; // unix timestamp of the snapshot +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 98cc3ce6a..ea3f56705 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -8,6 +8,7 @@ message ServerInfo_User { IsModerator = 4; IsAdmin = 8; IsJudge = 16; + IsDeveloper = 32; }; message PawnColorsOverride { optional string left_side = 1; diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 5dbf69cbc..cfb1ef5d8 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -25,6 +25,9 @@ INSERT INTO cockatrice_schema_version VALUES(36); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, + -- Bitfield of staff levels: 1 = admin (implies moderator), 2 = moderator, + -- 4 = judge, 8 = developer. Operators set these by hand with + -- "UPDATE cockatrice_users SET admin = ...". `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 847be61da..36604bae7 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -641,6 +641,10 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer userLevel |= ServerInfo_User::IsJudge; } + if (is_admin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + result.set_user_level(userLevel); const QString country = query->value(3).toString(); @@ -1448,7 +1452,7 @@ QList Servatrice_DatabaseInterface::getModeratorLastL QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) " "FROM {prefix}_users u " "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " - "WHERE (u.admin & 7) <> 0 ORDER BY u.name"); + "WHERE (u.admin & 15) <> 0 ORDER BY u.name"); if (!execSqlQuery(query)) { qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; @@ -1469,6 +1473,9 @@ QList Servatrice_DatabaseInterface::getModeratorLastL if (isAdmin & 4) { userLevel |= ServerInfo_User::IsJudge; } + if (isAdmin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } loginDetails.set_user_level(userLevel); if (!query->value(2).isNull()) { @@ -1480,6 +1487,38 @@ QList Servatrice_DatabaseInterface::getModeratorLastL return results; } +Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId) +{ + UptimeSnapshot snapshot; + + if (!checkSql()) { + return snapshot; + } + + QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, " + "UNIX_TIMESTAMP(timest) FROM {prefix}_uptime " + "WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); + query->bindValue(":id_server", serverId); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error"; + return snapshot; + } + + if (query->next()) { + snapshot.valid = true; + snapshot.usersCount = query->value(0).toULongLong(); + snapshot.modsCount = query->value(1).toULongLong(); + snapshot.gamesCount = query->value(2).toULongLong(); + snapshot.txBytes = query->value(3).toULongLong(); + snapshot.rxBytes = query->value(4).toULongLong(); + snapshot.uptimeSecs = query->value(5).toULongLong(); + snapshot.timest = query->value(6).toULongLong(); + } + + return snapshot; +} + bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) { if (!checkSql()) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index cd76ae288..a891c7a3d 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -140,6 +140,21 @@ public: QList getUserSessions(const QString &userName, int limit); QList getUserAlts(const QString &userName); QList getModeratorLastLogins(); + + // Uptime snapshot as recorded by Servatrice::statusUpdate() into the + // {prefix}_uptime table. valid is false when no snapshot exists yet. + struct UptimeSnapshot + { + bool valid = false; + quint64 usersCount = 0; + quint64 modsCount = 0; + quint64 gamesCount = 0; + quint64 txBytes = 0; + quint64 rxBytes = 0; + quint64 uptimeSecs = 0; + quint64 timest = 0; + }; + UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 2a8b5f0a4..4b1502a15 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +81,7 @@ #include #include #include +#include #include #include #include @@ -284,7 +286,7 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo case ModeratorCommand::REPORT_RESOLVE: return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: - return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc); + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: return cmdGrantReplayAccess(cmd.GetExtension(Command_GrantReplayAccess::ext), rc); case ModeratorCommand::REPLAY_DOWNLOAD_BY_GAME_ID: @@ -337,6 +339,26 @@ AbstractServerSocketInterface::processExtendedAdminCommand(int cmdType, const Ad } } +// DEVELOPER FUNCTIONS. +// Permission is checked by processDeveloperCommandContainer. Only stats-style +// queries live here, never community moderation or server administration. +Response::ResponseCode AbstractServerSocketInterface::processExtendedDeveloperCommand(int cmdType, + const DeveloperCommand &cmd, + ResponseContainer &rc) +{ + switch ((DeveloperCommand::DeveloperCommandType)cmdType) { + case DeveloperCommand::GET_SERVER_STATS: + return cmdGetServerStats(cmd.GetExtension(Command_GetServerStats::ext), rc); + case DeveloperCommand::VIEWLOG_HISTORY: { + // Same query as the moderator log view, carried by the developer + // command family, but narrows out private chats and sender IPs. + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::dev_ext), rc, false); + } + default: + return Response::RespFunctionNotAllowed; + } +} + Response::ResponseCode AbstractServerSocketInterface::cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc) { if (authState != PasswordRight) { @@ -1020,12 +1042,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, - ResponseContainer &rc) + ResponseContainer &rc, + bool allowPrivateChat) { QList messageList; QString userName = nameFromStdString(cmd.user_name()); - QString ipAddress = nameFromStdString(cmd.ip_address()); + QString ipAddress = allowPrivateChat ? nameFromStdString(cmd.ip_address()) : QString(); QString gameName = nameFromStdString(cmd.game_name()); QString gameID = nameFromStdString(cmd.game_id()); QString message = textFromStdString(cmd.message()); @@ -1040,11 +1063,21 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com if (nameFromStdString(cmd.log_location(i)).simplified() == "game") { gameType = true; } - if (nameFromStdString(cmd.log_location(i)).simplified() == "chat") { + if (nameFromStdString(cmd.log_location(i)).simplified() == "chat" && allowPrivateChat) { chatType = true; } } + // For callers that must not see private conversations, never leave the + // target-type filter empty: if the request only asked for "chat" (or for + // nothing at all) the query below would carry no target_type restriction + // and would return every row, private messages included. Fall back to the + // game/room diagnostics the caller is allowed to see. + if (!allowPrivateChat && !gameType && !roomType) { + gameType = true; + roomType = true; + } + int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); @@ -1054,7 +1087,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com QListIterator messageIterator(sqlInterface->getMessageLogHistory( userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); while (messageIterator.hasNext()) { - re->add_log_message()->CopyFrom(messageIterator.next()); + ServerInfo_ChatMessage chatMessage = messageIterator.next(); + if (!allowPrivateChat) { + chatMessage.clear_sender_ip(); + } + re->add_log_message()->CopyFrom(chatMessage); } } else { ServerInfo_ChatMessage chatMessage; @@ -1643,6 +1680,34 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Co return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Command_GetServerStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + // Servatrice::statusUpdate() periodically snapshots server health into the + // uptime table. Serve the freshest snapshot for this server. + const auto snapshot = sqlInterface->getLatestUptimeSnapshot(servatrice->getServerID()); + if (!snapshot.valid) { + // No snapshot yet (fresh server, or statusUpdate() has not ticked). + return Response::RespInternalError; + } + + auto *re = new Response_GetServerStats; + re->set_users_count(snapshot.usersCount); + re->set_mods_count(snapshot.modsCount); + re->set_games_count(snapshot.gamesCount); + re->set_tx_bytes(snapshot.txBytes); + re->set_rx_bytes(snapshot.rxBytes); + re->set_uptime_secs(snapshot.uptimeSecs); + re->set_timest(snapshot.timest); + + rc.setResponseExtension(re); + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, ResponseContainer &rc) { @@ -3215,7 +3280,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN if (user) { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::DEMOTED); - event.set_reason_str("Your moderator and/or judge status has been revoked."); + event.set_reason_str("Your moderator, judge, and/or developer status has been revoked."); event.set_end_time(QDateTime::currentDateTime().toSecsSinceEpoch()); SessionEvent *se = user->prepareSessionEvent(event); @@ -3257,6 +3322,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAdjustMod(const Command } } + if (cmd.has_should_be_developer()) { + if (cmd.should_be_developer()) { + if (!addAdminFlagToUser(userName, 8)) { + return Response::RespInternalError; + } + } else { + if (!removeAdminFlagFromUser(userName, 8)) { + return Response::RespInternalError; + } + } + } + return Response::RespOk; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 600796b5f..c7516b405 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -115,7 +116,8 @@ private: Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc); Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc); - Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc); + Response::ResponseCode + cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc, bool allowPrivateChat); Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnList(const Command_GetWarnList &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc); @@ -151,6 +153,8 @@ private: processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); @@ -172,6 +176,8 @@ private: Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18ab60d06..b7e712e3a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test) add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) +add_test(NAME server_developer_role_test COMMAND server_developer_role_test) add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) @@ -30,6 +31,7 @@ add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) +add_executable(server_developer_role_test server_developer_role_test.cpp) add_executable(warning_categories_test warning_categories_test.cpp) add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) @@ -70,6 +72,7 @@ if(NOT GTEST_FOUND) add_dependencies(server_card_counter_test gtest) add_dependencies(server_counter_test gtest) add_dependencies(server_rate_limiter_test gtest) + add_dependencies(server_developer_role_test gtest) add_dependencies(warning_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) @@ -104,6 +107,10 @@ target_link_libraries( target_link_libraries( server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) diff --git a/tests/server_developer_role_test.cpp b/tests/server_developer_role_test.cpp new file mode 100644 index 000000000..127f606a8 --- /dev/null +++ b/tests/server_developer_role_test.cpp @@ -0,0 +1,137 @@ +/** @file server_developer_role_test.cpp + * @brief Tests for the developer staff role authorization and dispatch. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestDeveloperHandler : public Server_ProtocolHandler +{ +public: + explicit TestDeveloperHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != DeveloperCommand::GET_SERVER_STATS) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class DeveloperRoleTest : public ::testing::Test +{ +protected: + Server server; + TestDeveloperHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(DeveloperRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsModeratorThatIsNotDeveloper) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); +} + +TEST_F(DeveloperRoleTest, DispatchesToDeveloperCommandForDeveloper) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + DeveloperCommand *cmd = cont.add_developer_command(); + cmd->MutableExtension(Command_GetServerStats::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(DeveloperRoleTest, FailClosedForUnknownDeveloperCommand) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_developer_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 202a5ac958c26406b611fca8dcf2f5cf85bb5c16 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:18:56 +0200 Subject: [PATCH 20/24] [Client/Server/Protocol] Surface live metrics in the Developer tab (#7212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Instrument command processing, game starts, and event loops Add a lock-free MetricsRegistry that accumulates per-command processing times in preallocated histogram slots (one per protobuf command type, bucketed at 1/5/10/25/50/100/250/500/1000/2500/5000 ms +Inf). The hot-path observeCommand() uses only relaxed atomic adds — no locks, no allocations, no cache-line ping-pong beyond the unavoidable counter updates. Wire the registry into AbstractServerSocketInterface::processCommandContainer() so every processed command is attributed with its container's wall-clock time. When a container exceeds metrics/slow_command_ms (default 500), a warning is logged including the connected username. Add an EventLoopWatchdog heartbeat that runs on every socket pool thread. If a heartbeat overshoots metrics/stall_warn_ms (default 2000 ms), the overshoot is recorded in atomic counters and a warning is logged. Both thresholds are configurable in servatrice.ini; setting stall_warn_ms to 0 disables the watchdogs entirely. Track game-start durations via a separate histogram in MetricsRegistry. Server_Game::startGameNow() measures the time from zone creation through player materialization and reports it via Server::observeGameStartDurationMs(). Add a live card-count gauge: Server_Game exposes getCardsInGame() and Servatrice::getCardsInGamesTotal() sums across all running games under the appropriate read locks. Include a standalone metrics_registry_test (Google Test) that validates empty registries, single/multi-sample histograms, kind encoding, overflow-slot collapse, negative-duration clamping, gauge rendering, and the game-start histogram separation. Took 10 minutes * [Client/Server/Protocol] Surface live metrics in the Developer tab Extend Response_GetServerStats with live counters from the in-process MetricsRegistry: cards in games, event loop stall totals/worst, total commands processed, average command time, active command types, and game-start count/duration. Add a repeated CommandStats message carrying per-command breakdowns (kind, extension number, resolved protobuf name, count, total ms) for every type that has seen at least one sample. Server-side cmdGetServerStats() populates all new fields after the existing DB uptime snapshot query, resolving protobuf extension names via the descriptor pool for human-readable labels like session/Command_Ping. Expand TabDeveloper with two tables: an overview section (existing DB stats plus the new live metrics) and a per-command breakdown table (Command / Count / Total ms / Avg ms) sorted by total_ms descending so the hottest commands surface first. Took 55 minutes Took 47 seconds * [Server] Drop dead Prometheus histogram, add developer command metrics, fix watchdog init order - metrics_registry: remove toPrometheusText/appendCumulativeBuckets and the time-bucket histogram that nothing in production ever emitted (the future /metrics exporter can bring it back); keep counts/totals read by the Developer tab - Fix +Inf bucket routing that never incremented, and its test that locked the bug in - Instrument developer_command container (kind 6) in processCommandContainer and stats label resolution - Read metrics/{slow_command_ms,stall_warn_ms} at the top of initServer() so stall_warn_ms=0 disables the watchdogs before pool threads start - Shrink KindStride to 1280 (largest extension in use is 1206) with a static_assert; document scrape cost of getCardsInGamesTotal; note slow_command logging has no rate limit in servatrice.ini.example * [Tests] Give metrics_registry_test an explicit main * [Server] Record only the dispatched command family; drop unused totals processCommandContainer recorded every family in a container even though the base if/else-if dispatch processes at most one. An unauthenticated client could batch a session command (login) with fabricated developer, moderator, and admin entries and forge genuine-looking samples that were never executed or authorized. Mirror the base's selection, skip when the handler was already deleted, and skip entries whose extension number is -1 (which would otherwise wrap into the previous kind's id range). [Server] Drop dead process-lifetime byte/uptime counters txBytesTotal/rxBytesTotal added an atomic RMW to every socket write and read for counters nothing consumes (cmdGetServerStats fills tx_bytes, rx_bytes, and uptime_secs from the DB snapshot). Remove the two atomics and the getTxBytesTotal/getRxBytesTotal/getUptimeSeconds getters; the incTxBytes/incRxBytes slots and mutexes remain for the ISL legacy counters. [Protocol] Document kind 5 as developer in CommandStats NumKinds is 6 and the server emits kind_index = 5 for developer commands; the comment stopped at 4. * [Client] Togglable auto-refresh for Developer stats tab * [Oracle] Fix clang-format alignment of card type priority list --------- Co-authored-by: Lukas Brübach --- .../interface/widgets/tabs/tab_developer.cpp | 145 +++++++++++++++++- .../interface/widgets/tabs/tab_developer.h | 12 ++ .../remote/game/server_abstract_player.cpp | 9 ++ .../remote/game/server_abstract_player.h | 2 + .../server/remote/game/server_game.cpp | 16 ++ .../network/server/remote/game/server_game.h | 2 + .../network/server/remote/server.h | 5 + .../server/remote/server_protocolhandler.h | 2 +- .../pb/response_get_server_stats.proto | 22 +++ oracle/src/oracleimporter.cpp | 2 +- servatrice/CMakeLists.txt | 2 + servatrice/servatrice.ini.example | 13 ++ servatrice/src/event_loop_watchdog.cpp | 34 ++++ servatrice/src/event_loop_watchdog.h | 50 ++++++ servatrice/src/metrics_registry.cpp | 70 +++++++++ servatrice/src/metrics_registry.h | 120 +++++++++++++++ servatrice/src/servatrice.cpp | 59 ++++++- servatrice/src/servatrice.h | 52 +++++++ servatrice/src/serversocketinterface.cpp | 119 ++++++++++++++ servatrice/src/serversocketinterface.h | 1 + tests/CMakeLists.txt | 5 + tests/metrics_registry_test.cpp | 83 ++++++++++ 22 files changed, 818 insertions(+), 7 deletions(-) create mode 100644 servatrice/src/event_loop_watchdog.cpp create mode 100644 servatrice/src/event_loop_watchdog.h create mode 100644 servatrice/src/metrics_registry.cpp create mode 100644 servatrice/src/metrics_registry.h create mode 100644 tests/metrics_registry_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index b8e6a8033..39652ef7d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -6,40 +6,80 @@ #include "tab_developer.h" +#include #include +#include #include #include #include +#include #include +#include #include +#include #include #include #include #include +static constexpr int DEFAULT_AUTO_REFRESH_INTERVAL_SECS = 30; + TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) { statsTable = new QTableWidget(0, 2); - statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); statsTable->setSelectionMode(QAbstractItemView::SingleSelection); - statsTable->horizontalHeader()->setStretchLastSection(true); statsTable->verticalHeader()->setVisible(false); + statsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + statsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + statsTable->horizontalHeader()->setStretchLastSection(true); + + commandTable = new QTableWidget(0, 4); + commandTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + commandTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + commandTable->setSelectionBehavior(QAbstractItemView::SelectRows); + commandTable->setSelectionMode(QAbstractItemView::SingleSelection); + commandTable->verticalHeader()->setVisible(false); + commandTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Interactive); statusLabel = new QLabel; + autoRefreshCheckBox = new QCheckBox; + autoRefreshCheckBox->setChecked(false); + + refreshIntervalSpinBox = new QSpinBox; + refreshIntervalSpinBox->setRange(5, 3600); + refreshIntervalSpinBox->setValue(DEFAULT_AUTO_REFRESH_INTERVAL_SECS); + refreshIntervalSpinBox->setEnabled(false); + + autoRefreshTimer = new QTimer(this); + connect(autoRefreshTimer, &QTimer::timeout, this, &TabDeveloper::refreshClicked); + connect(autoRefreshCheckBox, &QCheckBox::toggled, this, &TabDeveloper::autoRefreshToggled); + connect(refreshIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), this, + &TabDeveloper::refreshIntervalChanged); + refreshButton = new QPushButton; refreshButton->setAutoDefault(true); connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked); auto *buttonLayout = new QHBoxLayout; buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft); + buttonLayout->addWidget(autoRefreshCheckBox, 0, Qt::AlignRight); + buttonLayout->addWidget(refreshIntervalSpinBox, 0, Qt::AlignRight); buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight); + auto *tableLayout = new QHBoxLayout; + tableLayout->addWidget(statsTable, 1); + tableLayout->addWidget(commandTable, 2); + auto *mainLayout = new QVBoxLayout; - mainLayout->addWidget(statsTable); + mainLayout->addLayout(tableLayout, 1); mainLayout->addLayout(buttonLayout); auto *central = new QWidget; @@ -51,8 +91,13 @@ TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_clien void TabDeveloper::retranslateUi() { + autoRefreshCheckBox->setText(tr("Auto-refresh")); + autoRefreshCheckBox->setToolTip(tr("Automatically request fresh server statistics at a fixed interval.")); + refreshIntervalSpinBox->setSuffix(tr(" s")); + refreshIntervalSpinBox->setToolTip(tr("Seconds between automatic refreshes.")); refreshButton->setText(tr("Refresh server stats")); statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";")); + commandTable->setHorizontalHeaderLabels(QString(tr("Command;Count;Total ms;Avg ms")).split(";")); if (statsTable->rowCount() == 0) { statusLabel->clear(); } @@ -75,6 +120,14 @@ QString TabDeveloper::formatBytes(quint64 bytes) return tr("%1 bytes").arg(bytes); } +QString TabDeveloper::formatDurationMs(qint64 ms) +{ + if (ms >= 1000) { + return tr("%1 s").arg(QString::number(ms / 1000.0, 'f', 2)); + } + return tr("%1 ms").arg(ms); +} + void TabDeveloper::appendStatRow(const QString &name, const QString &value) { const int row = statsTable->rowCount(); @@ -83,16 +136,52 @@ void TabDeveloper::appendStatRow(const QString &name, const QString &value) statsTable->setItem(row, 1, new QTableWidgetItem(value)); } +void TabDeveloper::appendSeparatorRow(const QString §ionTitle) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + auto *labelItem = new QTableWidgetItem(sectionTitle); + auto font = labelItem->font(); + font.setBold(true); + labelItem->setFont(font); + labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); + statsTable->setItem(row, 0, labelItem); + statsTable->setItem(row, 1, new QTableWidgetItem(QString())); +} + void TabDeveloper::refreshClicked() { + if (requestPending) { + return; + } + requestPending = true; Command_GetServerStats cmd; PendingCommand *pend = client->prepareDeveloperCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse); client->sendCommand(pend); } +void TabDeveloper::autoRefreshToggled(bool checked) +{ + refreshIntervalSpinBox->setEnabled(checked); + if (checked) { + refreshIntervalChanged(); + refreshClicked(); + } else { + autoRefreshTimer->stop(); + } +} + +void TabDeveloper::refreshIntervalChanged() +{ + if (autoRefreshCheckBox->isChecked()) { + autoRefreshTimer->start(refreshIntervalSpinBox->value() * 1000); + } +} + void TabDeveloper::serverStatsResponse(const Response &resp) { + requestPending = false; if (resp.response_code() != Response::RespOk) { statusLabel->setText(tr("No server statistics available yet.")); return; @@ -101,6 +190,8 @@ void TabDeveloper::serverStatsResponse(const Response &resp) const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext); statsTable->setRowCount(0); + + // Overview section appendStatRow(tr("Registered users online"), QString::number(response.users_count())); appendStatRow(tr("Moderators online"), QString::number(response.mods_count())); appendStatRow(tr("Games running"), QString::number(response.games_count())); @@ -117,6 +208,54 @@ void TabDeveloper::serverStatsResponse(const Response &resp) const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest())); appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm")); + // Live metrics section + appendSeparatorRow(tr("Live Metrics")); + appendStatRow(tr("Cards in live games"), QString::number(response.cards_in_games())); + appendStatRow(tr("Total commands processed"), QString::number(response.total_commands())); + + if (response.total_commands() > 0) { + const double avgMs = static_cast(response.total_command_time_ms()) / response.total_commands(); + appendStatRow(tr("Avg command time"), QString::number(avgMs, 'f', 2) + " ms"); + } + appendStatRow(tr("Active command types"), QString::number(response.active_command_types())); + + appendStatRow(tr("Event loop stalls"), QString::number(response.eventloop_stalls_total())); + appendStatRow(tr("Last stall overshoot"), formatDurationMs(response.eventloop_last_stall_ms())); + appendStatRow(tr("Worst stall overshoot"), formatDurationMs(response.eventloop_max_stall_ms())); + + if (response.game_start_count() > 0) { + appendStatRow(tr("Game starts"), QString::number(response.game_start_count())); + const double avgStartMs = static_cast(response.game_start_total_ms()) / response.game_start_count(); + appendStatRow(tr("Avg game start time"), QString::number(avgStartMs, 'f', 1) + " ms"); + } + + // Per-command breakdown table + QList sortedStats(response.command_stats().begin(), response.command_stats().end()); + std::sort(sortedStats.begin(), sortedStats.end(), + [](const auto &a, const auto &b) { return a.total_ms() > b.total_ms(); }); + + commandTable->setRowCount(0); + for (const auto &cs : sortedStats) { + const int row = commandTable->rowCount(); + commandTable->insertRow(row); + commandTable->setItem(row, 0, new QTableWidgetItem(QString::fromStdString(cs.command_name()))); + + auto *countItem = new QTableWidgetItem(QString::number(cs.count())); + countItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 1, countItem); + + auto *totalItem = new QTableWidgetItem(QString::number(cs.total_ms())); + totalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 2, totalItem); + + const double avg = cs.count() > 0 ? static_cast(cs.total_ms()) / cs.count() : 0.0; + auto *avgItem = new QTableWidgetItem(QString::number(avg, 'f', 2)); + avgItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 3, avgItem); + } + commandTable->resizeColumnsToContents(); statsTable->resizeColumnsToContents(); + commandTable->resizeColumnsToContents(); + statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm"))); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h index 501b14e3e..727a56bcb 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.h +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -10,9 +10,12 @@ #include "tab.h" class AbstractClient; +class QCheckBox; class QLabel; class QPushButton; +class QSpinBox; class QTableWidget; +class QTimer; class Response; class TabDeveloper : public Tab @@ -21,15 +24,24 @@ class TabDeveloper : public Tab private: AbstractClient *client; QTableWidget *statsTable; + QTableWidget *commandTable; QPushButton *refreshButton; QLabel *statusLabel; + QCheckBox *autoRefreshCheckBox; + QSpinBox *refreshIntervalSpinBox; + QTimer *autoRefreshTimer; + bool requestPending = false; void appendStatRow(const QString &name, const QString &value); + void appendSeparatorRow(const QString §ionTitle); static QString formatBytes(quint64 bytes); + static QString formatDurationMs(qint64 ms); private slots: void refreshClicked(); void serverStatsResponse(const Response &resp); + void autoRefreshToggled(bool checked); + void refreshIntervalChanged(); public: explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 957a89792..6b4101a99 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -66,6 +66,15 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, Server_AbstractPlayer::~Server_AbstractPlayer() = default; +int Server_AbstractPlayer::getCardCount() const +{ + int result = 0; + for (auto *zone : zones) { + result += zone->getCards().size(); + } + return result; +} + void Server_AbstractPlayer::prepareDestroy() { delete deck; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index 85fbc0557..4cc79c5fe 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -43,6 +43,8 @@ public: Server_AbstractUserInterface *_handler); ~Server_AbstractPlayer() override; void prepareDestroy() override; + /// Total cards across all of this player's zones. The caller must hold the game's mutex. + int getCardCount() const; const DeckList *getDeckList() const { return deck; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 43209e994..799b1e7ee 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -32,6 +32,7 @@ #include "server_spectator.h" #include +#include #include #include #include @@ -238,6 +239,17 @@ int Server_Game::getPlayerCount() const return participants.size() - getSpectatorCount(); } +int Server_Game::getCardsInGame() const +{ + QMutexLocker locker(&gameMutex); + + int result = 0; + for (auto *player : getPlayers()) { + result += player->getCardCount(); + } + return result; +} + int Server_Game::getSpectatorCount() const { QMutexLocker locker(&gameMutex); @@ -330,6 +342,9 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) } } + // Only actual starts are timed. The early returns above are no-ops. + QElapsedTimer startupTimer; + startupTimer.start(); players = getPlayers(); // players could have been kicked, get new list of players if (lifecycleStrategy->onGameStarting(this) == Server_GameLifecycleStrategy::StartAction::Handled) { locker.unlock(); @@ -373,6 +388,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) activePlayer = -1; nextTurn(); + room->getServer()->observeGameStartDurationMs(startupTimer.nsecsElapsed() / 1000000); locker.unlock(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 1b9f651bd..1ed4fe4ca 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -123,6 +123,8 @@ public: return gameStarted; } int getPlayerCount() const; + /// Total cards across all players' zones. Takes gameMutex itself. + int getCardsInGame() const; int getSpectatorCount() const; QMap getPlayers() const; Server_AbstractPlayer *getPlayer(int id) const; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 0ded27afa..3d27f4210 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -180,6 +180,11 @@ public: { return false; } + /// Called once per actual game start with how long bringing every player's + /// zones online took, so servers can spot deck sizes that wedge threads. + virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) + { + } Server_DatabaseInterface *getDatabaseInterface() const; int getNextLocalGameId() diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index d62213188..2c8efe50e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -136,7 +136,7 @@ public: return timeRunning - lastDataReceived; } bool addSaidMessageSize(int size); - void processCommandContainer(const CommandContainer &cont); + virtual void processCommandContainer(const CommandContainer &cont); void sendProtocolItem(const Response &item); void sendProtocolItem(const SessionEvent &item); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto index fb8a0cae2..bb8ff3c43 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -1,6 +1,14 @@ syntax = "proto2"; import "response.proto"; +message CommandStats { + optional uint32 kind_index = 1; // 0=session, 1=room, 2=game, 3=moderator, 4=admin, 5=developer + optional uint32 extension_number = 2; // protobuf extension number within the kind + optional string command_name = 3; // e.g. "session/Command_Ping" + optional uint64 count = 4; // number of times observed + optional uint64 total_ms = 5; // cumulative processing milliseconds +} + message Response_GetServerStats { extend Response { optional Response_GetServerStats ext = 1220; @@ -16,4 +24,18 @@ message Response_GetServerStats { optional uint64 uptime_secs = 6; optional uint64 timest = 7; // unix timestamp of the snapshot + + // Live metrics from MetricsRegistry (reset on server restart) + optional uint64 cards_in_games = 8; + optional uint64 eventloop_stalls_total = 9; + optional uint64 eventloop_last_stall_ms = 10; + optional uint64 eventloop_max_stall_ms = 11; + optional uint64 total_commands = 12; + optional uint64 total_command_time_ms = 13; + optional int32 active_command_types = 14; + optional uint64 game_start_count = 15; + optional uint64 game_start_total_ms = 16; + + // Per-command breakdown (only types with count > 0) + repeated CommandStats command_stats = 20; } diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 07aee0185..fdf11ef02 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -110,7 +110,7 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data) * A lower index means a higher priority. */ static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery", - "Instant", "Artifact", "Enchantment"}; + "Instant", "Artifact", "Enchantment"}; /** * Returns the priority (index) of the given main card type. Known types map to their diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 5d8089ad1..68e422d8c 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -6,7 +6,9 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${ set(servatrice_SOURCES src/email_parser.cpp + src/event_loop_watchdog.cpp src/main.cpp + src/metrics_registry.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp src/servatrice_database_interface.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c1940c22f..23eca3f1b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -382,6 +382,19 @@ max_reports_per_day=10 ; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit max_comments_per_hour=30 +[metrics] +; Command containers that take longer than this many milliseconds are logged +; as slow commands. Set to 0 to disable the log line. A latency spike produces +; one warning per slow container with no rate limiting of its own -- a bad +; patch can briefly flood the log, which is how you notice it. +slow_command_ms=500 + +; Each socket pool thread runs a watchdog heartbeat. If a heartbeat arrives +; this many milliseconds late, the stall is logged and exposed as +; servatrice_eventloop_* metrics in the Developer tab. Set to 0 to disable +; the watchdogs. +stall_warn_ms=2000 + [logging] ; Admin/Moderators can query the stored logs for information when looking up reports by various players. This ; option can allow or disallow them from doing so. diff --git a/servatrice/src/event_loop_watchdog.cpp b/servatrice/src/event_loop_watchdog.cpp new file mode 100644 index 000000000..e50bd4201 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.cpp @@ -0,0 +1,34 @@ +/** + * @file event_loop_watchdog.cpp + * @ingroup Servatrice + */ + +#include "event_loop_watchdog.h" + +#include "servatrice.h" + +#include + +EventLoopWatchdog::EventLoopWatchdog(Servatrice *_servatrice, QString _threadName) + : QObject(nullptr), servatrice(_servatrice), threadName(std::move(_threadName)) +{ +} + +void EventLoopWatchdog::start() +{ + heartbeatTimer = new QTimer(this); + sinceLastTick.start(); + connect(heartbeatTimer, &QTimer::timeout, this, &EventLoopWatchdog::checkHeartbeat); + heartbeatTimer->start(HeartbeatIntervalMs); +} + +void EventLoopWatchdog::checkHeartbeat() +{ + const qint64 elapsedMs = sinceLastTick.restart(); + const qint64 overshootMs = qMax(0, elapsedMs - HeartbeatIntervalMs); + if (overshootMs < servatrice->getMetricsStallWarnMs()) { + return; + } + + servatrice->observeEventLoopStall(threadName, overshootMs); +} diff --git a/servatrice/src/event_loop_watchdog.h b/servatrice/src/event_loop_watchdog.h new file mode 100644 index 000000000..b9061ff97 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.h @@ -0,0 +1,50 @@ +/** + * @file event_loop_watchdog.h + * @ingroup Servatrice + */ + +#ifndef EVENT_LOOP_WATCHDOG_H +#define EVENT_LOOP_WATCHDOG_H + +#include +#include +#include + +class Servatrice; +class QTimer; + +/** + * @brief Detects blocked or overloaded worker event loops. + * + * One instance lives in each socket pool thread. A heartbeat timer tick that + * arrives late means the loop spent that time elsewhere: busy work, a queued + * slot, or a hard wedge. Overshoots past the configured threshold bump + * lock-free counters on the metrics registry and log one warning per stall, + * so a stuck pool thread becomes visible instead of silent lag. + */ +class EventLoopWatchdog : public QObject +{ + Q_OBJECT +public: + /// How often the heartbeat expects to fire. Small enough to catch short stalls. + static constexpr int HeartbeatIntervalMs = 500; + + EventLoopWatchdog(Servatrice *_servatrice, QString _threadName); + + /** + * Starts the heartbeat timer. Must be invoked queued after the instance + * was moved to its target thread so the timer lives there too. + */ + void start(); + +private slots: + void checkHeartbeat(); + +private: + Servatrice *servatrice; + QString threadName; + QElapsedTimer sinceLastTick; + QTimer *heartbeatTimer = nullptr; +}; + +#endif diff --git a/servatrice/src/metrics_registry.cpp b/servatrice/src/metrics_registry.cpp new file mode 100644 index 000000000..99ce390f1 --- /dev/null +++ b/servatrice/src/metrics_registry.cpp @@ -0,0 +1,70 @@ +#include "metrics_registry.h" + +#include + +void MetricsRegistry::observeCommand(int typeId, qint64 elapsedMs) +{ + if (typeId < 0 || typeId >= MaxTypes) { + typeId = MaxTypes - 1; // overflow slot keeps misrouted ids visible + } + if (elapsedMs < 0) { + elapsedMs = 0; + } + + TypeStats &stats = slotFor(typeId); + stats.count.fetch_add(1, std::memory_order_relaxed); + stats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); + totalCommandsCounter.fetch_add(1, std::memory_order_relaxed); + totalTimeCounter.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +void MetricsRegistry::observeGameStartDurationMs(qint64 elapsedMs) +{ + if (elapsedMs < 0) { + elapsedMs = 0; + } + + gameStartStats.count.fetch_add(1, std::memory_order_relaxed); + gameStartStats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) +{ + return typeSlots[static_cast(typeId)]; +} + +const MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) const +{ + return typeSlots[static_cast(typeId)]; +} + +int MetricsRegistry::activeTypeCount() const +{ + int active = 0; + for (int type = 0; type < MaxTypes; ++type) { + if (slotFor(type).count.load(std::memory_order_relaxed) > 0) { + ++active; + } + } + return active; +} + +QList MetricsRegistry::collectActiveStats() const +{ + QList result; + for (int type = 0; type < MaxTypes; ++type) { + const TypeStats &stats = slotFor(type); + const qint64 count = stats.count.load(std::memory_order_relaxed); + if (count == 0) { + continue; + } + result.append({type, count, stats.totalMs.load(std::memory_order_relaxed)}); + } + return result; +} + +MetricsRegistry::GameStartSnapshot MetricsRegistry::getGameStartSnapshot() const +{ + return {gameStartStats.count.load(std::memory_order_relaxed), + gameStartStats.totalMs.load(std::memory_order_relaxed)}; +} \ No newline at end of file diff --git a/servatrice/src/metrics_registry.h b/servatrice/src/metrics_registry.h new file mode 100644 index 000000000..4df1057c2 --- /dev/null +++ b/servatrice/src/metrics_registry.h @@ -0,0 +1,120 @@ +/** + * @file metrics_registry.h + * @ingroup Servatrice + */ + +#ifndef METRICS_REGISTRY_H +#define METRICS_REGISTRY_H + +#include +#include +#include +#include + +/** + * @brief Lock-free accumulation of command processing statistics. + * + * observeCommand() is called once per processed command from whichever socket + * thread handled it. It uses relaxed atomic adds on preallocated storage only, + * so it introduces no locks, allocations, or shared cache-line ping-pong + * beyond the unavoidable counter updates. + * + * Reading happens rarely (metrics scraping), accepts momentary tears between + * related counters, and therefore also needs no synchronization. + * + * Only counts and totals are retained. An earlier Prometheus-style cumulative + * histogram (per-type, time-bucketed) was cut because nothing in the server + * ever wrote it out; it belongs to the future /metrics exporter that needs it. + */ +class MetricsRegistry +{ +public: + /** + * Extension numbers are only unique per command kind, so recorded ids + * combine the kind index with the protobuf extension number. + * + * The stride is only as wide as it needs to be: 1280 is the first round + * number above the largest extension actually in use (ModeratorCommand = + * 1206) and keeps the preallocated TypeStats array small. Bump it if a new + * command exceeds it. + */ + static constexpr int KindStride = 1280; + + static constexpr int NumKinds = 6; + + static constexpr const char *KindNames[NumKinds] = {"session", "room", "game", "moderator", "admin", "developer"}; + + /// Upper bound on distinct command type ids (see typeIdFor). + static constexpr int MaxTypes = NumKinds * KindStride; + + /// Guard against typeIdFor() overflowing into the neighbouring kind's slots. + static_assert(KindStride > 1206, "KindStride must exceed the highest command extension number in use"); + + static int typeIdFor(int kindIndex, int extensionNumber) + { + return kindIndex * KindStride + extensionNumber; + } + + void observeCommand(int typeId, qint64 elapsedMs); + + /** + * Records how long one game start took to bring every player's zones + * online. Kept separate from command timings because it is triggered by + * the server itself and can dwarf any single command when decks are huge. + */ + void observeGameStartDurationMs(qint64 elapsedMs); + + /// Total number of observed commands across all types. + qint64 totalCommands() const + { + return totalCommandsCounter.load(std::memory_order_relaxed); + } + + /// Cumulative processing milliseconds across all types. + qint64 totalTimeMs() const + { + return totalTimeCounter.load(std::memory_order_relaxed); + } + + /// Number of distinct type slots that have seen at least one sample. + int activeTypeCount() const; + + struct ActiveTypeStats + { + int typeId; + qint64 count; + qint64 totalMs; + }; + + /** + * Returns stats for every type slot that has seen at least one sample. + * Callers resolve the numeric type id to a human-readable label via + * typeIdFor()/KindNames as needed. + */ + QList collectActiveStats() const; + + struct GameStartSnapshot + { + qint64 count; + qint64 totalMs; + }; + + GameStartSnapshot getGameStartSnapshot() const; + +private: + struct TypeStats + { + std::atomic count{0}; + std::atomic totalMs{0}; + }; + + TypeStats &slotFor(int typeId); + const TypeStats &slotFor(int typeId) const; + + std::array typeSlots{}; + TypeStats gameStartStats{}; + std::atomic totalCommandsCounter{0}; + std::atomic totalTimeCounter{0}; +}; + +#endif \ No newline at end of file diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index db8751658..26352ccd7 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -20,6 +20,7 @@ #include "servatrice.h" #include "email_parser.h" +#include "event_loop_watchdog.h" #include "isl_interface.h" #include "main.h" #include "servatrice_connection_pool.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +65,7 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -86,7 +89,6 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(qint64)), this, SLOT(incTxBytes(qint64))); ssi->moveToThread(pool->thread()); pool->addClient(); connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); @@ -131,6 +133,7 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -156,7 +159,6 @@ void Servatrice_WebsocketGameServer::onNewConnection() Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new WebsocketServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(quint64)), this, SLOT(incTxBytes(quint64))); /* * Due to a Qt limitation, websockets can't be moved to another thread. * This will hopefully change in Qt6 if QtWebSocket will be integrated in QtNetwork @@ -226,6 +228,13 @@ bool Servatrice::initServer() { serverId = getServerID(); + + // METRICS (always active. Slow-command logging and stall watchdogs are + // controlled by their respective thresholds below). Read up front so the + // values are available before any pool thread is started and watchdogged. + metricsSlowCommandMs = settingsCache->value("metrics/slow_command_ms", 500).toInt(); + metricsStallWarnMs = qMax(0, settingsCache->value("metrics/stall_warn_ms", 2000).toInt()); + if (getAuthenticationMethodString() == "sql") { qDebug() << "Authenticating method: sql"; authenticationMethod = AuthenticationSql; @@ -470,9 +479,55 @@ bool Servatrice::initServer() } setRequiredFeatures(getRequiredFeatures()); + return true; } +void Servatrice::observeGameStartDurationMs(qint64 elapsedMs) +{ + metricsRegistry.observeGameStartDurationMs(elapsedMs); +} + +void Servatrice::observeEventLoopStall(const QString &threadName, qint64 overshootMs) +{ + eventLoopStallsTotal.fetch_add(1, std::memory_order_relaxed); + eventLoopLastStallMs.store(overshootMs, std::memory_order_relaxed); + qint64 prevMax = eventLoopMaxStallMs.load(std::memory_order_relaxed); + while (overshootMs > prevMax && + !eventLoopMaxStallMs.compare_exchange_weak(prevMax, overshootMs, std::memory_order_relaxed)) { + // retry until the max is at least as high as the new sample + } + + qWarning() << "Event loop stall in" << threadName << "- heartbeat overshot by" << overshootMs << "ms"; +} + +void Servatrice::watchWorkerThread(QThread *thread) +{ + if (metricsStallWarnMs <= 0) { + return; // watchdogs disabled via metrics/stall_warn_ms = 0 + } + + auto *watchdog = new EventLoopWatchdog(this, thread->objectName()); + connect(thread, &QThread::finished, watchdog, &QObject::deleteLater); + watchdog->moveToThread(thread); + QMetaObject::invokeMethod(watchdog, &EventLoopWatchdog::start, Qt::QueuedConnection); +} + +qint64 Servatrice::getCardsInGamesTotal() const +{ + qint64 total = 0; + QReadLocker roomsLocker(&roomsLock); // locking order: roomsLock before gamesLock/gameMutex + QMapIterator roomIterator(rooms); + while (roomIterator.hasNext()) { + Server_Room *room = roomIterator.next().value(); + QReadLocker gamesLocker(&room->gamesLock); + for (auto *game : room->getGames()) { + total += game->getCardsInGame(); + } + } + return total; +} + void Servatrice::addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface) { databaseInterfaces.insert(thread, databaseInterface); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8b0f5ad60..8d964a52b 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,8 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include "metrics_registry.h" + #include #include #include @@ -30,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -170,6 +173,12 @@ private: int uptime; QMutex txBytesMutex, rxBytesMutex; quint64 txBytes, rxBytes; + MetricsRegistry metricsRegistry; + int metricsSlowCommandMs = 500; + int metricsStallWarnMs = 2000; + std::atomic eventLoopStallsTotal{0}; ///< heartbeat overshoots past the warn threshold + std::atomic eventLoopLastStallMs{0}; ///< overshoot of the most recent stall + std::atomic eventLoopMaxStallMs{0}; ///< worst overshoot seen since process start QString shutdownReason; int shutdownMinutes; @@ -286,6 +295,49 @@ public: void incRxBytes(quint64 num); void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface); + // Metrics (see [metrics] section in servatrice.ini.example) + MetricsRegistry &getMetricsRegistry() + { + return metricsRegistry; + } + /** + * Sums cards across all zones of all running games. Each game takes its + * own gameMutex -- the hot per-game lock every game action contends on -- + * and then iterates every player's zones, so the scrape cost is really + * O(total cards in play) plus one mutex acquisition per live game. Keep + * scrapes infrequent in big multiplayer rooms. + */ + qint64 getCardsInGamesTotal() const; + int getMetricsSlowCommandMs() const + { + return metricsSlowCommandMs; + } + /// Heartbeat overshoot that counts as a stall. A value of 0 disables the watchdogs. + int getMetricsStallWarnMs() const + { + return metricsStallWarnMs; + } + void observeGameStartDurationMs(qint64 elapsedMs) override; + qint64 getEventLoopStallsTotal() const + { + return eventLoopStallsTotal.load(std::memory_order_relaxed); + } + qint64 getEventLoopLastStallMs() const + { + return eventLoopLastStallMs.load(std::memory_order_relaxed); + } + qint64 getEventLoopMaxStallMs() const + { + return eventLoopMaxStallMs.load(std::memory_order_relaxed); + } + /// Records one heartbeat overshoot and logs a single warning for it. + void observeEventLoopStall(const QString &threadName, qint64 overshootMs); + /** + * Installs an EventLoopWatchdog in @p thread. Called once per socket pool + * thread right after it starts. + */ + void watchWorkerThread(QThread *thread); + bool islConnectionExists(int _serverId) const; void addIslInterface(int _serverId, IslInterface *interface); void removeIslInterface(int _serverId); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 4b1502a15..aeffd7081 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include #include @@ -39,8 +40,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -192,6 +195,77 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) +{ + QElapsedTimer timer; + timer.start(); + Server_ProtocolHandler::processCommandContainer(cont); + const qint64 elapsedMs = timer.nsecsElapsed() / 1000000; + + // The base dispatch is an if/else-if chain — at most one family is + // actually processed. Recording every family in the container would + // let an unauthenticated client stampforge developer/moderator/admin + // samples by batching them alongside a session command the server + // actually runs. Mirror the base's selection and skip entirely when + // deleted or when no family matched. + if (deleted) { + return; + } + + // When getPbExtension returns -1 (no extension set) and the kind is + // non-zero, typeIdFor wraps into the previous kind's range instead of + // hitting the typeId < 0 guard in observeCommand. Skip such entries. + int kind = -1; + if (cont.game_command_size()) { + kind = 2; + } else if (cont.room_command_size()) { + kind = 1; + } else if (cont.session_command_size()) { + kind = 0; + } else if (cont.moderator_command_size()) { + kind = 3; + } else if (cont.admin_command_size()) { + kind = 4; + } else if (cont.developer_command_size()) { + kind = 5; + } + + if (kind >= 0) { + auto recordDispatched = [&](int familyKind, const auto &cmds) { + for (const auto &cmd : cmds) { + const int ext = getPbExtension(cmd); + if (ext >= 0) { + servatrice->getMetricsRegistry().observeCommand(MetricsRegistry::typeIdFor(familyKind, ext), + elapsedMs); + } + } + }; + + if (kind == 0) { + recordDispatched(kind, cont.session_command()); + } else if (kind == 1) { + recordDispatched(kind, cont.room_command()); + } else if (kind == 2) { + recordDispatched(kind, cont.game_command()); + } else if (kind == 3) { + recordDispatched(kind, cont.moderator_command()); + } else if (kind == 4) { + recordDispatched(kind, cont.admin_command()); + } else { + recordDispatched(kind, cont.developer_command()); + } + } + + const int slowCommandMs = servatrice->getMetricsSlowCommandMs(); + if (slowCommandMs > 0 && elapsedMs >= slowCommandMs) { + const ServerInfo_User *info = getUserInfo(); + const QString user = authState == PasswordRight && info ? QString::fromStdString(info->name()) + : QStringLiteral("unauthenticated"); + qCWarning(AbstractServerSocketInterfaceLog) << "slow command container from" << user << "processed in" + << elapsedMs << "ms (" << cont.ByteSizeLong() << "bytes)"; + } +} + Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) @@ -1704,6 +1778,51 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Co re->set_uptime_secs(snapshot.uptimeSecs); re->set_timest(snapshot.timest); + // Live metrics from the in-process MetricsRegistry (resets on server restart) + re->set_cards_in_games(static_cast(servatrice->getCardsInGamesTotal())); + re->set_eventloop_stalls_total(static_cast(servatrice->getEventLoopStallsTotal())); + re->set_eventloop_last_stall_ms(static_cast(servatrice->getEventLoopLastStallMs())); + re->set_eventloop_max_stall_ms(static_cast(servatrice->getEventLoopMaxStallMs())); + re->set_total_commands(static_cast(servatrice->getMetricsRegistry().totalCommands())); + re->set_total_command_time_ms( + static_cast(servatrice->getMetricsRegistry().totalTimeMs())); + re->set_active_command_types(servatrice->getMetricsRegistry().activeTypeCount()); + + const auto gameStart = servatrice->getMetricsRegistry().getGameStartSnapshot(); + re->set_game_start_count(static_cast(gameStart.count)); + re->set_game_start_total_ms(static_cast(gameStart.totalMs)); + + // Per-command breakdown: resolve protobuf extension names via the descriptor pool + static const char *messageNames[] = {"SessionCommand", "RoomCommand", "GameCommand", + "ModeratorCommand", "AdminCommand", "DeveloperCommand"}; + const auto activeStats = servatrice->getMetricsRegistry().collectActiveStats(); + for (const auto &stat : activeStats) { + const int kind = stat.typeId / MetricsRegistry::KindStride; + const int number = stat.typeId % MetricsRegistry::KindStride; + + QString label; + if (kind >= 0 && kind < MetricsRegistry::NumKinds) { + const google::protobuf::DescriptorPool *pool = google::protobuf::DescriptorPool::generated_pool(); + const google::protobuf::Descriptor *message = pool->FindMessageTypeByName(messageNames[kind]); + const google::protobuf::FieldDescriptor *extension = + message ? pool->FindExtensionByNumber(message, number) : nullptr; + if (extension) { + label = QString::fromLatin1(MetricsRegistry::KindNames[kind]) + QStringLiteral("/") + + QString::fromStdString(std::string(extension->message_type()->name())); + } + } + if (label.isEmpty()) { + label = QString::number(stat.typeId); + } + + CommandStats *cs = re->add_command_stats(); + cs->set_kind_index(static_cast(kind)); + cs->set_extension_number(static_cast(number)); + cs->set_command_name(label.toStdString()); + cs->set_count(static_cast(stat.count)); + cs->set_total_ms(static_cast(stat.totalMs)); + } + rc.setResponseExtension(re); return Response::RespOk; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index c7516b405..b464e6a9b 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -81,6 +81,7 @@ signals: protected: void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); + void processCommandContainer(const CommandContainer &cont) override; virtual void writeToSocket(QByteArray &data) = 0; virtual void flushSocket() = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b7e712e3a..4f5dc88eb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,7 @@ add_test(NAME server_developer_role_test COMMAND server_developer_role_test) add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) +add_test(NAME metrics_registry_test COMMAND metrics_registry_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) @@ -36,6 +37,7 @@ add_executable(warning_categories_test warning_categories_test.cpp) add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) add_executable(latency_tracker_test latency_tracker_test.cpp) +add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) find_package(GTest) @@ -76,6 +78,7 @@ if(NOT GTEST_FOUND) add_dependencies(warning_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) + add_dependencies(metrics_registry_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -118,6 +121,8 @@ target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} target_link_libraries( latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) +target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) diff --git a/tests/metrics_registry_test.cpp b/tests/metrics_registry_test.cpp new file mode 100644 index 000000000..1abef58e1 --- /dev/null +++ b/tests/metrics_registry_test.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include + +TEST(MetricsRegistryTest, EmptyRegistryHasZeroedCounters) +{ + MetricsRegistry registry; + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + EXPECT_EQ(0, registry.getGameStartSnapshot().count); +} + +TEST(MetricsRegistryTest, SampleIsRecordedInTotalsAndSlot) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), 7); + + EXPECT_EQ(1, registry.totalCommands()); + EXPECT_EQ(7, registry.totalTimeMs()); + EXPECT_EQ(1, registry.activeTypeCount()); + + const auto stats = registry.collectActiveStats(); + ASSERT_EQ(1, stats.size()); + EXPECT_EQ(MetricsRegistry::typeIdFor(0, 1000), stats[0].typeId); + EXPECT_EQ(1, stats[0].count); + EXPECT_EQ(7, stats[0].totalMs); +} + +TEST(MetricsRegistryTest, KindEncodingSeparatesSameExtensionNumber) +{ + MetricsRegistry registry; + const int sessionPing = MetricsRegistry::typeIdFor(0, 1000); + const int roomLeaveRoom = MetricsRegistry::typeIdFor(1, 1000); + ASSERT_NE(sessionPing, roomLeaveRoom); + + registry.observeCommand(sessionPing, 1); + registry.observeCommand(roomLeaveRoom, 5000); + + EXPECT_EQ(2, registry.activeTypeCount()); +} + +TEST(MetricsRegistryTest, OutOfRangeIdsLandInOverflowSlot) +{ + MetricsRegistry registry; + registry.observeCommand(-1, 4); + registry.observeCommand(MetricsRegistry::MaxTypes + 12345, 4); + + EXPECT_EQ(2, registry.totalCommands()); + EXPECT_EQ(1, registry.activeTypeCount()); // both collapsed into one slot + EXPECT_EQ(8, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, NegativeDurationsAreClamped) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), -50); + + EXPECT_EQ(0, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, GameStartTrackedSeparatelyFromCommands) +{ + MetricsRegistry registry; + registry.observeGameStartDurationMs(120); + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + + const auto snapshot = registry.getGameStartSnapshot(); + EXPECT_EQ(1, snapshot.count); + EXPECT_EQ(120, snapshot.totalMs); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file From e9bf1e6e467558bdbb8e9e913725d2d70903b1f7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:59:57 +0200 Subject: [PATCH 21/24] [Theme] Add scheme-variant asset resolution (#7275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add scheme-variant theme asset resolution (#7209-1) ThemeManager::assetPath() and schemeVariantPath() resolve a theme asset to its scheme-variant file (prefix-light/dark.png) with fallback to the plain asset, and themePixmap()/loadBrush()/loadExtraBrush() use them. CSS files load style-dark.css or style-light.css when present. Home widget re-resolves its background on theme change. Link pixel_map_generator.cpp into the oracle target, which needs Qt6::Xml for QDomDocument. * Fix clang-format wrap of theme format probe lists --------- Co-authored-by: Lukas Brübach --- cmake/FindQtRuntime.cmake | 2 +- .../src/interface/pixel_map_generator.cpp | 57 ++++++++++++++-- .../src/interface/pixel_map_generator.h | 11 ++++ cockatrice/src/interface/theme_manager.cpp | 65 +++++++++++++++++-- cockatrice/src/interface/theme_manager.h | 14 ++++ .../interface/widgets/general/home_widget.cpp | 9 ++- oracle/CMakeLists.txt | 1 + 7 files changed, 147 insertions(+), 12 deletions(-) diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 0259d12e1..971c9094d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -28,7 +28,7 @@ if(WITH_CLIENT) ) endif() if(WITH_ORACLE) - set(_ORACLE_NEEDED Concurrent Network Svg Widgets) + set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml) endif() if(TEST) # Union of Qt modules required across all test targets (independent of application targets). diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 674f3dd4c..e74e86471 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,5 +1,7 @@ #include "pixel_map_generator.h" +#include "theme_manager.h" + #include #include #include @@ -83,7 +85,13 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl /** * Try to load path image from non-SVG formats, otherwise fall back to SVG. * This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading. - * @param path The path to the file, with no file extension. File formats will be automatically detected. + * + * The path may already carry the resolved file extension (e.g. via + * ThemeManager::assetPath); such paths are loaded directly. Otherwise a + * format-agnostic lookup probes png, jpg and finally svg. + * + * @param path The path to the file, with no file extension unless the caller + * already resolved it. File formats will be automatically detected. * @param size The desired size of the pixmap. * @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG). * @@ -91,6 +99,19 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl */ static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false) { + if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) { + return loadSvg(path, size, expandOnly); + } + if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) { + QPixmap pix(path); + if (!pix.isNull()) { + return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return {}; + } + const auto formats = {"png", "jpg"}; QPixmap returnPixmap; @@ -112,7 +133,8 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height)); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name), + QSize(height, height)); pmCache.insert(key, pixmap); return pixmap; @@ -399,7 +421,8 @@ QPixmap LockPixmapGenerator::generatePixmap(int height) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; } @@ -414,7 +437,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) } QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; @@ -475,6 +499,13 @@ QHash ManaSymbolPixmapGenerator::scaledCache; QPixmap loadColorAdjustedPixmap(const QString &name) { + // Prefer an authored scheme-qualified variant when one exists for this asset. + const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); + if (!variant.isEmpty()) { + return QPixmap(QStringLiteral("theme:") + variant); + } + + // Legacy fallback: runtime-invert for dark mode when no authored variant. if (qApp->palette().windowText().color().lightness() > 200) { QImage img(name); img.invertPixels(); @@ -485,3 +516,21 @@ QPixmap loadColorAdjustedPixmap(const QString &name) return QPixmap(name); } } + +QPixmap themePixmap(QStringView prefix) +{ + const QString resolved = themeManager->assetPath(prefix); + return QPixmap(QStringLiteral("theme:") + resolved); +} + +void clearPixmapGeneratorCaches() +{ + PhasePixmapGenerator::clear(); + CounterPixmapGenerator::clear(); + PingPixmapGenerator::clear(); + CountryPixmapGenerator::clear(); + UserLevelPixmapGenerator::clear(); + LockPixmapGenerator::clear(); + DropdownIconPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); +} diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 17720166a..b6e822fd9 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -156,4 +156,15 @@ public: QPixmap loadColorAdjustedPixmap(const QString &name); +// Loads a "theme:" asset (with no file extension in prefix), preferring the +// scheme-qualified variant (prefix-dark / prefix-light, resolved via +// ThemeManager::assetPath) and falling back to the plain asset. Callers load +// the returned path directly. Use for scheme-sensitive pixmaps like +// backgrounds, the card back, and the app logo. +QPixmap themePixmap(QStringView prefix); + +// Clears every PixmapGenerator's static cache so scheme variants are +// re-resolved when the active theme or color scheme changes. +void clearPixmapGeneratorCaches(); + #endif diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index e6b4b3c7f..cc6be175a 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -1,10 +1,12 @@ #include "theme_manager.h" #include "../../client/settings/cache_settings.h" +#include "pixel_map_generator.h" #include #include #include +#include #include #include #include @@ -140,6 +142,48 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) const } } +QString ThemeManager::schemeVariantPath(QStringView prefix) const +{ + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme; + + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) { + return variantStem + format; + } + } + return QString(); +} + +QString ThemeManager::assetPath(QStringView prefix) const +{ + // Probe order mirrors tryLoadImage: a theme may override the default SVG + // with a raster of the same stem, so raster wins over SVG within a stem. + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + + auto findExisting = [](const QString &stem) { + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) { + return stem + format; + } + } + return QString(); + }; + + // Prefer the scheme-qualified variant when it exists, else the plain + // asset as the super fallback. Both return the resolved path including + // its file extension so callers can load it directly. + const QString variant = schemeVariantPath(prefix); + if (!variant.isEmpty()) { + return variant; + } + const QString resolvedPlain = findExisting(prefix.toString()); + return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; +} + bool ThemeManager::isBuiltInTheme() { const auto themeName = SettingsCache::instance().getThemeName(); @@ -195,7 +239,7 @@ QStringMap &ThemeManager::getAvailableThemes() QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush.setColor(fallbackColor); brush.setStyle(Qt::SolidPattern); @@ -209,7 +253,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush = fallbackBrush; @@ -393,9 +437,19 @@ void ThemeManager::themeChangedSlot() currentThemePath = dirPath; QDir dir(dirPath); - // CSS - if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) { - qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + // CSS — prefer the scheme-qualified stylesheet (style-dark.css / + // style-light.css) when present, else the plain style.css as fallback. + if (!dirPath.isEmpty()) { + const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') + + scheme + QStringLiteral(".css"); + if (dir.exists(schemeCss)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss)); + } else if (dir.exists(STYLE_CSS_NAME)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + } else { + qApp->setStyleSheet(""); + } } else { qApp->setStyleSheet(""); } @@ -446,6 +500,7 @@ void ThemeManager::themeChangedSlot() } QPixmapCache::clear(); + clearPixmapGeneratorCaches(); emit themeChanged(); } diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 79a1b6470..ac35042a0 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -87,6 +87,20 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed + // internally across the formats themes may ship (.png/.jpg/.svg), so + // callers load the returned path directly. Prefers "-" + // when a file exists at that stem, otherwise the plain "" as the + // super fallback. The resolved scheme covers explicit light/dark as well + // as OS-resolved "system". Returns the path with its file extension when a + // match is found; unqualified assets keep working unchanged. + QString assetPath(QStringView prefix) const; + // Like assetPath, but resolves only the scheme-qualified variant + // ("-.") and returns an empty string when no + // variant exists — it never falls back to the plain "" asset. + // Callers that must distinguish "no authored variant" (e.g. to keep a + // legacy runtime fallback alive) should use this instead of assetPath. + QString schemeVariantPath(QStringView prefix) const; // Load the theme's shipped default palette, falling back to the system // theme directory when it is absent from the resolved (user) directory. static PaletteConfig diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 10fcdcb43..0d030b973 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" +#include "../../pixel_map_generator.h" #include "../../theme_manager.h" #include "../../window_main.h" #include "../cards/art_crop_attribution.h" @@ -20,7 +21,8 @@ #include HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) - : QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice") + : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))), + overlay(themePixmap(QStringLiteral("cockatrice"))) { layout = new QGridLayout(this); @@ -56,6 +58,9 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + // Scheme flips (light/dark/system with an OS switch) fire on themeManager, + // not on SettingsCache::themeChanged, so re-resolve the variant background. + connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, &HomeWidget::updateButtonsToBackgroundColor); } @@ -74,7 +79,7 @@ void HomeWidget::initializeBackgroundFromSource() switch (backgroundSourceType) { case BackgroundSources::Theme: cardChangeTimer->stop(); - background = QPixmap("theme:backgrounds/home"); + background = themePixmap(QStringLiteral("backgrounds/home")); backgroundSourceDeck = DeckList(); backgroundSourceCard->setCard(ExactCard()); updateButtonsToBackgroundColor(); diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 68c4709bb..392184b6e 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -28,6 +28,7 @@ set(oracle_SOURCES ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp ../cockatrice/src/client/network/update/client/release_channel.cpp + ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_manager.cpp ../cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp From a85203e457c4a5eddfee9046a0477fd33dbc7557 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:59:57 +0200 Subject: [PATCH 22/24] Migrate theme asset loads to scheme-variant resolution (#7209-2) (#7276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace remaining QPixmap("theme:...") call sites with themePixmap() so icons, replay controls, card backs, and other images resolve to -dark/-light variants under theme schemes. Stem-exact 1:1 migration; behavior unchanged for non-variant themes. Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 3 ++- cockatrice/src/filters/filter_builder.cpp | 3 ++- cockatrice/src/game_graphics/hand_counter.cpp | 4 +++- .../player/player_list_widget.cpp | 8 ++++---- .../game_graphics/zones/view_zone_widget.cpp | 4 ++-- .../card_picture_loader.cpp | 7 ++++--- .../abstract_analytics_panel_widget.cpp | 3 ++- .../deck_editor_database_display_widget.cpp | 6 +++--- .../deck_editor_deck_dock_widget.cpp | 9 +++++---- .../deck_editor_filter_dock_widget.cpp | 5 +++-- .../deck_list_history_manager_widget.cpp | 7 ++++--- .../interface/widgets/dialogs/dlg_connect.cpp | 7 ++++--- .../widgets/dialogs/dlg_edit_tokens.cpp | 5 +++-- .../widgets/dialogs/dlg_manage_sets.cpp | 11 ++++++----- .../widgets/dialogs/dlg_register.cpp | 5 +++-- .../widgets/dialogs/dlg_settings.cpp | 3 ++- .../quick_settings/settings_button_widget.cpp | 4 +++- .../widgets/replay/replay_widget.cpp | 7 ++++--- .../widgets/server/game_selector.cpp | 5 +++-- .../remote/remote_replay_list_tree_widget.cpp | 4 +++- .../deck_editor_settings_page.cpp | 7 ++++--- .../settings_page/messages_settings_page.cpp | 7 ++++--- .../settings_page/shortcut_settings_page.cpp | 5 +++-- .../tabs/api/archidekt/tab_archidekt.cpp | 3 ++- .../commander_bracket_widget.cpp | 3 ++- .../widgets/tabs/tab_deck_storage.cpp | 15 ++++++++------- .../interface/widgets/tabs/tab_replays.cpp | 19 ++++++++++--------- .../src/interface/widgets/tabs/tab_room.cpp | 3 ++- .../interface/widgets/tabs/tab_supervisor.cpp | 2 +- .../widgets/utility/sequence_edit.cpp | 5 +++-- ...database_display_filter_toolbar_widget.cpp | 13 +++++++------ .../visual_database_display_widget.cpp | 4 ++-- .../visual_deck_display_options_widget.cpp | 9 +++++---- .../visual_deck_editor_widget.cpp | 3 ++- .../visual_deck_storage_search_widget.cpp | 2 +- .../visual_deck_storage_widget.cpp | 3 ++- cockatrice/src/interface/window_main.cpp | 8 +++++--- cockatrice/src/main.cpp | 2 +- 38 files changed, 129 insertions(+), 94 deletions(-) diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 890a621c8..c1598bd25 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -1,5 +1,6 @@ #include "remote_connection_controller.h" +#include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" @@ -180,7 +181,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve "games will be lost.\nReason for shutdown: %1", "", event.minutes()) .arg(QString::fromStdString(event.reason()))); - serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64)); + serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64)); serverShutdownMessageBox.setText(tr("Scheduled server shutdown")); serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal); serverShutdownMessageBox.setVisible(true); diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 785f753e7..f109fbcd3 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,5 +1,6 @@ #include "filter_builder.h" +#include "../interface/pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -21,7 +22,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString()); + QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp index 35989ff38..8dcbcfdaa 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,5 +1,6 @@ #include "hand_counter.h" +#include "../interface/pixel_map_generator.h" #include "zones/card_zone.h" #include @@ -32,7 +33,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize(); QPixmap cachedPixmap; if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) { - cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + cachedPixmap = + themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap); } resetPainterTransform(painter); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 13a077af8..a7ec2e4a9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -53,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = QPixmap("theme:icons/ready_start"); - notReadyIcon = QPixmap("theme:icons/not_ready_start"); - concededIcon = QPixmap("theme:icons/conceded"); + readyIcon = themePixmap(QStringLiteral("icons/ready_start")); + notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); + concededIcon = themePixmap(QStringLiteral("icons/conceded")); playerIcon = loadColorAdjustedPixmap("theme:icons/player"); judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 17118e80d..c13b79fea 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -62,7 +62,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); @@ -549,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const { QStyleOptionTitleBar *titleBar = qstyleoption_cast(option); if (titleBar) { - titleBar->icon = QPixmap("theme:cockatrice"); + titleBar->icon = themePixmap(QStringLiteral("cockatrice")); } } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 7daafb610..8c81d641d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -1,6 +1,7 @@ #include "card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include "../pixel_map_generator.h" #include "card_picture_loader_cache_method.h" #include "card_picture_loader_local_schemes.h" @@ -62,7 +63,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap."; @@ -83,7 +84,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz "_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback."; @@ -105,7 +106,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si "_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback."; diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 089abc5c8..483fc71b0 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,5 +1,6 @@ #include "abstract_analytics_panel_widget.h" +#include "../../pixel_map_generator.h" #include "deck_list_statistics_analyzer.h" #include @@ -20,7 +21,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(QPixmap("theme:icons/cogwheel")); + configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 9da821813..c8bdacf0d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -28,7 +28,7 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -59,13 +59,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(QPixmap("theme:icons/arrow_right_green")); + aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(QPixmap("theme:icons/arrow_right_blue")); + aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index e2175a358..a3653f03e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../playmat/playmat_settings_dialog.h" #include "../settings_page/user_interface_settings_page.h" #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" @@ -192,25 +193,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(QPixmap("theme:icons/increment")); + aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(QPixmap("theme:icons/decrement")); + aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(QPixmap("theme:icons/remove_row")); + aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(QPixmap("theme:icons/swap")); + aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 2d4fb60e8..c6b6e4416 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" +#include "../../pixel_map_generator.h" #include #include @@ -42,11 +43,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(QPixmap("theme:icons/decrement")); + aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(QPixmap("theme:icons/clearsearch")); + aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index cef459752..c93f12b34 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,5 +1,6 @@ #include "deck_list_history_manager_widget.h" +#include "../../pixel_map_generator.h" #include "deck_state_manager.h" DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager, @@ -10,7 +11,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(QPixmap("theme:icons/arrow_undo")); + aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -19,7 +20,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(QPixmap("theme:icons/arrow_redo")); + aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -31,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(QPixmap("theme:icons/arrow_history")); + historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index aa8a916f8..700012d45 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,6 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -21,7 +22,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -29,7 +30,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -99,7 +100,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(QPixmap("theme:icons/forgot_password")); + btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index f249976c2..a56aa8e35 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,5 +1,6 @@ #include "dlg_edit_tokens.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" #include @@ -90,10 +91,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(QPixmap("theme:icons/increment")); + aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(QPixmap("theme:icons/decrement")); + aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index 7c107eb2f..556c270be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,6 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" @@ -35,28 +36,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(QPixmap("theme:icons/arrow_top_green")); + aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(QPixmap("theme:icons/arrow_up_green")); + aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(QPixmap("theme:icons/arrow_down_green")); + aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(QPixmap("theme:icons/arrow_bottom_green")); + aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -66,7 +67,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(QPixmap("theme:icons/search"), LineEditUnfocusable::LeadingPosition); + searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 6ae8c9adb..6d022861c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,6 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -24,7 +25,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -32,7 +33,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index 4698b011f..de7dd3e97 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -6,6 +6,7 @@ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -96,7 +97,7 @@ void DlgSettings::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index 9c433ab5a..881c3e3ac 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,5 +1,7 @@ #include "settings_button_widget.h" +#include "../../pixel_map_generator.h" + #include #include #include @@ -8,7 +10,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(QPixmap("theme:icons/cogwheel")); + button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 6c85d950e..c92771f8c 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/tabs/tab_game.h" #include "replay_manager.h" #include "replay_quick_settings_widget.h" @@ -50,15 +51,15 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) replayPlayButton = new QToolButton; replayPlayButton->setIconSize(QSize(32, 32)); QIcon playButtonIcon = QIcon(); - playButtonIcon.addPixmap(QPixmap("theme:replay/start"), QIcon::Normal, QIcon::Off); - playButtonIcon.addPixmap(QPixmap("theme:replay/pause"), QIcon::Normal, QIcon::On); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/start")), QIcon::Normal, QIcon::Off); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/pause")), QIcon::Normal, QIcon::On); replayPlayButton->setIcon(playButtonIcon); replayPlayButton->setCheckable(true); connect(replayPlayButton, &QToolButton::toggled, this, &ReplayWidget::replayPlayButtonToggled); replayFastForwardButton = new QToolButton; replayFastForwardButton->setIconSize(QSize(32, 32)); - replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward")); + replayFastForwardButton->setIcon(themePixmap(QStringLiteral("replay/fastforward"))); replayFastForwardButton->setCheckable(true); connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor); diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index f41002247..a8bf54e91 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,5 +1,6 @@ #include "game_selector.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_account.h" @@ -95,10 +96,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(QPixmap("theme:icons/search")); + filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(QPixmap("theme:icons/clearsearch")); + clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index 1f034b767..d62bf81aa 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,5 +1,7 @@ #include "remote_replay_list_tree_widget.h" +#include "../../../pixel_map_generator.h" + #include #include #include @@ -37,7 +39,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index f425afe60..f3eac05b8 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,6 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "update/card_spoiler/spoiler_background_updater.h" #include @@ -53,15 +54,15 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); auto *urlToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index c161030d8..a3b89f8c4 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,6 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" #include @@ -107,15 +108,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index 1f1867f7c..1277d0e49 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,6 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -47,8 +48,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(QPixmap("theme:icons/update")); - btnClearAll->setIcon(QPixmap("theme:icons/clearsearch")); + btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); + btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 98b21d0f1..888197f53 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,6 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../utility/completer_utils.h" #include "../../tab_supervisor.h" @@ -213,7 +214,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 4f50e38a6..2cbbedfa4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -1,6 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "commander_bracket_service.h" #include @@ -30,7 +31,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(QPixmap("theme:icons/reload")); + bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 49e42e4cf..f3535d850 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -105,19 +106,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(QPixmap("theme:icons/arrow_right_green")); + aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -126,16 +127,16 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 5618604df..ac4b2cbe9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,6 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "tab_game.h" @@ -102,17 +103,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(QPixmap("theme:icons/view")); + aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -164,24 +165,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(QPixmap("theme:icons/view")); + aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(QPixmap("theme:icons/lock")); + aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(QPixmap("theme:icons/share")); + aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(QPixmap("theme:icons/search")); + aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 6245b5301..866324367 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -98,7 +99,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(QPixmap("theme:icons/settings")); + chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index bb8ec719e..462aa420b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1300,7 +1300,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), QPixmap("theme:icons/tab_changed")); + setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index c6bf289ba..06561a41d 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -14,8 +15,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(QPixmap("theme:icons/clearsearch")); - defaultButton->setIcon(QPixmap("theme:icons/update")); + clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); auto *layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 4a558a5e0..a59a068dd 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,5 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" +#include "../../pixel_map_generator.h" #include "../deck_editor/card_database_view.h" #include "visual_database_display_widget.h" @@ -60,22 +61,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(QPixmap("theme:icons/floppy_disk")); + quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(QPixmap("theme:icons/pen_to_square")); + quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(QPixmap("theme:icons/circle_half_stroke")); + quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(QPixmap("theme:icons/dragon")); + quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(QPixmap("theme:icons/scroll")); + quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 76bbf344b..f4530a12b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -66,7 +66,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -121,7 +121,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(QPixmap("theme:icons/delete")); + clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index f44c9c3ef..28abe480e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,5 +1,6 @@ #include "visual_deck_display_options_widget.h" +#include "../../pixel_map_generator.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" #include @@ -47,7 +48,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(QPixmap("theme:icons/sort_arrow_down")); + sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -92,7 +93,7 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } @@ -117,11 +118,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scroll")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); break; } emit displayTypeChanged(currentDisplayType); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index e3261b346..064fbed5d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -4,6 +4,7 @@ #include "../../../main.h" #include "../../deck_loader/deck_loader.h" #include "../../layouts/overlap_layout.h" +#include "../../pixel_map_generator.h" #include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -131,7 +132,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(QPixmap("theme:icons/search")); + searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); if (card) { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index baa5e5792..406527893 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -25,7 +25,7 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar->setClearButtonEnabled(true); searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index acb0dcab2..da7ddc368 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,6 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../quick_settings/settings_button_widget.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" @@ -43,7 +44,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 43f1de9dd..ce28fc24a 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -44,6 +44,7 @@ #include "intents/intent_open_server_room_by_name.h" #include "intents/url_parser.h" #include "logger.h" +#include "pixel_map_generator.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" @@ -271,7 +272,8 @@ void MainWindow::actAbout() GITHUB_TROUBLESHOOTING_URL + "'>" + tr("Troubleshooting") + "
" + "" + tr("F.A.Q.") + "
"), QMessageBox::Ok, this); - mb.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + mb.setIconPixmap( + themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); mb.exec(); } @@ -323,7 +325,7 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(QPixmap("theme:icons/settings")); + aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -817,7 +819,7 @@ void MainWindow::createTrayIcon() trayIcon = new QSystemTrayIcon(this); trayIcon->setContextMenu(trayIconMenu); - trayIcon->setIcon(QPixmap("theme:cockatrice")); + trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice"))); trayIcon->show(); } diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index d8aa1cd08..ac77b4241 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -349,7 +349,7 @@ int main(int argc, char *argv[]) } qCInfo(MainLog) << "MainWindow constructor finished"; - ui.setWindowIcon(QPixmap("theme:cockatrice")); + ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); // set name of the app desktop file; used by wayland to load the window icon QGuiApplication::setDesktopFileName("cockatrice"); From 5d025ca0bde16f8ab88ffffa3c37e20d0e200b8f Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 12 Sep 2026 17:30:46 +0200 Subject: [PATCH 23/24] Use capitalized app names (#7255) * use capitalized app name * update urls * app description * Update main.cpp --- cockatrice/src/client/sound_engine.cpp | 4 ++-- cockatrice/src/interface/deck_loader/deck_file_format.h | 2 +- cockatrice/src/interface/deck_loader/deck_loader.cpp | 2 +- cockatrice/src/interface/deck_loader/deck_loader.h | 2 +- cockatrice/src/interface/theme_manager.cpp | 2 +- cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp | 4 ++-- cockatrice/src/interface/window_main.cpp | 6 +++--- cockatrice/src/main.cpp | 8 ++++---- oracle/src/main.cpp | 6 +++--- oracle/src/pages.cpp | 2 +- oracle/src/parsehelpers.cpp | 2 +- oracle/src/raw_json_scanner.h | 2 +- servatrice/src/servatrice_database_interface.cpp | 7 ++++--- 13 files changed, 25 insertions(+), 24 deletions(-) diff --git a/cockatrice/src/client/sound_engine.cpp b/cockatrice/src/client/sound_engine.cpp index 18de2264d..96cafa3d3 100644 --- a/cockatrice/src/client/sound_engine.cpp +++ b/cockatrice/src/client/sound_engine.cpp @@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes() QDir dir; availableThemes.clear(); - // load themes from user profile dir + // Load themes from user profile dir dir.setPath(SettingsCache::instance().getDataPath() + "/sounds"); @@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes() } } - // load themes from cockatrice system dir + // Load themes from Cockatrice system dir dir.setPath(qApp->applicationDirPath() + #ifdef Q_OS_MAC "/../Resources/sounds" diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/cockatrice/src/interface/deck_loader/deck_file_format.h index 995de32c0..3a25797ec 100644 --- a/cockatrice/src/interface/deck_loader/deck_file_format.h +++ b/cockatrice/src/interface/deck_loader/deck_file_format.h @@ -17,7 +17,7 @@ enum Format PlainText, /** - * This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. + * This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. * Stored as .cod files. */ Cockatrice diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index f03339da8..f29b4eed2 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo result = deckList.loadFromFile_Native(&file); if (!result) { qCInfo(DeckLoaderLog) << "Failed to load " << fileName - << "as cockatrice format; retrying as plain format"; + << "as Cockatrice format; retrying as plain format"; file.seek(0); result = deckList.loadFromFile_Plain(&file, CardNameNormalizer()); fmt = DeckFileFormat::PlainText; diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index b851c6895..be0df311d 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -131,7 +131,7 @@ public: static void printDeckList(QPrinter *printer, const DeckList &deckList); /** - * Converts the given deck's file to the cockatrice file format. + * Converts the given deck's file to the Cockatrice file format. * Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to. * @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo. * @return Whether the conversion succeeded. diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index cc6be175a..12c8fad2c 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -224,7 +224,7 @@ QStringMap &ThemeManager::getAvailableThemes() } } - // load themes from cockatrice system dir + // Load themes from Cockatrice system dir dir.setPath(systemThemesBasePath()); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index de7dd3e97..fb559fc4b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -478,13 +478,13 @@ void DlgSettings::closeEvent(QCloseEvent *event) case Invalid: loadErrorMessage = tr("Your card database is invalid.\n\n" "Cockatrice may not function correctly with an invalid database\n\n" - "You may need to rerun oracle to update your card database.\n\n" + "You may need to rerun Oracle to update your card database.\n\n" "Would you like to change your database location setting?"); break; case VersionTooOld: loadErrorMessage = tr("Your card database version is too old.\n\n" "This can cause problems loading card information or images\n\n" - "Usually this can be fixed by rerunning oracle to to update your card database.\n\n" + "Usually this can be fixed by rerunning Oracle to to update your card database.\n\n" "Would you like to change your database location setting?"); break; case NotLoaded: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index ce28fc24a..3daaeb8d3 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -92,8 +92,8 @@ #include #define GITHUB_PAGES_URL "https://cockatrice.github.io" -#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c" -#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice" +#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors" +#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#" #define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame" #define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ" #define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues" @@ -1050,7 +1050,7 @@ void MainWindow::createCardUpdateProcess(bool background) if (dir.exists(binaryName)) { updaterCmd = dir.absoluteFilePath(binaryName); - } else { // try and find the directory oracle is stored in the build directory + } else { // try and find the directory Oracle is stored in the build directory QDir findLocalDir(dir); findLocalDir.cdUp(); findLocalDir.cd(getCardUpdaterBinaryName()); diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index ac77b4241..829e08742 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -231,7 +231,7 @@ int main(int argc, char *argv[]) // These values are only used by the settings loader/saver // Wrong or outdated values are kept to not break things QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice.de"); + QCoreApplication::setOrganizationDomain("cockatrice.github.io"); QCoreApplication::setApplicationName("Cockatrice"); QCoreApplication::setApplicationVersion(VERSION_STRING); @@ -250,7 +250,7 @@ int main(int argc, char *argv[]) // Command-line parser QCommandLineParser parser; - parser.setApplicationDescription("Cockatrice"); + parser.setApplicationDescription("Cockatrice Client"); parser.addHelpOption(); parser.addVersionOption(); @@ -350,8 +350,8 @@ int main(int argc, char *argv[]) qCInfo(MainLog) << "MainWindow constructor finished"; ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); - // set name of the app desktop file; used by wayland to load the window icon - QGuiApplication::setDesktopFileName("cockatrice"); + // Set name of the app desktop file; used by wayland to load the window icon + QGuiApplication::setDesktopFileName("Cockatrice"); SettingsCache::instance().network().setClientID(generateClientID()); diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index bb88153b1..0f962f9be 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -50,8 +50,8 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice"); - // this can't be changed, as it influences the default save path for cards.xml + QCoreApplication::setOrganizationDomain("Cockatrice"); + // This can't be changed, as it influences the default save path for cards.xml QCoreApplication::setApplicationName("Cockatrice"); // If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens @@ -83,7 +83,7 @@ int main(int argc, char *argv[]) QIcon icon("theme:appicon.svg"); wizard.setWindowIcon(icon); // set name of the app desktop file; used by wayland to load the window icon - QGuiApplication::setDesktopFileName("oracle"); + QGuiApplication::setDesktopFileName("Oracle"); wizard.show(); diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index a10daadb3..df0c3f51d 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -796,7 +796,7 @@ void SaveSetsPage::retranslateUi() { setTitle(tr("Sets imported")); if (wizard()->downloadedPlainXml) { - setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.") + setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.") .arg(qRound(wizard()->xmlData.size() / 1000000.0))); } else { setSubTitle(tr("The following sets have been found:")); diff --git a/oracle/src/parsehelpers.cpp b/oracle/src/parsehelpers.cpp index a97bf67c9..ee7c4eb2e 100644 --- a/oracle/src/parsehelpers.cpp +++ b/oracle/src/parsehelpers.cpp @@ -20,7 +20,7 @@ * Note that "...enters tapped unless..." returns false. * * @param name The name of the card - * @param text The oracle text of the card + * @param text The Oracle text of the card */ bool parseCipt(const QString &name, const QString &text) { diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h index 086f2b3e3..04fcc52e4 100644 --- a/oracle/src/raw_json_scanner.h +++ b/oracle/src/raw_json_scanner.h @@ -54,7 +54,7 @@ using ScanProgressCallback = std::function expectedversion) { - qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version" - << dbversion << "is too new, you need to update servatrice" - << "(this servatrice actually uses version" << expectedversion << ")"; + qCCritical(DatabaseInterfaceLog) + << poolStr << "Error opening database: the database schema version" << dbversion + << "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version" + << expectedversion << ")"; return false; } } else { From 6150eb43e871f2636ff44f432f76f8cc2e25df1c Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 12 Sep 2026 20:30:39 +0200 Subject: [PATCH 24/24] use new variable for added test to master, too --- tests/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f94797387..cd2a587e9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -121,7 +121,7 @@ target_link_libraries( latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST} ) target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) -target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) +target_link_libraries(metrics_registry_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${QT_MODULES_TEST}) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase)