* Split cards - fixed not working combo with suspend/delay abilities (#946, #6549);

This commit is contained in:
Oleg Agafonov 2020-06-01 09:41:48 +04:00
parent e7684e4bba
commit 121dc3501e
3 changed files with 166 additions and 53 deletions

View file

@ -1,4 +1,3 @@
package org.mage.test.cards.abilities.keywords;
import mage.abilities.keyword.HasteAbility;
@ -9,15 +8,13 @@ import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
*
* @author LevelX2
* @author LevelX2, JayDi85
*/
public class SuspendTest extends CardTestPlayerBase {
/**
* Tests Epochrasite works (give suspend to a exiled card) When Epochrasite
* dies, exile it with three time counters on it and it gains suspend.
*
*/
@Test
public void testEpochrasite() {
@ -46,7 +43,6 @@ public class SuspendTest extends CardTestPlayerBase {
* 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
* card. If it doesn't have suspend, it gains suspend.
*
*/
@Test
public void testJhoiraOfTheGhitu() {
@ -71,7 +67,6 @@ public class SuspendTest extends CardTestPlayerBase {
/**
* Tests that a spell countered with delay goes to exile with 3 time
* counters and can be cast after the 3 counters are removed
*
*/
@Test
public void testDelay() {
@ -141,7 +136,6 @@ public class SuspendTest extends CardTestPlayerBase {
/**
* Suppression Field incorrectly makes suspend cards cost 2 more to suspend.
* It made my Rift Bolt cost 2R to suspend instead of R
*
*/
@Test
public void testCostManipulation() {
@ -164,9 +158,8 @@ public class SuspendTest extends CardTestPlayerBase {
* Cards cast from other zones that aren't the hand should not trigger
* Knowledge Pool, as it states that only cards cast from the hand should be
* exiled afterwards.
*
* <p>
* Example: cards coming off suspend shouldn't trigger Knowledge Pool.
*
*/
@Test
public void testThatNotCastFromHand() {
@ -199,4 +192,124 @@ public class SuspendTest extends CardTestPlayerBase {
assertPermanentCount(playerA, "Silvercoat Lion", 0);
}
/*
Delay {1}{U}
Counter target spell. If the spell is countered this way, exile it with three time counters on it instead of putting
it into its owners graveyard. If it doesnt have suspend, it gains suspend. (At the beginning of its owners upkeep,
remove a time counter from that card. When the last is removed, the player plays it without paying its mana cost.
If its a creature, it has haste.)
Bug: Casting Delay on a fused Wear // Tear resulted in time counters never coming off it. It just sat there with
three counters every turn. See https://github.com/magefree/mage/issues/6549
*/
@Test
public void test_Delay_SimpleSpell() {
//
addCard(Zone.HAND, playerA, "Delay", 1);
addCard(Zone.BATTLEFIELD, playerA, "Island", 2);
//
addCard(Zone.HAND, playerA, "Lightning Bolt", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3);
// cast spell and counter it with delay
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", playerB);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Delay", "Lightning Bolt", "Lightning Bolt");
//
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkLife("after counter", 1, PhaseStep.PRECOMBAT_MAIN, playerB, 20);
checkExileCount("after counter", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", 1);
// 3 time counters removes on upkeep (3, 5, 7) and cast again
setChoice(playerA, "Cast");
addTarget(playerA, playerB);
checkLife("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerB, 20 - 3);
checkGraveyardCount("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", 1);
setStrictChooseMode(true);
setStopAt(7, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
@Test
public void test_Delay_SplitSingle() {
addCard(Zone.HAND, playerA, "Delay", 1);
addCard(Zone.BATTLEFIELD, playerA, "Island", 5);
//
// Wear {1}{R} Destroy target artifact.
// Tear {W} Destroy target enchantment.
addCard(Zone.HAND, playerA, "Wear // Tear", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 5);
addCard(Zone.BATTLEFIELD, playerA, "Plains", 5);
//
addCard(Zone.BATTLEFIELD, playerB, "Bident of Thassa", 1); // Legendary Enchantment Artifact
addCard(Zone.BATTLEFIELD, playerB, "Bow of Nylea", 1); // Legendary Enchantment Artifact
// cast spell and counter it with delay
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Wear", "Bident of Thassa");
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Delay", "Wear", "Wear");
//
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after counter", 1, PhaseStep.PRECOMBAT_MAIN, playerB, "Bident of Thassa", 1);
checkPermanentCount("after counter", 1, PhaseStep.PRECOMBAT_MAIN, playerB, "Bow of Nylea", 1);
checkExileCount("after counter", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Wear // Tear", 1);
// 3 time counters removes on upkeep (3, 5, 7) and cast again
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);
checkGraveyardCount("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerA, "Wear // Tear", 1);
setStrictChooseMode(true);
setStopAt(7, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
@Test
public void test_Delay_SplitFused() {
/*
Bug: Casting Delay on a fused Wear // Tear resulted in time counters never coming off it. It just sat there with
three counters every turn. See https://github.com/magefree/mage/issues/6549
*/
//
addCard(Zone.HAND, playerA, "Delay", 1);
addCard(Zone.BATTLEFIELD, playerA, "Island", 5);
//
// Wear {1}{R} Destroy target artifact.
// Tear {W} Destroy target enchantment.
addCard(Zone.HAND, playerA, "Wear // Tear", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 5);
addCard(Zone.BATTLEFIELD, playerA, "Plains", 5);
//
addCard(Zone.BATTLEFIELD, playerB, "Bident of Thassa", 1); // Legendary Enchantment Artifact
addCard(Zone.BATTLEFIELD, playerB, "Bow of Nylea", 1); // Legendary Enchantment Artifact
// cast fused spell and counter it with delay
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "fused Wear // Tear");
addTarget(playerA, "Bident of Thassa");
addTarget(playerA, "Bow of Nylea");
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Delay", "Cast fused Wear // Tear", "Cast fused Wear // Tear");
//
checkPermanentCount("after counter", 1, PhaseStep.POSTCOMBAT_MAIN, playerB, "Bident of Thassa", 1);
checkPermanentCount("after counter", 1, PhaseStep.POSTCOMBAT_MAIN, playerB, "Bow of Nylea", 1);
checkExileCount("after counter", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Wear // Tear", 1);
// 3 time counters removes on upkeep (3, 5, 7) and cast again (fused cards can't be played from exile zone, so select split spell only)
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);
checkGraveyardCount("after suspend", 7, PhaseStep.PRECOMBAT_MAIN, playerA, "Wear // Tear", 1);
setStrictChooseMode(true);
setStopAt(7, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
}

View file

@ -1,8 +1,5 @@
package mage.abilities.keyword;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import mage.MageObject;
import mage.MageObjectReference;
import mage.abilities.Ability;
@ -27,10 +24,13 @@ import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.target.targetpointer.FixedTarget;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
*
* 502.59. Suspend
*
* <p>
* 502.59a Suspend is a keyword that represents three abilities. The first is a
* static ability that functions while the card with suspend is in a player's
* hand. The second and third are triggered abilities that function in the
@ -42,13 +42,13 @@ import mage.target.targetpointer.FixedTarget;
* card, if it's removed from the game, play it without paying its mana cost if
* able. If you can't, it remains removed from the game. If you play it this way
* and it's a creature, it gains haste until you lose control of it."
*
* <p>
* 502.59b A card is "suspended" if it's in the removed-from-the-game zone, has
* suspend, and has a time counter on it.
*
* <p>
* 502.59c Playing a spell as an effect of its suspend ability follows the rules
* for paying alternative costs in rules 409.1b and 409.1f-h.
*
* <p>
* The phrase "if you could play this card from your hand" checks only for
* timing restrictions and permissions. This includes both what's inherent in
* the card's type (for example, if the card with suspend is a creature, it must
@ -57,38 +57,38 @@ import mage.target.targetpointer.FixedTarget;
* actually follow all steps in playing the card is irrelevant. If the card is
* impossible to play due to a lack of legal targets or an unpayable mana cost,
* for example, it may still be removed from the game with suspend.
*
* <p>
* Removing a card from the game with its suspend ability is not playing that
* card. This action doesn't use the stack and can't be responded to.
*
* <p>
* If a spell with suspend has targets, the targets are chosen when the spell is
* played, not when it's removed from the game.
*
* <p>
* If the first triggered ability of suspend is countered, no time counter is
* removed. The ability will trigger again during its owner's next upkeep.
*
* <p>
* When the last time counter is removed from a suspended card, the second
* triggered ability of suspend will trigger. It doesn't matter why the time
* counter was removed or whose effect removed it. (The _Time Spiral_ reminder
* text is misleading on this point.)
*
* <p>
* If the second triggered ability of suspend is countered, the card can't be
* played. It remains in the removed-from-the-game zone without any time
* counters on it for the rest of the game, and it's no longer considered
* suspended.
*
* <p>
* If the second triggered ability of suspend resolves, the card's owner must
* play the spell if possible, even if that player doesn't want to. Normal
* timing considerations for the spell are ignored (for example, if the
* suspended card is a creature and this ability resolves during your upkeep,
* you're able to play the card), but other play restrictions are not ignored.
*
* <p>
* If the second triggered ability of suspend resolves and the suspended card
* can't be played due to a lack of legal targets or a play restriction, for
* example, it remains in the removed-from-the-game zone without any time
* counters on it for the rest of the game, and it's no longer considered
* suspended.
*
* <p>
* As the second triggered ability of suspend resolves, if playing the suspended
* card involves an additional cost, the card's owner must pay that cost if
* able. If they can't, the card remains removed from the game. If the
@ -99,14 +99,12 @@ import mage.target.targetpointer.FixedTarget;
* cost, then they have a choice: The player may play the spell, produce mana,
* and pay the cost. Or the player may choose to play no mana abilities, thus
* making the card impossible to play because the additional mana can't be paid.
*
* <p>
* A creature played via suspend comes into play with haste. It still has haste
* after the first turn it's in play as long as the same player controls it. As
* soon as another player takes control of it, it loses haste.
*
*
* @author LevelX2
*
*/
public class SuspendAbility extends SpecialAction {
@ -117,9 +115,9 @@ public class SuspendAbility extends SpecialAction {
* Gives the card the SuspendAbility
*
* @param suspend - amount of time counters, if Integer.MAX_VALUE is set
* there will be {X} costs and X counters added
* @param cost - null is used for temporary gained suspend ability
* @param card - card that has the suspend ability
* there will be {X} costs and X counters added
* @param cost - null is used for temporary gained suspend ability
* @param card - card that has the suspend ability
*/
public SuspendAbility(int suspend, ManaCost cost, Card card) {
this(suspend, cost, card, false);
@ -138,13 +136,13 @@ public class SuspendAbility extends SpecialAction {
}
StringBuilder sb = new StringBuilder("Suspend ");
if (cost != null) {
sb.append(suspend == Integer.MAX_VALUE ? "X" : suspend).append("&mdash;").append(cost.getText()).append(suspend
sb.append(suspend == Integer.MAX_VALUE ? "X" : suspend).append("&mdash;").append(cost.getText()).append(suspend
== Integer.MAX_VALUE ? ". X can't be 0" : "");
if (!shortRule) {
sb.append(" <i>(Rather than cast this card from your hand, pay ")
.append(cost.getText())
.append(" and exile it with ")
.append((suspend == 1 ? "a time counter" : (suspend == Integer.MAX_VALUE
.append((suspend == 1 ? "a time counter" : (suspend == Integer.MAX_VALUE
? "X time counters" : suspend + " time counters")))
.append(" on it.")
.append(" At the beginning of your upkeep, remove a time counter. "
@ -176,7 +174,7 @@ public class SuspendAbility extends SpecialAction {
ability.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, ability);
SuspendBeginningOfUpkeepInterveningIfTriggeredAbility ability1 =
SuspendBeginningOfUpkeepInterveningIfTriggeredAbility ability1 =
new SuspendBeginningOfUpkeepInterveningIfTriggeredAbility();
ability1.setSourceId(card.getId());
ability1.setControllerId(card.getOwnerId());
@ -214,8 +212,8 @@ public class SuspendAbility extends SpecialAction {
MageObject object = game.getObject(sourceId);
return new ActivationStatus(object.isInstant()
|| object.hasAbility(FlashAbility.getInstance(), game)
|| null != game.getContinuousEffects().asThough(sourceId,
AsThoughEffectType.CAST_AS_INSTANT, this, playerId, game)
|| null != game.getContinuousEffects().asThough(sourceId,
AsThoughEffectType.CAST_AS_INSTANT, this, playerId, game)
|| game.canPlaySorcery(playerId), null);
}
@ -241,7 +239,7 @@ class SuspendExileEffect extends OneShotEffect {
public SuspendExileEffect(int suspend) {
super(Outcome.PutCardInPlay);
this.staticText = new StringBuilder("Suspend ").append(suspend
this.staticText = new StringBuilder("Suspend ").append(suspend
== Integer.MAX_VALUE ? "X" : suspend).toString();
this.suspend = suspend;
}
@ -262,7 +260,7 @@ class SuspendExileEffect extends OneShotEffect {
Player controller = game.getPlayer(source.getControllerId());
if (card != null && controller != null) {
UUID exileId = SuspendAbility.getSuspendExileId(controller.getId(), game);
if (controller.moveCardToExileWithInfo(card, exileId, "Suspended cards of "
if (controller.moveCardToExileWithInfo(card, exileId, "Suspended cards of "
+ controller.getName(), source.getSourceId(), game, Zone.HAND, true)) {
if (suspend == Integer.MAX_VALUE) {
suspend = source.getManaCostsToPay().getX();
@ -298,11 +296,9 @@ class SuspendPlayCardAbility extends TriggeredAbilityImpl {
public boolean checkTrigger(GameEvent event, Game game) {
if (event.getTargetId().equals(getSourceId())) {
Card card = game.getCard(getSourceId());
if (card != null
return card != null
&& game.getState().getZone(card.getId()) == Zone.EXILED
&& card.getCounters(game).getCount(CounterType.TIME) == 0) {
return true;
}
&& card.getCounters(game).getCount(CounterType.TIME) == 0;
}
return false;
}
@ -340,8 +336,9 @@ class SuspendPlayCardEffect extends OneShotEffect {
Card card = game.getCard(source.getSourceId());
if (player != null && card != null) {
// 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()) {
for (Ability ability : card.getAbilities(game)) {
if (ability instanceof SuspendAbility) {
if (((SuspendAbility) ability).isGainedTemporary()) {
abilitiesToRemove.add(ability);
@ -350,7 +347,7 @@ class SuspendPlayCardEffect extends OneShotEffect {
}
if (!abilitiesToRemove.isEmpty()) {
for (Ability ability : card.getAbilities()) {
if (ability instanceof SuspendBeginningOfUpkeepInterveningIfTriggeredAbility
if (ability instanceof SuspendBeginningOfUpkeepInterveningIfTriggeredAbility
|| ability instanceof SuspendPlayCardAbility) {
abilitiesToRemove.add(ability);
}
@ -416,7 +413,7 @@ class GainHasteEffect extends ContinuousEffectImpl {
}
return true;
}
if (game.getState().getZoneChangeCounter(((FixedTarget) getTargetPointer()).getTarget())
if (game.getState().getZoneChangeCounter(((FixedTarget) getTargetPointer()).getTarget())
>= ((FixedTarget) getTargetPointer()).getZoneChangeCounter()) {
this.discard();
}
@ -428,8 +425,8 @@ class GainHasteEffect extends ContinuousEffectImpl {
class SuspendBeginningOfUpkeepInterveningIfTriggeredAbility extends ConditionalInterveningIfTriggeredAbility {
public SuspendBeginningOfUpkeepInterveningIfTriggeredAbility() {
super(new BeginningOfUpkeepTriggeredAbility(Zone.EXILED, new RemoveCounterSourceEffect(CounterType.TIME.createInstance()),
TargetController.YOU, false),
super(new BeginningOfUpkeepTriggeredAbility(Zone.EXILED, new RemoveCounterSourceEffect(CounterType.TIME.createInstance()),
TargetController.YOU, false),
SuspendedCondition.instance,
"At the beginning of your upkeep, if this card ({this}) is suspended, remove a time counter from it.");
this.setRuleVisible(false);

View file

@ -1,8 +1,5 @@
package mage.cards;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import mage.MageObject;
import mage.abilities.Abilities;
import mage.abilities.AbilitiesImpl;
@ -13,6 +10,10 @@ import mage.constants.SpellAbilityType;
import mage.constants.Zone;
import mage.game.Game;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* @author LevelX2
*/
@ -135,11 +136,13 @@ public abstract class SplitCard extends CardImpl {
public Abilities<Ability> getAbilities(Game game) {
Abilities<Ability> allAbilites = new AbilitiesImpl<>();
for (Ability ability : super.getAbilities(game)) {
// ignore split abilities TODO: why it here, for GUI's cleanup in card texts? Maybe it can be removed
if (ability instanceof SpellAbility
&& ((SpellAbility) ability).getSpellAbilityType() != SpellAbilityType.SPLIT
&& ((SpellAbility) ability).getSpellAbilityType() != SpellAbilityType.SPLIT_AFTERMATH) {
allAbilites.add(ability);
&& (((SpellAbility) ability).getSpellAbilityType() == SpellAbilityType.SPLIT
|| ((SpellAbility) ability).getSpellAbilityType() == SpellAbilityType.SPLIT_AFTERMATH)) {
continue;
}
allAbilites.add(ability);
}
allAbilites.addAll(leftHalfCard.getAbilities(game));
allAbilites.addAll(rightHalfCard.getAbilities(game));