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" 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 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/CMakeLists.txt b/cockatrice/CMakeLists.txt index b6050a1bd..4263fc6e2 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 @@ -381,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 @@ -401,6 +403,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/brand_colors.h src/interface/widgets/onboarding/first_run_wizard.cpp src/interface/widgets/onboarding/first_run_wizard.h src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -528,6 +531,7 @@ qt6_add_shaders( "src/interface/widgets/onboarding/shaders" FILES src/interface/widgets/onboarding/shaders/brand_banner.frag + src/interface/widgets/onboarding/shaders/brand_plate.frag ) qt6_add_resources( diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index e21bdb0be..14cf15b2f 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -63,6 +63,8 @@ resources/icons/mana/W.svg resources/backgrounds/home.png + resources/backgrounds/home-dark.png + resources/backgrounds/home-light.png resources/backgrounds/card_triplet.svg resources/backgrounds/placeholder_printing_selector.svg @@ -363,6 +365,8 @@ resources/usericons/pawn_single.svg resources/usericons/pawn_double.svg + resources/usericons/pawn_dev_single.svg + resources/usericons/pawn_dev_double.svg resources/usericons/pawn_donator_single.svg resources/usericons/pawn_donator_double.svg resources/usericons/pawn_judge_single.svg diff --git a/cockatrice/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png new file mode 100644 index 000000000..68f48e2c2 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.png differ diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png new file mode 100644 index 000000000..eaaaba932 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.png differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png index 68f48e2c2..eaaaba932 100644 Binary files a/cockatrice/resources/backgrounds/home.png and b/cockatrice/resources/backgrounds/home.png differ diff --git a/cockatrice/resources/usericons/pawn_dev_double.svg b/cockatrice/resources/usericons/pawn_dev_double.svg new file mode 100644 index 000000000..57ed5c2da --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_double.svg @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/cockatrice/resources/usericons/pawn_dev_single.svg b/cockatrice/resources/usericons/pawn_dev_single.svg new file mode 100644 index 000000000..f7c4e7018 --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_single.svg @@ -0,0 +1,211 @@ + + + +image/svg+xml + + 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/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/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/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/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/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/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/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/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 1410d0c80..3969b7d03 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,6 +1,7 @@ #include "abstract_card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../game_scene.h" #include "../z_values.h" @@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, @@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS if (SettingsCache::instance().debug().getShowCardId()) { prefix = "#" + QString::number(id) + " "; } - nameStr = prefix + cardRef.name; + nameStr = prefix + CardLocalization::displayName(getCardInfo()); } painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, translatedSize.height() - 6 * scaleFactor), 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/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()) { 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) { 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_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 8bf2703e1..122ab83be 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" @@ -251,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(); } }); @@ -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/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 4268e1019..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); @@ -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/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 910ee9c17..d6c28370d 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()) { @@ -132,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()); 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/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_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..0bfe4a764 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#include +#include +#include + +namespace CardLocalization +{ +/** + * @brief The language code selected for localized card text and images. + */ +inline QString displayLang() +{ + return SettingsCache::instance().cardsDisplay().getCardLang(); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized name, or an empty string for a null card. + */ +inline QString displayName(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized text, or an empty string for a null card. + */ +inline QString displayText(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedText(displayLang()); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayName(const CardInfo &card) +{ + return card.getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayText(const CardInfo &card) +{ + return card.getLocalizedText(displayLang()); +} +} // namespace CardLocalization + +#endif // COCKATRICE_CARD_LOCALIZATION_H \ No newline at end of file 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..b8a54761a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -1,13 +1,14 @@ #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" #include #include #include -#include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -36,8 +38,10 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) &CardPictureLoader::picsPathChanged); connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, &CardPictureLoader::picDownloadChanged); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardPictureLoader::cardLangChanged); - qRegisterMetaType(); + qRegisterMetaType("ExactCard"); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); statusBar = new CardPictureLoaderStatusBar(nullptr); @@ -62,7 +66,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 +87,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 +109,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."; @@ -138,7 +142,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()) { @@ -204,7 +209,49 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) card.emitPixmapUpdated(); } -void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap) +void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + auto printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + QString fullPath = baseDir.filePath(rel); + + if (QFile::exists(fullPath)) { + QFile::remove(fullPath); + } + } +} + +void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, + const QPixmap &pixmap, + const bool allowOverwrite) { if (pixmap.isNull() || !card) { return; @@ -264,8 +311,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QFileInfo outInfo(baseDir.filePath(relativePath)); - // Do not overwrite existing files - if (outInfo.exists()) { + // Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override. + // Only the explicit override paths pass allowOverwrite == true. + if (!allowOverwrite && outInfo.exists()) { return; } @@ -286,6 +334,122 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const } } +void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Overriding a card with itself is the reset case, not a real override: every code path below + // would re-enter itself through emitPixmapUpdated(). Reject it outright. + if (originalCard == overrideCard) { + return; + } + + CardInfoPtr cardPtr = overrideCard.getCardPtr(); + if (!cardPtr) { + return; + } + + // Heap-allocate so the lambda can capture it before the connection is made + auto *connectionHandle = new QMetaObject::Connection; + + *connectionHandle = + connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(), + [originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) { + // All printings share the same CardInfo, so ignore updates triggered by any + // other printing (e.g., the original card re-loading from disk). + if (printing != overrideCard.getPrinting()) { + return; + } + + QPixmap pixmap; + if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) { + // The override art has resolved — persist it and reflect it immediately. + // Retire the connection before emitting so the refresh can't re-enter. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + + QObject::disconnect(*connectionHandle); + delete connectionHandle; + + QPixmapCache::clear(); + originalCard.emitPixmapUpdated(); + return; + } + + // The art could not be resolved. Keep the connection armed so a late resolution + // still lands, and surface a visible refusal instead of a silent no-op. An + // override already on disk is left untouched and simply re-displayed. + QPixmapCache::clear(); + if (!hasLocalOverrides(originalCard)) { + QPixmap refusedPixmap; + getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672)); + QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap); + } + originalCard.emitPixmapUpdated(); + }); + + // Now enqueue; if the image is already loading (deduplicated in the worker), + // the signal will still fire when it completes + CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard); +} + +void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Same guard as installPrintingOverrideOnLoad: self-override is the reset case. + if (originalCard == overrideCard) { + return; + } + + QPixmap pixmap; + const QString key = overrideCard.getPixmapCacheKey(); + + if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) { + // Already cached — save immediately; the caller refreshes the card. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + return; + } + + // Cache miss or previously failed load — enqueue load and wait for the signal. + installPrintingOverrideOnLoad(originalCard, overrideCard); +} + +bool CardPictureLoader::hasLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return false; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return false; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + const PrintingInfo printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + if (QFile::exists(baseDir.filePath(rel))) { + return true; + } + } + + return false; +} + void CardPictureLoader::clearPixmapCache() { QPixmapCache::clear(); @@ -325,31 +489,11 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } -bool CardPictureLoader::hasCustomArt() +void CardPictureLoader::cardLangChanged() { - auto picsPath = SettingsCache::instance().paths().getPicsPath(); - QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot); - - // Check if there is at least one non-directory file in the pics path, other - // than in the "downloadedPics" subdirectory. - while (it.hasNext()) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) - QFileInfo dir(it.nextFileInfo()); -#else - // nextFileInfo() is only available in Qt 6.3+, for previous versions, we build - // the QFileInfo from a QString which requires more system calls. - QFileInfo dir(it.next()); -#endif - - if (it.fileName() == "downloadedPics") { - continue; - } - - QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - if (subIt.hasNext()) { - return true; - } - } - - return false; + // Localized images are fetched via a different URL, but the in-memory + // pixmap cache is keyed by card name/uuid, so drop everything cached + // (including failure timestamps) to force a reload in the new language. + QPixmapCache::clear(); + failedAt.clear(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index 5c3ac84a3..0a4934e6d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -97,10 +97,17 @@ public: static void cacheCardPixmaps(const QList &cards); /** - * @brief Check if the user has custom card art in the picsPath directory. - * @return True if any custom art exists. + * @brief Check if a local override image already exists for the card. + * @param card The card to check. + * @return True if the card has at least one locally stored override image. */ - static bool hasCustomArt(); + static bool hasLocalOverrides(const ExactCard &card); + + /** + * @brief Removes all locally stored override images for the card. + * @param card The card to remove the override images of. + */ + static void deleteAllLocalOverrides(const ExactCard &card); /** * @brief Clears the in-memory QPixmap cache for all cards. @@ -120,7 +127,9 @@ public slots: * @param image Loaded QImage. */ void imageLoaded(const ExactCard &card, const QImage &image); - void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap); + void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false); + void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard); + void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard); private slots: /** @@ -134,6 +143,12 @@ private slots: * Clears the QPixmap cache to reload images. */ void picsPathChanged(); + + /** + * @brief Triggered when the card language setting changes. + * Clears the in-memory picture caches so images reload in the new language. + */ + void cardLangChanged(); }; #endif diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 39621839a..c82fca403 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -94,6 +94,10 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant; } + // Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into + // downloadedPics/; check there as a fallback so local overrides round-trip. + candidatePaths << picsPath + "/downloadedPics/" + nameVariant; + for (const QString &path : candidatePaths) { QFileInfo fileInfo(path); QDir dir = fileInfo.dir(); @@ -105,7 +109,8 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, QStringList files = dir.entryList(QDir::Files); for (const QString &file : files) { - if (!file.startsWith(baseName)) { + QFileInfo fi(file); + if (fi.completeBaseName() != baseName) { continue; } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index d288236d2..34092f361 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -84,8 +84,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && cache->metaData(url).isValid()) { - // If we hit a cached url, we get to make the request for free, since it won't contribute towards the - // rate-limit + // A request that will be served from the disk cache never touches the network and therefore + // doesn't use up any of the rate limit, so it gets to skip the queue. makeRequest(url, worker); return; } @@ -107,10 +107,13 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); - bool useNetworkCache = - !picDownload && static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE; + // Cached entries are served straight from the disk cache even when picture downloads are + // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a + // genuine cache miss goes to the network, and only when downloads are enabled. + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index 5f4ff0bbd..7cb502e92 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -94,7 +94,8 @@ void CardPictureToLoad::populateSetUrls() } } - for (const QString &urlTemplate : urlTemplates) { + const QStringList orderedTemplates = urlTemplates; + for (const QString &urlTemplate : orderedTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { @@ -282,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } // language setting - transformMap["!sflang!"] = QString(QCoreApplication::translate( - "PictureLoader", "en", "code for scryfall's language property, not available for all languages")); + const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); + transformMap["!sflang!"] = cardLang; + + // The localized printing's own id is unknown, so Scryfall must resolve it by + // its translated name (see populateSetUrls); expose that name for the + // `/cards/named` template. + if (cardLang != "en") { + transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang); + } QString transformedUrl = urlTemplate; for (const QString &prop : transformMap.keys()) { 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/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 9cde72c01..d5a168708 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -1,6 +1,5 @@ #include "palette_editor_dialog.h" -#include "../../client/settings/cache_settings.h" #include "../theme_manager.h" #include "palette_generator.h" #include "palette_grid_widget.h" @@ -11,31 +10,11 @@ #include #include #include -#include #include -#include #include -#include #include #include -#include #include -#include - -// Probe whether a directory is truly writable by trying to create and remove a -// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably -// on Windows where UAC VirtualStore can make a system dir appear writable). -static bool isDirReallyWritable(const QString &dirPath) -{ - const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); - QFile f(probe); - if (!f.open(QIODevice::WriteOnly)) { - return false; - } - f.close(); - f.remove(); - return true; -} PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) @@ -46,14 +25,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Resolve a writable directory for saving. Built-in (Default / Fusion) and // other read-only theme directories must be customised in the user-writable // themes directory; otherwise the write would fail or be lost on upgrade. - if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) { - saveDir = themeDirPath; - } else { - saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); - if (!QDir().mkpath(saveDir)) { - qWarning() << "Failed to create palette save directory:" << saveDir; - } - } + saveDir = ThemeManager::writableThemeDir(themeName); // Load both scheme configs upfront so switching is instant loadSchemes(); @@ -214,7 +186,7 @@ void PaletteEditorDialog::retranslateUi() resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); - if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) { + if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); } @@ -297,7 +269,7 @@ void PaletteEditorDialog::onSave() if (it.key() == loadedScheme) { continue; } - if (it.value().colors == savedConfig.value(it.key()).colors) { + if (it.value() == savedConfig.value(it.key())) { continue; } if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { @@ -308,7 +280,7 @@ void PaletteEditorDialog::onSave() } // Commit the active scheme last so the global colour scheme matches. - if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { + if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) { if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { QMessageBox::warning(this, tr("Save failed"), tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d30dd14f1..822e57250 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -150,6 +150,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; + // Accent: same primary hue as Highlight, so palettes derived from a + // QuickSetup accent always carry a matching Accent role. +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + set3(CR::Accent, hl, disText, hl); +#endif + + // Application role colors: Strong tracks the primary accent, while Soft is + // the lightened, desaturated companion used for button-gradient highlights. + cfg.appColors[AppColor::AccentStrong] = hl; + cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // BrightText QColor bright; if (achromatic) { diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 67294cd98..97d28b731 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -1,5 +1,7 @@ #include "palette_grid_widget.h" +#include "../theme_manager.h" + #include #include #include @@ -45,6 +47,11 @@ static const QMap ROLE_DESCRIPTIONS = { {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, }; +static const QMap APP_ROLE_DESCRIPTIONS = { + {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, +}; + PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { scroll = new QScrollArea(this); @@ -122,6 +129,46 @@ void PaletteGridWidget::buildGrid(QWidget *host) grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); } } + + // Application color section: one ColorButton per role below the role grid. + // These are not tied to a color group, so a single button spans the row. + QMetaEnum appEnum = QMetaEnum::fromType(); + + const int appHeaderRow = roles.size() + 1; + + auto *appHeader = new QLabel(tr("App colors"), host); + appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); + QFont appHeaderFont = appHeader->font(); + appHeaderFont.setBold(true); + appHeader->setFont(appHeaderFont); + appHeader->setAutoFillBackground(true); + appHeader->setContentsMargins(4, 4, 4, 4); + grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); + headerLabels.append(appHeader); + + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + const int row = appHeaderRow + 1 + i; + + if (i % 2 == 0) { + for (int col = 0; col < 4; ++col) { + auto *shade = new QWidget(host); + shade->setAutoFillBackground(true); + grid->addWidget(shade, row, col); + rowShadeWidgets.push_back(shade); + } + } + + auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); + label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setContentsMargins(4, 2, 8, 2); + grid->addWidget(label, row, 0); + + auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); + appColorButtons[role] = btn; + grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter); + } } void PaletteGridWidget::changeEvent(QEvent *e) @@ -166,6 +213,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg) colorButtons[group][role]->setColor(color); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + QColor color = cfg.appColors.value(role); + if (!color.isValid()) { + color = themeManager->appColor(role); + } + appColorButtons[role]->setColor(color); + } } PaletteConfig PaletteGridWidget::currentPaletteConfig() const @@ -176,5 +233,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const cfg.colors[group][role] = colorButtons[group][role]->getColor(); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + cfg.appColors[role] = appColorButtons[role]->getColor(); + } + return cfg; } \ No newline at end of file diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index 1a665971a..77cbf1c62 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -31,6 +31,7 @@ private: void refreshChromePalettes(); QMap> colorButtons; + QMap appColorButtons; QScrollArea *scroll; QWidget *gridHost; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 9b8c4bcdc..b70dc576f 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 @@ -82,7 +84,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). * @@ -90,6 +98,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; @@ -111,7 +132,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; @@ -339,6 +361,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl return "pawn_judge"; } + if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) { + return "pawn_dev"; + } + if (!privLevel.isEmpty() && privLevel.toLower() != "none") { return QString("pawn_%1").arg(privLevel.toLower()); } @@ -396,7 +422,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; } @@ -411,7 +438,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; @@ -472,6 +500,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(); @@ -482,3 +517,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_config.cpp b/cockatrice/src/interface/theme_config.cpp index 3c43c467d..8293a82cf 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -16,7 +16,7 @@ QString ThemeConfig::toIni() const out += "[Appearance]\n"; out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += "\n[Style]\n"; - out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName); + out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName); return out; } @@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const bool PaletteConfig::hasPalette() const { - return !colors.isEmpty(); + return !colors.isEmpty() || !appColors.isEmpty(); } QString PaletteConfig::toToml() const @@ -133,6 +133,24 @@ QString PaletteConfig::toToml() const out += "\n"; } + if (!appColors.isEmpty()) { + QMetaEnum appEnum = QMetaEnum::fromType(); + + out += "[AppColors]\n"; + + for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) { + const char *roleName = appEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb)); + } + + out += "\n"; + } + return out; } @@ -152,6 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } QMetaEnum roleEnum = QMetaEnum::fromType(); + QMetaEnum appEnum = QMetaEnum::fromType(); QString currentSection; QPalette::ColorGroup currentGroup = QPalette::Active; @@ -202,6 +221,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } } + QColor color(value); + + if (!color.isValid()) { + continue; + } + + if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) { + if (key.startsWith("AppColor::")) { + key = key.mid(10); + } + + int appRoleInt = appEnum.keyToValue(key.toUtf8().constData()); + + if (appRoleInt >= 0) { + cfg.appColors[static_cast(appRoleInt)] = color; + } + + continue; + } + if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) { continue; } @@ -216,11 +255,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) continue; } - QColor color(value); - - if (color.isValid()) { - cfg.colors[currentGroup][static_cast(roleInt)] = color; - } + cfg.colors[currentGroup][static_cast(roleInt)] = color; } return cfg; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..567aeccda 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -3,9 +3,25 @@ #include #include +#include #include #include +// Application-specific color roles, layered on top of the fixed QPalette role +// set. Stored in the same palette-.toml under an [AppColors] section +// and editable from the palette editor, so theme authors can control colors +// beyond what Qt's palette can express. +namespace AppColor +{ +Q_NAMESPACE +enum Role +{ + AccentStrong, + AccentSoft, +}; +Q_ENUM_NS(Role) +} // namespace AppColor + struct ThemeConfig { QString colorScheme; @@ -21,7 +37,16 @@ struct ThemeConfig struct PaletteConfig { QMap> colors; + QMap appColors; + bool operator==(const PaletteConfig &rhs) const + { + return colors == rhs.colors && appColors == rhs.appColors; + } + bool operator!=(const PaletteConfig &rhs) const + { + return !(*this == rhs); + } bool hasPalette() const; QString toToml() const; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index e6b4b3c7f..d86ed77f9 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -1,10 +1,13 @@ #include "theme_manager.h" #include "../../client/settings/cache_settings.h" +#include "pixel_map_generator.h" #include #include #include +#include +#include #include #include #include @@ -19,7 +22,7 @@ #include #include -#define NONE_THEME_NAME "Default" +#define SYSTEM_THEME_NAME "System" #define FUSION_THEME_NAME "Fusion" #define STYLE_CSS_NAME "style.css" #define HANDZONE_BG_NAME "handzone" @@ -93,7 +96,7 @@ struct PaletteColorInfo static QString usableDefaultStyle(const QString &style) { // The Windows 11 native style is broken: when the OS default - // ("Default" theme selection) would use it, fall back to the Vista style. + // ("System" theme selection) would use it, fall back to the Vista style. // Explicitly choosing "windows11" in a theme is still honored. return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style; } @@ -116,10 +119,16 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) void ThemeManager::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getThemeName())) { + auto &settings = SettingsCache::instance(); + + // Migrate the old "Default" theme name to "System" + if (settings.getThemeName() == "Default") { + settings.setThemeName(SYSTEM_THEME_NAME); + } + + if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) { qCInfo(ThemeManagerLog) << "Theme name not set, setting default value"; - SettingsCache::instance().setThemeName(NONE_THEME_NAME); + settings.setThemeName(FUSION_THEME_NAME); } } @@ -140,11 +149,74 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) const } } -bool ThemeManager::isBuiltInTheme() +QString ThemeManager::schemeVariantPath(QStringView prefix) const { - const auto themeName = SettingsCache::instance().getThemeName(); + 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; - return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; + 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; +} + +// Probe whether a directory is truly writable by trying to create and remove a +// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably +// on Windows where UAC VirtualStore can make a system dir appear writable). +bool ThemeManager::isDirReallyWritable(const QString &dirPath) +{ + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + f.close(); + f.remove(); + return true; +} + +QString ThemeManager::writableThemeDir(const QString &themeName) +{ + // All theme writes go to the user themes directory regardless of whether + // the resolved (system) theme directory happens to be writable. Even when a + // write would succeed in-place, routing it to the user directory keeps the + // install intact and guarantees changes survive upgrades. + const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); + if (!QDir().mkpath(dirPath)) { + qWarning() << "Failed to create theme save directory:" << dirPath; + } + return dirPath; } // System (read-only) themes location, relative to the application binary. @@ -169,9 +241,7 @@ QStringMap &ThemeManager::getAvailableThemes() // load themes from user profile dir dir.setPath(SettingsCache::instance().paths().getThemesPath()); - // add default value - availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default")); - + availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System")); availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion")); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { @@ -180,7 +250,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)) { @@ -195,7 +265,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 +279,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; @@ -287,7 +357,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col void ThemeManager::setColorScheme(const QString &scheme) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.colorScheme = scheme; @@ -298,7 +368,7 @@ void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setStyleName(const QString &styleName) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.styleName = styleName; @@ -329,7 +399,7 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, Q_UNUSED(activeScheme) #endif QString styleName = themeCfg.styleName; - if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { + if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { styleName = "Fusion"; } else { @@ -372,6 +442,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, qApp->setPalette(base); qApp->setStyle(style); + currentAppColors = palCfg.appColors; + // Force every widget to re-polish and repaint immediately rather than // waiting for natural expose events, which produces a patchwork of old // and new colours during a live preview. @@ -384,6 +456,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, style->polish(widget); widget->update(); } + + emit paletteChanged(); +} + +QColor ThemeManager::appColor(AppColor::Role role) const +{ + const auto it = currentAppColors.constFind(role); + if (it != currentAppColors.constEnd()) { + return it.value(); + } + + // QPalette::Accent was introduced in Qt 6.6 and several shipped palettes + // set it to a value barely distinguishable from Window, so it is not a + // reliable accent source. The selection highlight is the stable accent + // (Accent defaults to Highlight when unset), and deriving from it + // unconditionally keeps every Qt version rendering identically. + const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); + + if (role == AppColor::AccentSoft) { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; + + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + + return accent; } void ThemeManager::themeChangedSlot() @@ -393,9 +494,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(""); } @@ -410,8 +521,19 @@ void ThemeManager::themeChangedSlot() // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); - if (!palette.hasPalette()) { - palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + if (palette.hasPalette()) { + // A custom palette written before [AppColors] existed carries no app + // colors; merge the theme's shipped defaults so the identity colors + // survive (hasPalette() counts an app-colors-only file as a palette, + // so those are kept wholesale and never reach here empty). + for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) { + if (!palette.appColors.contains(it.key())) { + palette.appColors.insert(it.key(), it.value()); + } + } + } else { + palette = themeDefault; } applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); @@ -446,6 +568,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..aadb38ee9 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -50,6 +50,7 @@ private: QString currentThemePath; std::array brushes; QStringMap availableThemes; + QMap currentAppColors; /* Internal cache for multiple backgrounds */ @@ -65,7 +66,16 @@ protected: const QString &activeScheme); public: - bool isBuiltInTheme(); + // Resolves the directory to write theme changes to for the given theme + // name. The resolved theme dir (user or system) is used when writable; + // read-only system themes fall back to the user themes directory, creating + // it if needed, so customisations never get lost on upgrade. + static QString writableThemeDir(const QString &themeName); + // Probe whether a directory is truly writable by trying to create and remove + // a temporary file. QFileInfo::isWritable() on a directory is unreliable + // (notably on Windows where UAC VirtualStore can make a system dir appear + // writable). + static bool isDirReallyWritable(const QString &dirPath); // Explicit color scheme of the theme: theme.cfg's ColorScheme setting // (Dark/Light), falling back to the OS color scheme when it is "System". bool isDarkMode(const QString &themeDirPath) const; @@ -87,6 +97,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 @@ -101,12 +125,17 @@ public: void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + // Resolves an application color role: the theme's stored [AppColors] value + // when present, otherwise a palette-accent-derived fallback. + QColor appColor(AppColor::Role role) const; + QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); protected slots: void themeChangedSlot(); signals: void themeChanged(); + void paletteChanged(); }; extern ThemeManager *themeManager; 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..2dd21e78a 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 @@ -73,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** @@ -193,12 +196,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/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index c6af5320b..e98c3c02a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,6 +1,7 @@ #include "card_info_text_widget.h" #include "../../../game_graphics/board/card_item.h" +#include "../../card_localization.h" #include #include @@ -10,7 +11,7 @@ #include #include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n grid->setRowStretch(1, 1); retranslateUi(); + + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] { + if (currentCard) { + setCard(currentCard); + } + }); } void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText) @@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) QString text = ""; text += QString("") - .arg(tr("Name:"), card->getName().toHtmlEscaped()); + .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); if (!exactCard.getPrinting().isEmpty()) { QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped(); @@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) } text += "
%1%2
"; - setTexts(text, card->getText()); + setTexts(text, CardLocalization::displayText(card)); + currentCard = exactCard; } void CardInfoTextWidget::setInvalidCardName(const QString &cardName) diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index a9c29da37..683be5ab5 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -23,7 +23,7 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - CardInfoPtr info; + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. void setTexts(const QString &propsText, const QString &textText); public: 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..b0b31074c 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); @@ -344,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard() if (!current.isValid()) { return {}; } - const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); const QModelIndex gparent = current.parent().parent(); 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/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index e563729a4..cfe989e9f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,13 +1,20 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" + #include #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this)) { + deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel, + [this](const QString &lang) { deckListModel->setDisplayLanguage(lang); }); + connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] { setModified(true); emit historyChanged(); @@ -260,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); QModelIndex gparent = idx.parent().parent(); @@ -277,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) QString reason = tr("Moved to %1 1 × \"%2\" (%3)") // .arg(otherZoneName) - .arg(cardName) + .arg(displayCardName) .arg(providerId); return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) { @@ -291,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); - - QString reason = tr("Removed \"%1\" (all copies)").arg(cardName); + QString reason = + tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString()); return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); }); } 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 883cfcd03..581920dbc 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); @@ -132,7 +133,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); @@ -415,6 +416,11 @@ void DlgSettings::setTab(int index) } } +AbstractSettingsPage *DlgSettings::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + void DlgSettings::updateLanguage() { qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) @@ -477,13 +483,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/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index b700f7af9..845c0b4d6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -54,6 +54,7 @@ public: explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: void onTabClicked(int index); diff --git a/cockatrice/src/interface/widgets/general/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h index 1550b57e7..b45bd7a92 100644 --- a/cockatrice/src/interface/widgets/general/home_tab_button_color.h +++ b/cockatrice/src/interface/widgets/general/home_tab_button_color.h @@ -11,8 +11,8 @@ namespace HomeTabButtonColor */ enum Source { - Automatic, ///< Extract color from background, or use theme color if no background - FromBackground, ///< Always extract color from background + FromThemeColors, ///< Use the theme's identity accent colors + FromBackground, ///< Extract colour from the background image }; struct Entry @@ -23,7 +23,7 @@ struct Entry inline QList all() { - static QList entries = {{Automatic, QT_TR_NOOP("Automatic")}, + static QList entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")}, {FromBackground, QT_TR_NOOP("Extract from background")}}; return entries; @@ -33,12 +33,12 @@ inline QList all() * Safely converts an int into the corresponding Source. * * @param value The int value - * @return The Source. Returns Source::Automatic if the value is not within range + * @return The Source. Returns Source::FromThemeColors if the value is not within range */ inline Source intToSource(int value) { if (value > FromBackground) { - return Automatic; // default + return FromThemeColors; // default } return static_cast(value); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 10fcdcb43..648d315f9 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" @@ -10,6 +11,8 @@ #include "home_tab_button_color.h" #include +#include +#include #include #include #include @@ -20,7 +23,7 @@ #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"))) { layout = new QGridLayout(this); @@ -52,16 +55,27 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) // Lambda is cleaner to read than overloading this connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundDimChanged, this, + [this] { repaint(); }); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &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(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateLogoOverlay); connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::initializeBackgroundFromSource() { + // The featured logo is theme/scheme-derived too; reload it alongside the + // background so a theme or appearance switch doesn't leave it stale. + updateLogoOverlay(); + if (CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &HomeWidget::initializeBackgroundFromSource); @@ -74,7 +88,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(); @@ -100,32 +114,24 @@ void HomeWidget::loadBackgroundSourceDeck() backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } -static bool isDefaultBackgroundAndTheme() +static QPair paletteDerivedButtonColors() { - QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); - return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme; + return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)}; } QPair HomeWidget::determineButtonColor() const { - static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)}; - auto colorSource = HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); switch (colorSource) { - case HomeTabButtonColor::Automatic: { - if (isDefaultBackgroundAndTheme()) { - return defaultColor; - } else { - return extractDominantColors(background); - } - } + case HomeTabButtonColor::FromThemeColors: + return paletteDerivedButtonColors(); case HomeTabButtonColor::FromBackground: return extractDominantColors(background); } - return defaultColor; + return paletteDerivedButtonColors(); } void HomeWidget::setRandomCard(ExactCard &newCard) @@ -229,10 +235,10 @@ QGroupBox *HomeWidget::createButtons() QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); - QLabel *logoLabel = new QLabel; - logoLabel->setPixmap(overlay.scaledToWidth(200, Qt::SmoothTransformation)); + logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); boxLayout->addWidget(logoLabel); + updateLogoOverlay(); boxLayout->addSpacing(25); connectButton = new HomeStyledButton("Connect/Play", gradientColors); @@ -360,13 +366,15 @@ void HomeWidget::paintEvent(QPaintEvent *event) painter.drawPixmap(topLeft, toDraw); } - // Draw translucent black overlay with rounded corners - QRectF overlayRect(5, 5, width() - 10, height() - 10); - QPainterPath roundedRectPath; - roundedRectPath.addRoundedRect(overlayRect, 20, 20); + if (SettingsCache::instance().appearance().getHomeTabBackgroundDim()) { + // Draw translucent black overlay with rounded corners + QRectF overlayRect(5, 5, width() - 10, height() - 10); + QPainterPath roundedRectPath; + roundedRectPath.addRoundedRect(overlayRect, 20, 20); - QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); - painter.fillPath(roundedRectPath, semiTransparentBlack); + QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); + painter.fillPath(roundedRectPath, semiTransparentBlack); + } // Card name overlay (above the attribution, bottom-right) QString cardName; @@ -431,3 +439,56 @@ void HomeWidget::paintEvent(QPaintEvent *event) QWidget::paintEvent(event); } + +void HomeWidget::updateLogoOverlay() +{ + // Emulate cockatrice.svg in Qt rather than rendering the baked-in SVG. + // The SVG has no separate plate: the gradient fills the bird's silhouette + // paths (light #c9fd62/AccentSoft at the top-left, dark #139740/AccentStrong + // toward the bottom-right — the SVG's linearGradient4265-7-8 stops along + // its userSpaceOnUse axis), and the white highlight path + // (cockatrice-logo-white) sits on top. So we paint that gradient clipped to + // the full logo silhouette (the full-color logo's alpha), then overlay the + // white mark. Colours stay fully theme-driven and independent of the static + // greens baked into the SVG. + const QColor strong = themeManager->appColor(AppColor::AccentStrong); + const QColor soft = themeManager->appColor(AppColor::AccentSoft); + + const QPixmap silhouette = themePixmap(QStringLiteral("cockatrice")).scaledToWidth(200, Qt::SmoothTransformation); + const QPixmap whiteMark = + themePixmap(QStringLiteral("cockatrice-logo-white")).scaledToWidth(200, Qt::SmoothTransformation); + if (silhouette.isNull() || whiteMark.isNull()) { + return; + } + + QPixmap composite(silhouette.size()); + composite.fill(Qt::transparent); + + { + QPainter painter(&composite); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + + // Recreate cockatrice.svg's own gradient geometry (linearGradient + // 4265-7-8, userSpaceOnUse): light AccentSoft at S=(-8.097,-97.746), + // dark AccentStrong at E=(162.455,295.208), on the SVG's 300x300 + // canvas. Scale those coordinates to this composite's size. + const qreal scale = composite.width() / 300.0; + QLinearGradient gradient(QPointF(-8.097, -97.746) * scale, QPointF(162.455, 295.208) * scale); + gradient.setColorAt(0.0, soft); + gradient.setColorAt(1.0, strong); + painter.fillRect(composite.rect(), gradient); + + // Clip the gradient to the full logo silhouette exactly as the SVG's + // gradient paths are confined to the bird. + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawPixmap(0, 0, silhouette); + + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.drawPixmap(0, 0, whiteMark); + } + + if (logoLabel) { + logoLabel->setPixmap(composite); + } +} diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 9df0d7b6a..1cadc4a67 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -15,6 +15,9 @@ #include #include +class QGridLayout; +class QLabel; + class HomeWidget : public QWidget { @@ -41,13 +44,14 @@ private: QPixmap background; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; - QPixmap overlay; + QLabel *logoLabel = nullptr; QPair gradientColors; HomeStyledButton *connectButton; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); QPair determineButtonColor() const; + void updateLogoOverlay(); }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h index 32f3e89c0..6008044ff 100644 --- a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -38,6 +38,10 @@ class BannerShaderConfig : public QObject Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged) Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged) Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged) + Q_PROPERTY(QColor glowColor READ glowColor WRITE setGlowColor NOTIFY glowColorChanged) + Q_PROPERTY(QColor brandStrong READ brandStrong WRITE setBrandStrong NOTIFY brandStrongChanged) + Q_PROPERTY(QColor brandSoft READ brandSoft WRITE setBrandSoft NOTIFY brandSoftChanged) + Q_PROPERTY(qreal vignetteMin READ vignetteMin WRITE setVignetteMin NOTIFY vignetteMinChanged) Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) @@ -185,6 +189,54 @@ public: } } + QColor glowColor() const + { + return m_glowColor; + } + void setGlowColor(const QColor &c) + { + if (c != m_glowColor) { + m_glowColor = c; + emit glowColorChanged(); + } + } + + QColor brandStrong() const + { + return m_brandStrong; + } + void setBrandStrong(const QColor &c) + { + if (c != m_brandStrong) { + m_brandStrong = c; + emit brandStrongChanged(); + } + } + + QColor brandSoft() const + { + return m_brandSoft; + } + void setBrandSoft(const QColor &c) + { + if (c != m_brandSoft) { + m_brandSoft = c; + emit brandSoftChanged(); + } + } + + qreal vignetteMin() const + { + return m_vignetteMin; + } + void setVignetteMin(qreal v) + { + if (v != m_vignetteMin) { + m_vignetteMin = v; + emit vignetteMinChanged(); + } + } + bool logoVisible() const { return m_logoVisible; @@ -222,6 +274,10 @@ signals: void colorAChanged(); void colorBChanged(); void accentChanged(); + void glowColorChanged(); + void brandStrongChanged(); + void brandSoftChanged(); + void vignetteMinChanged(); void logoVisibleChanged(); void logoGlowChanged(); @@ -239,9 +295,16 @@ private: bool m_frontIsA = true; + // Curated fallback seed values -- BannerHost overwrites these with + // palette-derived colours (see shader_banner_widget.cpp) before the first + // paint, so they only matter as a safe pre-first-apply default. QColor m_colorA{0x1A, 0x1A, 0x20}; QColor m_colorB{0x0E, 0x0E, 0x12}; QColor m_accent{0x8B, 0xDD, 0x6B}; + QColor m_glowColor{Qt::white}; + QColor m_brandStrong{0x13, 0x97, 0x40}; + QColor m_brandSoft{0xC9, 0xFD, 0x62}; + qreal m_vignetteMin = 0.62; bool m_logoVisible = false; qreal m_logoGlow = 1.0; diff --git a/cockatrice/src/interface/widgets/onboarding/brand_colors.h b/cockatrice/src/interface/widgets/onboarding/brand_colors.h new file mode 100644 index 000000000..bf173270b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/brand_colors.h @@ -0,0 +1,15 @@ +#ifndef BRAND_COLORS_H +#define BRAND_COLORS_H + +#include + +/** @brief Cockatrice brand green. + * + * Single source of truth for the onboarding brand accent: it backs the + * banner's shader-accent uniform as the curated fallback when the active + * palette resolves no usable Highlight, and it preseads the wizard's + * QuickSetupPanel so a freshly generated palette keeps the brand identity + * until the user picks their own look. */ +inline const QColor kCockatriceBrandGreen(0x8B, 0xDD, 0x6B); + +#endif // BRAND_COLORS_H \ No newline at end of file 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/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp index 3293b19ac..797fe4425 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -14,10 +14,31 @@ #include #include #include +#include #include #include #include +namespace +{ +/** @brief A theme's shipped identity accent, immune to any user- or auto- + * generated palette that may currently be masking appColor(). */ +QColor themeIdentityAccent(const QString &themeDirPath, const QString &themeName) +{ + for (const QString &scheme : {QStringLiteral("Light"), QStringLiteral("Dark")}) { + const PaletteConfig cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); + if (cfg.appColors.contains(AppColor::AccentStrong)) { + return cfg.appColors.value(AppColor::AccentStrong); + } + const QColor highlight = cfg.colors.value(QPalette::Active).value(QPalette::Highlight); + if (highlight.isValid()) { + return highlight; + } + } + return {}; +} +} // namespace + ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) { themeCombo = new QComboBox(this); @@ -30,6 +51,15 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) quickSetupPanel = new QuickSetupPanel(this); + // Seed the picker from the current theme's own identity accent rather than + // a hardcoded brand green: Plasma seeds violet, Fusion green, etc., and it + // is immune to stale generated palettes that may mask appColor(). This was + // initially a brand-green workaround from before Fusion became the default. + // setAccentColor blocks signals, so this never triggers a generation. + lastSeededTheme = SettingsCache::instance().getThemeName(); + quickSetupPanel->setAccentColor( + themeIdentityAccent(themeManager->getAvailableThemes().value(lastSeededTheme), lastSeededTheme)); + connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); @@ -47,7 +77,8 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) // Mirrors AppearanceSettingsPage's identical listener for the combo-sync // half of this. connect(themeManager, &ThemeManager::themeChanged, this, [this] { - const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString newTheme = SettingsCache::instance().getThemeName(); + const QString newDir = themeManager->getAvailableThemes().value(newTheme); const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); const QString current = cfg.colorScheme; @@ -56,6 +87,14 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); schemeCombo->blockSignals(false); + // Keep the picker's accent in step with the theme's own identity; the + // swatch seeded at construction would otherwise stay stale (e.g. green + // from a previous theme) when the user toggles themes. + if (newTheme != lastSeededTheme) { + lastSeededTheme = newTheme; + quickSetupPanel->setAccentColor(themeIdentityAccent(newDir, newTheme)); + } + maybeAutoGeneratePalette(); }); @@ -158,8 +197,14 @@ void ThemeSetupPage::maybeAutoGeneratePalette() const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString scheme = resolvedScheme(); + // The theme dir may resolve to the user profile even for built-in themes + // (getAvailableThemes gives the user copy precedence), so consult the + // shipped palette too -- both via loadDefaultPaletteConfig's system fallback. + // Without it, scheme flips regenerate a fresh palette from the picker accent + // and clobber the curated colours the theme explicitly ships. if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || - PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) { + ThemeManager::loadDefaultPaletteConfig(dirPath, SettingsCache::instance().getThemeName(), scheme) + .hasPalette()) { return; // theme already has something real to show -- leave it alone } diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h index d1f84c1b9..16a3c9a5d 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -53,6 +53,9 @@ private: QComboBox *homeTabBackgroundCombo; bool paletteDirty = false; + + /// Theme whose identity accent currently seeds the picker. + QString lastSeededTheme; }; #endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml index f1a385cad..d94e15280 100644 --- a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -16,6 +16,8 @@ Item { property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin property real uLogoGlow: bannerConfig.logoGlow fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" } @@ -33,30 +35,62 @@ Item { property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin property real uLogoGlow: bannerConfig.logoGlow fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" } - // The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range - Image { - id: logo - anchors.centerIn: parent + // The white logo sits at full opacity on top of the static gradient plate — + // no glow, no breathing. The plate matches home_widget's QPainter composite. + Item { + id: logoHost visible: bannerConfig.logoVisible - source: "qrc:/resources/cockatrice-logo-white.svg" + anchors.centerIn: parent width: root.height * 0.6 - height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1) - fillMode: Image.PreserveAspectFit - smooth: true - opacity: 0.5 + 0.5 * bannerConfig.logoGlow - sourceSize: Qt.size(256, 256) + height: width - Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } } + // The full-color logo renders beneath the white mark and is consumed as + // a texture (layer.enabled) by the plate shader's silhouette mask, so + // the gradient is clipped to the bird exactly as the SVG's gradient + // paths are. It is never drawn to the screen itself. + Image { + id: silhouetteMask + anchors.fill: parent + source: "qrc:/resources/cockatrice.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + visible: false + layer.enabled: true + layer.smooth: true + } - transform: Scale { - origin.x: logo.width / 2 - origin.y: logo.height / 2 - xScale: 0.94 + 0.06 * bannerConfig.logoGlow - yScale: 0.94 + 0.06 * bannerConfig.logoGlow + // The logo's gradient plate, drawn behind the mark: a linear + // AccentSoft (light) -> AccentStrong (dark) sheet along the same + // top-left -> bottom-right userSpaceOnUse axis the baked-in SVG used, + // clipped to the bird silhouette via uSilhouette. The white highlight + // path above is theme independent. Sized to the logo itself — no + // rounded badge, matching home_widget's QPainter composite. Static. + // Small ShaderEffect, Qt 6.4-safe. + ShaderEffect { + id: brandPlate + anchors.fill: parent + property vector4d uStrong: Qt.vector4d(bannerConfig.brandStrong.r, bannerConfig.brandStrong.g, + bannerConfig.brandStrong.b, 1.0) + property vector4d uSoft: Qt.vector4d(bannerConfig.brandSoft.r, bannerConfig.brandSoft.g, + bannerConfig.brandSoft.b, 1.0) + property var uSilhouette: silhouetteMask + fragmentShader: "qrc:/onboarding/shaders/brand_plate.frag.qsb" + } + + Image { + id: logoImage + anchors.fill: parent + source: "qrc:/resources/cockatrice-logo-white.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true } } -} +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp index fd1fb2a98..0b9f65783 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -1,7 +1,10 @@ #include "shader_banner_widget.h" +#include "../../theme_manager.h" #include "banner_shader_config.h" +#include "brand_colors.h" +#include #include #include #include @@ -11,11 +14,98 @@ namespace { -// Near-black base palette -- the background is dark and quiet so the green -// accent stands out. -constexpr QRgb kColorA = 0x1A1A20; -constexpr QRgb kColorB = 0x0E0E12; -constexpr QRgb kAccent = 0x8BDD6B; +// Curated near-black stage -- used only when the active palette resolves no +// usable window colour. Matches the banner's original design (dark and quiet +// so the accent stands out) and satisfies design-plans §2.1's "identity +// survives a bare palette". +constexpr QRgb kFallbackColorA = 0x1A1A20; +constexpr QRgb kFallbackColorB = 0x0E0E12; + +struct SuggestedColors +{ + QColor colorA; + QColor colorB; + QColor accent; + QColor glowColor; + QColor brandStrong; + QColor brandSoft; + qreal vignetteMin = 0.62; +}; + +SuggestedColors suggestedBannerColors() +{ + const QPalette &pal = qApp->palette(); + const QColor window = pal.color(QPalette::Active, QPalette::Window); + // Identity accent: the theme's [AppColors] AccentStrong, which appColor() + // resolves to QPalette::Highlight when a theme doesn't pin AccentStrong. + // Reading bare Highlight ignored curated accent tokens (Plasma's violet + // vs Default's green) whenever a palette didn't set the role itself. + const QColor accentStrong = themeManager->appColor(AppColor::AccentStrong); + if (!window.isValid() || !accentStrong.isValid()) { + return {QColor(kFallbackColorA), + QColor(kFallbackColorB), + kCockatriceBrandGreen, + QColor(Qt::white), + kCockatriceBrandGreen, + QColor(0xC9, 0xFD, 0x62), + 0.62}; + } + + // The theme's brand pair: AccentStrong is the deep green, AccentSoft the + // lime. These two appColors form the logo's "surrounding gradient" (deep + // core grading out to the soft, brand-toned glow) on both the banner and + // the home screen. + const QColor brandStrong = accentStrong; + const QColor brandSoft = themeManager->appColor(AppColor::AccentSoft); + + // Dress the stage for the scheme so the banner never fights the + // surrounding window in either mode. Dark palettes keep the original + // quiet near-black stage (lightness 29 → 16) with the theme's window + // hue; light palettes get a pastel "frosted accent" treatment built from + // the accent hue instead of a plain near-white copy: a coloured wash that + // clearly belongs to the theme. + const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue(); + const bool lightStage = luma > 115.0; + if (lightStage) { + const int hue = accentStrong.hslHue(); + // Achromatic accents (grey) get a neutral near-white stage instead. + const int stageSat = hue < 0 ? 0 : 64; + const int hueSafe = hue < 0 ? 0 : hue; + // Depth is what stops a light stage reading as a washed-out near-white + // copy of the page behind the banner: deepen the lower pastel band and + // raise saturation so the hue is clearly present while staying frosted. + auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); }; + auto pastelLower = [hueSafe](int lightness) { return QColor::fromHsl(hueSafe, 76, lightness); }; + // Brightness-lifted accent for additive glows: the raw accent on a + // light stage must be mid-bright to read instead of washing out, so + // lift lightness and saturation together. + const int accentLightness = qBound(158, accentStrong.lightness() + 82, 198); + const int accentSaturation = hue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 180); + const QColor liftedAccent = + hue < 0 ? accentStrong : QColor::fromHsl(hueSafe, accentSaturation, accentLightness); + // The centre glow (and logo tint in QML) uses the deep accent itself: + // a coloured halo/fill behind the logo instead of a white or black one. + return {pastel(214), pastelLower(186), liftedAccent, accentStrong, brandStrong, brandSoft, 0.80}; + } + + // Dark stage: force the window hue down to the banner's curated darkness, + // scaling saturation away so chromatic palettes tint it without going + // muddy. The accent is the bright, brand-driven tone (hue from the accent + // itself, never the -- often grey -- window), and it drives both the + // embers/fog and the logo glow so the mark tints like the light stage. + auto stage = [&window](int lightness) { + const int hue = window.hslHue(); + const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255); + return QColor::fromHsl(hue, saturation, lightness); + }; + const int accentHue = accentStrong.hslHue(); + const int accentHueSafe = accentHue < 0 ? 0 : accentHue; + const int accentLightness = qBound(150, accentStrong.lightness() + 70, 185); + const int accentSaturation = accentHue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 160); + const QColor accent = + accentHue < 0 ? accentStrong : QColor::fromHsl(accentHueSafe, accentSaturation, accentLightness); + return {stage(29), stage(16), accent, accent, brandStrong, brandSoft, 0.62}; +} } // namespace class GradientFallbackWidget : public QWidget @@ -23,15 +113,27 @@ class GradientFallbackWidget : public QWidget public: using QWidget::QWidget; + void setColors(const QColor &a, const QColor &b) + { + if (a != colorA || b != colorB) { + colorA = a; + colorB = b; + } + } + protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); QLinearGradient gradient(0, 0, width(), height()); - gradient.setColorAt(0.0, QColor(kColorA)); - gradient.setColorAt(1.0, QColor(kColorB)); + gradient.setColorAt(0.0, colorA); + gradient.setColorAt(1.0, colorB); painter.fillRect(rect(), gradient); } + +private: + QColor colorA{QColor(kFallbackColorA)}; + QColor colorB{QColor(kFallbackColorB)}; }; BannerHost::BannerHost(QWidget *parent) : QWidget(parent) @@ -62,6 +164,9 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent) connect(&clock, &QTimer::timeout, this, &BannerHost::tick); clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock + connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors); + applyThemeColors(); + applyMotifPreset(currentMotif); updateAspect(); } @@ -123,9 +228,9 @@ void BannerHost::applyMotifPreset(Motif motif) const Preset p = presetFor(motif); - config->setColorA(QColor(kColorA)); - config->setColorB(QColor(kColorB)); - config->setAccent(QColor(kAccent)); + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); config->setLogoVisible(motif == Motif::Welcome); if (isFirstApply) { @@ -183,8 +288,34 @@ void BannerHost::hideEvent(QHideEvent *event) clock.stop(); } +void BannerHost::applyThemeColors() +{ + const SuggestedColors colors = suggestedBannerColors(); + bannerColorA = colors.colorA; + bannerColorB = colors.colorB; + bannerAccent = colors.accent; + + if (usingFallback) { + fallback->setColors(bannerColorA, bannerColorB); + fallback->update(); + } else if (config) { + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setGlowColor(colors.glowColor); + config->setBrandStrong(colors.brandStrong); + config->setBrandSoft(colors.brandSoft); + config->setVignetteMin(colors.vignetteMin); + } +} + void BannerHost::tick() { + // Palette previews (e.g. accent drags in the wizard's QuickSetupPanel) + // apply qApp->palette() without firing themeChanged, so re-derive here; + // BannerShaderConfig's setters are equality-guarded, so this is a no-op + // unless the colours actually changed. + applyThemeColors(); if (config) { qreal t = elapsed.elapsed() / 1000.0; config->setTime(t); diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h index 2e230ad7f..ac47b1141 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -1,6 +1,7 @@ #ifndef SHADER_BANNER_WIDGET_H #define SHADER_BANNER_WIDGET_H +#include #include #include #include @@ -53,6 +54,7 @@ protected: private slots: void tick(); void onSceneGraphFailed(); + void applyThemeColors(); private: struct Preset @@ -73,6 +75,12 @@ private: BannerShaderConfig *config = nullptr; GradientFallbackWidget *fallback = nullptr; + // Palette-derived banner colours -- the theme's window hue forced down to + // the banner's curated darkness, plus the theme's Highlight as accent. + QColor bannerColorA; + QColor bannerColorB; + QColor bannerAccent; + QTimer clock; QElapsedTimer elapsed; Motif currentMotif = Motif::Welcome; diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag index 508bd4bc4..2bf6d0abf 100644 --- a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -28,6 +28,8 @@ layout(std140, binding = 0) uniform buf vec4 uColorA; vec4 uColorB; vec4 uAccent; + vec4 uGlowColor; + float uVignetteMin; float uLogoGlow; }; @@ -119,7 +121,7 @@ vec3 backgroundField(vec2 uv, float time) // Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02); - col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10; + col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.14; return col; } @@ -136,14 +138,17 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t) vec2 center = vec2(asp * 0.5, 0.5); float cDist = length(ac - center); - // Centre bloom at logo position; intensity scales with uLogoGlow + // Centre bloom at logo position; intensity scales with uLogoGlow. The + // QML brandGlow halo now supplies the primary logo surround (the two + // brand appColors), so this shader bloom is deliberately kept as a subtle + // ambience rather than a competing glow. float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); - col += centreLight * 0.20 * uLogoGlow; + col += uGlowColor.rgb * centreLight * 0.20 * uLogoGlow; // Flow-noise shimmer gated by Gaussian mask at centre float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5; float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp)); - col += shimmer * shimmerMask * 0.04 * uLogoGlow; + col += uGlowColor.rgb * shimmer * shimmerMask * 0.04 * uLogoGlow; // 48 ember particles: hash-seeded position, speed, size, brightness. // Embers within a distance threshold of centre are deflected into an @@ -162,8 +167,8 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t) float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp; float pY = fract(baseY + t * riseSpeed); - float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012; - float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30; + float size = 0.010 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.014; + float bright = 0.18 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.32; // Fade out near top/bottom edges float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); @@ -232,7 +237,7 @@ vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) // Semi-transparent dark fill float fill = smoothstep(0.015, -0.005, d); - col = mix(col, uColorB.rgb * 0.55, fill * 0.50); + col = mix(col, uColorB.rgb * 0.60, fill * 0.62); // Accent outline float edge = smoothstep(0.035, 0.0, abs(d)); @@ -309,7 +314,7 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t) // Node glow via bloom; intensity modulated by pulse float dist = length(ac - pos); - col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse); + col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.30, 0.55, pulse); } // Edges: connect nodes within a radius threshold @@ -323,19 +328,19 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t) vec2 ba = nodePos[j] - nodePos[i]; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); float lineDist = length(pa - ba * h); - col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10; + col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.14; } } } // Central bloom at banner centre float cDist = length(ac - center); - col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12; + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.20; // Periodic expanding ring from centre float ripplePhase = t * 0.4; float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7); - col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10; + col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.14; return col; } @@ -456,6 +461,8 @@ void main() else if (uMode < 4.5) col = motifPreferences(uv, bg, t); else col = motifFinish(uv, bg, t); - col *= mix(0.62, 1.0, vignette(uv)); + // Corner vignette; uVignetteMin is scheme-driven (0.62 on dark stages, + // gentler on light ones so near-white corners don't go muddy grey). + col *= mix(uVignetteMin, 1.0, vignette(uv)); fragColor = vec4(col, 1.0) * qt_Opacity; } diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag new file mode 100644 index 000000000..43b798c9f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag @@ -0,0 +1,42 @@ +#version 440 + +// The logo's gradient plate, drawn by us rather than the baked-in SVG: a +// linear blend between the two brand appColors (light AccentSoft at the +// top-left grading to dark AccentStrong at the bottom-right, mirroring +// cockatrice.svg's linearGradient4265-7-8 userSpaceOnUse axis), clipped to +// the bird's full silhouette via the full-color logo's alpha (uSilhouette). +// The white highlight path (cockatrice-logo-white) is overlaid in QML on top, +// exactly as the SVG stacks its white path over the gradient paths. Fully +// static: no glow, no breathing — the plate just sits there like the home +// widget's QPainter composite. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + vec4 uStrong; + vec4 uSoft; +}; + +// The full-color logo's alpha channel acts as the silhouette mask: the +// gradient only appears inside the bird, exactly like the SVG's gradient paths. +layout(binding = 1) uniform sampler2D uSilhouette; + +void main() +{ + // Recreate cockatrice.svg's own gradient geometry (linearGradient4265-7-8, + // userSpaceOnUse): light AccentSoft at the start point S=(-8.097,-97.746), + // dark AccentStrong at the end E=(162.455,295.208), on the SVG's 300x300 + // canvas. Normalized to UV space, V=E-S=(0.5685,1.3098), so + // t = dot(uv - S_norm, V)/|V|^2 with S_norm=(-0.0270,-0.3258). + float t = clamp(dot(qt_TexCoord0 - vec2(-0.02699, -0.32582), vec2(0.56851, 1.30985)) / 2.03891, 0.0, 1.0); + vec3 color = mix(uSoft.rgb, uStrong.rgb, t); + + // Anti-aliased silhouette clip from the full-color logo's alpha. + float alpha = texture(uSilhouette, qt_TexCoord0).a; + + fragColor = vec4(color * alpha, alpha) * qt_Opacity; +} \ No newline at end of file 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/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 0b77ca185..e8816847e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -1,13 +1,19 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" #include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" +#include +#include #include #include #include +#include #include +#include +#include #include #include #include @@ -15,6 +21,49 @@ #include #include +namespace +{ +/** + * @brief Places the preview beside the highlighted action inside the given screen. + * + * Side-aware: hugs the side of the action that has room, aligned with its row, then + * clamps every edge so the preview always lands fully on-screen on first show. + */ +QPoint +previewPositionNear(const QRect &actionRect, const QSize &labelSize, const QRect &screenGeometry, int previewOffset) +{ + const bool rightFits = actionRect.right() + previewOffset + labelSize.width() <= screenGeometry.right(); + const bool leftFits = actionRect.left() - previewOffset - labelSize.width() >= screenGeometry.left(); + + int x; + if (rightFits) { + x = actionRect.right() + previewOffset; + } else if (leftFits) { + x = actionRect.left() - previewOffset - labelSize.width(); + } else { + x = actionRect.left(); + } + x = qMax(screenGeometry.left(), x); + x = qMin(screenGeometry.right() - labelSize.width() + 1, x); + + const bool belowFits = actionRect.bottom() + previewOffset + labelSize.height() <= screenGeometry.bottom(); + const bool aboveFits = actionRect.top() - previewOffset - labelSize.height() >= screenGeometry.top(); + + int y; + if (belowFits) { + y = actionRect.bottom() + previewOffset; + } else if (aboveFits) { + y = actionRect.top() - previewOffset - labelSize.height(); + } else { + y = actionRect.top(); + } + y = qMax(screenGeometry.top(), y); + y = qMin(screenGeometry.bottom() - labelSize.height() + 1, y); + + return {x, y}; +} +} // namespace + /** * @brief Constructs a PrintingSelectorCardOverlayWidget for displaying a card overlay. * @@ -50,6 +99,31 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa initializePinBadge(); + // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps + // it a frameless, non-activating top-level window despite the parent). + cardOverridePreviewLabel = new QLabel(this, Qt::ToolTip); + cardOverridePreviewLabel->setWindowFlag(Qt::FramelessWindowHint); + cardOverridePreviewLabel->setAttribute(Qt::WA_ShowWithoutActivating); + cardOverridePreviewLabel->setScaledContents(true); + cardOverridePreviewLabel->hide(); + + // While the preview is visible, keep it honest: when the hovered printing's art + // resolves (all alternate printings share the root card's CardInfo), redraw it in place. + if (rootCard.getCardPtr()) { + connect(rootCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this] { + if (cardOverridePreviewLabel->isVisible()) { + refreshPreview(); + } + }); + } + + // Alt-Tab / app-inactive must not strand the floating preview. + connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + if (state != Qt::ApplicationActive) { + hidePreview(); + } + }); + // Update when this overlay emits cardPreferenceChanged or when size/scale changes connect(this, &PrintingSelectorCardOverlayWidget::cardPreferenceChanged, this, &PrintingSelectorCardOverlayWidget::updatePinBadgeVisibility); @@ -185,19 +259,23 @@ void PrintingSelectorCardOverlayWidget::leaveEvent(QEvent *event) } /** - * @brief Creates and shows a custom context menu when the right mouse button is clicked. + * @brief Creates and shows the card-overlay context menu. * - * The context menu includes an option to show related cards, which displays a submenu with actions - * for each related card. When an action is triggered, the card information is updated, and the - * printing selector is shown. + * The menu includes the card art preference (Pin/Unpin Printing), the Image Overrides + * submenu (Load Custom Image, Clear Custom Image, and one entry per alternate printing with a + * live preview), and the Show Related cards submenu. * - * @param point The position of the mouse when the right-click occurred. + * @param point The local position the menu should pop at. */ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) { QMenu menu; - auto *preferenceMenu = new QMenu(tr("Preference")); + hidePreview(); // Clear any preview state left over from a previous menu run. + + // Submenus are owned by the stack-allocated top-level menu (addMenu() does not + // transfer ownership). + auto *preferenceMenu = new QMenu(tr("Preference"), &menu); menu.addMenu(preferenceMenu); const auto &preferredProviderId = @@ -219,8 +297,66 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } + menu.addSeparator(); + + auto *overrideMenu = new QMenu(tr("Image Overrides"), &menu); + + auto *loadCustomAction = overrideMenu->addAction(tr("Load Custom Image...")); + auto *clearOverrideAction = overrideMenu->addAction(tr("Clear Custom Image")); + + // Nothing to clear on a card that has no local override yet. + clearOverrideAction->setEnabled(CardPictureLoader::hasLocalOverrides(rootCard)); + + overrideMenu->addSeparator(); + + const auto &allSets = rootCard.getInfo().getSets(); + + for (const auto &set : allSets) { + for (const auto &printing : set) { + if (printing == rootCard.getPrinting()) { + continue; + } + + // The submenu is already scoped to this card, so the rows lead with set + + // collector; only printings with a distinct display name add their own name. + const CardSetPtr cardSet = printing.getSet(); + if (!cardSet) { + continue; + } + + QString label = tr("%1 %2").arg(cardSet->getCorrectedShortName(), printing.getProperty("num")); + + auto *action = overrideMenu->addAction(label); + + ExactCard overrideCard(rootCard.getCardPtr(), printing); + action->setData(QVariant::fromValue(overrideCard)); + + connect(action, &QAction::triggered, this, [this, overrideCard]() { + CardPictureLoader::getInstance().installPrintingOverride(rootCard, overrideCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // refresh the overlay art in place, like the other paths + }); + } + } + + connect(clearOverrideAction, &QAction::triggered, this, [this]() { + CardPictureLoader::deleteAllLocalOverrides(rootCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // force UI refresh + }); + + connect(loadCustomAction, &QAction::triggered, this, &PrintingSelectorCardOverlayWidget::loadCustomImage); + + connect(overrideMenu, &QMenu::hovered, this, &PrintingSelectorCardOverlayWidget::showPreviewForAction); + connect(overrideMenu, &QMenu::aboutToHide, this, &PrintingSelectorCardOverlayWidget::hidePreview); + connect(overrideMenu, &QMenu::triggered, this, &PrintingSelectorCardOverlayWidget::hidePreview); + + menu.addMenu(overrideMenu); + + menu.addSeparator(); + // filling out the related cards submenu - auto *relatedMenu = new QMenu(tr("Show Related cards")); + auto *relatedMenu = new QMenu(tr("Show Related cards"), &menu); menu.addMenu(relatedMenu); auto relatedCards = rootCard.getInfo().getAllRelatedCards(); if (relatedCards.isEmpty()) { @@ -235,7 +371,11 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } } + // The preview anchors itself to this popup's global geometry while it is open, so the + // pointer must stay valid for the whole exec() and be dropped before the stack unwinds. + previewSourceMenu = overrideMenu; menu.exec(this->mapToGlobal(point)); + previewSourceMenu = nullptr; } /** @@ -291,3 +431,136 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() pinBadge->setVisible(false); pinBadge->raise(); } + +/** + * @brief Asks for an image file and installs it as the card's custom art. + * + * Unreadable files answer with a visible warning instead of a silent no-op. + */ +void PrintingSelectorCardOverlayWidget::loadCustomImage() +{ + QString filePath = QFileDialog::getOpenFileName(this, tr("Select Card Image"), QString(), + tr("Images (*.png *.jpg *.jpeg *.webp)")); + + if (filePath.isEmpty()) { + return; + } + + QPixmap pixmap(filePath); + if (pixmap.isNull()) { + // No silent paths: a file that cannot be read answers visibly instead of a no-op. + QMessageBox::warning(this, tr("Load Custom Image"), tr("The selected file could not be read as an image.")); + return; + } + + CardPictureLoader::getInstance().saveCardImageToLocalStorage(rootCard, pixmap, true); + + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); +} + +/** + * @brief Shows the hover preview for a highlighted printing entry in the Image Overrides submenu. + * + * QMenu::hovered fires on keyboard highlight too, so the preview appears when arrows walk + * onto a printing entry, not only under the mouse. + * + * Non-printing entries (e.g., Load Custom Image, Clear Custom Image) hide the preview. + * + * @param action The action that was highlighted. + */ +void PrintingSelectorCardOverlayWidget::showPreviewForAction(QAction *action) +{ + if (!action) { + hidePreview(); + return; + } + + const QVariant data = action->data(); + + if (!data.canConvert()) { + hidePreview(); + return; + } + + const ExactCard previewCard = qvariant_cast(data); + if (previewCard.isEmpty()) { + hidePreview(); + return; + } + + hoveredOverrideCard = previewCard; + hoveredOverrideAction = action; + refreshPreview(); +} + +/** + * @brief Renders the hover preview for the currently highlighted printing. + * + * The preview shows the loading placeholder while the art is pending and swaps in the real + * art when it resolves. The label is positioned against its already-resized geometry so the + * first-ever show at the screen's edges stays fully on-screen. + */ +void PrintingSelectorCardOverlayWidget::refreshPreview() +{ + if (hoveredOverrideCard.isEmpty()) { + hidePreview(); + return; + } + + constexpr QSize previewSize(240, 336); + constexpr int previewOffset = 20; + + QPixmap pixmap; + CardPictureLoader::getPixmap(pixmap, hoveredOverrideCard, previewSize); + + if (pixmap.isNull()) { + // Keep the preview honest while loading: show the loading placeholder instead of a void. + // Fetch at the logical size and let the label scale it, so the placeholder matches the + // real art's footprint rather than doubling on HiDPI displays. + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, previewSize); + } + + cardOverridePreviewLabel->setPixmap(pixmap); + // QPixmap::size() is physical pixels; the label layout must use the device-independent size + // so the preview keeps a constant footprint across DPI settings (QScreen geometry is logical). + const QSize labelSize = pixmap.deviceIndependentSize().toSize(); + cardOverridePreviewLabel->resize(labelSize); + + // Anchor the preview to the walked submenu popup rather than QCursor::pos(), which is idle + // under keyboard-only operation: a keyboard-highlighted row must preview at the same place as + // a hovered one. The mouse path is unchanged in effect — the popup sits under the cursor, so + // the preview stays beside the row in both modalities. + const QMenu *popup = previewSourceMenu; + if (!popup || !popup->isVisible() || !hoveredOverrideAction) { + hidePreview(); + return; + } + + const QRect popupGeometry = popup->geometry(); + const QRect actionRectLocal = popup->actionGeometry(hoveredOverrideAction); + const QRect actionRect(popupGeometry.topLeft() + actionRectLocal.topLeft(), actionRectLocal.size()); + + QScreen *screen = QGuiApplication::screenAt(popupGeometry.center()); + if (!screen) { + hidePreview(); + return; + } + const QRect &screenGeometry = screen->geometry(); + + cardOverridePreviewLabel->move(previewPositionNear(actionRect, labelSize, screenGeometry, previewOffset)); + cardOverridePreviewLabel->show(); +} + +/** + * @brief Hides the hover preview and forgets the currently highlighted printing. + */ +void PrintingSelectorCardOverlayWidget::hidePreview() +{ + hoveredOverrideCard = ExactCard(); + hoveredOverrideAction = nullptr; + + if (cardOverridePreviewLabel) { + cardOverridePreviewLabel->hide(); + } +} diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h index 228393c9c..fbcfa9230 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h @@ -13,6 +13,9 @@ #include +class QAction; +class QMenu; + class PrintingSelectorCardOverlayWidget : public QWidget { Q_OBJECT @@ -43,11 +46,19 @@ private slots: private: void initializePinBadge(); + void loadCustomImage(); + void showPreviewForAction(QAction *action); + void refreshPreview(); + void hidePreview(); CardInfoPictureWidget *cardInfoPicture; AllZonesCardAmountWidget *allZonesCardAmountWidget; QLabel *pinBadge = nullptr; AbstractTabDeckEditor *deckEditor; ExactCard rootCard; + QLabel *cardOverridePreviewLabel = nullptr; + ExactCard hoveredOverrideCard; + QMenu *previewSourceMenu = nullptr; + QAction *hoveredOverrideAction = nullptr; }; #endif // PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H 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_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); 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/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); 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..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,9 +441,18 @@ 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); + aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); aReport->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); @@ -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) { @@ -606,7 +625,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) @@ -698,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/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c8494f095..f161f0f19 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -59,8 +59,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&schemeCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); - // Qt widget style; "Default" lets the application decide - styleCombo.addItem(tr("Default"), QStringLiteral("Default")); + // Qt widget style; "System" lets the application decide + styleCombo.addItem(tr("System"), QStringLiteral("System")); for (const QString &key : QStyleFactory::keys()) { styleCombo.addItem(key, key); } @@ -132,6 +132,10 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setHomeTabDisplayCardName); + homeTabBackgroundDimCheckBox.setChecked(settings.appearance().getHomeTabBackgroundDim()); + connect(&homeTabBackgroundDimCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundDim); + for (const auto &entry : HomeTabButtonColor::all()) { homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); } @@ -150,6 +154,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2); homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0); homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1); + homeTabGrid->addWidget(&homeTabBackgroundDimCheckBox, 4, 0, 1, 2); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); @@ -508,9 +513,12 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:")); homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + homeTabBackgroundDimCheckBox.setText(tr("Dim the home tab background")); + homeTabBackgroundDimCheckBox.setToolTip( + tr("Draw a translucent overlay over the home tab background so buttons and text stand out")); homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); homeTabButtonColorSourceBox.setToolTip( - tr("Automatic: extract from background if present, otherwise use theme default")); + tr("Use the theme's identity accent colors, or extract colors from the background image")); playmatGroupBox->setTitle(tr("Playmat settings")); playmatVisibilityLabel.setText(tr("Playmat visibility:")); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 8db71ff8f..bec4cd72f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -41,6 +41,7 @@ private: QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QCheckBox homeTabBackgroundDimCheckBox; QLabel homeTabButtonColorSourceLabel; QComboBox homeTabButtonColorSourceBox; 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/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 62b06fb60..36436f8a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,15 +1,21 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" #include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" #include +#include #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -46,10 +52,27 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&languageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::languageBoxChanged); + // card text & images language, independent of the UI language + cardLanguageBox.addItem(tr("English"), "en"); + for (const QString &code : CardLocalization::supportedLanguages()) { + cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code); + } + const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang()); + cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex); + + connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 1, 0); + languageGrid->addWidget(&cardLanguageBox, 1, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); @@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index) SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); } +void GeneralSettingsPage::cardLanguageBoxChanged(int index) +{ + const QString lang = cardLanguageBox.itemData(index).toString(); + SettingsCache::instance().cardsDisplay().setCardLang(lang); + + // Switching to a non-default language only takes effect after the card + // database is re-imported with that language selected; English data is always + // present, so switching back to English needs no prompt. + if (lang == "en") { + return; + } + + // The binary cache does not track the language its entries were imported in, + // and the downloaded pictures were fetched with English art names, so both are + // stale until Oracle re-imports the database in the new language: drop them. + QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache"); + CardPictureLoader::clearNetworkCache(); + CardPictureLoader::clearPixmapCache(); + + // Art is resolved by the translated card name for non-English languages, so the + // matching Scryfall URL is added to the top of the download list. It stays + // visible in the deck editor settings, where it can be removed or reordered. + const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl(); + + QString message = tr("

