Implement Impending mechanic (#12865)

* implement Impending mechanic

* add initial test

* add more tests

* small fix
This commit is contained in:
Evan Kranzler 2024-09-13 20:44:38 -04:00 committed by GitHub
parent 50f892b935
commit d923f2941d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 348 additions and 20 deletions

View file

@ -36,7 +36,7 @@ public final class OverlordOfTheBalemurk extends CardImpl {
this.toughness = new MageInt(5);
// Impending 5--{1}{B}
this.addAbility(new ImpendingAbility("{1}{B}", 5));
this.addAbility(new ImpendingAbility(5, "{1}{B}"));
// Whenever Overlord of the Balemurk enters or attacks, mill four cards, then you may return a non-Avatar creature card or a planeswalker card from your graveyard to your hand.
Ability ability = new EntersBattlefieldOrAttacksSourceTriggeredAbility(new MillCardsControllerEffect(4));

View file

@ -27,7 +27,7 @@ public final class OverlordOfTheBoilerbilges extends CardImpl {
this.toughness = new MageInt(5);
// Impending 4--{2}{R}{R}
this.addAbility(new ImpendingAbility("{2}{R}{R}"));
this.addAbility(new ImpendingAbility(4, "{2}{R}{R}"));
// Whenever Overlord of the Boilerbilges enters or attacks, it deals 4 damage to any target.
Ability ability = new EntersBattlefieldOrAttacksSourceTriggeredAbility(new DamageTargetEffect(4));

View file

@ -26,7 +26,7 @@ public final class OverlordOfTheFloodpits extends CardImpl {
this.toughness = new MageInt(3);
// Impending 4--{1}{U}{U}
this.addAbility(new ImpendingAbility("{1}{U}{U}"));
this.addAbility(new ImpendingAbility(4, "{1}{U}{U}"));
// Flying
this.addAbility(FlyingAbility.getInstance());

View file

@ -26,7 +26,7 @@ public final class OverlordOfTheHauntwoods extends CardImpl {
this.toughness = new MageInt(5);
// Impending 4--{1}{G}{G}
this.addAbility(new ImpendingAbility("{1}{G}{G}"));
this.addAbility(new ImpendingAbility(4, "{1}{G}{G}"));
// Whenever Overlord of the Hauntwoods enters or attacks, create a tapped colorless land token named Everywhere that is every basic land type.
this.addAbility(new EntersBattlefieldOrAttacksSourceTriggeredAbility(

View file

@ -26,7 +26,7 @@ public final class OverlordOfTheMistmoors extends CardImpl {
this.toughness = new MageInt(6);
// Impending 4--{2}{W}{W}
this.addAbility(new ImpendingAbility("{2}{W}{W}"));
this.addAbility(new ImpendingAbility(4, "{2}{W}{W}"));
// Whenever Overlord of the Mistmoors enters or attacks, create two 2/1 white Insect creature tokens with flying.
this.addAbility(new EntersBattlefieldOrAttacksSourceTriggeredAbility(new CreateTokenEffect(new InsectWhiteToken(), 2)));

View file

@ -224,7 +224,5 @@ public final class DuskmournHouseOfHorror extends ExpansionSet {
cards.add(new SetCardInfo("Winter, Misanthropic Guide", 240, Rarity.RARE, mage.cards.w.WinterMisanthropicGuide.class));
cards.add(new SetCardInfo("Withering Torment", 124, Rarity.UNCOMMON, mage.cards.w.WitheringTorment.class));
cards.add(new SetCardInfo("Zimone, All-Questioning", 241, Rarity.RARE, mage.cards.z.ZimoneAllQuestioning.class));
cards.removeIf(setCardInfo -> setCardInfo.getName().startsWith("Overlord"));
}
}

View file

@ -0,0 +1,226 @@
package org.mage.test.cards.abilities.keywords;
import mage.abilities.keyword.ImpendingAbility;
import mage.constants.CardType;
import mage.constants.PhaseStep;
import mage.constants.SubType;
import mage.constants.Zone;
import mage.counters.CounterType;
import mage.game.permanent.Permanent;
import org.junit.Assert;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author TheElk801
*/
public class ImpendingTest extends CardTestPlayerBase {
private static final String hauntwoods = "Overlord of the Hauntwoods";
public void assertHasImpending(String name, boolean hasAbility) {
Permanent permanent = getPermanent(name);
Assert.assertEquals(
"Should" + (hasAbility ? "" : "n't") + " have Impending ability",
hasAbility, permanent.getAbilities(currentGame).containsClass(ImpendingAbility.class)
);
}
@Test
public void testCastRegular() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 5);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with no");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, true);
assertPermanentCount(playerA, "Everywhere", 1);
}
@Test
public void testCastImpending() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, false);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 4);
assertHasImpending(hauntwoods, true);
assertPermanentCount(playerA, "Everywhere", 1);
}
@Test
public void testImpendingRemoveCounter() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
setStrictChooseMode(true);
setStopAt(2, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, false);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 4 - 1);
assertHasImpending(hauntwoods, true);
assertPermanentCount(playerA, "Everywhere", 1);
}
@Test
public void testCastImpendingRemoveAllCounters() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
setStrictChooseMode(true);
setStopAt(8, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, false);
assertPermanentCount(playerA, "Everywhere", 1);
}
private static final String hexmage = "Vampire Hexmage";
@Test
public void testCastImpendingHexmage() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.BATTLEFIELD, playerA, hexmage);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
activateAbility(1, PhaseStep.BEGIN_COMBAT, playerA, "Sacrifice", hauntwoods);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, true);
assertPermanentCount(playerA, "Everywhere", 1);
}
@Test
public void testCastImpendingHexmageNextTurn() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.BATTLEFIELD, playerA, hexmage);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
activateAbility(1, PhaseStep.BEGIN_COMBAT, playerA, "Sacrifice", hauntwoods);
setStrictChooseMode(true);
setStopAt(2, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, false);
assertPermanentCount(playerA, "Everywhere", 1);
}
private static final String solemnity = "Solemnity";
@Test
public void testCastImpendingSolemnity() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.BATTLEFIELD, playerA, solemnity);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, true);
assertPermanentCount(playerA, "Everywhere", 1);
}
@Test
public void testCastImpendingSolemnityNextTurn() {
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
addCard(Zone.BATTLEFIELD, playerA, solemnity);
addCard(Zone.HAND, playerA, hauntwoods);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, hauntwoods);
setChoice(playerA, "Cast with Impending");
setStrictChooseMode(true);
setStopAt(2, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, hauntwoods, 1);
assertType(hauntwoods, CardType.ENCHANTMENT, true);
assertType(hauntwoods, CardType.CREATURE, true);
assertSubtype(hauntwoods, SubType.AVATAR);
assertSubtype(hauntwoods, SubType.HORROR);
assertCounterCount(playerA, hauntwoods, CounterType.TIME, 0);
assertPowerToughness(playerA, hauntwoods, 6, 5);
assertHasImpending(hauntwoods, false);
assertPermanentCount(playerA, "Everywhere", 1);
}
}

