diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e895e2220..75fbc59f1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Initialize CodeQL" uses: github/codeql-action/init@v4 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index df4fe233c..255e8b045 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -127,7 +127,7 @@ jobs: steps: - name: "Download digests" - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: ${{ runner.temp }}/digests pattern: digest-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 27fecc979..bac46c2bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,20 +8,20 @@ # cmake 3.16 is required if using qt6 cmake_minimum_required(VERSION 3.10) -# Early detect ccache -option(USE_CCACHE "Cache the build results with ccache" ON) +# Use compiler cache (ccache) +option(USE_CCACHE "Cache the build results with ccache" OFF) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) -# Compile servatrice -option(WITH_SERVER "build servatrice" OFF) -# Compile cockatrice -option(WITH_CLIENT "build cockatrice" ON) -# Compile oracle -option(WITH_ORACLE "build oracle" ON) +# Compile Cockatrice +option(WITH_CLIENT "Build Cockatrice client" ON) +# Compile Oracle +option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) +# Compile Servatrice +option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) # Compile tests -option(TEST "build tests" OFF) +option(TEST "Build tests" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 198fa259b..a4a31d78d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,9 +1,17 @@ #include "dlg_convert_deck_to_cod_format.h" +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" + #include #include +#include +#include +#include #include +#include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -38,3 +46,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const { return dontAskAgainCheckbox->isChecked(); } + +namespace +{ + +bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) +{ + QFileInfo fileInfo(filePath); + QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); + + if (QFile::exists(newFileName)) { + QMessageBox::StandardButton reply = + QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?"), + QMessageBox::Yes | QMessageBox::No); + return reply == QMessageBox::Yes; + } + return true; // Safe to proceed +} + +} // namespace + +bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent, + const QString &filePath, + const std::function &convert) +{ + if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { + return true; + } + + // Retrieve saved preference if the prompt is disabled + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { + return false; + } + + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + return convert(); + } + + // Show the dialog to the user + DialogConvertDeckToCodFormat conversionDialog(parent); + if (conversionDialog.exec() != QDialog::Accepted) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( + !conversionDialog.dontAskAgain()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); + + return false; + } + + // Try to convert file + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + if (!convert()) { + return false; + } + + if (conversionDialog.dontAskAgain()) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); + } + + return true; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h index 6642ad8c6..526582135 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h @@ -13,6 +13,9 @@ #include #include #include +#include + +class QWidget; class DialogConvertDeckToCodFormat : public QDialog { @@ -24,6 +27,21 @@ public: [[nodiscard]] bool dontAskAgain() const; + /** + * @brief Checks whether the deck file at \a filePath can store tags. + * + * If the file is not a .cod deck, prompts the user for conversion to the + * Cockatrice format, honoring the saved "always convert / don't ask again" + * preference. On acceptance \a convert is called to perform the conversion. + * + * @param parent The widget to parent the prompt to. + * @param filePath The path of the deck file to check. + * @param convert Called to convert the deck once the user agrees. + * @return true if tags can be stored (no conversion needed, or the conversion + * was performed), false if the user declined to convert. + */ + static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function &convert); + private: QVBoxLayout *layout; QLabel *label; 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 f6f34a6a5..fb610e814 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -525,6 +525,13 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); add(games); + // ── Invite (only while the inviter has a joinable game for this user) ──── + if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) { + auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme); + connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); }); + add(invite); + } + // ── Buddy / ignore (registered users only) ──────────────────────────────── if (!isSelf && isReg) { if (isBuddy) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index 02cc2b44e..ed7320fba 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -149,6 +150,17 @@ public: /** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */ void refreshHeader(); + /** + * Sets a predicate evaluated on every action-button rebuild. It receives + * the name of the user the popup currently shows; when it returns true an + * "Invite" button is shown. The popup itself never resolves the invite + * link, it just forwards the request. + */ + void setGameInviteAvailable(std::function available) + { + gameInviteAvailable = std::move(available); + } + signals: void mouseEnteredPopup(); void mouseLeftPopup(); @@ -159,6 +171,7 @@ signals: // ── Action signals — connect to UserContextMenu::exec*() ────────────────── void chatRequested(const QString &userName); + void inviteRequested(const QString &userName); void detailsRequested(const QString &userName); void showGamesRequested(const QString &userName); void addBuddyRequested(const QString &userName); @@ -200,6 +213,7 @@ private: QString currentUser; ServerInfo_User currentUserInfo; bool currentOnline = false; + std::function gameInviteAvailable; UserInfoHeaderWidget *header; QWidget *actionArea; ///< rebuilt per user 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 2cacfc4f9..a8c99c979 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -345,6 +345,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, &cardArtProvider->cache(), &cardArtParamsMap, window()); // parented to main window so it floats above siblings + // The invite availability is scoped to the room this list belongs to, + // and gated on the room's buddy-only setting for the hovered user. + userInfoPopup->setGameInviteAvailable( + [this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); }); + userInfoPopup->hide(); userInfoPopup->setWindowOpacity(0.0); userInfoPopup->installEventFilter(this); @@ -662,6 +667,8 @@ void UserListWidget::connectPopupSignals() // Wire all action signals to UserContextMenu::exec*() connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(userInfoPopup, &UserInfoPopup::inviteRequested, this, + [this](const QString &userName) { userContextMenu->execInvite(userName); }); connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 7531ef925..412271160 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -22,6 +22,7 @@ #include #include #include +#include #include class QTreeWidget; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index f96c139b3..b0dac3e7c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1091,7 +1091,8 @@ QList TabSupervisor::getGameInviteLinksForRoom(int roomId) con // The inviter may be in several games of the same room (hosting one and // spectating another, for example). Return every game so the caller can // let the user choose which one to invite to. - for (TabGame *tab : gameTabs) { + for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) { + TabGame *tab = it.value(); GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); if (metaInfo->proto().room_id() != roomId) { continue; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 04dcdf7f2..876fbf6ad 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -10,8 +10,6 @@ #include "../visual_deck_storage_widget.h" #include "deck_preview_deck_tags_display_widget.h" -#include -#include #include #include #include @@ -499,21 +497,6 @@ void DeckPreviewWidget::actDeleteFile() // The folder widget removes this preview once the row is gone. } -static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) -{ - QFileInfo fileInfo(filePath); - QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); - - if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; - } - return true; // Safe to proceed -} - /** * Checks if the deck's file format supports tags. * If not, then prompt the user for file conversion. @@ -521,45 +504,8 @@ static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) */ bool DeckPreviewWidget::promptFileConversionIfRequired() { - if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { - return true; - } - - // Retrieve saved preference if the prompt is disabled - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { - return false; - } - - if (!confirmOverwriteIfExists(this, filePath)) { - return false; - } - + return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] { model->convertToCockatriceFormat(row()); return true; - } - - // Show the dialog to the user - DialogConvertDeckToCodFormat conversionDialog(this); - if (conversionDialog.exec() != QDialog::Accepted) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( - !conversionDialog.dontAskAgain()); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(this, filePath)) { - return false; - } - - model->convertToCockatriceFormat(row()); - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); - } - - return true; + }); } 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 21f628f9d..91fe1874b 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 @@ -5,6 +5,8 @@ #include #include +static constexpr int MAX_DECK_SIZE = 1e5; + DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -113,7 +115,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml) { QString zoneName = xml->attributes().value("name").toString(); InnerDecklistNode *newZone = getZoneObjFromName(zoneName); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards); } DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, @@ -125,6 +127,8 @@ DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, const QString &cardProviderId, const bool formatLegal) { + amount = qMin(amount, MAX_DECK_SIZE - totalCards); + totalCards += amount; auto *zoneNode = getZoneObjFromName(zoneName); auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); 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 1012d5919..af1193f26 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 @@ -10,6 +10,7 @@ class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). + int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ 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 705dfae4c..7200ede5f 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,15 +34,15 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) +int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */) { while (!xml->atEnd()) { xml->readNext(); if (xml->isEndElement() && xml->name().toString() == "card") { - return false; + return 0; } } - return true; + return 0; } void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) @@ -60,4 +60,4 @@ void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) if (!getCardProviderId().isEmpty()) { xml->writeAttribute("uuid", getCardProviderId()); } -} \ No newline at end of file +} 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 df903a168..52dd56529 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 @@ -141,7 +141,7 @@ public: * * This supports loading deck files from Cockatrice’s XML format. */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit) override; /** * @brief Serialize this node’s properties to XML. 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 c5cb25d8f..9c4290db0 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 @@ -183,7 +183,7 @@ public: * Cockatrice deck XML format. * @{ */ - virtual bool readElement(QXmlStreamReader *xml) = 0; + 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 1f470695d..ec860dc56 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 @@ -141,27 +141,31 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } -bool InnerDecklistNode::readElement(QXmlStreamReader *xml) +int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) { + int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, limit - totalCards); } else if (childName == "card") { - auto *newCard = new DecklistCardNode( - xml->attributes().value("name").toString(), xml->attributes().value("number").toString().toInt(), - this, -1, xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), xml->attributes().value("uuid").toString()); - newCard->readElement(xml); + 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()); + totalCards += amount; + totalCards += newCard->readElement(xml, limit - totalCards); } } else if (xml->isEndElement() && (childName == "zone")) { - return false; + return totalCards; } } - return true; + return totalCards; } void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) @@ -201,4 +205,4 @@ QVector> InnerDecklistNode::sort(Qt::SortOrder order) } return result; -} \ No newline at end of file +} 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 f8fdedf30..906ed6cb5 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 @@ -216,9 +216,10 @@ public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. - * @return true if parsing succeeded. + * @param limit The maximum amount of cards to read + * @return the amount of cards found */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit) override; /** * @brief Serialize this node and its children to XML. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a28f671c9..34784538b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) +set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) # Find GTest