The card database only contains English card data. To see cards in %1, " + "Oracle must run once with this language selected and re-import the card " + "database.

" + "

The cached database and the downloaded card pictures have been cleared, so a " + "re-import is picked up without stale entries.

") + .arg(cardLanguageBox.itemText(index)); + if (localizedUrlAdded) { + message += tr("

The Scryfall URL that resolves card art by translated name was added to the top of your " + "download list. You can remove or reorder it any time.

"); + } + message += tr("

Run Oracle now?

"); + + const QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + // The answer only controls whether Oracle starts right away; the caches stay + // cleared so the next import or launch rebuilds them in the new language. + if (answer == QMessageBox::Yes) { + emit cardDatabaseUpdateRequested(); + } +} + void GeneralSettingsPage::updateStartupServerControlsVisibility() { const int index = startupTabSelector.currentIndex(); @@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi() languageGroupBox->setTitle(tr("Language settings")); languageLabel.setText(tr("Language:")); + cardLanguageBox.setItemText(0, tr("English")); + cardLanguageLabel.setText(tr("Card text & images language:")); + cardLanguageNoteLabel.setText( + tr("Foreign card names, text and art apply after you update the card database (Oracle).")); advertiseTranslationPageLabel.setText( QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index e0c1a47bf..7afba158f 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -23,6 +23,10 @@ public: static QStringList findQmFiles(); static QString languageName(const QString &lang); +signals: + /// Request to re-import the card database with the newly selected card language + void cardDatabaseUpdateRequested(); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -33,6 +37,7 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(int index); void updateStartupServerControlsVisibility(); private: @@ -46,6 +51,10 @@ private: QComboBox languageBox; QLabel advertiseTranslationPageLabel; + QLabel cardLanguageLabel; + QComboBox cardLanguageBox; + QLabel cardLanguageNoteLabel; + QLabel updateReleaseChannelLabel; QComboBox updateReleaseChannelBox; QCheckBox startupUpdateCheckCheckBox; 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..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 @@ -59,6 +60,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 +81,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); @@ -102,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; @@ -256,6 +262,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/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/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 66b68d823..657ef3dbe 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_api_response_deck_display_widget.h" +#include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" #include "../../../../../deck_loader/deck_loader.h" #include "../../../../cards/card_size_widget.h" @@ -10,6 +11,7 @@ #include #include +#include ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi } model = new DeckListModel(this); + model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model, + [this](const QString &lang) { model->setDisplayLanguage(lang); }); connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset); auto decklist = QSharedPointer(new DeckList); 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_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) 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_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp new file mode 100644 index 000000000..39652ef7d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,261 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#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::Preferred); + statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + statsTable->setSelectionMode(QAbstractItemView::SingleSelection); + 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->addLayout(tableLayout, 1); + mainLayout->addLayout(buttonLayout); + + auto *central = new QWidget; + central->setLayout(mainLayout); + setCentralWidget(central); + + retranslateUi(); +} + +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(); + } +} + +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); +} + +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(); + statsTable->insertRow(row); + statsTable->setItem(row, 0, new QTableWidgetItem(name)); + 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; + } + + 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())); + 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")); + + // 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 new file mode 100644 index 000000000..727a56bcb --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,55 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +class AbstractClient; +class QCheckBox; +class QLabel; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTimer; +class Response; + +class TabDeveloper : public Tab +{ + Q_OBJECT +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); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Developer"); + } +}; + +#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() 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_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_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_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 ed0ddaf06..462aa420b 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) { @@ -1063,6 +1114,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); @@ -1242,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); @@ -1277,7 +1335,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/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/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/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index fd529ff69..d1a780c38 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,11 +1,10 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "../visual_deck_storage_widget.h" #include -DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) +DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) { setLayout(layout); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index def45de66..d54984bcf 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -14,14 +14,12 @@ #include #include -class VisualDeckStorageWidget; - class DeckPreviewColorIdentityFilterWidget : public QWidget { Q_OBJECT public: - explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); + explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr); void retranslateUi(); /** diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index db466b77a..a7fef5031 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -48,6 +48,7 @@ QSize DeckPreviewTagDisplayWidget::sizeHint() const void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) { + const TagState previousState = state; switch (event->button()) { case Qt::LeftButton: setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected); @@ -62,7 +63,12 @@ void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) break; } - emit tagClicked(); + // Only announce a change when the state was actually toggled, so a click that falls + // through the switch (e.g. a button the widget does not react to) does not drive a + // full tag-filter update and layout pass for nothing. + if (state != previousState) { + emit tagClicked(); + } QWidget::mousePressEvent(event); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h index 980741c5d..df2a6b404 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h @@ -48,7 +48,10 @@ public: signals: /** - * @brief Emitted when the tag is clicked. + * @brief Emitted when a click toggles the chip's selection/exclusion state. + * + * Not emitted for clicks that leave the state unchanged. Connected handlers use + * this as the trigger to update filters built from selectedTags()/excludedTags(). */ void tagClicked(); 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_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index ba52cf8e9..c7f1b9f13 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,14 +2,10 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" -#include "visual_deck_storage_widget.h" #include -VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent) - : QWidget(_parent), parent(_parent) +VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); @@ -25,99 +21,72 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto layout->addWidget(flowWidget); } +void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function()> &provider) +{ + allTagsProvider = provider; +} + void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) { QWidget::showEvent(event); refreshTags(); } -/** - * @brief The tags of all decks currently accepted by the proxy model. - */ -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const -{ - QSet allTags; - auto *proxy = parent->proxyModel(); - - for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { - const QModelIndex index = proxy->index(proxyRow, 0); - if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { - continue; - } - const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); - for (const QString &tag : deckTags) { - allTags.insert(tag); - } - } - - return allTags; -} - void VisualDeckStorageTagFilterWidget::refreshTags() { - QSet allTags = gatherAllTags(); - removeTagsNotInList(allTags); - addTagsIfNotPresent(allTags); - sortTags(); -} + const QSet allTags = allTagsProvider ? allTagsProvider() : QSet(); -void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) -{ + // Existing chips survive if their tag is still part of the deck set, or if the chip + // is currently selected/excluded. Everything else is dropped. Dropped chips must NOT + // be re-added to the layout afterwards: they are reparented to nullptr and scheduled + // for a deferred delete (QWidget::setParent(nullptr) also hides them), and the flow + // layout would keep a dangling reference to them once the deletion runs on the next + // event-loop cycle. + QList chips; for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - const QString &tagName = tagWidget->getTagName(); - - // Keep the tag widget if it is either selected or excluded - if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) { + if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) { + chips.append(tagWidget); + } else { flowWidget->removeWidget(tagWidget); + tagWidget->setParent(nullptr); tagWidget->deleteLater(); } } -} -void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet &tags) -{ - for (const QString &tag : tags) { - addTagIfNotPresent(tag); - } -} - -void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) -{ - // Check if the tag already exists in the flow widget - bool tagExists = false; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getTagName() == tag) { - tagExists = true; - break; + // Add chips for tags that are not shown yet. + for (const QString &tag : allTags) { + bool tagExists = false; + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { + if (tagWidget->getTagName() == tag) { + tagExists = true; + break; + } + } + if (!tagExists) { + auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); + connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, + &VisualDeckStorageTagFilterWidget::filterChanged); + flowWidget->addWidget(newTagWidget); + chips.append(newTagWidget); } } - // If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget - if (!tagExists) { - auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, - &VisualDeckStorageWidget::updateTagFilter); - flowWidget->addWidget(newTagWidget); - } -} - -void VisualDeckStorageTagFilterWidget::sortTags() -{ - // Get all tag widgets - QList tagWidgets = findChildren(); - - // Sort widgets by tag name - std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { + // Sort, but skip the full remove/re-add when the order already matches the layout. + // FlowWidget inherits QLayout::removeWidget's linear scan, so rebuilding an unchanged + // order would be quadratic plus a full relayout on every chip click and load batch. + std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { return a->getTagName().toLower() < b->getTagName().toLower(); }); - - // Clear and re-add widgets in sorted order - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + if (chips == currentChipOrder) { + return; + } + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->removeWidget(tagWidget); } - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->addWidget(tagWidget); } + currentChipOrder = chips; } QStringList VisualDeckStorageTagFilterWidget::selectedTags() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 337c053c7..8d89bed34 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -9,26 +9,28 @@ #include #include #include +#include +class DeckPreviewTagDisplayWidget; class FlowWidget; -class VisualDeckStorageWidget; + class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT - VisualDeckStorageWidget *parent; FlowWidget *flowWidget; - - [[nodiscard]] QSet gatherAllTags() const; - void removeTagsNotInList(const QSet &tags); - void addTagsIfNotPresent(const QSet &tags); - void addTagIfNotPresent(const QString &tag); - void sortTags(); + std::function()> allTagsProvider; + QList currentChipOrder; public: - explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); + explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr); [[nodiscard]] QStringList getAllKnownTags() const; + /** + * @brief Sets a provider for the full set of tags to draw chips from. + */ + void setAllTagsProvider(const std::function()> &provider); + /** * @brief The tags currently in "selected" state. */ @@ -39,9 +41,18 @@ public: */ [[nodiscard]] QStringList excludedTags() const; +signals: + /** + * Emitted when a chip's selection or exclusion state changes. + * + * The chip only emits when its state actually changed, so this fires once per + * effective toggle rather than on every click." + */ + void filterChanged(); + public slots: /** - * @brief Rebuilds the tag chips from the tags of the currently visible decks. + * @brief Rebuilds the tag chips from the currently available tags. */ void refreshTags(); void showEvent(QShowEvent *event) override; 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..78c7961aa 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); @@ -61,6 +62,9 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); }); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, + &VisualDeckStorageWidget::updateTagFilter); updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); deckPreviewSelectionAnimationEnabled = @@ -216,6 +220,25 @@ void VisualDeckStorageWidget::updateTagFilter() tagFilterWidget->refreshTags(); } +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageWidget::gatherVisibleTags() const +{ + QSet allTags; + for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) { + const QModelIndex index = storageProxyModel->index(proxyRow, 0); + if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { + continue; + } + const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); + for (const QString &tag : deckTags) { + allTags.insert(tag); + } + } + return allTags; +} + /** * Pushes the color identity filter widget's state into the proxy model. */ diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index fe6389414..5988e5704 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -70,6 +70,7 @@ protected: private: void reapplySortAndFilters(); + [[nodiscard]] QSet gatherVisibleTags() const; private: QVBoxLayout *layout; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4567991c8..722df90ef 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -33,6 +33,7 @@ #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" #include "../interface/widgets/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" @@ -44,6 +45,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" @@ -91,8 +93,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" @@ -245,6 +247,8 @@ void MainWindow::actFullScreen(bool checked) void MainWindow::actSettings() { DlgSettings dlg(this); + auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage)); + connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates); dlg.exec(); } @@ -271,7 +275,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 +328,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 */ @@ -682,6 +687,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); @@ -816,7 +822,7 @@ void MainWindow::createTrayIcon() trayIcon = new QSystemTrayIcon(this); trayIcon->setContextMenu(trayIconMenu); - trayIcon->setIcon(QPixmap("theme:cockatrice")); + trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice"))); trayIcon->show(); } @@ -843,6 +849,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; @@ -1036,7 +1053,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()); @@ -1057,11 +1074,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 +1160,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/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index d8aa1cd08..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(); @@ -349,9 +349,9 @@ int main(int argc, char *argv[]) } qCInfo(MainLog) << "MainWindow constructor finished"; - ui.setWindowIcon(QPixmap("theme:cockatrice")); - // set name of the app desktop file; used by wayland to load the window icon - QGuiApplication::setDesktopFileName("cockatrice"); + ui.setWindowIcon(themePixmap(QStringLiteral("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/cockatrice/themes/CMakeLists.txt b/cockatrice/themes/CMakeLists.txt index 577977551..70344d95a 100644 --- a/cockatrice/themes/CMakeLists.txt +++ b/cockatrice/themes/CMakeLists.txt @@ -2,7 +2,7 @@ # # add themes subfolders -set(defthemes Default Fabric Fusion Leather Plasma VelvetMarble) +set(defthemes Fabric Fusion Leather Plasma VelvetMarble System) if(UNIX) if(APPLE) diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png new file mode 100644 index 000000000..8d9514626 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png new file mode 100644 index 000000000..78f51a686 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml new file mode 100644 index 000000000..cad18921a --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Fabric — dark identity palette (navy-cloth chrome) +[Palette] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #4a82e6 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #18202e +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #9d9d9d +BrightText = #8ec2ff +ButtonText = #787878 +Base = #18202e +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml new file mode 100644 index 000000000..f5151af3e --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -0,0 +1,74 @@ +# Fabric — light identity palette (navy-cloth chrome) +[Palette] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #2f6fd6 +HighlightedText = #ffffff +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[Palette.Disabled] +WindowText = #787878 +Button = #dbe3ef +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #787878 +BrightText = #1d4fa0 +ButtonText = #969696 +Base = #dbe3ef +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #000000 +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/theme.cfg b/cockatrice/themes/Fabric/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Fabric/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Fabric/zones/handzone-light.png b/cockatrice/themes/Fabric/zones/handzone-light.png new file mode 100644 index 000000000..a87f9e292 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/handzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/handzone.png b/cockatrice/themes/Fabric/zones/handzone.png index 25d22b070..a5b72286c 100644 Binary files a/cockatrice/themes/Fabric/zones/handzone.png and b/cockatrice/themes/Fabric/zones/handzone.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone-light.png b/cockatrice/themes/Fabric/zones/playerzone-light.png new file mode 100644 index 000000000..05da3ea9b Binary files /dev/null and b/cockatrice/themes/Fabric/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone.png b/cockatrice/themes/Fabric/zones/playerzone.png index 728fccdfa..7fa977249 100644 Binary files a/cockatrice/themes/Fabric/zones/playerzone.png and b/cockatrice/themes/Fabric/zones/playerzone.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone-light.png b/cockatrice/themes/Fabric/zones/stackzone-light.png new file mode 100644 index 000000000..dcd6dc045 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone.png b/cockatrice/themes/Fabric/zones/stackzone.png index 89e2080f8..9046aa049 100644 Binary files a/cockatrice/themes/Fabric/zones/stackzone.png and b/cockatrice/themes/Fabric/zones/stackzone.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone-light.png b/cockatrice/themes/Fabric/zones/tablezone-light.png new file mode 100644 index 000000000..cb297e7e1 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone.png b/cockatrice/themes/Fabric/zones/tablezone.png index 6a2ce68e1..8048a4cc1 100644 Binary files a/cockatrice/themes/Fabric/zones/tablezone.png and b/cockatrice/themes/Fabric/zones/tablezone.png differ diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index c1d83a4cd..b180e9b63 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -11,15 +11,15 @@ ButtonText = #ffffffff Base = #ff2d2d2d Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff -Accent = #ff00d346 +Accent = #ffc9fd62 [Palette.Disabled] WindowText = #ff9d9d9d @@ -34,7 +34,7 @@ ButtonText = #ff9d9d9d Base = #ff1e1e1e Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff308cc6 LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #ff1e1e1e Shadow = #ff000000 Highlight = #ff1e1e1e HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff Accent = #ff1e1e1e + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 86c41be78..5a1163a2b 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -11,15 +11,15 @@ ButtonText = #ff000000 Base = #ffffffff Window = #fff0f0f0 Shadow = #ff696969 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 -Accent = #ff107532 +Accent = #ff139740 [Palette.Disabled] WindowText = #ff787878 @@ -34,7 +34,7 @@ ButtonText = #ff787878 Base = #fff0f0f0 Window = #fff0f0f0 Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff0000ff LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #fff0f0f0 Shadow = #ff696969 Highlight = #fff0f0f0 HighlightedText = #ff000000 -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 Accent = #fff0f0f0 + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.png b/cockatrice/themes/Leather/backgrounds/home-dark.png new file mode 100644 index 000000000..20d16a27f Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png new file mode 100644 index 000000000..ee1da361d Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml new file mode 100644 index 000000000..b0db0e90a --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Leather — dark identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #4a5f8f +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #1d1a16 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #9d9d9d +BrightText = #e3c27e +ButtonText = #787878 +Base = #1d1a16 +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml new file mode 100644 index 000000000..ff122db76 --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -0,0 +1,74 @@ +# Leather — light identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #34508c +HighlightedText = #ffffff +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[Palette.Disabled] +WindowText = #787878 +Button = #f0e2cb +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #787878 +BrightText = #7a531c +ButtonText = #969696 +Base = #f0e2cb +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #787878 + +[Palette.Inactive] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #000000 +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/theme.cfg b/cockatrice/themes/Leather/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Leather/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Leather/zones/handzone-light.png b/cockatrice/themes/Leather/zones/handzone-light.png new file mode 100644 index 000000000..b2c9bba34 Binary files /dev/null and b/cockatrice/themes/Leather/zones/handzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/handzone.png b/cockatrice/themes/Leather/zones/handzone.png index aba879683..5f803df74 100644 Binary files a/cockatrice/themes/Leather/zones/handzone.png and b/cockatrice/themes/Leather/zones/handzone.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone-light.png b/cockatrice/themes/Leather/zones/playerzone-light.png new file mode 100644 index 000000000..a26f96a5e Binary files /dev/null and b/cockatrice/themes/Leather/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone.png b/cockatrice/themes/Leather/zones/playerzone.png index 4f42ee41d..cccbb3fd3 100644 Binary files a/cockatrice/themes/Leather/zones/playerzone.png and b/cockatrice/themes/Leather/zones/playerzone.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone-light.png b/cockatrice/themes/Leather/zones/stackzone-light.png new file mode 100644 index 000000000..117c738f7 Binary files /dev/null and b/cockatrice/themes/Leather/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone.png b/cockatrice/themes/Leather/zones/stackzone.png index c02e2284a..d9cff892b 100644 Binary files a/cockatrice/themes/Leather/zones/stackzone.png and b/cockatrice/themes/Leather/zones/stackzone.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone-light.png b/cockatrice/themes/Leather/zones/tablezone-light.png new file mode 100644 index 000000000..912cbfb94 Binary files /dev/null and b/cockatrice/themes/Leather/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone.png b/cockatrice/themes/Leather/zones/tablezone.png index 152f4f7e9..8af4c853d 100644 Binary files a/cockatrice/themes/Leather/zones/tablezone.png and b/cockatrice/themes/Leather/zones/tablezone.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png new file mode 100644 index 000000000..ce1469e2b Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png new file mode 100644 index 000000000..2e9000bd6 Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml new file mode 100644 index 000000000..222456f4f --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Plasma — dark identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #6a4df0 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #151220 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #9d9d9d +BrightText = #7fd7ff +ButtonText = #787878 +Base = #151220 +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml new file mode 100644 index 000000000..d54db31d2 --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -0,0 +1,74 @@ +# Plasma — light identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #5a3ee2 +HighlightedText = #ffffff +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[Palette.Disabled] +WindowText = #787878 +Button = #e3dcf7 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #787878 +BrightText = #10406e +ButtonText = #969696 +Base = #e3dcf7 +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #000000 +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/theme.cfg b/cockatrice/themes/Plasma/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Plasma/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Plasma/zones/handzone-light.png b/cockatrice/themes/Plasma/zones/handzone-light.png new file mode 100644 index 000000000..89fa7e6d4 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/handzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/handzone.png b/cockatrice/themes/Plasma/zones/handzone.png index 543571d8a..82debb405 100644 Binary files a/cockatrice/themes/Plasma/zones/handzone.png and b/cockatrice/themes/Plasma/zones/handzone.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone-light.png b/cockatrice/themes/Plasma/zones/playerzone-light.png new file mode 100644 index 000000000..9034818f2 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone.png b/cockatrice/themes/Plasma/zones/playerzone.png index eebcd1f84..4623524f6 100644 Binary files a/cockatrice/themes/Plasma/zones/playerzone.png and b/cockatrice/themes/Plasma/zones/playerzone.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone-light.png b/cockatrice/themes/Plasma/zones/stackzone-light.png new file mode 100644 index 000000000..2607b3389 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone.png b/cockatrice/themes/Plasma/zones/stackzone.png index 1845c7091..144d991f3 100644 Binary files a/cockatrice/themes/Plasma/zones/stackzone.png and b/cockatrice/themes/Plasma/zones/stackzone.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone-light.png b/cockatrice/themes/Plasma/zones/tablezone-light.png new file mode 100644 index 000000000..dcf2ef95e Binary files /dev/null and b/cockatrice/themes/Plasma/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone.png b/cockatrice/themes/Plasma/zones/tablezone.png index 8f998d4bb..4dc36ec2d 100644 Binary files a/cockatrice/themes/Plasma/zones/tablezone.png and b/cockatrice/themes/Plasma/zones/tablezone.png differ diff --git a/cockatrice/themes/Default/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml similarity index 96% rename from cockatrice/themes/Default/palette-default-dark.toml rename to cockatrice/themes/System/palette-default-dark.toml index 3ee174a2f..e101935b4 100644 --- a/cockatrice/themes/Default/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #6effffff + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml new file mode 100644 index 000000000..14c215cdf --- /dev/null +++ b/cockatrice/themes/System/palette-default-light.toml @@ -0,0 +1,67 @@ +[Palette] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ffffffff +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Disabled] +WindowText = #ff787878 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #fff7f7f7 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff787878 +BrightText = #ffffffff +ButtonText = #ff787878 +Base = #fff0f0f0 +Window = #fff0f0f0 +Shadow = #ff000000 +HighlightedText = #ffffffff +Link = #ff0000ff +LinkVisited = #ffff00ff +AlternateBase = #fff7f7f7 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Inactive] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ff000000 +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 \ No newline at end of file diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/System/theme.cfg similarity index 73% rename from cockatrice/themes/Default/theme.cfg rename to cockatrice/themes/System/theme.cfg index d2016a238..4b756a5e8 100644 --- a/cockatrice/themes/Default/theme.cfg +++ b/cockatrice/themes/System/theme.cfg @@ -2,4 +2,4 @@ ColorScheme = Light [Style] -Name = Default +Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png new file mode 100644 index 000000000..34738090f Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png new file mode 100644 index 000000000..de783e086 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png differ diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml new file mode 100644 index 000000000..496943931 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -0,0 +1,74 @@ +# VelvetMarble — dark identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #8fa6c0 +HighlightedText = #101318 +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #131519 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #9d9d9d +BrightText = #c3d8f2 +ButtonText = #787878 +Base = #131519 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #ffffff +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml new file mode 100644 index 000000000..5323fd320 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -0,0 +1,74 @@ +# VelvetMarble — light identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #54687e +HighlightedText = #ffffff +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[Palette.Disabled] +WindowText = #787878 +Button = #d9e0ea +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #787878 +BrightText = #26465f +ButtonText = #969696 +Base = #d9e0ea +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #000000 +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/theme.cfg b/cockatrice/themes/VelvetMarble/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/zones/handzone-light.png b/cockatrice/themes/VelvetMarble/zones/handzone-light.png new file mode 100644 index 000000000..b21e78e61 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.jpg b/cockatrice/themes/VelvetMarble/zones/handzone.jpg deleted file mode 100644 index 2ec9b37fe..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/handzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.png b/cockatrice/themes/VelvetMarble/zones/handzone.png new file mode 100644 index 000000000..3e2b7ebea Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone-light.png b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png new file mode 100644 index 000000000..630aac6eb Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg b/cockatrice/themes/VelvetMarble/zones/playerzone.jpg deleted file mode 100644 index dd13cab78..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.png b/cockatrice/themes/VelvetMarble/zones/playerzone.png new file mode 100644 index 000000000..dcf21ba38 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone-light.png b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png new file mode 100644 index 000000000..62027827c Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg b/cockatrice/themes/VelvetMarble/zones/stackzone.jpg deleted file mode 100644 index b63aa0902..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.png b/cockatrice/themes/VelvetMarble/zones/stackzone.png new file mode 100644 index 000000000..a47f7bf9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone-light.png b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png new file mode 100644 index 000000000..5a9014f9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg b/cockatrice/themes/VelvetMarble/zones/tablezone.jpg deleted file mode 100644 index 9f511491a..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.png b/cockatrice/themes/VelvetMarble/zones/tablezone.png new file mode 100644 index 000000000..1523d0821 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone.png differ diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 59ca3e560..82aed32ab 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -35,6 +35,13 @@ + + + + + + + @@ -49,6 +56,13 @@ + + + + + + + diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index 081e6fd05..15388ecfc 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/card_localization.h libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h @@ -27,6 +28,7 @@ add_library( ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp + libcockatrice/card/card_localization.cpp libcockatrice/card/lazy_properties_hash.cpp libcockatrice/card/database/card_database.cpp libcockatrice/card/database/card_database_cache.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 786e17950..56c737793 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -40,8 +40,11 @@ CardInfo::CardInfo(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(LazyPropertiesHash(_properties)), + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)), + localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) { @@ -59,8 +62,11 @@ CardInfo::CardInfo(const QString &_name, SetToPrintingsMap _sets, const UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames) + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) @@ -83,10 +89,12 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) { - CardInfoPtr ptr( - new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes)); + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, + _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -109,11 +117,13 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets) + bool _appendToSets, + QMap _localizedNames, + QMap _localizedTexts) { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), - std::move(_altNames))); + std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); if (_appendToSets) { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 392dc3849..17894cec5 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -77,6 +77,9 @@ private: QString text; ///< Text description or rules text of the card. bool isToken; ///< Whether this card is a token or not. + QMap localizedNames; ///< Localized card names, keyed by language code. + QMap localizedTexts; ///< Localized rules text, keyed by language code. + LazyPropertiesHash properties; ///< Key-value store of dynamic card properties. QList relatedCards; ///< Forward references to related cards. @@ -100,6 +103,8 @@ public: * @param _reverseRelatedCards Backward references to related cards. * @param _sets Map of set names to printing information. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -108,7 +113,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Constructs a CardInfo from a cache snapshot with precomputed derived @@ -130,6 +137,8 @@ public: * @param _uiAttributes Attributes that affect display and game logic. * @param _simpleName Precomputed simplified name. * @param _altNames Precomputed alternate names. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -140,7 +149,9 @@ public: SetToPrintingsMap _sets, UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames); + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -151,7 +162,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), + isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), + properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -179,6 +191,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -188,7 +202,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Creates a new instance from a cache snapshot with precomputed @@ -208,6 +224,8 @@ public: * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -220,7 +238,9 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true); + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -270,6 +290,96 @@ public: text = _text; emit cardInfoChanged(smartThis); } + + /** + * @brief Returns the card name in the given language, falling back to the + * English name when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized name, or the English name as fallback. + */ + [[nodiscard]] const QString &getLocalizedName(const QString &lang) const + { + const auto it = localizedNames.constFind(lang); + return it != localizedNames.constEnd() ? it.value() : name; + } + + /** + * @brief Returns the rules text in the given language, falling back to the + * English text when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized text, or the English text as fallback. + */ + [[nodiscard]] const QString &getLocalizedText(const QString &lang) const + { + const auto it = localizedTexts.constFind(lang); + return it != localizedTexts.constEnd() ? it.value() : text; + } + + /** + * @brief Returns the localized card names keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedNames() const + { + return localizedNames; + } + + /** + * @brief Returns the localized rules text keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedTexts() const + { + return localizedTexts; + } + + /** + * @brief Sets the card name for the given language. + * + * @param lang Language code. + * @param _localizedName The localized card name. + */ + void setLocalizedName(const QString &lang, const QString &_localizedName) + { + if (localizedNames.value(lang) == _localizedName) { + return; + } + localizedNames.insert(lang, _localizedName); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Sets the rules text for the given language. + * + * @param lang Language code. + * @param _localizedText The localized rules text. + */ + void setLocalizedText(const QString &lang, const QString &_localizedText) + { + if (localizedTexts.value(lang) == _localizedText) { + return; + } + localizedTexts.insert(lang, _localizedText); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Returns the language codes for which this card has a localized + * name or rules text. + */ + [[nodiscard]] QStringList localizationLanguages() const + { + QStringList languages = localizedNames.keys(); + languages.append(localizedTexts.keys()); + languages.removeDuplicates(); + return languages; + } [[nodiscard]] bool getIsToken() const { return isToken; diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..03c6c659b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,38 @@ +#include "card_localization.h" + +#include +#include +#include + +namespace CardLocalization +{ +const QStringList &supportedLanguages() +{ + static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"}; + return languages; +} + +QString languageDisplayName(const QString &lang) +{ + static const QHash displayNames = { + {"cs", "Česky (Czech)"}, + {"de", "Deutsch (German)"}, + {"es", "Español (Spanish)"}, + {"fr", "Français (French)"}, + {"it", "Italiano (Italian)"}, + {"ja", "日本語 (Japanese)"}, + {"ko", "한국어 (Korean)"}, + {"pt", "Português (Portuguese)"}, + {"ru", "Русский (Russian)"}, + {"he", "עברית (Hebrew)"}, + {"zhs", "简体中文 (Chinese Simplified)"}, + {"zht", "繁體中文 (Chinese Traditional)"}, + }; + const QString displayName = displayNames.value(lang); + if (!displayName.isEmpty()) { + return displayName; + } + const QString nativeName = QLocale(lang).nativeLanguageName(); + return nativeName.isEmpty() ? lang : nativeName; +} +} // namespace CardLocalization \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h new file mode 100644 index 000000000..a9c8d28ea --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,43 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include + +/** + * @namespace CardLocalization + * @ingroup Cards + * + * @brief Shared language metadata for localized card text and images. + * + * Lists the language codes Cockatrice can display localized card data for and + * provides human-readable names. The list is shared between Oracle (which + * imports the selected language's card data) and the client settings UI (which + * offers the language choice). + */ +namespace CardLocalization +{ +/** + * @brief Language codes for which localized card data can be imported/displayed. + * + * Matches the languages Scryfall can serve localized card images for. "en" is + * always available as the default/fallback and is not listed here. + * + * @return The list of supported language codes. + */ +[[nodiscard]] const QStringList &supportedLanguages(); + +/** + * @brief Human-readable name for a language code. + * + * Follows the same "native name (English name)" format the UI language list + * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The language's native name with its English name in parentheses, or + * the code itself if it cannot be resolved. + */ +[[nodiscard]] QString languageDisplayName(const QString &lang); +} // namespace CardLocalization + +#endif // CARD_LOCALIZATION_H \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 2b27f50f8..3165ff871 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -17,7 +17,7 @@ namespace { constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" -constexpr quint32 CACHE_VERSION = 2; +constexpr quint32 CACHE_VERSION = 3; // ---- Primitives ----------------------------------------------------------- @@ -71,6 +71,35 @@ QDate readDate(QDataStream &in) return d; } +void writeStringMap(QDataStream &out, const QMap &map) +{ + out << static_cast(map.size()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + writeString(out, it.key()); + writeString(out, it.value()); + } +} + +QMap readStringMap(QDataStream &in) +{ + QMap map; + quint32 count = 0; + in >> count; + if (in.status() != QDataStream::Ok) { + return map; + } + for (quint32 i = 0; i < count; ++i) { + QString key = readString(in); + QString value = readString(in); + if (in.status() != QDataStream::Ok) { + map.clear(); + return map; + } + map.insert(key, value); + } + return map; +} + // ---- CardRelation ---------------------------------------------------------- void writeRelation(QDataStream &out, const CardRelation *rel) @@ -195,6 +224,10 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) for (const CardRelation *rel : reverse) { writeRelation(out, rel); } + + // localized card data + writeStringMap(out, card->getLocalizedNames()); + writeStringMap(out, card->getLocalizedTexts()); } CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) @@ -268,8 +301,19 @@ CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) reverse.append(readRelation(in)); } - return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName, - altNames, false); + const QMap localizedNames = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + const QMap localizedTexts = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + + CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, + simpleName, altNames, false, localizedNames, localizedTexts); + + return card; } // ---- FormatRules ----------------------------------------------------------- diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index ec460d685..19e972a7a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -273,6 +273,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString name = QString(""); QString text = QString(""); QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -298,6 +300,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) // generic properties } else if (xmlName == "prop") { properties = loadCardPropertiesFromXml(xml); + // localized card data + } else if (xmlName == "localizations") { + while (!xml.atEnd()) { + if (xml.readNextStartElement()) { + const QString elementName = xml.name().toString(); + if (elementName == "localization") { + const QString lang = xml.attributes().value("lang").toString(); + QString localizedName; + QString localizedText; + while (!xml.atEnd()) { + if (xml.readNext() == QXmlStreamReader::EndElement) { + break; + } + if (xml.isStartElement()) { + const QString childName = xml.name().toString(); + QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements); + if (childName == "name") { + localizedName = value; + } else if (childName == "text") { + localizedText = value; + } + } + } + if (!lang.isEmpty()) { + if (!localizedName.isEmpty()) { + localizedNames.insert(lang, localizedName); + } + if (!localizedText.isEmpty()) { + localizedTexts.insert(lang, localizedText); + } + } + } else { + xml.skipCurrentElement(); + } + } else { + break; + } + } // positioning info } else if (xmlName == "tablerow") { tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt(); @@ -399,8 +439,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, + attributes, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. @@ -517,6 +558,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } xml.writeEndElement(); + // localized card data + const QStringList localizedLanguages = info->localizationLanguages(); + if (!localizedLanguages.isEmpty()) { + xml.writeStartElement("localizations"); + const QMap &localizedNames = info->getLocalizedNames(); + const QMap &localizedTexts = info->getLocalizedTexts(); + for (const QString &lang : localizedLanguages) { + xml.writeStartElement("localization"); + xml.writeAttribute("lang", lang); + if (localizedNames.contains(lang)) { + xml.writeTextElement("name", localizedNames.value(lang)); + } + if (localizedTexts.contains(lang)) { + xml.writeTextElement("text", localizedTexts.value(lang)); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + } + // sets for (const auto &printings : info->getSets()) { for (const PrintingInfo &set : printings) { diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index c7a54a390..0c487466c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,6 +11,7 @@ set(HEADERS libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h + libcockatrice/deck_list/deck_list_plain_text_parser.h libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -27,6 +28,7 @@ add_library( libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp + libcockatrice/deck_list/deck_list_plain_text_parser.cpp libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 1a3876cd3..0013cb475 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -1,6 +1,7 @@ #include "deck_list.h" #include "deck_list_memento.h" +#include "deck_list_plain_text_parser.h" #include "tree/abstract_deck_list_node.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" @@ -8,26 +9,112 @@ #include #include #include -#include #include #include #include -#if QT_VERSION < 0x050600 -// qHash on QRegularExpression was added in 5.6, FIX IT -uint qHash(const QRegularExpression &key, uint seed) noexcept -{ - return qHash(key.pattern(), seed); // call qHash on pattern QString instead -} -#endif - static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; +/** + * @brief Parses a floating point XML attribute into a clamped playmat parameter. + * + * Falls back to @p fallback when the attribute is missing or malformed, so + * malformed deck files cannot produce degenerate art rectangles (e.g. a zoom + * of 0 dividing by zero). + * + * @param valueString Raw attribute text. + * @param fallback Value used when the text cannot be parsed. + * @param min Lower clamp bound. + * @param max Upper clamp bound. + * @return The parsed value clamped to [min, max], or @p fallback. + */ +static double parseClampedParam(const QString &valueString, double fallback, double min, double max) +{ + bool ok = false; + const double value = valueString.toDouble(&ok); + if (!ok) { + return fallback; + } + return qBound(min, value, max); +} + bool DeckList::Metadata::isEmpty() const { return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty(); } +bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &childName) +{ + if (childName == "lastLoadedTimestamp") { + lastLoadedTimestamp = xml->readElementText(); + } else if (childName == "deckname") { + name = xml->readElementText(); + } else if (childName == "format") { + gameFormat = xml->readElementText(); + } else if (childName == "comments") { + comments = xml->readElementText(); + } else if (childName == "bannerCard") { + QString providerId = xml->attributes().value("providerId").toString(); + QString cardName = xml->readElementText(); + bannerCard = {cardName, providerId}; + } else if (childName == "playmatCard") { + QString providerId = xml->attributes().value("providerId").toString(); + // Attributes are read before readElementText consumes the element. + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + playmat.card = {cardName, providerId}; + // Clamp to the same ranges as the settings dialog and the remote + // player-properties path so malformed deck files cannot produce + // degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + playmat.params.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95); + playmat.params.marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95); + playmat.params.verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0); + playmat.params.zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0); + } else if (childName == "tags") { + tags.clear(); // Clear existing tags + while (xml->readNextStartElement()) { + if (xml->name().toString() == "tag") { + tags.append(xml->readElementText()); + } + } + } else { + return false; + } + return true; +} + +void DeckList::Metadata::write(QXmlStreamWriter *xml) const +{ + xml->writeTextElement("lastLoadedTimestamp", lastLoadedTimestamp); + xml->writeTextElement("deckname", name); + xml->writeTextElement("format", gameFormat); + xml->writeStartElement("bannerCard"); + xml->writeAttribute("providerId", bannerCard.providerId); + xml->writeCharacters(bannerCard.name); + xml->writeEndElement(); + if (!playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(playmat.params.zoom, 'f', 4)); + xml->writeCharacters(playmat.card.name); + xml->writeEndElement(); + } + xml->writeTextElement("comments", comments); + + // Write tags + xml->writeStartElement("tags"); + for (const QString &tag : tags) { + xml->writeTextElement("tag", tag); + } + xml->writeEndElement(); +} + DeckList::DeckList() { } @@ -62,56 +149,10 @@ bool DeckList::readElement(QXmlStreamReader *xml) { const QString childName = xml->name().toString(); if (xml->isStartElement()) { - if (childName == "lastLoadedTimestamp") { - metadata.lastLoadedTimestamp = xml->readElementText(); - } else if (childName == "deckname") { - metadata.name = xml->readElementText(); - } else if (childName == "format") { - metadata.gameFormat = xml->readElementText(); - } else if (childName == "comments") { - metadata.comments = xml->readElementText(); - } else if (childName == "bannerCard") { - QString providerId = xml->attributes().value("providerId").toString(); - QString cardName = xml->readElementText(); - metadata.bannerCard = {cardName, providerId}; - } else if (childName == "playmatCard") { - QString providerId = xml->attributes().value("providerId").toString(); - bool ok; - QString marginLStr = xml->attributes().value("marginPctL").toString(); - QString marginRStr = xml->attributes().value("marginPctR").toString(); - QString vOffStr = xml->attributes().value("verticalOffset").toString(); - QString zoomStr = xml->attributes().value("zoom").toString(); - QString cardName = xml->readElementText(); - PlaymatInfo playmat; - playmat.card = {cardName, providerId}; - // Clamp to the same ranges as the settings dialog and the remote - // player-properties path so malformed deck files cannot produce - // degenerate art rectangles (e.g. a zoom of 0 dividing by zero). - playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95); - if (!ok) { - playmat.params.marginPctL = 0.07; - } - playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95); - if (!ok) { - playmat.params.marginPctR = 0.07; - } - playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0); - if (!ok) { - playmat.params.verticalOffset = 0.33; - } - playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0); - if (!ok) { - playmat.params.zoom = 1.0; - } - metadata.playmat = playmat; - } else if (childName == "tags") { - metadata.tags.clear(); // Clear existing tags - while (xml->readNextStartElement()) { - if (xml->name().toString() == "tag") { - metadata.tags.append(xml->readElementText()); - } - } - } else if (childName == "zone") { + if (metadata.readElement(xml, childName)) { + return true; + } + if (childName == "zone") { tree.readZoneElement(xml); } else if (childName == "sideboard_plan") { SideboardPlan newSideboardPlan; @@ -125,41 +166,12 @@ bool DeckList::readElement(QXmlStreamReader *xml) return true; } -static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metadata) -{ - xml->writeTextElement("lastLoadedTimestamp", metadata.lastLoadedTimestamp); - xml->writeTextElement("deckname", metadata.name); - xml->writeTextElement("format", metadata.gameFormat); - xml->writeStartElement("bannerCard"); - xml->writeAttribute("providerId", metadata.bannerCard.providerId); - xml->writeCharacters(metadata.bannerCard.name); - xml->writeEndElement(); - if (!metadata.playmat.card.isEmpty()) { - xml->writeStartElement("playmatCard"); - xml->writeAttribute("providerId", metadata.playmat.card.providerId); - xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4)); - xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4)); - xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4)); - xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4)); - xml->writeCharacters(metadata.playmat.card.name); - xml->writeEndElement(); - } - xml->writeTextElement("comments", metadata.comments); - - // Write tags - xml->writeStartElement("tags"); - for (const QString &tag : metadata.tags) { - xml->writeTextElement("tag", tag); - } - xml->writeEndElement(); -} - void DeckList::write(QXmlStreamWriter *xml) const { xml->writeStartElement("cockatrice_deck"); xml->writeAttribute("version", "1"); - writeMetadata(xml, metadata); + metadata.write(xml); // Write zones tree.write(xml); @@ -248,160 +260,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in, bool preserveMetadata, const std::function &cardNameNormalizer) { - const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reEmpty("^\\s*$"); - const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", - QRegularExpression::CaseInsensitiveOption); - - // Regex for advanced card parsing - const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); - - // Regex for extracting set code and collector number with attached symbols - const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); - const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); - - cleanList(preserveMetadata); - - auto inputs = in.readAll().trimmed().split('\n'); - auto max_line = inputs.size(); - - // Start at the first empty line before the first card line - auto deckStart = inputs.indexOf(reCardLine); - if (deckStart == -1) { - if (inputs.indexOf(reComment) == -1) { - return false; // Input is empty - } - deckStart = max_line; - } else { - deckStart = inputs.lastIndexOf(reEmpty, deckStart); - if (deckStart == -1) { - deckStart = 0; - } + if (!preserveMetadata) { + metadata = {}; } - - // find sideboard position, if marks are used this won't be needed - int sBStart = -1; - if (inputs.indexOf(reSBMark, deckStart) == -1) { - sBStart = inputs.indexOf(reSBComment, deckStart); - if (sBStart == -1) { - sBStart = inputs.indexOf(reEmpty, deckStart + 1); - if (sBStart == -1) { - sBStart = max_line; - } - auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); - if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { - sBStart = max_line; - } - } - } - - int index = 0; - QRegularExpressionMatch match; - - // Parse name and comments - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.name = match.captured(); - break; - } - } - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.comments += match.captured() + '\n'; - } - } - metadata.comments.chop(1); - - // Discard empty lines - while (index < max_line && inputs.at(index).contains(reEmpty)) { - ++index; - } - - // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard - if (inputs.at(index).contains(reDeckComment)) { - ++index; - } - - // Parse decklist - for (; index < max_line; ++index) { - // check if line is a card - match = reCardLine.match(inputs.at(index)); - if (!match.hasMatch()) { - continue; - } - - QString cardName = match.captured().simplified(); - bool sideboard = false; - - // Sideboard detection - if (sBStart < 0) { - match = reSBMark.match(cardName); - if (match.hasMatch()) { - sideboard = true; - cardName = match.captured(1); - } - } else { - if (index == sBStart) { - continue; - } - sideboard = index > sBStart; - } - - // Extract set code, collector number, and foil - QString setCode; - QString collectorNumber; - bool isFoil = false; - - // Check for foil status at the end of the card name - if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { - isFoil = true; - cardName.chop(3); // Remove the "*F*" from the card name - } - Q_UNUSED(isFoil); - - // Attempt to match the hyphen-separated format (PLST-2094) - match = reHyphenFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(2).toUpper(); - collectorNumber = match.captured(3); - cardName = cardName.left(match.capturedStart()).trimmed(); - } else { - // Attempt to match the regular format (PLST) 2094 - match = reRegularFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(1).toUpper(); - collectorNumber = match.captured(2); - cardName = cardName.left(match.capturedStart()).trimmed(); - } - } - - // check if a specific amount is mentioned - int amount = 1; - match = reMultiplier.match(cardName); - if (match.hasMatch()) { - amount = match.captured(1).toInt(); - cardName = match.captured(2); - } - - // Normalize the card name - cardName = cardNameNormalizer(cardName); - - // Determine the zone (mainboard/sideboard) - QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; - - // make new entry in decklist - tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); - } - + bool ok = DeckListPlainText::parse(in, cardNameNormalizer, metadata, tree); refreshDeckHash(); - return true; + return ok; } bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 475d99560..229e2077c 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -76,6 +76,23 @@ public: * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. */ bool isEmpty() const; + + /** + * @brief Reads a single deck metadata element from a Cockatrice deck XML stream. + * + * @param xml Reader positioned at the element. + * @param childName Name of the current element. + * @return true if a metadata element was consumed, false if @p childName is + * not a metadata element. + */ + bool readElement(QXmlStreamReader *xml, const QString &childName); + + /** + * @brief Writes the deck metadata section of a Cockatrice deck XML file. + * + * @param xml Writer to append the metadata elements to. + */ + void write(QXmlStreamWriter *xml) const; }; private: diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp index acf4707ab..b6d0687b4 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp @@ -14,42 +14,32 @@ void DeckListHistoryManager::clear() emit undoRedoStateChanged(); } -void DeckListHistoryManager::undo(DeckList *deck) +void DeckListHistoryManager::restoreAndSwap(QStack &source, + QStack &target, + DeckList *deck) { - if (undoStack.isEmpty()) { + if (source.isEmpty()) { return; } - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = undoStack.top(); + // The reason is read before the source is popped. + const QString reason = source.top().getReason(); - // Save current state for redo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - redoStack.push(currentState); + // Save the current state so the opposite direction can return to it. + target.push(deck->createMemento(reason)); - // Pop the last state from undo stack and restore it - DeckListMemento memento = undoStack.pop(); - deck->restoreMemento(memento); + // Apply the state we are moving to. + deck->restoreMemento(source.pop()); emit undoRedoStateChanged(); } +void DeckListHistoryManager::undo(DeckList *deck) +{ + restoreAndSwap(undoStack, redoStack, deck); +} + void DeckListHistoryManager::redo(DeckList *deck) { - if (redoStack.isEmpty()) { - return; - } - - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = redoStack.top(); - - // Save current state for undo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - undoStack.push(currentState); - - // Pop the next state from redo stack and restore it - DeckListMemento memento = redoStack.pop(); - deck->restoreMemento(memento); - - emit undoRedoStateChanged(); + restoreAndSwap(redoStack, undoStack, deck); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h index e6bd27e2d..6acdb199e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h @@ -47,6 +47,13 @@ public: } private: + /** + * @brief Moves one state from @p source to @p target, applying it to @p deck. + * + * Used by both undo (undoStack -> redoStack) and redo (redoStack -> undoStack). + */ + void restoreAndSwap(QStack &source, QStack &target, DeckList *deck); + QStack undoStack; QStack redoStack; }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 91fe1874b..35c7943b4 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -197,7 +197,7 @@ void DecklistNodeTree::forEachCard(const std::functionsize(); i++) { auto *node = dynamic_cast(root->at(i)); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 5d91cd233..94fd87680 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -144,7 +144,7 @@ public: private: // Helpers for traversing the tree - InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; + InnerDecklistNode *getZoneObjFromName(const QString &zoneName); InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp new file mode 100644 index 000000000..de50d743b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,172 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "tree/inner_deck_list_node.h" + +#include +#include + +namespace DeckListPlainText +{ + +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree) +{ + tree.clear(); + + static const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reEmpty("^\\s*$"); + static const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", + QRegularExpression::CaseInsensitiveOption); + + // Regex for advanced card parsing + static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + + // Regex for extracting set code and collector number with attached symbols + static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); + static const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); + + auto inputs = in.readAll().trimmed().split('\n'); + auto max_line = inputs.size(); + + // Start at the first empty line before the first card line + auto deckStart = inputs.indexOf(reCardLine); + if (deckStart == -1) { + if (inputs.indexOf(reComment) == -1) { + return false; // Input is empty + } + deckStart = max_line; + } else { + deckStart = inputs.lastIndexOf(reEmpty, deckStart); + if (deckStart == -1) { + deckStart = 0; + } + } + + // find sideboard position, if marks are used this won't be needed + int sBStart = -1; + if (inputs.indexOf(reSBMark, deckStart) == -1) { + sBStart = inputs.indexOf(reSBComment, deckStart); + if (sBStart == -1) { + sBStart = inputs.indexOf(reEmpty, deckStart + 1); + if (sBStart == -1) { + sBStart = max_line; + } + auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); + if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { + sBStart = max_line; + } + } + } + + int index = 0; + QRegularExpressionMatch match; + + // Parse name and comments + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.name = match.captured(); + break; + } + } + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.comments += match.captured() + '\n'; + } + } + metadata.comments.chop(1); + + // Discard empty lines + while (index < max_line && inputs.at(index).contains(reEmpty)) { + ++index; + } + + // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard + if (inputs.at(index).contains(reDeckComment)) { + ++index; + } + + // Parse decklist + for (; index < max_line; ++index) { + // check if line is a card + match = reCardLine.match(inputs.at(index)); + if (!match.hasMatch()) { + continue; + } + + QString cardName = match.captured().simplified(); + bool sideboard = false; + + // Sideboard detection + if (sBStart < 0) { + match = reSBMark.match(cardName); + if (match.hasMatch()) { + sideboard = true; + cardName = match.captured(1); + } + } else { + if (index == sBStart) { + continue; + } + sideboard = index > sBStart; + } + + // Extract set code, collector number, and foil + QString setCode; + QString collectorNumber; + bool isFoil = false; + + // Check for foil status at the end of the card name + if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { + isFoil = true; + cardName.chop(3); // Remove the "*F*" from the card name + } + Q_UNUSED(isFoil); + + // Attempt to match the hyphen-separated format (PLST-2094) + match = reHyphenFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(2).toUpper(); + collectorNumber = match.captured(3); + cardName = cardName.left(match.capturedStart()).trimmed(); + } else { + // Attempt to match the regular format (PLST) 2094 + match = reRegularFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(1).toUpper(); + collectorNumber = match.captured(2); + cardName = cardName.left(match.capturedStart()).trimmed(); + } + } + + // check if a specific amount is mentioned + int amount = 1; + match = reMultiplier.match(cardName); + if (match.hasMatch()) { + amount = match.captured(1).toInt(); + cardName = match.captured(2); + } + + // Normalize the card name + cardName = cardNameNormalizer(cardName); + + // Determine the zone (mainboard/sideboard) + QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; + + // make new entry in decklist + tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); + } + + return true; +} + +} // namespace DeckListPlainText \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h new file mode 100644 index 000000000..e0f456a18 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,33 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include +#include + +class QTextStream; + +namespace DeckListPlainText +{ + +/** + * @brief Parses a plain-text deck list into a tree and its metadata. + * + * Clears the tree first, then fills both from the text. + * + * @param in The text to load + * @param cardNameNormalizer Function that takes the parsed card name string + * in the text and returns the name to store + * @param metadata Deck metadata written by the parser + * @param tree Deck tree the parser adds cards to + * @return False if the input was empty, true otherwise. + */ +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree); + +} // namespace DeckListPlainText + +#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 7200ede5f..685238d53 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp @@ -34,17 +34,6 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */) -{ - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isEndElement() && xml->name().toString() == "card") { - return 0; - } - } - return 0; -} - void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) { xml->writeEmptyElement("card"); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h index 52dd56529..0942a4601 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h @@ -134,15 +134,6 @@ public: */ bool compareName(AbstractDecklistNode *other) const; - /** - * @brief Deserialize this node’s properties from XML. - * @param xml QXmlStreamReader positioned at the element. - * @return true if parsing succeeded. - * - * This supports loading deck files from Cockatrice’s XML format. - */ - int readElement(QXmlStreamReader *xml, int limit) override; - /** * @brief Serialize this node’s properties to XML. * @param xml Writer to append this node’s XML element. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index 9c4290db0..38d1d44b8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -179,11 +179,10 @@ public: /** * @name XML serialization - * These methods support reading and writing decks from/to + * This method supports writing this node and its children to the * Cockatrice deck XML format. * @{ */ - virtual int readElement(QXmlStreamReader *xml, int limit) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0; /// @} }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index d082b3cca..510a710cd 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -164,12 +164,11 @@ int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) } else if (childName == "card") { int amount = xml->attributes().value("number").toString().toInt(); amount = qMin(amount, limit - totalCards); - auto *newCard = new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, - xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), - xml->attributes().value("uuid").toString()); + new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, + xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), + xml->attributes().value("uuid").toString()); totalCards += amount; - totalCards += newCard->readElement(xml, limit - totalCards); } } else if (xml->isEndElement() && (childName == "zone")) { return totalCards; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 0d454c11e..958229883 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -229,7 +229,7 @@ public: * @param limit The maximum amount of cards to read * @return the amount of cards found */ - int readElement(QXmlStreamReader *xml, int limit) override; + int readElement(QXmlStreamReader *xml, int limit); /** * @brief Serialize this node and its children to XML. diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3f2cbbe8e..304ab3cb7 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#include + class ICardsDisplaySettingsProvider { public: @@ -26,6 +28,7 @@ public: [[nodiscard]] virtual int getEDHRecCardSize() const = 0; [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; [[nodiscard]] virtual int getSampleHandSize() const = 0; + [[nodiscard]] virtual QString getCardLang() const = 0; }; #endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H 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_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 76afca0c4..a5628a844 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -28,6 +28,16 @@ DeckListModel::~DeckListModel() delete root; } +void DeckListModel::setDisplayLanguage(const QString &lang) +{ + if (displayLang == lang) { + return; + } + displayLang = lang; + emit layoutAboutToBeChanged(); + emit layoutChanged(); +} + /** * @brief Extract the value from the card that is used for the group criteria. * @param info Pointer to card information. @@ -181,8 +191,15 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: return card->getNumber(); - case DeckListModelColumns::CARD_NAME: + case DeckListModelColumns::CARD_NAME: { + if (role == Qt::DisplayRole) { + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); + if (info) { + return info->getLocalizedName(displayLang); + } + } return card->getName(); + } case DeckListModelColumns::CARD_SET: return card->getCardSetShortName(); case DeckListModelColumns::CARD_COLLECTOR_NUMBER: diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 09600ca67..ce6d7f8cb 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -287,6 +287,16 @@ public: explicit DeckListModel(QObject *parent, const QSharedPointer &deckList); ~DeckListModel() override; + /** + * @brief Selects the language code for localized card names in the display role. + * + * The model never reads global settings itself; callers wire this to the card + * language setting (including reacting to its changes) rather than the model + * querying it. + * @param lang Language code; "en" shows the canonical English names. + */ + void setDisplayLanguage(const QString &lang); + /** * @brief Returns the root index of the model. * @return QModelIndex representing the root node. @@ -408,6 +418,7 @@ private: DeckListModelGroupCriteria::Type activeGroupCriteria = DeckListModelGroupCriteria::MAIN_TYPE; int lastKnownColumn; /**< Last column used for sorting. */ Qt::SortOrder lastKnownOrder; /**< Last known sort order. */ + QString displayLang = "en"; /**< Language code for localized card names in the display role. */ InnerDecklistNode *createNodeIfNeeded(const QString &name, InnerDecklistNode *parent); QModelIndex nodeToIndex(AbstractDecklistNode *node) const; 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/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.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..2c8efe50e 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: @@ -129,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/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index f22828f46..bc4814d5e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -15,9 +15,17 @@ set(PROTO_FILES command_deck_del.proto command_deck_del_dir.proto command_deck_download.proto + command_deck_download_public.proto command_deck_list.proto + command_deck_list_other_user.proto command_deck_new_dir.proto command_deck_select.proto + command_deck_set_visibility.proto + command_deck_share_create.proto + command_deck_share_download.proto + command_deck_share_list.proto + command_deck_share_list_mine.proto + command_deck_share_remove.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -25,6 +33,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 +80,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 @@ -135,11 +145,16 @@ set(PROTO_FILES response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto + response_deck_share_create.proto + response_deck_share_download.proto + response_deck_share_list.proto + response_deck_share_list_mine.proto response_deck_upload.proto response_dump_zone.proto 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 @@ -172,6 +187,8 @@ set(PROTO_FILES serverinfo_cardcounter.proto serverinfo_chat_message.proto serverinfo_counter.proto + serverinfo_deck_share_item.proto + serverinfo_deck_share_summary.proto serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.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_deck_download_public.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto new file mode 100644 index 000000000..a5592ef51 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckDownloadPublic { + extend SessionCommand { + optional Command_DeckDownloadPublic ext = 1031; + } + optional uint32 deck_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto new file mode 100644 index 000000000..2459608e2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckListOtherUser { + extend SessionCommand { + optional Command_DeckListOtherUser ext = 1029; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto new file mode 100644 index 000000000..3ada87973 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckSetVisibility { + extend SessionCommand { + optional Command_DeckSetVisibility ext = 1030; + } + // Set the public visibility of a single deck (mutually exclusive with folder_path). + optional uint32 deck_id = 1; + // Set the public visibility of a folder (all decks under it inherit). + optional string folder_path = 2; + optional bool is_public = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto new file mode 100644 index 000000000..9cebf8eae --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message DeckShareItem { + // Reference an existing deck in the sharer's personal deck storage. + // Mutually exclusive with deck_list. + optional uint32 deck_id = 1; + // Inline deck content in the native format. + // Mutually exclusive with deck_id. + optional string deck_list = 2; + // Color identity of the deck (e.g. "WUBRG"), computed by the sharing client. + optional string color_identity = 3; +} + +message Command_DeckShareCreate { + extend SessionCommand { + optional Command_DeckShareCreate ext = 1026; + } + optional string name = 1; + repeated DeckShareItem items = 2; + // Path of a folder in the sharer's personal deck storage. When set, all + // decks in that folder are shared (resolved by the server). + optional string folder_path = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto new file mode 100644 index 000000000..251a662b3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareDownload { + extend SessionCommand { + optional Command_DeckShareDownload ext = 1028; + } + optional string token = 1; + optional uint32 item_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto new file mode 100644 index 000000000..b75fd65f9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareList { + extend SessionCommand { + optional Command_DeckShareList ext = 1027; + } + optional string token = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto new file mode 100644 index 000000000..75bc30714 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Requests the list of share bundles created by the calling user, so they can +// be reviewed and revoked before they expire. +message Command_DeckShareListMine { + extend SessionCommand { + optional Command_DeckShareListMine ext = 1032; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto new file mode 100644 index 000000000..348996da9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Revokes one of the calling user's own share bundles. The referenced items +// are removed by cascade. +message Command_DeckShareRemove { + extend SessionCommand { + optional Command_DeckShareRemove ext = 1033; + } + optional uint32 share_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 63d9c80ef..1a5d44e9e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -8,4 +8,10 @@ message Command_DeckUpload { optional string path = 1; // to upload a new deck optional uint32 deck_id = 2; // to replace an existing deck optional string deck_list = 3; + optional bool is_public = 4; // mark the deck public on upload (publish) + // The server derives the banner card and tags from deck_list, so clients only + // need to send the color identity, which cannot be computed server-side. + reserved 5, 6, 8; + reserved "banner_card_name", "banner_card_provider", "tags"; + optional string color_identity = 7; } 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..caf9febde 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -77,9 +77,14 @@ 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 + DECK_SHARE_CREATE = 1103; // Response to deck share creation + DECK_SHARE_LIST = 1104; // Response listing shared decks + DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download + DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto new file mode 100644 index 000000000..574c02eb6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareCreate { + extend Response { + optional Response_DeckShareCreate ext = 1103; + } + optional string token = 1; + optional uint64 expires_at = 2; + optional uint32 item_count = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto new file mode 100644 index 000000000..def0ccbe6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareDownload { + extend Response { + optional Response_DeckShareDownload ext = 1105; + } + optional string deck = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto new file mode 100644 index 000000000..3edffa8ac --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_item.proto"; + +message Response_DeckShareList { + extend Response { + optional Response_DeckShareList ext = 1104; + } + optional string name = 1; + optional uint64 expires_at = 2; + repeated ServerInfo_DeckShareItem items = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto new file mode 100644 index 000000000..e3cbf83b9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_summary.proto"; + +message Response_DeckShareListMine { + extend Response { + optional Response_DeckShareListMine ext = 1106; + } + repeated ServerInfo_DeckShareSummary shares = 1; +} 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..bb8ff3c43 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -0,0 +1,41 @@ +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; + } + + 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 + + // 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/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto new file mode 100644 index 000000000..bac025419 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +message ServerInfo_DeckShareItem { + optional uint32 id = 1; + optional string name = 2; + repeated string tags = 3; + optional string banner_card = 4; + optional string game_format = 5; + optional string color_identity = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto new file mode 100644 index 000000000..43273a54f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +// A share bundle created by a user, as reported by a "list my shares" query. +message ServerInfo_DeckShareSummary { + optional uint32 id = 1; + optional string name = 2; + optional uint64 creation_time = 3; + optional uint64 expires_at = 4; + optional uint32 item_count = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..b04d676d8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -1,10 +1,21 @@ syntax = "proto2"; message ServerInfo_DeckStorage_File { optional uint32 creation_time = 1; + optional bool is_public = 2; + // Preview metadata computed by the uploading client, so other clients can + // render this deck (e.g. in a visual storage grid) without downloading the + // full deck list. Empty for decks uploaded before the metadata columns. + optional string banner_card_name = 3; + optional string banner_card_provider = 4; + optional string color_identity = 5; + // Tag names associated with the deck. Empty for decks uploaded before the + // tags column existed. + repeated string tags = 6; } message ServerInfo_DeckStorage_Folder { repeated ServerInfo_DeckStorage_TreeItem items = 1; + optional bool is_public = 2; } message ServerInfo_DeckStorage_TreeItem { 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/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index fee8c36a8..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -28,6 +28,14 @@ message SessionCommand { FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; SET_CARD_ART_PARAMS = 1025; + DECK_SHARE_CREATE = 1026; + DECK_SHARE_LIST = 1027; + DECK_SHARE_DOWNLOAD = 1028; + DECK_LIST_OTHER_USER = 1029; + DECK_SET_VISIBILITY = 1030; + DECK_DOWNLOAD_PUBLIC = 1031; + DECK_SHARE_LIST_MINE = 1032; + DECK_SHARE_REMOVE = 1033; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 2f19d6224..839e33be4 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -70,6 +70,17 @@ void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) emit homeTabDisplayCardNameChanged(); } +bool AppearanceSettings::getHomeTabBackgroundDim() const +{ + return getValue("homeTabBackgroundDim", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setHomeTabBackgroundDim(bool _dimBackground) +{ + setValue(_dimBackground, "homeTabBackgroundDim"); + emit homeTabBackgroundDimChanged(); +} + int AppearanceSettings::getHomeTabButtonColorSourceIndex() const { return getValue("homeTabButtonColorSource", "", "", 0).toInt(); diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index 3a63f0df0..a4504798e 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -27,6 +27,8 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] bool getHomeTabBackgroundDim() const; + void setHomeTabBackgroundDim(bool _dimBackground); [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; void setHomeTabButtonColorSourceIndex(int index); @@ -36,6 +38,7 @@ signals: void homeTabBackgroundSourceChanged(); void homeTabBackgroundShuffleFrequencyChanged(); void homeTabDisplayCardNameChanged(); + void homeTabBackgroundDimChanged(); void homeTabButtonColorChanged(); public: diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f528a7c4b..b8eafca6c 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -105,6 +105,11 @@ int CardsDisplaySettings::getSampleHandSize() const return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); } +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) { setValue(_displayCardNames, "displayCardNames"); @@ -224,3 +229,16 @@ void CardsDisplaySettings::setSampleHandSize(int _sampleHandSize) setValue(_sampleHandSize, "sampleHandSize", "cards", "cardSize"); emit sampleHandSizeChanged(_sampleHandSize); } + +void CardsDisplaySettings::setCardLang(const QString &_cardLang) +{ + if (_cardLang == getCardLang()) { + return; + } + setValue(_cardLang, "cardLang"); + // Flush to disk immediately: the Oracle tool is a separate process that + // reads this value to decide which foreignData to import, so it must not + // observe a stale (pre-change) value. + sync(); + emit cardLangChanged(_cardLang); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index dbafa32ae..0b47ce490 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -31,6 +31,7 @@ public: [[nodiscard]] int getEDHRecCardSize() const override; [[nodiscard]] int getArchidektPreviewSize() const override; [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; void setDisplayCardNames(bool _displayCardNames); void setRoundCardCorners(bool _roundCardCorners); @@ -52,6 +53,7 @@ public: void setEDHRecCardSize(int _edhrecCardSize); void setArchidektPreviewCardSize(int _archidektPreviewCardSize); void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); signals: void displayCardNamesChanged(); @@ -68,6 +70,7 @@ signals: void edhRecCardSizeChanged(); void archidektPreviewSizeChanged(); void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); 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/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index cfa1c054e..eb73e58ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -4,11 +4,14 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { "https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg", - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!&lang=!sflang!", + "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image&lang=!sflang!", "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card", "https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"}; +const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = + "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -29,6 +32,18 @@ void DownloadSettings::resetToDefaultURLs() setValue(QVariant::fromValue(DEFAULT_DOWNLOAD_URLS), "urls"); } +bool DownloadSettings::addLocalizedScryfallUrl() +{ + const QStringList urls = getAllURLs(); + if (urls.contains(SCRYFALL_NAMED_LOCALIZED_URL)) { + return false; + } + QStringList updated = urls; + updated.prepend(SCRYFALL_NAMED_LOCALIZED_URL); + setDownloadUrls(updated); + return true; +} + bool DownloadSettings::getPicDownload() const { return getValue("pictureDownload", QString(), QString(), true).toBool(); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index a3a6f4ca9..ae49884f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -15,6 +15,7 @@ class DownloadSettings : public SettingsManager friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; public: explicit DownloadSettings(const QString &, QObject *); @@ -22,6 +23,7 @@ public: QStringList getAllURLs() const; void setDownloadUrls(const QStringList &downloadURLs); void resetToDefaultURLs(); + [[nodiscard]] bool addLocalizedScryfallUrl(); [[nodiscard]] bool getPicDownload() const; void setPicDownload(bool _picDownload); [[nodiscard]] bool getDownloadSpoilersStatus() const; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index 37dc9a0a0..f6e107dad 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -374,7 +374,11 @@ static void migrateAppearanceSettings(const QString &settingsPath, QSettings &gl QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { if (globalIni.contains(it.key())) { - appearanceIni.setValue(it.value(), globalIni.value(it.key())); + QVariant value = globalIni.value(it.key()); + if (it.key() == "theme/name" && value.toString() == "Default") { + value = "System"; + } + appearanceIni.setValue(it.value(), value); } } } 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 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/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index d745b250c..88b522197 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -22,8 +23,9 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -33,6 +35,42 @@ OracleImporter::OracleImporter(QObject *parent) : QObject(parent) { } +void OracleImporter::setCardLang(const QString &lang) +{ + cardLang = lang.trimmed().toLower(); + localizationEnabled = cardLang != "en" && CardLocalization::supportedLanguages().contains(cardLang); +} + +/** + * @brief Maps the MTGJSON foreignData language names to the short codes used by + * Scryfall and stored in cards.xml (e.g. "German" -> "de"). + * @param language The language name found in the MTGJSON foreignData entries. + * @return The short language code, or an empty string if unknown. + */ +static QString mtgjsonLanguageToCode(const QString &language) +{ + static const QHash map = { + {"Chinese Simplified", "zhs"}, + {"Chinese Traditional", "zht"}, + {"English", "en"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "grc"}, + {"Ancient Greek", "grc"}, + {"Hebrew", "he"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latin", "la"}, + {"Phyrexian", "ph"}, + {"Portuguese (Brazil)", "pt"}, + {"Russian", "ru"}, + {"Sanskrit", "sa"}, + {"Spanish", "es"}, + }; + return map.value(language); +} + static CardSet::Priority getSetPriority(const QString &setType, const QString &shortName) { if (!setTypePriorities.contains(setType.toLower())) { @@ -47,8 +85,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; @@ -96,16 +142,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; } @@ -232,6 +292,101 @@ static QString getJsonString(const QJsonObject &obj, const QString &key) return obj.value(key).toVariant().toString(); } +static QString normalizeCardName(QString name) +{ + // Mirror of the name cleanup applied in addCard(), so collected localization + // keys line up with the card map keys (Æ → AE, curly apostrophe → straight). + name = name.replace("Æ", "AE"); + name = name.replace("’", "'"); + return name; +} + +static QString matchingForeignEntryText(const QJsonObject &card, const QString &cardLang) +{ + // Multi-face cards (split/aftermath/adventure/prepare) expose each face as a + // separate card object, each with its own foreignData entry carrying that + // face's rules text; single-face cards carry the full text in one entry. + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) == cardLang) { + return getJsonString(entry, "text"); + } + } + return QString(); +} + +void OracleImporter::collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText) +{ + if (!localizationEnabled) { + return; + } + + LocalizedCardEntry incoming; + bool found = false; + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) { + continue; + } + incoming.name = getJsonString(entry, "name"); + incoming.text = collectText ? getJsonString(entry, "text") : QString(); + found = true; + break; + } + if (!found) { + return; + } + + // Prefer the entry from the highest-priority set (lower enum value = more + // authoritative); printings of equal priority keep the first one seen. + incoming.priority = currentSet->getPriority(); + const auto existing = localizedEntries.constFind(cardKey); + if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) { + localizedEntries.insert(cardKey, incoming); + } +} + +void OracleImporter::applyLocalizedData() +{ + if (!localizationEnabled) { + return; + } + for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + const LocalizedCardEntry &entry = it.value(); + if (!entry.name.isEmpty()) { + card->setLocalizedName(cardLang, entry.name); + } + if (!entry.text.isEmpty()) { + card->setLocalizedText(cardLang, entry.text); + } + } + localizedEntries.clear(); + + for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + if (!it.value().text.isEmpty()) { + card->setLocalizedText(cardLang, it.value().text); + } + } + splitLocalizedTexts.clear(); +} + int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name @@ -375,13 +530,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { auto _faceName = getJsonString(card, "faceName"); - SplitCardPart split(_faceName, text, properties, printingInfo); + // MTGJSON exposes each face as a separate card object, each with its + // own foreignData entry holding that face's rules text; collect it so + // the per-face texts can be joined the same way as the English text. + const QString faceLocalizedText = + localizationEnabled ? matchingForeignEntryText(card, cardLang) : QString(); + SplitCardPart split(_faceName, text, properties, printingInfo, faceLocalizedText); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { splitCards.insert(name + numProperty, {{split}, name}); } else { found_iter->first.append(split); } + // MTGJSON's foreignData name is the joined name present on every + // face, so collect the name once. + collectForeignData(normalizeCardName(name), currentSet, card, false); } else { // relations QList relatedCards; @@ -424,6 +587,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } + collectForeignData(normalizeCardName(name + numComponent), currentSet, card); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; @@ -437,6 +602,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { QString text; + QString localizedText; + bool localizedTextComplete = true; QHash properties; PrintingInfo printingInfo; @@ -446,6 +613,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } text.append(tmp.getText()); + // Build the cardLang text by joining each face's translated text with + // the same separator as the English text. Any face missing a complete + // translation abandons the whole join, falling back to the English text. + if (localizedTextComplete) { + const QString partLocalizedText = tmp.getLocalizedText(); + if (partLocalizedText.isEmpty()) { + localizedTextComplete = false; + } else { + if (!localizedText.isEmpty()) { + localizedText.append(splitCardTextSeparator); + } + localizedText.append(partLocalizedText); + } + } + if (properties.isEmpty()) { properties = tmp.getProperties(); printingInfo = tmp.getPrintingInfo(); @@ -460,8 +642,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); @@ -471,6 +660,18 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + if (localizationEnabled && localizedTextComplete && !localizedText.isEmpty()) { + // Same priority policy as collectForeignData(): the joined text from the + // highest-priority set seen so far wins, applied once all printings are in. + LocalizedCardEntry entry; + entry.text = localizedText; + entry.priority = currentSet->getPriority(); + const QString entryKey = normalizeCardName(name); + const auto existing = splitLocalizedTexts.constFind(entryKey); + if (existing == splitLocalizedTexts.constEnd() || entry.priority < existing->priority) { + splitLocalizedTexts.insert(entryKey, entry); + } + } numCards++; } @@ -551,6 +752,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 @@ -570,6 +773,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()); @@ -617,6 +826,8 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + applyLocalizedData(); + emit setIndexChanged(0, setIndex, QString()); // total number of sets @@ -642,4 +853,6 @@ void OracleImporter::clear() cards.clear(); allSets.clear(); rawSetsData.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 8cb30ca40..c8d3a19bf 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -3,6 +3,7 @@ #include "raw_json_scanner.h" +#include #include #include #include @@ -106,7 +107,8 @@ public: SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo); + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -115,6 +117,13 @@ public: { return text; } + /** + * @brief The cardLang rules text of this face's foreignData entry, if any. + */ + inline const QString &getLocalizedText() const + { + return localizedText; + } inline const QHash &getProperties() const { return properties; @@ -127,10 +136,18 @@ public: private: QString name; QString text; + QString localizedText; QHash properties; PrintingInfo printingInfo; }; +struct LocalizedCardEntry +{ + QString name; + QString text; + CardSet::Priority priority = CardSet::PriorityLowest; +}; + class OracleImporter : public QObject { Q_OBJECT @@ -155,24 +172,118 @@ 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; + + /** + * The ISO-639 language code whose foreignData is imported; "en" by default. + */ + QString cardLang = "en"; + + /** + * Whether cardLang is a supported language other than English, so per-card + * foreignData scanning can be skipped entirely when disabled. + */ + bool localizationEnabled = false; + + /** + * Localized name/text collected per imported card key while parsing sets, + * applied to the CardInfo objects by applyLocalizedData() once all + * printings have been seen so the best-priority one wins. + */ + QMap localizedEntries; + + /** + * cardLang rules text collected for split-card names while parsing sets, + * applied by applyLocalizedData(). Kept apart from localizedEntries because + * MTGJSON emits each split face as its own card object with the joined name + * on every foreignData entry: names and the per-face text join have different + * completeness and must not overwrite each other under the same key. + */ + QMap splitLocalizedTexts; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo); + + /** + * Records the first foreignData entry matching cardLang for the given card + * key, keeping the entry from the highest-priority set seen so far. + * + * Multi-face cards (split, adventure, aftermath, prepare) pass collectText = + * false: MTGJSON emits one foreignData entry per face with the same joined + * name but only that face's text, so the name is collected here while the + * per-face texts are joined during the split-card merge. + */ + void collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText = true); signals: void setIndexChanged(int cardsImported, int setIndex, const QString &setName); void dataReadProgress(int bytesRead, int totalBytes); 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; + } + /** + * Selects the ISO-639 language code whose foreignData is imported. + * English (the default) and unsupported codes disable localization. + */ + void setCardLang(const QString &lang); + const QString &getCardLang() const + { + return cardLang; + } /** * 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); + } + /** + * Applies the collected localized names/texts to the imported cards. + * Called automatically at the end of startImport(); exposed separately so + * tests can drive it after importing sets directly. + */ + void applyLocalizedData(); 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..9ce0d7b51 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) @@ -37,6 +38,9 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage); importer = new OracleImporter(this); + // Import card text in the language the client displays, if supported: + // foreignData for any other language is never imported. + importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); nam = new QNetworkAccessManager(this); @@ -110,6 +114,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 94e662ffe..df0c3f51d 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()); @@ -260,7 +378,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); @@ -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,11 +452,14 @@ 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)); } - progressLabel->setText(tr("Downloading (%1MB)").arg((int)received / (1024 * 1024))); + progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024))); } void LoadSetsPage::actDownloadFinishedSetsFile() @@ -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.")); } } @@ -577,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:")); @@ -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()); @@ -743,4 +977,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 +} 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/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.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..04fcc52e4 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,11 +43,18 @@ 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. * * Splits the top-level "data" object into per-set byte ranges and reads each - * set's metadata directly from the raw bytes. The oracle importer can then + * set's metadata directly from the raw bytes. The Oracle importer can then * parse one set at a time during import, keeping peak memory far below a single * QJsonDocument::fromJson() over the whole file. * @@ -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/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/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..576ab53b4 --- /dev/null +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -0,0 +1,71 @@ +-- Servatrice db migration from version 36 to version 37 + +-- Deck sharing (temporary share links + permanent public decks). +-- +-- This feature was developed behind several intermediate migrations that have +-- never shipped, so they are folded into this single 36 -> 37 migration: +-- temporary share links, permanent public-deck visibility, preview metadata, +-- and per-deck tags. + +-- 1. Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- 2. Permanent deck sharing: add public visibility flags to the deck storage +-- tables. A deck is visible to other users if it is marked public, or if any +-- ancestor folder is marked public (inherited). Existing decks default to +-- private, so the upgrade does not expose any data. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`; + +ALTER TABLE `cockatrice_decklist_folders` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`; + +-- 3. Per-deck preview metadata so clients can render another user's public +-- decks (e.g. in a visual deck storage grid) without downloading each deck +-- list. The metadata is derived by the server from the deck content (the color +-- identity is supplied by the uploading client); decks uploaded before this +-- migration have empty values until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `is_public`, + ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`, + ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`; + +-- 4. Per-deck tags for public decks. The server renders the deck's own tags +-- into a JSON array, so another user's public decks can filter by tag without +-- downloading each deck list. Decks uploaded before this migration have NULL +-- tags until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `tags` text NULL AFTER `color_identity`; + +UPDATE cockatrice_schema_version SET version=37 WHERE version=36; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c1940c22f..fc0ba11f2 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. @@ -439,3 +452,24 @@ ssl_cert=ssl_cert.pem ; Filename of the private key for the server-to-server certificate ssl_key=ssl_key.pem + + +[deck_share] + +; How many days a created deck share link remains valid before it expires. +; Default: 7 +expiry_days=7 + +; How often (in minutes) the server checks for and removes expired deck shares. +; A value of 0 disables the automatic cleanup. +; Default: 60 +cleanup_interval=60 + +; Maximum number of decks a single share link can contain. +; Default: 50 +max_decks_per_share=50 + +; Maximum number of share links a single user may create per day. +; A value of 0 disables the limit. +; Default: 0 +max_shares_per_day=0 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 5dbf69cbc..4fbc1d8bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,11 +20,14 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(36); +INSERT INTO cockatrice_schema_version VALUES(37); -- 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, @@ -63,16 +66,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( `name` varchar(50) NOT NULL, `upload_time` datetime NOT NULL, `content` text NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, + `banner_card_name` varchar(255) NULL, + `banner_card_provider` varchar(32) NULL, + `color_identity` varchar(5) NULL, + `tags` text NULL, PRIMARY KEY (`id`), KEY `FolderPlusUser` (`id_folder`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, `id_parent` int(7) unsigned zerofill NOT NULL, `id_user` int(7) unsigned NULL, `name` varchar(30) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `ParentPlusUser` (`id_parent`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/servatrice/src/deck_tag_serialization.h b/servatrice/src/deck_tag_serialization.h new file mode 100644 index 000000000..162b0604d --- /dev/null +++ b/servatrice/src/deck_tag_serialization.h @@ -0,0 +1,39 @@ +#ifndef DECK_TAG_SERIALIZATION_H +#define DECK_TAG_SERIALIZATION_H + +#include +#include +#include +#include +#include + +/** + * @brief Encodes deck tags as a compact JSON array for storage in a text column. + * + * Deck tags are stored as JSON (rather than a delimited string) so tag names may + * contain any character, and decoded uniformly everywhere they are read. + */ +inline QString serializeDeckTags(const QStringList &tags) +{ + QJsonArray array; + for (const QString &tag : tags) { + array.append(tag); + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} + +/** @brief Decodes deck tags previously written by serializeDeckTags. */ +inline QStringList deserializeDeckTags(const QString &serialized) +{ + QStringList tags; + if (serialized.isEmpty()) { + return tags; + } + const QJsonArray array = QJsonDocument::fromJson(serialized.toUtf8()).array(); + for (const QJsonValue &tag : array) { + tags.append(tag.toString()); + } + return tags; +} + +#endif // DECK_TAG_SERIALIZATION_H 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..56aeedb9a 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; @@ -428,6 +437,14 @@ bool Servatrice::initServer() statusUpdateClock->start(getServerStatusUpdateTime()); } + deckShareCleanupClock = new QTimer(this); + connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + const int deckShareCleanupInterval = getDeckShareCleanupInterval(); + if (deckShareCleanupInterval > 0) { + qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; + deckShareCleanupClock->start(deckShareCleanupInterval); + } + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -470,9 +487,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); @@ -600,6 +663,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList) qDebug() << "Set required client features to:" << serverRequiredFeatureList; } +void Servatrice::cleanupExpiredDeckShares() +{ + servatriceDatabaseInterface->cleanupExpiredDeckShares(); +} + void Servatrice::statusUpdate() { if (!servatriceDatabaseInterface->checkSql()) { @@ -1012,6 +1080,28 @@ int Servatrice::getServerStatusUpdateTime() const return settingsCache->value("server/statusupdate", 15000).toInt(); } +int Servatrice::getDeckShareExpiryDays() const +{ + return settingsCache->value("deck_share/expiry_days", 7).toInt(); +} + +int Servatrice::getDeckShareCleanupInterval() const +{ + // default: every 60 minutes + return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000; +} + +int Servatrice::getDeckShareMaxDecksPerShare() const +{ + return settingsCache->value("deck_share/max_decks_per_share", 50).toInt(); +} + +int Servatrice::getDeckShareMaxSharesPerDay() const +{ + // default: no limit + return settingsCache->value("deck_share/max_shares_per_day", 0).toInt(); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8b0f5ad60..f39a44d51 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 @@ -143,6 +146,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -156,6 +160,7 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -170,6 +175,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; @@ -267,6 +278,10 @@ public: int getMaxGameInactivityTime() const override; int getMaxPlayerInactivityTime() const override; int getClientKeepAlive() const override; + int getDeckShareExpiryDays() const; + int getDeckShareCleanupInterval() const; + int getDeckShareMaxDecksPerShare() const; + int getDeckShareMaxSharesPerDay() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; @@ -286,6 +301,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/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 847be61da..7a1456b2b 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1,5 +1,6 @@ #include "servatrice_database_interface.h" +#include "deck_tag_serialization.h" #include "servatrice.h" #include "serversocketinterface.h" #include "settingscache.h" @@ -7,11 +8,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -86,9 +89,10 @@ bool Servatrice_DatabaseInterface::openDatabase() << dbversion << "to version" << expectedversion; return false; } else if (dbversion > 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 { @@ -98,12 +102,59 @@ bool Servatrice_DatabaseInterface::openDatabase() return false; } + if (sqlDatabase.driverName() != "QMYSQL") { + qCCritical(DatabaseInterfaceLog) + << poolStr + << "Error opening database: connection is not a MySQL/MariaDB database, Servatrice only " + "supports the QMYSQL driver (actual driver:" + << sqlDatabase.driverName() << ")."; + return false; + } + + bool strictModeCheckOk = false; + const bool strictModeEnabled = isStrictModeEnabled(strictModeCheckOk); + if (!strictModeCheckOk) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: unable to determine whether MySQL/MariaDB strict " + "mode is enabled"; + return false; + } + if (strictModeEnabled) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: MySQL/MariaDB strict mode is enabled, which " + "breaks most Servatrice database operations. Please disable strict mode " + "by removing STRICT_TRANS_TABLES and STRICT_ALL_TABLES from sql_mode, " + "for example by adding 'sql_mode=NO_ENGINE_SUBSTITUTION' under [mysqld] " + "in your my.cnf (or my.ini on Windows) and restarting the database " + "server."; + return false; + } + // reset all prepared statements qDeleteAll(preparedStatements); preparedStatements.clear(); return true; } +bool Servatrice_DatabaseInterface::isStrictModeEnabled(bool &ok) const +{ + ok = true; + + QSqlQuery query(sqlDatabase); + if (!query.exec("SELECT @@GLOBAL.sql_mode")) { + ok = false; + return false; + } + + const QStringList modes = query.next() ? query.value(0).toString().split(',') : QStringList(); + for (const QString &mode : modes) { + if (mode.trimmed() == "STRICT_TRANS_TABLES" || mode.trimmed() == "STRICT_ALL_TABLES") { + return true; + } + } + return false; +} + bool Servatrice_DatabaseInterface::checkSql() { if (!sqlDatabase.isValid()) { @@ -641,6 +692,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(); @@ -1026,6 +1081,182 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user return deck; } +bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt) +{ + checkSql(); + + if (items.isEmpty()) { + return false; + } + + if (!sqlDatabase.transaction()) { + return false; + } + + QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) " + "values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))"); + query->bindValue(":token", token); + query->bindValue(":name", name); + query->bindValue(":created_by", userId < 1 ? QVariant() : userId); + query->bindValue(":days", expiryDays); + if (!execSqlQuery(query)) { + // A failed execSqlQuery has already closed and reopened the connection, + // which implicitly discards the transaction; rollback below is a no-op. + sqlDatabase.rollback(); + return false; + } + + const int shareId = query->lastInsertId().toInt(); + + // Read the expiry back from the database so the value returned to the client + // matches the server clock rather than being approximated client-side. + QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id"); + expiryQuery->bindValue(":id", shareId); + if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) { + // See the note above: after a failed execSqlQuery the transaction is + // already gone because the connection was torn down. + sqlDatabase.rollback(); + return false; + } + expiresAt = expiryQuery->value(0).toLongLong(); + + for (int i = 0; i < items.size(); ++i) { + const DeckShareItemRecord &item = items.at(i); + QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, " + "game_format, color_identity, content, position) values (:share_id, :name, " + ":tags, :banner_card, :game_format, :color_identity, :content, :position)"); + itemQuery->bindValue(":share_id", shareId); + itemQuery->bindValue(":name", item.name); + itemQuery->bindValue(":tags", serializeDeckTags(item.tags)); + itemQuery->bindValue(":banner_card", item.bannerCard); + itemQuery->bindValue(":game_format", item.gameFormat); + itemQuery->bindValue(":color_identity", item.colorIdentity); + itemQuery->bindValue(":content", item.content); + itemQuery->bindValue(":position", i); + if (!execSqlQuery(itemQuery)) { + // See the note above: the transaction is already gone after the + // reconnect performed by a failed execSqlQuery. + sqlDatabase.rollback(); + return false; + } + } + + if (!sqlDatabase.commit()) { + sqlDatabase.rollback(); + return false; + } + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token, + QString &name, + qint64 &expiresAt, + QList &items) +{ + checkSql(); + + QSqlQuery *query = + prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = " + ":token and expires_at > now()"); + query->bindValue(":token", token); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + const int shareId = query->value(0).toInt(); + name = query->value(1).toString(); + expiresAt = query->value(2).toLongLong(); + items.clear(); + + QSqlQuery *itemQuery = + prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item " + "where share_id = :share_id order by position"); + itemQuery->bindValue(":share_id", shareId); + execSqlQuery(itemQuery); + while (itemQuery->next()) { + DeckShareItemRecord item; + item.id = itemQuery->value(0).toInt(); + item.name = itemQuery->value(1).toString(); + item.tags = deserializeDeckTags(itemQuery->value(2).toString()); + item.bannerCard = itemQuery->value(3).toString(); + item.gameFormat = itemQuery->value(4).toString(); + item.colorIdentity = itemQuery->value(5).toString(); + items.append(item); + } + + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on " + "s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id"); + query->bindValue(":token", token); + query->bindValue(":id", itemId); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + content = query->value(0).toString(); + return true; +} + +void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()"); + execSqlQuery(query); +} + +bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), " + "UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join " + "{prefix}_deck_share_item i on i.share_id = s.id where s.created_by = :created_by " + "group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc"); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + + shares.clear(); + while (query->next()) { + DeckShareSummaryRecord summary; + summary.id = query->value(0).toInt(); + summary.name = query->value(1).toString(); + summary.creationTime = query->value(2).toLongLong(); + summary.expiresAt = query->value(3).toLongLong(); + summary.itemCount = query->value(4).toInt(); + shares.append(summary); + } + return true; +} + +bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by"); + query->bindValue(":id", shareId); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &senderName, const QString &senderIp, @@ -1448,7 +1679,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 +1700,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 +1714,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..ce51a2474 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -13,10 +13,32 @@ #include #include -#define DATABASE_SCHEMA_VERSION 36 +#define DATABASE_SCHEMA_VERSION 37 class Servatrice; +/** @brief Metadata of a single deck inside a temporary deck share bundle. */ +struct DeckShareItemRecord +{ + int id = -1; ///< Database id, used for downloads. + QString name; ///< Deck name. + QStringList tags; ///< Deck tags. + QString bannerCard; ///< Banner card name (deck image). + QString gameFormat; ///< Game format the deck was built for. + QString colorIdentity; ///< Color identity, e.g. "WUBRG". + QString content; ///< Deck content (native format); empty in list queries. +}; + +/** @brief Summary of a share bundle owned by a user. */ +struct DeckShareSummaryRecord +{ + int id = -1; ///< Database id, used for revocation. + QString name; ///< Share name. + qint64 creationTime = 0; ///< Unix timestamp at which the share was created. + qint64 expiresAt = 0; ///< Unix timestamp at which the share expires. + int itemCount = 0; ///< Number of decks in the bundle. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -32,6 +54,7 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + bool isStrictModeEnabled(bool &ok) const; protected: AuthenticationResult checkUserPassword(Server_ProtocolHandler *handler, @@ -79,6 +102,37 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; + /** + * @brief Creates a new temporary deck share bundle. + * @param expiresAt Receives the actual expiry read back from the database. + * @return false on failure. + */ + bool createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt); + /** @brief Lists the share bundles created by a user, newest first. */ + bool getDeckSharesForUser(int userId, QList &shares); + /** + * @brief Deletes one of a user's own share bundles (cascades to its items). + * @return false if no such bundle belongs to the user. + */ + bool deleteDeckShare(int shareId, int userId); + /** + * @brief Looks up a valid (non-expired) share bundle by token. + * @return false if the token is unknown or expired. + */ + bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList &items); + /** + * @brief Fetches the content of one item of a valid share bundle. + * @return false if the token is unknown/expired or the item does not belong to the bundle. + */ + bool getDeckShareItem(const QString &token, int itemId, QString &content); + /** @brief Deletes all expired share bundles (cascades to their items). */ + void cleanupExpiredDeckShares(); + int getNextGameId() override; int getNextReplayId() override; int getActiveUserCount(QString connectionType = QString()) override; @@ -140,6 +194,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..07bc227f4 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -20,6 +20,7 @@ #include "serversocketinterface.h" +#include "deck_tag_serialization.h" #include "email_parser.h" #include "main.h" #include "servatrice.h" @@ -30,23 +31,37 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include +#include #include +#include #include #include +#include #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include #include #include #include @@ -77,9 +92,14 @@ #include #include #include +#include +#include +#include +#include #include #include #include +#include #include #include #include @@ -190,6 +210,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) @@ -201,6 +292,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc); case SessionCommand::DECK_LIST: return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc); + case SessionCommand::DECK_LIST_OTHER_USER: + return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc); + case SessionCommand::DECK_SET_VISIBILITY: + return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc); + case SessionCommand::DECK_DOWNLOAD_PUBLIC: + return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc); case SessionCommand::DECK_NEW_DIR: return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc); case SessionCommand::DECK_DEL_DIR: @@ -244,6 +341,16 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); case SessionCommand::SET_CARD_ART_PARAMS: return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); + case SessionCommand::DECK_SHARE_CREATE: + return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc); + case SessionCommand::DECK_SHARE_LIST: + return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc); + case SessionCommand::DECK_SHARE_LIST_MINE: + return cmdDeckShareListMine(cmd.GetExtension(Command_DeckShareListMine::ext), rc); + case SessionCommand::DECK_SHARE_REMOVE: + return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); + case SessionCommand::DECK_SHARE_DOWNLOAD: + return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -284,7 +391,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 +444,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) { @@ -470,46 +597,72 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path) return getDeckPathId(0, path.split("/")); } -bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder) +bool AbstractServerSocketInterface::deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly) { - QSqlQuery *query = sqlInterface->prepareQuery( - "select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user"); + QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where " + "id_parent = :id_parent and id_user = :id_user"); query->bindValue(":id_parent", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } - QMap results; + QList>> folderRows; while (query->next()) { - results[query->value(0).toInt()] = query->value(1).toString(); + folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}}); } + std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); + + for (const auto &[folderIdValue, folderInfo] : folderRows) { + const QString name = folderInfo.first; + const bool ownPublic = folderInfo.second; + const bool effectivePublic = inheritedPublic || ownPublic; + if (publicOnly && !effectivePublic) { + continue; + } - for (int key : results.keys()) { ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); - newItem->set_id(key); - newItem->set_name(results.value(key).toStdString()); + newItem->set_id(folderIdValue); + newItem->set_name(name.toStdString()); + newItem->mutable_folder()->set_is_public(ownPublic); - if (!deckListHelper(newItem->id(), newItem->mutable_folder())) { + if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) { return false; } } - query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = " - ":id_folder and id_user = :id_user"); + query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " + "banner_card_provider, color_identity, tags from {prefix}_decklist_files where " + "id_folder = :id_folder and id_user = :id_user"); query->bindValue(":id_folder", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } while (query->next()) { + const bool ownPublic = query->value(3).toBool(); + if (publicOnly && !(inheritedPublic || ownPublic)) { + continue; + } + ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); newItem->set_id(query->value(0).toInt()); newItem->set_name(query->value(1).toString().toStdString()); ServerInfo_DeckStorage_File *newFile = newItem->mutable_file(); newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch()); + newFile->set_is_public(ownPublic); + newFile->set_banner_card_name(query->value(4).toString().toStdString()); + newFile->set_banner_card_provider(query->value(5).toString().toStdString()); + newFile->set_color_identity(query->value(6).toString().toStdString()); + for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { + newFile->add_tags(tag.toStdString()); + } } return true; @@ -530,7 +683,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ Response_DeckList *re = new Response_DeckList; ServerInfo_DeckStorage_Folder *root = re->mutable_root(); - if (!deckListHelper(0, root)) { + if (!deckListHelper(0, root, userInfo->id(), false, false)) { return Response::RespContextError; } @@ -538,6 +691,160 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const QString userName = nameFromStdString(cmd.user_name()); + const int userId = sqlInterface->getUserIdInDB(userName); + if (userId == -1) { + return Response::RespNameNotFound; + } + + Response_DeckList *re = new Response_DeckList; + ServerInfo_DeckStorage_Folder *root = re->mutable_root(); + + if (!deckListHelper(0, root, userId, false, true)) { + return Response::RespContextError; + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +int AbstractServerSocketInterface::getDeckOwnerId(int deckId) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return -1; + } + if (!query->next()) { + return -1; + } + return query->value(0).toInt(); +} + +bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId) +{ + QSqlQuery *query = + sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + if (!query->next()) { + return false; + } + if (query->value(0).toBool()) { + return true; + } + + int folderId = query->value(1).toInt(); + int guard = 0; + while (folderId != 0 && guard < 100) { + QSqlQuery *folderQuery = + sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id"); + folderQuery->bindValue(":id", folderId); + if (!sqlInterface->execSqlQuery(folderQuery)) { + return false; + } + if (!folderQuery->next()) { + return false; + } + if (folderQuery->value(0).toBool()) { + return true; + } + folderId = folderQuery->value(1).toInt(); + ++guard; + } + return false; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + if (cmd.has_deck_id()) { + QSqlQuery *query = + sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(query); + if (!query->next()) { + return Response::RespNameNotFound; + } + + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1 || folderId == 0) { + return Response::RespNameNotFound; + } + + QSqlQuery *query = + sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else { + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int deckId = cmd.deck_id(); + const int ownerId = getDeckOwnerId(deckId); + if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) { + return Response::RespNameNotFound; + } + + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, ownerId); + } catch (Response::ResponseCode &r) { + return r; + } + + Response_DeckDownload *re = new Response_DeckDownload; + re->set_deck(deck->writeToString_Native().toStdString()); + rc.setResponseExtension(re); + delete deck; + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer & /*rc*/) { @@ -646,6 +953,22 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D return Response::RespOk; } +namespace +{ +/** @brief Keeps only the WUBRG colors from a color identity string, deduplicated. */ +QString sanitizeColorIdentity(const QString &colorIdentity) +{ + QString sanitized; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitized.contains(upper)) { + sanitized.append(upper); + } + } + return sanitized; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -670,6 +993,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman deckName = "Unnamed deck"; } + // The server derives the banner card and tags from the deck itself. Only the + // color identity must come from the client, since the server has no card + // database to compute it. All values are bounded to the column sizes. + const QString bannerCardName = deck.getBannerCard().name.left(255); + const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); + const QString tagsJson = serializeDeckTags(deck.getTags()); + const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); if (folderId == -1) { @@ -678,38 +1009,67 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman QSqlQuery *query = sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " - "content) values(:id_folder, :id_user, :name, NOW(), :content)"); + "content, is_public, banner_card_name, banner_card_provider, color_identity, " + "tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, " + ":banner_card_name, :banner_card_provider, :color_identity, :tags)"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(query->lastInsertId().toInt()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); rc.setResponseExtension(re); } else if (cmd.has_deck_id()) { QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), " - "content=:content where id = :id_deck and id_user = :id_user"); + "content=:content, banner_card_name=:banner_card_name, " + "banner_card_provider=:banner_card_provider, color_identity=:color_identity, " + "tags=:tags where id = :id_deck and id_user = :id_user"); query->bindValue(":id_deck", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; } + QSqlQuery *visibilityQuery = + sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " + "id_user = :id_user"); + visibilityQuery->bindValue(":id", cmd.deck_id()); + visibilityQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(visibilityQuery)) { + return Response::RespContextError; + } + const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool(); + Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(cmd.deck_id()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(isPublic); rc.setResponseExtension(re); } else { return Response::RespInvalidData; @@ -740,6 +1100,245 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm return Response::RespOk; } +namespace +{ +/** @brief Builds a cryptographically random, URL-safe share token. */ +QString generateShareToken() +{ + QByteArray bytes(32, Qt::Uninitialized); + QRandomGenerator::system()->fillRange(reinterpret_cast(bytes.data()), bytes.size() / sizeof(quint32)); + return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +/** @brief Extracts the share metadata for a deck, materializing its content. */ +DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity) +{ + DeckShareItemRecord item; + item.name = deck.getName(); + if (item.name.isEmpty()) { + item.name = "Unnamed deck"; + } + item.tags = deck.getTags(); + item.bannerCard = deck.getBannerCard().name; + item.gameFormat = deck.getGameFormat(); + item.colorIdentity = sanitizeColorIdentity(colorIdentity); + item.content = deck.writeToString_Native(); + return item; +} +} // namespace + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (maxItems > 0 && cmd.items_size() > maxItems) { + return Response::RespInvalidData; + } + + // Per-user rate limit so share links cannot be used to build an unbounded + // word-of-mouth leak of public decks. + const int maxSharesPerDay = servatrice->getDeckShareMaxSharesPerDay(); + if (maxSharesPerDay > 0) { + QSqlQuery *countQuery = sqlInterface->prepareQuery("select count(*) from {prefix}_deck_share where " + "created_by = :created_by and created_at >= " + "DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":created_by", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxSharesPerDay) { + return Response::RespTooManyRequests; + } + } + + QList items; + if (cmd.items_size() > 0) { + for (const DeckShareItem &shareItem : cmd.items()) { + if (shareItem.has_deck_list()) { + DeckList deck; + if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) { + return Response::RespContextError; + } + items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity()))); + } else if (shareItem.has_deck_id()) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity()))); + delete deck; + } else { + return Response::RespInvalidData; + } + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1) { + return Response::RespNameNotFound; + } + + // Drain the deck list before resolving each deck: getDeckFromDatabase + // issues its own query on the same cached statement set. + QSqlQuery *query = sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_folder = " + ":id_folder and id_user = :id_user"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + QList deckIds; + while (query->next()) { + deckIds.append(query->value(0).toInt()); + } + for (const int deckId : deckIds) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, QString())); + delete deck; + } + } else { + return Response::RespInvalidData; + } + + if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) { + return Response::RespInvalidData; + } + + QString shareName = nameFromStdString(cmd.name()); + if (shareName.isEmpty()) { + shareName = "Shared decks"; + } + + const QString token = generateShareToken(); + qint64 expiresAt = 0; + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays(), + expiresAt)) { + return Response::RespInvalidData; + } + + Response_DeckShareCreate *re = new Response_DeckShareCreate; + re->set_token(token.toStdString()); + re->set_expires_at(expiresAt); + re->set_item_count(items.size()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareListMine(const Command_DeckShareListMine & /*cmd*/, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList shares; + if (!sqlInterface->getDeckSharesForUser(userInfo->id(), shares)) { + return Response::RespContextError; + } + + Response_DeckShareListMine *re = new Response_DeckShareListMine; + for (const DeckShareSummaryRecord &share : shares) { + ServerInfo_DeckShareSummary *summary = re->add_shares(); + summary->set_id(share.id); + summary->set_name(share.name.toStdString()); + summary->set_creation_time(share.creationTime); + summary->set_expires_at(share.expiresAt); + summary->set_item_count(share.itemCount); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareRemove(const Command_DeckShareRemove &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!cmd.has_share_id()) { + return Response::RespInvalidData; + } + + sqlInterface->checkSql(); + + if (!sqlInterface->deleteDeckShare(cmd.share_id(), userInfo->id())) { + return Response::RespNameNotFound; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString name; + qint64 expiresAt = 0; + QList items; + if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) { + return Response::RespNameNotFound; + } + + Response_DeckShareList *re = new Response_DeckShareList; + re->set_name(name.toStdString()); + re->set_expires_at(expiresAt); + for (const DeckShareItemRecord &item : items) { + ServerInfo_DeckShareItem *itemInfo = re->add_items(); + itemInfo->set_id(item.id); + itemInfo->set_name(item.name.toStdString()); + for (const QString &tag : item.tags) { + itemInfo->add_tags(tag.toStdString()); + } + itemInfo->set_banner_card(item.bannerCard.toStdString()); + itemInfo->set_game_format(item.gameFormat.toStdString()); + itemInfo->set_color_identity(item.colorIdentity.toStdString()); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString content; + if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) { + return Response::RespNameNotFound; + } + + Response_DeckShareDownload *re = new Response_DeckShareDownload; + re->set_deck(content.toStdString()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/, ResponseContainer &rc) { @@ -1020,12 +1619,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 +1640,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 +1664,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 +2257,79 @@ 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); + + // 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; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, ResponseContainer &rc) { @@ -3215,7 +3902,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 +3944,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..36b900d28 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -44,11 +45,19 @@ class ServerInfo_DeckStorage_Folder; class Command_AddToList; class Command_RemoveFromList; class Command_DeckList; +class Command_DeckListOtherUser; class Command_DeckNewDir; class Command_DeckDelDir; class Command_DeckDel; class Command_DeckDownload; +class Command_DeckDownloadPublic; class Command_DeckUpload; +class Command_DeckSetVisibility; +class Command_DeckShareCreate; +class Command_DeckShareList; +class Command_DeckShareListMine; +class Command_DeckShareRemove; +class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; @@ -80,6 +89,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; @@ -95,8 +105,16 @@ private: Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(const QString &path); - bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder); + bool deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly); + int getDeckOwnerId(int deckId); + bool isDeckEffectivelyPublic(int deckId); Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc); void deckDelDirHelper(int basePathId); void sendServerMessage(const QString userName, const QString message); @@ -105,6 +123,12 @@ private: Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareListMine(const Command_DeckShareListMine &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareRemove(const Command_DeckShareRemove &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc); @@ -115,7 +139,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 +176,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 +199,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..dfbdabb5e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,9 +11,12 @@ 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) +add_test(NAME metrics_registry_test COMMAND metrics_registry_test) +add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) @@ -30,10 +33,23 @@ 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) add_executable(latency_tracker_test latency_tracker_test.cpp) +add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) +add_executable( + loader_local_matching_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + loader_local_matching_test.cpp +) +target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) find_package(GTest) @@ -70,9 +86,12 @@ 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) + add_dependencies(metrics_registry_test gtest) + add_dependencies(loader_local_matching_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -104,6 +123,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} ) @@ -111,6 +134,11 @@ 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}) +target_link_libraries( + loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) 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; diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..392f6395a 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -2,9 +2,14 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include #include #include - namespace { @@ -33,6 +38,49 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(0, db->query()->getAllMainCardTypes().size()) << "Types not empty after clear"; ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } + +TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); + card->setLocalizedName("de", "Blitzschlag"); + card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + const QString fileName = tempDir.filePath("cards.xml"); + NoopCardPreferenceProvider prefProvider; + CockatriceXml4Parser writer(&prefProvider, &controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&prefProvider, &controller); + QFile file(fileName); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + parser.parseFileInto(file, data); + + CardInfoPtr loaded = data.cards.value("Lightning Bolt"); + ASSERT_FALSE(loaded.isNull()); + ASSERT_EQ(loaded->getName(), "Lightning Bolt"); + ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); +} } // namespace int main(int argc, char **argv) diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp new file mode 100644 index 000000000..7ffa7157d --- /dev/null +++ b/tests/loader_local_matching_test.cpp @@ -0,0 +1,187 @@ +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_local.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Builds an ExactCard with the requested identity fields. + * + * Mirrors how the client constructs cards: the set short name feeds tryLoad()'s + * setName, and the "num" printing property feeds the collector number. + */ +ExactCard cardFor(const QString &name, const QString &setShortName, const QString &collectorNumber) +{ + CardSetPtr set; + if (!setShortName.isEmpty()) { + set = CardSet::newInstance(new NoopCardSetPriorityController(), setShortName, setShortName); + } + + LazyPropertiesHash properties; + if (!collectorNumber.isEmpty()) { + properties.insert("num", collectorNumber); + } + + return ExactCard(CardInfo::newInstance(name), PrintingInfo(set, properties)); +} + +class LocalMatcherTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; ///< Sandboxed "pics" root for every test. + CardPictureLoaderLocal *loader = nullptr; ///< Constructed per test against the sandboxed paths. + + QString picsPath() const + { + return tempDir.path() + "/pics"; + } + + void SetUp() override + { + // The loader ctor snapshots the global picture paths once, so point them at the + // sandbox before constructing it. + SettingsCache::instance().paths().setPicsPath(picsPath()); + SettingsCache::instance().paths().setCustomPicsPath(picsPath() + "/CUSTOM/"); + + loader = new CardPictureLoaderLocal(nullptr); + } + + void TearDown() override + { + delete loader; + loader = nullptr; + } + + /** + * @brief Writes a valid 1x1 PNG under the sandboxed pics path. + */ + void writePngUnderPics(const QString &relativePath, const QColor &color = Qt::red) + { + const QString fullPath = picsPath() + "/" + relativePath; + ASSERT_TRUE(QDir().mkpath(QFileInfo(fullPath).absolutePath())); + + QImage image(1, 1, QImage::Format_RGB32); + image.fill(color); + + QImageWriter writer(fullPath, "PNG"); + ASSERT_TRUE(writer.write(image)); + } +}; + +TEST_F(LocalMatcherTest, ExactMatchBareFileWinsOverSuffixedCompanion) +{ + writePngUnderPics("downloadedPics/TestCard.png"); + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "The bare TestCard.png must be picked over its suffixed companion"; +} + +TEST_F(LocalMatcherTest, SuffixedFileWithoutExactMatchIsNotLoaded) +{ + // The pre-refactor prefix match would have accepted "TestCard (1).png" for "TestCard". + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed file must not satisfy an exact card-name lookup"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupIgnoresSuffixedFiles) +{ + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_TRUE(image.isNull()) << "Set-folder lookups must also require an exact name match"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupStillResolvesExactFile) +{ + writePngUnderPics("M10/TestCard.png"); + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_FALSE(image.isNull()) << "The exact file in the set folder must still resolve"; +} + +TEST_F(LocalMatcherTest, RootDownloadedPicsFallbackResolvesSchemeFilename) +{ + // Non-set-folder export schemes (Name_Set_Collector) write straight into downloadedPics/. + writePngUnderPics("downloadedPics/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "downloadedPics/TestCard_M10_1.png must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, RootFallbackResolvesDashSeparatedVariant) +{ + writePngUnderPics("downloadedPics/TestCard-M10-1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The dash-separated import variant must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, DownloadedPicsSetSubfolderStillResolves) +{ + writePngUnderPics("downloadedPics/M10/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The set-subfolder export scheme must keep resolving"; +} + +TEST_F(LocalMatcherTest, SetFolderCandidateTakesPrecedenceOverRootFallback) +{ + writePngUnderPics("M10/TestCard_M10_1.png", Qt::red); + writePngUnderPics("downloadedPics/TestCard_M10_1.png", Qt::blue); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + ASSERT_FALSE(image.isNull()); + EXPECT_EQ(image.pixelColor(0, 0), QColor(Qt::red)) << "The set-folder candidate must be preferred"; +} + +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + + // Some CI containers run as a uid without a passwd entry (e.g. GitHub's docker + // runner), so HOME resolves to "/" and the test-mode qttest data dir cannot be + // created. SettingsCache's QSettings then silently drops every write, reads come + // back empty, and the paths the loader searches are "". Give the test a writable + // HOME for the duration of the run so settings behave like on a normal machine. + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("card_picture_loader.*=false\nsettings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file 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 diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 3834be9ed..f66616e37 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 @@ -70,6 +72,18 @@ protected: return card; } + // Helper: build a single MTGJSON foreignData entry + QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text) + { + QJsonObject entry; + entry["language"] = language; + entry["name"] = name; + if (!text.isEmpty()) { + entry["text"] = text; + } + return entry; + } + NoopCardSetPriorityController *controller; OracleImporter *importer; CardSetPtr set; @@ -741,6 +755,372 @@ 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); +} + +// Localized card text tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + // English identity untouched + ASSERT_EQ(result->getName(), "Lightning Bolt"); + ASSERT_EQ(result->getText(), "Rules text."); +} + +TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards) +{ + // MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one + // card object per face; every face carries the joined name but only its own + // face's rules text in foreignData. The importer joins the per-face texts with + // the same separator as the English merge. + QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge"); + front["layout"] = "adventure"; + front["faceName"] = "Disruptive Stormbrood"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{ + makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine " + "Verzauberung deiner Wahl.")}; + QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge"); + back["layout"] = "adventure"; + back["faceName"] = "Petty Revenge"; + back["side"] = "b"; + back["text"] = "Destroy target creature."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache"); + ASSERT_EQ(result->getLocalizedText("de"), + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung " + "deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger."); + // English identity untouched + ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge"); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature."); +} + +TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText) +{ + // Both faces must carry a foreignData text for the joined text; otherwise the + // rules text stays English while the localized name (from a later complete + // printing) is still applied. + QJsonObject front = makeCard("Wear // Tear"); + front["layout"] = "split"; + front["faceName"] = "Wear"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")}; + QJsonObject back = makeCard("Wear // Tear"); + back["layout"] = "split"; + back["faceName"] = "Tear"; + back["side"] = "b"; + back["text"] = "Tear rules text."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + // The joined name is still applied. + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + // The incomplete text must not become the card's localized text. + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text."); +} + +TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + // cardLang defaults to "en" — foreignData must never be imported + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); +} + +TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")}; + QJsonArray cards{card}; + + importer->setCardLang("xx"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint) +{ + // First printing in a reprint set, then another in a (more authoritative) + // core set: the core set's German text must win even though it was seen later. + QJsonObject reprintCard = makeCard("Lightning Bolt"); + reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard}); + + QJsonObject primaryCard = makeCard("Lightning Bolt"); + primaryCard["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryCard}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint) +{ + // Split cards print each face as its own card object; the joined text is + // collected per set with the same priority policy as single-face cards, so a + // reprint set's German text must yield to the core set's even when reprints + // are imported first. + QJsonObject reprintFront = makeCard("Wear // Tear"); + reprintFront["layout"] = "split"; + reprintFront["faceName"] = "Wear"; + reprintFront["side"] = "a"; + reprintFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")}; + QJsonObject reprintBack = makeCard("Wear // Tear"); + reprintBack["layout"] = "split"; + reprintBack["faceName"] = "Tear"; + reprintBack["side"] = "b"; + reprintBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack}); + + QJsonObject primaryFront = makeCard("Wear // Tear"); + primaryFront["layout"] = "split"; + primaryFront["faceName"] = "Wear"; + primaryFront["side"] = "a"; + primaryFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")}; + QJsonObject primaryBack = makeCard("Wear // Tear"); + primaryBack["layout"] = "split"; + primaryBack["faceName"] = "Tear"; + primaryBack["side"] = "b"; + primaryBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")}; + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = QJsonArray{card}; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + importer->setCardLang("pt"); + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact))); + ASSERT_EQ(importer->startImport(), 1); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("pt"), "Raio"); + ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); 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(); +} diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 139656f27..c8c5ad4c8 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) @@ -380,6 +386,19 @@ TEST_F(SettingsDefaultsTest, Appearance_HomeTabDisplayCardName_Default) ASSERT_EQ(s.getHomeTabDisplayCardName(), true); } +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundDim(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setHomeTabBackgroundDim(false); + ASSERT_EQ(s.getHomeTabBackgroundDim(), false); +} + // --- InterfaceSettings --- TEST_F(SettingsDefaultsTest, Interface_ShowStatusBar_Default) @@ -543,6 +562,19 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardLang(), QString("en")); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setCardLang("de"); + ASSERT_EQ(s.getCardLang(), QString("de")); +} + // --- VisualDeckStorageSettings --- TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default)