View file

@ -30,11 +30,15 @@ public abstract class AlternativeSourceCostsImpl extends StaticAbility implement
}
protected AlternativeSourceCostsImpl(String name, String reminderText, Cost cost) {
this(name, reminderText, cost, name);
}
protected AlternativeSourceCostsImpl(String name, String reminderText, Cost cost, String activationKey) {
super(Zone.ALL, null);
this.name = name;
this.reminderText = reminderText;
this.alternativeCost = new AlternativeCostImpl<>(name, reminderText, cost);
this.activationKey = getActivationKey(name);
this.activationKey = getActivationKey(activationKey);
}
protected AlternativeSourceCostsImpl(final AlternativeSourceCostsImpl ability) {

View file

@ -1,12 +1,33 @@
package mage.abilities.keyword;
import mage.abilities.Ability;
import mage.abilities.common.BeginningOfEndStepTriggeredAbility;
import mage.abilities.common.EntersBattlefieldAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.condition.Condition;
import mage.abilities.condition.common.SourceHasCounterCondition;
import mage.abilities.costs.AlternativeSourceCostsImpl;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.abilities.decorator.ConditionalOneShotEffect;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.common.AddContinuousEffectToGame;
import mage.abilities.effects.common.counter.AddCountersSourceEffect;
import mage.abilities.effects.common.counter.RemoveCounterSourceEffect;
import mage.constants.*;
import mage.counters.CounterType;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.util.CardUtil;
import java.util.stream.Collectors;
/**
* TODO: Implement this
* "Impending N[cost]" is a keyword that represents multiple abilities.
* The official rules are as follows:
* (a) You may choose to pay [cost] rather than pay this spell's mana cost.
* (b) If you chose to pay this spell's impending cost, it enters the battlefield with N time counters on it.
* (c) As long as this permanent has a time counter on it, if it was cast for its impending cost, it's not a creature.
* (d) At the beginning of your end step, if this permanent was cast for its impending cost, remove a time counter from it. Then if it has no time counters on it, it loses impending.
*
* @author TheElk801
*/
@ -16,14 +37,24 @@ public class ImpendingAbility extends AlternativeSourceCostsImpl {
private static final String IMPENDING_REMINDER = "If you cast this spell for its impending cost, " +
"it enters with %s time counters and isn't a creature until the last is removed. " +
"At the beginning of your end step, remove a time counter from it.";
private static final Condition counterCondition = new SourceHasCounterCondition(CounterType.TIME, 0, 0);
public ImpendingAbility(String manaString) {
this(manaString, 4);
}
public ImpendingAbility(String manaString, int amount) {
super(IMPENDING_KEYWORD + ' ' + amount, String.format(IMPENDING_REMINDER, CardUtil.numberToText(amount)), manaString);
public ImpendingAbility(int amount, String manaString) {
super(IMPENDING_KEYWORD + ' ' + amount, String.format(IMPENDING_REMINDER, CardUtil.numberToText(amount)), new ManaCostsImpl<>(manaString), IMPENDING_KEYWORD);
this.setRuleAtTheTop(true);
this.addSubAbility(new EntersBattlefieldAbility(new ConditionalOneShotEffect(
new AddCountersSourceEffect(CounterType.TIME.createInstance(amount)), ImpendingCondition.instance, ""
), "").setRuleVisible(false));
this.addSubAbility(new SimpleStaticAbility(new ImpendingAbilityTypeEffect()).setRuleVisible(false));
Ability ability = new BeginningOfEndStepTriggeredAbility(
new RemoveCounterSourceEffect(CounterType.TIME.createInstance()),
TargetController.YOU, ImpendingCondition.instance, false
);
ability.addEffect(new ConditionalOneShotEffect(
new AddContinuousEffectToGame(new ImpendingAbilityRemoveEffect()),
counterCondition, "Then if it has no time counters on it, it loses impending"
));
this.addSubAbility(ability.setRuleVisible(false));
}
private ImpendingAbility(final ImpendingAbility ability) {
@ -35,12 +66,81 @@ public class ImpendingAbility extends AlternativeSourceCostsImpl {
return new ImpendingAbility(this);
}
@Override
public boolean isAvailable(Ability source, Game game) {
return true;
}
public static String getActivationKey() {
return getActivationKey(IMPENDING_KEYWORD);
}
}
enum ImpendingCondition implements Condition {
instance;
@Override
public boolean apply(Game game, Ability source) {
return CardUtil.checkSourceCostsTagExists(game, source, ImpendingAbility.getActivationKey());
}
}
class ImpendingAbilityTypeEffect extends ContinuousEffectImpl {
ImpendingAbilityTypeEffect() {
super(Duration.WhileOnBattlefield, Layer.TypeChangingEffects_4, SubLayer.NA, Outcome.Detriment);
staticText = "As long as this permanent has a time counter on it, if it was cast for its impending cost, it's not a creature.";
}
private ImpendingAbilityTypeEffect(final ImpendingAbilityTypeEffect effect) {
super(effect);
}
@Override
public ImpendingAbilityTypeEffect copy() {
return new ImpendingAbilityTypeEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
if (!ImpendingCondition.instance.apply(game, source)) {
return false;
}
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
if (permanent.getCounters(game).getCount(CounterType.TIME) < 1) {
return false;
}
permanent.removeCardType(game, CardType.CREATURE);
permanent.removeAllCreatureTypes(game);
return true;
}
}
class ImpendingAbilityRemoveEffect extends ContinuousEffectImpl {
ImpendingAbilityRemoveEffect() {
super(Duration.Custom, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.LoseAbility);
}
private ImpendingAbilityRemoveEffect(final ImpendingAbilityRemoveEffect effect) {
super(effect);
}
@Override
public ImpendingAbilityRemoveEffect copy() {
return new ImpendingAbilityRemoveEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Permanent permanent = source.getSourcePermanentIfItStillExists(game);
if (permanent == null) {
discard();
return false;
}
permanent.removeAbilities(
permanent
.getAbilities(game)
.stream()
.filter(ImpendingAbility.class::isInstance)
.collect(Collectors.toList()),
source.getSourceId(), game
);
return true;
}
}