From 29d3f963401ddd54ac0df3db80d2b04e19f1297a Mon Sep 17 00:00:00 2001 From: Daniel Bomar Date: Wed, 14 Jul 2021 15:12:25 -0500 Subject: [PATCH] [MH2] Implemented Carth the Lion (#7848) * [MH2] Implemented Carth the Lion * [MH2] Carth the Lion - Fixed loyalty cost modification * Fix copy constructor and add getters/setters * Call sourceObject.adjustCosts before checking cost modifications * Add unit test * Added additional comments, checks and tests; Co-authored-by: Oleg Agafonov --- Mage.Sets/src/mage/cards/c/CarthTheLion.java | 130 ++++++++++++++++++ Mage.Sets/src/mage/sets/ModernHorizons2.java | 1 + .../keywords/SpliceOnArcaneTest.java | 2 +- .../modification/CostModificationTest.java | 100 ++++++++++++++ .../java/mage/abilities/LoyaltyAbility.java | 48 ++++++- .../costs/common/PayLoyaltyCost.java | 45 ++++-- .../costs/common/PayVariableLoyaltyCost.java | 51 +++++-- 7 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 Mage.Sets/src/mage/cards/c/CarthTheLion.java diff --git a/Mage.Sets/src/mage/cards/c/CarthTheLion.java b/Mage.Sets/src/mage/cards/c/CarthTheLion.java new file mode 100644 index 0000000000..a5b751eeef --- /dev/null +++ b/Mage.Sets/src/mage/cards/c/CarthTheLion.java @@ -0,0 +1,130 @@ +package mage.cards.c; + +import java.util.UUID; +import mage.MageInt; +import mage.abilities.Ability; +import mage.abilities.LoyaltyAbility; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.common.LookLibraryAndPickControllerEffect; +import mage.abilities.effects.common.cost.CostModificationEffectImpl; +import mage.constants.*; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.filter.common.FilterPlaneswalkerCard; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.events.ZoneChangeEvent; +import mage.game.permanent.Permanent; + +/** + * + * @author weirddan455 + */ +public final class CarthTheLion extends CardImpl { + + public CarthTheLion(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{B}{G}"); + + this.addSuperType(SuperType.LEGENDARY); + this.subtype.add(SubType.HUMAN); + this.subtype.add(SubType.WARRIOR); + this.power = new MageInt(3); + this.toughness = new MageInt(5); + + // Whenever Carth the Lion enters the battlefield, or a planeswalker you control dies, look at the top seven cards of your library. + // You may reveal a planeswalker card from among them and put it into your hand. Put the rest on the bottom of your library in a random order. + this.addAbility(new CarthTheLionTriggeredAbility()); + + // Planeswalkers' loyalty abilities you activate cost an additonal {+1} to activate. + this.addAbility(new SimpleStaticAbility(new CarthTheLionLoyaltyCostEffect())); + } + + private CarthTheLion(final CarthTheLion card) { + super(card); + } + + @Override + public CarthTheLion copy() { + return new CarthTheLion(this); + } +} + +class CarthTheLionTriggeredAbility extends TriggeredAbilityImpl { + + private static final FilterPlaneswalkerCard filter = new FilterPlaneswalkerCard("a planeswalker card"); + + public CarthTheLionTriggeredAbility() { + super(Zone.BATTLEFIELD, new LookLibraryAndPickControllerEffect( + 7, 1, filter, true, false, Zone.HAND, true) + .setBackInRandomOrder(true) + ); + } + + private CarthTheLionTriggeredAbility(final CarthTheLionTriggeredAbility ability) { + super(ability); + } + + @Override + public CarthTheLionTriggeredAbility copy() { + return new CarthTheLionTriggeredAbility(this); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD + || event.getType() == GameEvent.EventType.ZONE_CHANGE; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD + && event.getTargetId().equals(getSourceId())) { + return true; + } + if (event.getType() == GameEvent.EventType.ZONE_CHANGE && event.getPlayerId().equals(getControllerId())) { + ZoneChangeEvent zEvent = (ZoneChangeEvent) event; + if (zEvent.isDiesEvent()) { + Permanent permanent = game.getPermanentOrLKIBattlefield(zEvent.getTargetId()); + return permanent != null && permanent.isPlaneswalker(); + } + } + return false; + } + + @Override + public String getRule() { + return "Whenever {this} enters the battlefield, or a planeswalker you control dies, " + super.getRule(); + } +} + +class CarthTheLionLoyaltyCostEffect extends CostModificationEffectImpl { + + public CarthTheLionLoyaltyCostEffect() { + super(Duration.WhileOnBattlefield, Outcome.Benefit, CostModificationType.INCREASE_COST); + this.staticText = "Planeswalkers' loyalty abilities you activate cost an additonal +1 to activate"; + } + + private CarthTheLionLoyaltyCostEffect(final CarthTheLionLoyaltyCostEffect effect) { + super(effect); + } + + @Override + public CarthTheLionLoyaltyCostEffect copy() { + return new CarthTheLionLoyaltyCostEffect(this); + } + + @Override + public boolean apply(Game game, Ability source, Ability abilityToModify) { + if (abilityToModify instanceof LoyaltyAbility) { + ((LoyaltyAbility) abilityToModify).increaseLoyaltyCost(1); + return true; + } + return false; + } + + @Override + public boolean applies(Ability abilityToModify, Ability source, Game game) { + return abilityToModify instanceof LoyaltyAbility && abilityToModify.getControllerId().equals(source.getControllerId()); + } +} diff --git a/Mage.Sets/src/mage/sets/ModernHorizons2.java b/Mage.Sets/src/mage/sets/ModernHorizons2.java index 14692386c3..08d60408ef 100644 --- a/Mage.Sets/src/mage/sets/ModernHorizons2.java +++ b/Mage.Sets/src/mage/sets/ModernHorizons2.java @@ -72,6 +72,7 @@ public final class ModernHorizons2 extends ExpansionSet { cards.add(new SetCardInfo("Burdened Aerialist", 38, Rarity.COMMON, mage.cards.b.BurdenedAerialist.class)); cards.add(new SetCardInfo("Cabal Coffers", 301, Rarity.MYTHIC, mage.cards.c.CabalCoffers.class)); cards.add(new SetCardInfo("Cabal Initiate", 78, Rarity.COMMON, mage.cards.c.CabalInitiate.class)); + cards.add(new SetCardInfo("Carth the Lion", 189, Rarity.RARE, mage.cards.c.CarthTheLion.class)); cards.add(new SetCardInfo("Calibrated Blast", 118, Rarity.RARE, mage.cards.c.CalibratedBlast.class)); cards.add(new SetCardInfo("Caprichrome", 9, Rarity.UNCOMMON, mage.cards.c.Caprichrome.class)); cards.add(new SetCardInfo("Captain Ripley Vance", 119, Rarity.UNCOMMON, mage.cards.c.CaptainRipleyVance.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SpliceOnArcaneTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SpliceOnArcaneTest.java index 2e76856b86..1da2b00728 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SpliceOnArcaneTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SpliceOnArcaneTest.java @@ -154,7 +154,7 @@ public class SpliceOnArcaneTest extends CardTestPlayerBase { @Test @Ignore public void testCounteredBecauseOfNoLegalTarget() { - // TODO: rewrite test, it's wrong and misleading-- user report about Griselbrand was destroyed by Terminate after splice anounce, but tests don't use it at all (Griselbrand legal target all the time) + // TODO: rewrite test, it's wrong and misleading-- user report about Griselbrand was destroyed by Terminate after splice announce, but tests don't use it at all (Griselbrand legal target all the time) addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); addCard(Zone.BATTLEFIELD, playerA, "Swamp", 8); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/CostModificationTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/CostModificationTest.java index a7b09ac9ed..be75c330b0 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/CostModificationTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/cost/modification/CostModificationTest.java @@ -1,8 +1,12 @@ package org.mage.test.cards.cost.modification; +import mage.abilities.LoyaltyAbility; +import mage.abilities.costs.common.PayVariableLoyaltyCost; import mage.constants.PhaseStep; 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; @@ -382,4 +386,100 @@ public class CostModificationTest extends CardTestPlayerBase { execute(); assertAllCommandsUsed(); } + + @Test + public void test_PlaneswalkerLoyalty_CostModification_Single() { + // Carth the Lion + // Planeswalkers' loyalty abilities you activate cost an additional {+1} to activate. + addCard(Zone.BATTLEFIELD, playerA, "Carth the Lion", 1); + // + // Vivien Reid + // 5 Loyalty + // +1, -3, -8 Abilities + addCard(Zone.BATTLEFIELD, playerA, "Vivien Reid", 1); + // + // Huatli, Warrior Poet + // 3 Loyalty + // Testing X Ability + // −X: Huatli, Warrior Poet deals X damage divided as you choose among any number of target creatures. Creatures dealt damage this way can’t block this turn. + addCard(Zone.BATTLEFIELD, playerA, "Huatli, Warrior Poet", 1); + // + // 2 toughness creatures for Huatli to kill + addCard(Zone.BATTLEFIELD, playerB, "Grizzly Bears", 1); + addCard(Zone.BATTLEFIELD, playerB, "Ghitu Lavarunner", 1); + + // Vivien: make cost +2 instead +1 (total 7 counters) + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "+1: Look at the top four"); + setChoice(playerA, "No"); + checkPermanentCounters("Vivien Reid counter check", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Vivien Reid", CounterType.LOYALTY, 7); + + // loyalty cost modification doesn't affect card rule's text, so it still shown an old cost value for a user + + // Vivien: make cost -7 instead -8 + activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "-8: You get an emblem"); + + // Huatli: check x cost changes + runCode("check x cost", 3, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> { + Permanent huatli = game.getBattlefield().getAllActivePermanents().stream().filter(p -> p.getName().equals("Huatli, Warrior Poet")).findFirst().orElse(null); + Assert.assertNotNull("must have huatli on battlefield", huatli); + LoyaltyAbility ability = (LoyaltyAbility) huatli.getAbilities(game).stream().filter(a -> a.getRule().startsWith("-X: ")).findFirst().orElse(null); + Assert.assertNotNull("must have loyalty ability", ability); + // counters: 3 + // cost modification: +1 + // max possible X to pay: 3 + 1 = 4 + PayVariableLoyaltyCost cost = (PayVariableLoyaltyCost) ability.getCosts().get(0); + Assert.assertEquals("must have max possible X as 4", 4, cost.getMaxValue(ability, game)); + }); + + // Huatli: make x cost -3 instead -4 + activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "-X: {this} deals X damage divided as you choose"); + setChoice(playerA, "X=4"); + addTargetAmount(playerA, "Grizzly Bears", 2); + addTargetAmount(playerA, "Ghitu Lavarunner", 2); + + setStrictChooseMode(true); + setStopAt(3, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertGraveyardCount(playerA, "Vivien Reid", 1); + assertGraveyardCount(playerA, "Huatli, Warrior Poet", 1); + assertGraveyardCount(playerB, "Grizzly Bears", 1); + assertGraveyardCount(playerB, "Ghitu Lavarunner", 1); + } + + @Test + public void test_PlaneswalkerLoyalty_CostModification_Multiple() { + // Carth the Lion + // Planeswalkers' loyalty abilities you activate cost an additional {+1} to activate. + addCard(Zone.BATTLEFIELD, playerA, "Carth the Lion", 1); + // + // Vivien Reid + // 5 Loyalty + // +1, -3, -8 Abilities + addCard(Zone.BATTLEFIELD, playerA, "Vivien Reid", 1); + // + // You may have Spark Double enter the battlefield as a copy of a creature or planeswalker you control... + addCard(Zone.HAND, playerA, "Spark Double", 2); // {3}{U} + addCard(Zone.BATTLEFIELD, playerA, "Island", 2 * 4); + + checkPlayableAbility("before", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "-8:", false); + + // prepare duplicates + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spark Double"); + setChoice(playerA, "Yes"); // copy + setChoice(playerA, "Carth the Lion"); // copy target + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spark Double"); + setChoice(playerA, "Yes"); // copy + setChoice(playerA, "Carth the Lion"); // copy target + + // x3 lions gives +3 in cost reduction (-8 -> -5) + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA); + checkPlayableAbility("after", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "-8:", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + } } diff --git a/Mage/src/main/java/mage/abilities/LoyaltyAbility.java b/Mage/src/main/java/mage/abilities/LoyaltyAbility.java index 85370117b3..8d8b3b221d 100644 --- a/Mage/src/main/java/mage/abilities/LoyaltyAbility.java +++ b/Mage/src/main/java/mage/abilities/LoyaltyAbility.java @@ -1,6 +1,6 @@ - package mage.abilities; +import mage.abilities.costs.Cost; import mage.abilities.costs.common.PayLoyaltyCost; import mage.abilities.costs.common.PayVariableLoyaltyCost; import mage.abilities.effects.Effect; @@ -9,7 +9,6 @@ import mage.constants.TimingRule; import mage.constants.Zone; /** - * * @author BetaSteward_at_googlemail.com */ public class LoyaltyAbility extends ActivatedAbilityImpl { @@ -43,4 +42,49 @@ public class LoyaltyAbility extends ActivatedAbilityImpl { return new LoyaltyAbility(this); } + /** + * Change loyalty cost by amount value + * + * @param amount + */ + public void increaseLoyaltyCost(int amount) { + + // loyalty cost modification rules from Carth the Lion + + // If a planeswalker’s loyalty ability normally has a cost of [+1], Carth’s ability makes it cost [+2] instead. + // A cost of [0] would become [+1], and a cost of [-6] would become [-5]. + // (2021-06-18) + // + // If you somehow manage to control two Carths (perhaps because of Spark Double), the cost-changing effect is + // cumulative. In total, loyalty abilities will cost an additional [+2] to activate. + // (2021-06-18) + // + // The total cost of a planeswalker’s loyalty ability is calculated before any counters are added or removed. + // If a loyalty ability normally costs [-3] to activate, you do not remove three counters from it and then + // put one counter on it. You remove two counters at one time when you pay the cost. + // (2021-06-18) + // + // If an effect replaces the number of counters that would be placed on a planeswalker, such as that of + // Vorinclex, Monstrous Raider, that replacement happens only once, at the time payment is made. + // (2021-06-18) + + // cost modification support only 1 cost item + int staticCount = 0; + for (Cost cost : costs) { + if (cost instanceof PayLoyaltyCost) { + // static cost + PayLoyaltyCost staticCost = (PayLoyaltyCost) cost; + staticCost.setAmount(staticCost.getAmount() + amount); + staticCount++; + } else if (cost instanceof PayVariableLoyaltyCost) { + // x cost (after x announce: x cost + static cost) + PayVariableLoyaltyCost xCost = (PayVariableLoyaltyCost) cost; + xCost.setCostModification(xCost.getCostModification() + amount); + } + } + if (staticCount > 1) { + throw new IllegalArgumentException(String.format("Loyalty ability must have only 1 static cost, but has %d: %s", + staticCount, this.getRule())); + } + } } diff --git a/Mage/src/main/java/mage/abilities/costs/common/PayLoyaltyCost.java b/Mage/src/main/java/mage/abilities/costs/common/PayLoyaltyCost.java index d4e83ad57b..65e5fccbbc 100644 --- a/Mage/src/main/java/mage/abilities/costs/common/PayLoyaltyCost.java +++ b/Mage/src/main/java/mage/abilities/costs/common/PayLoyaltyCost.java @@ -1,7 +1,7 @@ - package mage.abilities.costs.common; import mage.abilities.Ability; +import mage.abilities.LoyaltyAbility; import mage.abilities.costs.Cost; import mage.abilities.costs.CostImpl; import mage.counters.CounterType; @@ -11,19 +11,14 @@ import mage.game.permanent.Permanent; import java.util.UUID; /** - * * @author BetaSteward_at_googlemail.com */ public class PayLoyaltyCost extends CostImpl { - private final int amount; + private int amount; public PayLoyaltyCost(int amount) { - this.amount = amount; - this.text = Integer.toString(amount); - if (amount > 0) { - this.text = '+' + this.text; - } + setAmount(amount); } public PayLoyaltyCost(PayLoyaltyCost cost) { @@ -34,7 +29,26 @@ public class PayLoyaltyCost extends CostImpl { @Override public boolean canPay(Ability ability, Ability source, UUID controllerId, Game game) { Permanent planeswalker = game.getPermanent(source.getSourceId()); - return planeswalker != null && planeswalker.getCounters(game).getCount(CounterType.LOYALTY) + amount >= 0 && planeswalker.canLoyaltyBeUsed(game); + if (planeswalker == null) { + return false; + } + + int loyaltyCost = amount; + + // apply cost modification + if (ability instanceof LoyaltyAbility) { + LoyaltyAbility copiedAbility = ((LoyaltyAbility) ability).copy(); + planeswalker.adjustCosts(copiedAbility, game); + game.getContinuousEffects().costModification(copiedAbility, game); + loyaltyCost = 0; + for (Cost cost : copiedAbility.getCosts()) { + if (cost instanceof PayLoyaltyCost) { + loyaltyCost += ((PayLoyaltyCost) cost).getAmount(); + } + } + } + + return planeswalker.getCounters(game).getCount(CounterType.LOYALTY) + loyaltyCost >= 0 && planeswalker.canLoyaltyBeUsed(game); } /** @@ -43,7 +57,6 @@ public class PayLoyaltyCost extends CostImpl { * ability whose cost has you put loyalty counters on a planeswalker, the * number you put on isn't doubled. This is because those counters are put * on as a cost, not as an effect. - * */ @Override public boolean pay(Ability ability, Game game, Ability source, UUID controllerId, boolean noMana, Cost costToPay) { @@ -65,4 +78,16 @@ public class PayLoyaltyCost extends CostImpl { return new PayLoyaltyCost(this); } + public int getAmount() { + return amount; + } + + public void setAmount(int amount) { + this.amount = amount; + + this.text = Integer.toString(this.amount); + if (this.amount > 0) { + this.text = '+' + this.text; + } + } } diff --git a/Mage/src/main/java/mage/abilities/costs/common/PayVariableLoyaltyCost.java b/Mage/src/main/java/mage/abilities/costs/common/PayVariableLoyaltyCost.java index 30fe920e1b..bcf6a872ad 100644 --- a/Mage/src/main/java/mage/abilities/costs/common/PayVariableLoyaltyCost.java +++ b/Mage/src/main/java/mage/abilities/costs/common/PayVariableLoyaltyCost.java @@ -1,20 +1,27 @@ - - package mage.abilities.costs.common; -import java.util.UUID; import mage.abilities.Ability; +import mage.abilities.LoyaltyAbility; import mage.abilities.costs.Cost; import mage.abilities.costs.VariableCostImpl; import mage.counters.CounterType; import mage.game.Game; import mage.game.permanent.Permanent; +import java.util.UUID; + /** - * * @author BetaSteward_at_googlemail.com */ -public class PayVariableLoyaltyCost extends VariableCostImpl { +public class PayVariableLoyaltyCost extends VariableCostImpl { + + // dynamic x cost modification from effects like Carth the Lion + // GUI only (applies to -X value on X announce) + // Example: + // - counters: 3 + // - cost modification: +1 + // - max possible X to pay: 4 + private int costModification = 0; public PayVariableLoyaltyCost() { super("loyality counters to remove"); @@ -23,17 +30,18 @@ public class PayVariableLoyaltyCost extends VariableCostImpl { public PayVariableLoyaltyCost(final PayVariableLoyaltyCost cost) { super(cost); + this.costModification = cost.costModification; } @Override public PayVariableLoyaltyCost copy() { return new PayVariableLoyaltyCost(this); } - + @Override public boolean canPay(Ability ability, Ability source, UUID controllerId, Game game) { Permanent planeswalker = game.getPermanent(source.getSourceId()); - return planeswalker!= null && planeswalker.canLoyaltyBeUsed(game); + return planeswalker != null && planeswalker.canLoyaltyBeUsed(game); } @Override @@ -43,12 +51,33 @@ public class PayVariableLoyaltyCost extends VariableCostImpl { @Override public int getMaxValue(Ability source, Game game) { - int maxValue = 0; Permanent permanent = game.getPermanent(source.getSourceId()); - if (permanent != null) { - maxValue = permanent.getCounters(game).getCount(CounterType.LOYALTY.getName()); + if (permanent == null) { + return 0; } - return maxValue; + + int maxValue = permanent.getCounters(game).getCount(CounterType.LOYALTY.getName()); + + // apply cost modification + if (source instanceof LoyaltyAbility) { + LoyaltyAbility copiedAbility = ((LoyaltyAbility) source).copy(); + permanent.adjustCosts(copiedAbility, game); + game.getContinuousEffects().costModification(copiedAbility, game); + for (Cost cost : copiedAbility.getCosts()) { + if (cost instanceof PayVariableLoyaltyCost) { + maxValue += ((PayVariableLoyaltyCost) cost).getCostModification(); + } + } + } + + return Math.max(0, maxValue); } + public int getCostModification() { + return costModification; + } + + public void setCostModification(int costModification) { + this.costModification = costModification; + } }