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.
This commit is contained in:
Jmlundeen 2025-04-11 06:22:13 -05:00 committed by GitHub
parent 8dd8953a85
commit 1b06813997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 additions and 43 deletions

View file

@ -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);

View file

@ -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;
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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<Ability> 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);