From 6b86b1053fc10fa0e7ed9872f55d43f093acc229 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Sun, 26 May 2024 13:01:36 +0400 Subject: [PATCH] Modal double-faced cards - improved support with copy effects (fixed that copied token has abilities from both sides, closes #10146, closes #8476); --- .../ModalDoubleFacedCardsTest.java | 132 ++++++++++++++++++ .../common/CreateTokenCopyTargetEffect.java | 2 +- .../util/functions/CopyTokenFunction.java | 58 +++++--- 3 files changed, 168 insertions(+), 24 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/cost/modaldoublefaced/ModalDoubleFacedCardsTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modaldoublefaced/ModalDoubleFacedCardsTest.java index 1eb571247d4..e356561a03d 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/cost/modaldoublefaced/ModalDoubleFacedCardsTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modaldoublefaced/ModalDoubleFacedCardsTest.java @@ -1,18 +1,27 @@ package org.mage.test.cards.cost.modaldoublefaced; import mage.abilities.keyword.HasteAbility; +import mage.abilities.keyword.MenaceAbility; import mage.cards.Card; import mage.cards.ModalDoubleFacedCard; +import mage.constants.CardType; import mage.constants.PhaseStep; import mage.constants.SubType; import mage.constants.Zone; import mage.game.permanent.PermanentCard; +import mage.game.permanent.PermanentToken; import mage.util.CardUtil; import mage.util.ManaUtil; +import mage.view.GameView; +import mage.view.PermanentView; import org.junit.Assert; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBase; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * @author JayDi85 */ @@ -973,6 +982,129 @@ public class ModalDoubleFacedCardsTest extends CardTestPlayerBase { assertPermanentCount(playerA, "Birgi, God of Storytelling", 1); } + @Test + public void test_Copy_TokenFromCard_MustIgnoreSecondSide() { + // bug: copied tokens of MDF cards has abilities from both sides + // https://github.com/magefree/mage/issues/8476 + + // 707.8a + // If an effect creates a token that is a copy of a transforming permanent or a transforming double-faced + // card not on the battlefield, the resulting token is a transforming token that has both a front face + // and a back face. The characteristics of each face are determined by the copiable values of the same + // face of the permanent it is a copy of, as modified by any other copy effects that apply to that permanent. + // If the token is a copy of a transforming permanent with its back face up, the token enters the battlefield + // with its back face up. This rule does not apply to tokens that are created with their own set of + // characteristics and enter the battlefield as a copy of a transforming permanent due to a replacement effect. + + // MDFC is not transforming doubled-faced card, so token must have only single non-transformable side + + // {2}{R}, {T}: Create a token that's a copy of target creature card in your graveyard, except it's an artifact + // in addition to its other types. It gains haste. Sacrifice it at the beginning of the next end step. + addCard(Zone.BATTLEFIELD, playerA, "Feldon of the Third Path"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3); + // + // 1 + // Tergrid, God of Fright + // Legendary Creature - God + // Menace + // Whenever an opponent sacrifices a nontoken permanent or discards a permanent card, you may put that card onto + // the battlefield under your control from their graveyard. + // 2 + // Tergrid's Lantern + // Legendary Artifact + // 4/5 + // {T}: Target player loses 3 life unless they sacrifice a nonland permanent or discard a card. + // {3}{B}: Untap Tergrid’s Lantern. + addCard(Zone.GRAVEYARD, playerA, "Tergrid, God of Fright", 1); // {2}{R} + + // find and keep image data + Map imageData = new HashMap<>(); + imageData.put("set code", ""); + imageData.put("card number", ""); + imageData.put("image number", ""); + imageData.put("use var art", ""); + runCode("collect", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> { + Assert.assertEquals(1, playerA.getGraveyard().size()); + Card card = playerA.getGraveyard().getCards(game).stream().findFirst().orElse(null); + Assert.assertNotNull(card); + imageData.put("set code", card.getExpansionSetCode()); + imageData.put("card number", card.getCardNumber()); + imageData.put("image number", String.valueOf(card.getImageNumber())); + imageData.put("use var art", String.valueOf(card.getUsesVariousArt())); + }); + + // prepare token from MDFC + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}{R}, {T}: Create a token"); + addTarget(playerA, "Tergrid, God of Fright"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + + // SERVER SIDE + // from side 1 + checkType("server must use side 1 - type", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", CardType.CREATURE, true); + checkSubType("server must use side 1 - subtype", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", SubType.GOD, true); + checkPT("server must use side 1 - PT", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", 4, 5); + checkAbility("server must use side 1 - menace ability", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", MenaceAbility.class, true); + // from copy effect + checkType("server must use effect - artifact", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", CardType.ARTIFACT, true); + checkAbility("server must use effect - haste ability", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Tergrid, God of Fright", HasteAbility.class, true); + // from side 2 + runCode("check side 2", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> { + PermanentToken permanent = (PermanentToken) game.getBattlefield().getAllPermanents() + .stream() + .filter(p -> p.getName().equals("Tergrid, God of Fright")) + .findFirst() + .orElse(null); + Assert.assertNotNull(permanent); + + // MDFC on battlefield has only one side (not transformable) + Assert.assertFalse("server must not be transformable", permanent.isTransformable()); + Assert.assertNull("server must have not other side", permanent.getOtherFace()); + + List rules = permanent.getRules(game); + Assert.assertTrue("server must ignore side 2 - untap ability", rules.stream().noneMatch(r -> r.contains("Untap"))); + Assert.assertTrue("server must ignore side 2 - target player ability", rules.stream().noneMatch(r -> r.contains("Target player loses"))); + + Assert.assertEquals("server image data - set code", imageData.get("set code"), permanent.getExpansionSetCode()); + Assert.assertEquals("server image data - card number", imageData.get("card number"), permanent.getCardNumber()); + Assert.assertEquals("server image data - image number", imageData.get("image number"), String.valueOf(permanent.getImageNumber())); + Assert.assertEquals("server image data - use var art", imageData.get("use var art"), String.valueOf(permanent.getUsesVariousArt())); + }); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPowerToughness(playerA, "Tergrid, God of Fright", 4, 5); + + // CLIENT SIDE + GameView gameView = getGameView(playerA); + PermanentView permanentView = gameView.getMyPlayer().getBattlefield().values() + .stream() + .filter(p -> p.getName().equals("Tergrid, God of Fright")) + .findFirst() + .orElse(null); + Assert.assertNotNull(permanentView); + List rules = permanentView.getRules(); + // from side 1 + Assert.assertTrue("client must use side 1 - type", permanentView.getTypeText().contains("Creature")); + Assert.assertTrue("client must use side 1 - subtype", permanentView.getSubTypes().contains(SubType.GOD)); + Assert.assertEquals("client must use side 1 - P", "4", permanentView.getPower()); + Assert.assertEquals("client must use side 1 - T", "5", permanentView.getToughness()); + Assert.assertTrue("client must use side 1 - menace ability", rules.stream().anyMatch(r -> r.contains("Menace"))); + // from copy effect + Assert.assertTrue("client must use effect - artifact", permanentView.getTypeText().contains("Artifact")); + Assert.assertTrue("client must use effect - haste ability", rules.stream().anyMatch(r -> r.contains("Haste"))); + // from side 2 + Assert.assertTrue("client must ignore side 2 - untap ability", rules.stream().noneMatch(r -> r.contains("Untap"))); + Assert.assertTrue("client must ignore side 2 - target player ability", rules.stream().noneMatch(r -> r.contains("Target player loses"))); + // image data + Assert.assertEquals("client image data - set code", imageData.get("set code"), permanentView.getExpansionSetCode()); + Assert.assertEquals("client image data - card number", imageData.get("card number"), permanentView.getCardNumber()); + Assert.assertEquals("client image data - image number", imageData.get("image number"), String.valueOf(permanentView.getImageNumber())); + Assert.assertEquals("client image data - use var art", imageData.get("use var art"), String.valueOf(permanentView.getUsesVariousArt())); + } + + @Test public void test_FindMovedPermanentByCard() { // original problem: you must be able to find a card after move it to battlefield diff --git a/Mage/src/main/java/mage/abilities/effects/common/CreateTokenCopyTargetEffect.java b/Mage/src/main/java/mage/abilities/effects/common/CreateTokenCopyTargetEffect.java index d1c7fd59748..bcf7f83578b 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/CreateTokenCopyTargetEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/CreateTokenCopyTargetEffect.java @@ -205,7 +205,7 @@ public class CreateTokenCopyTargetEffect extends OneShotEffect { } // create token and modify all attributes permanently (without game usage) - Token token = CopyTokenFunction.createTokenCopy(copyFrom, game); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer) + Token token = CopyTokenFunction.createTokenCopy(copyFrom, game); // needed so that entersBattlefield triggered abilities see the attributes (e.g. Master Biomancer) applier.apply(game, token, source, targetId); if (becomesArtifact) { token.addCardType(CardType.ARTIFACT); diff --git a/Mage/src/main/java/mage/util/functions/CopyTokenFunction.java b/Mage/src/main/java/mage/util/functions/CopyTokenFunction.java index a1950d663a0..d3b78546363 100644 --- a/Mage/src/main/java/mage/util/functions/CopyTokenFunction.java +++ b/Mage/src/main/java/mage/util/functions/CopyTokenFunction.java @@ -43,32 +43,40 @@ public class CopyTokenFunction { if (target == null) { throw new IllegalArgumentException("Target can't be null"); } - // A copy contains only the attributes of the basic card or basic Token that's the base of the permanent - // else gained abililies would be copied too. - target.setEntersTransformed(source instanceof Permanent && ((Permanent) source).isTransformed()); - MageObject sourceObj; - // from another token + // apply transformed status on ETB + target.setEntersTransformed(source instanceof Permanent && ((Permanent) source).isTransformed()); + + // 707.8a + // If an effect creates a token that is a copy of a transforming permanent or a transforming double-faced + // card not on the battlefield, the resulting token is a transforming token that has both a front face + // and a back face. The characteristics of each face are determined by the copiable values of the same + // face of the permanent it is a copy of, as modified by any other copy effects that apply to that permanent. + // If the token is a copy of a transforming permanent with its back face up, the token enters the battlefield + // with its back face up. This rule does not apply to tokens that are created with their own set of + // characteristics and enter the battlefield as a copy of a transforming permanent due to a replacement effect. + + // from token permanent if (source instanceof PermanentToken) { // create token from another token - Token sourceToken = ((PermanentToken) source).getToken(); - sourceObj = sourceToken; - target.setCopySourceCard(((PermanentToken) source).getToken().getCopySourceCard()); - + Token sourceObj = ((PermanentToken) source).getToken(); + target.setCopySourceCard(sourceObj.getCopySourceCard()); // must link with original card + // main side copyToToken(target, sourceObj, game); CardUtil.copySetAndCardNumber(target, source); - if (sourceToken.getBackFace() != null) { - copyToToken(target.getBackFace(), sourceToken.getBackFace(), game); - CardUtil.copySetAndCardNumber(target.getBackFace(), sourceToken.getBackFace()); + // second side + if (sourceObj.getBackFace() != null) { + copyToToken(target.getBackFace(), sourceObj.getBackFace(), game); + CardUtil.copySetAndCardNumber(target.getBackFace(), sourceObj.getBackFace()); } return; } - // from a permanent + // from non-token permanent if (source instanceof PermanentCard) { // create token from non-token permanent - // face down must hide all info + // apply face down status PermanentCard sourcePermanent = (PermanentCard) source; BecomesFaceDownCreatureEffect.FaceDownType faceDownType = BecomesFaceDownCreatureEffect.findFaceDownType(game, sourcePermanent); if (faceDownType != null) { @@ -76,15 +84,18 @@ public class CopyTokenFunction { return; } - sourceObj = source.getMainCard(); - target.setCopySourceCard((Card) sourceObj); - + Card sourceObj = source.getMainCard(); + target.setCopySourceCard(sourceObj); + // main side copyToToken(target, sourceObj, game); CardUtil.copySetAndCardNumber(target, sourceObj); - if (((Card) sourceObj).isTransformable()) { - copyToToken(target.getBackFace(), ((Card) sourceObj).getSecondCardFace(), game); - CardUtil.copySetAndCardNumber(target.getBackFace(), ((Card) sourceObj).getSecondCardFace()); + // second side + if (sourceObj.isTransformable()) { + copyToToken(target.getBackFace(), sourceObj.getSecondCardFace(), game); + CardUtil.copySetAndCardNumber(target.getBackFace(), sourceObj.getSecondCardFace()); } + + // apply prototyped status if (((PermanentCard) source).isPrototyped()){ Abilities abilities = source.getAbilities(); for (Ability ability : abilities){ @@ -96,12 +107,13 @@ public class CopyTokenFunction { return; } - // from another object like card (example: Embalm ability) - sourceObj = source; + // from another card (example: Embalm ability) + Card sourceObj = CardUtil.getDefaultCardSideForBattlefield(game, source.getMainCard()); target.setCopySourceCard(source); - + // main side copyToToken(target, sourceObj, game); CardUtil.copySetAndCardNumber(target, sourceObj); + // second side if (source.isTransformable()) { if (target.getBackFace() == null) { // if you catch this then a weird use case here: card with single face copy another token with double face??