(.*?)",
+ 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/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 9b55ce2fd..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
@@ -39,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()
@@ -202,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/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
index 0e8b6ced3..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"
@@ -154,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);
diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp
index 50e80d359..3a740aa98 100644
--- a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp
@@ -1,5 +1,7 @@
#include "abstract_graphics_item.h"
+#include "../../interface/theme_manager.h"
+
#include
#include
#include
@@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number,
painter->save();
QString numStr = QString::number(number);
- QFont font("Serif");
+ QFont font = themeManager->cardTextFont();
font.setPixelSize(fontSize);
font.setWeight(QFont::Bold);
diff --git a/cockatrice/src/game_graphics/board/counter_general.cpp b/cockatrice/src/game_graphics/board/counter_general.cpp
index 4a8bbd6a4..23618e6bf 100644
--- a/cockatrice/src/game_graphics/board/counter_general.cpp
+++ b/cockatrice/src/game_graphics/board/counter_general.cpp
@@ -1,6 +1,7 @@
#include "counter_general.h"
#include "../../interface/pixel_map_generator.h"
+#include "../../interface/theme_manager.h"
#include "abstract_graphics_item.h"
#include
@@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /
painter->drawPixmap(QPoint(0, 0), pixmap);
if (value) {
- QFont f("Serif");
+ QFont f = themeManager->cardTextFont();
f.setPixelSize(qMax((int)(radius * scaleFactor), 10));
f.setWeight(QFont::Bold);
painter->setPen(Qt::black);
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp
index 6e0b0eaaa..f62d33335 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view.cpp
+++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp
@@ -240,8 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI
painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY));
painter->setPen(QColor(Qt::white));
- QFont f("Serif");
- f.setStyleHint(QFont::Serif);
+ // Family comes from the DeckList theme role; pixel sizes/weights unchanged.
+ QFont f = themeManager->deckListFont();
f.setPixelSize(24);
f.setWeight(QFont::Bold);
painter->setFont(f);
@@ -292,8 +292,8 @@ QList> DeckViewCardContainer::getRowsAndCols() const
int DeckViewCardContainer::getCardTypeTextWidth() const
{
- QFont f("Serif");
- f.setStyleHint(QFont::Serif);
+ // Family comes from the DeckList theme role; pixel size/weight unchanged.
+ QFont f = themeManager->deckListFont();
f.setPixelSize(16);
f.setWeight(QFont::Bold);
QFontMetrics fm(f);
@@ -366,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width)
DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0)
{
+ // Zone headers read the DeckList role at paint time and cache their
+ // rendering, so touch every item to invalidate the caches and pick up the
+ // new family on font changes (containers cannot connect this themselves:
+ // QGraphicsItem is not a QObject).
+ connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
+ for (auto *item : items()) {
+ item->update();
+ }
+ });
}
DeckViewScene::~DeckViewScene()
diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
index 3bef9e59e..fe63d7a6f 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
+++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp
@@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state)
DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
: QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId)
{
+ setObjectName("deckViewContainer");
+
loadLocalButton = new QPushButton;
loadRemoteButton = new QPushButton;
loadFromClipboardButton = new QPushButton;
@@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
forceStartGameButton = new QPushButton;
sideboardLockButton = new ToggleButton;
+ loadLocalButton->setObjectName("loadLocalButton");
+ loadRemoteButton->setObjectName("loadRemoteButton");
+ loadFromClipboardButton->setObjectName("loadFromClipboardButton");
+ readyStartButton->setObjectName("readyStartButton");
+ forceStartGameButton->setObjectName("forceStartGameButton");
+ unloadDeckButton->setObjectName("unloadDeckButton");
+ sideboardLockButton->setObjectName("sideboardLockButton");
+
connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck);
connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck);
connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard);
diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp
index 90cbed9a1..75ede0ca8 100644
--- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp
+++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp
@@ -14,6 +14,7 @@ class TabGame;
TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent)
: QTabWidget(nullptr), playerId(_playerId), parentGame(parent)
{
+ setObjectName(QString("tabbedDeckView_%1").arg(_playerId));
setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab);
diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp
index db1040006..128899a13 100644
--- a/cockatrice/src/game_graphics/game_scene.cpp
+++ b/cockatrice/src/game_graphics/game_scene.cpp
@@ -1,6 +1,7 @@
#include "game_scene.h"
#include "../client/settings/cache_settings.h"
+#include "../interface/theme_manager.h"
#include "animated_item.h"
#include "board/abstract_card_item.h"
#include "board/arrow_item.h"
@@ -35,6 +36,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -64,6 +66,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
addItem(phasesToolbar);
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
+ // CardText items (P/T boxes, counters, player names) read the role at
+ // paint time and cache their rendering, so touch every item to invalidate
+ // the caches and pick up the new family on font changes.
+ connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
+ for (auto *item : items()) {
+ item->update();
+ }
+ });
rearrange();
}
@@ -432,6 +442,37 @@ QList GameScene::collectActivePlayers(int &firstPlayerIndex) cons
}
}
}
+
+ // Lay out the table in seat order rather than id order. Players missing
+ // from the tracked order keep id order at the end.
+ QList seatOrder;
+ for (auto *pgItem : playerViews.values()) {
+ if (PlayerLogic *p = pgItem->getLogic()) {
+ seatOrder = p->getGame()->getGameState()->getSeatOrder();
+ break;
+ }
+ }
+ if (!seatOrder.isEmpty()) {
+ std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) {
+ int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId());
+ int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId());
+ if (indexA == -1) {
+ indexA = seatOrder.size() + a->getPlayerInfo()->getId();
+ }
+ if (indexB == -1) {
+ indexB = seatOrder.size() + b->getPlayerInfo()->getId();
+ }
+ return indexA < indexB;
+ });
+ // Recompute the local-player index after sorting.
+ firstPlayerIndex = 0;
+ for (int i = 0; i < activePlayers.size(); ++i) {
+ if (activePlayers[i]->getPlayerInfo()->getLocal()) {
+ firstPlayerIndex = i;
+ break;
+ }
+ }
+ }
return activePlayers;
}
diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp
index cb59b3d7d..7a5aa073a 100644
--- a/cockatrice/src/game_graphics/game_view.cpp
+++ b/cockatrice/src/game_graphics/game_view.cpp
@@ -2,12 +2,14 @@
#include "../client/settings/cache_settings.h"
#include "../client/settings/shortcuts_settings.h"
+#include "../interface/theme_manager.h"
#include "game_scene.h"
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -24,6 +26,21 @@
#include
#include
+namespace
+{
+QFont deckOverlayFont(int pixelSize, bool bold)
+{
+ // The drag/total counters and the tally columns are in-game overlays, not
+ // deck list text: keep the monospace family the stylesheets used to set so
+ // digit widths stay stable as counts tick and `white-space: pre` lines up.
+ QFont font(QStringLiteral("monospace"));
+ font.setStyleHint(QFont::Monospace);
+ font.setPixelSize(pixelSize);
+ font.setBold(bold);
+ return font;
+}
+} // namespace
+
// QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings.
// This subclass disables that behavior so dragCountLabel can appear above it.
class SelectionRubberBand : public QRubberBand
@@ -48,6 +65,7 @@ protected:
GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0)
{
+ setObjectName("gameView");
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setViewportUpdateMode(BoundingRectViewportUpdate);
@@ -75,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this);
const QString baseProperties = "color: white; "
- "font-family: monospace; "
"background-color: rgba(0, 0, 0, 160); "
"border-radius: 3px; "
"padding: 1px 2px; "
"white-space: pre;";
- const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;";
- const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;";
- const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;";
+ const QString dragCountLabelStyle = baseProperties;
+ const QString totalCountLabelStyle = baseProperties;
+ const QString subtypeTallyLabelStyle = baseProperties;
dragCountLabel = new QLabel(this);
dragCountLabel->setStyleSheet(dragCountLabelStyle);
@@ -100,6 +117,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
tallyLayout->setContentsMargins(2, 2, 2, 2);
tallyLayout->setSpacing(2);
tallyContainer->hide();
+
+ applyDeckOverlayFonts();
+}
+
+void GameView::applyDeckOverlayFonts()
+{
+ dragCountLabel->setFont(deckOverlayFont(14, true));
+ totalCountLabel->setFont(deckOverlayFont(16, true));
+ tallyContainer->setFont(deckOverlayFont(12, false));
+ for (int i = 0; i < tallyLayout->count(); ++i) {
+ if (auto *label = qobject_cast(tallyLayout->itemAt(i)->widget())) {
+ // Count column (odd positions) keeps the bold emphasis.
+ const bool count = i % 2 == 1;
+ label->setFont(deckOverlayFont(count ? 14 : 12, count));
+ }
+ }
+
+ // The container keeps the size computed from the previous fonts, so force
+ // rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows.
+ cachedTallyRows.clear();
+ updateTotalSelectionCount();
}
void GameView::resizeEvent(QResizeEvent *event)
@@ -219,9 +257,8 @@ QSize GameView::rebuildTallyLabels(const QList &entries)
{
clearTallyLabels();
- const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;");
- const QString countStyle =
- QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;");
+ const QString nameStyle = QStringLiteral("color: white; background: transparent;");
+ const QString countStyle = QStringLiteral("color: white; background: transparent;");
int totalHeight = 0;
int maxNameWidth = 0;
@@ -231,11 +268,13 @@ QSize GameView::rebuildTallyLabels(const QList &entries)
for (const TallyRow &entry : entries) {
auto *nameLabel = new QLabel(entry.name, tallyContainer);
nameLabel->setStyleSheet(nameStyle);
+ nameLabel->setFont(deckOverlayFont(12, false));
nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(nameLabel, row, 0);
auto *countLabel = new QLabel(entry.value, tallyContainer);
countLabel->setStyleSheet(countStyle);
+ countLabel->setFont(deckOverlayFont(14, true));
countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(countLabel, row, 1);
diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h
index e57267734..99a73a0a8 100644
--- a/cockatrice/src/game_graphics/game_view.h
+++ b/cockatrice/src/game_graphics/game_view.h
@@ -50,6 +50,7 @@ private slots:
void refreshShortcuts();
void updateTotalSelectionCount(const QSize &viewSize = QSize());
void setFocusDisabled(bool disabled);
+ void applyDeckOverlayFonts();
public slots:
void updateSceneRect(const QRectF &rect);
diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp
index 046a1ae90..d526766af 100644
--- a/cockatrice/src/game_graphics/log/message_log_widget.cpp
+++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp
@@ -4,6 +4,8 @@
#include "../../client/settings/card_counter_settings.h"
#include "../../client/sound_engine.h"
#include "../../interface/card_localization.h"
+#include "../../interface/theme_icon.h"
+#include "../../interface/theme_manager.h"
#include "../../interface/widgets/server/chat_view/chat_view.h"
#include "../board/card_item.h"
#include "../board/translate_counter_name.h"
@@ -12,8 +14,12 @@
#include
#include
#include
+#include
+#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
void MessageLogWidget::containerProcessingDone()
{
currentContext = MessageContext_None;
+ contextJudgeName.clear();
messageSuffix = messagePrefix = QString();
}
@@ -605,6 +612,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed)
.arg(reversed ? tr("reversed") : tr("normal")));
}
+void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized)
+{
+ QStringList sanitizedNames;
+ for (const QString &name : playerNames) {
+ sanitizedNames.append(sanitizeHtml(name));
+ }
+ const QString order = sanitizedNames.join(" -> ");
+ if (randomized) {
+ appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order));
+ } else {
+ appendHtmlServerMessage(tr("New turn order set: %1.").arg(order));
+ }
+}
+
void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList &rolls)
{
if (rolls.length() == 1) {
@@ -841,8 +862,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player)
void MessageLogWidget::setContextJudgeName(QString name)
{
- messagePrefix = QString("");
- messageSuffix = QString(" [
%1]").arg(sanitizeHtml(name));
+ contextJudgeName = std::move(name);
+ refreshJudgeSuffix();
+}
+
+void MessageLogWidget::refreshJudgeSuffix()
+{
+ const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24));
+ if (!pixmap.isNull()) {
+ // Rich text cannot resolve `currentColor`; register a palette-rendered
+ // image so already-appended judge tags repaint on a theme switch.
+ document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap);
+ document()->markContentsDirty(0, document()->characterCount());
+ }
+
+ if (contextJudgeName.isEmpty()) {
+ return;
+ }
+
+ messagePrefix = QStringLiteral("").arg(palette().color(QPalette::WindowText).name());
+ messageSuffix =
+ QStringLiteral(" [
%1]").arg(sanitizeHtml(contextJudgeName));
}
void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor)
@@ -891,4 +931,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve
MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent)
: ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None)
{
+ connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix);
+ connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix);
}
diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h
index 5642a76e9..3ec5ff93c 100644
--- a/cockatrice/src/game_graphics/log/message_log_widget.h
+++ b/cockatrice/src/game_graphics/log/message_log_widget.h
@@ -39,8 +39,10 @@ private:
MessageContext currentContext;
QString messagePrefix, messageSuffix;
+ QString contextJudgeName;
static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange);
+ void refreshJudgeSuffix();
public:
void connectToPlayerEventHandler(PlayerEventHandler *player);
@@ -94,6 +96,7 @@ public slots:
int amount,
bool isLentToAnotherPlayer);
void logReverseTurn(PlayerLogic *player, bool reversed);
+ void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logRollDie(PlayerLogic *player, int sides, const QList &rolls);
void logSay(PlayerLogic *player, QString message);
void logSetActivePhase(int phase);
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp
index 7b358010f..6895e7db8 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.cpp
+++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp
@@ -1,6 +1,7 @@
#include "player_list_widget.h"
#include "../../interface/pixel_map_generator.h"
+#include "../../interface/theme_icon.h"
#include "../../interface/widgets/server/user/user_context_menu.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "user_level.h"
@@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
- readyIcon = themePixmap(QStringLiteral("icons/ready_start"));
- notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start"));
- concededIcon = themePixmap(QStringLiteral("icons/conceded"));
- playerIcon = loadColorAdjustedPixmap("theme:icons/player");
- judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
- spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
- lockIcon = themePixmap(QStringLiteral("icons/lock"));
+ readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"));
+ notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"));
+ concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
+ playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
+ judgeIcon = loadThemedIcon(QStringLiteral("icons/scales"));
+ spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator"));
+ lockIcon = loadThemedIcon(QStringLiteral("icons/lock"));
if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this);
diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp
index 1cd97e7b5..0d10ba4ab 100644
--- a/cockatrice/src/game_graphics/player/player_target.cpp
+++ b/cockatrice/src/game_graphics/player/player_target.cpp
@@ -2,6 +2,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../interface/pixel_map_generator.h"
+#include "../../interface/theme_manager.h"
#include "../board/abstract_counter.h"
#include "../board/abstract_graphics_item.h"
#include "../board/arrow_target.h"
@@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
QRectF translatedRect = path.controlPointRect();
QSize translatedSize = translatedRect.size().toSize();
- QFont font("Serif");
+ QFont font = themeManager->cardTextFont();
font.setWeight(QFont::Bold);
font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9));
painter->setFont(font);
@@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
name = name.mid(0, 10) + "...";
}
- QFont font;
+ QFont font = themeManager->appFont(AppFont::CardText);
font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9));
painter->setFont(font);
painter->setPen(Qt::white);
diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
new file mode 100644
index 000000000..c6376ef75
--- /dev/null
+++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
@@ -0,0 +1,628 @@
+#include "pq_cockatrice_event_player.h"
+
+#include "../../interface/widgets/tabs/tab_game.h"
+#include "board/card_item.h"
+#include "game_scene.h"
+#include "game_view.h"
+#include "zones/card_zone.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer")
+
+namespace
+{
+/**
+ * Strips Qt's mnemonic markers so a test can name a menu entry by the label the
+ * user sees ("Turn Over" rather than "T&urn Over").
+ */
+QString normalizeActionText(const QString &text)
+{
+ QString normalized = text;
+ normalized.remove('&');
+ return normalized.simplified();
+}
+
+/**
+ * Maps a printable character onto the key a US keyboard would report for it, so
+ * typing produces the same QKeyEvents a recording would.
+ */
+Qt::Key keyForCharacter(QChar character)
+{
+ if (character.isLetter()) {
+ return static_cast(Qt::Key_A + (character.toUpper().unicode() - 'A'));
+ }
+ if (character.isDigit()) {
+ return static_cast(Qt::Key_0 + (character.unicode() - '0'));
+ }
+ switch (character.unicode()) {
+ case ' ':
+ return Qt::Key_Space;
+ case '\n':
+ return Qt::Key_Return;
+ case '\t':
+ return Qt::Key_Tab;
+ case '-':
+ return Qt::Key_Minus;
+ case '_':
+ return Qt::Key_Underscore;
+ case '/':
+ return Qt::Key_Slash;
+ case ',':
+ return Qt::Key_Comma;
+ case '.':
+ return Qt::Key_Period;
+ case '\'':
+ return Qt::Key_Apostrophe;
+ case ';':
+ return Qt::Key_Semicolon;
+ case ':':
+ return Qt::Key_Colon;
+ case '"':
+ return Qt::Key_QuoteDbl;
+ case '&':
+ return Qt::Key_Ampersand;
+ case '(':
+ return Qt::Key_ParenLeft;
+ case ')':
+ return Qt::Key_ParenRight;
+ case '[':
+ return Qt::Key_BracketLeft;
+ case ']':
+ return Qt::Key_BracketRight;
+ default:
+ return Qt::Key_unknown;
+ }
+}
+
+/** Depth-first search for the first model index whose label matches @p text. */
+QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {})
+{
+ for (int row = 0; row < model->rowCount(parent); ++row) {
+ const QModelIndex index = model->index(row, 0, parent);
+ if (index.data(Qt::DisplayRole).toString() == text) {
+ return index;
+ }
+ const QModelIndex child = findModelIndexByText(model, text, index);
+ if (child.isValid()) {
+ return child;
+ }
+ }
+ return {};
+}
+
+bool needsShift(QChar character)
+{
+ if (character.isUpper()) {
+ return true;
+ }
+ return QStringLiteral("_:\"&()").contains(character);
+}
+} // namespace
+
+pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
+{
+}
+
+bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
+{
+ // A known command that fails is a handled-but-failed event: QtTesting logs
+ // "Unhandled event" when playEvent returns false, so report through `error`
+ // (the contract playExpectProperty already follows) and still return true.
+ const auto run = [&error](bool ok) {
+ if (!ok) {
+ error = true;
+ }
+ return true;
+ };
+
+ if (command == "clickCard") {
+ return run(playClickCard(object, arguments));
+ } else if (command == "flipCard") {
+ return run(playFlipCard(object, arguments));
+ } else if (command == "cardAction") {
+ return run(playCardAction(object, arguments));
+ } else if (command == "moveCard") {
+ return run(playMoveCard(object, arguments));
+ } else if (command == "drawArrow") {
+ return run(playDrawArrow(object, arguments));
+ } else if (command == "createToken") {
+ return run(playCreateToken(object, arguments));
+ } else if (command == "selectItem") {
+ return run(playSelectItem(object, arguments));
+ } else if (command == "typeText") {
+ return run(playTypeText(object, arguments));
+ } else if (command == "expectProperty") {
+ return playExpectProperty(object, arguments, error);
+ }
+ return false;
+}
+
+QGraphicsItem *
+pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName)
+{
+ for (QGraphicsItem *gi : scene->items()) {
+ if (gi->type() != typeCard) {
+ continue;
+ }
+ auto *card = static_cast(gi);
+ if (!card->getZone() || card->getZone()->getName() != zoneName) {
+ continue;
+ }
+ QString actualName = card->getName();
+ if (actualName == cardName || actualName.endsWith(" " + cardName)) {
+ return card;
+ }
+ }
+ return nullptr;
+}
+
+QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName)
+{
+ for (QGraphicsItem *gi : scene->items()) {
+ if (gi->type() != typeZone) {
+ continue;
+ }
+ auto *zone = static_cast(gi);
+ if (zone->getLogic() && zone->getLogic()->getName() == zoneName) {
+ return zone;
+ }
+ }
+ return nullptr;
+}
+
+QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText)
+{
+ if (!menu || actionText.isEmpty()) {
+ return nullptr;
+ }
+ for (QAction *action : menu->actions()) {
+ if (action->isSeparator()) {
+ continue;
+ }
+ if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) {
+ return action;
+ }
+ if (QMenu *subMenu = action->menu()) {
+ if (QAction *found = findActionInMenu(subMenu, actionText)) {
+ return found;
+ }
+ }
+ }
+ return nullptr;
+}
+
+QMenu *pqCockatriceEventPlayer::visiblePopupMenu()
+{
+ for (QWidget *w : QApplication::topLevelWidgets()) {
+ auto *menu = qobject_cast(w);
+ if (menu && menu->isVisible()) {
+ return menu;
+ }
+ }
+ return nullptr;
+}
+
+QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item)
+{
+ return view->mapFromScene(item->sceneBoundingRect().center());
+}
+void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view,
+ QEvent::Type type,
+ const QPoint &pos,
+ Qt::MouseButton button,
+ Qt::MouseButtons buttons)
+{
+ QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier);
+ qApp->notify(view->viewport(), &event);
+}
+
+bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 2) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments;
+ return false;
+ }
+
+ QGraphicsScene *scene = gv->scene();
+ if (!scene) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView";
+ return false;
+ }
+
+ auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed());
+ if (!target) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
+ return false;
+ }
+
+ const QPoint pos = viewportPoint(gv, target);
+ // A double click is the play gesture from hand: press/release, then the
+ // double-click event and its release, exactly as a recording produces it.
+ sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton);
+ sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
+ sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton);
+ sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
+
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 2) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments;
+ return false;
+ }
+
+ QGraphicsScene *scene = gv->scene();
+ if (!scene) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene";
+ return false;
+ }
+
+ auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
+ if (!card) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
+ return false;
+ }
+
+ return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over"));
+}
+
+bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 3) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments;
+ return false;
+ }
+
+ QGraphicsScene *scene = gv->scene();
+ if (!scene) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene";
+ return false;
+ }
+
+ auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
+ if (!card) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
+ return false;
+ }
+
+ return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed());
+}
+
+bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command,
+ QGraphicsView *view,
+ CardItem *card,
+ const QString &actionText)
+{
+ const QPoint pos = viewportPoint(view, card);
+ sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton);
+ sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton);
+ qApp->processEvents();
+
+ QMenu *menu = visiblePopupMenu();
+ if (!menu) {
+ qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click";
+ return false;
+ }
+
+ QAction *action = findActionInMenu(menu, actionText);
+ if (!action) {
+ qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText;
+ menu->close();
+ return false;
+ }
+
+ action->trigger();
+ menu->close();
+ qApp->processEvents();
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 3) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments;
+ return false;
+ }
+
+ const QString cardName = parts[0].trimmed();
+ const QString srcZoneName = parts[1].trimmed();
+ const QString dstZoneName = parts[2].trimmed();
+
+ QGraphicsScene *scene = gv->scene();
+ if (!scene) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene";
+ return false;
+ }
+
+ auto *card = findCardInScene(scene, cardName, srcZoneName);
+ if (!card) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName;
+ return false;
+ }
+
+ auto *dstZone = findZoneInScene(scene, dstZoneName);
+ if (!dstZone) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found";
+ return false;
+ }
+
+ const QPoint srcPos = viewportPoint(gv, card);
+ const QPoint dstPos = viewportPoint(gv, dstZone);
+
+ // A drag has to travel past Qt's start distance before the scene turns it
+ // into a card drag item, otherwise the move is interpreted as a click.
+ const int dragDistance = 2 * QApplication::startDragDistance() + 10;
+ const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
+
+ sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton);
+ sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton);
+ qApp->processEvents();
+ sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton);
+ qApp->processEvents();
+ sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton);
+ qApp->processEvents();
+
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 4) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments;
+ return false;
+ }
+
+ const QString srcCardName = parts[0].trimmed();
+ const QString srcZoneName = parts[1].trimmed();
+ const QString dstCardName = parts[2].trimmed();
+ const QString dstZoneName = parts[3].trimmed();
+
+ QGraphicsScene *scene = gv->scene();
+ if (!scene) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene";
+ return false;
+ }
+
+ auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName);
+ if (!srcCard) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName;
+ return false;
+ }
+
+ auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName);
+ if (!dstCard) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName;
+ return false;
+ }
+
+ const QPoint srcPos = viewportPoint(gv, srcCard);
+ const QPoint dstPos = viewportPoint(gv, dstCard);
+
+ const int dragDistance = 2 * QApplication::startDragDistance() + 10;
+ const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
+
+ sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton);
+ sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton);
+ qApp->processEvents();
+ sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton);
+ sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton);
+ qApp->processEvents();
+
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments)
+{
+ auto *gv = qobject_cast(object);
+ if (!gv) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView";
+ return false;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 3) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments;
+ return false;
+ }
+
+ QString tokenName = parts[0].trimmed();
+ QString tokenColor = parts[1].trimmed();
+ QString tokenPt = parts[2].trimmed();
+
+ TabGame *tabGame = nullptr;
+ QWidget *w = gv;
+ while (w) {
+ tabGame = qobject_cast(w);
+ if (tabGame) {
+ break;
+ }
+ w = w->parentWidget();
+ }
+ if (!tabGame) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found";
+ return false;
+ }
+
+ AbstractGame *game = tabGame->getGame();
+ if (!game) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game";
+ return false;
+ }
+
+ int activePlayerId = game->getGameState()->getActivePlayer();
+ PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId);
+ if (!player) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player";
+ return false;
+ }
+
+ TokenInfo info;
+ info.name = tokenName;
+ info.color = tokenColor;
+ info.pt = tokenPt;
+ info.destroy = true;
+ info.faceDown = false;
+
+ player->getPlayerActions()->actCreateToken(info);
+
+ for (int i = 0; i < 10; ++i) {
+ qApp->processEvents();
+ }
+
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments)
+{
+ const QString text = arguments.trimmed();
+ if (text.isEmpty()) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select";
+ return false;
+ }
+
+ if (auto *tree = qobject_cast(object)) {
+ QTreeWidgetItemIterator item(tree);
+ while (*item) {
+ if ((*item)->text(0) == text) {
+ tree->setCurrentItem(*item);
+ qApp->processEvents();
+ return true;
+ }
+ ++item;
+ }
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text;
+ return false;
+ }
+
+ if (auto *view = qobject_cast(object)) {
+ const QModelIndex index = findModelIndexByText(view->model(), text);
+ if (!index.isValid()) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text;
+ return false;
+ }
+ view->setCurrentIndex(index);
+ qApp->processEvents();
+ return true;
+ }
+
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view";
+ return false;
+}
+
+bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments)
+{
+ auto *widget = qobject_cast(object);
+ if (!widget) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget";
+ return false;
+ }
+
+ for (const QChar character : arguments) {
+ const Qt::Key key = keyForCharacter(character);
+ const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier;
+ QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character));
+ QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character));
+ qApp->notify(widget, &press);
+ qApp->notify(widget, &release);
+ }
+ qApp->processEvents();
+
+ return true;
+}
+
+bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error)
+{
+ if (!object) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object";
+ error = true;
+ return true;
+ }
+
+ QStringList parts = arguments.split(',');
+ if (parts.size() < 2) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments;
+ error = true;
+ return true;
+ }
+
+ const QString propertyName = parts[0].trimmed();
+ const QString expected = parts.mid(1).join(',');
+
+ const QVariant actual = object->property(propertyName.toUtf8().constData());
+ if (!actual.isValid()) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
+ << "expectProperty:" << object->objectName() << "has no property" << propertyName;
+ error = true;
+ return true;
+ }
+
+ if (actual.toString() != expected) {
+ qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is"
+ << actual.toString() << "but the test expected" << expected;
+ error = true;
+ return true;
+ }
+
+ return true;
+}
diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.h b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h
new file mode 100644
index 000000000..89d88af77
--- /dev/null
+++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h
@@ -0,0 +1,91 @@
+#ifndef PQ_COCKATRICE_EVENT_PLAYER_H
+#define PQ_COCKATRICE_EVENT_PLAYER_H
+
+#include "pqWidgetEventPlayer.h"
+
+#include