From d28b9e6d05780e07ea46e099b42edc901a7c12bc Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Sun, 12 May 2024 12:33:48 +0400 Subject: [PATCH] tests: added additional tests for Dryad Militant card and Madness abilities, added docs; --- Mage.Sets/src/mage/cards/c/ChandraAblaze.java | 7 + .../continuous/UnboundFlourishingTest.java | 2 +- .../cards/replacement/DryadMilitantTest.java | 271 ++++++++++++++++-- .../abilities/keyword/MadnessAbility.java | 13 +- .../common/CardsExiledThisTurnWatcher.java | 28 +- .../common/CardsPutIntoGraveyardWatcher.java | 5 +- 6 files changed, 283 insertions(+), 43 deletions(-) diff --git a/Mage.Sets/src/mage/cards/c/ChandraAblaze.java b/Mage.Sets/src/mage/cards/c/ChandraAblaze.java index 4826388a400..813308966ed 100644 --- a/Mage.Sets/src/mage/cards/c/ChandraAblaze.java +++ b/Mage.Sets/src/mage/cards/c/ChandraAblaze.java @@ -45,12 +45,14 @@ public final class ChandraAblaze extends CardImpl { ability.addEffect(new ChandraAblazeEffect2()); ability.addTarget(new TargetAnyTarget()); this.addAbility(ability); + // -2: Each player discards their hand, then draws three cards. ability = new LoyaltyAbility(new DiscardHandAllEffect(), -2); Effect effect = new DrawCardAllEffect(3); effect.setText(", then draws three cards"); ability.addEffect(effect); this.addAbility(ability); + // -7: Cast any number of red instant and/or sorcery cards from your graveyard without paying their mana costs. ability = new LoyaltyAbility(new ChandraAblazeEffect5(), -7); this.addAbility(ability); @@ -84,6 +86,11 @@ class ChandraAblazeEffect1 extends OneShotEffect { @Override public boolean apply(Game game, Ability source) { + // If you activate Chandra Ablaze’s first ability, you don’t discard a card until the ability resolves. + // You may activate the ability even if your hand is empty. You choose a target as you activate the ability + // even if you have no red cards in hand at that time. + // (2009-10-01) + Player player = game.getPlayer(source.getControllerId()); if (player != null) { TargetDiscard target = new TargetDiscard(player.getId()); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UnboundFlourishingTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UnboundFlourishingTest.java index 7fe96d2e752..be56cc73bd6 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UnboundFlourishingTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UnboundFlourishingTest.java @@ -59,7 +59,7 @@ public class UnboundFlourishingTest extends CardTestPlayerBase { } @Test - public void test_OnCastInstantOrSourcery_MustCopy() { + public void test_OnCastInstantOrSorcery_MustCopy() { addCard(Zone.BATTLEFIELD, playerA, "Unbound Flourishing", 1); // // Banefire deals X damage to any target. diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/replacement/DryadMilitantTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/replacement/DryadMilitantTest.java index ea904d27993..0057343ce95 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/replacement/DryadMilitantTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/replacement/DryadMilitantTest.java @@ -1,78 +1,287 @@ - package org.mage.test.cards.replacement; +import mage.cards.Card; import mage.constants.PhaseStep; import mage.constants.Zone; +import mage.watchers.common.CardsExiledThisTurnWatcher; +import mage.watchers.common.CardsPutIntoGraveyardWatcher; +import org.junit.Assert; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBase; +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + /** - * Dryad Militant - * Creature - Dryad Soldier 1/1 {G/W} - * If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. - * - * @author LevelX2 + * Dryad Militant oracle rules: + *

+ * If an instant or sorcery spell destroys Dryad Militant directly (like Murder does), that instant or sorcery + * card will be put into its owner’s graveyard. However, if an instant or sorcery card deals lethal damage to + * Dryad Militant, Dryad Militant will remain on the battlefield until the next time state-based actions are + * checked, which is after the instant or sorcery finishes resolving. + * The instant or sorcery will be exiled. + * (2012-10-01) - supported, has tests + *

+ * If an instant or sorcery card is discarded while Dryad Militant is on the battlefield, abilities that + * function when a card is discarded (such as madness) still work, even though that card never reaches a + * graveyard. In addition, spells or abilities that check the characteristics of a discarded card (such as + * Chandra Ablaze’s first ability) can find that card in exile. + * (2012-10-01) - supported, has tests + * + * @author LevelX2, JayDi85 */ public class DryadMilitantTest extends CardTestPlayerBase { - + /** * Tests that an instant or sorcery card is moved to exile */ @Test - public void testNormalCase() { - addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1); - addCard(Zone.HAND, playerA, "Lightning Bolt"); + public void test_OnResolve_MustWork() { + // If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); - + // + addCard(Zone.HAND, playerA, "Lightning Bolt"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", playerB); + + setStrictChooseMode(true); setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); - + assertLife(playerB, 17); assertExileCount("Lightning Bolt", 1); - } + } + /** * Tests if Dryad Militant dies by damage spell, the - * spell gets exiled + * spell gets exiled (see oracle rules) */ @Test - public void testDiesByDamage() { - addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1); - addCard(Zone.HAND, playerA, "Lightning Bolt"); + public void test_DiesByDamage_MustWork() { + // If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); - + // + addCard(Zone.HAND, playerA, "Lightning Bolt"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", "Dryad Militant"); + + setStrictChooseMode(true); setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); - + assertLife(playerB, 20); - assertExileCount("Lightning Bolt", 1); - } - + } + /** * Tests if Dryad Militant dies by destroy spell, the - * spell don't get exiled + * spell don't get exiled (see oracle rules) */ @Test - public void testDiesByDestroy() { + public void test_DiesByDestroy_MustNotWork() { + // If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. + addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); + // + // Destroy target creature. It can't be regenerated. + addCard(Zone.HAND, playerA, "Terminate"); // {B}{R} addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1); addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); - addCard(Zone.HAND, playerA, "Terminate"); - addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); - castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Terminate", "Dryad Militant"); + + setStrictChooseMode(true); setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); - - assertLife(playerB, 20); + assertLife(playerB, 20); assertHandCount(playerA, "Terminate", 0); assertGraveyardCount(playerA, "Terminate", 1); - } -} + } + private void prepareGraveyardAndExileWatchers() { + currentGame.getState().addWatcher(new CardsPutIntoGraveyardWatcher()); + currentGame.getState().addWatcher(new CardsExiledThisTurnWatcher()); + } + private void assertCardsList(String info, Collection currentList, Collection needList) { + String current = currentList.stream().map(Card::getName).sorted().collect(Collectors.joining("; ")); + String need = needList.stream().sorted().collect(Collectors.joining("; ")); + Assert.assertEquals(info, need, current); + } + + private void assertReachesGraveyard(Collection needList) { + CardsPutIntoGraveyardWatcher graveyardWatcher = currentGame.getState().getWatcher(CardsPutIntoGraveyardWatcher.class); + assertCardsList( + "graveyard must reaches " + needList.size() + " cards this turn", + graveyardWatcher.getCardsPutIntoGraveyardFromAnywhere(currentGame), + needList + ); + } + + private void assertReachesExile(Collection needList) { + CardsExiledThisTurnWatcher exileWatcher = currentGame.getState().getWatcher(CardsExiledThisTurnWatcher.class); + assertCardsList( + "exile must reaches " + needList.size() + " cards this turn", + exileWatcher.getCardsExiledThisTurn(currentGame), + needList + ); + } + + @Test + public void test_MadnessCreature_OnDiscard() { + prepareGraveyardAndExileWatchers(); + + // Madness {B}{B} (If you discard this card, discard it into exile. When you do, cast it for its madness cost + // or put it into your graveyard.) + addCard(Zone.HAND, playerA, "Gorgon Recluse"); // {3}{B}{B} + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 2); + // + // Target player discards a card. + addCard(Zone.HAND, playerA, "Raven's Crime"); // {B} + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); + + // discard + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Raven's Crime", playerA); + setChoice(playerA, true); // use madness + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // raven on cast go to grave + // gorgon on madness go to exile and cast instead grave + assertReachesGraveyard(Arrays.asList("Raven's Crime")); + assertReachesExile(Arrays.asList("Gorgon Recluse")); // was cast from exile + } + + @Test + public void test_MadnessSorcery_OnDiscard() { + prepareGraveyardAndExileWatchers(); + + // Alchemist’s Greeting deals 4 damage to target creature. + // Madness {1}{R} (If you discard this card, discard it into exile. When you do, cast it for its madness + // cost or put it into your graveyard.) + addCard(Zone.HAND, playerA, "Alchemist's Greeting"); // {4}{R} + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.BATTLEFIELD, playerA, "Grizzly Bears", 1); + // + // Target player discards a card. + addCard(Zone.HAND, playerA, "Raven's Crime"); // {B} + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); + + // discard + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 1); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Raven's Crime", playerA); + setChoice(playerA, true); // use madness + addTarget(playerA, "Grizzly Bears"); // target of madness card + + showGraveyard("after grave", 1, PhaseStep.END_TURN, playerA); + showExile("after exile", 1, PhaseStep.END_TURN, playerA); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertGraveyardCount(playerA, "Grizzly Bears", 1); + assertGraveyardCount(playerA, "Alchemist's Greeting", 1); // go to grave after madness cast resolve + + // raven on cast go to grave + // alchemist on discard with madness go to exile and grave + // bears on damage go to grave + assertReachesGraveyard(Arrays.asList("Raven's Crime", "Alchemist's Greeting", "Grizzly Bears")); + assertReachesExile(Arrays.asList("Alchemist's Greeting")); + } + + /** + * Make sure no exile zone reaches due replacement effects (see oracle rules) + */ + @Test + public void test_MadnessSorcery_OnDiscardWithDryadMilitant_BySpell() { + prepareGraveyardAndExileWatchers(); + + // checking rule: + // If an instant or sorcery card is discarded while Dryad Militant is on the battlefield, abilities that + // function when a card is discarded (such as madness) still work, even though that card never reaches a + // graveyard. + + // If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. + addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); + // + // Alchemist’s Greeting deals 4 damage to target creature. + // Madness {1}{R} (If you discard this card, discard it into exile. When you do, cast it for its madness + // cost or put it into your graveyard.) + addCard(Zone.HAND, playerA, "Alchemist's Greeting"); // {4}{R} + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.BATTLEFIELD, playerA, "Grizzly Bears", 1); + // + // Target player discards a card. + addCard(Zone.HAND, playerA, "Raven's Crime"); // {B} + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); + + // discard + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 1); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Raven's Crime", playerA); + setChoice(playerA, "Alchemist's Greeting"); // choose replacement effects (madness first) + setChoice(playerA, true); // use madness + addTarget(playerA, "Grizzly Bears"); // target of madness card + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertGraveyardCount(playerA, "Grizzly Bears", 1); + assertExileCount(playerA, "Alchemist's Greeting", 1); // discard -> exile and cast madness -> exile instead grave + + // raven on cast go to exile instead grave due dryad effect + // alchemist on discard with madness go to exile, after cast go to exile again instead grave due dryad effect + // bears on damage go to grave (ignored by dryad effect due it's not a instant/sorcery) + assertReachesGraveyard(Arrays.asList("Grizzly Bears")); + assertReachesExile(Arrays.asList("Raven's Crime", "Alchemist's Greeting", "Alchemist's Greeting")); + } + + @Test + public void test_MadnessSorcery_OnDiscardWithDryadMilitant_ByChandraAblaze() { + prepareGraveyardAndExileWatchers(); + + // checking rule: + // In addition, spells or abilities that check the characteristics of a discarded card (such as + // Chandra Ablaze’s first ability) can find that card in exile. + + // If an instant or sorcery card would be put into a graveyard from anywhere, exile it instead. + addCard(Zone.BATTLEFIELD, playerB, "Dryad Militant"); + // + // Alchemist’s Greeting deals 4 damage to target creature. + // Madness {1}{R} (If you discard this card, discard it into exile. When you do, cast it for its madness + // cost or put it into your graveyard.) + addCard(Zone.HAND, playerA, "Alchemist's Greeting"); // {4}{R} + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.BATTLEFIELD, playerA, "Grizzly Bears", 1); + // + // +1: Discard a card. If a red card is discarded this way, Chandra Ablaze deals 4 damage to any target. + addCard(Zone.BATTLEFIELD, playerA, "Chandra Ablaze"); + + // discard + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+1"); + addTarget(playerA, playerB); // x4 damage by chandra + setChoice(playerA, "Alchemist's Greeting"); // discard by chandra + setChoice(playerA, "Alchemist's Greeting"); // choose replacement effects (madness first) + setChoice(playerA, true); // use madness + addTarget(playerA, "Grizzly Bears"); // target of madness card + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertGraveyardCount(playerA, "Grizzly Bears", 1); + assertExileCount(playerA, "Alchemist's Greeting", 1); // discard -> exile and cast madness -> exile instead grave + assertLife(playerB, 20 - 4); // by chandra + + // alchemist on discard with madness go to exile, after cast go to exile again instead grave due dryad effect + // bears on damage go to grave (ignored by dryad effect due it's not a instant/sorcery) + assertReachesGraveyard(Arrays.asList("Grizzly Bears")); + assertReachesExile(Arrays.asList("Alchemist's Greeting", "Alchemist's Greeting")); + } +} \ No newline at end of file diff --git a/Mage/src/main/java/mage/abilities/keyword/MadnessAbility.java b/Mage/src/main/java/mage/abilities/keyword/MadnessAbility.java index 347cf080648..646275ace2f 100644 --- a/Mage/src/main/java/mage/abilities/keyword/MadnessAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/MadnessAbility.java @@ -131,13 +131,16 @@ class MadnessReplacementEffect extends ReplacementEffectImpl { return false; } - // TODO, deal with deprecated call - if (controller.moveCards(card, Zone.EXILED, source, game)) { - game.applyEffects(); // needed to add Madness ability to cards (e.g. by Falkenrath Gorger) - GameEvent gameEvent = new MadnessCardExiledEvent(card.getId(), source, controller.getId()); - game.fireEvent(gameEvent); + if (!controller.moveCards(card, Zone.EXILED, source, game)) { + return false; } + // needed to add Madness ability to cards (e.g. by Falkenrath Gorger) + game.getState().processAction(game); + + GameEvent gameEvent = new MadnessCardExiledEvent(card.getId(), source, controller.getId()); + game.fireEvent(gameEvent); + return true; } diff --git a/Mage/src/main/java/mage/watchers/common/CardsExiledThisTurnWatcher.java b/Mage/src/main/java/mage/watchers/common/CardsExiledThisTurnWatcher.java index 783eb3bf0c0..42a02626ae8 100644 --- a/Mage/src/main/java/mage/watchers/common/CardsExiledThisTurnWatcher.java +++ b/Mage/src/main/java/mage/watchers/common/CardsExiledThisTurnWatcher.java @@ -1,5 +1,6 @@ package mage.watchers.common; +import mage.cards.Card; import mage.constants.WatcherScope; import mage.constants.Zone; import mage.game.Game; @@ -7,12 +8,22 @@ import mage.game.events.GameEvent; import mage.game.events.ZoneChangeEvent; import mage.watchers.Watcher; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + /** - * @author Susucr + * Counts cards that was moved to exile zone by any way + *

+ * Can contain multiple instances of the same card (if it was moved multiple times per turn) + * + * @author Susucr, JayDi85 */ public class CardsExiledThisTurnWatcher extends Watcher { - private int countExiled = 0; + private final List exiledCards = new ArrayList<>(); public CardsExiledThisTurnWatcher() { super(WatcherScope.GAME); @@ -22,17 +33,24 @@ public class CardsExiledThisTurnWatcher extends Watcher { public void watch(GameEvent event, Game game) { if (event.getType() == GameEvent.EventType.ZONE_CHANGE && ((ZoneChangeEvent) event).getToZone() == Zone.EXILED) { - countExiled++; + this.exiledCards.add(event.getTargetId()); } } public int getCountCardsExiledThisTurn() { - return countExiled; + return this.exiledCards.size(); + } + + public List getCardsExiledThisTurn(Game game) { + return this.exiledCards.stream() + .map(game::getCard) + .filter(Objects::nonNull) + .collect(Collectors.toList()); } @Override public void reset() { super.reset(); - countExiled = 0; + this.exiledCards.clear(); } } diff --git a/Mage/src/main/java/mage/watchers/common/CardsPutIntoGraveyardWatcher.java b/Mage/src/main/java/mage/watchers/common/CardsPutIntoGraveyardWatcher.java index a282318f028..08fa4ec9a3b 100644 --- a/Mage/src/main/java/mage/watchers/common/CardsPutIntoGraveyardWatcher.java +++ b/Mage/src/main/java/mage/watchers/common/CardsPutIntoGraveyardWatcher.java @@ -16,6 +16,8 @@ import java.util.stream.Collectors; * Counts how many cards are put into each player's graveyard this turn. * Keeps track of the UUIDs of the cards that went to graveyard this turn. * from the battlefield, from anywhere other both from anywhere and from only the battlefield. + *

+ * Can contain multiple instances of the same card (if it was moved multiple times per turn) * * @author LevelX2 */ @@ -40,7 +42,8 @@ public class CardsPutIntoGraveyardWatcher extends Watcher { } UUID playerId = event.getPlayerId(); - if (playerId == null || game.getCard(event.getTargetId()) == null) { + Card card = game.getCard(event.getTargetId()); + if (playerId == null || card == null) { return; }