(&QComboBox::currentIndexChanged), this,
+ &GeneralSettingsPage::cardLanguageBoxChanged);
+
auto *languageGrid = new QGridLayout;
languageGrid->addWidget(&languageLabel, 0, 0);
languageGrid->addWidget(&languageBox, 0, 1);
- languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight);
+ languageGrid->addWidget(&cardLanguageLabel, 1, 0);
+ languageGrid->addWidget(&cardLanguageBox, 1, 1);
+ languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1);
+ languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight);
+
+ cardLanguageNoteLabel.setWordWrap(true);
+ cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
languageGroupBox = new QGroupBox;
languageGroupBox->setLayout(languageGrid);
@@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index)
SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString());
}
+void GeneralSettingsPage::cardLanguageBoxChanged(int index)
+{
+ const QString lang = cardLanguageBox.itemData(index).toString();
+ SettingsCache::instance().cardsDisplay().setCardLang(lang);
+
+ // Switching to a non-default language only takes effect after the card
+ // database is re-imported with that language selected; English data is always
+ // present, so switching back to English needs no prompt.
+ if (lang == "en") {
+ return;
+ }
+
+ // The binary cache does not track the language its entries were imported in,
+ // and the downloaded pictures were fetched with English art names, so both are
+ // stale until Oracle re-imports the database in the new language: drop them.
+ QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache");
+ CardPictureLoader::clearNetworkCache();
+ CardPictureLoader::clearPixmapCache();
+
+ // Art is resolved by the translated card name for non-English languages, so the
+ // matching Scryfall URL is added to the top of the download list. It stays
+ // visible in the deck editor settings, where it can be removed or reordered.
+ const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl();
+
+ QString message = tr("The card database only contains English card data. To see cards in %1, "
+ "Oracle must run once with this language selected and re-import the card "
+ "database.
"
+ "The cached database and the downloaded card pictures have been cleared, so a "
+ "re-import is picked up without stale entries.
")
+ .arg(cardLanguageBox.itemText(index));
+ if (localizedUrlAdded) {
+ message += tr("The Scryfall URL that resolves card art by translated name was added to the top of your "
+ "download list. You can remove or reorder it any time.
");
+ }
+ message += tr("Run Oracle now?
");
+
+ const QMessageBox::StandardButton answer = QMessageBox::question(
+ this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
+
+ // The answer only controls whether Oracle starts right away; the caches stay
+ // cleared so the next import or launch rebuilds them in the new language.
+ if (answer == QMessageBox::Yes) {
+ emit cardDatabaseUpdateRequested();
+ }
+}
+
void GeneralSettingsPage::updateStartupServerControlsVisibility()
{
const int index = startupTabSelector.currentIndex();
@@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi()
languageGroupBox->setTitle(tr("Language settings"));
languageLabel.setText(tr("Language:"));
+ cardLanguageBox.setItemText(0, tr("English"));
+ cardLanguageLabel.setText(tr("Card text & images language:"));
+ cardLanguageNoteLabel.setText(
+ tr("Foreign card names, text and art apply after you update the card database (Oracle)."));
advertiseTranslationPageLabel.setText(
QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
index e0c1a47bf..7afba158f 100644
--- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
@@ -23,6 +23,10 @@ public:
static QStringList findQmFiles();
static QString languageName(const QString &lang);
+signals:
+ /// Request to re-import the card database with the newly selected card language
+ void cardDatabaseUpdateRequested();
+
private slots:
void deckPathButtonClicked();
void filtersPathButtonClicked();
@@ -33,6 +37,7 @@ private slots:
void tokenDatabasePathButtonClicked();
void resetAllPathsClicked();
void languageBoxChanged(int index);
+ void cardLanguageBoxChanged(int index);
void updateStartupServerControlsVisibility();
private:
@@ -46,6 +51,10 @@ private:
QComboBox languageBox;
QLabel advertiseTranslationPageLabel;
+ QLabel cardLanguageLabel;
+ QComboBox cardLanguageBox;
+ QLabel cardLanguageNoteLabel;
+
QLabel updateReleaseChannelLabel;
QComboBox updateReleaseChannelBox;
QCheckBox startupUpdateCheckCheckBox;
diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp
index 66b68d823..657ef3dbe 100644
--- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp
@@ -1,5 +1,6 @@
#include "archidekt_api_response_deck_display_widget.h"
+#include "../../../../../../client/settings/cache_settings.h"
#include "../../../../../deck_loader/card_node_function.h"
#include "../../../../../deck_loader/deck_loader.h"
#include "../../../../cards/card_size_widget.h"
@@ -10,6 +11,7 @@
#include
#include
+#include
ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent,
ArchidektApiResponseDeck _response,
@@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi
}
model = new DeckListModel(this);
+ model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang());
+ connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model,
+ [this](const QString &lang) { model->setDisplayLanguage(lang); });
connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset);
auto decklist = QSharedPointer(new DeckList);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp
index 13a77e957..fca32094c 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp
@@ -13,6 +13,7 @@
#include
#include
#include
+#include
#include
#include
@@ -185,25 +186,37 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event)
void TabServer::joinRoom(int id, bool setCurrent)
{
TabRoom *room = tabSupervisor->getRoomTabs().value(id);
- if (!room) {
- Command_JoinRoom cmd;
- cmd.set_room_id(id);
-
- PendingCommand *pend = client->prepareSessionCommand(cmd);
- pend->setExtraData(setCurrent);
- connect(pend, &PendingCommand::finished, this,
- [this, id](const Response &r, const CommandContainer &c, const QVariant &v) {
- joinRoomFinished(r, c, v, id);
- });
-
- client->sendCommand(pend);
-
+ if (room) {
+ if (setCurrent) {
+ tabSupervisor->setCurrentWidget((QWidget *)room);
+ }
return;
}
- if (setCurrent) {
- tabSupervisor->setCurrentWidget((QWidget *)room);
+ auto pendingIt = pendingRoomJoins.find(id);
+ if (pendingIt != pendingRoomJoins.end()) {
+ // A join for this room is already in flight: the room tab opens when its response
+ // arrives. Fold the new request into the pending one so that, for example, clicking
+ // a room the selector is auto-joining does not send a second Command_JoinRoom - the
+ // server would reject that duplicate with RespContextError.
+ if (setCurrent) {
+ pendingIt.value() = true;
+ }
+ return;
}
+
+ pendingRoomJoins.insert(id, setCurrent);
+
+ Command_JoinRoom cmd;
+ cmd.set_room_id(id);
+
+ PendingCommand *pend = client->prepareSessionCommand(cmd);
+ pend->setExtraData(setCurrent);
+ connect(
+ pend, &PendingCommand::finished, this,
+ [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); });
+
+ client->sendCommand(pend);
}
void TabServer::joinRoomFinished(const Response &r,
@@ -211,34 +224,72 @@ void TabServer::joinRoomFinished(const Response &r,
const QVariant &extraData,
int roomId)
{
+ const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool());
+ pendingRoomJoins.remove(roomId);
+ const bool healedJoin = healedRoomJoins.contains(roomId);
+ healedRoomJoins.remove(roomId);
+
switch (r.response_code()) {
case Response::RespOk:
break;
case Response::RespNameNotFound:
- QMessageBox::critical(this, tr("Error"),
- tr("Failed to join the server room: it doesn't exist on the server."));
+ if (setCurrent) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Failed to join the server room: it doesn't exist on the server."));
+ }
emit roomJoinFailed(roomId);
return;
case Response::RespContextError:
- QMessageBox::critical(
- this, tr("Error"),
- tr("The server thinks you are in the server room but your client is unable to display it. "
- "Try restarting your client."));
- emit roomJoinFailed(roomId);
+ if (healedJoin) {
+ // The rejoin below was already answered and the server still rejects the join, so
+ // the stale-membership heal cannot help: surface the error. The guard was already
+ // released above so a later user-initiated join may try a fresh heal.
+ if (setCurrent) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("The server thinks you are in the server room but your client is unable to display it. "
+ "Try restarting your client."));
+ }
+ emit roomJoinFailed(roomId);
+ return;
+ }
+ // The server already had us registered in the room even though no tab was open,
+ // usually because two join attempts for the same room overlapped. Leaving and
+ // rejoining makes the server reply with a fresh RespOk so the tab is displayed
+ // without requiring a client restart. The guard above covers exactly the rejoin that
+ // leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError
+ // gets one heal attempt per join instead of an endless recursion.
+ healedRoomJoins.insert(roomId);
+ leaveAndRejoinRoom(roomId, setCurrent);
return;
case Response::RespUserLevelTooLow:
- QMessageBox::critical(this, tr("Error"),
- tr("You do not have the required permission to join this server room."));
+ if (setCurrent) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("You do not have the required permission to join this server room."));
+ }
emit roomJoinFailed(roomId);
return;
default:
- QMessageBox::critical(
- this, tr("Error"),
- tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
+ if (setCurrent) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
+ }
emit roomJoinFailed(roomId);
return;
}
const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext);
- emit roomJoined(resp.room_info(), extraData.toBool());
+ emit roomJoined(resp.room_info(), setCurrent);
+}
+
+void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent)
+{
+ // Clear the stale room membership server-side. The leave is sent before the rejoin below,
+ // so the server no longer considers us a member by the time the join arrives. The leave
+ // response is intentionally not awaited: commands are processed in send order on the
+ // connection, and a failed leave (RespNotInRoom) only means the membership was already gone.
+ client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId));
+
+ joinRoom(roomId, setCurrent);
}
diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h
index c10b7945b..121ff814d 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_server.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_server.h
@@ -10,6 +10,8 @@
#include "tab.h"
#include
+#include
+#include
#include
#include
@@ -58,10 +60,17 @@ private slots:
int roomId);
private:
+ void leaveAndRejoinRoom(int roomId, bool setCurrent);
+
AbstractClient *client;
RoomSelector *roomSelector;
QTextBrowser *serverInfoBox;
bool shouldEmitUpdate = false;
+ /** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */
+ QHash pendingRoomJoins;
+ /** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the
+ * rejoin has been answered, so a heal is attempted at most once per join. */
+ QSet healedRoomJoins;
public:
TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
index ce70db6c2..c793b0744 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
@@ -1451,6 +1451,11 @@ bool TabSupervisor::getAdminLocked() const
return tabAdmin->getLocked();
}
+bool TabSupervisor::canOverrideGameRestrictions() const
+{
+ return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge);
+}
+
void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event)
{
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
index aec1d7418..adde7f971 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
@@ -171,6 +171,7 @@ public:
[[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const;
void sendInviteToUser(const QString &userName, const QString &inviteText);
[[nodiscard]] bool getAdminLocked() const;
+ [[nodiscard]] bool canOverrideGameRestrictions() const;
void closeEvent(QCloseEvent *event) override;
bool switchToGameTabIfAlreadyExists(const int gameId);
static void actShowPopup(const QString &message);
diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp
index 3daaeb8d3..722df90ef 100644
--- a/cockatrice/src/interface/window_main.cpp
+++ b/cockatrice/src/interface/window_main.cpp
@@ -33,6 +33,7 @@
#include "../interface/widgets/dialogs/dlg_update.h"
#include "../interface/widgets/dialogs/dlg_view_log.h"
#include "../interface/widgets/onboarding/first_run_wizard.h"
+#include "../interface/widgets/settings_page/general_settings_page.h"
#include "../interface/widgets/tabs/tab_game.h"
#include "../interface/widgets/tabs/tab_server.h"
#include "../interface/widgets/tabs/tab_supervisor.h"
@@ -246,6 +247,8 @@ void MainWindow::actFullScreen(bool checked)
void MainWindow::actSettings()
{
DlgSettings dlg(this);
+ auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage));
+ connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates);
dlg.exec();
}
diff --git a/cockatrice/themes/CMakeLists.txt b/cockatrice/themes/CMakeLists.txt
index 0c512bf2e..adfbddaed 100644
--- a/cockatrice/themes/CMakeLists.txt
+++ b/cockatrice/themes/CMakeLists.txt
@@ -2,7 +2,7 @@
#
# Add themes subfolders
-set(defthemes Default Fabric Fusion Leather Plasma VelvetMarble)
+set(defthemes Fabric Fusion Leather Plasma VelvetMarble System)
if(UNIX)
if(APPLE)
diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png
new file mode 100644
index 000000000..8d9514626
Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.png differ
diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png
new file mode 100644
index 000000000..78f51a686
Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.png differ
diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml
new file mode 100644
index 000000000..cad18921a
--- /dev/null
+++ b/cockatrice/themes/Fabric/palette-default-dark.toml
@@ -0,0 +1,74 @@
+# Fabric — dark identity palette (navy-cloth chrome)
+[Palette]
+WindowText = #e8ecf2
+Button = #2b374a
+Light = #3a4759
+Midlight = #333f50
+Dark = #141a23
+Mid = #252c38
+Text = #e8ecf2
+BrightText = #8ec2ff
+ButtonText = #f2f4f8
+Base = #222c3d
+Window = #18202e
+Shadow = #0b0f15
+Highlight = #4a82e6
+HighlightedText = #ffffff
+Link = #7fb0f2
+LinkVisited = #a98ad9
+AlternateBase = #1d2636
+ToolTipBase = #2b3546
+ToolTipText = #f2f4f8
+PlaceholderText = #6ee8ecf2
+Accent = #4a82e6
+
+[Palette.Disabled]
+WindowText = #9d9d9d
+Button = #18202e
+Light = #3a4759
+Midlight = #333f50
+Dark = #141a23
+Mid = #252c38
+Text = #9d9d9d
+BrightText = #8ec2ff
+ButtonText = #787878
+Base = #18202e
+Window = #18202e
+Shadow = #0b0f15
+Highlight = #222d40
+HighlightedText = #9d9d9d
+Link = #308cc6
+LinkVisited = #b450ff
+AlternateBase = #1d2636
+ToolTipBase = #2b3546
+ToolTipText = #f2f4f8
+PlaceholderText = #6ee8ecf2
+Accent = #9d9d9d
+
+[Palette.Inactive]
+WindowText = #e8ecf2
+Button = #2b374a
+Light = #3a4759
+Midlight = #333f50
+Dark = #141a23
+Mid = #252c38
+Text = #e8ecf2
+BrightText = #8ec2ff
+ButtonText = #f2f4f8
+Base = #222c3d
+Window = #18202e
+Shadow = #0b0f15
+Highlight = #222d40
+HighlightedText = #ffffff
+Link = #7fb0f2
+LinkVisited = #a98ad9
+AlternateBase = #1d2636
+ToolTipBase = #2b3546
+ToolTipText = #f2f4f8
+PlaceholderText = #6ee8ecf2
+Accent = #4a82e6
+
+[AppColors]
+AccentStrong = #3a6fd6
+AccentSoft = #8fb8f2
+
diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml
new file mode 100644
index 000000000..f5151af3e
--- /dev/null
+++ b/cockatrice/themes/Fabric/palette-default-light.toml
@@ -0,0 +1,74 @@
+# Fabric — light identity palette (navy-cloth chrome)
+[Palette]
+WindowText = #1c2330
+Button = #c8d6ea
+Light = #ffffff
+Midlight = #b9c6dc
+Dark = #8fa0bd
+Mid = #a3b3cd
+Text = #1c2330
+BrightText = #1d4fa0
+ButtonText = #141c29
+Base = #f6f8fc
+Window = #dbe3ef
+Shadow = #5a6a85
+Highlight = #2f6fd6
+HighlightedText = #ffffff
+Link = #2555b0
+LinkVisited = #6a4bb8
+AlternateBase = #e9eef7
+ToolTipBase = #e9eff8
+ToolTipText = #141c29
+PlaceholderText = #7a1c2330
+Accent = #2f6fd6
+
+[Palette.Disabled]
+WindowText = #787878
+Button = #dbe3ef
+Light = #ffffff
+Midlight = #b9c6dc
+Dark = #8fa0bd
+Mid = #a3b3cd
+Text = #787878
+BrightText = #1d4fa0
+ButtonText = #969696
+Base = #dbe3ef
+Window = #dbe3ef
+Shadow = #5a6a85
+Highlight = #cdd8e9
+HighlightedText = #787878
+Link = #0000ff
+LinkVisited = #ff00ff
+AlternateBase = #e9eef7
+ToolTipBase = #e9eff8
+ToolTipText = #141c29
+PlaceholderText = #7a1c2330
+Accent = #787878
+
+[Palette.Inactive]
+WindowText = #1c2330
+Button = #c8d6ea
+Light = #ffffff
+Midlight = #b9c6dc
+Dark = #8fa0bd
+Mid = #a3b3cd
+Text = #1c2330
+BrightText = #1d4fa0
+ButtonText = #141c29
+Base = #f6f8fc
+Window = #dbe3ef
+Shadow = #5a6a85
+Highlight = #cdd8e9
+HighlightedText = #000000
+Link = #2555b0
+LinkVisited = #6a4bb8
+AlternateBase = #e9eef7
+ToolTipBase = #e9eff8
+ToolTipText = #141c29
+PlaceholderText = #7a1c2330
+Accent = #2f6fd6
+
+[AppColors]
+AccentStrong = #3a6fd6
+AccentSoft = #8fb8f2
+
diff --git a/cockatrice/themes/Fabric/theme.cfg b/cockatrice/themes/Fabric/theme.cfg
new file mode 100644
index 000000000..55b916e71
--- /dev/null
+++ b/cockatrice/themes/Fabric/theme.cfg
@@ -0,0 +1,5 @@
+[Appearance]
+ColorScheme = System
+
+[Style]
+Name = Fusion
\ No newline at end of file
diff --git a/cockatrice/themes/Fabric/zones/handzone-light.png b/cockatrice/themes/Fabric/zones/handzone-light.png
new file mode 100644
index 000000000..a87f9e292
Binary files /dev/null and b/cockatrice/themes/Fabric/zones/handzone-light.png differ
diff --git a/cockatrice/themes/Fabric/zones/handzone.png b/cockatrice/themes/Fabric/zones/handzone.png
index 25d22b070..a5b72286c 100644
Binary files a/cockatrice/themes/Fabric/zones/handzone.png and b/cockatrice/themes/Fabric/zones/handzone.png differ
diff --git a/cockatrice/themes/Fabric/zones/playerzone-light.png b/cockatrice/themes/Fabric/zones/playerzone-light.png
new file mode 100644
index 000000000..05da3ea9b
Binary files /dev/null and b/cockatrice/themes/Fabric/zones/playerzone-light.png differ
diff --git a/cockatrice/themes/Fabric/zones/playerzone.png b/cockatrice/themes/Fabric/zones/playerzone.png
index 728fccdfa..7fa977249 100644
Binary files a/cockatrice/themes/Fabric/zones/playerzone.png and b/cockatrice/themes/Fabric/zones/playerzone.png differ
diff --git a/cockatrice/themes/Fabric/zones/stackzone-light.png b/cockatrice/themes/Fabric/zones/stackzone-light.png
new file mode 100644
index 000000000..dcd6dc045
Binary files /dev/null and b/cockatrice/themes/Fabric/zones/stackzone-light.png differ
diff --git a/cockatrice/themes/Fabric/zones/stackzone.png b/cockatrice/themes/Fabric/zones/stackzone.png
index 89e2080f8..9046aa049 100644
Binary files a/cockatrice/themes/Fabric/zones/stackzone.png and b/cockatrice/themes/Fabric/zones/stackzone.png differ
diff --git a/cockatrice/themes/Fabric/zones/tablezone-light.png b/cockatrice/themes/Fabric/zones/tablezone-light.png
new file mode 100644
index 000000000..cb297e7e1
Binary files /dev/null and b/cockatrice/themes/Fabric/zones/tablezone-light.png differ
diff --git a/cockatrice/themes/Fabric/zones/tablezone.png b/cockatrice/themes/Fabric/zones/tablezone.png
index 6a2ce68e1..8048a4cc1 100644
Binary files a/cockatrice/themes/Fabric/zones/tablezone.png and b/cockatrice/themes/Fabric/zones/tablezone.png differ
diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml
index c1d83a4cd..b180e9b63 100644
--- a/cockatrice/themes/Fusion/palette-default-dark.toml
+++ b/cockatrice/themes/Fusion/palette-default-dark.toml
@@ -11,15 +11,15 @@ ButtonText = #ffffffff
Base = #ff2d2d2d
Window = #ff1e1e1e
Shadow = #ff000000
-Highlight = #ff148c3c
+Highlight = #ff139740
HighlightedText = #ffffffff
-Link = #ff00f652
-LinkVisited = #ff00d346
+Link = #ffc9fd62
+LinkVisited = #ff9ad43e
AlternateBase = #ff353535
ToolTipBase = #ff3c3c3c
ToolTipText = #ffd4d4d4
PlaceholderText = #80ffffff
-Accent = #ff00d346
+Accent = #ffc9fd62
[Palette.Disabled]
WindowText = #ff9d9d9d
@@ -34,7 +34,7 @@ ButtonText = #ff9d9d9d
Base = #ff1e1e1e
Window = #ff1e1e1e
Shadow = #ff000000
-Highlight = #ff148c3c
+Highlight = #ff139740
HighlightedText = #ffffffff
Link = #ff308cc6
LinkVisited = #ffff00ff
@@ -59,11 +59,15 @@ Window = #ff1e1e1e
Shadow = #ff000000
Highlight = #ff1e1e1e
HighlightedText = #ffffffff
-Link = #ff00f652
-LinkVisited = #ff00d346
+Link = #ffc9fd62
+LinkVisited = #ff9ad43e
AlternateBase = #ff353535
ToolTipBase = #ff3c3c3c
ToolTipText = #ffd4d4d4
PlaceholderText = #80ffffff
Accent = #ff1e1e1e
+
+[AppColors]
+AccentStrong = #ff139740
+AccentSoft = #ffc9fd62
diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml
index 86c41be78..5a1163a2b 100644
--- a/cockatrice/themes/Fusion/palette-default-light.toml
+++ b/cockatrice/themes/Fusion/palette-default-light.toml
@@ -11,15 +11,15 @@ ButtonText = #ff000000
Base = #ffffffff
Window = #fff0f0f0
Shadow = #ff696969
-Highlight = #ff148c3c
+Highlight = #ff139740
HighlightedText = #ffffffff
-Link = #ff0d5f28
-LinkVisited = #ff08401b
+Link = #ff0e6b31
+LinkVisited = #ff0a521f
AlternateBase = #ffe9e7e3
ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #80000000
-Accent = #ff107532
+Accent = #ff139740
[Palette.Disabled]
WindowText = #ff787878
@@ -34,7 +34,7 @@ ButtonText = #ff787878
Base = #fff0f0f0
Window = #fff0f0f0
Shadow = #ff000000
-Highlight = #ff148c3c
+Highlight = #ff139740
HighlightedText = #ffffffff
Link = #ff0000ff
LinkVisited = #ffff00ff
@@ -59,11 +59,15 @@ Window = #fff0f0f0
Shadow = #ff696969
Highlight = #fff0f0f0
HighlightedText = #ff000000
-Link = #ff0d5f28
-LinkVisited = #ff08401b
+Link = #ff0e6b31
+LinkVisited = #ff0a521f
AlternateBase = #ffe9e7e3
ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #80000000
Accent = #fff0f0f0
+
+[AppColors]
+AccentStrong = #ff139740
+AccentSoft = #ffc9fd62
diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.png b/cockatrice/themes/Leather/backgrounds/home-dark.png
new file mode 100644
index 000000000..20d16a27f
Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.png differ
diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png
new file mode 100644
index 000000000..ee1da361d
Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.png differ
diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml
new file mode 100644
index 000000000..b0db0e90a
--- /dev/null
+++ b/cockatrice/themes/Leather/palette-default-dark.toml
@@ -0,0 +1,74 @@
+# Leather — dark identity palette (black-brown chrome with brass accents)
+[Palette]
+WindowText = #eee9e2
+Button = #332d26
+Light = #4a4239
+Midlight = #3d3730
+Dark = #131009
+Mid = #26221c
+Text = #eee9e2
+BrightText = #e3c27e
+ButtonText = #f5f1ea
+Base = #26221d
+Window = #1d1a16
+Shadow = #0d0b07
+Highlight = #4a5f8f
+HighlightedText = #ffffff
+Link = #cfa263
+LinkVisited = #9a7db8
+AlternateBase = #211d19
+ToolTipBase = #37302a
+ToolTipText = #f5f1ea
+PlaceholderText = #6eeee9e2
+Accent = #c9995a
+
+[Palette.Disabled]
+WindowText = #9d9d9d
+Button = #1d1a16
+Light = #4a4239
+Midlight = #3d3730
+Dark = #131009
+Mid = #26221c
+Text = #9d9d9d
+BrightText = #e3c27e
+ButtonText = #787878
+Base = #1d1a16
+Window = #1d1a16
+Shadow = #0d0b07
+Highlight = #2d2822
+HighlightedText = #9d9d9d
+Link = #308cc6
+LinkVisited = #b450ff
+AlternateBase = #211d19
+ToolTipBase = #37302a
+ToolTipText = #f5f1ea
+PlaceholderText = #6eeee9e2
+Accent = #9d9d9d
+
+[Palette.Inactive]
+WindowText = #eee9e2
+Button = #332d26
+Light = #4a4239
+Midlight = #3d3730
+Dark = #131009
+Mid = #26221c
+Text = #eee9e2
+BrightText = #e3c27e
+ButtonText = #f5f1ea
+Base = #26221d
+Window = #1d1a16
+Shadow = #0d0b07
+Highlight = #2d2822
+HighlightedText = #ffffff
+Link = #cfa263
+LinkVisited = #9a7db8
+AlternateBase = #211d19
+ToolTipBase = #37302a
+ToolTipText = #f5f1ea
+PlaceholderText = #6eeee9e2
+Accent = #c9995a
+
+[AppColors]
+AccentStrong = #b7823b
+AccentSoft = #e4c58f
+
diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml
new file mode 100644
index 000000000..ff122db76
--- /dev/null
+++ b/cockatrice/themes/Leather/palette-default-light.toml
@@ -0,0 +1,74 @@
+# Leather — light identity palette (black-brown chrome with brass accents)
+[Palette]
+WindowText = #2a2114
+Button = #dfcbb0
+Light = #ffffff
+Midlight = #c6b08c
+Dark = #a18a64
+Mid = #b29a74
+Text = #2a2114
+BrightText = #7a531c
+ButtonText = #1f1710
+Base = #fdf8ee
+Window = #f0e2cb
+Shadow = #5d4d33
+Highlight = #34508c
+HighlightedText = #ffffff
+Link = #8a5e1e
+LinkVisited = #6b5190
+AlternateBase = #f5ebd7
+ToolTipBase = #fff7e8
+ToolTipText = #1f1710
+PlaceholderText = #7a2a2114
+Accent = #a5712f
+
+[Palette.Disabled]
+WindowText = #787878
+Button = #f0e2cb
+Light = #ffffff
+Midlight = #c6b08c
+Dark = #a18a64
+Mid = #b29a74
+Text = #787878
+BrightText = #7a531c
+ButtonText = #969696
+Base = #f0e2cb
+Window = #f0e2cb
+Shadow = #5d4d33
+Highlight = #ecd9bb
+HighlightedText = #787878
+Link = #0000ff
+LinkVisited = #ff00ff
+AlternateBase = #f5ebd7
+ToolTipBase = #fff7e8
+ToolTipText = #1f1710
+PlaceholderText = #7a2a2114
+Accent = #787878
+
+[Palette.Inactive]
+WindowText = #2a2114
+Button = #dfcbb0
+Light = #ffffff
+Midlight = #c6b08c
+Dark = #a18a64
+Mid = #b29a74
+Text = #2a2114
+BrightText = #7a531c
+ButtonText = #1f1710
+Base = #fdf8ee
+Window = #f0e2cb
+Shadow = #5d4d33
+Highlight = #ecd9bb
+HighlightedText = #000000
+Link = #8a5e1e
+LinkVisited = #6b5190
+AlternateBase = #f5ebd7
+ToolTipBase = #fff7e8
+ToolTipText = #1f1710
+PlaceholderText = #7a2a2114
+Accent = #a5712f
+
+[AppColors]
+AccentStrong = #b7823b
+AccentSoft = #e4c58f
+
diff --git a/cockatrice/themes/Leather/theme.cfg b/cockatrice/themes/Leather/theme.cfg
new file mode 100644
index 000000000..55b916e71
--- /dev/null
+++ b/cockatrice/themes/Leather/theme.cfg
@@ -0,0 +1,5 @@
+[Appearance]
+ColorScheme = System
+
+[Style]
+Name = Fusion
\ No newline at end of file
diff --git a/cockatrice/themes/Leather/zones/handzone-light.png b/cockatrice/themes/Leather/zones/handzone-light.png
new file mode 100644
index 000000000..b2c9bba34
Binary files /dev/null and b/cockatrice/themes/Leather/zones/handzone-light.png differ
diff --git a/cockatrice/themes/Leather/zones/handzone.png b/cockatrice/themes/Leather/zones/handzone.png
index aba879683..5f803df74 100644
Binary files a/cockatrice/themes/Leather/zones/handzone.png and b/cockatrice/themes/Leather/zones/handzone.png differ
diff --git a/cockatrice/themes/Leather/zones/playerzone-light.png b/cockatrice/themes/Leather/zones/playerzone-light.png
new file mode 100644
index 000000000..a26f96a5e
Binary files /dev/null and b/cockatrice/themes/Leather/zones/playerzone-light.png differ
diff --git a/cockatrice/themes/Leather/zones/playerzone.png b/cockatrice/themes/Leather/zones/playerzone.png
index 4f42ee41d..cccbb3fd3 100644
Binary files a/cockatrice/themes/Leather/zones/playerzone.png and b/cockatrice/themes/Leather/zones/playerzone.png differ
diff --git a/cockatrice/themes/Leather/zones/stackzone-light.png b/cockatrice/themes/Leather/zones/stackzone-light.png
new file mode 100644
index 000000000..117c738f7
Binary files /dev/null and b/cockatrice/themes/Leather/zones/stackzone-light.png differ
diff --git a/cockatrice/themes/Leather/zones/stackzone.png b/cockatrice/themes/Leather/zones/stackzone.png
index c02e2284a..d9cff892b 100644
Binary files a/cockatrice/themes/Leather/zones/stackzone.png and b/cockatrice/themes/Leather/zones/stackzone.png differ
diff --git a/cockatrice/themes/Leather/zones/tablezone-light.png b/cockatrice/themes/Leather/zones/tablezone-light.png
new file mode 100644
index 000000000..912cbfb94
Binary files /dev/null and b/cockatrice/themes/Leather/zones/tablezone-light.png differ
diff --git a/cockatrice/themes/Leather/zones/tablezone.png b/cockatrice/themes/Leather/zones/tablezone.png
index 152f4f7e9..8af4c853d 100644
Binary files a/cockatrice/themes/Leather/zones/tablezone.png and b/cockatrice/themes/Leather/zones/tablezone.png differ
diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png
new file mode 100644
index 000000000..ce1469e2b
Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.png differ
diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png
new file mode 100644
index 000000000..2e9000bd6
Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.png differ
diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml
new file mode 100644
index 000000000..222456f4f
--- /dev/null
+++ b/cockatrice/themes/Plasma/palette-default-dark.toml
@@ -0,0 +1,74 @@
+# Plasma — dark identity palette (electric violet chrome with cyan sparks)
+[Palette]
+WindowText = #eeebff
+Button = #2a2440
+Light = #453d63
+Midlight = #383252
+Dark = #0e0c16
+Mid = #211e31
+Text = #eeebff
+BrightText = #7fd7ff
+ButtonText = #f6f4ff
+Base = #1e1a2e
+Window = #151220
+Shadow = #08070e
+Highlight = #6a4df0
+HighlightedText = #ffffff
+Link = #37c7e8
+LinkVisited = #9b7aff
+AlternateBase = #1a1726
+ToolTipBase = #2c2745
+ToolTipText = #f6f4ff
+PlaceholderText = #6eeeebff
+Accent = #6a4df0
+
+[Palette.Disabled]
+WindowText = #9d9d9d
+Button = #151220
+Light = #453d63
+Midlight = #383252
+Dark = #0e0c16
+Mid = #211e31
+Text = #9d9d9d
+BrightText = #7fd7ff
+ButtonText = #787878
+Base = #151220
+Window = #151220
+Shadow = #08070e
+Highlight = #211c32
+HighlightedText = #9d9d9d
+Link = #308cc6
+LinkVisited = #b450ff
+AlternateBase = #1a1726
+ToolTipBase = #2c2745
+ToolTipText = #f6f4ff
+PlaceholderText = #6eeeebff
+Accent = #9d9d9d
+
+[Palette.Inactive]
+WindowText = #eeebff
+Button = #2a2440
+Light = #453d63
+Midlight = #383252
+Dark = #0e0c16
+Mid = #211e31
+Text = #eeebff
+BrightText = #7fd7ff
+ButtonText = #f6f4ff
+Base = #1e1a2e
+Window = #151220
+Shadow = #08070e
+Highlight = #211c32
+HighlightedText = #ffffff
+Link = #37c7e8
+LinkVisited = #9b7aff
+AlternateBase = #1a1726
+ToolTipBase = #2c2745
+ToolTipText = #f6f4ff
+PlaceholderText = #6eeeebff
+Accent = #6a4df0
+
+[AppColors]
+AccentStrong = #5b3ee0
+AccentSoft = #a28bf7
+
diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml
new file mode 100644
index 000000000..d54db31d2
--- /dev/null
+++ b/cockatrice/themes/Plasma/palette-default-light.toml
@@ -0,0 +1,74 @@
+# Plasma — light identity palette (electric violet chrome with cyan sparks)
+[Palette]
+WindowText = #1d1830
+Button = #cfc6f0
+Light = #ffffff
+Midlight = #b7adde
+Dark = #9589c8
+Mid = #a49ad2
+Text = #1d1830
+BrightText = #10406e
+ButtonText = #120d26
+Base = #f9f7ff
+Window = #e3dcf7
+Shadow = #554c85
+Highlight = #5a3ee2
+HighlightedText = #ffffff
+Link = #0f8fb5
+LinkVisited = #6a45d6
+AlternateBase = #ece8fa
+ToolTipBase = #f5f3ff
+ToolTipText = #120d26
+PlaceholderText = #7a1d1830
+Accent = #5a3ee2
+
+[Palette.Disabled]
+WindowText = #787878
+Button = #e3dcf7
+Light = #ffffff
+Midlight = #b7adde
+Dark = #9589c8
+Mid = #a49ad2
+Text = #787878
+BrightText = #10406e
+ButtonText = #969696
+Base = #e3dcf7
+Window = #e3dcf7
+Shadow = #554c85
+Highlight = #d6ccf3
+HighlightedText = #787878
+Link = #0000ff
+LinkVisited = #ff00ff
+AlternateBase = #ece8fa
+ToolTipBase = #f5f3ff
+ToolTipText = #120d26
+PlaceholderText = #7a1d1830
+Accent = #787878
+
+[Palette.Inactive]
+WindowText = #1d1830
+Button = #cfc6f0
+Light = #ffffff
+Midlight = #b7adde
+Dark = #9589c8
+Mid = #a49ad2
+Text = #1d1830
+BrightText = #10406e
+ButtonText = #120d26
+Base = #f9f7ff
+Window = #e3dcf7
+Shadow = #554c85
+Highlight = #d6ccf3
+HighlightedText = #000000
+Link = #0f8fb5
+LinkVisited = #6a45d6
+AlternateBase = #ece8fa
+ToolTipBase = #f5f3ff
+ToolTipText = #120d26
+PlaceholderText = #7a1d1830
+Accent = #5a3ee2
+
+[AppColors]
+AccentStrong = #5b3ee0
+AccentSoft = #a28bf7
+
diff --git a/cockatrice/themes/Plasma/theme.cfg b/cockatrice/themes/Plasma/theme.cfg
new file mode 100644
index 000000000..55b916e71
--- /dev/null
+++ b/cockatrice/themes/Plasma/theme.cfg
@@ -0,0 +1,5 @@
+[Appearance]
+ColorScheme = System
+
+[Style]
+Name = Fusion
\ No newline at end of file
diff --git a/cockatrice/themes/Plasma/zones/handzone-light.png b/cockatrice/themes/Plasma/zones/handzone-light.png
new file mode 100644
index 000000000..89fa7e6d4
Binary files /dev/null and b/cockatrice/themes/Plasma/zones/handzone-light.png differ
diff --git a/cockatrice/themes/Plasma/zones/handzone.png b/cockatrice/themes/Plasma/zones/handzone.png
index 543571d8a..82debb405 100644
Binary files a/cockatrice/themes/Plasma/zones/handzone.png and b/cockatrice/themes/Plasma/zones/handzone.png differ
diff --git a/cockatrice/themes/Plasma/zones/playerzone-light.png b/cockatrice/themes/Plasma/zones/playerzone-light.png
new file mode 100644
index 000000000..9034818f2
Binary files /dev/null and b/cockatrice/themes/Plasma/zones/playerzone-light.png differ
diff --git a/cockatrice/themes/Plasma/zones/playerzone.png b/cockatrice/themes/Plasma/zones/playerzone.png
index eebcd1f84..4623524f6 100644
Binary files a/cockatrice/themes/Plasma/zones/playerzone.png and b/cockatrice/themes/Plasma/zones/playerzone.png differ
diff --git a/cockatrice/themes/Plasma/zones/stackzone-light.png b/cockatrice/themes/Plasma/zones/stackzone-light.png
new file mode 100644
index 000000000..2607b3389
Binary files /dev/null and b/cockatrice/themes/Plasma/zones/stackzone-light.png differ
diff --git a/cockatrice/themes/Plasma/zones/stackzone.png b/cockatrice/themes/Plasma/zones/stackzone.png
index 1845c7091..144d991f3 100644
Binary files a/cockatrice/themes/Plasma/zones/stackzone.png and b/cockatrice/themes/Plasma/zones/stackzone.png differ
diff --git a/cockatrice/themes/Plasma/zones/tablezone-light.png b/cockatrice/themes/Plasma/zones/tablezone-light.png
new file mode 100644
index 000000000..dcf2ef95e
Binary files /dev/null and b/cockatrice/themes/Plasma/zones/tablezone-light.png differ
diff --git a/cockatrice/themes/Plasma/zones/tablezone.png b/cockatrice/themes/Plasma/zones/tablezone.png
index 8f998d4bb..4dc36ec2d 100644
Binary files a/cockatrice/themes/Plasma/zones/tablezone.png and b/cockatrice/themes/Plasma/zones/tablezone.png differ
diff --git a/cockatrice/themes/Default/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml
similarity index 96%
rename from cockatrice/themes/Default/palette-default-dark.toml
rename to cockatrice/themes/System/palette-default-dark.toml
index 3ee174a2f..e101935b4 100644
--- a/cockatrice/themes/Default/palette-default-dark.toml
+++ b/cockatrice/themes/System/palette-default-dark.toml
@@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #6effffff
+
+[AppColors]
+AccentStrong = #ff148c3c
+AccentSoft = #ff78c850
diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml
new file mode 100644
index 000000000..14c215cdf
--- /dev/null
+++ b/cockatrice/themes/System/palette-default-light.toml
@@ -0,0 +1,67 @@
+[Palette]
+WindowText = #ff000000
+Button = #fff0f0f0
+Light = #ffffffff
+Midlight = #ffe3e3e3
+Dark = #ffa0a0a0
+Mid = #ffa0a0a0
+Text = #ff000000
+BrightText = #ffffffff
+ButtonText = #ff000000
+Base = #ffffffff
+Window = #fff0f0f0
+Shadow = #ff696969
+HighlightedText = #ffffffff
+Link = #ff0d5f28
+LinkVisited = #ff08401b
+AlternateBase = #ffe9e7e3
+ToolTipBase = #ffffffdc
+ToolTipText = #ff000000
+PlaceholderText = #80000000
+
+[Palette.Disabled]
+WindowText = #ff787878
+Button = #fff0f0f0
+Light = #ffffffff
+Midlight = #fff7f7f7
+Dark = #ffa0a0a0
+Mid = #ffa0a0a0
+Text = #ff787878
+BrightText = #ffffffff
+ButtonText = #ff787878
+Base = #fff0f0f0
+Window = #fff0f0f0
+Shadow = #ff000000
+HighlightedText = #ffffffff
+Link = #ff0000ff
+LinkVisited = #ffff00ff
+AlternateBase = #fff7f7f7
+ToolTipBase = #ffffffdc
+ToolTipText = #ff000000
+PlaceholderText = #80000000
+
+[Palette.Inactive]
+WindowText = #ff000000
+Button = #fff0f0f0
+Light = #ffffffff
+Midlight = #ffe3e3e3
+Dark = #ffa0a0a0
+Mid = #ffa0a0a0
+Text = #ff000000
+BrightText = #ffffffff
+ButtonText = #ff000000
+Base = #ffffffff
+Window = #fff0f0f0
+Shadow = #ff696969
+HighlightedText = #ff000000
+Link = #ff0d5f28
+LinkVisited = #ff08401b
+AlternateBase = #ffe9e7e3
+ToolTipBase = #ffffffdc
+ToolTipText = #ff000000
+PlaceholderText = #80000000
+
+
+[AppColors]
+AccentStrong = #ff148c3c
+AccentSoft = #ff78c850
\ No newline at end of file
diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/System/theme.cfg
similarity index 73%
rename from cockatrice/themes/Default/theme.cfg
rename to cockatrice/themes/System/theme.cfg
index d2016a238..4b756a5e8 100644
--- a/cockatrice/themes/Default/theme.cfg
+++ b/cockatrice/themes/System/theme.cfg
@@ -2,4 +2,4 @@
ColorScheme = Light
[Style]
-Name = Default
+Name = System
diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png
new file mode 100644
index 000000000..34738090f
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png differ
diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png
new file mode 100644
index 000000000..de783e086
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png differ
diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml
new file mode 100644
index 000000000..496943931
--- /dev/null
+++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml
@@ -0,0 +1,74 @@
+# VelvetMarble — dark identity palette (charcoal-velvet chrome with slate marble)
+[Palette]
+WindowText = #e8eaee
+Button = #262a31
+Light = #3a3f47
+Midlight = #31363d
+Dark = #0e1013
+Mid = #1f2329
+Text = #e8eaee
+BrightText = #c3d8f2
+ButtonText = #f2f3f6
+Base = #1b1e23
+Window = #131519
+Shadow = #08090b
+Highlight = #8fa6c0
+HighlightedText = #101318
+Link = #a9c6e8
+LinkVisited = #b9a8d9
+AlternateBase = #171a1f
+ToolTipBase = #2d323a
+ToolTipText = #f2f3f6
+PlaceholderText = #6ee8eaee
+Accent = #8fa6c0
+
+[Palette.Disabled]
+WindowText = #9d9d9d
+Button = #131519
+Light = #3a3f47
+Midlight = #31363d
+Dark = #0e1013
+Mid = #1f2329
+Text = #9d9d9d
+BrightText = #c3d8f2
+ButtonText = #787878
+Base = #131519
+Window = #131519
+Shadow = #08090b
+Highlight = #1f2229
+HighlightedText = #9d9d9d
+Link = #308cc6
+LinkVisited = #b450ff
+AlternateBase = #171a1f
+ToolTipBase = #2d323a
+ToolTipText = #f2f3f6
+PlaceholderText = #6ee8eaee
+Accent = #9d9d9d
+
+[Palette.Inactive]
+WindowText = #e8eaee
+Button = #262a31
+Light = #3a3f47
+Midlight = #31363d
+Dark = #0e1013
+Mid = #1f2329
+Text = #e8eaee
+BrightText = #c3d8f2
+ButtonText = #f2f3f6
+Base = #1b1e23
+Window = #131519
+Shadow = #08090b
+Highlight = #1f2229
+HighlightedText = #ffffff
+Link = #a9c6e8
+LinkVisited = #b9a8d9
+AlternateBase = #171a1f
+ToolTipBase = #2d323a
+ToolTipText = #f2f3f6
+PlaceholderText = #6ee8eaee
+Accent = #8fa6c0
+
+[AppColors]
+AccentStrong = #54687e
+AccentSoft = #9db0c4
+
diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml
new file mode 100644
index 000000000..5323fd320
--- /dev/null
+++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml
@@ -0,0 +1,74 @@
+# VelvetMarble — light identity palette (charcoal-velvet chrome with slate marble)
+[Palette]
+WindowText = #1b1d21
+Button = #c7d1dd
+Light = #ffffff
+Midlight = #b3becb
+Dark = #93a0b1
+Mid = #a1adbc
+Text = #1b1d21
+BrightText = #26465f
+ButtonText = #121418
+Base = #f7f9fb
+Window = #d9e0ea
+Shadow = #57626f
+Highlight = #54687e
+HighlightedText = #ffffff
+Link = #2e4d6b
+LinkVisited = #5d4a78
+AlternateBase = #e6ebf2
+ToolTipBase = #f2f4f6
+ToolTipText = #121418
+PlaceholderText = #7a1b1d21
+Accent = #54687e
+
+[Palette.Disabled]
+WindowText = #787878
+Button = #d9e0ea
+Light = #ffffff
+Midlight = #b3becb
+Dark = #93a0b1
+Mid = #a1adbc
+Text = #787878
+BrightText = #26465f
+ButtonText = #969696
+Base = #d9e0ea
+Window = #d9e0ea
+Shadow = #57626f
+Highlight = #ccd5e3
+HighlightedText = #787878
+Link = #0000ff
+LinkVisited = #ff00ff
+AlternateBase = #e6ebf2
+ToolTipBase = #f2f4f6
+ToolTipText = #121418
+PlaceholderText = #7a1b1d21
+Accent = #787878
+
+[Palette.Inactive]
+WindowText = #1b1d21
+Button = #c7d1dd
+Light = #ffffff
+Midlight = #b3becb
+Dark = #93a0b1
+Mid = #a1adbc
+Text = #1b1d21
+BrightText = #26465f
+ButtonText = #121418
+Base = #f7f9fb
+Window = #d9e0ea
+Shadow = #57626f
+Highlight = #ccd5e3
+HighlightedText = #000000
+Link = #2e4d6b
+LinkVisited = #5d4a78
+AlternateBase = #e6ebf2
+ToolTipBase = #f2f4f6
+ToolTipText = #121418
+PlaceholderText = #7a1b1d21
+Accent = #54687e
+
+[AppColors]
+AccentStrong = #54687e
+AccentSoft = #9db0c4
+
diff --git a/cockatrice/themes/VelvetMarble/theme.cfg b/cockatrice/themes/VelvetMarble/theme.cfg
new file mode 100644
index 000000000..55b916e71
--- /dev/null
+++ b/cockatrice/themes/VelvetMarble/theme.cfg
@@ -0,0 +1,5 @@
+[Appearance]
+ColorScheme = System
+
+[Style]
+Name = Fusion
\ No newline at end of file
diff --git a/cockatrice/themes/VelvetMarble/zones/handzone-light.png b/cockatrice/themes/VelvetMarble/zones/handzone-light.png
new file mode 100644
index 000000000..b21e78e61
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone-light.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.jpg b/cockatrice/themes/VelvetMarble/zones/handzone.jpg
deleted file mode 100644
index 2ec9b37fe..000000000
Binary files a/cockatrice/themes/VelvetMarble/zones/handzone.jpg and /dev/null differ
diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.png b/cockatrice/themes/VelvetMarble/zones/handzone.png
new file mode 100644
index 000000000..3e2b7ebea
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone-light.png b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png
new file mode 100644
index 000000000..630aac6eb
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg b/cockatrice/themes/VelvetMarble/zones/playerzone.jpg
deleted file mode 100644
index dd13cab78..000000000
Binary files a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg and /dev/null differ
diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.png b/cockatrice/themes/VelvetMarble/zones/playerzone.png
new file mode 100644
index 000000000..dcf21ba38
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone-light.png b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png
new file mode 100644
index 000000000..62027827c
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg b/cockatrice/themes/VelvetMarble/zones/stackzone.jpg
deleted file mode 100644
index b63aa0902..000000000
Binary files a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg and /dev/null differ
diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.png b/cockatrice/themes/VelvetMarble/zones/stackzone.png
new file mode 100644
index 000000000..a47f7bf9d
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone-light.png b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png
new file mode 100644
index 000000000..5a9014f9d
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png differ
diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg b/cockatrice/themes/VelvetMarble/zones/tablezone.jpg
deleted file mode 100644
index 9f511491a..000000000
Binary files a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg and /dev/null differ
diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.png b/cockatrice/themes/VelvetMarble/zones/tablezone.png
new file mode 100644
index 000000000..1523d0821
Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone.png differ
diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd
index 59ca3e560..82aed32ab 100644
--- a/doc/carddatabase_v4/cards.xsd
+++ b/doc/carddatabase_v4/cards.xsd
@@ -35,6 +35,13 @@
+
+
+
+
+
+
+
@@ -49,6 +56,13 @@
+
+
+
+
+
+
+
diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt
index 081e6fd05..15388ecfc 100644
--- a/libcockatrice_card/CMakeLists.txt
+++ b/libcockatrice_card/CMakeLists.txt
@@ -5,6 +5,7 @@ set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/card/card_info.h
libcockatrice/card/card_info_comparator.h
+ libcockatrice/card/card_localization.h
libcockatrice/card/lazy_properties_hash.h
libcockatrice/card/database/card_database.h
libcockatrice/card/database/card_database_loader.h
@@ -27,6 +28,7 @@ add_library(
${MOC_SOURCES}
libcockatrice/card/card_info.cpp
libcockatrice/card/card_info_comparator.cpp
+ libcockatrice/card/card_localization.cpp
libcockatrice/card/lazy_properties_hash.cpp
libcockatrice/card/database/card_database.cpp
libcockatrice/card/database/card_database_cache.cpp
diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp
index 786e17950..56c737793 100644
--- a/libcockatrice_card/libcockatrice/card/card_info.cpp
+++ b/libcockatrice_card/libcockatrice/card/card_info.cpp
@@ -40,8 +40,11 @@ CardInfo::CardInfo(const QString &_name,
const QList &_relatedCards,
const QList &_reverseRelatedCards,
SetToPrintingsMap _sets,
- const UiAttributes _uiAttributes)
- : name(_name), text(_text), isToken(_isToken), properties(LazyPropertiesHash(_properties)),
+ const UiAttributes _uiAttributes,
+ QMap _localizedNames,
+ QMap _localizedTexts)
+ : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)),
+ localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)),
relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)),
uiAttributes(_uiAttributes)
{
@@ -59,8 +62,11 @@ CardInfo::CardInfo(const QString &_name,
SetToPrintingsMap _sets,
const UiAttributes _uiAttributes,
QString _simpleName,
- QSet _altNames)
+ QSet _altNames,
+ QMap _localizedNames,
+ QMap _localizedTexts)
: name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken),
+ localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)),
properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards),
reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes),
altNames(std::move(_altNames))
@@ -83,10 +89,12 @@ CardInfoPtr CardInfo::newInstance(const QString &_name,
const QList &_relatedCards,
const QList &_reverseRelatedCards,
SetToPrintingsMap _sets,
- const UiAttributes _uiAttributes)
+ const UiAttributes _uiAttributes,
+ QMap _localizedNames,
+ QMap _localizedTexts)
{
- CardInfoPtr ptr(
- new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes));
+ CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets,
+ _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts)));
ptr->setSmartPointer(ptr);
for (const auto &printings : _sets) {
@@ -109,11 +117,13 @@ CardInfoPtr CardInfo::newInstance(const QString &_name,
const UiAttributes _uiAttributes,
QString _simpleName,
QSet _altNames,
- bool _appendToSets)
+ bool _appendToSets,
+ QMap _localizedNames,
+ QMap _localizedTexts)
{
CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards,
_reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName),
- std::move(_altNames)));
+ std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts)));
ptr->setSmartPointer(ptr);
if (_appendToSets) {
diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h
index 392dc3849..17894cec5 100644
--- a/libcockatrice_card/libcockatrice/card/card_info.h
+++ b/libcockatrice_card/libcockatrice/card/card_info.h
@@ -77,6 +77,9 @@ private:
QString text; ///< Text description or rules text of the card.
bool isToken; ///< Whether this card is a token or not.
+ QMap localizedNames; ///< Localized card names, keyed by language code.
+ QMap localizedTexts; ///< Localized rules text, keyed by language code.
+
LazyPropertiesHash properties; ///< Key-value store of dynamic card properties.
QList relatedCards; ///< Forward references to related cards.
@@ -100,6 +103,8 @@ public:
* @param _reverseRelatedCards Backward references to related cards.
* @param _sets Map of set names to printing information.
* @param _uiAttributes Attributes that affect display and game logic
+ * @param _localizedNames Localized card names, keyed by language code.
+ * @param _localizedTexts Localized rules text, keyed by language code.
*/
explicit CardInfo(const QString &_name,
const QString &_text,
@@ -108,7 +113,9 @@ public:
const QList &_relatedCards,
const QList &_reverseRelatedCards,
SetToPrintingsMap _sets,
- UiAttributes _uiAttributes);
+ UiAttributes _uiAttributes,
+ QMap _localizedNames = {},
+ QMap _localizedTexts = {});
/**
* @brief Constructs a CardInfo from a cache snapshot with precomputed derived
@@ -130,6 +137,8 @@ public:
* @param _uiAttributes Attributes that affect display and game logic.
* @param _simpleName Precomputed simplified name.
* @param _altNames Precomputed alternate names.
+ * @param _localizedNames Localized card names, keyed by language code.
+ * @param _localizedTexts Localized rules text, keyed by language code.
*/
explicit CardInfo(const QString &_name,
const QString &_text,
@@ -140,7 +149,9 @@ public:
SetToPrintingsMap _sets,
UiAttributes _uiAttributes,
QString _simpleName,
- QSet _altNames);
+ QSet _altNames,
+ QMap _localizedNames = {},
+ QMap _localizedTexts = {});
/**
* @brief Copy constructor for CardInfo.
@@ -151,7 +162,8 @@ public:
*/
CardInfo(const CardInfo &other)
: QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text),
- isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards),
+ isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts),
+ properties(other.properties), relatedCards(other.relatedCards),
reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe),
setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames),
altNames(other.altNames)
@@ -179,6 +191,8 @@ public:
* @param _reverseRelatedCards Reverse relationships.
* @param _sets Printing information per set.
* @param _uiAttributes Attributes that affect display and game logic
+ * @param _localizedNames Localized card names, keyed by language code.
+ * @param _localizedTexts Localized rules text, keyed by language code.
* @return Shared pointer to the new CardInfo instance.
*/
static CardInfoPtr newInstance(const QString &_name,
@@ -188,7 +202,9 @@ public:
const QList &_relatedCards,
const QList &_reverseRelatedCards,
SetToPrintingsMap _sets,
- UiAttributes _uiAttributes);
+ UiAttributes _uiAttributes,
+ QMap _localizedNames = {},
+ QMap _localizedTexts = {});
/**
* @brief Creates a new instance from a cache snapshot with precomputed
@@ -208,6 +224,8 @@ public:
* its CardSets. Pass false when building cards in parallel so the
* (non-thread-safe) set membership is populated in a later
* single-threaded pass.
+ * @param _localizedNames Localized card names, keyed by language code.
+ * @param _localizedTexts Localized rules text, keyed by language code.
* @return Shared pointer to the new CardInfo instance.
*/
static CardInfoPtr newInstance(const QString &_name,
@@ -220,7 +238,9 @@ public:
UiAttributes _uiAttributes,
QString _simpleName,
QSet _altNames,
- bool _appendToSets = true);
+ bool _appendToSets = true,
+ QMap _localizedNames = {},
+ QMap _localizedTexts = {});
/**
* @brief Clones the current CardInfo instance.
@@ -270,6 +290,96 @@ public:
text = _text;
emit cardInfoChanged(smartThis);
}
+
+ /**
+ * @brief Returns the card name in the given language, falling back to the
+ * English name when no localization is available.
+ *
+ * @param lang Language code (e.g. "de", "ja", "zhs").
+ * @return The localized name, or the English name as fallback.
+ */
+ [[nodiscard]] const QString &getLocalizedName(const QString &lang) const
+ {
+ const auto it = localizedNames.constFind(lang);
+ return it != localizedNames.constEnd() ? it.value() : name;
+ }
+
+ /**
+ * @brief Returns the rules text in the given language, falling back to the
+ * English text when no localization is available.
+ *
+ * @param lang Language code (e.g. "de", "ja", "zhs").
+ * @return The localized text, or the English text as fallback.
+ */
+ [[nodiscard]] const QString &getLocalizedText(const QString &lang) const
+ {
+ const auto it = localizedTexts.constFind(lang);
+ return it != localizedTexts.constEnd() ? it.value() : text;
+ }
+
+ /**
+ * @brief Returns the localized card names keyed by language code.
+ *
+ * Only languages that have an entry are present; there is no English
+ * fallback in this map.
+ */
+ [[nodiscard]] const QMap &getLocalizedNames() const
+ {
+ return localizedNames;
+ }
+
+ /**
+ * @brief Returns the localized rules text keyed by language code.
+ *
+ * Only languages that have an entry are present; there is no English
+ * fallback in this map.
+ */
+ [[nodiscard]] const QMap &getLocalizedTexts() const
+ {
+ return localizedTexts;
+ }
+
+ /**
+ * @brief Sets the card name for the given language.
+ *
+ * @param lang Language code.
+ * @param _localizedName The localized card name.
+ */
+ void setLocalizedName(const QString &lang, const QString &_localizedName)
+ {
+ if (localizedNames.value(lang) == _localizedName) {
+ return;
+ }
+ localizedNames.insert(lang, _localizedName);
+ emit cardInfoChanged(smartThis);
+ }
+
+ /**
+ * @brief Sets the rules text for the given language.
+ *
+ * @param lang Language code.
+ * @param _localizedText The localized rules text.
+ */
+ void setLocalizedText(const QString &lang, const QString &_localizedText)
+ {
+ if (localizedTexts.value(lang) == _localizedText) {
+ return;
+ }
+ localizedTexts.insert(lang, _localizedText);
+ emit cardInfoChanged(smartThis);
+ }
+
+ /**
+ * @brief Returns the language codes for which this card has a localized
+ * name or rules text.
+ */
+ [[nodiscard]] QStringList localizationLanguages() const
+ {
+ QStringList languages = localizedNames.keys();
+ languages.append(localizedTexts.keys());
+ languages.removeDuplicates();
+ return languages;
+ }
[[nodiscard]] bool getIsToken() const
{
return isToken;
diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp
new file mode 100644
index 000000000..03c6c659b
--- /dev/null
+++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp
@@ -0,0 +1,38 @@
+#include "card_localization.h"
+
+#include
+#include
+#include
+
+namespace CardLocalization
+{
+const QStringList &supportedLanguages()
+{
+ static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"};
+ return languages;
+}
+
+QString languageDisplayName(const QString &lang)
+{
+ static const QHash displayNames = {
+ {"cs", "Česky (Czech)"},
+ {"de", "Deutsch (German)"},
+ {"es", "Español (Spanish)"},
+ {"fr", "Français (French)"},
+ {"it", "Italiano (Italian)"},
+ {"ja", "日本語 (Japanese)"},
+ {"ko", "한국어 (Korean)"},
+ {"pt", "Português (Portuguese)"},
+ {"ru", "Русский (Russian)"},
+ {"he", "עברית (Hebrew)"},
+ {"zhs", "简体中文 (Chinese Simplified)"},
+ {"zht", "繁體中文 (Chinese Traditional)"},
+ };
+ const QString displayName = displayNames.value(lang);
+ if (!displayName.isEmpty()) {
+ return displayName;
+ }
+ const QString nativeName = QLocale(lang).nativeLanguageName();
+ return nativeName.isEmpty() ? lang : nativeName;
+}
+} // namespace CardLocalization
\ No newline at end of file
diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h
new file mode 100644
index 000000000..a9c8d28ea
--- /dev/null
+++ b/libcockatrice_card/libcockatrice/card/card_localization.h
@@ -0,0 +1,43 @@
+#ifndef CARD_LOCALIZATION_H
+#define CARD_LOCALIZATION_H
+
+#include
+#include
+
+/**
+ * @namespace CardLocalization
+ * @ingroup Cards
+ *
+ * @brief Shared language metadata for localized card text and images.
+ *
+ * Lists the language codes Cockatrice can display localized card data for and
+ * provides human-readable names. The list is shared between Oracle (which
+ * imports the selected language's card data) and the client settings UI (which
+ * offers the language choice).
+ */
+namespace CardLocalization
+{
+/**
+ * @brief Language codes for which localized card data can be imported/displayed.
+ *
+ * Matches the languages Scryfall can serve localized card images for. "en" is
+ * always available as the default/fallback and is not listed here.
+ *
+ * @return The list of supported language codes.
+ */
+[[nodiscard]] const QStringList &supportedLanguages();
+
+/**
+ * @brief Human-readable name for a language code.
+ *
+ * Follows the same "native name (English name)" format the UI language list
+ * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible.
+ *
+ * @param lang Language code (e.g. "de", "ja", "zhs").
+ * @return The language's native name with its English name in parentheses, or
+ * the code itself if it cannot be resolved.
+ */
+[[nodiscard]] QString languageDisplayName(const QString &lang);
+} // namespace CardLocalization
+
+#endif // CARD_LOCALIZATION_H
\ No newline at end of file
diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp
index 2b27f50f8..3165ff871 100644
--- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp
+++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp
@@ -17,7 +17,7 @@
namespace
{
constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC"
-constexpr quint32 CACHE_VERSION = 2;
+constexpr quint32 CACHE_VERSION = 3;
// ---- Primitives -----------------------------------------------------------
@@ -71,6 +71,35 @@ QDate readDate(QDataStream &in)
return d;
}
+void writeStringMap(QDataStream &out, const QMap &map)
+{
+ out << static_cast(map.size());
+ for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
+ writeString(out, it.key());
+ writeString(out, it.value());
+ }
+}
+
+QMap readStringMap(QDataStream &in)
+{
+ QMap map;
+ quint32 count = 0;
+ in >> count;
+ if (in.status() != QDataStream::Ok) {
+ return map;
+ }
+ for (quint32 i = 0; i < count; ++i) {
+ QString key = readString(in);
+ QString value = readString(in);
+ if (in.status() != QDataStream::Ok) {
+ map.clear();
+ return map;
+ }
+ map.insert(key, value);
+ }
+ return map;
+}
+
// ---- CardRelation ----------------------------------------------------------
void writeRelation(QDataStream &out, const CardRelation *rel)
@@ -195,6 +224,10 @@ void writeCard(QDataStream &out, const CardInfoPtr &card)
for (const CardRelation *rel : reverse) {
writeRelation(out, rel);
}
+
+ // localized card data
+ writeStringMap(out, card->getLocalizedNames());
+ writeStringMap(out, card->getLocalizedTexts());
}
CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets)
@@ -268,8 +301,19 @@ CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets)
reverse.append(readRelation(in));
}
- return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName,
- altNames, false);
+ const QMap localizedNames = readStringMap(in);
+ if (in.status() != QDataStream::Ok) {
+ return nullptr;
+ }
+ const QMap localizedTexts = readStringMap(in);
+ if (in.status() != QDataStream::Ok) {
+ return nullptr;
+ }
+
+ CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui,
+ simpleName, altNames, false, localizedNames, localizedTexts);
+
+ return card;
}
// ---- FormatRules -----------------------------------------------------------
diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp
index ec460d685..19e972a7a 100644
--- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp
+++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp
@@ -273,6 +273,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml)
QString name = QString("");
QString text = QString("");
QHash properties;
+ QMap localizedNames;
+ QMap localizedTexts;
QList relatedCards, reverseRelatedCards;
auto _sets = SetToPrintingsMap();
int tableRow = 0;
@@ -298,6 +300,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml)
// generic properties
} else if (xmlName == "prop") {
properties = loadCardPropertiesFromXml(xml);
+ // localized card data
+ } else if (xmlName == "localizations") {
+ while (!xml.atEnd()) {
+ if (xml.readNextStartElement()) {
+ const QString elementName = xml.name().toString();
+ if (elementName == "localization") {
+ const QString lang = xml.attributes().value("lang").toString();
+ QString localizedName;
+ QString localizedText;
+ while (!xml.atEnd()) {
+ if (xml.readNext() == QXmlStreamReader::EndElement) {
+ break;
+ }
+ if (xml.isStartElement()) {
+ const QString childName = xml.name().toString();
+ QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements);
+ if (childName == "name") {
+ localizedName = value;
+ } else if (childName == "text") {
+ localizedText = value;
+ }
+ }
+ }
+ if (!lang.isEmpty()) {
+ if (!localizedName.isEmpty()) {
+ localizedNames.insert(lang, localizedName);
+ }
+ if (!localizedText.isEmpty()) {
+ localizedTexts.insert(lang, localizedText);
+ }
+ }
+ } else {
+ xml.skipCurrentElement();
+ }
+ } else {
+ break;
+ }
+ }
// positioning info
} else if (xmlName == "tablerow") {
tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt();
@@ -399,8 +439,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml)
.landscapeOrientation = landscapeOrientation,
.tableRow = tableRow,
.upsideDownArt = upsideDown};
- CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards,
- reverseRelatedCards, _sets, attributes);
+ CardInfoPtr newCard =
+ CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets,
+ attributes, std::move(localizedNames), std::move(localizedTexts));
if (targetData) {
// Mirror CardDatabase::addCard: if a card with this name already
// exists, merge the new printings into it instead of replacing.
@@ -517,6 +558,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in
}
xml.writeEndElement();
+ // localized card data
+ const QStringList localizedLanguages = info->localizationLanguages();
+ if (!localizedLanguages.isEmpty()) {
+ xml.writeStartElement("localizations");
+ const QMap &localizedNames = info->getLocalizedNames();
+ const QMap &localizedTexts = info->getLocalizedTexts();
+ for (const QString &lang : localizedLanguages) {
+ xml.writeStartElement("localization");
+ xml.writeAttribute("lang", lang);
+ if (localizedNames.contains(lang)) {
+ xml.writeTextElement("name", localizedNames.value(lang));
+ }
+ if (localizedTexts.contains(lang)) {
+ xml.writeTextElement("text", localizedTexts.value(lang));
+ }
+ xml.writeEndElement();
+ }
+ xml.writeEndElement();
+ }
+
// sets
for (const auto &printings : info->getSets()) {
for (const PrintingInfo &set : printings) {
diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt
index c7a54a390..0c487466c 100644
--- a/libcockatrice_deck_list/CMakeLists.txt
+++ b/libcockatrice_deck_list/CMakeLists.txt
@@ -11,6 +11,7 @@ set(HEADERS
libcockatrice/deck_list/deck_list_history_manager.h
libcockatrice/deck_list/deck_list_node_tree.h
libcockatrice/deck_list/deck_list_memento.h
+ libcockatrice/deck_list/deck_list_plain_text_parser.h
libcockatrice/deck_list/playmat_resolver.h
libcockatrice/deck_list/sideboard_plan.h
)
@@ -27,6 +28,7 @@ add_library(
libcockatrice/deck_list/deck_list.cpp
libcockatrice/deck_list/deck_list_history_manager.cpp
libcockatrice/deck_list/deck_list_node_tree.cpp
+ libcockatrice/deck_list/deck_list_plain_text_parser.cpp
libcockatrice/deck_list/playmat_resolver.cpp
libcockatrice/deck_list/sideboard_plan.cpp
)
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp
index af8628d21..90f63c09d 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp
@@ -1,6 +1,7 @@
#include "deck_list.h"
#include "deck_list_memento.h"
+#include "deck_list_plain_text_parser.h"
#include "tree/abstract_deck_list_node.h"
#include "tree/deck_list_card_node.h"
#include "tree/inner_deck_list_node.h"
@@ -8,18 +9,138 @@
#include
#include
#include
-#include
#include
#include
#include
static const QString CURRENT_SIDEBOARD_PLAN_KEY = "";
+/**
+ * @brief Parses a floating point XML attribute into a clamped playmat parameter.
+ *
+ * Falls back to @p fallback when the attribute is missing or malformed, so
+ * malformed deck files cannot produce degenerate art rectangles (e.g. a zoom
+ * of 0 dividing by zero).
+ *
+ * @param valueString Raw attribute text.
+ * @param fallback Value used when the text cannot be parsed.
+ * @param min Lower clamp bound.
+ * @param max Upper clamp bound.
+ * @return The parsed value clamped to [min, max], or @p fallback.
+ */
+static double parseClampedParam(const QString &valueString, double fallback, double min, double max)
+{
+ bool ok = false;
+ const double value = valueString.toDouble(&ok);
+ if (!ok) {
+ return fallback;
+ }
+ return qBound(min, value, max);
+}
+
+/**
+ * @brief Reads a `bannerCard` element from the XML stream.
+ *
+ * @param xml Reader positioned at the element.
+ * @return The referenced card.
+ */
+static CardRef readBannerCard(QXmlStreamReader *xml)
+{
+ QString providerId = xml->attributes().value("providerId").toString();
+ QString cardName = xml->readElementText();
+ return {cardName, providerId};
+}
+
+/**
+ * @brief Reads a `playmatCard` element from the XML stream.
+ *
+ * Attribute values are read before readElementText consumes the element, and
+ * the params are clamped to the same ranges as the settings dialog and the
+ * remote player-properties path so malformed deck files cannot produce
+ * degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
+ *
+ * @param xml Reader positioned at the element.
+ * @return The referenced card plus its clamped positioning parameters.
+ */
+static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml)
+{
+ QString providerId = xml->attributes().value("providerId").toString();
+ QString marginLStr = xml->attributes().value("marginPctL").toString();
+ QString marginRStr = xml->attributes().value("marginPctR").toString();
+ QString vOffStr = xml->attributes().value("verticalOffset").toString();
+ QString zoomStr = xml->attributes().value("zoom").toString();
+ QString cardName = xml->readElementText();
+
+ return {
+ .card = {cardName, providerId},
+ .params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95),
+ .marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95),
+ .verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0),
+ .zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)},
+ };
+}
+
bool DeckList::Metadata::isEmpty() const
{
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty();
}
+bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &childName)
+{
+ if (childName == "lastLoadedTimestamp") {
+ lastLoadedTimestamp = xml->readElementText();
+ } else if (childName == "deckname") {
+ name = xml->readElementText();
+ } else if (childName == "format") {
+ gameFormat = xml->readElementText();
+ } else if (childName == "comments") {
+ comments = xml->readElementText();
+ } else if (childName == "bannerCard") {
+ bannerCard = readBannerCard(xml);
+ } else if (childName == "playmatCard") {
+ playmat = readPlaymatCard(xml);
+ } else if (childName == "tags") {
+ tags.clear(); // Clear existing tags
+ while (xml->readNextStartElement()) {
+ if (xml->name().toString() == "tag") {
+ tags.append(xml->readElementText());
+ }
+ }
+ } else {
+ return false;
+ }
+ return true;
+}
+
+void DeckList::Metadata::write(QXmlStreamWriter *xml) const
+{
+ xml->writeTextElement("lastLoadedTimestamp", lastLoadedTimestamp);
+ xml->writeTextElement("deckname", name);
+ xml->writeTextElement("format", gameFormat);
+ xml->writeStartElement("bannerCard");
+ xml->writeAttribute("providerId", bannerCard.providerId);
+ xml->writeCharacters(bannerCard.name);
+ xml->writeEndElement();
+ if (!playmat.card.isEmpty()) {
+ xml->writeStartElement("playmatCard");
+ xml->writeAttribute("providerId", playmat.card.providerId);
+ xml->writeAttribute("marginPctL", QString::number(playmat.params.marginPctL, 'f', 4));
+ xml->writeAttribute("marginPctR", QString::number(playmat.params.marginPctR, 'f', 4));
+ xml->writeAttribute("verticalOffset", QString::number(playmat.params.verticalOffset, 'f', 4));
+ xml->writeAttribute("zoom", QString::number(playmat.params.zoom, 'f', 4));
+ xml->writeCharacters(playmat.card.name);
+ xml->writeEndElement();
+ }
+ xml->writeTextElement("comments", comments);
+
+ // Write tags
+ xml->writeStartElement("tags");
+ for (const QString &tag : tags) {
+ xml->writeTextElement("tag", tag);
+ }
+ xml->writeEndElement();
+}
+
DeckList::DeckList()
{
}
@@ -54,56 +175,10 @@ bool DeckList::readElement(QXmlStreamReader *xml)
{
const QString childName = xml->name().toString();
if (xml->isStartElement()) {
- if (childName == "lastLoadedTimestamp") {
- metadata.lastLoadedTimestamp = xml->readElementText();
- } else if (childName == "deckname") {
- metadata.name = xml->readElementText();
- } else if (childName == "format") {
- metadata.gameFormat = xml->readElementText();
- } else if (childName == "comments") {
- metadata.comments = xml->readElementText();
- } else if (childName == "bannerCard") {
- QString providerId = xml->attributes().value("providerId").toString();
- QString cardName = xml->readElementText();
- metadata.bannerCard = {cardName, providerId};
- } else if (childName == "playmatCard") {
- QString providerId = xml->attributes().value("providerId").toString();
- bool ok;
- QString marginLStr = xml->attributes().value("marginPctL").toString();
- QString marginRStr = xml->attributes().value("marginPctR").toString();
- QString vOffStr = xml->attributes().value("verticalOffset").toString();
- QString zoomStr = xml->attributes().value("zoom").toString();
- QString cardName = xml->readElementText();
- PlaymatInfo playmat;
- playmat.card = {cardName, providerId};
- // Clamp to the same ranges as the settings dialog and the remote
- // player-properties path so malformed deck files cannot produce
- // degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
- playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95);
- if (!ok) {
- playmat.params.marginPctL = 0.07;
- }
- playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95);
- if (!ok) {
- playmat.params.marginPctR = 0.07;
- }
- playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0);
- if (!ok) {
- playmat.params.verticalOffset = 0.33;
- }
- playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0);
- if (!ok) {
- playmat.params.zoom = 1.0;
- }
- metadata.playmat = playmat;
- } else if (childName == "tags") {
- metadata.tags.clear(); // Clear existing tags
- while (xml->readNextStartElement()) {
- if (xml->name().toString() == "tag") {
- metadata.tags.append(xml->readElementText());
- }
- }
- } else if (childName == "zone") {
+ if (metadata.readElement(xml, childName)) {
+ return true;
+ }
+ if (childName == "zone") {
tree.readZoneElement(xml);
} else if (childName == "sideboard_plan") {
SideboardPlan newSideboardPlan;
@@ -117,41 +192,12 @@ bool DeckList::readElement(QXmlStreamReader *xml)
return true;
}
-static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metadata)
-{
- xml->writeTextElement("lastLoadedTimestamp", metadata.lastLoadedTimestamp);
- xml->writeTextElement("deckname", metadata.name);
- xml->writeTextElement("format", metadata.gameFormat);
- xml->writeStartElement("bannerCard");
- xml->writeAttribute("providerId", metadata.bannerCard.providerId);
- xml->writeCharacters(metadata.bannerCard.name);
- xml->writeEndElement();
- if (!metadata.playmat.card.isEmpty()) {
- xml->writeStartElement("playmatCard");
- xml->writeAttribute("providerId", metadata.playmat.card.providerId);
- xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4));
- xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4));
- xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4));
- xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4));
- xml->writeCharacters(metadata.playmat.card.name);
- xml->writeEndElement();
- }
- xml->writeTextElement("comments", metadata.comments);
-
- // Write tags
- xml->writeStartElement("tags");
- for (const QString &tag : metadata.tags) {
- xml->writeTextElement("tag", tag);
- }
- xml->writeEndElement();
-}
-
void DeckList::write(QXmlStreamWriter *xml) const
{
xml->writeStartElement("cockatrice_deck");
xml->writeAttribute("version", "1");
- writeMetadata(xml, metadata);
+ metadata.write(xml);
// Write zones
tree.write(xml);
@@ -164,6 +210,27 @@ void DeckList::write(QXmlStreamWriter *xml) const
xml->writeEndElement(); // Close "cockatrice_deck"
}
+bool DeckList::seekToNextElement(QXmlStreamReader *xml)
+{
+ while (!xml->atEnd()) {
+ xml->readNext();
+ if (xml->isStartElement()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+void DeckList::readDeckBody(QXmlStreamReader *xml)
+{
+ while (!xml->atEnd()) {
+ xml->readNext();
+ if (!readElement(xml)) {
+ break;
+ }
+ }
+}
+
bool DeckList::loadFromXml(QXmlStreamReader *xml)
{
if (xml->error()) {
@@ -172,19 +239,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml)
}
cleanList();
- while (!xml->atEnd()) {
- xml->readNext();
- if (xml->isStartElement()) {
- if (xml->name().toString() != "cockatrice_deck") {
- return false;
- }
- while (!xml->atEnd()) {
- xml->readNext();
- if (!readElement(xml)) {
- break;
- }
- }
+ while (seekToNextElement(xml)) {
+ if (xml->name().toString() != "cockatrice_deck") {
+ return false;
}
+ readDeckBody(xml);
}
refreshDeckHash();
if (xml->error()) {
@@ -240,160 +299,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in,
bool preserveMetadata,
const std::function &cardNameNormalizer)
{
- const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
- const QRegularExpression reEmpty("^\\s*$");
- const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption);
- const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption);
- const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption);
- const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b",
- QRegularExpression::CaseInsensitiveOption);
-
- // Regex for advanced card parsing
- const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
-
- // Regex for extracting set code and collector number with attached symbols
- const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
- const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))");
-
- cleanList(preserveMetadata);
-
- auto inputs = in.readAll().trimmed().split('\n');
- auto max_line = inputs.size();
-
- // Start at the first empty line before the first card line
- auto deckStart = inputs.indexOf(reCardLine);
- if (deckStart == -1) {
- if (inputs.indexOf(reComment) == -1) {
- return false; // Input is empty
- }
- deckStart = max_line;
- } else {
- deckStart = inputs.lastIndexOf(reEmpty, deckStart);
- if (deckStart == -1) {
- deckStart = 0;
- }
+ if (!preserveMetadata) {
+ metadata = {};
}
-
- // find sideboard position, if marks are used this won't be needed
- int sBStart = -1;
- if (inputs.indexOf(reSBMark, deckStart) == -1) {
- sBStart = inputs.indexOf(reSBComment, deckStart);
- if (sBStart == -1) {
- sBStart = inputs.indexOf(reEmpty, deckStart + 1);
- if (sBStart == -1) {
- sBStart = max_line;
- }
- auto nextCard = inputs.indexOf(reCardLine, sBStart + 1);
- if (inputs.indexOf(reEmpty, nextCard + 1) != -1) {
- sBStart = max_line;
- }
- }
- }
-
- int index = 0;
- QRegularExpressionMatch match;
-
- // Parse name and comments
- while (index < deckStart) {
- const auto ¤t = inputs.at(index++);
- if (!current.contains(reEmpty)) {
- match = reComment.match(current);
- metadata.name = match.captured();
- break;
- }
- }
- while (index < deckStart) {
- const auto ¤t = inputs.at(index++);
- if (!current.contains(reEmpty)) {
- match = reComment.match(current);
- metadata.comments += match.captured() + '\n';
- }
- }
- metadata.comments.chop(1);
-
- // Discard empty lines
- while (index < max_line && inputs.at(index).contains(reEmpty)) {
- ++index;
- }
-
- // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard
- if (inputs.at(index).contains(reDeckComment)) {
- ++index;
- }
-
- // Parse decklist
- for (; index < max_line; ++index) {
- // check if line is a card
- match = reCardLine.match(inputs.at(index));
- if (!match.hasMatch()) {
- continue;
- }
-
- QString cardName = match.captured().simplified();
- bool sideboard = false;
-
- // Sideboard detection
- if (sBStart < 0) {
- match = reSBMark.match(cardName);
- if (match.hasMatch()) {
- sideboard = true;
- cardName = match.captured(1);
- }
- } else {
- if (index == sBStart) {
- continue;
- }
- sideboard = index > sBStart;
- }
-
- // Extract set code, collector number, and foil
- QString setCode;
- QString collectorNumber;
- bool isFoil = false;
-
- // Check for foil status at the end of the card name
- if (cardName.endsWith("*F*", Qt::CaseInsensitive)) {
- isFoil = true;
- cardName.chop(3); // Remove the "*F*" from the card name
- }
- Q_UNUSED(isFoil);
-
- // Attempt to match the hyphen-separated format (PLST-2094)
- match = reHyphenFormat.match(cardName);
- if (match.hasMatch()) {
- setCode = match.captured(2).toUpper();
- collectorNumber = match.captured(3);
- cardName = cardName.left(match.capturedStart()).trimmed();
- } else {
- // Attempt to match the regular format (PLST) 2094
- match = reRegularFormat.match(cardName);
- if (match.hasMatch()) {
- setCode = match.captured(1).toUpper();
- collectorNumber = match.captured(2);
- cardName = cardName.left(match.capturedStart()).trimmed();
- }
- }
-
- // check if a specific amount is mentioned
- int amount = 1;
- match = reMultiplier.match(cardName);
- if (match.hasMatch()) {
- amount = match.captured(1).toInt();
- cardName = match.captured(2);
- }
-
- // Normalize the card name
- cardName = cardNameNormalizer(cardName);
-
- // Determine the zone (mainboard/sideboard)
- QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN;
-
- // make new entry in decklist
- tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber);
- }
-
+ bool ok = DeckListPlainText::parse(in, cardNameNormalizer, metadata, tree);
refreshDeckHash();
- return true;
+ return ok;
}
bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer)
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h
index 475d99560..199d3a9a8 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h
@@ -76,6 +76,23 @@ public:
* @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty.
*/
bool isEmpty() const;
+
+ /**
+ * @brief Reads a single deck metadata element from a Cockatrice deck XML stream.
+ *
+ * @param xml Reader positioned at the element.
+ * @param childName Name of the current element.
+ * @return true if a metadata element was consumed, false if @p childName is
+ * not a metadata element.
+ */
+ bool readElement(QXmlStreamReader *xml, const QString &childName);
+
+ /**
+ * @brief Writes the deck metadata section of a Cockatrice deck XML file.
+ *
+ * @param xml Writer to append the metadata elements to.
+ */
+ void write(QXmlStreamWriter *xml) const;
};
private:
@@ -89,6 +106,22 @@ private:
*/
mutable QString cachedDeckHash;
+ /** @name XML load helpers */
+ ///@{
+ /**
+ * @brief Advances to the next element in the XML stream.
+ * @param xml Reader to advance past non-element tokens.
+ * @return true when a start element was reached, false at end of stream.
+ */
+ bool seekToNextElement(QXmlStreamReader *xml);
+
+ /**
+ * @brief Reads the contents of a `cockatrice_deck` element into this deck.
+ * @param xml Reader positioned at the deck element, stopped at its end.
+ */
+ void readDeckBody(QXmlStreamReader *xml);
+ ///@}
+
public:
/** @name Metadata setters */
///@{
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp
index acf4707ab..b6d0687b4 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp
@@ -14,42 +14,32 @@ void DeckListHistoryManager::clear()
emit undoRedoStateChanged();
}
-void DeckListHistoryManager::undo(DeckList *deck)
+void DeckListHistoryManager::restoreAndSwap(QStack &source,
+ QStack &target,
+ DeckList *deck)
{
- if (undoStack.isEmpty()) {
+ if (source.isEmpty()) {
return;
}
- // Peek at the memento we are going to restore
- const DeckListMemento &mementoToRestore = undoStack.top();
+ // The reason is read before the source is popped.
+ const QString reason = source.top().getReason();
- // Save current state for redo
- DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason());
- redoStack.push(currentState);
+ // Save the current state so the opposite direction can return to it.
+ target.push(deck->createMemento(reason));
- // Pop the last state from undo stack and restore it
- DeckListMemento memento = undoStack.pop();
- deck->restoreMemento(memento);
+ // Apply the state we are moving to.
+ deck->restoreMemento(source.pop());
emit undoRedoStateChanged();
}
+void DeckListHistoryManager::undo(DeckList *deck)
+{
+ restoreAndSwap(undoStack, redoStack, deck);
+}
+
void DeckListHistoryManager::redo(DeckList *deck)
{
- if (redoStack.isEmpty()) {
- return;
- }
-
- // Peek at the memento we are going to restore
- const DeckListMemento &mementoToRestore = redoStack.top();
-
- // Save current state for undo
- DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason());
- undoStack.push(currentState);
-
- // Pop the next state from redo stack and restore it
- DeckListMemento memento = redoStack.pop();
- deck->restoreMemento(memento);
-
- emit undoRedoStateChanged();
+ restoreAndSwap(redoStack, undoStack, deck);
}
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h
index e6bd27e2d..6acdb199e 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h
@@ -47,6 +47,13 @@ public:
}
private:
+ /**
+ * @brief Moves one state from @p source to @p target, applying it to @p deck.
+ *
+ * Used by both undo (undoStack -> redoStack) and redo (redoStack -> undoStack).
+ */
+ void restoreAndSwap(QStack &source, QStack &target, DeckList *deck);
+
QStack undoStack;
QStack redoStack;
};
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp
index 91fe1874b..66f228d19 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp
@@ -7,6 +7,84 @@
static constexpr int MAX_DECK_SIZE = 1e5;
+namespace
+{
+
+/**
+ * @brief Expands card nodes into one lowercase name entry per copy.
+ *
+ * @param nodes The card nodes to expand.
+ * @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard).
+ * @return One entry per copy, in node order.
+ */
+QStringList cardNodesToCopies(const QList &nodes, const QString &prefix = {})
+{
+ QStringList result;
+ for (auto node : nodes) {
+ for (int i = 0; i < node->getNumber(); ++i) {
+ result.append(prefix + node->getName().toLower());
+ }
+ }
+ return result;
+}
+
+/**
+ * @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number.
+ *
+ * The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32,
+ * byte 4 unshifted) so decks that only differ in their low-order hash bytes still
+ * produce distinct identifiers.
+ *
+ * @return The 8-character base-32 representation of the packed number.
+ */
+QString encodeDeckHash(const QByteArray &digest)
+{
+ quint64 number = 0;
+ for (int i = 0; i < 5; ++i) {
+ number |= static_cast(static_cast(digest[i])) << (32 - 8 * i);
+ }
+ return QString::number(number, 32).rightJustified(8, '0');
+}
+
+/**
+ * @brief Collects every card node in @p node's subtree, in tree order.
+ *
+ * @return The collected card nodes.
+ */
+QList collectCardsRecursive(const InnerDecklistNode *node)
+{
+ QList result;
+ for (int i = 0; i < node->size(); i++) {
+ if (auto *card = dynamic_cast(node->at(i))) {
+ result.append(card);
+ } else if (auto *inner = dynamic_cast(node->at(i))) {
+ result.append(collectCardsRecursive(inner));
+ }
+ }
+ return result;
+}
+
+/**
+ * @brief Invokes @p func on every card in @p node's subtree.
+ *
+ * Cards nested in custom zones are reported with their top-level @p boardZone
+ * so that callers can classify cards by board (main/side/maybeboard/tokens).
+ */
+void forEachCardInNode(InnerDecklistNode *boardZone,
+ InnerDecklistNode *node,
+ const std::function &func)
+{
+ for (int i = 0; i < node->size(); i++) {
+ if (auto *card = dynamic_cast(node->at(i))) {
+ func(boardZone, card);
+ } else if (auto *inner = dynamic_cast(node->at(i))) {
+ forEachCardInNode(boardZone, inner, func);
+ }
+ }
+}
+
+} // namespace
+
DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode())
{
}
@@ -43,19 +121,8 @@ QList