(.*?)",
+ QRegularExpression::DotMatchesEverythingOption);
+ const auto match = errorsRx.match(data);
+ if (match.hasMatch()) {
+ static const QRegularExpression itemRx("
(.*?)", QRegularExpression::DotMatchesEverythingOption);
+ auto items = itemRx.globalMatch(match.captured(1));
+ while (items.hasNext()) {
+ const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified();
+ if (!error.isEmpty()) {
+ errorMessageList.append(error);
+ }
+ }
+ }
+ qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus;
+ showError(errorMessageList.join('\n'));
}
void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
@@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
DeckList mainboard, sideboard;
copyDeckSplitMainAndSide(deck, mainboard, sideboard);
- QUrl params;
- QUrlQuery urlQuery;
- urlQuery.addQueryItem("name", deck.getName());
- urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true));
- urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true));
- params.setQuery(urlQuery);
- data.append(params.query(QUrl::EncodeReserved).toUtf8());
+ data.append("name=" + QUrl::toPercentEncoding(deck.getName()));
+ data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true)));
+ data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true)));
}
void TappedOutInterface::analyzeDeck(const DeckList &deck)
@@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
- if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
+ // Exclude confirmed tokens, but preserve cards missing from the local
+ // database, including newly released cards in an imported deck.
+ if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}
diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.h b/cockatrice/src/client/network/interfaces/tapped_out_interface.h
index 615991b07..27e8c2273 100644
--- a/cockatrice/src/client/network/interfaces/tapped_out_interface.h
+++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.h
@@ -14,6 +14,7 @@
inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface");
class QByteArray;
+class QString;
class QNetworkAccessManager;
class QNetworkReply;
class DeckList;
@@ -29,6 +30,8 @@ class TappedOutInterface : public QObject
Q_OBJECT
private:
QNetworkAccessManager *manager;
+ bool replyHandled = false;
+ void showError(const QString &message);
void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard);
private slots:
diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp
index 7127b0a29..2b307735d 100644
--- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp
+++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp
@@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json";
static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/";
static const QString MOXFIELD_SUFFIX = "/";
+static const QString DECKSTATS_BASE = "https://deckstats.net/decks/";
static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1";
+static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/";
+
bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
{
static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)");
static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)");
static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)");
static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)");
+ static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)");
QRegularExpressionMatch match;
@@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
return true;
} else if ((match = rxDeckstats.match(url)).hasMatch()) {
QString deckPath = match.captured(1);
- outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/",
+ outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE,
.deckID = deckPath,
- .fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX,
+ .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX,
.provider = DeckProvider::Deckstats};
return true;
+ } else if ((match = rxManabox.match(url)).hasMatch()) {
+ QString deckPath = match.captured(1);
+ outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE,
+ .deckID = deckPath,
+ .fullUrl = MANABOX_BASE + deckPath,
+ .provider = DeckProvider::Manabox};
+ return true;
}
return false;
diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h
index bbdd70c97..d3bff976f 100644
--- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h
+++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h
@@ -15,6 +15,7 @@ enum class DeckProvider
Archidekt,
Moxfield,
Deckstats,
+ Manabox,
Unknown
};
diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h
index 87fde4d54..7e15d472f 100644
--- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h
+++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h
@@ -8,12 +8,19 @@
#define INTERFACE_JSON_DECK_PARSER_H
#include "../../../interface/deck_loader/card_node_function.h"
+#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include
#include
+#include
+#include
+#include
+#include
#include
#include
+inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser");
+
class IJsonDeckParser
{
public:
@@ -32,9 +39,6 @@ public:
QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString();
- deckList.setName(deckName);
- deckList.setComments(deckDescription);
-
QString outputText;
QTextStream outStream(&outputText);
@@ -50,8 +54,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
}
+ outStream.flush();
+ outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
+ deckList.setName(deckName);
+ deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
+ deckList.setName(deckName);
+ deckList.setComments(deckDescription);
return deckList;
}
@@ -67,9 +77,6 @@ public:
QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString();
- deckList.setName(deckName);
- deckList.setComments(deckDescription);
-
QString outputText;
QTextStream outStream(&outputText);
@@ -97,8 +104,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
}
+ outStream.flush();
+ outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
+ deckList.setName(deckName);
+ deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
+ deckList.setName(deckName);
+ deckList.setComments(deckDescription);
QJsonObject commandersObj = obj.value("commanders").toObject();
if (!commandersObj.isEmpty()) {
@@ -114,6 +127,113 @@ public:
}
}
+ // Moxfield stores Oathbreaker signature spells in their own board,
+ // separate from both mainboard and commanders. They are playable deck
+ // cards, but must not replace the Oathbreaker as the banner card.
+ const QJsonObject signatureSpells = obj.value("signatureSpells").toObject();
+ for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) {
+ const QJsonObject entry = it.value().toObject();
+ const QJsonObject card = entry.value("card").toObject();
+ const QString name = card.value("name").toString();
+ const int quantity = entry.value("quantity").toInt();
+ if (name.isEmpty() || quantity <= 0) {
+ continue;
+ }
+ const QString set = card.value("set").toString().toUpper();
+ const QString collectorNumber = card.value("cn").toString();
+ const QString providerId = card.value("scryfall_id").toString();
+ deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId);
+ }
+
+ return deckList;
+ }
+};
+
+class ManaboxJsonParser : public IJsonDeckParser
+{
+ enum BoardType : quint32
+ {
+ MainBoard = 0,
+ SideBoard = 1,
+ Commander = 2,
+ MaybeBoard = 3,
+ };
+
+public:
+ DeckList parse(const QJsonObject &obj) override
+ {
+ DeckList deckList;
+
+ QString deckName = obj.value("name").toString();
+
+ QString outputText;
+ QString mainboardText;
+ QString sideboardText;
+ QTextStream mainboardStream(&mainboardText);
+ QTextStream sideboardStream(&sideboardText);
+ QTextStream outStream(&outputText);
+
+ auto commanderList = QList();
+ auto maybeboardList = QList();
+ for (auto entry : obj.value("cards").toArray()) {
+ auto card = entry.toObject();
+
+ auto collectorNumber = card.value("collectorNumber").toString();
+ auto cardName = card.value("name").toString();
+ auto boardType = static_cast(card.value("boardCategory").toInt());
+ auto setName = card.value("setId").toString();
+ auto quantity = card.value("quantity").toInt();
+ auto providerId = card.value("scryfallId").toString();
+
+ switch (boardType) {
+ case MainBoard:
+ mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber
+ << '\n';
+ break;
+ case SideBoard:
+ sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") "
+ << collectorNumber << '\n';
+ break;
+ case MaybeBoard:
+ maybeboardList.append(card);
+ break;
+ case Commander:
+ commanderList.append(card);
+ break;
+ default:
+ qCWarning(InterfaceJsonDeckParserLog)
+ << "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName;
+ break;
+ }
+ }
+
+ outStream << mainboardText << sideboardText;
+
+ deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
+ deckList.setName(deckName);
+ deckList.forEachCard(CardNodeFunction::ResolveProviderId());
+
+ for (const auto &maybeboardCard : maybeboardList) {
+ QString cardName = maybeboardCard.value("name").toString();
+ QString setName = maybeboardCard.value("setId").toString().toUpper();
+ QString collectorNumber = maybeboardCard.value("collectorNumber").toString();
+ QString providerId = maybeboardCard.value("scryfallId").toString();
+
+ for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) {
+ deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId);
+ }
+ }
+ for (const auto &commanderCard : commanderList) {
+
+ QString commanderName = commanderCard.value("name").toString();
+ QString setName = commanderCard.value("setId").toString().toUpper();
+ QString collectorNumber = commanderCard.value("collectorNumber").toString();
+ QString providerId = commanderCard.value("scryfallId").toString();
+
+ deckList.setBannerCard({commanderName, providerId});
+ deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId);
+ }
+
return deckList;
}
};
diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
index 62668c4d9..d74414d4a 100644
--- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
+++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
@@ -2,253 +2,305 @@
#include "../../../../main.h"
#include "../../../settings/cache_settings.h"
+#include "spoiler_file.h"
-#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
-#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
-#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"
-
-SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
+namespace
{
- isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
- if (isSpoilerDownloadEnabled) {
- // Start the process of checking if we're in spoiler season
- // File exists means we're in spoiler season
- startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false);
+const QUrl SPOILERS_STATUS_URL(
+ QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"));
+const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"));
+// Request coordination stays on the GUI thread; reload workers never access it.
+QPointer currentUpdater;
+QPointer> activeReload;
+quint64 newestOperation = 0;
+
+bool removeSpoilerFile(const QString &path)
+{
+ QFile file(path);
+ if (!file.exists()) {
+ return false;
+ }
+ if (!file.remove()) {
+ qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString();
+ return false;
+ }
+ return true;
+}
+} // namespace
+
+SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload)
+ : QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()),
+ forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()),
+ operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon)
+{
+ connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this,
+ &SpoilerBackgroundUpdater::cancelIfSettingsChanged);
+ connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this,
+ &SpoilerBackgroundUpdater::cancelIfSettingsChanged);
+ // Even a disabled/no-op operation completes after its caller can connect signals.
+ QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin);
+}
+
+void SpoilerBackgroundUpdater::begin()
+{
+ if (completed) {
+ return;
+ }
+ if (cancelled || operationId != newestOperation ||
+ (reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus()
+ : (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) ||
+ targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
+ finish();
+ return;
+ }
+ const auto previous = currentUpdater;
+ if (activeReload) {
+ // The existing database API stores its final load status after its parser
+ // mutex is released. Do not overlap updater-owned calls to that API.
+ connect(activeReload, &QFutureWatcher::finished, this, &SpoilerBackgroundUpdater::begin,
+ static_cast(Qt::QueuedConnection | Qt::SingleShotConnection));
+ if (previous && previous != this) {
+ previous->cancel();
+ }
+ return;
+ }
+ currentUpdater = this;
+ QPointer guard(this);
+ if (previous && previous != this) {
+ previous->cancel();
+ }
+ if (!guard) {
+ return;
+ }
+ if (!isCurrent()) {
+ finish();
+ } else if (reloadOnly) {
+ if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) {
+ finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card "
+ "Database to retry."));
+ return;
+ }
+ reloadDatabase(false);
} else {
- qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled";
+ downloadFromURL(SPOILERS_STATUS_URL, false);
}
}
-void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults)
+bool SpoilerBackgroundUpdater::isCurrent() const
{
- auto spoilerURL = QUrl(url);
- downloadFromURL(spoilerURL, saveResults);
+ return !completed && !cancelled && currentUpdater == this && operationId == newestOperation &&
+ (reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) &&
+ targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath();
}
-void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults)
+void SpoilerBackgroundUpdater::cancelIfSettingsChanged()
{
- auto *nam = NetworkManagerFactory::create(this);
- auto request = QNetworkRequest(url);
- request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
- QNetworkReply *reply = nam->get(request);
+ if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() ||
+ targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
+ cancel();
+ }
+}
+void SpoilerBackgroundUpdater::cancel()
+{
+ if (completed) {
+ return;
+ }
+ cancelled = true;
+ const auto reply = activeReply;
+ activeReply.clear();
+ QPointer guard(this);
+ if (reply) {
+ reply->abort();
+ if (reply) {
+ reply->deleteLater();
+ }
+ }
+ // A running reload owns no updater state. Allow its completion to retire its
+ // watcher before a heap-owned updater is released by the caller.
+ if (guard && !reloading) {
+ finish();
+ }
+}
+
+void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults)
+{
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
+ request.setTransferTimeout(60000);
+ activeReply = manager->get(request);
if (saveResults) {
- // This will write out to the file (used for spoiler.xml)
- connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
+ connect(activeReply, &QNetworkReply::finished, this,
+ &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
} else {
- // This will check the status (used to see if we're in spoiler season or not)
- connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
+ connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
+ }
+}
+
+void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
+{
+ auto *reply = qobject_cast(sender());
+ if (!reply || reply != activeReply) {
+ return;
+ }
+ activeReply.clear();
+ reply->deleteLater();
+ if (!isCurrent()) {
+ finish();
+ return;
+ }
+ if (reply->error() == QNetworkReply::NoError) {
+ downloadFromURL(SPOILERS_URL, true);
+ } else if (reply->error() == QNetworkReply::ContentNotFoundError) {
+ const bool existed = QFileInfo::exists(targetPath);
+ seasonEnded = existed;
+ if (existed && !removeSpoilerFile(targetPath)) {
+ finish(tr("The spoiler season has ended, but the old spoiler database could not be removed."));
+ return;
+ }
+ if (existed || forceReload) {
+ reloadDatabase(false);
+ } else {
+ finish();
+ }
+ } else {
+ finish(tr("Could not check spoiler availability: %1").arg(reply->errorString()));
}
}
void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile()
{
- // Check for server reply
- auto *reply = dynamic_cast(sender());
- QNetworkReply::NetworkError errorCode = reply->error();
-
- if (errorCode == QNetworkReply::NoError) {
- spoilerData = reply->readAll();
-
- // Save the spoiler.xml file to the disk
- saveDownloadedFile(spoilerData);
-
- reply->deleteLater();
- emit spoilerCheckerDone();
+ auto *reply = qobject_cast(sender());
+ if (!reply || reply != activeReply) {
+ return;
+ }
+ activeReply.clear();
+ reply->deleteLater();
+ if (!isCurrent()) {
+ finish();
+ return;
+ }
+ if (reply->error() != QNetworkReply::NoError) {
+ finish(tr("Could not download spoilers: %1").arg(reply->errorString()));
+ return;
+ }
+ spoilerData = reply->readAll();
+ QString error;
+ const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error);
+ if (result == SpoilerFile::SaveResult::Failed) {
+ finish(isCurrent() ? error : QString());
+ } else if (result == SpoilerFile::SaveResult::Written || forceReload) {
+ reloadDatabase(result == SpoilerFile::SaveResult::Written);
} else {
- qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode;
- emit spoilerCheckerDone();
+ finish();
}
}
-bool SpoilerBackgroundUpdater::deleteSpoilerFile()
+void SpoilerBackgroundUpdater::reloadDatabase(bool updated)
{
- QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
- QFileInfo fi(fileName);
- QDir fileDir(fi.path());
- QFile file(fileName);
-
- // Delete the spoiler.xml file
- if (file.exists() && file.remove()) {
- qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml";
- return true;
- }
-
- qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted";
- return false;
-}
-
-void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
-{
- auto *response = dynamic_cast(sender());
- QNetworkReply::NetworkError errorCode = response->error();
-
- if (errorCode == QNetworkReply::ContentNotFoundError) {
- // Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted
- // The user should run Oracle to get the latest card information
- if (deleteSpoilerFile() && trayIcon) {
- trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle"));
+ reloading = true;
+ auto *database = CardDatabaseManager::getInstance();
+ // The job outlives an individual request, so destroying that request cannot
+ // let a newer updater overlap the worker that is still completing.
+ auto *watcher = new QFutureWatcher(QCoreApplication::instance());
+ activeReload = watcher;
+ connect(watcher, &QFutureWatcher::finished, watcher, [watcher] {
+ if (activeReload == watcher) {
+ activeReload.clear();
}
-
- qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline";
- emit spoilerCheckerDone();
- } else if (errorCode == QNetworkReply::NoError) {
- qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online";
- startSpoilerDownloadProcess(SPOILERS_URL, true);
- } else if (errorCode == QNetworkReply::HostNotFoundError) {
- if (trayIcon) {
- trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection"));
- }
-
- qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection";
- emit spoilerCheckerDone();
- } else {
- if (trayIcon) {
- trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode);
- }
-
- qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode;
- emit spoilerCheckerDone();
- }
-}
-
-bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
-{
- QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
- QFileInfo fi(fileName);
- QDir fileDir(fi.path());
-
- if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
- return false;
- }
-
- // Check if the data matches. If it does, then spoilers are up to date.
- if (getHash(fileName) == getHash(data)) {
- if (trayIcon) {
- trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added"));
- }
-
- qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date";
- return false;
- }
-
- QFile file(fileName);
- if (!file.open(QIODevice::WriteOnly)) {
- qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName;
- file.close();
- return false;
- }
-
- if (file.write(data) == -1) {
- qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName;
- file.close();
- return false;
- }
-
- file.close();
-
- // Data written, so reload the card database
- qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
- // Spoilers can introduce sets the config has never seen; the loader never
- // runs checkUnknownSets() itself, so run it once the finished snapshot has
- // been swapped in.
- auto *watcher = new QFutureWatcher(this);
- connect(watcher, &QFutureWatcher::finished, this, [watcher] {
watcher->deleteLater();
- CardDatabaseManager::getInstance()->checkUnknownSets();
});
- watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
-
- // If the user has notifications enabled, let them know
- // when the database was last updated
- if (trayIcon) {
- QList lines = data.split('\n');
-
- for (const QByteArray &line : lines) {
- if (line.contains("Created At:")) {
- QString timeStamp = QString(line).replace("Created At:", "").trimmed();
- timeStamp.chop(6); // Remove " (UTC)"
-
- auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss");
-#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
- utcTime.setTimeZone(QTimeZone::UTC);
-#else
- utcTime.setTimeSpec(Qt::UTC);
-#endif
-
- QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm");
-
- trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime);
- emit spoilersUpdatedSuccessfully();
- return true;
- }
+ connect(watcher, &QFutureWatcher::finished, this, [this, watcher, database, updated] {
+ bool loaded = false;
+ try {
+ loaded = watcher->result() && database->getLoadStatus() == Ok;
+ } catch (const std::exception &error) {
+ qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what();
}
- }
-
- return true;
+ reloading = false;
+ if (!isCurrent()) {
+ finish();
+ } else if (!loaded) {
+ finish(reloadOnly
+ ? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.")
+ : tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the "
+ "reload."));
+ } else {
+ finish({}, updated);
+ }
+ });
+ watcher->setFuture(QtConcurrent::run([database] {
+ database->reloadCardDatabasesAndNotify();
+ return true;
+ }));
}
-QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName)
+void SpoilerBackgroundUpdater::finish(const QString &error, bool updated)
{
- QFile file(fileName);
-
- if (file.open(QFile::ReadOnly)) {
- // Only read the first 512 bytes (enough to get the "created" tag)
- const QByteArray bytes = file.read(512);
-
- QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
- hash.addData(bytes);
-
- qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result();
-
- file.close();
- return hash.result();
- } else {
- qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!";
- file.close();
- return QByteArray();
+ if (completed) {
+ return;
+ }
+ completed = true;
+ spoilerData = QByteArray();
+ if (currentUpdater == this) {
+ currentUpdater.clear();
+ }
+ QPointer guard(this);
+ if (!error.isEmpty() && !cancelled) {
+ qCWarning(SpoilerBackgroundUpdaterLog) << error;
+ if (notificationIcon) {
+ notificationIcon->showMessage(tr("Spoiler update failed"), error);
+ }
+ if (!guard) {
+ return;
+ }
+ emit spoilerUpdateFailed(error);
+ } else if (updated && !cancelled) {
+ if (notificationIcon) {
+ notificationIcon->showMessage(tr("Spoilers have been updated!"),
+ tr("The card database now includes the downloaded spoilers."));
+ }
+ } else if (seasonEnded && !cancelled && notificationIcon) {
+ notificationIcon->showMessage(tr("Spoiler season has ended"),
+ tr("The spoiler database was removed. Please run Oracle to get released cards."));
+ }
+ if (guard) {
+ emit spoilerCheckerDone();
}
}
-QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data)
+SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current()
{
- // Only read the first 512 bytes (enough to get the "created" tag)
- const QByteArray bytes = data.left(512);
-
- QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
- hash.addData(bytes);
-
- qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result();
-
- return hash.result();
+ return currentUpdater;
+}
+
+SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile()
+{
+ // Deletion and the following reload share the operation lifetime. Callers can
+ // observe either failure before the queued operation starts, and an explicit
+ // retry still reloads when the file has already been removed.
+ auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true);
+ updater->reloadOnly = true;
+ connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater);
+ return updater;
}
diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h
index a6c4ed00f..fd90eb688 100644
--- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h
+++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h
@@ -2,7 +2,6 @@
* @file spoiler_background_updater.h
* @ingroup Client
*/
-//! \todo Document this file.
#ifndef COCKATRICE_SPOILER_DOWNLOADER_H
#define COCKATRICE_SPOILER_DOWNLOADER_H
@@ -10,10 +9,13 @@
#include
#include
#include
+#include
#include
#include
-class QProcess;
+class QNetworkAccessManager;
+class QNetworkReply;
+class QSystemTrayIcon;
class QUrl;
inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater");
@@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject
{
Q_OBJECT
public:
- explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr);
+ explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false);
inline QString getCardUpdaterBinaryName()
{
return "oracle";
}
- QByteArray getHash(const QString fileName);
- QByteArray getHash(QByteArray data);
- static bool deleteSpoilerFile();
+ // Queues removal and reload; the application owns the returned updater and
+ // deletes it on completion. Callers can connect its failure/completion signals.
+ static SpoilerBackgroundUpdater *deleteSpoilerFile();
+ // The operation currently running, if any. The updater outlives the page that
+ // started it, so a reopened page uses this to observe the in-flight update.
+ static SpoilerBackgroundUpdater *current();
private slots:
+ void begin();
void actDownloadFinishedSpoilersFile();
void actCheckIfSpoilerSeasonEnabled();
+ void cancelIfSettingsChanged();
private:
- bool isSpoilerDownloadEnabled;
- QProcess *cardUpdateProcess;
+ const bool enabledAtStart;
+ const bool forceReload;
+ const QString targetPath;
+ const quint64 operationId;
+ QNetworkAccessManager *manager;
+ QPointer activeReply;
+ QPointer notificationIcon;
QByteArray spoilerData;
- void startSpoilerDownloadProcess(QString url, bool saveResults);
- void downloadFromURL(QUrl url, bool saveResults);
- bool saveDownloadedFile(QByteArray data);
+ bool completed = false;
+ bool cancelled = false;
+ bool reloading = false;
+ bool seasonEnded = false;
+ bool reloadOnly = false;
+
+ bool isCurrent() const;
+ void cancel();
+ void finish(const QString &error = {}, bool updated = false);
+ void downloadFromURL(const QUrl &url, bool saveResults);
+ void reloadDatabase(bool updated);
signals:
- void spoilersUpdatedSuccessfully();
void spoilerCheckerDone();
+ void spoilerUpdateFailed(const QString &message);
};
-#endif // COCKATRICE_SPOILER_DOWNLOADER_H
\ No newline at end of file
+#endif // COCKATRICE_SPOILER_DOWNLOADER_H
diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
new file mode 100644
index 000000000..554ea1108
--- /dev/null
+++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
@@ -0,0 +1,109 @@
+#include "spoiler_file.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+QByteArray hashFile(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ return {};
+ }
+ QCryptographicHash hash(QCryptographicHash::Sha256);
+ return hash.addData(&file) ? hash.result() : QByteArray();
+}
+} // namespace
+
+namespace SpoilerFile
+{
+QString validate(const QByteArray &data)
+{
+ QXmlStreamReader xml(data);
+ if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) {
+ return QCoreApplication::translate(
+ "SpoilerBackgroundUpdater",
+ "The download is not a Cockatrice card database. The previous spoiler file was preserved.");
+ }
+ bool versionOk = false;
+ const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk);
+ if (!versionOk || (version != 3 && version != 4)) {
+ return QCoreApplication::translate(
+ "SpoilerBackgroundUpdater",
+ "The spoiler database uses an unsupported format. The previous spoiler file was preserved.");
+ }
+ bool hasCards = false;
+ int depth = 1;
+ while (!xml.atEnd()) {
+ const auto token = xml.readNext();
+ if (token == QXmlStreamReader::StartElement) {
+ ++depth;
+ if (depth == 2 && xml.name() == QStringLiteral("cards")) {
+ hasCards = true;
+ }
+ } else if (token == QXmlStreamReader::EndElement) {
+ --depth;
+ }
+ }
+ if (xml.hasError() || !hasCards) {
+ return QCoreApplication::translate(
+ "SpoilerBackgroundUpdater",
+ "The spoiler database is incomplete or invalid. The previous spoiler file was preserved.");
+ }
+ return {};
+}
+
+SaveResult
+save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error)
+{
+ error = validate(data);
+ if (!error.isEmpty()) {
+ return SaveResult::Failed;
+ }
+ if (!isCurrent()) {
+ error = QCoreApplication::translate("SpoilerBackgroundUpdater",
+ "The spoiler update was cancelled because its settings changed.");
+ return SaveResult::Failed;
+ }
+ if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) {
+ return SaveResult::Unchanged;
+ }
+ const QDir directory = QFileInfo(targetPath).dir();
+ if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) {
+ error = QCoreApplication::translate("SpoilerBackgroundUpdater",
+ "Could not create the spoiler database directory: %1")
+ .arg(directory.absolutePath());
+ return SaveResult::Failed;
+ }
+ QSaveFile file(targetPath);
+ file.setDirectWriteFallback(false);
+ if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) {
+ error = QCoreApplication::translate(
+ "SpoilerBackgroundUpdater",
+ "Could not save the spoiler database to %1: %2. The previous file was preserved.")
+ .arg(targetPath, file.errorString());
+ return SaveResult::Failed;
+ }
+ if (!isCurrent()) {
+ file.cancelWriting();
+ error = QCoreApplication::translate("SpoilerBackgroundUpdater",
+ "The spoiler update was cancelled because its settings changed.");
+ return SaveResult::Failed;
+ }
+ if (!file.commit()) {
+ error = QCoreApplication::translate(
+ "SpoilerBackgroundUpdater",
+ "Could not replace the spoiler database at %1: %2. The previous file was preserved.")
+ .arg(targetPath, file.errorString());
+ return SaveResult::Failed;
+ }
+ return SaveResult::Written;
+}
+
+} // namespace SpoilerFile
diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h
new file mode 100644
index 000000000..3a138cdea
--- /dev/null
+++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h
@@ -0,0 +1,24 @@
+#ifndef COCKATRICE_SPOILER_FILE_H
+#define COCKATRICE_SPOILER_FILE_H
+
+#include
+#include
+#include
+
+namespace SpoilerFile
+{
+enum class SaveResult
+{
+ Failed,
+ Unchanged,
+ Written
+};
+
+QString validate(const QByteArray &data);
+// Validate before opening the destination, compare complete files, and replace
+// atomically. Check operation ownership both before writing and before committing.
+SaveResult
+save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error);
+} // namespace SpoilerFile
+
+#endif // COCKATRICE_SPOILER_FILE_H
diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp
index 8868876fa..8b5ef3d6c 100644
--- a/cockatrice/src/client/network/update/client/update_downloader.cpp
+++ b/cockatrice/src/client/network/update/client/update_downloader.cpp
@@ -1,15 +1,16 @@
#include "update_downloader.h"
-#include
-#include
#include
-#include
+#include
#include
#include
#include
+#include
+#include
#include
#include
#include
+#include
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
{
@@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl)
originalUrl = downloadUrl;
}
+ if (auto *old = std::exchange(response, nullptr)) {
+ old->abort();
+ old->deleteLater();
+ }
+
+ errorReported = false;
+ cancelRequested = false;
+
+ // The transfer timeout comes from the manager (NetworkManagerFactory).
response = netMan->get(QNetworkRequest(downloadUrl));
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
+ connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress);
- connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort);
+ connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload);
}
-void UpdateDownloader::downloadError(QNetworkReply::NetworkError)
+void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError)
{
- if (response == nullptr) {
+ // A reply that was superseded by beginDownload() can still deliver a
+ // queued error; it must not be reported against the current reply.
+ if (response == nullptr || sender() != response || errorReported) {
return;
}
- emit error(response->errorString().toUtf8());
+ // A transfer timeout aborts the reply, so it arrives as OperationCanceledError.
+ // Only an abort the user asked for is suppressed; every other cancel is a
+ // stalled transfer and has to be reported.
+ if (networkError == QNetworkReply::OperationCanceledError) {
+ if (cancelRequested) {
+ return;
+ }
+
+ errorReported = true;
+ emit error(tr("The download timed out. Check your connection and try again."));
+ return;
+ }
+
+ errorReported = true;
+ emit error(response->errorString());
+}
+
+void UpdateDownloader::cancelDownload()
+{
+ cancelRequested = true;
+ if (response != nullptr) {
+ response->abort();
+ }
}
void UpdateDownloader::fileFinished()
{
- // If we finished but there's a redirect, follow it
- QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute);
- if (!redirect.isNull()) {
- beginDownload(redirect.toUrl());
+ // An aborted, superseded reply also emits finished(). Only the reply
+ // currently stored in response may be consumed.
+ if (response == nullptr || sender() != response) {
return;
}
- // Handle any errors we had
- if (response->error()) {
- emit error(response->errorString());
+ // errorOccurred normally reports this first; keep the check for errors that
+ // only surface when the reply finishes.
+ if (response->error() != QNetworkReply::NoError) {
+ downloadError(response->error());
+ response->deleteLater();
+ response = nullptr;
return;
}
- // Work out the file name of the download
- QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1);
+ // Give the update its own private directory. The name is unpredictable, so a
+ // local user cannot pre-create or symlink the path, and the original file
+ // name (including the installer suffix) is preserved inside it.
+ QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX")));
+ if (!downloadDir.isValid()) {
+ emit error(tr("Could not create a temporary directory for the download."));
+ response->deleteLater();
+ response = nullptr;
+ return;
+ }
+ downloadDir.setAutoRemove(false);
- // Save the build in a temporary directory
- QFile file(fileName);
+ const QString remoteName = QFileInfo(originalUrl.path()).fileName();
+ const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
+
+ QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
- emit error(tr("Could not open the file for reading."));
+ downloadDir.remove();
+ emit error(tr("Could not open the file for writing: %1").arg(file.errorString()));
+ response->deleteLater();
+ response = nullptr;
return;
}
- file.write(response->readAll());
- file.close();
+ const QByteArray data = response->readAll();
+ if (file.write(data) != data.size()) {
+ file.cancelWriting();
+ downloadDir.remove();
+ emit error(tr("Could not write the downloaded file to disk."));
+ response->deleteLater();
+ response = nullptr;
+ return;
+ }
+
+ if (!file.commit()) {
+ downloadDir.remove();
+ emit error(tr("Could not save the downloaded file: %1").arg(file.errorString()));
+ response->deleteLater();
+ response = nullptr;
+ return;
+ }
+
+ response->deleteLater();
+ response = nullptr;
// Emit the success signal with a URL to the download file
emit downloadSuccessful(QUrl::fromLocalFile(fileName));
diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h
index 8aeefb22a..7704eca38 100644
--- a/cockatrice/src/client/network/update/client/update_downloader.h
+++ b/cockatrice/src/client/network/update/client/update_downloader.h
@@ -32,10 +32,13 @@ private:
QUrl originalUrl;
QNetworkAccessManager *netMan;
QNetworkReply *response;
+ bool errorReported = false;
+ bool cancelRequested = false;
private slots:
void fileFinished();
void downloadProgress(qint64 bytesRead, qint64 totalBytes);
void downloadError(QNetworkReply::NetworkError);
+ void cancelDownload();
};
#endif // COCKATRICE_UPDATEDOWNLOADER_H
diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp
index 08eccf1e4..b40e26377 100644
--- a/cockatrice/src/client/settings/cache_settings.cpp
+++ b/cockatrice/src/client/settings/cache_settings.cpp
@@ -6,6 +6,7 @@
#include "shortcuts_settings.h"
#include
+#include
#include
#include
#include
@@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache)
SettingsCache &SettingsCache::instance()
{
- return *settingsCache; // returns a QT managed singleton reference
+ SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference
+ // The release channels own QNetworkAccessManagers, which must be destroyed
+ // while the application object is alive. ~SettingsCache() runs during
+ // static teardown, after QApplication is gone, so hook the application's
+ // shutdown instead. Retried here because the first access may predate the
+ // application object.
+ cache.installShutdownHook();
+ return cache;
+}
+
+void SettingsCache::installShutdownHook()
+{
+ if (shutdownHookInstalled) {
+ return;
+ }
+ QCoreApplication *application = QCoreApplication::instance();
+ if (application == nullptr) {
+ return;
+ }
+ connect(application, &QCoreApplication::aboutToQuit, this, [this] {
+ for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) {
+ delete releaseChannel;
+ }
+ releaseChannels.clear();
+ });
+ shutdownHookInstalled = true;
}
QString SettingsCache::getDataPath()
diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h
index 9aeb88a18..8ccf6f76b 100644
--- a/cockatrice/src/client/settings/cache_settings.h
+++ b/cockatrice/src/client/settings/cache_settings.h
@@ -90,8 +90,13 @@ private:
QString themeName;
QList releaseChannels;
+ bool shutdownHookInstalled = false;
bool isPortableBuild;
+ // Connects the aboutToQuit handler that deletes the release channels while
+ // the application object is still alive; a no-op before it exists.
+ void installShutdownHook();
+
[[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const;
[[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const;
void loadPaths();
diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp
index 4a5bafb01..2501798cb 100644
--- a/cockatrice/src/client/settings/shortcut_treeview.cpp
+++ b/cockatrice/src/client/settings/shortcut_treeview.cpp
@@ -1,5 +1,6 @@
#include "shortcut_treeview.h"
+#include "../../interface/theme_manager.h"
#include "cache_settings.h"
#include "shortcuts_settings.h"
@@ -78,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent)
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&ShortcutTreeView::refreshShortcuts);
+
+ // Group headers store their font on the item; re-apply the role family on
+ // font changes (setFont emits itemChanged, which repaints the rows).
+ connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
+ QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault);
+ groupFont.setBold(true);
+ for (int row = 0; row < shortcutsModel->rowCount(); ++row) {
+ if (auto *groupItem = shortcutsModel->item(row)) {
+ groupItem->setFont(groupFont);
+ }
+ }
+ });
}
void ShortcutTreeView::populateShortcutsModel()
@@ -93,7 +106,8 @@ void ShortcutTreeView::populateShortcutsModel()
curParent = parentItems.value(group);
} else {
curParent = new QStandardItem(group);
- static QFont font = curParent->font();
+ // Family comes from the InterfaceDefault theme role; bold stays caller-side.
+ QFont font = themeManager->appFont(AppFont::InterfaceDefault);
font.setBold(true);
curParent->setFont(font);
parentItems.insert(group, curParent);
diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h
index 1feb2006b..f4bf23141 100644
--- a/cockatrice/src/database/interface/settings_card_preference_provider.h
+++ b/cockatrice/src/database/interface/settings_card_preference_provider.h
@@ -2,13 +2,18 @@
#define COCKATRICE_SETTINGS_CARD_PREFERENCE_PROVIDER_H
#include "../../client/settings/cache_settings.h"
+#include
#include
#include
#include
-class SettingsCardPreferenceProvider : public ICardPreferenceProvider
+class SettingsCardPreferenceProvider : public QObject, public ICardPreferenceProvider
{
public:
+ explicit SettingsCardPreferenceProvider(QObject *parent = nullptr) : QObject(parent)
+ {
+ }
+
[[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const override
{
return SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardName);
diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp
index 616311750..f0df897fc 100644
--- a/cockatrice/src/filters/deck_filter_string.cpp
+++ b/cockatrice/src/filters/deck_filter_string.cpp
@@ -1,6 +1,5 @@
#include "deck_filter_string.h"
-#include "../interface/deck_loader/loaded_deck.h"
#include "libcockatrice/card/card_localization.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/deck_list.h"
@@ -15,6 +14,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h
index 76b891b59..b27cf87ca 100644
--- a/cockatrice/src/filters/deck_filter_string.h
+++ b/cockatrice/src/filters/deck_filter_string.h
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
struct CardSearchLanguage;
struct LoadedDeck;
diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp
index 22679abb1..9c2a91a5b 100644
--- a/cockatrice/src/filters/filter_builder.cpp
+++ b/cockatrice/src/filters/filter_builder.cpp
@@ -1,6 +1,6 @@
#include "filter_builder.h"
-#include "../interface/pixel_map_generator.h"
+#include "../interface/theme_icon.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include
@@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i));
}
- QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString());
+ QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);
diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp
index b3bf307f1..66fe39821 100644
--- a/cockatrice/src/filters/filter_tree_model.cpp
+++ b/cockatrice/src/filters/filter_tree_model.cpp
@@ -1,5 +1,7 @@
#include "filter_tree_model.h"
+#include "../interface/theme_manager.h"
+
#include
#include
#include
@@ -11,6 +13,27 @@
class QObject;
+namespace
+{
+/**
+ * @brief Visits every filter item below @p node, depth first.
+ */
+template void forEachFilter(const FilterTreeNode *node, const Callback &callback)
+{
+ if (node == nullptr) {
+ return;
+ }
+
+ if (const auto *filterItem = dynamic_cast(node)) {
+ callback(filterItem);
+ }
+
+ for (int i = 0; i < node->childCount(); ++i) {
+ forEachFilter(node->nodeAt(i), callback);
+ }
+}
+} // namespace
+
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{
fTree = new FilterTree;
@@ -18,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow);
connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow);
connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow);
+ // FontRole data reads the DeckList role live; rebroadcast so attached
+ // views re-query instead of waiting for an incidental repaint.
+ connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
+ emit layoutAboutToBeChanged();
+ emit layoutChanged();
+ });
}
FilterTreeModel::~FilterTreeModel()
@@ -110,46 +139,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{
QList filters;
- if (!fTree) {
- return filters;
- }
-
- std::function traverse;
- traverse = [&](const FilterTreeNode *node) {
- if (const auto *filterItem = dynamic_cast(node)) {
- if (filterItem->attr() == filterType) {
- QString text = filterItem->text();
- filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
- }
+ forEachFilter(fTree, [&](const FilterItem *filterItem) {
+ if (filterItem->attr() == filterType) {
+ filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
}
- for (int i = 0; i < node->childCount(); ++i) {
- traverse(node->nodeAt(i));
- }
- };
-
- traverse(fTree);
+ });
return filters;
}
+QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
+{
+ QStringList terms;
+ forEachFilter(fTree, [&](const FilterItem *filterItem) {
+ if (filterItem->attr() == filterType) {
+ terms.append(filterItem->text());
+ }
+ });
+ return terms;
+}
+
QList FilterTreeModel::allFilters() const
{
QList filters;
- if (!fTree) {
- return filters;
- }
-
- std::function traverse;
- traverse = [&](const FilterTreeNode *node) {
- if (const auto *filterItem = dynamic_cast(node)) {
- QString text = filterItem->text();
- filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
- }
- for (int i = 0; i < node->childCount(); ++i) {
- traverse(node->nodeAt(i));
- }
- };
-
- traverse(fTree);
+ forEachFilter(fTree, [&](const FilterItem *filterItem) {
+ filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
+ });
return filters;
}
@@ -196,7 +210,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const
switch (role) {
case Qt::FontRole:
if (!node->isLeaf()) {
- QFont f;
+ // Family comes from the DeckList theme role; bold stays caller-side.
+ QFont f = themeManager->appFont(AppFont::DeckList);
f.setBold(true);
return f;
}
diff --git a/cockatrice/src/filters/filter_tree_model.h b/cockatrice/src/filters/filter_tree_model.h
index 826fcc941..05f34f508 100644
--- a/cockatrice/src/filters/filter_tree_model.h
+++ b/cockatrice/src/filters/filter_tree_model.h
@@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType);
+ /**
+ * @brief Copies every active filter of one attribute into new CardFilter objects.
+ *
+ * The caller owns the returned filters. Prefer getFilterTermsOfType() when only
+ * the terms are needed.
+ */
[[nodiscard]] QList getFiltersOfType(CardFilter::Attr filterType) const;
+ /// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList allFilters() const;
private slots:
@@ -48,6 +55,13 @@ public:
{
return fTree;
}
+
+ /**
+ * @brief The texts of every active filter of one attribute, without allocating filters.
+ *
+ * Prefer this over getFiltersOfType() when only the filter terms are needed.
+ */
+ [[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp
deleted file mode 100644
index 3f879a849..000000000
--- a/cockatrice/src/game/board/card_state.cpp
+++ /dev/null
@@ -1,113 +0,0 @@
-#include "card_state.h"
-
-#include
-
-void CardState::resetState(bool keepAnnotations)
-{
- attacking = false;
- counters.clear();
- pt.clear();
- if (!keepAnnotations) {
- annotation.clear();
- }
- attachedTo = nullptr;
-}
-
-void CardState::setZone(CardZoneLogic *_zone)
-{
- if (zone == _zone) {
- return;
- }
-
- zone = _zone;
- emit zoneChanged(this, zone);
- emit stateChanged();
-}
-
-void CardState::setAttacking(bool _attacking)
-{
- if (attacking == _attacking) {
- return;
- }
- attacking = _attacking;
- emit attackingChanged(_attacking);
- emit stateChanged();
-}
-
-void CardState::insertCounter(int id, int value)
-{
- counters.insert(id, value);
-
- emit countersChanged(counters);
- emit stateChanged();
-}
-
-void CardState::setCounter(int id, int value)
-{
- if (value) {
- counters[id] = value;
- } else {
- counters.remove(id);
- }
-
- emit countersChanged(counters);
- emit stateChanged();
-}
-
-void CardState::clearCounters()
-{
- counters.clear();
- emit countersChanged(counters);
- emit stateChanged();
-}
-
-void CardState::setAnnotation(const QString &_annotation)
-{
- if (annotation == _annotation) {
- return;
- }
- annotation = _annotation;
- emit annotationChanged(annotation);
- emit stateChanged();
-}
-
-void CardState::setPT(const QString &_pt)
-{
- if (pt == _pt) {
- return;
- }
- pt = _pt;
- emit ptChanged(pt);
- emit stateChanged();
-}
-
-void CardState::setDoesntUntap(bool _doesntUntap)
-{
- if (doesntUntap == _doesntUntap) {
- return;
- }
- doesntUntap = _doesntUntap;
- emit doesntUntapChanged(_doesntUntap);
- emit stateChanged();
-}
-
-void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange)
-{
- if (destroyOnZoneChange == _destroyOnZoneChange) {
- return;
- }
-
- destroyOnZoneChange = _destroyOnZoneChange;
- emit destroyOnZoneChangeChanged(_destroyOnZoneChange);
- emit stateChanged();
-}
-
-void CardState::setAttachedTo(CardItem *_attachedTo)
-{
- if (attachedTo == _attachedTo) {
- return;
- }
- attachedTo = _attachedTo;
- emit attachedToChanged(_attachedTo);
- emit stateChanged();
-}
\ No newline at end of file
diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h
deleted file mode 100644
index ca7aaf927..000000000
--- a/cockatrice/src/game/board/card_state.h
+++ /dev/null
@@ -1,106 +0,0 @@
-#ifndef COCKATRICE_CARD_STATE_H
-#define COCKATRICE_CARD_STATE_H
-
-#include
-#include
-#include
-#include
-
-class CardZoneLogic;
-class CardItem;
-
-class CardState : public QObject
-{
- Q_OBJECT
-
-private:
- bool attacking = false;
- QMap counters;
- QString annotation;
- QString pt;
- bool doesntUntap = false;
- bool destroyOnZoneChange = false;
-
- CardItem *attachedTo = nullptr;
- CardZoneLogic *zone = nullptr;
-
-signals:
- void stateChanged();
-
- void attackingChanged(bool newValue);
- void countersChanged(const QMap &newCounters);
- void annotationChanged(const QString &newAnnotation);
- void ptChanged(const QString &newPt);
- void doesntUntapChanged(bool newValue);
- void destroyOnZoneChangeChanged(bool newValue);
- void attachedToChanged(CardItem *newAttachedTo);
- void zoneChanged(CardState *changedCard, CardZoneLogic *newZone);
-
-public:
- explicit CardState(QObject *parent, CardZoneLogic *_zone) : QObject(parent), zone(_zone)
- {
- }
-
- void resetState(bool keepAnnotations);
-
- CardZoneLogic *getZone() const
- {
- return zone;
- }
-
- void setZone(CardZoneLogic *_zone);
-
- bool getAttacking() const
- {
- return attacking;
- }
- void setAttacking(bool _attacking);
-
- const QMap &getCounters() const
- {
- return counters;
- }
-
- void insertCounter(int id, int value);
-
- void setCounter(int id, int value);
-
- void clearCounters();
-
- QString getAnnotation() const
- {
- return annotation;
- }
-
- void setAnnotation(const QString &_annotation);
-
- QString getPT() const
- {
- return pt;
- }
-
- void setPT(const QString &_pt);
-
- bool getDoesntUntap() const
- {
- return doesntUntap;
- }
-
- void setDoesntUntap(bool _doesntUntap);
-
- bool getDestroyOnZoneChange() const
- {
- return destroyOnZoneChange;
- }
-
- void setDestroyOnZoneChange(bool _destroyOnZoneChange);
-
- CardItem *getAttachedTo() const
- {
- return attachedTo;
- }
-
- void setAttachedTo(CardItem *_attachedTo);
-};
-
-#endif // COCKATRICE_CARD_STATE_H
diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h
deleted file mode 100644
index cc995dbac..000000000
--- a/cockatrice/src/game/replay.h
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @file replay.h
- * @ingroup GameLogic
- * @ingroup Replay
- */
-//! \todo Document this file.
-
-#ifndef COCKATRICE_REPLAY_H
-#define COCKATRICE_REPLAY_H
-
-#include "abstract_game.h"
-
-#include
-
-class GameReplay;
-class QObject;
-
-class Replay : public AbstractGame
-{
- Q_OBJECT
-
-public:
- explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame);
-};
-
-#endif // COCKATRICE_REPLAY_H
diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp
index 2e18fca2d..c707b4022 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
update();
});
- connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
+ // The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough:
+ // a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately.
+ connect(_item, &QObject::destroyed, this, [this]() {
+ hide();
+ if (QGraphicsScene *sc = scene()) {
+ sc->removeItem(this);
+ }
+ deleteLater();
+ });
+}
+
+AbstractCardItem *AbstractCardDragItem::getItem() const
+{
+ return item.data();
}
QPainterPath AbstractCardDragItem::shape() const
@@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const
void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
+ if (item.isNull()) {
+ return;
+ }
+
item->paint(painter, option, widget);
// adds a mask to the card so it looks like the card hasnt been placed yet
diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h
index 18afdcbb8..80e40ff1d 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h
+++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h
@@ -14,6 +14,7 @@
#include
#include
#include
+#include
#include
#include
@@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
protected:
- AbstractCardItem *item;
+ /// Guarded pointer: the source card may be destroyed while this drag item still exists.
+ QPointer item;
QPointF hotSpot;
QList childDrags;
@@ -44,10 +46,8 @@ public:
}
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
- [[nodiscard]] AbstractCardItem *getItem() const
- {
- return item;
- }
+ /// @return the source card, or nullptr if it has already been destroyed.
+ [[nodiscard]] AbstractCardItem *getItem() const;
[[nodiscard]] QPointF getHotSpot() const
{
return hotSpot;
diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
index cc121cfb2..84e062cd7 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
@@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
+#include "../../interface/theme_manager.h"
#include "../card_dimensions.h"
#include "../game_scene.h"
#include "../z_values.h"
@@ -34,7 +35,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -43,13 +43,23 @@
class QSizeF;
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
- : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
- bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false)
+ : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0),
+ bgColor(Qt::transparent), isHovered(false), realZValue(0)
{
setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable);
setCacheMode(DeviceCoordinateCache);
+ connect(state, &CardState::cardPixmapUpdated, this, &AbstractCardItem::pixmapUpdated);
+ connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged);
+ connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged);
+ connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged);
+ connect(state, &CardState::facedownChanged, this, [this] { update(); });
+ connect(state, &CardState::colorChanged, this, [this] {
+ cacheBgColor();
+ update();
+ });
+
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
@@ -67,7 +77,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
AbstractCardItem::~AbstractCardItem()
{
- emit deleteCardInfoPopup(cardRef.name);
+ emit deleteCardInfoPopup(state->getCardRef().name);
}
QRectF AbstractCardItem::boundingRect() const
@@ -90,30 +100,29 @@ void AbstractCardItem::pixmapUpdated()
emit sigPixmapUpdated();
}
-void AbstractCardItem::refreshCardInfo()
+void AbstractCardItem::onCardInfoChanged()
{
- exactCard = CardDatabaseManager::query()->getCard(cardRef);
-
- if (!exactCard && !cardRef.name.isEmpty()) {
- CardInfo::UiAttributes attributes = {.tableRow = -1};
- auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes);
- exactCard = ExactCard(info);
- }
- if (exactCard) {
- connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &AbstractCardItem::pixmapUpdated);
- }
-
cacheBgColor();
update();
}
+void AbstractCardItem::onCardRefChanged(const CardRef &oldCardRef, const CardRef & /*newCardRef*/)
+{
+ emit deleteCardInfoPopup(oldCardRef.name);
+}
+
+void AbstractCardItem::refreshCardInfo()
+{
+ state->refreshCardInfo();
+}
+
/**
* Convenience method to get the CardInfo of the exactCard
* @return A const reference to the CardInfo, or an empty CardInfo if card was null
*/
const CardInfo &AbstractCardItem::getCardInfo() const
{
- return exactCard.getInfo();
+ return state->getCardInfo();
}
void AbstractCardItem::setRealZValue(qreal _zValue)
@@ -146,7 +155,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla
painter->rotate(angle);
painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2);
- QFont f;
+ QFont f = themeManager->appFont(AppFont::CardText);
f.setPixelSize(fontSize * scale);
painter->setFont(f);
@@ -158,13 +167,13 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
QPixmap translatedPixmap;
bool paintImage = true;
- if (facedown || cardRef.name.isEmpty()) {
+ if (state->getFaceDown() || state->getCardRef().name.isEmpty()) {
// never reveal card color, always paint the card back
CardPictureLoader::getCardBackPixmap(translatedPixmap, translatedSize.toSize());
} else {
// don't even spend time trying to load the picture if our size is too small
if (translatedSize.width() > 10) {
- CardPictureLoader::getPixmap(translatedPixmap, exactCard, translatedSize.toSize());
+ CardPictureLoader::getPixmap(translatedPixmap, state->getCard(), translatedSize.toSize());
if (translatedPixmap.isNull()) {
paintImage = false;
}
@@ -185,19 +194,20 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
painter->drawPath(shape());
}
- if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) {
+ if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() ||
+ state->getFaceDown()) {
painter->save();
transformPainter(painter, translatedSize, angle);
painter->setPen(Qt::white);
painter->setBackground(Qt::black);
painter->setBackgroundMode(Qt::OpaqueMode);
QString nameStr;
- if (facedown) {
- nameStr = "# " + QString::number(id);
+ if (state->getFaceDown()) {
+ nameStr = "# " + QString::number(state->getId());
} else {
QString prefix = "";
if (SettingsCache::instance().debug().getShowCardId()) {
- prefix = "#" + QString::number(id) + " ";
+ prefix = "#" + QString::number(state->getId()) + " ";
}
nameStr = prefix + CardLocalization::displayName(getCardInfo());
}
@@ -235,21 +245,6 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *
painter->restore();
}
-void AbstractCardItem::setCardRef(const CardRef &_cardRef)
-{
- if (cardRef == _cardRef) {
- return;
- }
-
- emit deleteCardInfoPopup(cardRef.name);
- if (exactCard) {
- disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr);
- }
- cardRef = _cardRef;
-
- refreshCardInfo();
-}
-
void AbstractCardItem::setHovered(bool _hovered)
{
if (isHovered == _hovered) {
@@ -275,18 +270,17 @@ void AbstractCardItem::setHovered(bool _hovered)
void AbstractCardItem::setColor(const QString &_color)
{
- color = _color;
- cacheBgColor();
- update();
+ state->setColor(_color);
}
void AbstractCardItem::cacheBgColor()
{
QChar colorChar;
- if (color.isEmpty()) {
- colorChar = exactCard.getInfo().getColorChar();
+ const QString cardColor = state->getColor();
+ if (cardColor.isEmpty()) {
+ colorChar = state->getCardInfo().getColorChar();
} else {
- colorChar = color.at(0);
+ colorChar = cardColor.at(0);
}
switch (colorChar.toLower().toLatin1()) {
@@ -314,17 +308,12 @@ void AbstractCardItem::cacheBgColor()
}
}
-void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
+void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate)
{
- if (tapped == _tapped) {
- return;
- }
-
- tapped = _tapped;
if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) {
static_cast(scene())->registerAnimationItem(this);
} else {
- tapAngle = tapped ? 90 : 0;
+ tapAngle = newTapped ? 90 : 0;
setTransform(QTransform()
.translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F)
.rotate(tapAngle)
@@ -338,12 +327,6 @@ bool AbstractCardItem::animationEvent()
return false;
}
-void AbstractCardItem::setFaceDown(bool _facedown)
-{
- facedown = _facedown;
- update();
-}
-
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
cancelPendingSelectionCollapse();
@@ -357,7 +340,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
}
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
- emit cardShiftClicked(cardRef.name);
+ emit cardShiftClicked(state->getCardRef().name);
} else if ((event->modifiers() & Qt::ControlModifier)) {
setSelected(!isSelected());
} else if (!isSelected()) {
@@ -371,7 +354,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
if (event->button() == Qt::LeftButton) {
setCursor(Qt::ClosedHandCursor);
} else if (event->button() == Qt::MiddleButton) {
- emit showCardInfoPopup(event->screenPos(), cardRef);
+ emit showCardInfoPopup(event->screenPos(), state->getCardRef());
}
event->accept();
}
@@ -405,7 +388,7 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
}
if (event->button() == Qt::MiddleButton) {
- emit deleteCardInfoPopup(cardRef.name);
+ emit deleteCardInfoPopup(state->getCardRef().name);
}
// This function ensures the parent function doesn't mess around with our selection.
diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h
index e2b150ce8..eee8ec743 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_item.h
+++ b/cockatrice/src/game_graphics/board/abstract_card_item.h
@@ -16,8 +16,7 @@
#include
#include
#include
-#include
-#include
+#include
#include
class PlayerLogic;
@@ -29,13 +28,8 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem
{
Q_OBJECT
protected:
- ExactCard exactCard;
- int id;
- CardRef cardRef;
- bool tapped;
- bool facedown;
+ CardState *state;
int tapAngle;
- QString color;
QColor bgColor;
private:
@@ -45,6 +39,9 @@ private:
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
private slots:
void pixmapUpdated();
+ void onCardInfoChanged();
+ void onCardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef);
+ void onTappedChanged(bool newTapped, bool canAnimate);
public slots:
void refreshCardInfo();
@@ -80,31 +77,38 @@ public:
QSizeF getTranslatedSize(QPainter *painter) const;
void paintPicture(QPainter *painter, const QSizeF &translatedSize, int angle);
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
+ [[nodiscard]] CardState *getState() const
+ {
+ return state;
+ }
ExactCard getCard() const
{
- return exactCard;
+ return state->getCard();
}
const CardInfo &getCardInfo() const;
int getId() const
{
- return id;
+ return state->getId();
}
void setId(int _id)
{
- id = _id;
+ state->setId(_id);
}
QString getName() const
{
- return cardRef.name;
+ return state->getCardRef().name;
}
QString getProviderId() const
{
- return cardRef.providerId;
+ return state->getCardRef().providerId;
+ }
+ void setCardRef(const CardRef &_cardRef)
+ {
+ state->setCardRef(_cardRef);
}
- void setCardRef(const CardRef &_cardRef);
CardRef getCardRef() const
{
- return cardRef;
+ return state->getCardRef();
}
qreal getRealZValue() const
{
@@ -116,27 +120,33 @@ public:
{
return isHovered;
}
- QString getColor() const
+ [[nodiscard]] QString getColor() const
{
- return color;
+ return state->getColor();
}
void setColor(const QString &_color);
bool getTapped() const
{
- return tapped;
+ return state->getTapped();
+ }
+ void setTapped(bool _tapped, bool canAnimate = false)
+ {
+ state->setTapped(_tapped, canAnimate);
}
- void setTapped(bool _tapped, bool canAnimate = false);
bool getFaceDown() const
{
- return facedown;
+ return state->getFaceDown();
+ }
+ void setFaceDown(bool _facedown)
+ {
+ state->setFaceDown(_facedown);
}
- void setFaceDown(bool _facedown);
void processHoverEvent();
/** @brief Drop a pending collapse and disarm any deferred collapse timer. */
void cancelPendingSelectionCollapse();
void deleteCardInfoPopup()
{
- emit deleteCardInfoPopup(cardRef.name);
+ emit deleteCardInfoPopup(state->getCardRef().name);
}
/** @brief Default: no per-tick animation. Subclasses override to animate. */
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp
index 8500b9c51..30ffde320 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp
@@ -2,10 +2,6 @@
#include "../../client/settings/cache_settings.h"
#include "../../client/settings/shortcuts_settings.h"
-#include "../../game/board/counter_state.h"
-#include "../../game/player/player_actions.h"
-#include "../../game/player/player_info.h"
-#include "../../game/player/player_logic.h"
#include "../../game_graphics/board/translate_counter_name.h"
#include "../../interface/widgets/menus/tearoff_menu.h"
@@ -23,8 +19,10 @@
#include
#include
#include
-#include
-#include
+#include
+#include
+#include