diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 046a1ae90..05db71a27 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -117,9 +118,32 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position return {cardName, fromStr}; } +void MessageLogWidget::logTransformIntoCard(const QString &playerName, + QString oldCardName, + QString newCardName, + QString pt, + bool faceDown) +{ + if (faceDown) { + appendHtmlServerMessage( + tr("%1 transforms %2 into a face down card.").arg(playerName).arg(cardLink(std::move(oldCardName)))); + } else { + appendHtmlServerMessage(tr("%1 transforms %2 into %3%4.") + .arg(playerName) + .arg(cardLink(std::move(oldCardName))) + .arg(cardLink(std::move(newCardName))) + .arg(pt.isEmpty() ? QString() : QString(" (%1)").arg(sanitizeHtml(pt)))); + } +} + void MessageLogWidget::containerProcessingDone() { + for (const auto &[playerName, cardName] : deferredDestroyCardNames) { + appendHtmlServerMessage(tr("%1 destroys %2.").arg(playerName).arg(cardLink(cardName))); + } + deferredDestroyCardNames.clear(); currentContext = MessageContext_None; + transformOldCardName.clear(); messageSuffix = messagePrefix = QString(); } @@ -129,6 +153,8 @@ void MessageLogWidget::containerProcessingStarted(const GameEventContext &contex currentContext = MessageContext_MoveCard; } else if (context.HasExtension(Context_Mulligan::ext)) { currentContext = MessageContext_Mulligan; + } else if (context.HasExtension(Context_TransformCard::ext)) { + currentContext = MessageContext_TransformCard; } } @@ -252,6 +278,27 @@ void MessageLogWidget::logCreateArrow(PlayerLogic *player, void MessageLogWidget::logCreateToken(PlayerLogic *player, QString cardName, QString pt, bool faceDown) { + if (currentContext == MessageContext_TransformCard) { + if (transformOldCardName.isEmpty()) { + appendHtmlServerMessage(tr("%1 creates token: %2%3.") + .arg(sanitizeHtml(player->getPlayerInfo()->getName())) + .arg(cardLink(std::move(cardName))) + .arg(pt.isEmpty() ? QString() : QString(" (%1)").arg(sanitizeHtml(pt)))); + } else { + logTransformIntoCard(sanitizeHtml(player->getPlayerInfo()->getName()), std::move(transformOldCardName), + std::move(cardName), pt, faceDown); + } + transformOldCardName.clear(); + return; + } + + if (currentContext == MessageContext_MoveCard && !deferredDestroyCardNames.isEmpty()) { + const auto deferredDestroy = deferredDestroyCardNames.takeLast(); + logTransformIntoCard(deferredDestroy.first, std::move(deferredDestroy.second), std::move(cardName), pt, + faceDown); + return; + } + if (faceDown) { appendHtmlServerMessage( tr("%1 creates a face down token.").arg(sanitizeHtml(player->getPlayerInfo()->getName()))); @@ -278,6 +325,16 @@ void MessageLogWidget::logDeckSelect(PlayerLogic *player, QString deckHash, int void MessageLogWidget::logDestroyCard(PlayerLogic *player, QString cardName) { + if (currentContext == MessageContext_TransformCard) { + transformOldCardName = cardName; + return; + } + + if (currentContext == MessageContext_MoveCard) { + deferredDestroyCardNames.append({sanitizeHtml(player->getPlayerInfo()->getName()), std::move(cardName)}); + return; + } + appendHtmlServerMessage( tr("%1 destroys %2.").arg(sanitizeHtml(player->getPlayerInfo()->getName())).arg(cardLink(std::move(cardName)))); } @@ -289,6 +346,14 @@ void MessageLogWidget::logMoveCard(PlayerLogic *player, CardZoneLogic *targetZone, int newX) { + // A destroyed card that was not replaced by a re-created card has no + // following move event, so flush any pending destroy logs before logging + // the next move within this batch. + for (const auto &[playerName, cardName] : deferredDestroyCardNames) { + appendHtmlServerMessage(tr("%1 destroys %2.").arg(playerName).arg(cardLink(cardName))); + } + deferredDestroyCardNames.clear(); + if (currentContext == MessageContext_Mulligan) { return; } @@ -817,6 +882,10 @@ void MessageLogWidget::logSpectatorSay(const ServerInfo_User &spectator, QString void MessageLogWidget::logUnattachCard(PlayerLogic *player, QString cardName) { + if (currentContext == MessageContext_TransformCard) { + return; + } + appendHtmlServerMessage(tr("%1 unattaches %2.") .arg(sanitizeHtml(player->getPlayerInfo()->getName())) .arg(cardLink(std::move(cardName)))); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5642a76e9..3ddcdd7ab 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -9,6 +9,7 @@ #include "../../interface/widgets/server/chat_view/chat_view.h" +#include #include #include #include @@ -34,13 +35,25 @@ private: { MessageContext_None, MessageContext_MoveCard, - MessageContext_Mulligan + MessageContext_Mulligan, + MessageContext_TransformCard }; MessageContext currentContext; + QString transformOldCardName; + // During a move, a destroyed token may be replaced by a re-created stashed + // card in the start zone. The destroy event name is buffered here so the + // matching create-token event can render "transforms X into Y" instead of + // a destroy + create token pair. Entries are {playerName, cardName}. + QList> deferredDestroyCardNames; QString messagePrefix, messageSuffix; static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange); + void logTransformIntoCard(const QString &playerName, + QString oldCardName, + QString newCardName, + QString pt, + bool faceDown); public: void connectToPlayerEventHandler(PlayerEventHandler *player); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index b5a620798..32104ca48 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -1012,6 +1013,7 @@ Server_AbstractPlayer::cmdCreateToken(const Command_CreateToken &cmd, ResponseCo if (targetZone) { targetCard = targetZone->getCard(cmd.target_card_id()); if (targetCard && cmd.target_mode() == Command_CreateToken::TRANSFORM_INTO) { + ges.setGameEventContext(Context_TransformCard()); if (targetCard->getParentCard()) { ges.enqueueGameEvent(makeAttachCardEvent(targetCard), playerId); } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 059e83096..598fe63a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -80,6 +80,7 @@ set(PROTO_FILES context_ping_changed.proto context_ready_start.proto context_set_sideboard_lock.proto + context_transform_card.proto context_undo_draw.proto developer_commands.proto event_add_to_list.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/context_transform_card.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/context_transform_card.proto new file mode 100644 index 000000000..f4aef7433 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/context_transform_card.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "game_event_context.proto"; + +message Context_TransformCard { + extend GameEventContext { + optional Context_TransformCard ext = 1010; + } +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event_context.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event_context.proto index 76dccad0d..94f8b0fa2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event_context.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event_context.proto @@ -11,6 +11,7 @@ message GameEventContext { CONNECTION_STATE_CHANGED = 1007; SET_SIDEBOARD_LOCK = 1008; UNCONCEDE = 1009; + TRANSFORM_CARD = 1010; } extensions 100 to max; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4d7a02ea4..3bb0bdb09 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -340,6 +340,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_transform_context_test + SOURCES + server_transform_context_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_forced_password_change_test diff --git a/tests/server_transform_context_test.cpp b/tests/server_transform_context_test.cpp new file mode 100644 index 000000000..42f02b3ff --- /dev/null +++ b/tests/server_transform_context_test.cpp @@ -0,0 +1,248 @@ +/** @file server_transform_context_test.cpp + * @brief Tests that the card transform path attaches a Context_TransformCard + * to the event storage, so the client can render a single + * "Player X transforms card Y into card Z" chat message. + * @ingroup Tests + */ + +#include "game/game_config.h" +#include "game/server_abstract_player.h" +#include "game/server_card.h" +#include "game/server_cardzone.h" +#include "game/server_game.h" +#include "movecard_tests/server_test_helpers.h" +#include "server_response_containers.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class TransformContextTest : public ::testing::Test +{ +protected: + ServerInfo_User user; + FakeServer server; + std::unique_ptr room; + std::unique_ptr game; + std::unique_ptr player; + Server_Card *targetCard = nullptr; + + void SetUp() override + { + user.set_name("test-user"); + + room = std::make_unique(0, 0, "", "", "", "", false, "", QList(), &server); + GameConfig config{.creatorInfo = user, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + game = std::make_unique(config, room.get()); + + // Start the game with no participants so cmdCreateToken can run; the + // start is emitted via a queued connection, so process posted events. + game->startGameIfReady(true); + QCoreApplication::processEvents(); + ASSERT_TRUE(game->getGameStarted()); + + player = std::make_unique(game.get(), 1, user, false, nullptr); + auto *tableZone = new Server_CardZone(player.get(), ZoneNames::TABLE, true, ServerInfo_Zone::PublicZone); + player->addZone(tableZone); + + targetCard = new Server_Card({"Old Card", "old-card"}, player->newCardId(), 0, 0); + tableZone->insertCard(targetCard, 0, 0); + } +}; + +Server_CardZone *tableZoneOf(Server_AbstractPlayer &player) +{ + return player.getZones().value(ZoneNames::TABLE); +} + +Command_CreateToken makeTransformCommand(uint32_t targetCardId) +{ + Command_CreateToken cmd; + cmd.set_zone(ZoneNames::TABLE); + cmd.set_target_zone(ZoneNames::TABLE); + cmd.set_target_card_id(targetCardId); + cmd.set_target_mode(Command_CreateToken::TRANSFORM_INTO); + cmd.set_card_name("New Card"); + cmd.set_card_provider_id("new-card"); + cmd.set_color("m"); + cmd.set_pt("2/2"); + cmd.set_destroy_on_zone_change(true); + cmd.set_x(-1); + cmd.set_y(-1); + return cmd; +} + +TEST_F(TransformContextTest, TransformCommandSetsTransformContext) +{ + GameEventStorage ges; + ResponseContainer rc(0); + + const auto response = player->cmdCreateToken(makeTransformCommand(targetCard->getId()), rc, ges); + + EXPECT_EQ(response, Response::RespOk); + ASSERT_NE(ges.getGameEventContext(), nullptr); + EXPECT_TRUE(static_cast(ges.getGameEventContext())->HasExtension(Context_TransformCard::ext)); +} + +TEST_F(TransformContextTest, TransformEventSequenceDestroyThenCreate) +{ + GameEventStorage ges; + ResponseContainer rc(0); + + ASSERT_EQ(player->cmdCreateToken(makeTransformCommand(targetCard->getId()), rc, ges), Response::RespOk); + + QList destroyedIds; + QList createdTokens; + for (const auto *item : ges.getGameEventList()) { + const GameEvent &event = item->getGameEvent(); + if (event.HasExtension(Event_DestroyCard::ext)) { + destroyedIds.append(event.GetExtension(Event_DestroyCard::ext).card_id()); + } + if (event.HasExtension(Event_CreateToken::ext)) { + createdTokens.append(event.GetExtension(Event_CreateToken::ext)); + } + } + + // The old card is destroyed and exactly one token is created in its place. + EXPECT_EQ(destroyedIds, QList({static_cast(targetCard->getId())})); + ASSERT_EQ(createdTokens.size(), 1); + EXPECT_EQ(createdTokens.first().card_name(), "New Card"); + EXPECT_EQ(createdTokens.first().pt(), "2/2"); + EXPECT_EQ(createdTokens.first().destroy_on_zone_change(), true); + + // The old card is removed from the table and the new card replaces it. + EXPECT_EQ(tableZoneOf(*player)->getCard(targetCard->getId()), nullptr); + EXPECT_NE(tableZoneOf(*player)->getCard(createdTokens.first().card_id()), nullptr); +} + +TEST_F(TransformContextTest, PlainTokenCreateHasNoTransformContext) +{ + Command_CreateToken cmd; + cmd.set_zone(ZoneNames::TABLE); + cmd.set_card_name("Plain Token"); + cmd.set_card_provider_id("plain-token"); + cmd.set_color(""); + cmd.set_pt("1/1"); + cmd.set_destroy_on_zone_change(true); + cmd.set_x(-1); + cmd.set_y(-1); + + GameEventStorage ges; + ResponseContainer rc(0); + + EXPECT_EQ(player->cmdCreateToken(cmd, rc, ges), Response::RespOk); + EXPECT_EQ(ges.getGameEventContext(), nullptr); +} + +TEST_F(TransformContextTest, MovingTransformedTokenRevertsToOriginalCard) +{ + GameEventStorage transformGes; + ResponseContainer rc(0); + ASSERT_EQ(player->cmdCreateToken(makeTransformCommand(targetCard->getId()), rc, transformGes), Response::RespOk); + + uint32_t transformedCardId = 0; + for (const auto *item : transformGes.getGameEventList()) { + const GameEvent &event = item->getGameEvent(); + if (event.HasExtension(Event_CreateToken::ext)) { + transformedCardId = event.GetExtension(Event_CreateToken::ext).card_id(); + } + } + ASSERT_NE(transformedCardId, 0u); + ASSERT_NE(tableZoneOf(*player)->getCard(transformedCardId), nullptr); + + auto *graveZone = new Server_CardZone(player.get(), ZoneNames::GRAVE, false, ServerInfo_Zone::HiddenZone); + player->addZone(graveZone); + + CardToMove cardToMove; + cardToMove.set_card_id(transformedCardId); + QList cardsToMove = {&cardToMove}; + + GameEventStorage moveGes; + ASSERT_EQ(player->moveCard(moveGes, tableZoneOf(*player), cardsToMove, graveZone, 0, 0, false, false, false), + Response::RespOk); + + // The batch context is MoveCard so the client associates this sequence with + // the revert of a transformed token. + ASSERT_NE(moveGes.getGameEventContext(), nullptr); + EXPECT_TRUE(static_cast(moveGes.getGameEventContext())->HasExtension(Context_MoveCard::ext)); + + int destroyIndex = -1; + int createIndex = -1; + int moveIndex = -1; + Event_CreateToken resurrectEvent; + QList events; + for (const auto *item : moveGes.getGameEventList()) { + events.append(&item->getGameEvent()); + } + for (int i = 0; i < events.size(); ++i) { + const GameEvent &event = *events.at(i); + if (event.HasExtension(Event_DestroyCard::ext)) { + const auto &destroyEvent = event.GetExtension(Event_DestroyCard::ext); + EXPECT_EQ(destroyEvent.card_id(), transformedCardId); + EXPECT_EQ(QString::fromStdString(destroyEvent.zone_name()), ZoneNames::TABLE); + destroyIndex = i; + } else if (event.HasExtension(Event_CreateToken::ext)) { + resurrectEvent = event.GetExtension(Event_CreateToken::ext); + createIndex = i; + } else if (event.HasExtension(Event_MoveCard::ext)) { + moveIndex = i; + } + } + + // The transformed token is destroyed, the stashed original is re-created in + // the start zone, and the original card continues into the target zone. + EXPECT_NE(destroyIndex, -1); + ASSERT_NE(createIndex, -1); + EXPECT_NE(moveIndex, -1); + EXPECT_LT(destroyIndex, createIndex); + EXPECT_LT(createIndex, moveIndex); + + EXPECT_EQ(resurrectEvent.card_name(), "Old Card"); + EXPECT_EQ(QString::fromStdString(resurrectEvent.zone_name()), ZoneNames::TABLE); + ASSERT_TRUE(resurrectEvent.has_card_id()); + + // The transformed card is gone from the table and the original card moved + // into the graveyard. + EXPECT_EQ(tableZoneOf(*player)->getCard(transformedCardId), nullptr); + const auto &graveCards = graveZone->getCards(); + ASSERT_EQ(graveCards.size(), 1); + EXPECT_EQ(graveCards.first()->getId(), resurrectEvent.card_id()); + EXPECT_EQ(graveCards.first()->getName(), "Old Card"); +} + +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file