diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/BattlefieldThaumaturgeTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/BattlefieldThaumaturgeTest.java new file mode 100644 index 0000000000..01b3c69440 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/BattlefieldThaumaturgeTest.java @@ -0,0 +1,300 @@ +package org.mage.test.cards.cost.modification; + +import mage.abilities.keyword.HexproofAbility; +import mage.constants.PhaseStep; +import mage.constants.Zone; +import mage.game.permanent.Permanent; +import org.junit.Assert; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * Battlefield Thaumaturge: + * Creature - Human Wizard + * Each instant and sorcery spell you cast costs {1} less to cast for each creature it targets. + * Heroic - Whenever you cast a spell that targets Battlefield Thaumaturge, Battlefield Thaumaturge gains hexproof until end of turn. + * + * @author Simown + */ +public class BattlefieldThaumaturgeTest extends CardTestPlayerBase { + + @Test + public void testSingleTargetReduction() { + + addCard(Zone.BATTLEFIELD, playerA, "Battlefield Thaumaturge"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain"); + addCard(Zone.BATTLEFIELD, playerA, "Island"); + addCard(Zone.HAND, playerA, "Lightning Strike"); + + addCard(Zone.BATTLEFIELD, playerB, "Akroan Skyguard"); + + // Lightning Strike - {1}{R} - Lightning Strike deals 3 damage to target creature or player. + // Because Battlefield Thaumaturge is on the battlefield, and the creature is targeted by the + // Lightning Strike it will be payable with {R}. + castSpell(2, PhaseStep.POSTCOMBAT_MAIN, playerA, "Lightning Strike", "Akroan Skyguard"); + setStopAt(2, PhaseStep.END_TURN); + execute(); + + // PlayerA still has the Battlefield Thaumaturge + assertPermanentCount(playerA, "Battlefield Thaumaturge", 1); + // The Akroan Skyguard has been killed by the Lightning Strike + assertPermanentCount(playerB, "Akroan Skyguard", 0); + assertGraveyardCount(playerB, "Akroan Skyguard", 1); + + // Check {R} has been used to pay, and the other land remains untapped + assertTappedCount("Mountain", true, 1); + assertTappedCount("Island", false, 1); + } + + @Test + public void testStriveTargetingReduction1() { + + addCard(Zone.BATTLEFIELD, playerA, "Battlefield Thaumaturge"); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 3); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 4); + addCard(Zone.BATTLEFIELD, playerA, "Pharika's Chosen"); + addCard(Zone.HAND, playerA, "Silence the Believers"); + + addCard(Zone.BATTLEFIELD, playerB, "Battlewise Hoplite"); + /** + * Silence the Believers - {2}{B}{B} + * Exile any number of target creatures and all Auras attached to them + * Strive - Silence the Believers costs {2}{B} more to cast for each target beyond the first. + * Targetting a creature on both sides of the battlefield. + */ + castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerA, "Silence the Believers", "Pharika's Chosen^Battlewise Hoplite"); + setStopAt(2, PhaseStep.END_TURN); + execute(); + + // Both creatures were exiled + assertExileCount("Pharika's Chosen", 1); + assertExileCount("Battlewise Hoplite", 1); + // Not still on the battlefield or in the graveyard + assertPermanentCount(playerA, "Pharika's Chosen", 0); + assertPermanentCount(playerB, "Battlewise Hoplite", 0); + assertGraveyardCount(playerA, "Pharika's Chosen", 0); + assertGraveyardCount(playerB, "Battlewise Hoplite", 0); + + /* Cost to exile 2 permanents will be: + * + {2}{B}{B} for the base spell + * + {2}{B} for an additional target + * - {2} for Battlefield Thaumaturge cost reducing effect + * = {2}{B}{B}{B} to pay. + */ + // Check 3 Swamps have been tapped to pay the reduced cost + assertTappedCount("Swamp", true, 3); + // And 2 Forests for the colorless mana + assertTappedCount("Forest", true, 2); + // And the rest of the Forests remain untapped + assertTappedCount("Forest", false, 2); + } + + @Test + public void testStriveTargetingReduction2() { + + String [] creatures = {"Battlefield Thaumaturge", "Agent of Horizons", "Blood-Toll Harpy", "Anvilwrought Raptor", "Fleshmad Steed" }; + for(String creature : creatures) { + addCard(Zone.BATTLEFIELD, playerA, creature); + } + addCard(Zone.BATTLEFIELD, playerA, "Plains", 1); + addCard(Zone.BATTLEFIELD, playerA, "Island", 5); + addCard(Zone.HAND, playerA, "Launch the Fleet"); + + // Launch the Fleet - {W} + // Strive - Launch the Fleet costs {1} more to cast for each target beyond the first. + // Until end of turn, any number of target creatures each gain "Whenever this creature attacks, put a 1/1 white Soldier token onto the battlefield tapped and attacking." + castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Launch the Fleet", createTargetingString(creatures)); + for(String creature : creatures) { + attack(3, playerA, creature); + } + setStopAt(3, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + // 5 initial creatures + 5 soldier tokens + 6 lands + assertPermanentCount(playerA, 16); + // The initial creatures exist + for(String creature : creatures) { + assertPermanentCount(playerA, creature, 1); + } + // Each has a solider token generated while attacking + assertPermanentCount(playerA, "Soldier", 5); + // Battlefield Thaumaturge will have hexproof from heroic trigger + Permanent battlefieldThaumaturge = getPermanent("Battlefield Thaumaturge", playerA.getId()); + Assert.assertTrue("Battlefield Thaumaturge must have hexproof", battlefieldThaumaturge.getAbilities().contains(HexproofAbility.getInstance())); + + assertLife(playerA, 20); + // 5 initial creatures + 5 soldier tokens => 16 damage + assertLife(playerB, 4); + /* Cost to have 5 attackers generate soldier tokens + * + {W} for the base spell + * + {4} for an additional targets + * - {4} for Battlefield Thaumaturge cost reducing effect (reduce {1} per target) + * = {W} to pay. + */ + assertTappedCount("Plains", true, 1); + // No other mana has been tapped to pay costs + assertTappedCount("Island", false, 5); + } + + @Test + public void testVariableCostReduction() { + addCard(Zone.BATTLEFIELD, playerA, "Battlefield Thaumaturge"); + addCard(Zone.BATTLEFIELD, playerA, "Island", 2); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + addCard(Zone.HAND, playerA, "Curse of the Swine"); + + String [] opponentsCreatures = {"Fleecemane Lion", "Sedge Scorpion", "Boon Satyr", "Bronze Sable"}; + for(String creature: opponentsCreatures) { + addCard(Zone.BATTLEFIELD, playerB, creature); + } + + /* Curse of the Swine - {X}{U}{U} + * Exile X target creatures. For each creature exiled this way, + * its controller puts a 2/2 green Boar creature token onto the battlefield + */ + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Curse of the Swine"); + setChoice(playerA, "X=4"); + addTarget(playerA, createTargetingString(opponentsCreatures)); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + // All the opponents creatures have been exiled from the battlefield + for(String creature: opponentsCreatures) { + assertPermanentCount(playerB, creature, 0); + assertGraveyardCount(playerB, creature, 0); + assertExileCount(creature, 1); + } + + // All 4 creatures have been replaced by boars + assertPermanentCount(playerB, "Boar", 4); + + /* Cost to target 4 permanents will be: + * + {4}{U}{U} for the base spell with X = 4 + * - {4} for Battlefield Thaumaturge cost reducing effect as 4 creatures are targetted + * = {U}{U} to pay. + */ + // Check 2 islands have been tapped to pay the reduced cost + assertTappedCount("Island", true, 2); + // And the rest of the lands remain untapped + assertTappedCount("Plains", false, 4); + } + + @Test + public void testMutipleTargetReduction() { + + String [] playerACreatures = {"Battlefield Thaumaturge", "Sedge Scorpion", "Boon Satyr"}; + String [] playerBCreatures = {"Agent of Horizons", "Blood-Toll Harpy", "Anvilwrought Raptor"}; + + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 4); + // Creatures for player A + for(String creature: playerACreatures) { + addCard(Zone.BATTLEFIELD, playerA, creature); + } + addCard(Zone.HAND, playerA, "Descent of the Dragons"); + // Creatures for player B + for(String creature: playerBCreatures) { + addCard(Zone.BATTLEFIELD, playerB, creature); + } + /* Descent of the Dragons - {4}{R}{R} + * Destroy any number of target creatures. + * For each creature destroyed this way, its controller puts a 4/4 red Dragon creature token with flying onto the battlefield. + * Battlefield Thaumaturge should reduce the cost of the spell when cast, before he is destroyed and replaced with a dragon. + */ + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Descent of the Dragons", createTargetingString(playerACreatures) + "^" + createTargetingString(playerBCreatures)); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // All creatures have been put in the graveyard + for(String creature: playerACreatures) { + assertPermanentCount(playerA, creature, 0); + assertGraveyardCount(playerA, creature, 1); + } + for(String creature: playerBCreatures) { + assertPermanentCount(playerB, creature, 0); + assertGraveyardCount(playerB, creature, 1); + } + + // And each player has 3 dragons + assertPermanentCount(playerA, "Dragon", 3); + assertPermanentCount(playerB, "Dragon", 3); + + /* Cost to target 6 creatures will be + * + {4}{R}{R} for the fixed cost base spell + * - {4} for Battlefield Thaumaturge cost reducing effect + * each creature targeted will reduce the cost by {1} so the cost + * can only be reduced by {4} maximum using Battlefield Thaumaturge + * even though 6 creatures are targeted. + * = {R}{R} to pay. + */ + // Check 2 mountains have been tapped to pay the reduced cost + assertTappedCount("Mountain", true, 2); + // And the rest of the lands remain untapped + assertTappedCount("Swamp", false, 4); + } + + @Test + public void testTargetNonCreature() { + + addCard(Zone.BATTLEFIELD, playerA, "Battlefield Thaumaturge"); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + addCard(Zone.HAND, playerA, "Fade into Antiquity"); + + addCard(Zone.BATTLEFIELD, playerB, "Heliod, God of the Sun"); + + // Fade into Antiquity - Sorcery - {2}{G} - Exile target artifact or enchantment. + // Battlefield Thaumaturge only reduces the cost instants and sorceries where the target is a creature + // No cost reduction for targeting an enchantment (devotion is too low for Heliod to be a creature) + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Fade into Antiquity", "Heliod, God of the Sun"); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // PlayerA still has the Battlefield Thaumaturge + assertPermanentCount(playerA, "Battlefield Thaumaturge", 1); + // Heliod has been exiled + assertPermanentCount(playerB, "Heliod, God of the Sun", 0); + assertGraveyardCount(playerB, "Heliod, God of the Sun", 0); + assertExileCount("Heliod, God of the Sun", 1); + + // Expect full amount paid, i.e. all lands are tapped. Cost was not reduced by Battlefield Thaumaturge, no creature was targeted. + assertTappedCount("Forest", true, 3); + } + + @Test + public void testTargetWithAura() { + + addCard(Zone.BATTLEFIELD, playerA, "Battlefield Thaumaturge"); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 5); + addCard(Zone.HAND, playerA, "Spectra Ward"); + + // Spectra Ward - {3}{W}{W} - Aura + // Enchanted creature gets +2/+2 and has protection from all colors. This effect doesn't remove auras. + // Battlefield Thaumaturge only reduces the cost instants and sorceries targeting creatures. + // No cost reduction for targeting a creature with an Aura. + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Spectra Ward", "Battlefield Thaumaturge"); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, "Battlefield Thaumaturge", 1); + assertPermanentCount(playerA, "Spectra Ward", 1); + // Battlefield Thaumaturge will have hexproof from heroic trigger + Permanent battlefieldThaumaturge = getPermanent("Battlefield Thaumaturge", playerA.getId()); + Assert.assertTrue("Battlefield Thaumaturge must have hexproof", battlefieldThaumaturge.getAbilities().contains(HexproofAbility.getInstance())); + + // No cost reduction from Battlefield Thaumaturge, full amount paid + assertTappedCount("Plains", true, 5); + } + + private String createTargetingString(String [] targets) { + StringBuilder targetBuilder = new StringBuilder(); + for (String target : targets) { + if (targetBuilder.length() > 0) { + targetBuilder.append('^'); + } + targetBuilder.append(target); + } + System.out.println(targetBuilder.toString()); + return targetBuilder.toString(); + } + +} diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java index 331f42e4fe..25b40da186 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java @@ -711,6 +711,28 @@ public abstract class CardTestPlayerAPIImpl extends MageTestPlayerBase implement Assert.assertEquals("(Battlefield) Tapped state is not equal (" + cardName + ")", tapped, found.isTapped()); } + /** + * Assert whether X permanents of the same name are tapped or not. + * + * @param cardName Name of the permanent that should be checked. + * @param tapped Whether the permanent is tapped or not + * @param count The amount of this permanents that should be tapped + */ + public void assertTappedCount(String cardName, boolean tapped, int count) throws AssertionError { + int tappedAmount = 0; + Permanent found = null; + for (Permanent permanent : currentGame.getBattlefield().getAllActivePermanents()) { + if (permanent.getName().equals(cardName)) { + if(permanent.isTapped() == tapped) { + tappedAmount++; + } + found = permanent; + } + } + Assert.assertNotNull("There is no such permanent on the battlefield, cardName=" + cardName, found); + Assert.assertEquals("(Battlefield) " + count + " permanents (" + cardName + ") are not " + ((tapped) ? "" : "un") + "tapped.", count, tappedAmount); + } + /** * Assert whether a permanent is attacking or not * diff --git a/Mage.Tests/src/test/java/org/mage/test/utils/ManaUtilTest.java b/Mage.Tests/src/test/java/org/mage/test/utils/ManaUtilTest.java index 07bc006edf..49ee45f089 100644 --- a/Mage.Tests/src/test/java/org/mage/test/utils/ManaUtilTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/utils/ManaUtilTest.java @@ -66,6 +66,22 @@ public class ManaUtilTest extends CardTestPlayerBase { testManaToPayVsLand("{W/R}{R/G}", "Sacred Foundry", 2, 2); // can't auto choose to pay } + @Test + public void testManaCondensing() { + Assert.assertEquals("{5}{W}", ManaUtil.condenseManaCostString(("{1}{1}{1}{2}{W}"))); + Assert.assertEquals("{4}{B}{B}", ManaUtil.condenseManaCostString("{2}{B}{2}{B}")); + Assert.assertEquals("{6}{R}{R}{R}{U}", ManaUtil.condenseManaCostString("{R}{1}{R}{2}{R}{3}{U}")); + Assert.assertEquals("{5}{B}{U}{W}", ManaUtil.condenseManaCostString("{1}{B}{W}{4}{U}")); + Assert.assertEquals("{8}{B}{G}{G}{U}", ManaUtil.condenseManaCostString("{1}{G}{1}{2}{3}{G}{B}{U}{1}")); + Assert.assertEquals("{3}{R}{U}", ManaUtil.condenseManaCostString("{3}{R}{U}")); + Assert.assertEquals("{10}", ManaUtil.condenseManaCostString("{1}{2}{3}{4}")); + Assert.assertEquals("{B}{G}{R}{U}{W}", ManaUtil.condenseManaCostString("{B}{G}{R}{U}{W}")); + Assert.assertEquals("{R}{R}", ManaUtil.condenseManaCostString("{R}{R}")); + Assert.assertEquals("{U}", ManaUtil.condenseManaCostString("{U}")); + Assert.assertEquals("{2}", ManaUtil.condenseManaCostString("{2}")); + Assert.assertEquals("", ManaUtil.condenseManaCostString("")); + } + /** * Common way to test ManaUtil.tryToAutoPay * @@ -133,4 +149,5 @@ public class ManaUtilTest extends CardTestPlayerBase { } return useableAbilities; } + } diff --git a/Mage/src/mage/abilities/abilityword/StriveAbility.java b/Mage/src/mage/abilities/abilityword/StriveAbility.java index 404933fde2..cc7c57bd04 100644 --- a/Mage/src/mage/abilities/abilityword/StriveAbility.java +++ b/Mage/src/mage/abilities/abilityword/StriveAbility.java @@ -39,6 +39,7 @@ import mage.constants.Outcome; import mage.constants.Zone; import mage.game.Game; import mage.target.Target; +import mage.util.ManaUtil; /** * @@ -50,7 +51,7 @@ public class StriveAbility extends SimpleStaticAbility { private final String striveCost; public StriveAbility(String manaString) { - super(Zone.STACK, new StriveCostIncreasementEffect(new ManaCostsImpl(manaString))); + super(Zone.STACK, new StriveCostIncreasingEffect(new ManaCostsImpl(manaString))); setRuleAtTheTop(true); this.striveCost = manaString; } @@ -71,29 +72,32 @@ public class StriveAbility extends SimpleStaticAbility { } } -class StriveCostIncreasementEffect extends CostModificationEffectImpl { +class StriveCostIncreasingEffect extends CostModificationEffectImpl { private ManaCostsImpl striveCosts = null; - public StriveCostIncreasementEffect(ManaCostsImpl striveCosts) { + public StriveCostIncreasingEffect(ManaCostsImpl striveCosts) { super(Duration.WhileOnStack, Outcome.Benefit, CostModificationType.INCREASE_COST); this.striveCosts = striveCosts; } - protected StriveCostIncreasementEffect(StriveCostIncreasementEffect effect) { + protected StriveCostIncreasingEffect(StriveCostIncreasingEffect effect) { super(effect); this.striveCosts = effect.striveCosts; } @Override public boolean apply(Game game, Ability source, Ability abilityToModify) { - // Target target = abilityToModify.getTargets().get(0); for (Target target : abilityToModify.getTargets()) { if (target.getMaxNumberOfTargets() == Integer.MAX_VALUE) { int additionalTargets = target.getTargets().size() - 1; + StringBuilder sb = new StringBuilder(); for (int i = 0; i < additionalTargets; i++) { - abilityToModify.getManaCostsToPay().add(striveCosts.copy()); + // Build up a string of strive costs for each target + sb.append(striveCosts.getText()); } + String finalCost = ManaUtil.condenseManaCostString(sb.toString()); + abilityToModify.getManaCostsToPay().add(new ManaCostsImpl(finalCost)); return true; } } @@ -106,7 +110,7 @@ class StriveCostIncreasementEffect extends CostModificationEffectImpl { } @Override - public StriveCostIncreasementEffect copy() { - return new StriveCostIncreasementEffect(this); + public StriveCostIncreasingEffect copy() { + return new StriveCostIncreasingEffect(this); } } diff --git a/Mage/src/mage/util/ManaUtil.java b/Mage/src/mage/util/ManaUtil.java index 6b8e006292..d094f807d3 100644 --- a/Mage/src/mage/util/ManaUtil.java +++ b/Mage/src/mage/util/ManaUtil.java @@ -1,9 +1,7 @@ package mage.util; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.Set; -import java.util.UUID; +import java.util.*; + import mage.MageObject; import mage.Mana; import mage.ManaSymbol; @@ -379,4 +377,46 @@ public class ManaUtil { return unpaid.getText(); } } + + /** Converts a collection of mana symbols into a single condensed string + * e.g. + * {1}{1}{1}{1}{1}{W} = {5}{W} + * {2}{B}{2}{B}{2}{B} = {6}{B}{B}{B} + * {1}{2}{R}{U}{1}{1} = {5}{R}{U} + * {B}{G}{R} = {B}{G}{R} + * */ + public static String condenseManaCostString(String rawCost) { + int total = 0; + int index = 0; + // Split the string in to an array of numbers and colored mana symbols + String[] splitCost = rawCost.replace("{", "").replace("}", " ").split(" "); + // Sort alphabetically which will push1 the numbers to the front before the colored mana symbols + Arrays.sort(splitCost); + for (String c : splitCost) { + // If the string is a representation of a number + if(c.matches("\\d+")) { + total += Integer.parseInt(c); + } else { + // First non-number we see we can finish as they are sorted + break; + } + index++; + } + int splitCostLength = splitCost.length; + // No need to add {total} to the mana cost if total == 0 + int shift = (total > 0) ? 1 : 0; + String [] finalCost = new String[shift + splitCostLength - index]; + // Account for no colourless mana symbols seen + if(total > 0) { + finalCost[0] = String.valueOf(total); + } + System.arraycopy(splitCost, index, finalCost, shift, splitCostLength - index); + // Combine the cost back as a mana string + StringBuilder sb = new StringBuilder(); + for(String s: finalCost) { + sb.append("{" + s + "}"); + } + // Return the condensed string + return sb.toString(); + } }