diff --git a/Mage.Sets/src/mage/cards/f/FblthpLostOnTheRange.java b/Mage.Sets/src/mage/cards/f/FblthpLostOnTheRange.java new file mode 100644 index 00000000000..a5300120290 --- /dev/null +++ b/Mage.Sets/src/mage/cards/f/FblthpLostOnTheRange.java @@ -0,0 +1,116 @@ +package mage.cards.f; + +import mage.MageInt; +import mage.abilities.Ability; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.costs.mana.ManaCostsImpl; +import mage.abilities.effects.ContinuousEffectImpl; +import mage.abilities.effects.common.continuous.LookAtTopCardOfLibraryAnyTimeEffect; +import mage.abilities.keyword.PlotAbility; +import mage.abilities.keyword.WardAbility; +import mage.cards.Card; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.game.Game; +import mage.players.Player; + +import java.util.UUID; + +/** + * @author Susucr + */ +public final class FblthpLostOnTheRange extends CardImpl { + + public FblthpLostOnTheRange(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{1}{U}{U}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.HOMUNCULUS); + this.power = new MageInt(1); + this.toughness = new MageInt(1); + + // Ward {2} + this.addAbility(new WardAbility(new ManaCostsImpl<>("{2}"))); + + // You may look at the top card of your library any time. + this.addAbility(new SimpleStaticAbility(new LookAtTopCardOfLibraryAnyTimeEffect())); + + // The top card of your library has plot. The plot cost is equal to its mana cost. + this.addAbility(new SimpleStaticAbility(new FblthpLostOnTheRangePlotGivingEffect())); + + // You may plot nonland cards from the top of your library. + this.addAbility(new SimpleStaticAbility(new FblthpLostOnTheRangePermissionEffect())); + } + + private FblthpLostOnTheRange(final FblthpLostOnTheRange card) { + super(card); + } + + @Override + public FblthpLostOnTheRange copy() { + return new FblthpLostOnTheRange(this); + } +} + +class FblthpLostOnTheRangePlotGivingEffect extends ContinuousEffectImpl { + + FblthpLostOnTheRangePlotGivingEffect() { + super(Duration.WhileOnBattlefield, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility); + this.staticText = "The top card of your library has plot. The plot cost is equal to its mana cost."; + } + + private FblthpLostOnTheRangePlotGivingEffect(final FblthpLostOnTheRangePlotGivingEffect effect) { + super(effect); + } + + @Override + public FblthpLostOnTheRangePlotGivingEffect copy() { + return new FblthpLostOnTheRangePlotGivingEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + if (controller == null) { + return false; + } + Card card = controller.getLibrary().getFromTop(game); + if (card == null) { + return false; + } + game.getState().addOtherAbility(card, new PlotAbility(card.getManaCost().getText())); + return true; + } +} + +class FblthpLostOnTheRangePermissionEffect extends ContinuousEffectImpl { + + FblthpLostOnTheRangePermissionEffect() { + this(Duration.WhileOnBattlefield); + } + + public FblthpLostOnTheRangePermissionEffect(Duration duration) { + super(duration, Layer.PlayerEffects, SubLayer.NA, Outcome.Benefit); + staticText = "You may plot nonland cards from the top of your library"; + } + + private FblthpLostOnTheRangePermissionEffect(final FblthpLostOnTheRangePermissionEffect effect) { + super(effect); + } + + @Override + public FblthpLostOnTheRangePermissionEffect copy() { + return new FblthpLostOnTheRangePermissionEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + if (controller != null) { + controller.setPlotFromTopOfLibrary(true); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/Mage.Sets/src/mage/sets/OutlawsOfThunderJunction.java b/Mage.Sets/src/mage/sets/OutlawsOfThunderJunction.java index 0ade499a711..f4363d2fa49 100644 --- a/Mage.Sets/src/mage/sets/OutlawsOfThunderJunction.java +++ b/Mage.Sets/src/mage/sets/OutlawsOfThunderJunction.java @@ -92,6 +92,7 @@ public final class OutlawsOfThunderJunction extends ExpansionSet { cards.add(new SetCardInfo("Explosive Derailment", 122, Rarity.COMMON, mage.cards.e.ExplosiveDerailment.class)); cards.add(new SetCardInfo("Failed Fording", 47, Rarity.COMMON, mage.cards.f.FailedFording.class)); cards.add(new SetCardInfo("Fake Your Own Death", 87, Rarity.COMMON, mage.cards.f.FakeYourOwnDeath.class)); + cards.add(new SetCardInfo("Fblthp, Lost on the Range", 48, Rarity.RARE, mage.cards.f.FblthpLostOnTheRange.class)); cards.add(new SetCardInfo("Ferocification", 123, Rarity.UNCOMMON, mage.cards.f.Ferocification.class)); cards.add(new SetCardInfo("Festering Gulch", 257, Rarity.COMMON, mage.cards.f.FesteringGulch.class)); cards.add(new SetCardInfo("Final Showdown", 11, Rarity.MYTHIC, mage.cards.f.FinalShowdown.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/otj/FblthpLostOnTheRangeTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/otj/FblthpLostOnTheRangeTest.java new file mode 100644 index 00000000000..9e29a657159 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/otj/FblthpLostOnTheRangeTest.java @@ -0,0 +1,120 @@ +package org.mage.test.cards.single.otj; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author Susucr + */ +public class FblthpLostOnTheRangeTest extends CardTestPlayerBase { + + /** + * {@link mage.cards.f.FblthpLostOnTheRange Fblthp, Lost on the Range} {1}{U}{U} + * Legendary Creature — Homunculus + * Ward {2} + * You may look at the top card of your library any time. + * The top card of your library has plot. The plot cost is equal to its mana cost. + * You may plot nonland cards from the top of your library. + * 1/1 + */ + private static final String fblthp = "Fblthp, Lost on the Range"; + + @Test + public void Test_Plot_FromTop_LightningBolt() { + setStrictChooseMode(true); + skipInitShuffling(); + + addCard(Zone.BATTLEFIELD, playerA, fblthp); + addCard(Zone.LIBRARY, playerA, "Lightning Bolt"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain"); + + assertHandCount(playerA, 0); // no card in hand, Bolt is on top. + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertExileCount(playerA, "Lightning Bolt", 1); + assertTappedCount("Mountain", true, 1); // cost {R} to plot + } + + @Test + public void Test_Plot_FromTop_RegularPlot() { + setStrictChooseMode(true); + skipInitShuffling(); + + addCard(Zone.BATTLEFIELD, playerA, fblthp); + addCard(Zone.LIBRARY, playerA, "Beastbond Outcaster"); // {2}{G}, plot {1}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); + + assertHandCount(playerA, 0); // no card in hand, Outcaster is on top. + checkPlayableAbility("regular Plot {1}{G}", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {1}{G}", true); + checkPlayableAbility("no mana for added Plot {2}{G}", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {2}{G}", false); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {1}{G}"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertExileCount(playerA, "Beastbond Outcaster", 1); + assertTappedCount("Forest", true, 2); + } + + @Test + public void Test_Plot_FromTop_AddedPlot() { + setStrictChooseMode(true); + skipInitShuffling(); + + addCard(Zone.BATTLEFIELD, playerA, fblthp); + addCard(Zone.LIBRARY, playerA, "Beastbond Outcaster"); // {2}{G}, plot {1}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + + assertHandCount(playerA, 0); // no card in hand, Outcaster is on top. + checkPlayableAbility("regular Plot {1}{G}", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {1}{G}", true); + checkPlayableAbility("added Plot {2}{G}", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {2}{G}", true); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {2}{G}"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertExileCount(playerA, "Beastbond Outcaster", 1); + assertTappedCount("Forest", true, 3); + } + + @Test + public void Test_Plot_FromTop_Adventure() { + setStrictChooseMode(true); + skipInitShuffling(); + + addCard(Zone.BATTLEFIELD, playerA, fblthp); + addCard(Zone.LIBRARY, playerA, "Bonecrusher Giant"); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot {2}{R}"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertExileCount(playerA, "Bonecrusher Giant", 1); + assertTappedCount("Mountain", true, 3); + } + + @Test + public void Test_Plot_FromTop_Split() { + setStrictChooseMode(true); + skipInitShuffling(); + + addCard(Zone.BATTLEFIELD, playerA, fblthp); + addCard(Zone.LIBRARY, playerA, "Life // Death"); // split {G} / {1}{B} + addCard(Zone.BATTLEFIELD, playerA, "Bayou", 3); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Plot"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertExileCount(playerA, "Life // Death", 1); + assertTappedCount("Bayou", true, 3); + } +} diff --git a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java index f4886175e37..59083137054 100644 --- a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java +++ b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java @@ -1225,10 +1225,10 @@ public class TestPlayer implements Player { + ", " + (c.isTapped() ? "Tapped" : "Untapped") + getPrintableAliases(", [", c.getId(), "]") + (c.getAttachedTo() == null ? "" - : ", attached to " - + (game.getObject(c.getAttachedTo()) == null - ? game.getPlayer(c.getAttachedTo()).getName() - : game.getObject(c.getAttachedTo()).getIdName())))) + : ", attached to " + + (game.getObject(c.getAttachedTo()) == null + ? game.getPlayer(c.getAttachedTo()).getName() + : game.getObject(c.getAttachedTo()).getIdName())))) .sorted() .collect(Collectors.toList()); @@ -3833,6 +3833,16 @@ public class TestPlayer implements Player { computerPlayer.setDrawsOnOpponentsTurn(drawsOnOpponentsTurn); } + @Override + public boolean canPlotFromTopOfLibrary() { + return computerPlayer.canPlotFromTopOfLibrary(); + } + + @Override + public void setPlotFromTopOfLibrary(boolean canPlotFromTopOfLibrary) { + computerPlayer.setPlotFromTopOfLibrary(canPlotFromTopOfLibrary); + } + @Override public boolean isDrawsOnOpponentsTurn() { return computerPlayer.isDrawsOnOpponentsTurn(); diff --git a/Mage/src/main/java/mage/abilities/keyword/PlotAbility.java b/Mage/src/main/java/mage/abilities/keyword/PlotAbility.java index 5d5b996597b..17c667914e0 100644 --- a/Mage/src/main/java/mage/abilities/keyword/PlotAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/PlotAbility.java @@ -27,7 +27,7 @@ public class PlotAbility extends SpecialAction { private final String rule; public PlotAbility(String plotCost) { - super(Zone.HAND); + super(Zone.ALL); // Usually, plot only works from hand. However [[Fblthp, Lost on the Range]] allows plotting from library this.addCost(new ManaCostsImpl<>(plotCost)); this.addEffect(new PlotSourceExileEffect()); this.setTiming(TimingRule.SORCERY); @@ -50,19 +50,34 @@ public class PlotAbility extends SpecialAction { return rule; } - // TODO: handle [[Fblthp, Lost on the Range]] allowing player to plot from library. @Override public ActivationStatus canActivate(UUID playerId, Game game) { - // plot can only be activated from a hand - // TODO: change that for Fblthp. - if (game.getState().getZone(getSourceId()) != Zone.HAND) { - return ActivationStatus.getFalse(); - } - // suspend uses card's timing restriction + // Plot ability uses card's timing restriction Card card = game.getCard(getSourceId()); if (card == null) { return ActivationStatus.getFalse(); } + // plot can only be activated from hand or from top of library if allowed to. + Zone zone = game.getState().getZone(getSourceId()); + if (zone == Zone.HAND) { + // Allowed from hand + } else if (zone == Zone.LIBRARY) { + // Allowed only if permitted for top card, and only if the card is on top and is nonland + // Note: if another effect changes zones where permitted, or if different card categories are permitted, + // it would be better to refactor this as an unique AsThoughEffect. + // As of now, only Fblthp, Lost on the Range changes permission of plot. + Player player = game.getPlayer(getControllerId()); + if (player == null || !player.canPlotFromTopOfLibrary()) { + return ActivationStatus.getFalse(); + } + Card topCardLibrary = player.getLibrary().getFromTop(game); + if (topCardLibrary == null || !topCardLibrary.getId().equals(card.getId()) || card.isLand()) { + return ActivationStatus.getFalse(); + } + } else { + // Not Allowed from other zones + return ActivationStatus.getFalse(); + } if (!card.getSpellAbility().spellCanBeActivatedRegularlyNow(playerId, game)) { return ActivationStatus.getFalse(); } @@ -99,11 +114,17 @@ public class PlotAbility extends SpecialAction { UUID exileId = PlotAbility.getPlotExileId(owner.getId(), game); String exileZoneName = "Plots of " + owner.getName(); Card mainCard = card.getMainCard(); + Zone zone = game.getState().getZone(mainCard.getId()); if (mainCard.moveToExile(exileId, exileZoneName, source, game)) { // Remember on which turn the card was last plotted. game.getState().setValue(PlotAbility.getPlotTurnKeyForCard(mainCard.getId()), game.getTurnNum()); game.addEffect(new PlotAddSpellAbilityEffect(new MageObjectReference(mainCard, game)), source); - game.informPlayers(owner.getLogName() + " plots " + mainCard.getLogName()); + game.informPlayers( + owner.getLogName() + + " plots " + mainCard.getLogName() + + " from " + zone.toString().toLowerCase() + + CardUtil.getSourceLogName(game, source, card.getId()) + ); game.fireEvent(GameEvent.getEvent(GameEvent.EventType.BECOME_PLOTTED, mainCard.getId(), source, owner.getId())); } return true; diff --git a/Mage/src/main/java/mage/players/Player.java b/Mage/src/main/java/mage/players/Player.java index 5fedee1b40b..499203ff2b1 100644 --- a/Mage/src/main/java/mage/players/Player.java +++ b/Mage/src/main/java/mage/players/Player.java @@ -195,6 +195,10 @@ public interface Player extends MageItem, Copyable { boolean canPlayCardsFromGraveyard(); + void setPlotFromTopOfLibrary(boolean canPlotFromTopOfLibrary); + + boolean canPlotFromTopOfLibrary(); + void setDrawsOnOpponentsTurn(boolean drawsOnOpponentsTurn); boolean isDrawsOnOpponentsTurn(); @@ -363,6 +367,7 @@ public interface Player extends MageItem, Copyable { /** * Return player's turn control to prev player + * * @param value * @param fullRestore return turn control to own */ @@ -988,7 +993,7 @@ public interface Player extends MageItem, Copyable { * @param source * @param game * @param fromZone - * @param withName for face down: used to hide card name in game logs before real face down status apply + * @param withName for face down: used to hide card name in game logs before real face down status apply * @return */ @Deprecated diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index 967ddb6a9e6..d465f350a48 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -154,6 +154,7 @@ public abstract class PlayerImpl implements Player, Serializable { protected PayLifeCostLevel payLifeCostLevel = PayLifeCostLevel.allAbilities; protected boolean loseByZeroOrLessLife = true; protected boolean canPlayCardsFromGraveyard = true; + protected boolean canPlotFromTopOfLibrary = false; protected boolean drawsOnOpponentsTurn = false; protected FilterPermanent sacrificeCostFilter; @@ -251,6 +252,7 @@ public abstract class PlayerImpl implements Player, Serializable { this.canLoseLife = player.canLoseLife; this.loseByZeroOrLessLife = player.loseByZeroOrLessLife; this.canPlayCardsFromGraveyard = player.canPlayCardsFromGraveyard; + this.canPlotFromTopOfLibrary = player.canPlotFromTopOfLibrary; this.drawsOnOpponentsTurn = player.drawsOnOpponentsTurn; this.attachments.addAll(player.attachments); @@ -360,6 +362,7 @@ public abstract class PlayerImpl implements Player, Serializable { ? player.getSacrificeCostFilter().copy() : null; this.loseByZeroOrLessLife = player.canLoseByZeroOrLessLife(); this.canPlayCardsFromGraveyard = player.canPlayCardsFromGraveyard(); + this.canPlotFromTopOfLibrary = player.canPlotFromTopOfLibrary(); this.drawsOnOpponentsTurn = player.isDrawsOnOpponentsTurn(); this.alternativeSourceCosts.clear(); this.alternativeSourceCosts.addAll(player.getAlternativeSourceCosts()); @@ -474,6 +477,7 @@ public abstract class PlayerImpl implements Player, Serializable { this.payLifeCostLevel = PayLifeCostLevel.allAbilities; this.loseByZeroOrLessLife = true; this.canPlayCardsFromGraveyard = true; + this.canPlotFromTopOfLibrary = false; this.drawsOnOpponentsTurn = false; this.sacrificeCostFilter = null; @@ -516,6 +520,7 @@ public abstract class PlayerImpl implements Player, Serializable { this.sacrificeCostFilter = null; this.loseByZeroOrLessLife = true; this.canPlayCardsFromGraveyard = false; + this.canPlotFromTopOfLibrary = false; this.drawsOnOpponentsTurn = false; this.topCardRevealed = false; this.alternativeSourceCosts.clear(); @@ -4526,6 +4531,16 @@ public abstract class PlayerImpl implements Player, Serializable { this.canPlayCardsFromGraveyard = playCardsFromGraveyard; } + @Override + public boolean canPlotFromTopOfLibrary() { + return canPlotFromTopOfLibrary; + } + + @Override + public void setPlotFromTopOfLibrary(boolean canPlotFromTopOfLibrary) { + this.canPlotFromTopOfLibrary = canPlotFromTopOfLibrary; + } + @Override public void setDrawsOnOpponentsTurn(boolean drawsOnOpponentsTurn) { this.drawsOnOpponentsTurn = drawsOnOpponentsTurn;