From 1b06813997e554301e045653e30f927e7fa0ca76 Mon Sep 17 00:00:00 2001 From: Jmlundeen <98545818+Jmlundeen@users.noreply.github.com> Date: Fri, 11 Apr 2025 06:22:13 -0500 Subject: [PATCH] Reworked Suspend ability: (#13527) * Updated Delay and Gandalf Of The Secret Fire to get the main card since they target spells * Suspend now properly lets you play either side of mdfc and spell parts from adventure/omen cards utilizing CardUtil.castSpellWithAttributesForFree method * Removed extra code in SuspendPlayCardEffect since the referenced bug for Epochrasite does not seem to appear. Removed related gainedTemporary variable also. * Added tests for Omen and Suspend With Taigam, Master Opportunists as well as an Epochrasite test for recasting after suspend. --- Mage.Sets/src/mage/cards/d/Delay.java | 2 +- .../mage/cards/g/GandalfOfTheSecretFire.java | 2 +- .../cards/abilities/keywords/SuspendTest.java | 35 ++++++ .../tdm/TaigamMasterOpportunistTest.java | 105 ++++++++++++++++++ .../abilities/keyword/SuspendAbility.java | 57 +++------- 5 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/single/tdm/TaigamMasterOpportunistTest.java diff --git a/Mage.Sets/src/mage/cards/d/Delay.java b/Mage.Sets/src/mage/cards/d/Delay.java index 0e43d711f0f..ee21e510be2 100644 --- a/Mage.Sets/src/mage/cards/d/Delay.java +++ b/Mage.Sets/src/mage/cards/d/Delay.java @@ -68,7 +68,7 @@ class DelayEffect extends OneShotEffect { if (controller != null && spell != null) { Effect effect = new CounterTargetWithReplacementEffect(PutCards.EXILED); effect.setTargetPointer(this.getTargetPointer().copy()); - Card card = game.getCard(spell.getSourceId()); + Card card = spell.getMainCard(); if (card != null && effect.apply(game, source) && game.getState().getZone(card.getId()) == Zone.EXILED) { boolean hasSuspend = card.getAbilities(game).containsClass(SuspendAbility.class); UUID exileId = SuspendAbility.getSuspendExileId(controller.getId(), game); diff --git a/Mage.Sets/src/mage/cards/g/GandalfOfTheSecretFire.java b/Mage.Sets/src/mage/cards/g/GandalfOfTheSecretFire.java index 165793c9f6a..b7b93aa887f 100644 --- a/Mage.Sets/src/mage/cards/g/GandalfOfTheSecretFire.java +++ b/Mage.Sets/src/mage/cards/g/GandalfOfTheSecretFire.java @@ -119,7 +119,7 @@ class GandalfOfTheSecretFireEffect extends ReplacementEffectImpl { game.informPlayers(controller.getLogName() + " exiles " + sourceSpell.getLogName() + " with 3 time counters on it"); } if (!sourceSpell.getAbilities(game).containsClass(SuspendAbility.class)) { - game.addEffect(new GainSuspendEffect(new MageObjectReference(sourceSpell.getCard(), game)), source); + game.addEffect(new GainSuspendEffect(new MageObjectReference(sourceSpell.getMainCard(), game)), source); } return true; } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SuspendTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SuspendTest.java index 7cd178aa7e6..dd292c7229a 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SuspendTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SuspendTest.java @@ -42,6 +42,40 @@ public class SuspendTest extends CardTestPlayerBase { } + /** + * Tests bug that was mentioned in suspend ability, but does not appear to still be an issue. + * Epochrasite being unable to be cast after casting from suspend and returning to hand. + */ + @Test + public void test_Single_Epochrasite_Recast_After_Suspend() { + // Bug was mentioned in suspend ability, but does not appear to still be an issue + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + // Epochrasite enters the battlefield with three +1/+1 counters on it if you didn't cast it from your hand. + // When Epochrasite dies, exile it with three time counters on it and it gains suspend. + addCard(Zone.HAND, playerA, "Epochrasite", 1); + addCard(Zone.HAND, playerB, "Lightning Bolt", 1); + addCard(Zone.HAND, playerB, "Boomerang", 1); + addCard(Zone.BATTLEFIELD, playerB, "Mountain", 1); + addCard(Zone.BATTLEFIELD, playerB, "Island", 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Epochrasite"); + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerB, "Lightning Bolt", "Epochrasite"); + castSpell(7, PhaseStep.DRAW, playerB, "Boomerang", "Epochrasite"); + castSpell(7, PhaseStep.PRECOMBAT_MAIN, playerA, "Epochrasite"); + + + setChoice(playerA, true); // choose yes to cast + + setStrictChooseMode(true); + setStopAt(7, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertGraveyardCount(playerB, "Lightning Bolt", 1); + assertPermanentCount(playerA, "Epochrasite", 1); // returned on turn 7 and cast again after going to hand + assertPowerToughness(playerA, "Epochrasite", 1, 1); + assertAbility(playerA, "Epochrasite", HasteAbility.getInstance(), false); + } + /** * Tests Jhoira of the Ghitu works (give suspend to a exiled card) {2}, * Exile a nonland card from your hand: Put four time counters on the exiled @@ -275,6 +309,7 @@ public class SuspendTest extends CardTestPlayerBase { // 3 time counters removes on upkeep (3, 5, 7) and cast again setChoice(playerA, true); // choose yes to cast + setChoice(playerA, "Cast Wear"); addTarget(playerA, "Bident of Thassa"); checkPermanentCount("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerB, "Bident of Thassa", 0); checkPermanentCount("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerB, "Bow of Nylea", 1); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/tdm/TaigamMasterOpportunistTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/tdm/TaigamMasterOpportunistTest.java new file mode 100644 index 00000000000..9cda471dcce --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/tdm/TaigamMasterOpportunistTest.java @@ -0,0 +1,105 @@ +package org.mage.test.cards.single.tdm; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import mage.counters.CounterType; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +public class TaigamMasterOpportunistTest extends CardTestPlayerBase { + + private static final String TAIGAM = "Taigam, Master Opportunist"; + private static final String ORNITHOPTER = "Ornithopter"; + private static final String TWINMAW = "Twinmaw Stormbrood"; + private static final String BITE = "Charring Bite"; + private static final String TURTLE = "Aegis Turtle"; + private static final String AKOUM = "Akoum Warrior"; + + @Test + public void testCardWithSpellOption() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, TAIGAM); + addCard(Zone.HAND, playerA, ORNITHOPTER); + addCard(Zone.HAND, playerA, TWINMAW); + addCard(Zone.BATTLEFIELD, playerA, "Plateau", 6); + addCard(Zone.BATTLEFIELD, playerB, TURTLE, 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, ORNITHOPTER, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, BITE, TURTLE); + checkCardCounters("time counters", 1, PhaseStep.BEGIN_COMBAT, playerA, TWINMAW, CounterType.TIME, 4); + setChoice(playerA, true); + setChoice(playerA, "Cast " + BITE); + addTarget(playerA, TURTLE); + + setStopAt(9, PhaseStep.PRECOMBAT_MAIN); + execute(); + + assertLibraryCount(playerA, TWINMAW, 1); + assertGraveyardCount(playerB, TURTLE, 2); + } + + @Test + public void testMDFC() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, TAIGAM); + addCard(Zone.BATTLEFIELD, playerA, "Plateau", 6); + addCard(Zone.HAND, playerA, ORNITHOPTER); + addCard(Zone.HAND, playerA, AKOUM); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, ORNITHOPTER, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, AKOUM); + setChoice(playerA, true); + setChoice(playerA, "Play Akoum Teeth"); + + setStopAt(9, PhaseStep.PRECOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, "Akoum Teeth", 1); + assertTapped("Akoum Teeth", true); + } + + @Test + public void testMDFC2() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, "Plateau", 6); + addCard(Zone.BATTLEFIELD, playerB, "Island", 2); + addCard(Zone.HAND, playerB, "Delay"); + addCard(Zone.HAND, playerA, AKOUM); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, AKOUM); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Delay", AKOUM); + setChoice(playerA, true); + setChoice(playerA, "Play Akoum Teeth"); + + setStopAt(7, PhaseStep.PRECOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, "Akoum Teeth", 1); + assertTapped("Akoum Teeth", true); + } + + @Test + public void test() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, "Plateau", 6); + addCard(Zone.BATTLEFIELD, playerB, "Island", 2); + addCard(Zone.BATTLEFIELD, playerB, TURTLE); + addCard(Zone.HAND, playerB, "Delay"); + addCard(Zone.HAND, playerA, TWINMAW); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, TWINMAW); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Delay", TWINMAW); + setChoice(playerA, true); + setChoice(playerA, "Cast " + BITE); + addTarget(playerA, TURTLE); + + setStopAt(7, PhaseStep.PRECOMBAT_MAIN); + execute(); + + } + +} \ No newline at end of file diff --git a/Mage/src/main/java/mage/abilities/keyword/SuspendAbility.java b/Mage/src/main/java/mage/abilities/keyword/SuspendAbility.java index 72d5cbcbde4..1114ecebc64 100644 --- a/Mage/src/main/java/mage/abilities/keyword/SuspendAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/SuspendAbility.java @@ -1,6 +1,5 @@ package mage.abilities.keyword; -import mage.ApprovingObject; import mage.MageIdentifier; import mage.abilities.Ability; import mage.abilities.SpecialAction; @@ -17,8 +16,11 @@ import mage.abilities.effects.ContinuousEffectImpl; import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.common.counter.RemoveCounterSourceEffect; import mage.cards.Card; +import mage.cards.CardsImpl; +import mage.cards.ModalDoubleFacedCard; import mage.constants.*; import mage.counters.CounterType; +import mage.filter.StaticFilters; import mage.game.Game; import mage.game.events.GameEvent; import mage.game.permanent.Permanent; @@ -26,8 +28,6 @@ import mage.players.Player; import mage.target.targetpointer.FixedTarget; import mage.util.CardUtil; -import java.util.ArrayList; -import java.util.List; import java.util.Set; import java.util.UUID; @@ -112,7 +112,6 @@ import java.util.UUID; public class SuspendAbility extends SpecialAction { private final String ruleText; - private boolean gainedTemporary; /** * Gives the card the SuspendAbility @@ -177,6 +176,11 @@ public class SuspendAbility extends SpecialAction { * or added by Jhoira of the Ghitu */ public static void addSuspendTemporaryToCard(Card card, Ability source, Game game) { + if (card instanceof ModalDoubleFacedCard) { + // Need to ensure the suspend ability gets put on the left side card + // since counters get added to this card. + card = ((ModalDoubleFacedCard) card).getLeftHalfCard(); + } SuspendAbility ability = new SuspendAbility(0, null, card, false); ability.setSourceId(card.getId()); ability.setControllerId(card.getOwnerId()); @@ -206,7 +210,6 @@ public class SuspendAbility extends SpecialAction { private SuspendAbility(final SuspendAbility ability) { super(ability); this.ruleText = ability.ruleText; - this.gainedTemporary = ability.gainedTemporary; } @Override @@ -232,10 +235,6 @@ public class SuspendAbility extends SpecialAction { return ruleText; } - public boolean isGainedTemporary() { - return gainedTemporary; - } - @Override public SuspendAbility copy() { return new SuspendAbility(this); @@ -345,40 +344,16 @@ class SuspendPlayCardEffect extends OneShotEffect { if (player == null || card == null) { return false; } - if (!player.chooseUse(Outcome.Benefit, "Play " + card.getLogName() + " without paying its mana cost?", source, game)) { + // ensure we're getting the main card when passing to CardUtil to check all parts of card + // MDFC points to left half card + card = card.getMainCard(); + // cast/play the card for free + if (!CardUtil.castSpellWithAttributesForFree(player, source, game, new CardsImpl(card), + StaticFilters.FILTER_CARD, null, true)) { return true; } - // remove temporary suspend ability (used e.g. for Epochrasite) - // TODO: isGainedTemporary is not set or use in other places, so it can be deleted?! - List abilitiesToRemove = new ArrayList<>(); - for (Ability ability : card.getAbilities(game)) { - if (ability instanceof SuspendAbility && (((SuspendAbility) ability).isGainedTemporary())) { - abilitiesToRemove.add(ability); - } - } - if (!abilitiesToRemove.isEmpty()) { - for (Ability ability : card.getAbilities(game)) { - if (ability instanceof SuspendBeginningOfUpkeepInterveningIfTriggeredAbility - || ability instanceof SuspendPlayCardAbility) { - abilitiesToRemove.add(ability); - } - } - // remove the abilities from the card - // TODO: will not work with Adventure Cards and another auto-generated abilities list - // TODO: is it work after blink or return to hand? - /* - bug example: - Epochrasite bug: It comes out of suspend, is cast and enters the battlefield. THEN if it's returned to - its owner's hand from battlefield, the bounced Epochrasite can't be cast for the rest of the game. - */ - card.getAbilities().removeAll(abilitiesToRemove); - } - // cast the card for free - game.getState().setValue("PlayFromNotOwnHandZone" + card.getId(), Boolean.TRUE); - boolean cardWasCast = player.cast(player.chooseAbilityForCast(card, game, true), - game, true, new ApprovingObject(source, game)); - game.getState().setValue("PlayFromNotOwnHandZone" + card.getId(), null); - if (cardWasCast && (card.isCreature(game))) { + // creatures cast from suspend gain haste + if ((card.isCreature(game))) { ContinuousEffect effect = new GainHasteEffect(); effect.setTargetPointer(new FixedTarget(card.getId(), card.getZoneChangeCounter(game) + 1)); game.addEffect(effect, source);