diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..a272b1644 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -84,6 +84,7 @@ set(cockatrice_SOURCES src/game_graphics/deckview/tabbed_deck_view_container.cpp src/game_graphics/dialogs/dlg_annotation.cpp src/game_graphics/dialogs/dlg_create_token.cpp + src/game_graphics/dialogs/token_preview_widget.cpp src/game_graphics/dialogs/dlg_move_top_cards_until.cpp src/game_graphics/dialogs/dlg_roll_dice.cpp src/game_graphics/game_scene.cpp diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index 5ce050380..bf55584f3 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -1,9 +1,9 @@ #include "dlg_create_token.h" #include "../../client/settings/cache_settings.h" -#include "../../interface/widgets/cards/card_info_picture_widget.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" +#include "token_preview_widget.h" #include #include @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -41,11 +42,12 @@ class QWidget; DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent) : QDialog(parent), predefinedTokens(_predefinedTokens) { - pic = new CardInfoPictureWidget(); + pic = new TokenPreviewWidget(); pic->setObjectName("pic"); nameLabel = new QLabel(tr("&Name:")); nameEdit = new QLineEdit(tr("Token")); + nameEdit->setObjectName("tokenName"); nameEdit->setMaxLength(MAX_NAME_LENGTH); QTimer::singleShot(100, this, [=, this]() { nameEdit->selectAll(); @@ -57,6 +59,7 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa colorLabel = new QLabel(tr("C&olor:")); colorEdit = new QComboBox; + colorEdit->setObjectName("tokenColor"); colorEdit->addItem(tr("white"), QChar('w')); colorEdit->addItem(tr("blue"), QChar('u')); colorEdit->addItem(tr("black"), QChar('b')); @@ -68,11 +71,13 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa ptLabel = new QLabel(tr("&P/T:")); ptEdit = new QLineEdit; + ptEdit->setObjectName("tokenPT"); ptEdit->setMaxLength(MAX_NAME_LENGTH); ptLabel->setBuddy(ptEdit); annotationLabel = new QLabel(tr("&Annotation:")); annotationEdit = new QLineEdit; + annotationEdit->setObjectName("tokenAnnotation"); annotationEdit->setMaxLength(MAX_NAME_LENGTH); annotationLabel->setBuddy(annotationEdit); @@ -80,6 +85,7 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa destroyCheckBox->setChecked(true); faceDownCheckBox = new QCheckBox(tr("Create face-down (Only hides name)")); + faceDownCheckBox->setObjectName("tokenFaceDown"); connect(faceDownCheckBox, &QCheckBox::toggled, this, &DlgCreateToken::faceDownCheckBoxToggled); auto *grid = new QGridLayout; @@ -173,6 +179,12 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa setWindowTitle(tr("Create token")); + connect(nameEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + connect(colorEdit, qOverload(&QComboBox::currentIndexChanged), this, &DlgCreateToken::updatePreview); + connect(ptEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + connect(annotationEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + updatePreview(); + resize(600, 500); restoreGeometry(SettingsCache::instance().layouts().getTokenDialogGeometry()); } @@ -185,17 +197,14 @@ void DlgCreateToken::closeEvent(QCloseEvent *event) void DlgCreateToken::faceDownCheckBoxToggled(bool checked) { + // Keep the face-up draft so toggling this option is reversible. The outgoing + // TokenInfo and preview apply the same face-down color/P/T restrictions. + colorEdit->setEnabled(!checked); + ptEdit->setEnabled(!checked); if (checked) { - colorEdit->setCurrentIndex(6); - colorEdit->setEnabled(false); - ptEdit->clear(); ptEdit->clearFocus(); - ptEdit->setEnabled(false); - } else { - colorEdit->setEnabled(true); - ptEdit->setEnabled(true); - annotationEdit->setEnabled(true); } + updatePreview(); } void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QModelIndex & /*previous*/) @@ -204,11 +213,14 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo CardInfoPtr cardInfo; - if (current.row() >= 0) { + if (realIndex.isValid()) { cardInfo = cardDatabaseModel->getCard(realIndex.row()); } if (cardInfo) { + const QSignalBlocker colorBlocker(colorEdit); + const QSignalBlocker ptBlocker(ptEdit); + const QSignalBlocker annotationBlocker(annotationEdit); updateSearchFieldWithoutUpdatingFilter(cardInfo->getName()); const QChar cardColor = cardInfo->getColorChar(); colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString)); @@ -216,21 +228,22 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo if (SettingsCache::instance().userInterface().getAnnotateTokens()) { annotationEdit->setText(cardInfo->getText()); } - } else { - nameEdit->setText(""); - colorEdit->setCurrentIndex(colorEdit->findData(QString(), Qt::UserRole, Qt::MatchFixedString)); - ptEdit->setText(""); - annotationEdit->setText(""); } - pic->setCard(CardDatabaseManager::query()->getPreferredCard(cardInfo)); + // Filtering, changing the deck/all scope, or reloading the database can + // invalidate the current row. None of these actions discard the token draft. + updatePreview(); } void DlgCreateToken::updateSearchFieldWithoutUpdatingFilter(const QString &newValue) const { - nameEdit->blockSignals(true); + const QSignalBlocker blocker(nameEdit); nameEdit->setText(newValue); - nameEdit->blockSignals(false); +} + +void DlgCreateToken::updatePreview() +{ + pic->setToken(getTokenInfo()); } void DlgCreateToken::updateSearch(const QString &search) @@ -266,11 +279,24 @@ void DlgCreateToken::actReject() TokenInfo DlgCreateToken::getTokenInfo() const { - return {.name = nameEdit->text(), - .color = colorEdit->itemData(colorEdit->currentIndex()).toString(), - .pt = ptEdit->text(), - .annotation = annotationEdit->text(), - .destroy = destroyCheckBox->isChecked(), - .faceDown = faceDownCheckBox->isChecked(), - .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(nameEdit->text())}; + TokenInfo token = { + .name = nameEdit->text(), + .color = faceDownCheckBox->isChecked() ? QString(QChar()) + : colorEdit->itemData(colorEdit->currentIndex()).toString(), + .pt = faceDownCheckBox->isChecked() ? QString() : ptEdit->text(), + .annotation = annotationEdit->text(), + .destroy = destroyCheckBox->isChecked(), + .faceDown = faceDownCheckBox->isChecked(), + .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(nameEdit->text())}; + + // Match the normalization applied when the player creates the token, so + // manually typed aliases and the default P/T preview the actual result. + const ExactCard correctedCard = CardDatabaseManager::query()->guessCard({token.name, token.providerId}); + if (correctedCard) { + token.name = correctedCard.getName(); + if (!token.faceDown && token.pt.isEmpty()) { + token.pt = correctedCard.getInfo().getPowTough(); + } + } + return token; } diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index 323e61829..22905b13c 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -22,7 +22,7 @@ class QRadioButton; class QTreeView; class CardDatabaseModel; class TokenDisplayModel; -class CardInfoPictureWidget; +class TokenPreviewWidget; class QModelIndex; class QWidget; @@ -39,6 +39,7 @@ private slots: void faceDownCheckBoxToggled(bool checked); void tokenSelectionChanged(const QModelIndex ¤t, const QModelIndex &previous); void updateSearch(const QString &search); + void updatePreview(); void actChooseTokenFromAll(bool checked); void actChooseTokenFromDeck(bool checked); void actOk(); @@ -54,7 +55,7 @@ private: QCheckBox *destroyCheckBox; QCheckBox *faceDownCheckBox; QRadioButton *chooseTokenFromAllRadioButton, *chooseTokenFromDeckRadioButton; - CardInfoPictureWidget *pic; + TokenPreviewWidget *pic; QTreeView *chooseTokenView; void updateSearchFieldWithoutUpdatingFilter(const QString &newValue) const; diff --git a/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp b/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp new file mode 100644 index 000000000..69d29397a --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp @@ -0,0 +1,92 @@ +#include "token_preview_widget.h" + +#include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../board/abstract_card_item.h" + +#include +#include +#include +#include + +class TokenPreviewCardItem : public AbstractCardItem +{ +public: + TokenPreviewCardItem() + { + setFlag(ItemIsSelectable, false); + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); + setCursor(Qt::ArrowCursor); + connect(state, &CardState::stateChanged, this, [this] { update(); }); + } + + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override + { + // Use the board's artwork, custom-token background and name rendering. A + // face-down preview has no server-assigned card ID to draw on its back. + if (getFaceDown()) { + QPixmap back; + CardPictureLoader::getCardBackPixmap(back, getTranslatedSize(painter).toSize()); + painter->save(); + painter->setClipPath(shape()); + painter->drawPixmap(boundingRect(), back, back.rect()); + painter->restore(); + } else { + AbstractCardItem::paint(painter, option, widget); + } + + const QSizeF translatedSize = getTranslatedSize(painter); + const qreal scale = translatedSize.width() / boundingRect().width(); + painter->save(); + transformPainter(painter, translatedSize, 0); + painter->setPen(Qt::white); + painter->setBackground(Qt::black); + painter->setBackgroundMode(Qt::OpaqueMode); + const QRectF textRect(4 * scale, 4 * scale, translatedSize.width() - 8 * scale, + translatedSize.height() - 8 * scale); + painter->drawText(textRect, Qt::AlignCenter | Qt::TextWrapAnywhere, state->getAnnotation()); + if (state->getPT() != getCardInfo().getPowTough()) { + painter->setPen(QColor(255, 150, 0)); + } + painter->drawText(textRect, Qt::AlignRight | Qt::AlignBottom, state->getPT()); + painter->restore(); + } +}; + +TokenPreviewWidget::TokenPreviewWidget(QWidget *parent) : QGraphicsView(parent), card(new TokenPreviewCardItem) +{ + auto *previewScene = new QGraphicsScene(this); + previewScene->addItem(card); + previewScene->setSceneRect(card->boundingRect()); + setScene(previewScene); + setInteractive(false); + setFocusPolicy(Qt::NoFocus); + setFrameShape(QFrame::NoFrame); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setMinimumSize(150, 200); + setAccessibleName(tr("Token preview")); +} + +void TokenPreviewWidget::setToken(const TokenInfo &token) +{ + card->setCardRef({token.name, token.providerId}); + card->setColor(token.color); + card->setFaceDown(token.faceDown); + card->getState()->setPT(token.pt); + card->getState()->setAnnotation(token.annotation); + setToolTip(token.annotation); + setAccessibleDescription(token.faceDown ? tr("Face-down token") + : token.name + " " + token.pt + " " + token.annotation); +} + +QSize TokenPreviewWidget::sizeHint() const +{ + return {200, 280}; +} + +void TokenPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QGraphicsView::resizeEvent(event); + fitInView(sceneRect(), Qt::KeepAspectRatio); +} diff --git a/cockatrice/src/game_graphics/dialogs/token_preview_widget.h b/cockatrice/src/game_graphics/dialogs/token_preview_widget.h new file mode 100644 index 000000000..c3ea94777 --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/token_preview_widget.h @@ -0,0 +1,25 @@ +#ifndef COCKATRICE_TOKEN_PREVIEW_WIDGET_H +#define COCKATRICE_TOKEN_PREVIEW_WIDGET_H + +#include +#include + +class TokenPreviewCardItem; + +/** A noninteractive preview of the token that the dialog will create. */ +class TokenPreviewWidget : public QGraphicsView +{ + Q_OBJECT +public: + explicit TokenPreviewWidget(QWidget *parent = nullptr); + void setToken(const TokenInfo &token); + QSize sizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + +private: + TokenPreviewCardItem *card; +}; + +#endif diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 90823fec6..3c8765323 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -951,7 +951,7 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate) lastTokenInfo.name = correctedCard.getName(); int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow; lastTokenTableRow = TableZoneLogic::tableRowToGridY(tableRow); - if (lastTokenInfo.pt.isEmpty()) { + if (!lastTokenInfo.faceDown && lastTokenInfo.pt.isEmpty()) { lastTokenInfo.pt = correctedCard.getInfo().getPowTough(); } } diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp index 356d544bf..1bb41e560 100644 --- a/tests/game/game_smoke_test.cpp +++ b/tests/game/game_smoke_test.cpp @@ -8,12 +8,25 @@ #include "gtest/gtest.h" #include +#include #include #include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include +#include namespace { @@ -25,6 +38,57 @@ CardRef cardRefFor(const QString &name) return cardRef; } +// Capture the real serialized command at the transport boundary. PlayerActions, +// GameEventHandler and PendingCommand retain their production behavior. +class CapturingClient : public AbstractClient +{ +public: + QList commands; + + ~CapturingClient() override + { + for (auto *command : pendingCommands) { + delete command; + } + } + +protected: + void sendCommandContainer(const CommandContainer &command) override + { + commands.append(command); + } +}; + +class TokenCommandTest : public ::testing::Test +{ +protected: + // The client outlives the game and its pending-command signal receivers. + CapturingClient client; + AbstractGame game{nullptr}; + PlayerLogic *player = nullptr; + CardInfoPtr knownToken; + + void SetUp() override + { + knownToken = CardInfo::newInstance(QStringLiteral("Token Command Test Goblin"), "", true, {}, {}, {}, {}, + {.tableRow = 2}); + knownToken->setColors(QStringLiteral("R")); + knownToken->setPowTough(QStringLiteral("2/2")); + CardDatabaseManager::getInstance()->addCard(knownToken); + + game.gameState = new GameState(&game, 0, 0, false, {&client}, true, false, 0, false); + game.playerManager = new PlayerManager(&game, 0, false, false); + ServerInfo_User user; + user.set_name("Token command test player"); + player = game.playerManager->addPlayer(0, user); + } + + void TearDown() override + { + CardDatabaseManager::getInstance()->removeCard(knownToken); + } +}; + } // namespace TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) @@ -86,6 +150,64 @@ TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) EXPECT_TRUE(arguments.at(1).toBool()); } +TEST_F(TokenCommandTest, FaceDownKnownTokenDoesNotRestoreDatabasePowerToughness) +{ + TokenInfo token; + token.name = knownToken->getName(); + token.color = QString(QChar()); + token.faceDown = true; + player->getPlayerActions()->actCreateToken(token); + + ASSERT_EQ(client.commands.size(), 1); + ASSERT_EQ(client.commands.first().game_command_size(), 1); + const auto &gameCommand = client.commands.first().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_EQ(command.card_name(), knownToken->getName().toStdString()); + EXPECT_TRUE(command.face_down()); + EXPECT_TRUE(command.pt().empty()); + + player->getPlayerActions()->actCreateAnotherToken(); + ASSERT_EQ(client.commands.size(), 2); + const auto &repeat = client.commands.last().game_command(0).GetExtension(Command_CreateToken::ext); + EXPECT_TRUE(repeat.face_down()); + EXPECT_TRUE(repeat.pt().empty()); +} + +TEST_F(TokenCommandTest, FaceUpKnownAliasKeepsCanonicalNameAndDefaultPowerToughness) +{ + TokenInfo token; + token.name = QStringLiteral("token command test goblin"); + player->getPlayerActions()->actCreateToken(token); + + ASSERT_EQ(client.commands.size(), 1); + ASSERT_EQ(client.commands.first().game_command_size(), 1); + const auto &gameCommand = client.commands.first().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_EQ(command.card_name(), knownToken->getName().toStdString()); + EXPECT_FALSE(command.face_down()); + EXPECT_EQ(command.pt(), "2/2"); +} + +TEST_F(TokenCommandTest, FaceUpKnownTokenPreservesExplicitPowerToughness) +{ + TokenInfo token; + token.name = knownToken->getName(); + token.pt = QStringLiteral("7/9"); + token.annotation = QStringLiteral("Flying"); + player->getPlayerActions()->actCreateToken(token); + + ASSERT_EQ(client.commands.size(), 1); + ASSERT_EQ(client.commands.first().game_command_size(), 1); + const auto &gameCommand = client.commands.first().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_FALSE(command.face_down()); + EXPECT_EQ(command.pt(), "7/9"); + EXPECT_EQ(command.annotation(), "Flying"); +} + int main(int argc, char **argv) { // Keep SettingsCache reads and writes away from the real user profile: the game logic still