diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 04037a74e..6ca634389 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -152,7 +152,7 @@ jobs:
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
- CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
@@ -176,8 +176,12 @@ jobs:
shell: bash
run: |
source .ci/docker.sh
- RUN --server --debug --test --ccache "$CCACHE_SIZE" \
- --cmake-generator "$CMAKE_GENERATOR"
+ args=()
+ [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
+ args+=(--ccache "$CCACHE_SIZE")
+ args+=(--cmake-generator "$CMAKE_GENERATOR")
+
+ RUN --server --debug --test "${args[@]}"
- name: "Build release package"
id: build
@@ -338,7 +342,7 @@ jobs:
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
- CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
+ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
steps:
- name: "Checkout"
diff --git a/README.md b/README.md
index f22df461f..5935bb540 100644
--- a/README.md
+++ b/README.md
@@ -149,15 +149,15 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`:
-| Flag | Description |
-| --- | --- |
-| `-DWITH_SERVER=1` | Build Servatrice server |
-| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
-| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
-| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings |
-| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode |
-| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files |
-| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
+| Flag | Description |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `-DWITH_SERVER=1` | Build Servatrice server |
+| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
+| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
+| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings |
+| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode |
+| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files |
+| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
# Run
diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake
index 0259d12e1..971c9094d 100644
--- a/cmake/FindQtRuntime.cmake
+++ b/cmake/FindQtRuntime.cmake
@@ -28,7 +28,7 @@ if(WITH_CLIENT)
)
endif()
if(WITH_ORACLE)
- set(_ORACLE_NEEDED Concurrent Network Svg Widgets)
+ set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml)
endif()
if(TEST)
# Union of Qt modules required across all test targets (independent of application targets).
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index b6050a1bd..63ccc4e9c 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -166,6 +166,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
src/interface/widgets/cards/art_crop_attribution.cpp
+ src/interface/widgets/cards/card_art_utils.cpp
src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp
@@ -381,6 +382,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp
+ src/interface/widgets/tabs/tab_developer.cpp
src/interface/widgets/tabs/tab_game.cpp
src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp
diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
index 890a621c8..c1598bd25 100644
--- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
+++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp
@@ -1,5 +1,6 @@
#include "remote_connection_controller.h"
+#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
@@ -180,7 +181,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve
"games will be lost.\nReason for shutdown: %1",
"", event.minutes())
.arg(QString::fromStdString(event.reason())));
- serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64));
+ serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64));
serverShutdownMessageBox.setText(tr("Scheduled server shutdown"));
serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal);
serverShutdownMessageBox.setVisible(true);
diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp
index 785f753e7..f109fbcd3 100644
--- a/cockatrice/src/filters/filter_builder.cpp
+++ b/cockatrice/src/filters/filter_builder.cpp
@@ -1,5 +1,6 @@
#include "filter_builder.h"
+#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include
@@ -21,7 +22,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i));
}
- QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString());
+ QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);
diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp
index 6da18b662..0970e4272 100644
--- a/cockatrice/src/game/board/counter_state.cpp
+++ b/cockatrice/src/game/board/counter_state.cpp
@@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject
convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent);
}
-void CounterState::setValue(int newValue)
+void CounterState::setValue(int newValue, bool skipDamageAnimation)
{
if (newValue == value) {
return;
}
int old = value;
value = newValue;
- emit valueChanged(old, newValue);
+ emit valueChanged(old, newValue, skipDamageAnimation);
}
\ No newline at end of file
diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h
index 0f2f16b55..4c7b34473 100644
--- a/cockatrice/src/game/board/counter_state.h
+++ b/cockatrice/src/game/board/counter_state.h
@@ -35,10 +35,23 @@ public:
return value;
}
- void setValue(int newValue);
+ /**
+ * @brief Set the counter value.
+ * @param newValue The new value.
+ * @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views
+ * suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay
+ * rewinds.
+ */
+ void setValue(int newValue, bool skipDamageAnimation = false);
signals:
- void valueChanged(int oldValue, int newValue);
+ /**
+ * @brief Emitted whenever the value changes.
+ * @param oldValue The previous value.
+ * @param newValue The new value.
+ * @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views.
+ */
+ void valueChanged(int oldValue, int newValue, bool skipDamageAnimation);
private:
int id;
diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp
index bc68d4d7c..f146cdbb4 100644
--- a/cockatrice/src/game/game_event_handler.cpp
+++ b/cockatrice/src/game/game_event_handler.cpp
@@ -430,12 +430,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
QString playerName = QString::fromStdString(playerInfo.user_info().name());
emit addPlayerToAutoCompleteList(playerName);
- if (game->getPlayerManager()->getPlayers().contains(playerId)) {
+ PlayerManager *playerManager = game->getPlayerManager();
+ if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) {
return;
}
if (playerInfo.spectator()) {
- game->getPlayerManager()->addSpectator(playerId, playerInfo);
+ playerManager->addSpectator(playerId, playerInfo);
emit logJoinSpectator(playerName);
emit spectatorJoined(playerInfo);
} else {
diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h
index 4c7663789..06238d77e 100644
--- a/cockatrice/src/game/player/event_processing_options.h
+++ b/cockatrice/src/game/player/event_processing_options.h
@@ -13,7 +13,8 @@
enum EventProcessingOption
{
SKIP_REVEAL_WINDOW = 0x0001,
- SKIP_TAP_ANIMATION = 0x0002
+ SKIP_TAP_ANIMATION = 0x0002,
+ SKIP_DAMAGE_ANIMATION = 0x0004
};
// Wrap it in a QFlags typedef
diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp
index bc48298f7..277b8b1d4 100644
--- a/cockatrice/src/game/player/player_event_handler.cpp
+++ b/cockatrice/src/game/player/player_event_handler.cpp
@@ -262,14 +262,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event)
player->addCounter(event.counter_info());
}
-void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event)
+void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options)
{
CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr);
if (!ctr) {
return;
}
int oldValue = ctr->getValue();
- ctr->setValue(event.value());
+ const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION);
+ ctr->setValue(event.value(), skipDamageAnimation);
emit logSetCounter(player, ctr->getName(), event.value(), oldValue);
}
@@ -625,7 +626,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type,
eventCreateCounter(event.GetExtension(Event_CreateCounter::ext));
break;
case GameEvent::SET_COUNTER:
- eventSetCounter(event.GetExtension(Event_SetCounter::ext));
+ eventSetCounter(event.GetExtension(Event_SetCounter::ext), options);
break;
case GameEvent::DEL_COUNTER:
eventDelCounter(event.GetExtension(Event_DelCounter::ext));
diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h
index 48ad85e88..300cacd08 100644
--- a/cockatrice/src/game/player/player_event_handler.h
+++ b/cockatrice/src/game/player/player_event_handler.h
@@ -153,7 +153,7 @@ public:
void eventCreateCounter(const Event_CreateCounter &event);
/// Set a player-level counter value.
- void eventSetCounter(const Event_SetCounter &event);
+ void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options);
/// Delete a player-level counter.
void eventDelCounter(const Event_DelCounter &event);
diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp
index 45ba09aac..143df5c57 100644
--- a/cockatrice/src/game/player/player_logic.cpp
+++ b/cockatrice/src/game/player/player_logic.cpp
@@ -175,7 +175,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info)
const ServerInfo_Card &cardInfo = zoneInfo.card_list(j);
auto *card = new CardItem(this);
card->processCardInfo(cardInfo);
- zone->addCard(card, false, cardInfo.x(), cardInfo.y());
+ // Zones without coordinates (hand, piles, stack) preserve the order
+ // they arrive in on the server in the positions of their cards list.
+ // The x coordinate of such cards is always 0, so inserting at it
+ // would reverse the list on reconnect. Append instead.
+ if (zoneInfo.with_coords()) {
+ zone->addCard(card, false, cardInfo.x(), cardInfo.y());
+ } else {
+ zone->addCard(card, false, -1);
+ }
}
}
if (zoneInfo.has_always_reveal_top_card()) {
diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp
index 6772d3ff1..8486efbeb 100644
--- a/cockatrice/src/game/player/player_manager.cpp
+++ b/cockatrice/src/game/player/player_manager.cpp
@@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const
return player;
}
+void PlayerManager::clearSpectators()
+{
+ const QList spectatorIds = spectators.keys();
+ for (int spectatorId : spectatorIds) {
+ removeSpectator(spectatorId);
+ }
+}
+
void PlayerManager::onPlayerConceded(int playerId, bool conceded)
{
// Everything else cares about this
diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h
index 2f8b87af8..504e65396 100644
--- a/cockatrice/src/game/player/player_manager.h
+++ b/cockatrice/src/game/player/player_manager.h
@@ -100,6 +100,9 @@ public:
emit spectatorRemoved(spectatorId, spectatorInfo);
}
+ /** @brief Remove all spectators, emitting the removal signal for each. */
+ void clearSpectators();
+
[[nodiscard]] AbstractGame *getGame() const
{
return game;
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp
index e63117e13..4ba04804f 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp
@@ -29,9 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state,
{
setAcceptHoverEvents(true);
- connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
+ connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) {
value = newValue;
- onValueChanged(oldValue, newValue);
+ onValueChanged(oldValue, newValue, skipDamageAnimation);
update();
});
@@ -230,7 +230,7 @@ void AbstractCounterDialog::changeValue(int diff)
setTextValue(QString::number(curValue));
}
-void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/)
+void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/)
{
// Default: no feedback. Subclasses such as PlayerCounter override this to
// flash the counter on meaningful changes (life gain/loss).
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h
index 9ddcc6d58..67b5b4074 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.h
+++ b/cockatrice/src/game_graphics/board/abstract_counter.h
@@ -39,8 +39,9 @@ protected:
* @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash).
*
* Called whenever the counter's value changes, before the item repaints.
+ * @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds).
*/
- virtual void onValueChanged(int oldValue, int newValue);
+ virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation);
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp
index c40c8c214..c2dc455cc 100644
--- a/cockatrice/src/game_graphics/board/card_item.cpp
+++ b/cockatrice/src/game_graphics/board/card_item.cpp
@@ -316,7 +316,7 @@ void CardItem::drawAttachArrow()
for (const auto &item : scene()->selectedItems()) {
CardItem *card = qgraphicsitem_cast(item);
- if (card == nullptr) {
+ if (card == nullptr || card == this) {
continue;
}
if (card->getZone() != state->getZone()) {
diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp
index 457f1b3f7..17af7618b 100644
--- a/cockatrice/src/game_graphics/game_scene.cpp
+++ b/cockatrice/src/game_graphics/game_scene.cpp
@@ -221,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player)
clearArrowsForPlayer(player->getPlayerInfo()->getId());
- for (ZoneViewWidget *zone : zoneViews) {
+ // Closing a view removes it from zoneViews synchronously, so iterate over a
+ // copy: otherwise a player with several open views (e.g. library and hand)
+ // only has the first one closed here and the remaining views are left
+ // pointing at a player that is about to be deleted.
+ const QList zoneViewCopy = zoneViews;
+ for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) {
zone->close();
}
@@ -664,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &items,
*/
void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed)
{
- for (auto &view : zoneViews) {
+ // Closing a view removes it from zoneViews synchronously, so iterate over a
+ // copy to make sure every already-open matching view is closed.
+ const QList zoneViewCopy = zoneViews;
+ for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast(temp->getLogic())->getNumberCards() == numberCards) {
diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp
index 35989ff38..8dcbcfdaa 100644
--- a/cockatrice/src/game_graphics/hand_counter.cpp
+++ b/cockatrice/src/game_graphics/hand_counter.cpp
@@ -1,5 +1,6 @@
#include "hand_counter.h"
+#include "../interface/pixel_map_generator.h"
#include "zones/card_zone.h"
#include
@@ -32,7 +33,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op
QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize();
QPixmap cachedPixmap;
if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) {
- cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ cachedPixmap =
+ themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap);
}
resetPainterTransform(painter);
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
index 8bf2703e1..122ab83be 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
@@ -3,6 +3,7 @@
#include "../../game/player/player_actions.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/widgets/cards/art_crop_attribution.h"
+#include "../../interface/widgets/cards/card_art_utils.h"
#include "../../interface/widgets/playmat/playmat_utils.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../board/abstract_card_item.h"
@@ -251,8 +252,8 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
AbstractCounter *widget;
if (state->getName() == "life") {
widget = playerTarget->addCounter(state);
- connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
- if (newValue < oldValue) {
+ connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) {
+ if (newValue < oldValue && !skipDamageAnimation) {
tableZoneGraphicsItem->triggerDamageShimmer();
}
});
@@ -442,7 +443,7 @@ void PlayerGraphicsItem::updatePlaymat()
hasPlaymat = true;
emit playmatChanged(true);
}
- playmatPixmap = fullRes;
+ playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card);
update();
}
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp
index 4268e1019..a7ec2e4a9 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.cpp
+++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp
@@ -53,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
- readyIcon = QPixmap("theme:icons/ready_start");
- notReadyIcon = QPixmap("theme:icons/not_ready_start");
- concededIcon = QPixmap("theme:icons/conceded");
+ readyIcon = themePixmap(QStringLiteral("icons/ready_start"));
+ notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start"));
+ concededIcon = themePixmap(QStringLiteral("icons/conceded"));
playerIcon = loadColorAdjustedPixmap("theme:icons/player");
judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
- lockIcon = QPixmap("theme:icons/lock");
+ lockIcon = themePixmap(QStringLiteral("icons/lock"));
if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this);
@@ -92,6 +92,11 @@ void PlayerListWidget::retranslateUi()
void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player)
{
+ if (players.contains(player.player_id())) {
+ updatePlayerProperties(player);
+ return;
+ }
+
QTreeWidgetItem *newPlayer = new PlayerListTWI;
players.insert(player.player_id(), newPlayer);
updatePlayerProperties(player);
@@ -176,6 +181,17 @@ void PlayerListWidget::removePlayer(int playerId)
delete takeTopLevelItem(indexOfTopLevelItem(player));
}
+void PlayerListWidget::clearSpectators()
+{
+ const QList playerIds = players.keys();
+ for (int playerId : playerIds) {
+ QTreeWidgetItem *player = players.value(playerId, 0);
+ if (player && !player->data(1, Qt::UserRole).toBool()) {
+ removePlayer(playerId);
+ }
+ }
+}
+
void PlayerListWidget::setActivePlayer(int playerId)
{
QMapIterator i(players);
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h
index a53cfa989..f2f0be5fd 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.h
+++ b/cockatrice/src/game_graphics/player/player_list_widget.h
@@ -66,6 +66,7 @@ public slots:
void addPlayer(const ServerInfo_PlayerProperties &player);
void removePlayer(int playerId);
void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1);
+ void clearSpectators();
};
#endif
diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp
index 910ee9c17..d6c28370d 100644
--- a/cockatrice/src/game_graphics/player/player_target.cpp
+++ b/cockatrice/src/game_graphics/player/player_target.cpp
@@ -69,7 +69,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
}
}
-void PlayerCounter::onValueChanged(int oldValue, int newValue)
+void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation)
{
flashDelta = newValue - oldValue;
if (flashDelta == 0) {
@@ -81,6 +81,11 @@ void PlayerCounter::onValueChanged(int oldValue, int newValue)
return;
}
+ if (skipDamageAnimation) {
+ flashAlpha = 0.0;
+ return;
+ }
+
flashAlpha = 1.0;
flashClock.start();
if (scene()) {
@@ -132,8 +137,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect);
QSize translatedSize = translatedRect.size().toSize();
QPixmap cachedPixmap;
+ // The key must cover everything the generated pawn depends on: the rendered
+ // size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is
+ // 0 for every null pixmap, so the default-pawn branch additionally needs the
+ // pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors
+ // in the key — otherwise two players without a custom avatar (and the same
+ // user level) would share one cached pawn.
const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" +
- QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey());
+ QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) +
+ "_" + QString::number(fullPixmap.cacheKey()) + "_" +
+ QString::fromStdString(info->privlevel()).toLower() + "_" +
+ QString::fromStdString(info->pawn_colors().left_side()) + "_" +
+ QString::fromStdString(info->pawn_colors().right_side());
if (!QPixmapCache::find(cacheKey, &cachedPixmap)) {
cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height());
diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h
index af0e9c8b7..1d06c6274 100644
--- a/cockatrice/src/game_graphics/player/player_target.h
+++ b/cockatrice/src/game_graphics/player/player_target.h
@@ -21,7 +21,7 @@ class PlayerCounter : public AbstractCounter, public IAnimatedItem
{
Q_OBJECT
protected:
- void onValueChanged(int oldValue, int newValue) override;
+ void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override;
private:
static constexpr qreal flashDurationMs = 450.0;
diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
index 17118e80d..c13b79fea 100644
--- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
+++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
@@ -62,7 +62,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit.setClearButtonEnabled(true);
searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
- auto help = searchEdit.addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
+ auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
@@ -549,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
{
QStyleOptionTitleBar *titleBar = qstyleoption_cast(option);
if (titleBar) {
- titleBar->icon = QPixmap("theme:cockatrice");
+ titleBar->icon = themePixmap(QStringLiteral("cockatrice"));
}
}
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
index 2f46e7941..8c81d641d 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
@@ -1,6 +1,7 @@
#include "card_picture_loader.h"
#include "../../client/settings/cache_settings.h"
+#include "../pixel_map_generator.h"
#include "card_picture_loader_cache_method.h"
#include "card_picture_loader_local_schemes.h"
@@ -62,7 +63,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey;
- QPixmap tmpPixmap("theme:cardback");
+ QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap.";
@@ -83,7 +84,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz
"_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
- QPixmap tmpPixmap("theme:cardback");
+ QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback.";
@@ -105,7 +106,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si
"_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
- QPixmap tmpPixmap("theme:cardback");
+ QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback.";
@@ -138,7 +139,8 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) {
- getCardBackLoadingFailedPixmap(pixmap, size);
+ // Leave the pixmap null so callers fall back to a solid color
+ // instead of showing the card back.
QDateTime failedAtTime = getInstance().failedAt.value(key);
if (!failedAtTime.isValid() ||
failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {
diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp
index 9b8c4bcdc..e74e86471 100644
--- a/cockatrice/src/interface/pixel_map_generator.cpp
+++ b/cockatrice/src/interface/pixel_map_generator.cpp
@@ -1,5 +1,7 @@
#include "pixel_map_generator.h"
+#include "theme_manager.h"
+
#include
#include
#include
@@ -14,6 +16,7 @@
#define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff";
#define DEFAULT_COLOR_MODERATOR_RIGHT "#000000";
#define DEFAULT_COLOR_ADMIN "#ff2701";
+#define DEFAULT_COLOR_DEVELOPER "#B8B8B8"
/**
* Clamps an svg render size so that rendering does not exceed a multiple of the requested size.
@@ -82,7 +85,13 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
/**
* Try to load path image from non-SVG formats, otherwise fall back to SVG.
* This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading.
- * @param path The path to the file, with no file extension. File formats will be automatically detected.
+ *
+ * The path may already carry the resolved file extension (e.g. via
+ * ThemeManager::assetPath); such paths are loaded directly. Otherwise a
+ * format-agnostic lookup probes png, jpg and finally svg.
+ *
+ * @param path The path to the file, with no file extension unless the caller
+ * already resolved it. File formats will be automatically detected.
* @param size The desired size of the pixmap.
* @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG).
*
@@ -90,6 +99,19 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
*/
static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false)
{
+ if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) {
+ return loadSvg(path, size, expandOnly);
+ }
+ if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) ||
+ path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) ||
+ path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) {
+ QPixmap pix(path);
+ if (!pix.isNull()) {
+ return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ }
+ return {};
+ }
+
const auto formats = {"png", "jpg"};
QPixmap returnPixmap;
@@ -111,7 +133,8 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name)
return pmCache.value(key);
}
- QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height));
+ QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name),
+ QSize(height, height));
pmCache.insert(key, pixmap);
return pixmap;
@@ -359,6 +382,8 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height,
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
colorLeft = DEFAULT_COLOR_ADMIN;
+ } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
+ colorLeft = DEFAULT_COLOR_DEVELOPER;
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
colorLeft = DEFAULT_COLOR_MODERATOR_LEFT;
colorRight = DEFAULT_COLOR_MODERATOR_RIGHT;
@@ -396,7 +421,8 @@ QPixmap LockPixmapGenerator::generatePixmap(int height)
return pmCache.value(key);
}
- QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true);
+ QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")),
+ QSize(height, height), true);
pmCache.insert(key, pixmap);
return pixmap;
}
@@ -411,7 +437,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
}
QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed";
- QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true);
+ QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name),
+ QSize(height, height), true);
pmCache.insert(key, pixmap);
return pixmap;
@@ -472,6 +499,13 @@ QHash ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
+ // Prefer an authored scheme-qualified variant when one exists for this asset.
+ const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size()));
+ if (!variant.isEmpty()) {
+ return QPixmap(QStringLiteral("theme:") + variant);
+ }
+
+ // Legacy fallback: runtime-invert for dark mode when no authored variant.
if (qApp->palette().windowText().color().lightness() > 200) {
QImage img(name);
img.invertPixels();
@@ -482,3 +516,21 @@ QPixmap loadColorAdjustedPixmap(const QString &name)
return QPixmap(name);
}
}
+
+QPixmap themePixmap(QStringView prefix)
+{
+ const QString resolved = themeManager->assetPath(prefix);
+ return QPixmap(QStringLiteral("theme:") + resolved);
+}
+
+void clearPixmapGeneratorCaches()
+{
+ PhasePixmapGenerator::clear();
+ CounterPixmapGenerator::clear();
+ PingPixmapGenerator::clear();
+ CountryPixmapGenerator::clear();
+ UserLevelPixmapGenerator::clear();
+ LockPixmapGenerator::clear();
+ DropdownIconPixmapGenerator::clear();
+ ManaSymbolPixmapGenerator::clear();
+}
diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h
index 17720166a..b6e822fd9 100644
--- a/cockatrice/src/interface/pixel_map_generator.h
+++ b/cockatrice/src/interface/pixel_map_generator.h
@@ -156,4 +156,15 @@ public:
QPixmap loadColorAdjustedPixmap(const QString &name);
+// Loads a "theme:" asset (with no file extension in prefix), preferring the
+// scheme-qualified variant (prefix-dark / prefix-light, resolved via
+// ThemeManager::assetPath) and falling back to the plain asset. Callers load
+// the returned path directly. Use for scheme-sensitive pixmaps like
+// backgrounds, the card back, and the app logo.
+QPixmap themePixmap(QStringView prefix);
+
+// Clears every PixmapGenerator's static cache so scheme variants are
+// re-resolved when the active theme or color scheme changes.
+void clearPixmapGeneratorCaches();
+
#endif
diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp
index 97886296b..12c8fad2c 100644
--- a/cockatrice/src/interface/theme_manager.cpp
+++ b/cockatrice/src/interface/theme_manager.cpp
@@ -1,10 +1,12 @@
#include "theme_manager.h"
#include "../../client/settings/cache_settings.h"
+#include "pixel_map_generator.h"
#include
#include
#include
+#include
#include
#include
#include
@@ -140,6 +142,48 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) const
}
}
+QString ThemeManager::schemeVariantPath(QStringView prefix) const
+{
+ static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"),
+ QStringLiteral(".svg")};
+ const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light");
+ const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme;
+
+ for (const QString &format : formats) {
+ if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) {
+ return variantStem + format;
+ }
+ }
+ return QString();
+}
+
+QString ThemeManager::assetPath(QStringView prefix) const
+{
+ // Probe order mirrors tryLoadImage: a theme may override the default SVG
+ // with a raster of the same stem, so raster wins over SVG within a stem.
+ static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"),
+ QStringLiteral(".svg")};
+
+ auto findExisting = [](const QString &stem) {
+ for (const QString &format : formats) {
+ if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) {
+ return stem + format;
+ }
+ }
+ return QString();
+ };
+
+ // Prefer the scheme-qualified variant when it exists, else the plain
+ // asset as the super fallback. Both return the resolved path including
+ // its file extension so callers can load it directly.
+ const QString variant = schemeVariantPath(prefix);
+ if (!variant.isEmpty()) {
+ return variant;
+ }
+ const QString resolvedPlain = findExisting(prefix.toString());
+ return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
+}
+
bool ThemeManager::isBuiltInTheme()
{
const auto themeName = SettingsCache::instance().getThemeName();
@@ -195,7 +239,7 @@ QStringMap &ThemeManager::getAvailableThemes()
QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
{
QBrush brush;
- QPixmap tmp = QPixmap("theme:zones/" + fileName);
+ QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName));
if (tmp.isNull()) {
brush.setColor(fallbackColor);
brush.setStyle(Qt::SolidPattern);
@@ -209,7 +253,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush)
{
QBrush brush;
- QPixmap tmp = QPixmap("theme:zones/" + fileName);
+ QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName));
if (tmp.isNull()) {
brush = fallbackBrush;
@@ -393,9 +437,19 @@ void ThemeManager::themeChangedSlot()
currentThemePath = dirPath;
QDir dir(dirPath);
- // CSS
- if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) {
- qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
+ // CSS — prefer the scheme-qualified stylesheet (style-dark.css /
+ // style-light.css) when present, else the plain style.css as fallback.
+ if (!dirPath.isEmpty()) {
+ const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light");
+ const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') +
+ scheme + QStringLiteral(".css");
+ if (dir.exists(schemeCss)) {
+ qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss));
+ } else if (dir.exists(STYLE_CSS_NAME)) {
+ qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
+ } else {
+ qApp->setStyleSheet("");
+ }
} else {
qApp->setStyleSheet("");
}
@@ -446,6 +500,7 @@ void ThemeManager::themeChangedSlot()
}
QPixmapCache::clear();
+ clearPixmapGeneratorCaches();
emit themeChanged();
}
diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h
index 79a1b6470..ac35042a0 100644
--- a/cockatrice/src/interface/theme_manager.h
+++ b/cockatrice/src/interface/theme_manager.h
@@ -87,6 +87,20 @@ public:
// Load/save per-scheme palette colors
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
+ // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed
+ // internally across the formats themes may ship (.png/.jpg/.svg), so
+ // callers load the returned path directly. Prefers "-"
+ // when a file exists at that stem, otherwise the plain "" as the
+ // super fallback. The resolved scheme covers explicit light/dark as well
+ // as OS-resolved "system". Returns the path with its file extension when a
+ // match is found; unqualified assets keep working unchanged.
+ QString assetPath(QStringView prefix) const;
+ // Like assetPath, but resolves only the scheme-qualified variant
+ // ("-.") and returns an empty string when no
+ // variant exists — it never falls back to the plain "" asset.
+ // Callers that must distinguish "no authored variant" (e.g. to keep a
+ // legacy runtime fallback alive) should use this instead of assetPath.
+ QString schemeVariantPath(QStringView prefix) const;
// Load the theme's shipped default palette, falling back to the system
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig
diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
new file mode 100644
index 000000000..b26b73593
--- /dev/null
+++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
@@ -0,0 +1,18 @@
+#include "card_art_utils.h"
+
+#include
+#include
+
+namespace CardArtUtils
+{
+QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card)
+{
+ if (!card.getInfo().getUiAttributes().landscapeOrientation) {
+ return art;
+ }
+
+ QTransform transform;
+ transform.rotate(90);
+ return art.transformed(transform, Qt::SmoothTransformation);
+}
+} // namespace CardArtUtils
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h
new file mode 100644
index 000000000..5c331a12c
--- /dev/null
+++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h
@@ -0,0 +1,25 @@
+#ifndef CARD_ART_UTILS_H
+#define CARD_ART_UTILS_H
+
+#include
+
+class ExactCard;
+
+namespace CardArtUtils
+{
+/**
+ * @brief Rotates a card's art upright when its layout shows sideways.
+ *
+ * Sideways-layout cards (planes, sieges/battles, split cards) store their
+ * landscape artwork rotated 90° inside a portrait frame. Art-crop displays,
+ * playmat art, and the card-info picture must show such art upright before
+ * sampling or painting. Portrait cards are returned unchanged.
+ *
+ * @param art The card pixmap to orient.
+ * @param card The card describing the art orientation.
+ * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art.
+ */
+QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card);
+} // namespace CardArtUtils
+
+#endif // CARD_ART_UTILS_H
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
index 79ae087d7..de622bdc8 100644
--- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
+++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
@@ -5,6 +5,7 @@
#include "../../../interface/card_picture_loader/card_picture_loader.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../window_main.h"
+#include "card_art_utils.h"
#include
#include
@@ -193,12 +194,7 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
QPixmap transformedPixmap = resizedPixmap; // Default pixmap
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
- if (exactCard.getInfo().getUiAttributes().landscapeOrientation) {
- // Rotate pixmap 90 degrees to the left
- QTransform transform;
- transform.rotate(90);
- transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation);
- }
+ transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard);
}
// Handle DPI scaling
diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp
index 089abc5c8..483fc71b0 100644
--- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp
@@ -1,5 +1,6 @@
#include "abstract_analytics_panel_widget.h"
+#include "../../pixel_map_generator.h"
#include "deck_list_statistics_analyzer.h"
#include
@@ -20,7 +21,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck
// config button
configureButton = new QPushButton(this);
- configureButton->setIcon(QPixmap("theme:icons/cogwheel"));
+ configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog);
bannerAndSettingsLayout->addWidget(configureButton, 0);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp
index 9da821813..c8bdacf0d 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp
@@ -28,7 +28,7 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
- auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
+ auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
setFocusProxy(searchEdit);
setFocusPolicy(Qt::ClickFocus);
@@ -59,13 +59,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
aAddCard = new QAction(QString(), this);
- aAddCard->setIcon(QPixmap("theme:icons/arrow_right_green"));
+ aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green")));
connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck);
auto *tbAddCard = new QToolButton(this);
tbAddCard->setDefaultAction(aAddCard);
aAddCardToSideboard = new QAction(QString(), this);
- aAddCardToSideboard->setIcon(QPixmap("theme:icons/arrow_right_blue"));
+ aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue")));
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
auto *tbAddCardToSideboard = new QToolButton(this);
tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
index e2175a358..a3653f03e 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../../pixel_map_generator.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../settings_page/user_interface_settings_page.h"
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
@@ -192,25 +193,25 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
aIncrement = new QAction(QString(), this);
- aIncrement->setIcon(QPixmap("theme:icons/increment"));
+ aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement);
aDecrement = new QAction(QString(), this);
- aDecrement->setIcon(QPixmap("theme:icons/decrement"));
+ aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement);
aRemoveCard = new QAction(QString(), this);
- aRemoveCard->setIcon(QPixmap("theme:icons/remove_row"));
+ aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard);
aSwapCard = new QAction(QString(), this);
- aSwapCard->setIcon(QPixmap("theme:icons/swap"));
+ aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap")));
connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection);
auto *tbSwapCard = new QToolButton(this);
tbSwapCard->setDefaultAction(aSwapCard);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp
index 2d4fb60e8..c6b6e4416 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp
@@ -4,6 +4,7 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../filters/filter_builder.h"
#include "../../../filters/filter_tree_model.h"
+#include "../../pixel_map_generator.h"
#include
#include
@@ -42,11 +43,11 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
aClearFilterOne = new QAction(QString(), this);
- aClearFilterOne->setIcon(QPixmap("theme:icons/decrement"));
+ aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
aClearFilterAll = new QAction(QString(), this);
- aClearFilterAll->setIcon(QPixmap("theme:icons/clearsearch"));
+ aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll);
auto *filterDelOne = new QToolButton();
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp
index cef459752..c93f12b34 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp
@@ -1,5 +1,6 @@
#include "deck_list_history_manager_widget.h"
+#include "../../pixel_map_generator.h"
#include "deck_state_manager.h"
DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager,
@@ -10,7 +11,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout = new QHBoxLayout(this);
aUndo = new QAction(QString(), this);
- aUndo->setIcon(QPixmap("theme:icons/arrow_undo"));
+ aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo")));
aUndo->setShortcut(QKeySequence::Undo);
aUndo->setShortcutContext(Qt::ApplicationShortcut);
connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo);
@@ -19,7 +20,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
undoButton->setDefaultAction(aUndo);
aRedo = new QAction(QString(), this);
- aRedo->setIcon(QPixmap("theme:icons/arrow_redo"));
+ aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo")));
aRedo->setShortcut(QKeySequence::Redo);
aRedo->setShortcutContext(Qt::ApplicationShortcut);
connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo);
@@ -31,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout->addWidget(redoButton);
historyButton = new SettingsButtonWidget(this);
- historyButton->setButtonIcon(QPixmap("theme:icons/arrow_history"));
+ historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history")));
historyLabel = new QLabel(this);
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
index aa8a916f8..700012d45 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
@@ -1,6 +1,7 @@
#include "dlg_connect.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include
#include
@@ -21,7 +22,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
- btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
+ btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
@@ -29,7 +30,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
- btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
+ btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync")));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
@@ -99,7 +100,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
updateDisplayInfo(previousHosts->currentText());
btnForgotPassword = new QPushButton(this);
- btnForgotPassword->setIcon(QPixmap("theme:icons/forgot_password"));
+ btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password")));
btnForgotPassword->setToolTip(tr("Reset Password"));
btnForgotPassword->setFixedWidth(30);
connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword);
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp
index f249976c2..a56aa8e35 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp
@@ -1,5 +1,6 @@
#include "dlg_edit_tokens.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include
@@ -90,10 +91,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged);
QAction *aAddToken = new QAction(tr("Add token"), this);
- aAddToken->setIcon(QPixmap("theme:icons/increment"));
+ aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
QAction *aRemoveToken = new QAction(tr("Remove token"), this);
- aRemoveToken->setIcon(QPixmap("theme:icons/decrement"));
+ aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
auto *databaseToolBar = new QToolBar;
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp
index 7c107eb2f..556c270be 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp
@@ -1,6 +1,7 @@
#include "dlg_manage_sets.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../interface/widgets/utility/custom_line_edit.h"
@@ -35,28 +36,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
aTop = new QAction(QString(), this);
- aTop->setIcon(QPixmap("theme:icons/arrow_top_green"));
+ aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green")));
aTop->setToolTip(tr("Move selected set to the top"));
aTop->setEnabled(false);
connect(aTop, &QAction::triggered, this, &WndSets::actTop);
setsEditToolBar->addAction(aTop);
aUp = new QAction(QString(), this);
- aUp->setIcon(QPixmap("theme:icons/arrow_up_green"));
+ aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green")));
aUp->setToolTip(tr("Move selected set up"));
aUp->setEnabled(false);
connect(aUp, &QAction::triggered, this, &WndSets::actUp);
setsEditToolBar->addAction(aUp);
aDown = new QAction(QString(), this);
- aDown->setIcon(QPixmap("theme:icons/arrow_down_green"));
+ aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green")));
aDown->setToolTip(tr("Move selected set down"));
aDown->setEnabled(false);
connect(aDown, &QAction::triggered, this, &WndSets::actDown);
setsEditToolBar->addAction(aDown);
aBottom = new QAction(QString(), this);
- aBottom->setIcon(QPixmap("theme:icons/arrow_bottom_green"));
+ aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green")));
aBottom->setToolTip(tr("Move selected set to the bottom"));
aBottom->setEnabled(false);
connect(aBottom, &QAction::triggered, this, &WndSets::actBottom);
@@ -66,7 +67,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
searchField = new LineEditUnfocusable;
searchField->setObjectName("searchEdit");
searchField->setPlaceholderText(tr("Search by set name, code, type, or release date"));
- searchField->addAction(QPixmap("theme:icons/search"), LineEditUnfocusable::LeadingPosition);
+ searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition);
searchField->setClearButtonEnabled(true);
setFocusProxy(searchField);
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp
index 6ae8c9adb..6d022861c 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp
@@ -1,6 +1,7 @@
#include "dlg_register.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../server/handle_public_servers.h"
#include "../server/user/user_info_connection.h"
@@ -24,7 +25,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
- btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
+ btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
@@ -32,7 +33,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
- btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
+ btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync")));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
index 4705acf65..fb559fc4b 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
@@ -6,6 +6,7 @@
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../main.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
@@ -96,7 +97,7 @@ void DlgSettings::setupUi()
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
- searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
+ searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
@@ -132,7 +133,7 @@ void DlgSettings::setupUi()
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
- pagesWidget->addWidget(messagesPage);
+ pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp
index 10fcdcb43..0d030b973 100644
--- a/cockatrice/src/interface/widgets/general/home_widget.cpp
+++ b/cockatrice/src/interface/widgets/general/home_widget.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
+#include "../../pixel_map_generator.h"
#include "../../theme_manager.h"
#include "../../window_main.h"
#include "../cards/art_crop_attribution.h"
@@ -20,7 +21,8 @@
#include
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
- : QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice")
+ : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))),
+ overlay(themePixmap(QStringLiteral("cockatrice")))
{
layout = new QGridLayout(this);
@@ -56,6 +58,9 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
&HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
+ // Scheme flips (light/dark/system with an OS switch) fire on themeManager,
+ // not on SettingsCache::themeChanged, so re-resolve the variant background.
+ connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
}
@@ -74,7 +79,7 @@ void HomeWidget::initializeBackgroundFromSource()
switch (backgroundSourceType) {
case BackgroundSources::Theme:
cardChangeTimer->stop();
- background = QPixmap("theme:backgrounds/home");
+ background = themePixmap(QStringLiteral("backgrounds/home"));
backgroundSourceDeck = DeckList();
backgroundSourceCard->setCard(ExactCard());
updateButtonsToBackgroundColor();
diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
index 618ac6f26..4af02fe4f 100644
--- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
+++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
@@ -182,6 +182,13 @@ void FirstRunWizard::onCardDatabaseUpdateFinished(bool success)
}
}
+void FirstRunWizard::onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total)
+{
+ if (cardDatabasePage) {
+ cardDatabasePage->onUpdateProgress(stage, done, total);
+ }
+}
+
void FirstRunWizard::finish()
{
accept();
diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
index 2c186ef95..21d7b6e06 100644
--- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
+++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
@@ -37,6 +37,9 @@ public slots:
/** @brief Forwarded from MainWindow once the background card database update process exits. */
void onCardDatabaseUpdateFinished(bool success);
+ /** @brief Forwarded from MainWindow while the background card database update process runs. */
+ void onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total);
+
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;
diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
index 12116de7a..50e8ff63d 100644
--- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
+++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include
#include
@@ -179,6 +180,25 @@ void CardDatabaseSetupPage::onUpdateFinished(bool success)
}
}
+void CardDatabaseSetupPage::onUpdateProgress(const QString &stage, qint64 done, qint64 total)
+{
+ if (state != State::Running) {
+ return;
+ }
+ progressBar->setRange(0, total > 0 ? static_cast(qMin(total, INT_MAX)) : 0);
+ progressBar->setValue(static_cast(qMin(done, INT_MAX)));
+ if (total > 0) {
+ const int percent = static_cast((100.0 * done) / total);
+ if (stage == QLatin1String("download")) {
+ statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent));
+ } else if (stage == QLatin1String("scan")) {
+ statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent));
+ } else if (stage == QLatin1String("import")) {
+ statusLabel->setText(tr("Importing cards (%1%)…").arg(percent));
+ }
+ }
+}
+
QString CardDatabaseSetupPage::nextButtonText() const
{
return state == State::NotStarted ? tr("Download") : QString();
diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
index 0461d11d5..870e759ea 100644
--- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
+++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
@@ -30,6 +30,7 @@ public:
void retranslateUi() override;
void onUpdateFinished(bool success);
+ void onUpdateProgress(const QString &stage, qint64 done, qint64 total);
signals:
void updateRequested();
diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
index 57706cf93..9459c5ea9 100644
--- a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
+++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
@@ -2,6 +2,7 @@
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/art_crop_attribution.h"
+#include "../cards/card_art_utils.h"
#include "../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
@@ -276,7 +277,7 @@ void PlaymatSettingsDialog::reloadPreview()
return;
}
- currentPixmap = fullRes;
+ currentPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
preview->setAttribution(buildArtAttribution(card));
diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp
index 9c433ab5a..881c3e3ac 100644
--- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp
+++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp
@@ -1,5 +1,7 @@
#include "settings_button_widget.h"
+#include "../../pixel_map_generator.h"
+
#include
#include
#include
@@ -8,7 +10,7 @@
SettingsButtonWidget::SettingsButtonWidget(QWidget *parent)
: QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr))
{
- button->setIcon(QPixmap("theme:icons/cogwheel"));
+ button->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
button->setCheckable(true);
button->setFixedSize(32, 32);
connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup);
diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
index a2c1e0ff0..c51b96b6c 100644
--- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
@@ -142,8 +142,10 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode)
}
// backwards skip => always skip tap animation
+ // backwards skip => always skip damage animation (battlefield shimmer / life counter flash)
if (playbackMode == BACKWARD_SKIP) {
options |= SKIP_TAP_ANIMATION;
+ options |= SKIP_DAMAGE_ANIMATION;
}
emit eventReplayed(replay->event_list(currentEvent), options);
diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp
index 6c85d950e..c92771f8c 100644
--- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/tabs/tab_game.h"
#include "replay_manager.h"
#include "replay_quick_settings_widget.h"
@@ -50,15 +51,15 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
replayPlayButton = new QToolButton;
replayPlayButton->setIconSize(QSize(32, 32));
QIcon playButtonIcon = QIcon();
- playButtonIcon.addPixmap(QPixmap("theme:replay/start"), QIcon::Normal, QIcon::Off);
- playButtonIcon.addPixmap(QPixmap("theme:replay/pause"), QIcon::Normal, QIcon::On);
+ playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/start")), QIcon::Normal, QIcon::Off);
+ playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/pause")), QIcon::Normal, QIcon::On);
replayPlayButton->setIcon(playButtonIcon);
replayPlayButton->setCheckable(true);
connect(replayPlayButton, &QToolButton::toggled, this, &ReplayWidget::replayPlayButtonToggled);
replayFastForwardButton = new QToolButton;
replayFastForwardButton->setIconSize(QSize(32, 32));
- replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward"));
+ replayFastForwardButton->setIcon(themePixmap(QStringLiteral("replay/fastforward")));
replayFastForwardButton->setCheckable(true);
connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor);
diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp
index f41002247..a8bf54e91 100644
--- a/cockatrice/src/interface/widgets/server/game_selector.cpp
+++ b/cockatrice/src/interface/widgets/server/game_selector.cpp
@@ -1,5 +1,6 @@
#include "game_selector.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/dialogs/dlg_filter_games.h"
#include "../interface/widgets/tabs/tab_account.h"
@@ -95,10 +96,10 @@ GameSelector::GameSelector(AbstractClient *_client,
}
filterButton = new QPushButton;
- filterButton->setIcon(QPixmap("theme:icons/search"));
+ filterButton->setIcon(themePixmap(QStringLiteral("icons/search")));
connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter);
clearFilterButton = new QPushButton;
- clearFilterButton->setIcon(QPixmap("theme:icons/clearsearch"));
+ clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults();
clearFilterButton->setEnabled(!filtersSetToDefault);
connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter);
diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp
index 1f034b767..d62bf81aa 100644
--- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp
+++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp
@@ -1,5 +1,7 @@
#include "remote_replay_list_tree_widget.h"
+#include "../../../pixel_map_generator.h"
+
#include
#include
#include
@@ -37,7 +39,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client,
QFileIconProvider fip;
dirIcon = fip.icon(QFileIconProvider::Folder);
fileIcon = fip.icon(QFileIconProvider::File);
- lockIcon = QPixmap("theme:icons/lock");
+ lockIcon = themePixmap(QStringLiteral("icons/lock"));
}
RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel()
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
index 3a1876fa1..2ba745715 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
@@ -1,6 +1,7 @@
#include "user_card_art_provider.h"
#include "../../../card_picture_loader/card_picture_loader.h"
+#include "../../cards/card_art_utils.h"
#include
#include
@@ -52,16 +53,25 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString
processQueue();
}
-QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes)
+QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card)
{
- const QSize sz = fullRes.size();
+ QPixmap source = fullRes;
+
+ // Sideways-layout cards (plane, siege/battle, split) store their landscape
+ // artwork rotated 90° inside a portrait frame. Rotate it upright first so
+ // the crop below lands on the horizontal art, mirroring the way
+ // CardInfoPictureWidget displays these cards.
+ const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation;
+ source = CardArtUtils::rotateSidewaysLayoutArt(source, card);
+
+ const QSize sz = source.size();
const int marginX = sz.width() * 0.07;
- const int topMargin = sz.height() * 0.11;
- const int bottomMargin = sz.height() * 0.45;
+ const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11;
+ const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45;
- const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
+ const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
- return fullRes.copy(foilRect.intersected(fullRes.rect()));
+ return source.copy(artRect.intersected(source.rect()));
}
void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap)
@@ -111,7 +121,7 @@ void UserCardArtProvider::processQueue()
// Synchronous hit (already loaded/on disk)
if (!fullRes.isNull()) {
- insertIntoCache(key, cropCardArt(fullRes));
+ insertIntoCache(key, cropCardArt(fullRes, card));
pending.remove(key);
emit cardArtUpdated(userName);
@@ -135,7 +145,7 @@ void UserCardArtProvider::processQueue()
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (!fullRes.isNull()) {
- self->insertIntoCache(key, self->cropCardArt(fullRes));
+ self->insertIntoCache(key, self->cropCardArt(fullRes, card));
}
self->pending.remove(key);
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
index 2592237c4..e8283a891 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
+++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
class UserCardArtProvider : public QObject
{
@@ -16,7 +17,7 @@ public:
void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId);
const QMap &cache() const;
- static QPixmap cropCardArt(const QPixmap &fullRes);
+ static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card);
signals:
void cardArtUpdated(const QString &userName);
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
index 532112964..d49e3d540 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
@@ -560,7 +560,7 @@ void UserCardArtSettingsDialog::reloadPreview()
return;
}
- currentPixmap = UserCardArtProvider::cropCardArt(fullRes);
+ currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
index 8d5d423f6..0d2267a63 100644
--- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
@@ -51,6 +51,8 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDemoteFromMod = new QAction(QString(), this);
aPromoteToJudge = new QAction(QString(), this);
aDemoteFromJudge = new QAction(QString(), this);
+ aPromoteToDeveloper = new QAction(QString(), this);
+ aDemoteFromDeveloper = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
@@ -76,6 +78,8 @@ void UserContextMenu::retranslateUi()
aDemoteFromMod->setText(tr("Dem&ote user from moderator"));
aPromoteToJudge->setText(tr("Promote user to &judge"));
aDemoteFromJudge->setText(tr("Demote user from judge"));
+ aPromoteToDeveloper->setText(tr("Promote user to &developer"));
+ aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
}
@@ -268,7 +272,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const
const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext);
if (resp.response_code() == Response::RespOk) {
- if (cmd.should_be_mod() || cmd.should_be_judge()) {
+ if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) {
QMessageBox::information(static_cast(parent()), tr("Success"),
tr("Successfully promoted user."));
} else {
@@ -276,7 +280,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const
}
} else {
- if (cmd.should_be_mod() || cmd.should_be_judge()) {
+ if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) {
QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to promote user."));
} else {
QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to demote user."));
@@ -437,9 +441,18 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
(tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
menu->addAction(aPromoteToJudge);
}
+
+ if (userLevel.testFlag(ServerInfo_User::IsDeveloper) &&
+ (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
+ menu->addAction(aDemoteFromDeveloper);
+
+ } else if (userLevel.testFlag(ServerInfo_User::IsRegistered) &&
+ (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
+ menu->addAction(aPromoteToDeveloper);
+ }
}
aDetails->setEnabled(true);
- aChat->setEnabled(anotherUser && online);
+ aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser);
@@ -455,6 +468,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aInvestigateUser->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
+ aPromoteToJudge->setEnabled(anotherUser);
+ aDemoteFromJudge->setEnabled(anotherUser);
+ aPromoteToDeveloper->setEnabled(anotherUser);
+ aDemoteFromDeveloper->setEnabled(anotherUser);
QAction *actionClicked = menu->exec(pos);
if (actionClicked == nullptr) {
@@ -489,6 +506,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execAdjustMod(userName, actionClicked == aPromoteToMod);
} else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) {
execAdjustJudge(userName, actionClicked == aPromoteToJudge);
+ } else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) {
+ execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper);
} else if (actionClicked == aBanHistory) {
execBanHistory(userName);
} else if (actionClicked == aWarnUser) {
@@ -606,7 +625,15 @@ void UserContextMenu::execAddToIgnore(const QString &userName)
Command_AddToList cmd;
cmd.set_list("ignore");
cmd.set_user_name(userName.toStdString());
- client->sendCommand(client->prepareSessionCommand(cmd));
+ PendingCommand *pend = client->prepareSessionCommand(cmd);
+ connect(pend, &PendingCommand::finished, this,
+ [this, userName](const Response &response, const CommandContainer &, const QVariant &) {
+ if (response.response_code() == Response::RespOk) {
+ QMessageBox::information(static_cast(parent()), tr("Ignore list"),
+ tr("%1 has been added to your ignore list.").arg(userName));
+ }
+ });
+ client->sendCommand(pend);
}
void UserContextMenu::execRemoveFromIgnore(const QString &userName)
@@ -698,4 +725,14 @@ void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudg
PendingCommand *pend = client->prepareAdminCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse);
client->sendCommand(pend);
-}
\ No newline at end of file
+}
+
+void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper)
+{
+ Command_AdjustMod cmd;
+ cmd.set_user_name(userName.toStdString());
+ cmd.set_should_be_developer(shouldBeDeveloper);
+ PendingCommand *pend = client->prepareAdminCommand(cmd);
+ connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse);
+ client->sendCommand(pend);
+}
diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h
index f1ce931f8..6abbc057a 100644
--- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h
+++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h
@@ -45,6 +45,7 @@ private:
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
+ QAction *aPromoteToDeveloper, *aDemoteFromDeveloper;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function()> gameInviteLinkProvider;
@@ -123,6 +124,7 @@ public:
void execInvestigateUser(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
+ void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);
private:
void execInvite(const QString &userName, const GameInviteOption &option);
diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
index 875bdfb05..3d89cecf5 100644
--- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp
@@ -122,6 +122,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user)
QString userLevelText;
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
userLevelText = tr("Administrator");
+ } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
+ userLevelText = tr("Developer");
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
userLevelText = tr("Moderator");
} else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {
diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
index fb610e814..8be76eea0 100644
--- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp
@@ -245,6 +245,9 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
if (level.testFlag(ServerInfo_User::IsAdmin)) {
return QColor(245, 158, 11);
}
+ if (level.testFlag(ServerInfo_User::IsDeveloper)) {
+ return QColor(185, 28, 28);
+ }
if (level.testFlag(ServerInfo_User::IsModerator)) {
return QColor(59, 130, 246);
}
@@ -300,6 +303,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
} badge;
if (level.testFlag(ServerInfo_User::IsAdmin)) {
badge = {"ADMIN", QColor(245, 158, 11)};
+ } else if (level.testFlag(ServerInfo_User::IsDeveloper)) {
+ badge = {"DEV", QColor(185, 28, 28)};
} else if (level.testFlag(ServerInfo_User::IsModerator)) {
badge = {"MOD", QColor(59, 130, 246)};
} else if (level.testFlag(ServerInfo_User::IsJudge)) {
diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
index 5c65b090d..34a3d6ae1 100644
--- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp
@@ -49,6 +49,8 @@ QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool onl
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
accentColor = QColor(245, 158, 11);
+ } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
+ accentColor = QColor(185, 28, 28);
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
accentColor = QColor(59, 130, 246);
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
@@ -299,6 +301,8 @@ QList UserListPainter::buildBadges(const UserLevelFlags
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
badges << Badge{"ADMIN", QColor(245, 158, 11)};
+ } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
+ badges << Badge{"DEV", QColor(185, 28, 28)};
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
badges << Badge{"MOD", QColor(59, 130, 246)};
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
@@ -385,9 +389,9 @@ void UserListPainter::paint(QPainter *painter,
const QString userName = QString::fromStdString(userInfo.name());
const QString privLevel = QString::fromStdString(userInfo.privlevel());
const QColor accentColor = getAccentColor(userLevel, online);
- const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) ||
- userLevel.testFlag(ServerInfo_User::IsModerator) ||
- userLevel.testFlag(ServerInfo_User::IsJudge);
+ const bool hasRole =
+ userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) ||
+ userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
const int cardRight = getCardRight(option, rect);
diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp
index a8c99c979..1bb7c5288 100644
--- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp
@@ -234,9 +234,11 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt());
- // Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator
+ // Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer
+ // or a moderator
static const QList userLevelWithNoOtherPrefOrder = {
- ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator};
+ ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper,
+ ServerInfo_User_UserLevelFlag_IsModerator};
for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) {
if (lhsUserLevelFlags.testFlag(userLevelEntry) &&
lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) {
diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp
index f425afe60..f3eac05b8 100644
--- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp
@@ -1,6 +1,7 @@
#include "deck_editor_settings_page.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "update/card_spoiler/spoiler_background_updater.h"
#include
@@ -53,15 +54,15 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
urlList->addItems(SettingsCache::instance().downloads().getAllURLs());
aAdd = new QAction(this);
- aAdd->setIcon(QPixmap("theme:icons/increment"));
+ aAdd->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL);
aEdit = new QAction(this);
- aEdit->setIcon(QPixmap("theme:icons/pencil"));
+ aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL);
aRemove = new QAction(this);
- aRemove->setIcon(QPixmap("theme:icons/decrement"));
+ aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
auto *urlToolBar = new QToolBar;
diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
index e4f24ab73..a3b89f8c4 100644
--- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
@@ -1,6 +1,7 @@
#include "messages_settings_page.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include
@@ -59,6 +60,10 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
&ChatSettings::setRoomHistory);
+ ignoreAllPrivateMessagesCheckBox.setChecked(SettingsCache::instance().chat().getIgnoreAllPrivateMessages());
+ connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
+ &ChatSettings::setIgnoreAllPrivateMessages);
+
customAlertString = new QLineEdit();
customAlertString->setText(SettingsCache::instance().chat().getHighlightWords());
connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(),
@@ -76,6 +81,7 @@ MessagesSettingsPage::MessagesSettingsPage()
chatGrid->addWidget(&messagePopups, 5, 0);
chatGrid->addWidget(&mentionPopups, 6, 0);
chatGrid->addWidget(&roomHistory, 7, 0);
+ chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatGrid);
@@ -102,15 +108,15 @@ MessagesSettingsPage::MessagesSettingsPage()
}
aAdd = new QAction(this);
- aAdd->setIcon(QPixmap("theme:icons/increment"));
+ aAdd->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd);
aEdit = new QAction(this);
- aEdit->setIcon(QPixmap("theme:icons/pencil"));
+ aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit);
aRemove = new QAction(this);
- aRemove->setIcon(QPixmap("theme:icons/decrement"));
+ aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove);
auto *messageToolBar = new QToolBar;
@@ -256,6 +262,7 @@ void MessagesSettingsPage::retranslateUi()
messagePopups.setText(tr("Enable desktop notifications for private messages"));
mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join"));
+ ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
index e98ae0592..436ebbad9 100644
--- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
@@ -40,6 +40,7 @@ private:
QCheckBox messagePopups;
QCheckBox mentionPopups;
QCheckBox roomHistory;
+ QCheckBox ignoreAllPrivateMessagesCheckBox;
QGroupBox *chatGroupBox;
QGroupBox *highlightGroupBox;
QGroupBox *messageGroupBox;
diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp
index 1f1867f7c..1277d0e49 100644
--- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp
@@ -3,6 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcut_treeview.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include "../interface/widgets/utility/sequence_edit.h"
@@ -47,8 +48,8 @@ ShortcutSettingsPage::ShortcutSettingsPage()
btnResetAll = new QPushButton(this);
btnClearAll = new QPushButton(this);
- btnResetAll->setIcon(QPixmap("theme:icons/update"));
- btnClearAll->setIcon(QPixmap("theme:icons/clearsearch"));
+ btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update")));
+ btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
// layout
auto *_editLayout = new QGridLayout;
diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
index 98b21d0f1..888197f53 100644
--- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
+++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
@@ -1,6 +1,7 @@
#include "tab_archidekt.h"
#include "../../../../../client/settings/cache_settings.h"
+#include "../../../../pixel_map_generator.h"
#include "../../../cards/additional_info/mana_symbol_widget.h"
#include "../../../utility/completer_utils.h"
#include "../../tab_supervisor.h"
@@ -213,7 +214,7 @@ void TabArchidekt::setupFilterWidgets()
// Format filter (collapsible)
formatButton = new SettingsButtonWidget(secondaryToolbar);
formatButton->setButtonText(tr("Formats"));
- formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced"));
+ formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced")));
QWidget *formatContainer = new QWidget(secondaryToolbar);
QGridLayout *formatLayout = new QGridLayout(formatContainer);
diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
index 4f50e38a6..2cbbedfa4 100644
--- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
+++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
@@ -1,6 +1,7 @@
#include "commander_bracket_widget.h"
#include "../../../../../client/settings/cache_settings.h"
+#include "../../../../pixel_map_generator.h"
#include "commander_bracket_service.h"
#include
@@ -30,7 +31,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent
bracketInfoButton->setEnabled(false);
bracketRefreshButton = new QToolButton(this);
- bracketRefreshButton->setIcon(QPixmap("theme:icons/reload"));
+ bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload")));
bracketRefreshButton->setAutoRaise(true);
connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp
index 410a48d40..dbcf50966 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp
@@ -137,6 +137,11 @@ void TabAccount::retranslateUi()
buddyList->retranslateUi();
ignoreList->retranslateUi();
userInfoBox->retranslateUi();
+
+ buddyList->setToolTip(tr("Buddies are marked with a star in chat, a sound plays when they join or leave the "
+ "server, and they can be invited to buddy-only games."));
+ ignoreList->setToolTip(tr("Ignored users' chat messages are hidden from you, and they cannot send you private "
+ "messages or join your games."));
}
void TabAccount::processListUsersResponse(const Response &response)
diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp
index 49e42e4cf..f3535d850 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "../interface/widgets/utility/get_text_with_max.h"
@@ -105,19 +106,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
// Left side actions
aOpenLocalDeck = new QAction(this);
- aOpenLocalDeck->setIcon(QPixmap("theme:icons/pencil"));
+ aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck);
aRenameLocal = new QAction(this);
- aRenameLocal->setIcon(QPixmap("theme:icons/rename"));
+ aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename")));
connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal);
aUpload = new QAction(this);
- aUpload->setIcon(QPixmap("theme:icons/arrow_right_green"));
+ aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green")));
connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload);
aNewLocalFolder = new QAction(this);
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
aDeleteLocalDeck = new QAction(this);
- aDeleteLocalDeck->setIcon(QPixmap("theme:icons/remove_row"));
+ aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck);
aOpenDecksFolder = new QAction(this);
@@ -126,16 +127,16 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
// Right side actions
aOpenRemoteDeck = new QAction(this);
- aOpenRemoteDeck->setIcon(QPixmap("theme:icons/pencil"));
+ aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck);
aDownload = new QAction(this);
- aDownload->setIcon(QPixmap("theme:icons/arrow_left_green"));
+ aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green")));
connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload);
aNewFolder = new QAction(this);
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
aDeleteRemoteDeck = new QAction(this);
- aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row"));
+ aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
// Add actions to toolbars
diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp
new file mode 100644
index 000000000..39652ef7d
--- /dev/null
+++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp
@@ -0,0 +1,261 @@
+/**
+ * @file tab_developer.cpp
+ * @ingroup ServerTabs
+ */
+//! \todo Document this file.
+
+#include "tab_developer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static constexpr int DEFAULT_AUTO_REFRESH_INTERVAL_SECS = 30;
+
+TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client)
+ : Tab(_tabSupervisor), client(_client)
+{
+ statsTable = new QTableWidget(0, 2);
+ statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ statsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
+ statsTable->setSelectionMode(QAbstractItemView::SingleSelection);
+ statsTable->verticalHeader()->setVisible(false);
+ statsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive);
+ statsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive);
+ statsTable->horizontalHeader()->setStretchLastSection(true);
+
+ commandTable = new QTableWidget(0, 4);
+ commandTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ commandTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ commandTable->setSelectionBehavior(QAbstractItemView::SelectRows);
+ commandTable->setSelectionMode(QAbstractItemView::SingleSelection);
+ commandTable->verticalHeader()->setVisible(false);
+ commandTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive);
+ commandTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive);
+ commandTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Interactive);
+ commandTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Interactive);
+
+ statusLabel = new QLabel;
+
+ autoRefreshCheckBox = new QCheckBox;
+ autoRefreshCheckBox->setChecked(false);
+
+ refreshIntervalSpinBox = new QSpinBox;
+ refreshIntervalSpinBox->setRange(5, 3600);
+ refreshIntervalSpinBox->setValue(DEFAULT_AUTO_REFRESH_INTERVAL_SECS);
+ refreshIntervalSpinBox->setEnabled(false);
+
+ autoRefreshTimer = new QTimer(this);
+ connect(autoRefreshTimer, &QTimer::timeout, this, &TabDeveloper::refreshClicked);
+ connect(autoRefreshCheckBox, &QCheckBox::toggled, this, &TabDeveloper::autoRefreshToggled);
+ connect(refreshIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), this,
+ &TabDeveloper::refreshIntervalChanged);
+
+ refreshButton = new QPushButton;
+ refreshButton->setAutoDefault(true);
+ connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked);
+
+ auto *buttonLayout = new QHBoxLayout;
+ buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft);
+ buttonLayout->addWidget(autoRefreshCheckBox, 0, Qt::AlignRight);
+ buttonLayout->addWidget(refreshIntervalSpinBox, 0, Qt::AlignRight);
+ buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight);
+
+ auto *tableLayout = new QHBoxLayout;
+ tableLayout->addWidget(statsTable, 1);
+ tableLayout->addWidget(commandTable, 2);
+
+ auto *mainLayout = new QVBoxLayout;
+ mainLayout->addLayout(tableLayout, 1);
+ mainLayout->addLayout(buttonLayout);
+
+ auto *central = new QWidget;
+ central->setLayout(mainLayout);
+ setCentralWidget(central);
+
+ retranslateUi();
+}
+
+void TabDeveloper::retranslateUi()
+{
+ autoRefreshCheckBox->setText(tr("Auto-refresh"));
+ autoRefreshCheckBox->setToolTip(tr("Automatically request fresh server statistics at a fixed interval."));
+ refreshIntervalSpinBox->setSuffix(tr(" s"));
+ refreshIntervalSpinBox->setToolTip(tr("Seconds between automatic refreshes."));
+ refreshButton->setText(tr("Refresh server stats"));
+ statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";"));
+ commandTable->setHorizontalHeaderLabels(QString(tr("Command;Count;Total ms;Avg ms")).split(";"));
+ if (statsTable->rowCount() == 0) {
+ statusLabel->clear();
+ }
+}
+
+QString TabDeveloper::formatBytes(quint64 bytes)
+{
+ const quint64 kib = 1024;
+ const quint64 mib = 1024 * kib;
+ const quint64 gib = 1024 * mib;
+ if (bytes >= gib) {
+ return tr("%1 GiB").arg(QString::number(bytes / static_cast(gib), 'f', 2));
+ }
+ if (bytes >= mib) {
+ return tr("%1 MiB").arg(QString::number(bytes / static_cast(mib), 'f', 2));
+ }
+ if (bytes >= kib) {
+ return tr("%1 KiB").arg(QString::number(bytes / static_cast(kib), 'f', 2));
+ }
+ return tr("%1 bytes").arg(bytes);
+}
+
+QString TabDeveloper::formatDurationMs(qint64 ms)
+{
+ if (ms >= 1000) {
+ return tr("%1 s").arg(QString::number(ms / 1000.0, 'f', 2));
+ }
+ return tr("%1 ms").arg(ms);
+}
+
+void TabDeveloper::appendStatRow(const QString &name, const QString &value)
+{
+ const int row = statsTable->rowCount();
+ statsTable->insertRow(row);
+ statsTable->setItem(row, 0, new QTableWidgetItem(name));
+ statsTable->setItem(row, 1, new QTableWidgetItem(value));
+}
+
+void TabDeveloper::appendSeparatorRow(const QString §ionTitle)
+{
+ const int row = statsTable->rowCount();
+ statsTable->insertRow(row);
+ auto *labelItem = new QTableWidgetItem(sectionTitle);
+ auto font = labelItem->font();
+ font.setBold(true);
+ labelItem->setFont(font);
+ labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable);
+ statsTable->setItem(row, 0, labelItem);
+ statsTable->setItem(row, 1, new QTableWidgetItem(QString()));
+}
+
+void TabDeveloper::refreshClicked()
+{
+ if (requestPending) {
+ return;
+ }
+ requestPending = true;
+ Command_GetServerStats cmd;
+ PendingCommand *pend = client->prepareDeveloperCommand(cmd);
+ connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse);
+ client->sendCommand(pend);
+}
+
+void TabDeveloper::autoRefreshToggled(bool checked)
+{
+ refreshIntervalSpinBox->setEnabled(checked);
+ if (checked) {
+ refreshIntervalChanged();
+ refreshClicked();
+ } else {
+ autoRefreshTimer->stop();
+ }
+}
+
+void TabDeveloper::refreshIntervalChanged()
+{
+ if (autoRefreshCheckBox->isChecked()) {
+ autoRefreshTimer->start(refreshIntervalSpinBox->value() * 1000);
+ }
+}
+
+void TabDeveloper::serverStatsResponse(const Response &resp)
+{
+ requestPending = false;
+ if (resp.response_code() != Response::RespOk) {
+ statusLabel->setText(tr("No server statistics available yet."));
+ return;
+ }
+
+ const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext);
+
+ statsTable->setRowCount(0);
+
+ // Overview section
+ appendStatRow(tr("Registered users online"), QString::number(response.users_count()));
+ appendStatRow(tr("Moderators online"), QString::number(response.mods_count()));
+ appendStatRow(tr("Games running"), QString::number(response.games_count()));
+ appendStatRow(tr("Traffic sent (last tick)"), formatBytes(response.tx_bytes()));
+ appendStatRow(tr("Traffic received (last tick)"), formatBytes(response.rx_bytes()));
+
+ const qint64 uptime = static_cast(response.uptime_secs());
+ const int days = static_cast(uptime / 86400);
+ const int hours = static_cast((uptime % 86400) / 3600);
+ const int minutes = static_cast((uptime % 3600) / 60);
+ appendStatRow(tr("Server uptime"), days > 0 ? tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes)
+ : tr("%1h %2m").arg(hours).arg(minutes));
+
+ const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest()));
+ appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm"));
+
+ // Live metrics section
+ appendSeparatorRow(tr("Live Metrics"));
+ appendStatRow(tr("Cards in live games"), QString::number(response.cards_in_games()));
+ appendStatRow(tr("Total commands processed"), QString::number(response.total_commands()));
+
+ if (response.total_commands() > 0) {
+ const double avgMs = static_cast(response.total_command_time_ms()) / response.total_commands();
+ appendStatRow(tr("Avg command time"), QString::number(avgMs, 'f', 2) + " ms");
+ }
+ appendStatRow(tr("Active command types"), QString::number(response.active_command_types()));
+
+ appendStatRow(tr("Event loop stalls"), QString::number(response.eventloop_stalls_total()));
+ appendStatRow(tr("Last stall overshoot"), formatDurationMs(response.eventloop_last_stall_ms()));
+ appendStatRow(tr("Worst stall overshoot"), formatDurationMs(response.eventloop_max_stall_ms()));
+
+ if (response.game_start_count() > 0) {
+ appendStatRow(tr("Game starts"), QString::number(response.game_start_count()));
+ const double avgStartMs = static_cast(response.game_start_total_ms()) / response.game_start_count();
+ appendStatRow(tr("Avg game start time"), QString::number(avgStartMs, 'f', 1) + " ms");
+ }
+
+ // Per-command breakdown table
+ QList sortedStats(response.command_stats().begin(), response.command_stats().end());
+ std::sort(sortedStats.begin(), sortedStats.end(),
+ [](const auto &a, const auto &b) { return a.total_ms() > b.total_ms(); });
+
+ commandTable->setRowCount(0);
+ for (const auto &cs : sortedStats) {
+ const int row = commandTable->rowCount();
+ commandTable->insertRow(row);
+ commandTable->setItem(row, 0, new QTableWidgetItem(QString::fromStdString(cs.command_name())));
+
+ auto *countItem = new QTableWidgetItem(QString::number(cs.count()));
+ countItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
+ commandTable->setItem(row, 1, countItem);
+
+ auto *totalItem = new QTableWidgetItem(QString::number(cs.total_ms()));
+ totalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
+ commandTable->setItem(row, 2, totalItem);
+
+ const double avg = cs.count() > 0 ? static_cast(cs.total_ms()) / cs.count() : 0.0;
+ auto *avgItem = new QTableWidgetItem(QString::number(avg, 'f', 2));
+ avgItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
+ commandTable->setItem(row, 3, avgItem);
+ }
+ commandTable->resizeColumnsToContents();
+ statsTable->resizeColumnsToContents();
+ commandTable->resizeColumnsToContents();
+
+ statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm")));
+}
diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h
new file mode 100644
index 000000000..727a56bcb
--- /dev/null
+++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h
@@ -0,0 +1,55 @@
+/**
+ * @file tab_developer.h
+ * @ingroup ServerTabs
+ */
+//! \todo Document this file.
+
+#ifndef TAB_DEVELOPER_H
+#define TAB_DEVELOPER_H
+
+#include "tab.h"
+
+class AbstractClient;
+class QCheckBox;
+class QLabel;
+class QPushButton;
+class QSpinBox;
+class QTableWidget;
+class QTimer;
+class Response;
+
+class TabDeveloper : public Tab
+{
+ Q_OBJECT
+private:
+ AbstractClient *client;
+ QTableWidget *statsTable;
+ QTableWidget *commandTable;
+ QPushButton *refreshButton;
+ QLabel *statusLabel;
+ QCheckBox *autoRefreshCheckBox;
+ QSpinBox *refreshIntervalSpinBox;
+ QTimer *autoRefreshTimer;
+ bool requestPending = false;
+
+ void appendStatRow(const QString &name, const QString &value);
+ void appendSeparatorRow(const QString §ionTitle);
+ static QString formatBytes(quint64 bytes);
+ static QString formatDurationMs(qint64 ms);
+
+private slots:
+ void refreshClicked();
+ void serverStatsResponse(const Response &resp);
+ void autoRefreshToggled(bool checked);
+ void refreshIntervalChanged();
+
+public:
+ explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client);
+ void retranslateUi() override;
+ [[nodiscard]] QString getTabText() const override
+ {
+ return tr("Developer");
+ }
+};
+
+#endif
diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp
index 196ea4526..035ab1004 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp
@@ -266,6 +266,10 @@ void TabGame::resetChatAndPhase()
// reset phase markers
game->getGameState()->setCurrentPhase(-1);
+
+ // reset spectator state so the replay can rebuild it from the start
+ game->getPlayerManager()->clearSpectators();
+ playerListWidget->clearSpectators();
}
void TabGame::emitUserEvent()
diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp
index e3678a903..f73d06b57 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp
@@ -19,7 +19,8 @@
#include
#include
-TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
+TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands)
+ : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
{
roomTable = new QTableWidget();
roomTable->setColumnCount(6);
@@ -80,7 +81,9 @@ void TabLog::getClicked()
if (!mainRoom->isChecked() && !gameRoom->isChecked() && !privateChat->isChecked()) {
mainRoom->setChecked(true);
gameRoom->setChecked(true);
- privateChat->setChecked(true);
+ if (!canUseDeveloperCommands) {
+ privateChat->setChecked(true);
+ }
}
if (maximumResults->value() == 0) {
@@ -117,7 +120,15 @@ void TabLog::getClicked()
};
cmd.set_date_range(dateRange);
cmd.set_maximum_results(maximumResults->value());
- PendingCommand *pend = client->prepareModeratorCommand(cmd);
+
+ PendingCommand *pend;
+ if (canUseDeveloperCommands) {
+ // Developers query logs through the developer command family.
+ pend = client->prepareDeveloperCommand(cmd);
+ } else {
+ pend = client->prepareModeratorCommand(cmd);
+ }
+
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
client->sendCommand(pend);
}
@@ -171,6 +182,14 @@ void TabLog::createDock()
mainRoom = new QCheckBox(tr("Main Room"));
gameRoom = new QCheckBox(tr("Game Room"));
privateChat = new QCheckBox(tr("Private Chat"));
+ if (canUseDeveloperCommands) {
+ // Developers cannot query private conversations.
+ privateChat->setVisible(false);
+ // The developer family ignores the IP filter server-side, so showing
+ // the field would silently unfilter the result by it. Hide it.
+ labelFindIPAddress->setVisible(false);
+ findIPAddress->setVisible(false);
+ }
pastDays = new QRadioButton(tr("Past X Days: "));
today = new QRadioButton(tr("Today"));
diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h
index 5d164dc92..8e914ea64 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_logs.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h
@@ -33,6 +33,7 @@ class TabLog : public Tab
Q_OBJECT
private:
AbstractClient *client;
+ bool canUseDeveloperCommands;
QLabel *labelFindUserName, *labelFindIPAddress, *labelFindGameName, *labelFindGameID, *labelMessage, *labelMaximum,
*labelDescription;
LineEditUnfocusable *findUsername, *findIPAddress, *findGameName, *findGameID, *findMessage;
@@ -58,7 +59,7 @@ private slots:
void restartLayout();
public:
- TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client);
+ TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands = false);
~TabLog() override;
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp
index 9506d96f3..418843178 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp
@@ -98,6 +98,12 @@ void TabMessage::closeEvent(QCloseEvent *event)
void TabMessage::sendPrivateMessage(const QString &text)
{
+ if (tabSupervisor->getUserListManager()->isUserIgnored(getUserName())) {
+ chatView->appendMessage(tr("You have ignored %1; your messages are not delivered.")
+ .arg(QString::fromStdString(otherUserInfo->name())));
+ return;
+ }
+
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(text.toStdString());
diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp
index 077b876d2..b9c37f1af 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp
@@ -381,6 +381,9 @@ void TabModeration::moderatorLoginsResponse(const Response &response)
if (login.user_level() & ServerInfo_User::IsAdmin) {
levels << tr("Admin");
}
+ if (login.user_level() & ServerInfo_User::IsDeveloper) {
+ levels << tr("Developer");
+ }
if (login.user_level() & ServerInfo_User::IsModerator) {
levels << tr("Moderator");
}
diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp
index 5618604df..ac4b2cbe9 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp
@@ -1,6 +1,7 @@
#include "tab_replays.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h"
#include "tab_game.h"
@@ -102,17 +103,17 @@ QGroupBox *TabReplays::createLeftLayout()
// Left side actions
aOpenLocalReplay = new QAction(this);
- aOpenLocalReplay->setIcon(QPixmap("theme:icons/view"));
+ aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view")));
connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay);
connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay);
aRenameLocal = new QAction(this);
- aRenameLocal->setIcon(QPixmap("theme:icons/rename"));
+ aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename")));
connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal);
aNewLocalFolder = new QAction(this);
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder);
aDeleteLocalReplay = new QAction(this);
- aDeleteLocalReplay->setIcon(QPixmap("theme:icons/remove_row"));
+ aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay);
aOpenReplaysFolder = new QAction(this);
@@ -164,24 +165,24 @@ QGroupBox *TabReplays::createRightLayout()
// Right side actions
aOpenRemoteReplay = new QAction(this);
- aOpenRemoteReplay->setIcon(QPixmap("theme:icons/view"));
+ aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view")));
connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay);
connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay);
aDownload = new QAction(this);
- aDownload->setIcon(QPixmap("theme:icons/arrow_left_green"));
+ aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green")));
connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload);
aKeep = new QAction(this);
- aKeep->setIcon(QPixmap("theme:icons/lock"));
+ aKeep->setIcon(themePixmap(QStringLiteral("icons/lock")));
connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay);
aDeleteRemoteReplay = new QAction(this);
- aDeleteRemoteReplay->setIcon(QPixmap("theme:icons/remove_row"));
+ aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay);
aGetReplayCode = new QAction(this);
- aGetReplayCode->setIcon(QPixmap("theme:icons/share"));
+ aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share")));
connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode);
aSubmitReplayCode = new QAction(this);
- aSubmitReplayCode->setIcon(QPixmap("theme:icons/search"));
+ aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search")));
connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode);
// Add actions to toolbars
diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp
index 6245b5301..866324367 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../../pixel_map_generator.h"
#include "../interface/widgets/dialogs/dlg_settings.h"
#include "../interface/widgets/server/chat_view/chat_view.h"
#include "../interface/widgets/server/game_link.h"
@@ -98,7 +99,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings);
auto *chatSettingsButton = new QToolButton;
- chatSettingsButton->setIcon(QPixmap("theme:icons/settings"));
+ chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings")));
chatSettingsButton->setMenu(chatSettingsMenu);
chatSettingsButton->setPopupMode(QToolButton::InstantPopup);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
index ed0ddaf06..462aa420b 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
@@ -15,6 +15,7 @@
#include "tab_card_art_rules.h"
#include "tab_deck_editor.h"
#include "tab_deck_storage.h"
+#include "tab_developer.h"
#include "tab_game.h"
#include "tab_home.h"
#include "tab_logs.h"
@@ -119,7 +120,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
- tabModeration(nullptr), isLocalGame(false)
+ tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setElideMode(Qt::ElideRight);
setMovable(true);
@@ -205,6 +206,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
aTabModeration->setCheckable(true);
connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration);
+ aTabDeveloper = new QAction(this);
+ aTabDeveloper->setCheckable(true);
+ connect(aTabDeveloper, &QAction::triggered, this, &TabSupervisor::actTabDeveloper);
+
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&TabSupervisor::refreshShortcuts);
refreshShortcuts();
@@ -247,6 +252,7 @@ void TabSupervisor::retranslateUi()
aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation"));
aTabCardArtRules->setText(tr("Card Art Rules"));
+ aTabDeveloper->setText(tr("Developer"));
// tabs
QList tabs;
@@ -259,6 +265,7 @@ void TabSupervisor::retranslateUi()
tabs.append(tabReport);
tabs.append(tabModeration);
tabs.append(tabCardArtRules);
+ tabs.append(tabDeveloper);
QMapIterator roomIterator(roomTabs);
while (roomIterator.hasNext()) {
tabs.append(roomIterator.next().value());
@@ -528,6 +535,19 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
}
}
+ if (userInfo->user_level() & ServerInfo_User::IsDeveloper) {
+ tabsMenu->addSeparator();
+ tabsMenu->addAction(aTabDeveloper);
+ // Developers without moderation rights get log access through their
+ // own role. Moderators already have the Logs entry from above.
+ if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) {
+ tabsMenu->addAction(aTabLog);
+ if (SettingsCache::instance().tabs().getTabLogOpen()) {
+ openTabLog();
+ }
+ }
+ }
+
retranslateUi();
}
@@ -540,6 +560,7 @@ void TabSupervisor::startLocal(const QList &_clients)
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
+ tabDeveloper = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
localClients = _clients;
@@ -590,6 +611,9 @@ void TabSupervisor::stop()
if (tabCardArtRules) {
tabCardArtRules->close();
}
+ if (tabDeveloper) {
+ tabDeveloper->close();
+ }
}
QList tabsToDelete;
@@ -819,7 +843,13 @@ void TabSupervisor::actTabLog(bool checked)
void TabSupervisor::openTabLog()
{
- tabLog = new TabLog(this, client);
+ // Developers query logs through the developer command family, so tell the
+ // tab which family to use. The moderator family is strictly stronger, so a
+ // moderator who also holds the developer bit keeps the moderator path — the
+ // developer bit only selects the (narrowed) developer family on its own.
+ const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) &&
+ !(userInfo->user_level() & ServerInfo_User::IsModerator);
+ tabLog = new TabLog(this, client, useDeveloperCommands);
myAddTab(tabLog, aTabLog);
connect(tabLog, &QObject::destroyed, this, [this] {
tabLog = nullptr;
@@ -881,6 +911,27 @@ void TabSupervisor::openTabModeration(const QString &userName)
aTabModeration->setChecked(true);
}
+void TabSupervisor::actTabDeveloper(bool checked)
+{
+ if (checked && !tabDeveloper) {
+ openTabDeveloper();
+ setCurrentWidget(tabDeveloper);
+ } else if (!checked && tabDeveloper) {
+ tabDeveloper->closeRequest();
+ }
+}
+
+void TabSupervisor::openTabDeveloper()
+{
+ tabDeveloper = new TabDeveloper(this, client);
+ myAddTab(tabDeveloper, aTabDeveloper);
+ connect(tabDeveloper, &QObject::destroyed, this, [this] {
+ tabDeveloper = nullptr;
+ aTabDeveloper->setChecked(false);
+ });
+ aTabDeveloper->setChecked(true);
+}
+
void TabSupervisor::updatePingTime(int value, int max)
{
if (!tabServer) {
@@ -1063,6 +1114,13 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus
return tab;
}
+ if (focus && userListManager->isUserIgnored(receiverName)) {
+ QMessageBox::information(
+ this, tr("Ignored user"),
+ tr("You have ignored %1. Remove them from your ignore list to open a private chat.").arg(receiverName));
+ return nullptr;
+ }
+
tab = new TabMessage(this, client, *userInfo, otherUser, userOnline);
connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft);
connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
@@ -1242,7 +1300,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent)
auto *tab = static_cast(sender());
if (tab != currentWidget()) {
tab->setContentsChanged(true);
- setTabIcon(indexOf(tab), QPixmap("theme:icons/tab_changed"));
+ setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed")));
}
if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) {
QApplication::alert(this);
@@ -1277,7 +1335,21 @@ void TabSupervisor::processGameEventContainer(const GameEventContainer &cont)
void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event)
{
+ // "Ignore all private messages" silences every PM, including messages to
+ // already-open tabs — unlike the unregistered/non-buddy filters below,
+ // which only apply when creating a new tab. Messages from moderators/admins
+ // are exempt to ensure warnings still reach users.
QString senderName = QString::fromStdString(event.sender_name());
+ if (SettingsCache::instance().chat().getIgnoreAllPrivateMessages()) {
+ const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName);
+ if (!onlineUserInfo) {
+ return;
+ }
+ const UserLevelFlags userLevel(onlineUserInfo->user_level());
+ if (!userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) {
+ return;
+ }
+ }
TabMessage *tab = messageTabs.value(senderName);
if (!tab) {
tab = messageTabs.value(QString::fromStdString(event.receiver_name()));
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
index b389bad3e..aec1d7418 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
@@ -45,6 +45,7 @@ class TabReport;
class TabModeration;
class TabAccount;
class TabDeckEditor;
+class TabDeveloper;
class TabLog;
class RoomEvent;
class GameEventContainer;
@@ -108,6 +109,7 @@ private:
TabLog *tabLog;
TabReport *tabReport;
TabModeration *tabModeration;
+ TabDeveloper *tabDeveloper;
QMap roomTabs;
QMap gameTabs;
QList replayTabs;
@@ -117,7 +119,7 @@ private:
QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage,
*aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin,
- *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration;
+ *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration, *aTabDeveloper;
int myAddTab(Tab *tab, QAction *manager = nullptr);
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
@@ -207,6 +209,7 @@ private slots:
void actTabLog(bool checked);
void actTabReport(bool checked);
void actTabModeration(bool checked);
+ void actTabDeveloper(bool checked);
void openTabVisualDeckStorage();
void openTabHome();
@@ -218,6 +221,7 @@ private slots:
void openTabCardArtRules();
void openTabLog();
void openTabReport();
+ void openTabDeveloper();
void updateCurrent(int index);
void updatePingTime(int value, int max);
diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp
index c6bf289ba..06561a41d 100644
--- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp
+++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp
@@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../../pixel_map_generator.h"
#include
#include
@@ -14,8 +15,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid
defaultButton = new QPushButton("", this);
lineEdit->setMinimumWidth(70);
- clearButton->setIcon(QPixmap("theme:icons/clearsearch"));
- defaultButton->setIcon(QPixmap("theme:icons/update"));
+ clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
+ defaultButton->setIcon(themePixmap(QStringLiteral("icons/update")));
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
index 4a558a5e0..a59a068dd 100644
--- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
@@ -1,5 +1,6 @@
#include "visual_database_display_filter_toolbar_widget.h"
+#include "../../pixel_map_generator.h"
#include "../deck_editor/card_database_view.h"
#include "visual_database_display_widget.h"
@@ -60,22 +61,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg
});
quickFilterSaveLoadWidget = new SettingsButtonWidget(this);
- quickFilterSaveLoadWidget->setButtonIcon(QPixmap("theme:icons/floppy_disk"));
+ quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk")));
quickFilterNameWidget = new SettingsButtonWidget(this);
- quickFilterNameWidget->setButtonIcon(QPixmap("theme:icons/pen_to_square"));
+ quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square")));
quickFilterMainTypeWidget = new SettingsButtonWidget(this);
- quickFilterMainTypeWidget->setButtonIcon(QPixmap("theme:icons/circle_half_stroke"));
+ quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke")));
quickFilterSubTypeWidget = new SettingsButtonWidget(this);
- quickFilterSubTypeWidget->setButtonIcon(QPixmap("theme:icons/dragon"));
+ quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon")));
quickFilterSetWidget = new SettingsButtonWidget(this);
- quickFilterSetWidget->setButtonIcon(QPixmap("theme:icons/scroll"));
+ quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll")));
quickFilterFormatLegalityWidget = new SettingsButtonWidget(this);
- quickFilterFormatLegalityWidget->setButtonIcon(QPixmap("theme:icons/scale_balanced"));
+ quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced")));
retranslateUi();
}
diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
index 76bbf344b..f4530a12b 100644
--- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
@@ -66,7 +66,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
- auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
+ auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); });
setFocusProxy(searchEdit);
@@ -121,7 +121,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
clearFilterWidget = new QToolButton();
clearFilterWidget->setFixedSize(32, 32);
- clearFilterWidget->setIcon(QPixmap("theme:icons/delete"));
+ clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete")));
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
filterModel->blockSignals(true);
filterModel->filterTree()->blockSignals(true);
diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp
index f44c9c3ef..28abe480e 100644
--- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp
@@ -1,5 +1,6 @@
#include "visual_deck_display_options_widget.h"
+#include "../../pixel_map_generator.h"
#include "../tabs/visual_deck_editor/tab_deck_editor_visual.h"
#include
@@ -47,7 +48,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent)
sortByLabel = new QLabel(this);
sortCriteriaButton = new SettingsButtonWidget(this);
- sortCriteriaButton->setButtonIcon(QPixmap("theme:icons/sort_arrow_down"));
+ sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down")));
sortLabel = new QLabel(sortCriteriaButton);
sortLabel->setWordWrap(true);
@@ -92,7 +93,7 @@ void VisualDeckDisplayOptionsWidget::retranslateUi()
sortLabel->setText(tr("Click and drag to change the sort order within the groups"));
sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups"));
displayTypeButton->setButtonText(tr("Toggle Layout: Overlap"));
- displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales"));
+ displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales")));
displayTypeButton->setToolTip(
tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)"));
}
@@ -117,11 +118,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType()
switch (currentDisplayType) {
case DisplayType::Flat:
displayTypeButton->setButtonText(tr("Toggle Layout: Flat"));
- displayTypeButton->setButtonIcon(QPixmap("theme:icons/scroll"));
+ displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll")));
break;
case DisplayType::Overlap:
displayTypeButton->setButtonText(tr("Toggle Layout: Overlap"));
- displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales"));
+ displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales")));
break;
}
emit displayTypeChanged(currentDisplayType);
diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp
index e3261b346..064fbed5d 100644
--- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp
@@ -4,6 +4,7 @@
#include "../../../main.h"
#include "../../deck_loader/deck_loader.h"
#include "../../layouts/overlap_layout.h"
+#include "../../pixel_map_generator.h"
#include "../cards/card_info_picture_with_text_overlay_widget.h"
#include "../cards/deck_card_zone_display_widget.h"
#include "../general/layout_containers/flow_widget.h"
@@ -131,7 +132,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter()
// Search button functionality
searchPushButton = new CompactPushButton(searchContainer);
- searchPushButton->setButtonIcon(QPixmap("theme:icons/search"));
+ searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search")));
connect(searchPushButton, &QPushButton::clicked, this, [=, this]() {
ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()});
if (card) {
diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
index baa5e5792..406527893 100644
--- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
@@ -25,7 +25,7 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) :
searchBar->setClearButtonEnabled(true);
searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
- auto help = searchBar->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
+ auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); });
layout->addWidget(searchBar);
diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
index acb0dcab2..da7ddc368 100644
--- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
@@ -1,6 +1,7 @@
#include "visual_deck_storage_widget.h"
#include "../../../client/settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
#include "../quick_settings/settings_button_widget.h"
#include "deck_preview/deck_preview_color_identity_filter_widget.h"
#include "deck_preview/deck_preview_widget.h"
@@ -43,7 +44,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
searchWidget = new VisualDeckStorageSearchWidget(this);
refreshButton = new QToolButton(this);
- refreshButton->setIcon(QPixmap("theme:icons/reload"));
+ refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload")));
refreshButton->setFixedSize(32, 32);
connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible);
diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp
index 0ec50fa85..3daaeb8d3 100644
--- a/cockatrice/src/interface/window_main.cpp
+++ b/cockatrice/src/interface/window_main.cpp
@@ -44,6 +44,7 @@
#include "intents/intent_open_server_room_by_name.h"
#include "intents/url_parser.h"
#include "logger.h"
+#include "pixel_map_generator.h"
#include "version_string.h"
#include "widgets/dialogs/dlg_connect.h"
#include "widgets/server/handle_public_servers.h"
@@ -271,7 +272,8 @@ void MainWindow::actAbout()
GITHUB_TROUBLESHOOTING_URL + "'>" + tr("Troubleshooting") + "
" + "" + tr("F.A.Q.") + "
"),
QMessageBox::Ok, this);
- mb.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ mb.setIconPixmap(
+ themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
mb.setTextInteractionFlags(Qt::TextBrowserInteraction);
mb.exec();
}
@@ -323,7 +325,7 @@ void MainWindow::retranslateUi()
aRegister->setText(tr("&Register to server..."));
aForgotPassword->setText(tr("&Restore password..."));
aSettings->setText(tr("&Settings..."));
- aSettings->setIcon(QPixmap("theme:icons/settings"));
+ aSettings->setIcon(themePixmap(QStringLiteral("icons/settings")));
aExit->setText(tr("&Exit"));
#if defined(__APPLE__) /* For OSX */
@@ -682,6 +684,7 @@ void MainWindow::runFirstRunWizard()
connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground);
connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates);
connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished);
+ connect(this, &MainWindow::cardDatabaseUpdateProgress, wizard, &FirstRunWizard::onCardDatabaseUpdateProgress);
connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer);
connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer);
@@ -816,7 +819,7 @@ void MainWindow::createTrayIcon()
trayIcon = new QSystemTrayIcon(this);
trayIcon->setContextMenu(trayIconMenu);
- trayIcon->setIcon(QPixmap("theme:cockatrice"));
+ trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice")));
trayIcon->show();
}
@@ -843,6 +846,17 @@ void MainWindow::closeEvent(QCloseEvent *event)
}
bClosingDown = true;
+ if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) {
+ if (QMessageBox::question(this, tr("Are you sure?"),
+ tr("A card database update is still running. Quitting now will cancel it.\n"
+ "Are you sure you want to quit?"),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
+ event->ignore();
+ bClosingDown = false;
+ return;
+ }
+ }
+
if (!tabSupervisor->close()) {
event->ignore();
bClosingDown = false;
@@ -1057,11 +1071,45 @@ void MainWindow::createCardUpdateProcess(bool background)
if (!background) {
cardUpdateProcess->start(updaterCmd, QStringList());
} else {
+ cardUpdateOutputBuffer.clear();
+ connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput);
cardUpdateProcess->start(updaterCmd, QStringList("-b"));
statusBar()->showMessage(tr("Card database update running."));
}
}
+void MainWindow::cardUpdateProgressOutput()
+{
+ if (!cardUpdateProcess) {
+ return;
+ }
+ cardUpdateOutputBuffer.append(cardUpdateProcess->readAllStandardOutput());
+ while (true) {
+ const int newline = cardUpdateOutputBuffer.indexOf('\n');
+ if (newline < 0) {
+ break;
+ }
+ const QByteArray line = cardUpdateOutputBuffer.left(newline).trimmed();
+ cardUpdateOutputBuffer.remove(0, newline + 1);
+ // Protocol emitted by `oracle -b`: "PROGRESS "
+ if (!line.startsWith("PROGRESS ")) {
+ continue;
+ }
+ const QList parts = line.split(' ');
+ if (parts.size() != 4) {
+ continue;
+ }
+ bool doneOk = false;
+ bool totalOk = false;
+ const qint64 done = parts.at(2).toLongLong(&doneOk);
+ const qint64 total = parts.at(3).toLongLong(&totalOk);
+ if (!doneOk || !totalOk || done < 0 || total < 0) {
+ continue;
+ }
+ emit cardDatabaseUpdateProgress(QString::fromLatin1(parts.at(1)), done, total);
+ }
+}
+
void MainWindow::exitCardDatabaseUpdate()
{
if (!cardUpdateProcess) {
@@ -1109,6 +1157,8 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err)
void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus)
{
+ cardUpdateProgressOutput(); // drain any progress lines not yet parsed
+
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
if (exitStatus == QProcess::NormalExit) {
SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date());
diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h
index 920145552..08481fd36 100644
--- a/cockatrice/src/interface/window_main.h
+++ b/cockatrice/src/interface/window_main.h
@@ -68,6 +68,11 @@ signals:
/** @brief Emitted after the background card-database update subprocess exits. */
void cardDatabaseUpdateFinished(bool success);
+ /** @brief Emitted while the background card-database update subprocess runs.
+ * @p stage is one of "download", "scan" or "import"; @p done/@p total
+ * are byte counts for the first two stages and set indices for "import". */
+ void cardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total);
+
public slots:
void actCheckCardUpdates();
void actCheckCardUpdatesBackground();
@@ -96,6 +101,7 @@ private slots:
void cardUpdateError(QProcess::ProcessError err);
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
+ void cardUpdateProgressOutput();
void refreshShortcuts();
void cardDatabaseLoadingFailed();
void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames);
@@ -159,6 +165,7 @@ private:
LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph
bool bHasActivated, askedForDbUpdater;
QProcess *cardUpdateProcess;
+ QByteArray cardUpdateOutputBuffer;
DlgViewLog *logviewDialog;
GameReplay *replay;
DlgTipOfTheDay *tip;
diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp
index 59afad42b..e5806a52c 100644
--- a/cockatrice/src/main.cpp
+++ b/cockatrice/src/main.cpp
@@ -349,8 +349,8 @@ int main(int argc, char *argv[])
}
qCInfo(MainLog) << "MainWindow constructor finished";
- ui.setWindowIcon(QPixmap("theme:cockatrice"));
- // Set name of the app desktop file; used by wayland to load the window icon
+ ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
+ // set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("Cockatrice");
SettingsCache::instance().network().setClientID(generateClientID());
diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
index cd9ad29e1..cdf2da5eb 100644
--- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
+++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
@@ -20,6 +20,7 @@ public:
[[nodiscard]] virtual bool getShowMessagePopup() const = 0;
[[nodiscard]] virtual bool getShowMentionPopup() const = 0;
[[nodiscard]] virtual bool getRoomHistory() const = 0;
+ [[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0;
[[nodiscard]] virtual QString getHighlightWords() const = 0;
};
diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp
index d6316deb3..687d93666 100644
--- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp
+++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp
@@ -253,3 +253,24 @@ PendingCommand *AbstractClient::prepareAdminCommand(const ::google::protobuf::Me
c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd);
return new PendingCommand(cont);
}
+
+PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf::Message &cmd)
+{
+ CommandContainer cont;
+ DeveloperCommand *c = cont.add_developer_command();
+ // A developer command message may also be usable through other command
+ // families, so select the extension scoped to DeveloperCommand rather than
+ // guessing by name.
+ const ::google::protobuf::Descriptor *cmdDescriptor = cmd.GetDescriptor();
+ const ::google::protobuf::Descriptor *developerDescriptor = DeveloperCommand::descriptor();
+ const ::google::protobuf::FieldDescriptor *developerExtension = nullptr;
+ for (int i = 0; i < cmdDescriptor->extension_count(); ++i) {
+ if (cmdDescriptor->extension(i)->containing_type() == developerDescriptor) {
+ developerExtension = cmdDescriptor->extension(i);
+ break;
+ }
+ }
+ Q_ASSERT(developerExtension != nullptr);
+ c->GetReflection()->MutableMessage(c, developerExtension)->CopyFrom(cmd);
+ return new PendingCommand(cont);
+}
diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
index 1ef9a31e4..af22a5c9d 100644
--- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
+++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
@@ -173,6 +173,7 @@ public:
static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId);
static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd);
static PendingCommand *prepareAdminCommand(const ::google::protobuf::Message &cmd);
+ static PendingCommand *prepareDeveloperCommand(const ::google::protobuf::Message &cmd);
QMap clientFeatures;
};
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp
index 957a89792..6b4101a99 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp
@@ -66,6 +66,15 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game,
Server_AbstractPlayer::~Server_AbstractPlayer() = default;
+int Server_AbstractPlayer::getCardCount() const
+{
+ int result = 0;
+ for (auto *zone : zones) {
+ result += zone->getCards().size();
+ }
+ return result;
+}
+
void Server_AbstractPlayer::prepareDestroy()
{
delete deck;
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h
index 85fbc0557..4cc79c5fe 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h
@@ -43,6 +43,8 @@ public:
Server_AbstractUserInterface *_handler);
~Server_AbstractPlayer() override;
void prepareDestroy() override;
+ /// Total cards across all of this player's zones. The caller must hold the game's mutex.
+ int getCardCount() const;
const DeckList *getDeckList() const
{
return deck;
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
index 43209e994..799b1e7ee 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp
@@ -32,6 +32,7 @@
#include "server_spectator.h"
#include
+#include
#include
#include
#include
@@ -238,6 +239,17 @@ int Server_Game::getPlayerCount() const
return participants.size() - getSpectatorCount();
}
+int Server_Game::getCardsInGame() const
+{
+ QMutexLocker locker(&gameMutex);
+
+ int result = 0;
+ for (auto *player : getPlayers()) {
+ result += player->getCardCount();
+ }
+ return result;
+}
+
int Server_Game::getSpectatorCount() const
{
QMutexLocker locker(&gameMutex);
@@ -330,6 +342,9 @@ void Server_Game::doStartGameIfReady(bool forceStartGame)
}
}
+ // Only actual starts are timed. The early returns above are no-ops.
+ QElapsedTimer startupTimer;
+ startupTimer.start();
players = getPlayers(); // players could have been kicked, get new list of players
if (lifecycleStrategy->onGameStarting(this) == Server_GameLifecycleStrategy::StartAction::Handled) {
locker.unlock();
@@ -373,6 +388,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame)
activePlayer = -1;
nextTurn();
+ room->getServer()->observeGameStartDurationMs(startupTimer.nsecsElapsed() / 1000000);
locker.unlock();
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
index 1b9f651bd..1ed4fe4ca 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
+++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h
@@ -123,6 +123,8 @@ public:
return gameStarted;
}
int getPlayerCount() const;
+ /// Total cards across all players' zones. Takes gameMutex itself.
+ int getCardsInGame() const;
int getSpectatorCount() const;
QMap getPlayers() const;
Server_AbstractPlayer *getPlayer(int id) const;
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h
index 0ded27afa..3d27f4210 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/server.h
+++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h
@@ -180,6 +180,11 @@ public:
{
return false;
}
+ /// Called once per actual game start with how long bringing every player's
+ /// zones online took, so servers can spot deck sizes that wedge threads.
+ virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
+ {
+ }
Server_DatabaseInterface *getDatabaseInterface() const;
int getNextLocalGameId()
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp
index 899df6529..8422d703d 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp
+++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp
@@ -388,6 +388,33 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons
return finalResponseCode;
}
+Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer(const CommandContainer &cont,
+ ResponseContainer &rc)
+{
+ if (!userInfo) {
+ return Response::RespLoginNeeded;
+ }
+ if (!(userInfo->user_level() & ServerInfo_User::IsDeveloper)) {
+ return Response::RespLoginNeeded;
+ }
+
+ resetIdleTimer();
+
+ Response::ResponseCode finalResponseCode = Response::RespOk;
+ for (int i = cont.developer_command_size() - 1; i >= 0; --i) {
+ Response::ResponseCode resp = Response::RespInvalidCommand;
+ const DeveloperCommand &sc = cont.developer_command(i);
+ const int num = getPbExtension(sc);
+ logDebugMessage(getSafeDebugString(sc));
+
+ resp = processExtendedDeveloperCommand(num, sc, rc);
+ if (resp != Response::RespOk) {
+ finalResponseCode = resp;
+ }
+ }
+ return finalResponseCode;
+}
+
void Server_ProtocolHandler::processCommandContainer(const CommandContainer &cont)
{
// Command processing must be disabled after prepareDestroy() has been called.
@@ -410,6 +437,8 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con
finalResponseCode = processModeratorCommandContainer(cont, responseContainer);
} else if (cont.admin_command_size()) {
finalResponseCode = processAdminCommandContainer(cont, responseContainer);
+ } else if (cont.developer_command_size()) {
+ finalResponseCode = processDeveloperCommandContainer(cont, responseContainer);
} else {
finalResponseCode = Response::RespInvalidCommand;
}
@@ -454,11 +483,12 @@ void Server_ProtocolHandler::pingClockTimeout()
prepareDestroy();
}
- // PrivLevel users, Moderators, and Admins are not subject to the server idle timeout policy
+ // PrivLevel users, Moderators, Admins, and Developers are not subject to the server idle timeout policy
const bool hasPrivLevel = userInfo && QString::fromStdString(userInfo->privlevel()).toLower() != "none";
- const bool isModOrAdmin =
- userInfo && (userInfo->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin));
- if (!hasPrivLevel && !isModOrAdmin) {
+ const bool isStaff =
+ userInfo && (userInfo->user_level() &
+ (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin | ServerInfo_User::IsDeveloper));
+ if (!hasPrivLevel && !isStaff) {
if ((server->getIdleClientTimeout() > 0) && (idleClientWarningSent)) {
if (timeRunning - lastActionReceived > server->getIdleClientTimeout()) {
prepareDestroy();
diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h
index 0d05b91c8..2c8efe50e 100644
--- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h
+++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h
@@ -27,6 +27,7 @@ class CommandContainer;
class SessionCommand;
class ModeratorCommand;
class AdminCommand;
+class DeveloperCommand;
class Command_Ping;
class Command_Login;
@@ -98,6 +99,12 @@ private:
{
return Response::RespFunctionNotAllowed;
}
+ Response::ResponseCode processDeveloperCommandContainer(const CommandContainer &cont, ResponseContainer &rc);
+ virtual Response::ResponseCode
+ processExtendedDeveloperCommand(int /* cmdType */, const DeveloperCommand & /* cmd */, ResponseContainer & /* rc */)
+ {
+ return Response::RespFunctionNotAllowed;
+ }
void resetIdleTimer();
private slots:
@@ -129,7 +136,7 @@ public:
return timeRunning - lastDataReceived;
}
bool addSaidMessageSize(int size);
- void processCommandContainer(const CommandContainer &cont);
+ virtual void processCommandContainer(const CommandContainer &cont);
void sendProtocolItem(const Response &item);
void sendProtocolItem(const SessionEvent &item);
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt
index f22828f46..0791f8c14 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt
@@ -25,6 +25,7 @@ set(PROTO_FILES
command_dump_zone.proto
command_flip_card.proto
command_game_say.proto
+ command_get_server_stats.proto
command_inc_card_counter.proto
command_inc_counter.proto
command_kick_from_game.proto
@@ -71,6 +72,7 @@ set(PROTO_FILES
context_ready_start.proto
context_set_sideboard_lock.proto
context_undo_draw.proto
+ developer_commands.proto
event_add_to_list.proto
event_attach_card.proto
event_change_zone_properties.proto
@@ -140,6 +142,7 @@ set(PROTO_FILES
response_forgotpasswordrequest.proto
response_get_admin_notes.proto
response_get_games_of_user.proto
+ response_get_server_stats.proto
response_get_user_info.proto
response_join_room.proto
response_list_users.proto
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto
index f8b34b3f8..f1f85e376 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto
@@ -37,6 +37,7 @@ message Command_AdjustMod {
required string user_name = 1;
optional bool should_be_mod = 2;
optional bool should_be_judge = 3;
+ optional bool should_be_developer = 4;
}
message Command_ResetUserPassword {
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto
new file mode 100644
index 000000000..33c56293b
--- /dev/null
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto
@@ -0,0 +1,8 @@
+syntax = "proto2";
+import "developer_commands.proto";
+
+message Command_GetServerStats {
+ extend DeveloperCommand {
+ optional Command_GetServerStats ext = 1000;
+ }
+}
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto
index b6eaf6733..964407819 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto
@@ -4,6 +4,7 @@ import "game_commands.proto";
import "room_commands.proto";
import "moderator_commands.proto";
import "admin_commands.proto";
+import "developer_commands.proto";
message CommandContainer {
optional uint64 cmd_id = 1;
@@ -16,4 +17,5 @@ message CommandContainer {
repeated RoomCommand room_command = 102;
repeated ModeratorCommand moderator_command = 103;
repeated AdminCommand admin_command = 104;
+ repeated DeveloperCommand developer_command = 105;
}
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto
new file mode 100644
index 000000000..bed47d44c
--- /dev/null
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto
@@ -0,0 +1,8 @@
+syntax = "proto2";
+message DeveloperCommand {
+ enum DeveloperCommandType {
+ GET_SERVER_STATS = 1000;
+ VIEWLOG_HISTORY = 1001;
+ }
+ extensions 100 to max;
+}
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto
index 685408830..4f1e80c27 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto
@@ -1,4 +1,5 @@
syntax = "proto2";
+import "developer_commands.proto";
message ModeratorCommand {
enum ModeratorCommandType {
BAN_FROM_SERVER = 1000;
@@ -80,6 +81,9 @@ message Command_ViewLogHistory {
extend ModeratorCommand {
optional Command_ViewLogHistory ext = 1005;
}
+ extend DeveloperCommand {
+ optional Command_ViewLogHistory dev_ext = 1001;
+ }
optional string user_name = 1; // user that created message
optional string ip_address = 2; // ip address of user that created message
optional string game_name = 3; // client id of user that created the message
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto
index 14ba737b5..42a42fcc0 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto
@@ -77,6 +77,7 @@ message Response {
FORGOT_PASSWORD_REQUEST = 1016; // Response to password reset request
PASSWORD_SALT = 1017; // Response containing password salt
GET_ADMIN_NOTES = 1018; // Response with admin notes
+ GET_SERVER_STATS = 1019; // Response with server status statistics
REPLAY_LIST = 1100; // Response listing replays
REPLAY_DOWNLOAD = 1101; // Response for replay download
REPLAY_GET_CODE = 1102; // Response containing replay code
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto
new file mode 100644
index 000000000..bb8ff3c43
--- /dev/null
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto
@@ -0,0 +1,41 @@
+syntax = "proto2";
+import "response.proto";
+
+message CommandStats {
+ optional uint32 kind_index = 1; // 0=session, 1=room, 2=game, 3=moderator, 4=admin, 5=developer
+ optional uint32 extension_number = 2; // protobuf extension number within the kind
+ optional string command_name = 3; // e.g. "session/Command_Ping"
+ optional uint64 count = 4; // number of times observed
+ optional uint64 total_ms = 5; // cumulative processing milliseconds
+}
+
+message Response_GetServerStats {
+ extend Response {
+ optional Response_GetServerStats ext = 1220;
+ }
+
+ optional uint64 users_count = 1;
+ optional uint64 mods_count = 2;
+ optional uint64 games_count = 3;
+
+ // Traffic recorded during the last status update tick
+ optional uint64 tx_bytes = 4;
+ optional uint64 rx_bytes = 5;
+
+ optional uint64 uptime_secs = 6;
+ optional uint64 timest = 7; // unix timestamp of the snapshot
+
+ // Live metrics from MetricsRegistry (reset on server restart)
+ optional uint64 cards_in_games = 8;
+ optional uint64 eventloop_stalls_total = 9;
+ optional uint64 eventloop_last_stall_ms = 10;
+ optional uint64 eventloop_max_stall_ms = 11;
+ optional uint64 total_commands = 12;
+ optional uint64 total_command_time_ms = 13;
+ optional int32 active_command_types = 14;
+ optional uint64 game_start_count = 15;
+ optional uint64 game_start_total_ms = 16;
+
+ // Per-command breakdown (only types with count > 0)
+ repeated CommandStats command_stats = 20;
+}
diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto
index 98cc3ce6a..ea3f56705 100644
--- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto
+++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto
@@ -8,6 +8,7 @@ message ServerInfo_User {
IsModerator = 4;
IsAdmin = 8;
IsJudge = 16;
+ IsDeveloper = 32;
};
message PawnColorsOverride {
optional string left_side = 1;
diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp
index 5e1102473..974173371 100644
--- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp
+++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp
@@ -65,6 +65,11 @@ bool ChatSettings::getRoomHistory() const
return getValue("roomHistory", QString(), QString(), true).toBool();
}
+bool ChatSettings::getIgnoreAllPrivateMessages() const
+{
+ return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool();
+}
+
QString ChatSettings::getHighlightWords() const
{
return getValue("highlightWords").toString();
@@ -131,6 +136,11 @@ void ChatSettings::setRoomHistory(bool _roomHistory)
setValue(_roomHistory, "roomHistory");
}
+void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages)
+{
+ setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages");
+}
+
void ChatSettings::setHighlightWords(const QString &_highlightWords)
{
setValue(_highlightWords, "highlightWords");
diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h
index 7cf4be3f6..9566671e2 100644
--- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h
+++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h
@@ -23,6 +23,7 @@ public:
[[nodiscard]] bool getShowMessagePopup() const override;
[[nodiscard]] bool getShowMentionPopup() const override;
[[nodiscard]] bool getRoomHistory() const override;
+ [[nodiscard]] bool getIgnoreAllPrivateMessages() const override;
[[nodiscard]] QString getHighlightWords() const override;
void setChatMention(bool _chatMention);
@@ -37,6 +38,7 @@ public:
void setShowMessagePopups(bool _showMessagePopups);
void setShowMentionPopups(bool _showMentionPopups);
void setRoomHistory(bool _roomHistory);
+ void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages);
void setHighlightWords(const QString &_highlightWords);
signals:
diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt
index 68c4709bb..392184b6e 100644
--- a/oracle/CMakeLists.txt
+++ b/oracle/CMakeLists.txt
@@ -28,6 +28,7 @@ set(oracle_SOURCES
../cockatrice/src/client/settings/card_counter_settings.cpp
../cockatrice/src/client/settings/shortcuts_settings.cpp
../cockatrice/src/client/network/update/client/release_channel.cpp
+ ../cockatrice/src/interface/pixel_map_generator.cpp
../cockatrice/src/interface/theme_config.cpp
../cockatrice/src/interface/theme_manager.cpp
../cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp
diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp
index d745b250c..fdf11ef02 100644
--- a/oracle/src/oracleimporter.cpp
+++ b/oracle/src/oracleimporter.cpp
@@ -47,8 +47,16 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
bool OracleImporter::readSetsFromByteArray(QByteArray data)
{
+ const RawJson::ScanProgressCallback progress =
+ progressReporting
+ ? [this](
+ qsizetype bytesRead,
+ qsizetype
+ totalBytes) { emit dataReadProgress(static_cast(bytesRead), static_cast(totalBytes)); }
+ : RawJson::ScanProgressCallback{};
+
RawJson::ScanError error;
- const QList ranges = RawJson::scanSetRanges(data, &error);
+ const QList ranges = RawJson::scanSetRanges(data, &error, progress);
if (error.isError()) {
qDebug() << "error: RawJson::scanSetRanges():" << error.message;
return false;
@@ -96,16 +104,30 @@ bool OracleImporter::readSetsFromByteArray(QByteArray data)
return true;
}
+/**
+ * The priority order used to pick a card's main type when a card has multiple
+ * types (e.g. "Artifact Creature") or multiple faces (e.g. split/adventure cards).
+ * A lower index means a higher priority.
+ */
+static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery",
+ "Instant", "Artifact", "Enchantment"};
+
+/**
+ * Returns the priority (index) of the given main card type. Known types map to their
+ * position in {@link mainCardTypePriority()}, unknown types map to -1 (lowest priority).
+ */
+static int mainCardTypePriority(const QString &mainCardType)
+{
+ return MAIN_CARD_TYPE_PRIORITY.indexOf(mainCardType);
+}
+
static QString getMainCardType(const QStringList &typeList)
{
if (typeList.isEmpty()) {
return {};
}
- static const QStringList typePriority = {"Planeswalker", "Creature", "Land", "Sorcery",
- "Instant", "Artifact", "Enchantment"};
-
- for (const auto &type : typePriority) {
+ for (const auto &type : MAIN_CARD_TYPE_PRIORITY) {
if (typeList.contains(type)) {
return type;
}
@@ -460,8 +482,15 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson
properties.insert(prop, thisCardPropertyValue);
} else if (prop == "colors" || prop == "coloridentity") { // the card is both colors
properties.insert(prop, originalPropertyValue + thisCardPropertyValue);
- } else if (prop == "maintype") { // don't create maintypes with //es in them
- continue;
+ } else if (prop == "maintype") {
+ // Use the same priority as getMainCardType() to pick the
+ // "best" type across faces — e.g. Creature over Instant
+ // for adventure cards like Bonecrusher Giant.
+ int currentPriority = mainCardTypePriority(originalPropertyValue);
+ int newPriority = mainCardTypePriority(thisCardPropertyValue);
+ if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) {
+ properties.insert(prop, thisCardPropertyValue);
+ }
} else {
properties.insert(prop,
originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue);
@@ -551,6 +580,8 @@ int OracleImporter::startImport()
{
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
+ importCancelled.storeRelease(0);
+
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
// distinct card names while raw ranges only count printings (AllPrintings
// ~100k printings vs ~35k names), so this over-reserves somewhat; an exact
@@ -570,6 +601,12 @@ int OracleImporter::startImport()
int setIndex = 0;
for (const SetToDownload &curSetToParse : allSets) {
+ if (importCancelled.loadAcquire()) {
+ // The wizard was closed mid-import: stop at the next set boundary so
+ // the caller can wait for this future without processing every set.
+ break;
+ }
+
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
curSetToParse.getLongName(), curSetToParse.getSetType(),
curSetToParse.getReleaseDate(), curSetToParse.getPriority());
diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h
index 8cb30ca40..748056772 100644
--- a/oracle/src/oracleimporter.h
+++ b/oracle/src/oracleimporter.h
@@ -3,6 +3,7 @@
#include "raw_json_scanner.h"
+#include
#include
#include
#include
@@ -155,6 +156,21 @@ private:
*/
QByteArray rawSetsData;
+ /**
+ * Whether readSetsFromByteArray() should report scan progress via
+ * dataReadProgress. A background run routes that signal to stdout (for the
+ * hosting Cockatrice client to parse); the flag exists to skip the scanner
+ * instrumentation entirely when no consumer needs it.
+ */
+ bool progressReporting = true;
+
+ /**
+ * Atomic "please stop importing" flag. startImport() checks it between sets
+ * so a wizard being closed mid-import can be torn down without waiting for
+ * the whole import (or racing it).
+ */
+ QAtomicInt importCancelled;
+
CardInfoPtr addCard(QString name,
const QString &text,
bool isToken,
@@ -167,12 +183,35 @@ signals:
public:
explicit OracleImporter(QObject *parent = nullptr);
+ /**
+ * @brief Controls whether readSetsFromByteArray() instruments the raw scan.
+ *
+ * When enabled (the default) the raw scanner reports progress via
+ * dataReadProgress(), which an interactive wizard shows on its progress bar
+ * and a background run routes to stdout for the hosting client. Switch it
+ * off only when nothing will consume scan progress.
+ */
+ void setProgressReporting(bool enabled)
+ {
+ progressReporting = enabled;
+ }
/**
* Scans the given JSON document for set metadata. Takes the data by value so
* the wizard can hand over its decompressed buffer without copying it.
*/
bool readSetsFromByteArray(QByteArray data);
int startImport();
+ /**
+ * @brief Requests an in-flight startImport() to stop at the next set boundary.
+ *
+ * Works by setting an atomic flag that startImport() polls between sets, so
+ * cancelImport() followed by a short waitForFinished() on the running future is
+ * safe the moment the wizard is about to be destroyed.
+ */
+ void cancelImport()
+ {
+ importCancelled.storeRelease(1);
+ }
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
/**
diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp
index 11200dfc5..dd3ac6459 100644
--- a/oracle/src/oraclewizard.cpp
+++ b/oracle/src/oraclewizard.cpp
@@ -110,6 +110,24 @@ void OracleWizard::accept()
QDialog::accept();
}
+void OracleWizard::reject()
+{
+ // The wizard is being closed while a page may still run a worker on the
+ // importer. Ask it to stop before the wizard (and the importer child) is
+ // destroyed, so the worker thread never touches freed memory.
+ if (auto *active = dynamic_cast(currentPage())) {
+ active->cancelWork();
+ }
+ QWizard::reject();
+}
+
+void OracleWizard::runInBackground()
+{
+ backgroundMode = true;
+ hide();
+ currentPage()->initializePage();
+}
+
void OracleWizard::enableButtons()
{
button(QWizard::NextButton)->setDisabled(false);
diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h
index 78427175c..9a509ce5e 100644
--- a/oracle/src/oraclewizard.h
+++ b/oracle/src/oraclewizard.h
@@ -23,6 +23,7 @@ class OracleWizard : public QWizard
public:
explicit OracleWizard(QWidget *parent = nullptr);
void accept() override;
+ void reject() override;
void enableButtons();
void disableButtons();
void retranslateUi();
@@ -52,12 +53,7 @@ public:
}
bool saveTokensToFile(const QString &fileName);
- void runInBackground()
- {
- backgroundMode = true;
- hide();
- currentPage()->initializePage();
- }
+ void runInBackground();
public:
OracleImporter *importer;
diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp
index d45953553..df0c3f51d 100644
--- a/oracle/src/pages.cpp
+++ b/oracle/src/pages.cpp
@@ -12,6 +12,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -20,14 +21,17 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
#include
+#include
#include
#ifdef HAS_LZMA
@@ -53,6 +57,115 @@
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json"
#endif
+/**
+ * @brief Emits one machine-readable background-run progress line to stdout.
+ *
+ * Used only in background mode, so the hosting Cockatrice client can parse these
+ * lines to drive a determinate progress bar. stderr stays reserved for
+ * human-readable log output.
+ */
+static void emitBackgroundProgress(const char *stage, qint64 done, qint64 total)
+{
+ QTextStream out(stdout);
+ out << "PROGRESS " << stage << ' ' << done << ' ' << total << '\n';
+ out.flush();
+}
+
+namespace
+{
+
+/**
+ * @brief Decompresses and dispatches a sets-file payload on a worker thread.
+ *
+ * Iteratively unwraps xz/zip compression, then either hands the JSON to the
+ * importer (which reports scan progress via dataReadProgress) or returns the raw
+ * XML for the plain-XML path. Must never touch the wizard or the page: the caller
+ * consumes the returned LoadSetsResult on the UI thread in importFinished().
+ */
+LoadSetsResult loadSetsData(const QPointer &importer, QByteArray data)
+{
+ LoadSetsResult result;
+
+ while (true) {
+ if (data.startsWith(XZ_SIGNATURE)) {
+#ifdef HAS_LZMA
+ QBuffer inBuffer(&data);
+ QByteArray decompressed;
+ QBuffer outBuffer(&decompressed);
+ inBuffer.open(QBuffer::ReadOnly);
+ outBuffer.open(QBuffer::WriteOnly);
+ XzDecompressor xz;
+ if (!xz.decompress(&inBuffer, &outBuffer)) {
+ result.errorMessage = LoadSetsPage::tr("Xz extraction failed.");
+ result.offerUncompressedFallback = true;
+ return result;
+ }
+ data = decompressed;
+ continue;
+#else
+ result.errorMessage =
+ LoadSetsPage::tr("Sorry, this version of Oracle does not support xz compressed files.");
+ result.offerUncompressedFallback = true;
+ return result;
+#endif
+ }
+
+ if (data.startsWith(ZIP_SIGNATURE)) {
+#ifdef HAS_ZLIB
+ QBuffer inBuffer(&data);
+ UnZip uz;
+ const UnZip::ErrorCode openEc = uz.openArchive(&inBuffer);
+ if (openEc != UnZip::Ok) {
+ result.errorMessage = LoadSetsPage::tr("Failed to open Zip archive: %1.").arg(uz.formatError(openEc));
+ result.offerUncompressedFallback = true;
+ return result;
+ }
+ if (uz.fileList().size() != 1) {
+ result.errorMessage =
+ LoadSetsPage::tr("Zip extraction failed: the Zip archive doesn't contain exactly one file.");
+ result.offerUncompressedFallback = true;
+ return result;
+ }
+ const QString fileName = uz.fileList().at(0);
+ QByteArray decompressed;
+ QBuffer outBuffer(&decompressed);
+ outBuffer.open(QBuffer::ReadWrite);
+ const UnZip::ErrorCode ec = uz.extractFile(fileName, &outBuffer);
+ uz.closeArchive();
+ if (ec != UnZip::Ok) {
+ result.errorMessage = LoadSetsPage::tr("Zip extraction failed: %1.").arg(uz.formatError(ec));
+ result.offerUncompressedFallback = true;
+ return result;
+ }
+ data = decompressed;
+ continue;
+#else
+ result.errorMessage = LoadSetsPage::tr("Sorry, this version of Oracle does not support zipped files.");
+ result.offerUncompressedFallback = true;
+ return result;
+#endif
+ }
+ break;
+ }
+
+ if (data.startsWith("<")) {
+ result.ok = true;
+ result.plainXml = true;
+ result.xmlData = std::move(data);
+ return result;
+ }
+
+ if (data.startsWith("{")) {
+ result.ok = importer && importer->readSetsFromByteArray(std::move(data));
+ return result;
+ }
+
+ result.errorMessage = LoadSetsPage::tr("Failed to interpret downloaded data.");
+ return result;
+}
+
+} // namespace
+
#define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml"
#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"
@@ -182,6 +295,11 @@ LoadSetsPage::LoadSetsPage(QWidget *parent) : OracleWizardPage(parent)
setLayout(layout);
}
+bool LoadSetsPage::isComplete() const
+{
+ return !loadActive;
+}
+
void LoadSetsPage::initializePage()
{
urlLineEdit->setText(wizard()->settings->value("allsetsurl", ALLSETS_URL).toString());
@@ -260,7 +378,7 @@ bool LoadSetsPage::validatePage()
return false;
}
- progressLabel->setText(tr("Downloading (0MB)"));
+ progressLabel->setText(tr("Downloading (0 MB)"));
// show an infinite progressbar
progressBar->setMaximum(0);
progressBar->setMinimum(0);
@@ -279,18 +397,13 @@ bool LoadSetsPage::validatePage()
return false;
}
- if (!setsFile.open(QIODevice::ReadOnly)) {
- QMessageBox::critical(nullptr, tr("Error"), tr("Cannot open file '%1'.").arg(fileLineEdit->text()));
- return false;
- }
-
wizard()->disableButtons();
setEnabled(false);
wizard()->setCardSourceUrl(setsFile.fileName());
wizard()->setCardSourceVersion("unknown");
- readSetsFromByteArray(setsFile.readAll());
+ readSetsFromFile(setsFile.fileName());
}
return false;
@@ -339,11 +452,14 @@ void LoadSetsPage::downloadSetsFile(const QUrl &url)
void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total)
{
+ if (wizard()->backgroundMode) {
+ emitBackgroundProgress("download", received, total);
+ }
if (total > 0) {
progressBar->setMaximum(static_cast(total));
progressBar->setValue(static_cast(received));
}
- progressLabel->setText(tr("Downloading (%1MB)").arg((int)received / (1024 * 1024)));
+ progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024)));
}
void LoadSetsPage::actDownloadFinishedSetsFile()
@@ -384,106 +500,97 @@ void LoadSetsPage::actDownloadFinishedSetsFile()
reply->deleteLater();
}
-void LoadSetsPage::readSetsFromByteArray(QByteArray _data)
+void LoadSetsPage::updateParsingProgress(int bytesRead, int totalBytes)
{
- // show an infinite progressbar
+ if (totalBytes <= 0) {
+ return;
+ }
+ progressBar->setRange(0, totalBytes);
+ progressBar->setValue(bytesRead);
+ const int percent = static_cast((100.0 * bytesRead) / totalBytes);
+ progressLabel->setText(tr("Parsing file (%1%)").arg(percent));
+}
+
+void LoadSetsPage::scanProgressToStdout(int bytesRead, int totalBytes)
+{
+ emitBackgroundProgress("scan", bytesRead, totalBytes);
+}
+
+void LoadSetsPage::beginLoadSets(bool compressedFile)
+{
+ // Show an infinite progressbar while the worker decompresses; the scan
+ // steals the label via dataReadProgress as soon as it starts.
progressBar->setMaximum(0);
progressBar->setMinimum(0);
progressBar->setValue(0);
- progressLabel->setText(tr("Parsing file"));
+ progressLabel->setText(compressedFile ? tr("Extracting file...") : tr("Parsing file"));
progressLabel->show();
progressBar->show();
+ // Keep Next disabled (via completeChanged) until the worker reports in;
+ // updateButtonStates() re-evaluates button state whenever we re-enable.
+ loadActive = true;
+ emit completeChanged();
+
wizard()->downloadedPlainXml = false;
wizard()->xmlData.clear();
- readSetsFromByteArrayRef(_data);
+
+ if (wizard()->backgroundMode) {
+ connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::scanProgressToStdout,
+ Qt::UniqueConnection);
+ } else {
+ connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::updateParsingProgress,
+ Qt::UniqueConnection);
+ }
}
-void LoadSetsPage::readSetsFromByteArrayRef(QByteArray &_data)
+void LoadSetsPage::readSetsFromByteArray(QByteArray _data)
{
- // unzip the file if needed
- if (_data.startsWith(XZ_SIGNATURE)) {
-#ifdef HAS_LZMA
- // zipped file
- auto *inBuffer = new QBuffer(&_data);
- auto newData = QByteArray();
- auto *outBuffer = new QBuffer(&newData);
- inBuffer->open(QBuffer::ReadOnly);
- outBuffer->open(QBuffer::WriteOnly);
- XzDecompressor xz;
- if (!xz.decompress(inBuffer, outBuffer)) {
- zipDownloadFailed(tr("Xz extraction failed."));
- return;
+ const bool compressed = _data.startsWith(XZ_SIGNATURE) || _data.startsWith(ZIP_SIGNATURE);
+ beginLoadSets(compressed);
+
+ // Decompress and scan off the UI thread so a large download can't freeze the window.
+ const QPointer importer = wizard()->importer;
+ future = QtConcurrent::run(
+ [importer, data = std::move(_data)]() mutable { return loadSetsData(importer, std::move(data)); });
+ watcher.setFuture(future);
+}
+
+void LoadSetsPage::readSetsFromFile(const QString &fileName)
+{
+ // Peek at the header on the UI thread so the status text can distinguish
+ // "Extracting file..." from a plain JSON parse; the full read happens in the worker.
+ QFile headerFile(fileName);
+ bool compressed = false;
+ if (headerFile.open(QIODevice::ReadOnly)) {
+ const QByteArray header = headerFile.read(6);
+ compressed = header.startsWith(XZ_SIGNATURE) || header.startsWith(ZIP_SIGNATURE);
+ }
+ beginLoadSets(compressed);
+
+ // Read, decompress and scan off the UI thread (a plain JSON can be hundreds
+ // of MB, so even the read itself must not block the window).
+ const QPointer importer = wizard()->importer;
+ future = QtConcurrent::run([importer, fileName]() mutable -> LoadSetsResult {
+ QFile file(fileName);
+ if (!file.open(QIODevice::ReadOnly)) {
+ LoadSetsResult readError;
+ readError.errorMessage = LoadSetsPage::tr("Cannot open file '%1'.").arg(fileName);
+ return readError;
}
- _data.clear();
- readSetsFromByteArrayRef(newData);
- return;
-#else
- zipDownloadFailed(tr("Sorry, this version of Oracle does not support xz compressed files."));
+ return loadSetsData(importer, file.readAll());
+ });
+ watcher.setFuture(future);
+}
- wizard()->enableButtons();
- setEnabled(true);
- progressLabel->hide();
- progressBar->hide();
- return;
-#endif
- } else if (_data.startsWith(ZIP_SIGNATURE)) {
-#ifdef HAS_ZLIB
- // zipped file
- auto *inBuffer = new QBuffer(&_data);
- auto newData = QByteArray();
- auto *outBuffer = new QBuffer(&newData);
- QString fileName;
- UnZip::ErrorCode ec;
- UnZip uz;
-
- ec = uz.openArchive(inBuffer);
- if (ec != UnZip::Ok) {
- zipDownloadFailed(tr("Failed to open Zip archive: %1.").arg(uz.formatError(ec)));
- return;
- }
-
- if (uz.fileList().size() != 1) {
- zipDownloadFailed(tr("Zip extraction failed: the Zip archive doesn't contain exactly one file."));
- return;
- }
- fileName = uz.fileList().at(0);
-
- outBuffer->open(QBuffer::ReadWrite);
- ec = uz.extractFile(fileName, outBuffer);
- if (ec != UnZip::Ok) {
- zipDownloadFailed(tr("Zip extraction failed: %1.").arg(uz.formatError(ec)));
- uz.closeArchive();
- return;
- }
- _data.clear();
- readSetsFromByteArrayRef(newData);
- return;
-#else
- zipDownloadFailed(tr("Sorry, this version of Oracle does not support zipped files."));
-
- wizard()->enableButtons();
- setEnabled(true);
- progressLabel->hide();
- progressBar->hide();
- return;
-#endif
- } else if (_data.startsWith("{")) {
- // Start the computation.
- jsonData = std::move(_data);
- future = QtConcurrent::run([this] { return wizard()->importer->readSetsFromByteArray(std::move(jsonData)); });
- watcher.setFuture(future);
- } else if (_data.startsWith("<")) {
- // save xml file and don't do any processing
- wizard()->downloadedPlainXml = true;
- wizard()->xmlData = std::move(_data);
- importFinished();
- } else {
- wizard()->enableButtons();
- setEnabled(true);
- progressLabel->hide();
- progressBar->hide();
- QMessageBox::critical(this, tr("Error"), tr("Failed to interpret downloaded data."));
+void LoadSetsPage::cancelWork()
+{
+ // The scan is short-lived; just wait it out before the wizard (and its
+ // importer) can be torn down underneath the worker thread.
+ if (future.isRunning()) {
+ future.cancel();
+ watcher.cancel();
+ future.waitForFinished();
}
}
@@ -509,17 +616,65 @@ void LoadSetsPage::zipDownloadFailed(const QString &message)
void LoadSetsPage::importFinished()
{
+ loadActive = false;
+ emit completeChanged();
wizard()->enableButtons();
setEnabled(true);
- progressLabel->hide();
- progressBar->hide();
- if (wizard()->downloadedPlainXml || watcher.future().result()) {
- wizard()->next();
- } else {
- QMessageBox::critical(this, tr("Error"),
- tr("The file was retrieved successfully, but it does not contain any sets data."));
+ const LoadSetsResult result = watcher.result();
+
+ if (result.plainXml) {
+ wizard()->downloadedPlainXml = true;
+ wizard()->xmlData = result.xmlData;
}
+
+ if (wizard()->backgroundMode) {
+ progressLabel->hide();
+ progressBar->hide();
+ if (!result.errorMessage.isEmpty()) {
+ qWarning() << result.errorMessage;
+ } else if (!result.ok && !result.plainXml) {
+ qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data.");
+ }
+ emit readyToContinue();
+ return;
+ }
+
+ const auto fail = [this](const QString &message) {
+ progressLabel->hide();
+ progressBar->hide();
+ QMessageBox::critical(this, tr("Error"), message);
+ };
+
+ if (!result.errorMessage.isEmpty()) {
+ if (result.offerUncompressedFallback) {
+ zipDownloadFailed(result.errorMessage);
+ return;
+ }
+ fail(result.errorMessage);
+ return;
+ }
+
+ if (!result.ok && !result.plainXml) {
+ fail(tr("The file was retrieved successfully, but it does not contain any sets data."));
+ return;
+ }
+
+ // Snap the bar to 100% and hold it there for a moment so the completed state
+ // is actually visible before the next page's own import progress takes over
+ // (a zero-length deferral can still fire before the repaint is delivered).
+ progressBar->setMaximum(1);
+ progressBar->setValue(1);
+ progressLabel->setText(tr("Parsing file (100%)"));
+ QTimer::singleShot(500, this, [this] {
+ if (wizard()->currentPage() == this) {
+ // Leave the page pristine: hide the completed load bar so a later
+ // Back from the save page doesn't show stale progress.
+ progressLabel->hide();
+ progressBar->hide();
+ wizard()->next();
+ }
+ });
}
SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent)
@@ -527,49 +682,113 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent)
pathLabel = new QLabel(this);
saveLabel = new QLabel(this);
+ progressBar = new QProgressBar(this);
+ progressBar->hide();
+
defaultPathCheckBox = new QCheckBox(this);
messageLog = new QTextEdit(this);
messageLog->setReadOnly(true);
auto *layout = new QGridLayout(this);
- layout->addWidget(messageLog, 0, 0);
- layout->addWidget(saveLabel, 1, 0);
- layout->addWidget(pathLabel, 2, 0);
- layout->addWidget(defaultPathCheckBox, 3, 0);
+ layout->addWidget(progressBar, 0, 0);
+ layout->addWidget(messageLog, 1, 0);
+ layout->addWidget(saveLabel, 2, 0);
+ layout->addWidget(pathLabel, 3, 0);
+ layout->addWidget(defaultPathCheckBox, 4, 0);
setLayout(layout);
}
+bool SaveSetsPage::isComplete() const
+{
+ return !importActive;
+}
+
void SaveSetsPage::cleanupPage()
{
+ cancelWork();
+ disconnect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress);
+ disconnect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished);
wizard()->importer->clear();
- disconnect(wizard()->importer, &OracleImporter::setIndexChanged, nullptr, nullptr);
}
void SaveSetsPage::initializePage()
{
- messageLog->clear();
-
retranslateUi();
if (wizard()->downloadedPlainXml) {
messageLog->hide();
- } else {
- messageLog->show();
- connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress);
-
- int setsImported = wizard()->importer->startImport();
-
- // JSON data no longer needed after CardInfo objects are built
- wizard()->importer->releaseSetData();
-
- if (setsImported == 0) {
- QMessageBox::critical(this, tr("Error"), tr("No set has been imported."));
+ progressBar->hide();
+ if (wizard()->backgroundMode) {
+ emit readyToContinue();
}
+ return;
+ }
+
+ messageLog->clear();
+ messageLog->show();
+ progressBar->show();
+
+ totalSets = wizard()->importer->getSets().size();
+ progressBar->setRange(0, totalSets);
+ progressBar->setValue(0);
+
+ connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress,
+ Qt::UniqueConnection);
+ connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection);
+
+ wizard()->disableButtons();
+ importActive = true;
+ emit completeChanged();
+
+ const QPointer importer = wizard()->importer;
+ importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; });
+ importWatcher.setFuture(importFuture);
+}
+
+void SaveSetsPage::cancelWork()
+{
+ if (!importActive) {
+ return;
+ }
+ // Ask the worker to stop at the next set boundary, then wait it out so the
+ // wizard (and the importer it owns) is never torn down under a running thread.
+ importActive = false;
+ emit completeChanged();
+ wizard()->importer->cancelImport();
+ importFuture.cancel();
+ importWatcher.cancel();
+ importFuture.waitForFinished();
+}
+
+void SaveSetsPage::importFinished()
+{
+ if (!importActive) {
+ return;
+ }
+ importActive = false;
+ emit completeChanged();
+
+ wizard()->enableButtons();
+
+ const int setsImported = importWatcher.result();
+ const QPointer importer = wizard()->importer;
+ if (importer) {
+ importer->releaseSetData();
}
if (wizard()->backgroundMode) {
+ if (setsImported == 0) {
+ qWarning() << tr("No set has been imported.");
+ }
emit readyToContinue();
+ return;
+ }
+
+ progressBar->setValue(progressBar->maximum());
+
+ if (setsImported == 0) {
+ QMessageBox::critical(this, tr("Error"), tr("No set has been imported."));
}
}
@@ -591,13 +810,28 @@ void SaveSetsPage::retranslateUi()
setButtonText(QWizard::NextButton, tr("&Save"));
}
-void SaveSetsPage::updateTotalProgress(int cardsImported, int /* setIndex */, const QString &setName)
+void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QString &setName)
{
+ if (!importActive) {
+ return;
+ }
if (setName.isEmpty()) {
- messageLog->append("" + tr("Import finished: %1 cards.").arg(wizard()->importer->getCardList().size()) +
- "");
+ progressBar->setValue(progressBar->maximum());
+ const int cardCount = wizard()->importer->getCardList().size();
+ if (wizard()->backgroundMode) {
+ qInfo() << tr("Import finished: %1 cards.").arg(cardCount);
+ emitBackgroundProgress("import", totalSets, totalSets);
+ } else {
+ messageLog->append("" + tr("Import finished: %1 cards.").arg(cardCount) + "");
+ }
} else {
- messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported));
+ progressBar->setValue(setIndex);
+ if (wizard()->backgroundMode) {
+ qInfo() << tr("%1: %2 cards imported").arg(setName).arg(cardsImported);
+ emitBackgroundProgress("import", setIndex, totalSets);
+ } else {
+ messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported));
+ }
}
messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum());
@@ -743,4 +977,4 @@ void LoadSpoilersPage::retranslateUi()
pathLabel->setText(tr("The spoiler database will be saved at the following location:") + "
" +
SettingsCache::instance().getSpoilerCardDatabasePath());
defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)"));
-}
\ No newline at end of file
+}
diff --git a/oracle/src/pages.h b/oracle/src/pages.h
index 066cc2e1b..d0a9fcd43 100644
--- a/oracle/src/pages.h
+++ b/oracle/src/pages.h
@@ -3,8 +3,10 @@
#include "pagetemplates.h"
+#include
#include
#include
+#include
#include
#include
#include
@@ -57,19 +59,31 @@ protected:
void initializePage() override;
};
+/** @brief Result of a worker-thread sets-file load (read + decompress + dispatch). */
+struct LoadSetsResult
+{
+ bool ok = false; ///< JSON scan produced set data (or plain XML was handled)
+ bool plainXml = false; ///< input was a plain Cockatrice XML database
+ QByteArray xmlData; ///< raw XML for the plain-XML path
+ QString errorMessage; ///< set when the input could not be processed
+ bool offerUncompressedFallback = false; ///< decompression-only failure: offer the uncompressed URL
+};
+
class LoadSetsPage : public OracleWizardPage
{
Q_OBJECT
public:
explicit LoadSetsPage(QWidget *parent = nullptr);
void retranslateUi() override;
+ bool isComplete() const override;
protected:
void initializePage() override;
bool validatePage() override;
void readSetsFromByteArray(QByteArray _data);
- void readSetsFromByteArrayRef(QByteArray &_data);
+ void readSetsFromFile(const QString &fileName);
void downloadSetsFile(const QUrl &url);
+ void cancelWork() override;
private:
QRadioButton *urlRadioButton;
@@ -81,15 +95,19 @@ private:
QLabel *progressLabel;
QProgressBar *progressBar;
- QFutureWatcher watcher;
- QFuture future;
- QByteArray jsonData;
+ QFutureWatcher watcher;
+ QFuture future;
+ bool loadActive = false;
+
+ void beginLoadSets(bool compressedFile = false);
private slots:
void actLoadSetsFile();
void actRestoreDefaultUrl();
void actDownloadProgressSetsFile(qint64 received, qint64 total);
void actDownloadFinishedSetsFile();
+ void updateParsingProgress(int bytesRead, int totalBytes);
+ void scanProgressToStdout(int bytesRead, int totalBytes);
void importFinished();
void zipDownloadFailed(const QString &message);
};
@@ -100,19 +118,28 @@ class SaveSetsPage : public OracleWizardPage
public:
explicit SaveSetsPage(QWidget *parent = nullptr);
void retranslateUi() override;
+ bool isComplete() const override;
private:
QTextEdit *messageLog;
+ QProgressBar *progressBar;
QCheckBox *defaultPathCheckBox;
QLabel *pathLabel;
QLabel *saveLabel;
+ QFutureWatcher importWatcher;
+ QFuture importFuture;
+ int totalSets = 0;
+ bool importActive = false;
+
protected:
void initializePage() override;
void cleanupPage() override;
bool validatePage() override;
+ void cancelWork() override;
private slots:
+ void importFinished();
void updateTotalProgress(int cardsImported, int setIndex, const QString &setName);
};
diff --git a/oracle/src/pagetemplates.h b/oracle/src/pagetemplates.h
index 6e79c867e..ccad7ec62 100644
--- a/oracle/src/pagetemplates.h
+++ b/oracle/src/pagetemplates.h
@@ -20,6 +20,14 @@ public:
}
virtual void retranslateUi() = 0;
+ /**
+ * @brief Asks an active page to stop any background worker before the wizard
+ * (and its importer) can be torn down underneath it. Default is a no-op.
+ */
+ virtual void cancelWork()
+ {
+ }
+
signals:
void readyToContinue();
diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp
index db972491c..8c4633598 100644
--- a/oracle/src/raw_json_scanner.cpp
+++ b/oracle/src/raw_json_scanner.cpp
@@ -1,5 +1,6 @@
#include "raw_json_scanner.h"
+#include
#include
namespace
@@ -11,6 +12,46 @@ namespace
// reason and reports DeepNesting).
constexpr int kMaxNestingDepth = 1024;
+/**
+ * @brief Throttled byte-position reporting for scanSetRanges().
+ *
+ * Threaded through the skip walk so progress can be reported without materializing
+ * the whole document. Reports are rate-limited so a GUI showing progress isn't
+ * flooded with interrupts: a callback is invoked at most ~100 times per scan
+ * regardless of element count. The closing stretch (the last ~1%) is reported
+ * more finely so a large document doesn't stall the progress bar on the final
+ * percent before the scan wraps up.
+ */
+struct ScanProgress
+{
+ const char *begin = nullptr;
+ qsizetype size = 0;
+ RawJson::ScanProgressCallback callback;
+ qsizetype step = 1;
+ qsizetype lastReported = 0;
+
+ /**
+ * @brief Reports the scanner's absolute offset, unless within @p step bytes
+ * of the previous report and not yet at the end of the document.
+ */
+ void report(const char *p)
+ {
+ if (!callback) {
+ return;
+ }
+ const qsizetype offset = p - begin;
+ if (offset == lastReported) {
+ return; // the final element often already sits exactly at the end
+ }
+ const qsizetype reportingStep = offset >= size - step ? std::max(1, step / 16) : step;
+ if (offset - lastReported < reportingStep && offset < size) {
+ return;
+ }
+ lastReported = offset;
+ callback(offset, size);
+ }
+};
+
inline bool isWhitespace(char c)
{
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
@@ -296,9 +337,9 @@ bool skipNumber(const char *&p, const char *end)
return true;
}
-bool skipValue(const char *&p, const char *end, int depth);
-bool skipObject(const char *&p, const char *end, int depth);
-bool skipArray(const char *&p, const char *end, int depth);
+bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan);
+bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan);
+bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan);
bool skipPrimitive(const char *&p, const char *end)
{
@@ -324,7 +365,7 @@ bool skipPrimitive(const char *&p, const char *end)
return false;
}
-bool skipObject(const char *&p, const char *end, int depth)
+bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan)
{
if (depth <= 0) {
return false; // nest deeper than the cap
@@ -348,9 +389,10 @@ bool skipObject(const char *&p, const char *end, int depth)
return false;
}
++p;
- if (!skipValue(p, end, depth - 1)) {
+ if (!skipValue(p, end, depth - 1, scan)) {
return false;
}
+ scan.report(p);
p = skipWhitespace(p, end);
if (p >= end) {
return false;
@@ -367,7 +409,7 @@ bool skipObject(const char *&p, const char *end, int depth)
}
}
-bool skipArray(const char *&p, const char *end, int depth)
+bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan)
{
if (depth <= 0) {
return false; // nest deeper than the cap
@@ -379,9 +421,10 @@ bool skipArray(const char *&p, const char *end, int depth)
return true;
}
for (;;) {
- if (!skipValue(p, end, depth - 1)) {
+ if (!skipValue(p, end, depth - 1, scan)) {
return false;
}
+ scan.report(p);
p = skipWhitespace(p, end);
if (p >= end) {
return false;
@@ -398,7 +441,7 @@ bool skipArray(const char *&p, const char *end, int depth)
}
}
-bool skipValue(const char *&p, const char *end, int depth)
+bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan)
{
p = skipWhitespace(p, end);
if (p >= end) {
@@ -407,10 +450,10 @@ bool skipValue(const char *&p, const char *end, int depth)
const char c = *p;
if (c == '{') {
// pass depth through: skipObject consumes the single decrement for this level
- return skipObject(p, end, depth);
+ return skipObject(p, end, depth, scan);
}
if (c == '[') {
- return skipArray(p, end, depth);
+ return skipArray(p, end, depth, scan);
}
// a primitive is a leaf, so it never wastes a nesting level
return skipPrimitive(p, end);
@@ -422,7 +465,8 @@ bool skipValue(const char *&p, const char *end, int depth)
* For each member invokes @p memberCallback with the key and the byte range of
* its value. Advancing @p p is unaffected by the callback.
*/
-template bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback)
+template
+bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback, ScanProgress &scan)
{
if (depth <= 0) {
return false; // nest deeper than the cap
@@ -449,7 +493,7 @@ template bool forEachObjectMember(const char *&p, const char *end,
++p;
const char *valueStart = skipWhitespace(p, end);
const char *valueEnd = valueStart;
- if (!skipValue(valueEnd, end, depth - 1)) {
+ if (!skipValue(valueEnd, end, depth - 1, scan)) {
return false;
}
if (!memberCallback(key, valueStart, valueEnd)) {
@@ -473,7 +517,7 @@ template bool forEachObjectMember(const char *&p, const char *end,
}
// Counts the direct elements of an array value; returns -1 if the array is malformed.
-int countArrayElements(const char *p, const char *end, int depth)
+int countArrayElements(const char *p, const char *end, int depth, ScanProgress &scan)
{
if (depth <= 0) {
return -1; // nest deeper than the cap
@@ -485,9 +529,10 @@ int countArrayElements(const char *p, const char *end, int depth)
return 0;
}
for (;;) {
- if (!skipValue(p, end, depth - 1)) {
+ if (!skipValue(p, end, depth - 1, scan)) {
return -1;
}
+ scan.report(p);
++count;
p = skipWhitespace(p, end);
if (p >= end) {
@@ -509,7 +554,7 @@ int countArrayElements(const char *p, const char *end, int depth)
namespace RawJson
{
-QList scanSetRanges(const QByteArray &json, ScanError *error)
+QList scanSetRanges(const QByteArray &json, ScanError *error, const ScanProgressCallback &progress)
{
QList ranges;
if (error) {
@@ -529,6 +574,14 @@ QList scanSetRanges(const QByteArray &json, ScanError *error)
return fail(QStringLiteral("empty JSON document"));
}
+ // Throttle reports to ~100 per scan so a GUI thread unthrottling them never
+ // drowns under per-card interrupts, whatever the document size.
+ ScanProgress scan;
+ scan.begin = begin;
+ scan.size = end - begin;
+ scan.step = std::max(1, scan.size / 100);
+ scan.callback = progress;
+
const char *p = skipWhitespace(begin, end);
if (p >= end || *p != '{') {
return fail(QStringLiteral("top-level JSON must be an object"));
@@ -545,55 +598,57 @@ QList scanSetRanges(const QByteArray &json, ScanError *error)
return false;
}
const char *setP = valueStart;
- const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1,
- [&](const QString &setCode, const char *setStart, const char *setEnd) {
- if (setStart >= setEnd || *setStart != '{') {
- malformedSetData = true;
- return false;
- }
- SetRange range;
- range.dataRange.start = setStart - begin;
- range.dataRange.length = setEnd - setStart;
- range.code = setCode;
+ const bool ok = forEachObjectMember(
+ setP, valueEnd, kMaxNestingDepth - 1,
+ [&](const QString &setCode, const char *setStart, const char *setEnd) {
+ if (setStart >= setEnd || *setStart != '{') {
+ malformedSetData = true;
+ return false;
+ }
+ SetRange range;
+ range.dataRange.start = setStart - begin;
+ range.dataRange.length = setEnd - setStart;
+ range.code = setCode;
- const char *memberP = setStart;
- const bool metaOk = forEachObjectMember(
- memberP, setEnd, kMaxNestingDepth - 2,
- [&](const QString &field, const char *fs, const char *fe) {
- if (field == QStringLiteral("code")) {
- return decodeStringMember(fs, fe, range.code);
- }
- if (field == QStringLiteral("name")) {
- return decodeStringMember(fs, fe, range.name);
- }
- if (field == QStringLiteral("type")) {
- return decodeStringMember(fs, fe, range.type);
- }
- if (field == QStringLiteral("releaseDate")) {
- return decodeStringMember(fs, fe, range.releaseDate);
- }
- if (field == QStringLiteral("cards")) {
- if (fs >= fe) {
- return false;
- }
- if (*fs != '[') {
- // e.g. "cards": null — treat as an empty array,
- // matching Qt's tolerance.
- return true;
- }
- range.dataRange.cardCount =
- countArrayElements(fs, fe, kMaxNestingDepth - 2);
- return range.dataRange.cardCount >= 0;
- }
- return true;
- });
- if (!metaOk) {
- malformedSetData = true;
- return false;
- }
- ranges.append(range);
- return true;
- });
+ const char *memberP = setStart;
+ const bool metaOk = forEachObjectMember(
+ memberP, setEnd, kMaxNestingDepth - 2,
+ [&](const QString &field, const char *fs, const char *fe) {
+ if (field == QStringLiteral("code")) {
+ return decodeStringMember(fs, fe, range.code);
+ }
+ if (field == QStringLiteral("name")) {
+ return decodeStringMember(fs, fe, range.name);
+ }
+ if (field == QStringLiteral("type")) {
+ return decodeStringMember(fs, fe, range.type);
+ }
+ if (field == QStringLiteral("releaseDate")) {
+ return decodeStringMember(fs, fe, range.releaseDate);
+ }
+ if (field == QStringLiteral("cards")) {
+ if (fs >= fe) {
+ return false;
+ }
+ if (*fs != '[') {
+ // e.g. "cards": null — treat as an empty array,
+ // matching Qt's tolerance.
+ return true;
+ }
+ range.dataRange.cardCount = countArrayElements(fs, fe, kMaxNestingDepth - 2, scan);
+ return range.dataRange.cardCount >= 0;
+ }
+ return true;
+ },
+ scan);
+ if (!metaOk) {
+ malformedSetData = true;
+ return false;
+ }
+ ranges.append(range);
+ return true;
+ },
+ scan);
if (!ok) {
malformedSetData = true;
return false;
@@ -602,7 +657,7 @@ QList scanSetRanges(const QByteArray &json, ScanError *error)
return true;
};
- if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) {
+ if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback, scan)) {
return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON"));
}
p = skipWhitespace(p, end);
@@ -615,6 +670,7 @@ QList scanSetRanges(const QByteArray &json, ScanError *error)
if (ranges.isEmpty()) {
return fail(QStringLiteral("no sets found in \"data\""));
}
+ scan.report(end);
return ranges;
}
diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h
index d44c797e7..04fcc52e4 100644
--- a/oracle/src/raw_json_scanner.h
+++ b/oracle/src/raw_json_scanner.h
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
namespace RawJson
{
@@ -42,6 +43,13 @@ struct ScanError
QString message;
};
+/**
+ * @brief Optional progress callback receiving @c (bytesRead, totalBytes) while
+ * the document is walked. Invoked from the scanning thread; the caller decides
+ * how the throttled offsets are relayed to a GUI event loop.
+ */
+using ScanProgressCallback = std::function;
+
/**
* @brief Scans a full MTGJSON document without materializing the JSON tree.
*
@@ -67,9 +75,14 @@ struct ScanError
* @param error Out parameter. Set to an error ScanError when the document
* cannot be parsed, otherwise left empty. Passing a null
* pointer disables error reporting.
+ * @param progress Optional progress callback. When non-empty it is invoked as
+ * the scanner advances through the document, throttled to a
+ * tiny fraction of the total size.
* @return The detected per-set ranges, or an empty list on failure.
*/
-QList scanSetRanges(const QByteArray &json, ScanError *error = nullptr);
+QList scanSetRanges(const QByteArray &json,
+ ScanError *error = nullptr,
+ const ScanProgressCallback &progress = ScanProgressCallback());
} // namespace RawJson
diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt
index 5d8089ad1..68e422d8c 100644
--- a/servatrice/CMakeLists.txt
+++ b/servatrice/CMakeLists.txt
@@ -6,7 +6,9 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${
set(servatrice_SOURCES
src/email_parser.cpp
+ src/event_loop_watchdog.cpp
src/main.cpp
+ src/metrics_registry.cpp
src/servatrice.cpp
src/servatrice_connection_pool.cpp
src/servatrice_database_interface.cpp
diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example
index c1940c22f..23eca3f1b 100644
--- a/servatrice/servatrice.ini.example
+++ b/servatrice/servatrice.ini.example
@@ -382,6 +382,19 @@ max_reports_per_day=10
; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit
max_comments_per_hour=30
+[metrics]
+; Command containers that take longer than this many milliseconds are logged
+; as slow commands. Set to 0 to disable the log line. A latency spike produces
+; one warning per slow container with no rate limiting of its own -- a bad
+; patch can briefly flood the log, which is how you notice it.
+slow_command_ms=500
+
+; Each socket pool thread runs a watchdog heartbeat. If a heartbeat arrives
+; this many milliseconds late, the stall is logged and exposed as
+; servatrice_eventloop_* metrics in the Developer tab. Set to 0 to disable
+; the watchdogs.
+stall_warn_ms=2000
+
[logging]
; Admin/Moderators can query the stored logs for information when looking up reports by various players. This
; option can allow or disallow them from doing so.
diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql
index 5dbf69cbc..cfb1ef5d8 100644
--- a/servatrice/servatrice.sql
+++ b/servatrice/servatrice.sql
@@ -25,6 +25,9 @@ INSERT INTO cockatrice_schema_version VALUES(36);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
+ -- Bitfield of staff levels: 1 = admin (implies moderator), 2 = moderator,
+ -- 4 = judge, 8 = developer. Operators set these by hand with
+ -- "UPDATE cockatrice_users SET admin = ...".
`admin` tinyint(1) NOT NULL,
`name` varchar(35) NOT NULL,
`realname` varchar(255) NOT NULL,
diff --git a/servatrice/src/event_loop_watchdog.cpp b/servatrice/src/event_loop_watchdog.cpp
new file mode 100644
index 000000000..e50bd4201
--- /dev/null
+++ b/servatrice/src/event_loop_watchdog.cpp
@@ -0,0 +1,34 @@
+/**
+ * @file event_loop_watchdog.cpp
+ * @ingroup Servatrice
+ */
+
+#include "event_loop_watchdog.h"
+
+#include "servatrice.h"
+
+#include
+
+EventLoopWatchdog::EventLoopWatchdog(Servatrice *_servatrice, QString _threadName)
+ : QObject(nullptr), servatrice(_servatrice), threadName(std::move(_threadName))
+{
+}
+
+void EventLoopWatchdog::start()
+{
+ heartbeatTimer = new QTimer(this);
+ sinceLastTick.start();
+ connect(heartbeatTimer, &QTimer::timeout, this, &EventLoopWatchdog::checkHeartbeat);
+ heartbeatTimer->start(HeartbeatIntervalMs);
+}
+
+void EventLoopWatchdog::checkHeartbeat()
+{
+ const qint64 elapsedMs = sinceLastTick.restart();
+ const qint64 overshootMs = qMax(0, elapsedMs - HeartbeatIntervalMs);
+ if (overshootMs < servatrice->getMetricsStallWarnMs()) {
+ return;
+ }
+
+ servatrice->observeEventLoopStall(threadName, overshootMs);
+}
diff --git a/servatrice/src/event_loop_watchdog.h b/servatrice/src/event_loop_watchdog.h
new file mode 100644
index 000000000..b9061ff97
--- /dev/null
+++ b/servatrice/src/event_loop_watchdog.h
@@ -0,0 +1,50 @@
+/**
+ * @file event_loop_watchdog.h
+ * @ingroup Servatrice
+ */
+
+#ifndef EVENT_LOOP_WATCHDOG_H
+#define EVENT_LOOP_WATCHDOG_H
+
+#include
+#include
+#include
+
+class Servatrice;
+class QTimer;
+
+/**
+ * @brief Detects blocked or overloaded worker event loops.
+ *
+ * One instance lives in each socket pool thread. A heartbeat timer tick that
+ * arrives late means the loop spent that time elsewhere: busy work, a queued
+ * slot, or a hard wedge. Overshoots past the configured threshold bump
+ * lock-free counters on the metrics registry and log one warning per stall,
+ * so a stuck pool thread becomes visible instead of silent lag.
+ */
+class EventLoopWatchdog : public QObject
+{
+ Q_OBJECT
+public:
+ /// How often the heartbeat expects to fire. Small enough to catch short stalls.
+ static constexpr int HeartbeatIntervalMs = 500;
+
+ EventLoopWatchdog(Servatrice *_servatrice, QString _threadName);
+
+ /**
+ * Starts the heartbeat timer. Must be invoked queued after the instance
+ * was moved to its target thread so the timer lives there too.
+ */
+ void start();
+
+private slots:
+ void checkHeartbeat();
+
+private:
+ Servatrice *servatrice;
+ QString threadName;
+ QElapsedTimer sinceLastTick;
+ QTimer *heartbeatTimer = nullptr;
+};
+
+#endif
diff --git a/servatrice/src/metrics_registry.cpp b/servatrice/src/metrics_registry.cpp
new file mode 100644
index 000000000..99ce390f1
--- /dev/null
+++ b/servatrice/src/metrics_registry.cpp
@@ -0,0 +1,70 @@
+#include "metrics_registry.h"
+
+#include
+
+void MetricsRegistry::observeCommand(int typeId, qint64 elapsedMs)
+{
+ if (typeId < 0 || typeId >= MaxTypes) {
+ typeId = MaxTypes - 1; // overflow slot keeps misrouted ids visible
+ }
+ if (elapsedMs < 0) {
+ elapsedMs = 0;
+ }
+
+ TypeStats &stats = slotFor(typeId);
+ stats.count.fetch_add(1, std::memory_order_relaxed);
+ stats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed);
+ totalCommandsCounter.fetch_add(1, std::memory_order_relaxed);
+ totalTimeCounter.fetch_add(elapsedMs, std::memory_order_relaxed);
+}
+
+void MetricsRegistry::observeGameStartDurationMs(qint64 elapsedMs)
+{
+ if (elapsedMs < 0) {
+ elapsedMs = 0;
+ }
+
+ gameStartStats.count.fetch_add(1, std::memory_order_relaxed);
+ gameStartStats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed);
+}
+
+MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId)
+{
+ return typeSlots[static_cast(typeId)];
+}
+
+const MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) const
+{
+ return typeSlots[static_cast(typeId)];
+}
+
+int MetricsRegistry::activeTypeCount() const
+{
+ int active = 0;
+ for (int type = 0; type < MaxTypes; ++type) {
+ if (slotFor(type).count.load(std::memory_order_relaxed) > 0) {
+ ++active;
+ }
+ }
+ return active;
+}
+
+QList MetricsRegistry::collectActiveStats() const
+{
+ QList result;
+ for (int type = 0; type < MaxTypes; ++type) {
+ const TypeStats &stats = slotFor(type);
+ const qint64 count = stats.count.load(std::memory_order_relaxed);
+ if (count == 0) {
+ continue;
+ }
+ result.append({type, count, stats.totalMs.load(std::memory_order_relaxed)});
+ }
+ return result;
+}
+
+MetricsRegistry::GameStartSnapshot MetricsRegistry::getGameStartSnapshot() const
+{
+ return {gameStartStats.count.load(std::memory_order_relaxed),
+ gameStartStats.totalMs.load(std::memory_order_relaxed)};
+}
\ No newline at end of file
diff --git a/servatrice/src/metrics_registry.h b/servatrice/src/metrics_registry.h
new file mode 100644
index 000000000..4df1057c2
--- /dev/null
+++ b/servatrice/src/metrics_registry.h
@@ -0,0 +1,120 @@
+/**
+ * @file metrics_registry.h
+ * @ingroup Servatrice
+ */
+
+#ifndef METRICS_REGISTRY_H
+#define METRICS_REGISTRY_H
+
+#include
+#include
+#include
+#include
+
+/**
+ * @brief Lock-free accumulation of command processing statistics.
+ *
+ * observeCommand() is called once per processed command from whichever socket
+ * thread handled it. It uses relaxed atomic adds on preallocated storage only,
+ * so it introduces no locks, allocations, or shared cache-line ping-pong
+ * beyond the unavoidable counter updates.
+ *
+ * Reading happens rarely (metrics scraping), accepts momentary tears between
+ * related counters, and therefore also needs no synchronization.
+ *
+ * Only counts and totals are retained. An earlier Prometheus-style cumulative
+ * histogram (per-type, time-bucketed) was cut because nothing in the server
+ * ever wrote it out; it belongs to the future /metrics exporter that needs it.
+ */
+class MetricsRegistry
+{
+public:
+ /**
+ * Extension numbers are only unique per command kind, so recorded ids
+ * combine the kind index with the protobuf extension number.
+ *
+ * The stride is only as wide as it needs to be: 1280 is the first round
+ * number above the largest extension actually in use (ModeratorCommand =
+ * 1206) and keeps the preallocated TypeStats array small. Bump it if a new
+ * command exceeds it.
+ */
+ static constexpr int KindStride = 1280;
+
+ static constexpr int NumKinds = 6;
+
+ static constexpr const char *KindNames[NumKinds] = {"session", "room", "game", "moderator", "admin", "developer"};
+
+ /// Upper bound on distinct command type ids (see typeIdFor).
+ static constexpr int MaxTypes = NumKinds * KindStride;
+
+ /// Guard against typeIdFor() overflowing into the neighbouring kind's slots.
+ static_assert(KindStride > 1206, "KindStride must exceed the highest command extension number in use");
+
+ static int typeIdFor(int kindIndex, int extensionNumber)
+ {
+ return kindIndex * KindStride + extensionNumber;
+ }
+
+ void observeCommand(int typeId, qint64 elapsedMs);
+
+ /**
+ * Records how long one game start took to bring every player's zones
+ * online. Kept separate from command timings because it is triggered by
+ * the server itself and can dwarf any single command when decks are huge.
+ */
+ void observeGameStartDurationMs(qint64 elapsedMs);
+
+ /// Total number of observed commands across all types.
+ qint64 totalCommands() const
+ {
+ return totalCommandsCounter.load(std::memory_order_relaxed);
+ }
+
+ /// Cumulative processing milliseconds across all types.
+ qint64 totalTimeMs() const
+ {
+ return totalTimeCounter.load(std::memory_order_relaxed);
+ }
+
+ /// Number of distinct type slots that have seen at least one sample.
+ int activeTypeCount() const;
+
+ struct ActiveTypeStats
+ {
+ int typeId;
+ qint64 count;
+ qint64 totalMs;
+ };
+
+ /**
+ * Returns stats for every type slot that has seen at least one sample.
+ * Callers resolve the numeric type id to a human-readable label via
+ * typeIdFor()/KindNames as needed.
+ */
+ QList collectActiveStats() const;
+
+ struct GameStartSnapshot
+ {
+ qint64 count;
+ qint64 totalMs;
+ };
+
+ GameStartSnapshot getGameStartSnapshot() const;
+
+private:
+ struct TypeStats
+ {
+ std::atomic count{0};
+ std::atomic totalMs{0};
+ };
+
+ TypeStats &slotFor(int typeId);
+ const TypeStats &slotFor(int typeId) const;
+
+ std::array typeSlots{};
+ TypeStats gameStartStats{};
+ std::atomic totalCommandsCounter{0};
+ std::atomic totalTimeCounter{0};
+};
+
+#endif
\ No newline at end of file
diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp
index db8751658..26352ccd7 100644
--- a/servatrice/src/servatrice.cpp
+++ b/servatrice/src/servatrice.cpp
@@ -20,6 +20,7 @@
#include "servatrice.h"
#include "email_parser.h"
+#include "event_loop_watchdog.h"
#include "isl_interface.h"
#include "main.h"
#include "servatrice_connection_pool.h"
@@ -38,6 +39,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -63,6 +65,7 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
server->addDatabaseInterface(newThread, newDatabaseInterface);
newThread->start();
+ server->watchWorkerThread(newThread);
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
Q_ARG(QSqlDatabase, _sqlDatabase));
@@ -86,7 +89,6 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor)
Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool();
auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface());
- connect(ssi, SIGNAL(incTxBytes(qint64)), this, SLOT(incTxBytes(qint64)));
ssi->moveToThread(pool->thread());
pool->addClient();
connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient()));
@@ -131,6 +133,7 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv
server->addDatabaseInterface(newThread, newDatabaseInterface);
newThread->start();
+ server->watchWorkerThread(newThread);
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
Q_ARG(QSqlDatabase, _sqlDatabase));
@@ -156,7 +159,6 @@ void Servatrice_WebsocketGameServer::onNewConnection()
Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool();
auto ssi = new WebsocketServerSocketInterface(server, pool->getDatabaseInterface());
- connect(ssi, SIGNAL(incTxBytes(quint64)), this, SLOT(incTxBytes(quint64)));
/*
* Due to a Qt limitation, websockets can't be moved to another thread.
* This will hopefully change in Qt6 if QtWebSocket will be integrated in QtNetwork
@@ -226,6 +228,13 @@ bool Servatrice::initServer()
{
serverId = getServerID();
+
+ // METRICS (always active. Slow-command logging and stall watchdogs are
+ // controlled by their respective thresholds below). Read up front so the
+ // values are available before any pool thread is started and watchdogged.
+ metricsSlowCommandMs = settingsCache->value("metrics/slow_command_ms", 500).toInt();
+ metricsStallWarnMs = qMax(0, settingsCache->value("metrics/stall_warn_ms", 2000).toInt());
+
if (getAuthenticationMethodString() == "sql") {
qDebug() << "Authenticating method: sql";
authenticationMethod = AuthenticationSql;
@@ -470,9 +479,55 @@ bool Servatrice::initServer()
}
setRequiredFeatures(getRequiredFeatures());
+
return true;
}
+void Servatrice::observeGameStartDurationMs(qint64 elapsedMs)
+{
+ metricsRegistry.observeGameStartDurationMs(elapsedMs);
+}
+
+void Servatrice::observeEventLoopStall(const QString &threadName, qint64 overshootMs)
+{
+ eventLoopStallsTotal.fetch_add(1, std::memory_order_relaxed);
+ eventLoopLastStallMs.store(overshootMs, std::memory_order_relaxed);
+ qint64 prevMax = eventLoopMaxStallMs.load(std::memory_order_relaxed);
+ while (overshootMs > prevMax &&
+ !eventLoopMaxStallMs.compare_exchange_weak(prevMax, overshootMs, std::memory_order_relaxed)) {
+ // retry until the max is at least as high as the new sample
+ }
+
+ qWarning() << "Event loop stall in" << threadName << "- heartbeat overshot by" << overshootMs << "ms";
+}
+
+void Servatrice::watchWorkerThread(QThread *thread)
+{
+ if (metricsStallWarnMs <= 0) {
+ return; // watchdogs disabled via metrics/stall_warn_ms = 0
+ }
+
+ auto *watchdog = new EventLoopWatchdog(this, thread->objectName());
+ connect(thread, &QThread::finished, watchdog, &QObject::deleteLater);
+ watchdog->moveToThread(thread);
+ QMetaObject::invokeMethod(watchdog, &EventLoopWatchdog::start, Qt::QueuedConnection);
+}
+
+qint64 Servatrice::getCardsInGamesTotal() const
+{
+ qint64 total = 0;
+ QReadLocker roomsLocker(&roomsLock); // locking order: roomsLock before gamesLock/gameMutex
+ QMapIterator roomIterator(rooms);
+ while (roomIterator.hasNext()) {
+ Server_Room *room = roomIterator.next().value();
+ QReadLocker gamesLocker(&room->gamesLock);
+ for (auto *game : room->getGames()) {
+ total += game->getCardsInGame();
+ }
+ }
+ return total;
+}
+
void Servatrice::addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface)
{
databaseInterfaces.insert(thread, databaseInterface);
diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h
index 8b0f5ad60..8d964a52b 100644
--- a/servatrice/src/servatrice.h
+++ b/servatrice/src/servatrice.h
@@ -20,6 +20,8 @@
#ifndef SERVATRICE_H
#define SERVATRICE_H
+#include "metrics_registry.h"
+
#include
#include
#include
@@ -30,6 +32,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -170,6 +173,12 @@ private:
int uptime;
QMutex txBytesMutex, rxBytesMutex;
quint64 txBytes, rxBytes;
+ MetricsRegistry metricsRegistry;
+ int metricsSlowCommandMs = 500;
+ int metricsStallWarnMs = 2000;
+ std::atomic eventLoopStallsTotal{0}; ///< heartbeat overshoots past the warn threshold
+ std::atomic eventLoopLastStallMs{0}; ///< overshoot of the most recent stall
+ std::atomic eventLoopMaxStallMs{0}; ///< worst overshoot seen since process start
QString shutdownReason;
int shutdownMinutes;
@@ -286,6 +295,49 @@ public:
void incRxBytes(quint64 num);
void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface);
+ // Metrics (see [metrics] section in servatrice.ini.example)
+ MetricsRegistry &getMetricsRegistry()
+ {
+ return metricsRegistry;
+ }
+ /**
+ * Sums cards across all zones of all running games. Each game takes its
+ * own gameMutex -- the hot per-game lock every game action contends on --
+ * and then iterates every player's zones, so the scrape cost is really
+ * O(total cards in play) plus one mutex acquisition per live game. Keep
+ * scrapes infrequent in big multiplayer rooms.
+ */
+ qint64 getCardsInGamesTotal() const;
+ int getMetricsSlowCommandMs() const
+ {
+ return metricsSlowCommandMs;
+ }
+ /// Heartbeat overshoot that counts as a stall. A value of 0 disables the watchdogs.
+ int getMetricsStallWarnMs() const
+ {
+ return metricsStallWarnMs;
+ }
+ void observeGameStartDurationMs(qint64 elapsedMs) override;
+ qint64 getEventLoopStallsTotal() const
+ {
+ return eventLoopStallsTotal.load(std::memory_order_relaxed);
+ }
+ qint64 getEventLoopLastStallMs() const
+ {
+ return eventLoopLastStallMs.load(std::memory_order_relaxed);
+ }
+ qint64 getEventLoopMaxStallMs() const
+ {
+ return eventLoopMaxStallMs.load(std::memory_order_relaxed);
+ }
+ /// Records one heartbeat overshoot and logs a single warning for it.
+ void observeEventLoopStall(const QString &threadName, qint64 overshootMs);
+ /**
+ * Installs an EventLoopWatchdog in @p thread. Called once per socket pool
+ * thread right after it starts.
+ */
+ void watchWorkerThread(QThread *thread);
+
bool islConnectionExists(int _serverId) const;
void addIslInterface(int _serverId, IslInterface *interface);
void removeIslInterface(int _serverId);
diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp
index e6b18686c..c4c7046c3 100644
--- a/servatrice/src/servatrice_database_interface.cpp
+++ b/servatrice/src/servatrice_database_interface.cpp
@@ -642,6 +642,10 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer
userLevel |= ServerInfo_User::IsJudge;
}
+ if (is_admin & 8) {
+ userLevel |= ServerInfo_User::IsDeveloper;
+ }
+
result.set_user_level(userLevel);
const QString country = query->value(3).toString();
@@ -1449,7 +1453,7 @@ QList Servatrice_DatabaseInterface::getModeratorLastL
QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) "
"FROM {prefix}_users u "
"LEFT JOIN {prefix}_user_analytics a ON a.id = u.id "
- "WHERE (u.admin & 7) <> 0 ORDER BY u.name");
+ "WHERE (u.admin & 15) <> 0 ORDER BY u.name");
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error";
@@ -1470,6 +1474,9 @@ QList Servatrice_DatabaseInterface::getModeratorLastL
if (isAdmin & 4) {
userLevel |= ServerInfo_User::IsJudge;
}
+ if (isAdmin & 8) {
+ userLevel |= ServerInfo_User::IsDeveloper;
+ }
loginDetails.set_user_level(userLevel);
if (!query->value(2).isNull()) {
@@ -1481,6 +1488,38 @@ QList Servatrice_DatabaseInterface::getModeratorLastL
return results;
}
+Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId)
+{
+ UptimeSnapshot snapshot;
+
+ if (!checkSql()) {
+ return snapshot;
+ }
+
+ QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, "
+ "UNIX_TIMESTAMP(timest) FROM {prefix}_uptime "
+ "WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1");
+ query->bindValue(":id_server", serverId);
+
+ if (!execSqlQuery(query)) {
+ qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error";
+ return snapshot;
+ }
+
+ if (query->next()) {
+ snapshot.valid = true;
+ snapshot.usersCount = query->value(0).toULongLong();
+ snapshot.modsCount = query->value(1).toULongLong();
+ snapshot.gamesCount = query->value(2).toULongLong();
+ snapshot.txBytes = query->value(3).toULongLong();
+ snapshot.rxBytes = query->value(4).toULongLong();
+ snapshot.uptimeSecs = query->value(5).toULongLong();
+ snapshot.timest = query->value(6).toULongLong();
+ }
+
+ return snapshot;
+}
+
bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName)
{
if (!checkSql()) {
diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h
index cd76ae288..a891c7a3d 100644
--- a/servatrice/src/servatrice_database_interface.h
+++ b/servatrice/src/servatrice_database_interface.h
@@ -140,6 +140,21 @@ public:
QList getUserSessions(const QString &userName, int limit);
QList getUserAlts(const QString &userName);
QList getModeratorLastLogins();
+
+ // Uptime snapshot as recorded by Servatrice::statusUpdate() into the
+ // {prefix}_uptime table. valid is false when no snapshot exists yet.
+ struct UptimeSnapshot
+ {
+ bool valid = false;
+ quint64 usersCount = 0;
+ quint64 modsCount = 0;
+ quint64 gamesCount = 0;
+ quint64 txBytes = 0;
+ quint64 rxBytes = 0;
+ quint64 uptimeSecs = 0;
+ quint64 timest = 0;
+ };
+ UptimeSnapshot getLatestUptimeSnapshot(int serverId);
bool removeUserAvatar(const QString &userName);
bool addForgotPassword(const QString &user);
bool removeForgotPassword(const QString &user) override;
diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp
index 2a8b5f0a4..aeffd7081 100644
--- a/servatrice/src/serversocketinterface.cpp
+++ b/servatrice/src/serversocketinterface.cpp
@@ -30,6 +30,7 @@
#include
#include
+#include
#include