From 5c83818cbae4da55d32b60369dc5e2ebbd9114bc Mon Sep 17 00:00:00 2001 From: Grath <1895280+Grath@users.noreply.github.com> Date: Mon, 4 Dec 2023 00:46:21 -0500 Subject: [PATCH] [LCC] Implement Topography Tracker (#11504) * Refactor replacement effects on ExploreEvent by using a queue of events --- .../src/mage/cards/t/TopographyTracker.java | 84 ++++++++++++++ .../src/mage/cards/t/TwistsAndTurns.java | 6 +- .../sets/LostCavernsOfIxalanCommander.java | 1 + .../cards/abilities/keywords/ExploreTest.java | 107 +++++++++++++++++- .../effects/keyword/ExploreSourceEffect.java | 77 ++++++++----- .../java/mage/game/events/ExploreEvent.java | 51 +++++++++ 6 files changed, 289 insertions(+), 37 deletions(-) create mode 100644 Mage.Sets/src/mage/cards/t/TopographyTracker.java create mode 100644 Mage/src/main/java/mage/game/events/ExploreEvent.java diff --git a/Mage.Sets/src/mage/cards/t/TopographyTracker.java b/Mage.Sets/src/mage/cards/t/TopographyTracker.java new file mode 100644 index 00000000000..2e3a39cfb16 --- /dev/null +++ b/Mage.Sets/src/mage/cards/t/TopographyTracker.java @@ -0,0 +1,84 @@ +package mage.cards.t; + +import java.util.UUID; +import mage.MageInt; +import mage.abilities.Ability; +import mage.abilities.common.EntersBattlefieldTriggeredAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.ReplacementEffectImpl; +import mage.abilities.effects.common.CreateTokenEffect; +import mage.constants.Duration; +import mage.constants.Outcome; +import mage.constants.SubType; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.game.Game; +import mage.game.events.ExploreEvent; +import mage.game.events.GameEvent; +import mage.game.permanent.token.MapToken; + +/** + * + * @author Grath + */ +public final class TopographyTracker extends CardImpl { + + public TopographyTracker(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{G}"); + + this.subtype.add(SubType.MERFOLK); + this.subtype.add(SubType.SCOUT); + this.power = new MageInt(2); + this.toughness = new MageInt(2); + + // When Topography Tracker enters the battlefield, create a Map token. + this.addAbility(new EntersBattlefieldTriggeredAbility(new CreateTokenEffect(new MapToken()), false)); + + // If a creature you control would explore, instead it explores, then it explores again. + this.addAbility(new SimpleStaticAbility(new TopographyTrackerEffect())); + } + + private TopographyTracker(final TopographyTracker card) { + super(card); + } + + @Override + public TopographyTracker copy() { + return new TopographyTracker(this); + } +} + +class TopographyTrackerEffect extends ReplacementEffectImpl { + + TopographyTrackerEffect() { + super(Duration.WhileOnBattlefield, Outcome.Benefit); + staticText = "If a creature you control would explore, instead it explores, then it explores again."; + } + + private TopographyTrackerEffect(final TopographyTrackerEffect effect) { + super(effect); + } + + @Override + public TopographyTrackerEffect copy() { + return new TopographyTrackerEffect(this); + } + + @Override + public boolean checksEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.EXPLORE; + } + + @Override + public boolean applies(GameEvent event, Ability source, Game game) { + return game.getPermanent(event.getTargetId()).isControlledBy(event.getPlayerId()); + } + + @Override + public boolean replaceEvent(GameEvent event, Ability source, Game game) { + ExploreEvent exploreEvent = (ExploreEvent)event; + exploreEvent.doubleExplores(); + return false; + } +} \ No newline at end of file diff --git a/Mage.Sets/src/mage/cards/t/TwistsAndTurns.java b/Mage.Sets/src/mage/cards/t/TwistsAndTurns.java index d8c6d68014d..e4585f8691c 100644 --- a/Mage.Sets/src/mage/cards/t/TwistsAndTurns.java +++ b/Mage.Sets/src/mage/cards/t/TwistsAndTurns.java @@ -18,8 +18,8 @@ import mage.constants.Duration; import mage.constants.Outcome; import mage.filter.StaticFilters; import mage.game.Game; +import mage.game.events.ExploreEvent; import mage.game.events.GameEvent; -import mage.players.Player; import mage.target.common.TargetControlledCreaturePermanent; import java.util.UUID; @@ -89,8 +89,8 @@ class TwistsAndTurnsReplacementEffect extends ReplacementEffectImpl { @Override public boolean replaceEvent(GameEvent event, Ability source, Game game) { - Player controller = game.getPlayer(source.getControllerId()); - controller.scry(1, source, game); + ExploreEvent exploreEvent = (ExploreEvent)event; + exploreEvent.addScry(); return false; } } diff --git a/Mage.Sets/src/mage/sets/LostCavernsOfIxalanCommander.java b/Mage.Sets/src/mage/sets/LostCavernsOfIxalanCommander.java index b4213aed442..6a4dd21fecb 100644 --- a/Mage.Sets/src/mage/sets/LostCavernsOfIxalanCommander.java +++ b/Mage.Sets/src/mage/sets/LostCavernsOfIxalanCommander.java @@ -270,6 +270,7 @@ public final class LostCavernsOfIxalanCommander extends ExpansionSet { cards.add(new SetCardInfo("Timothar, Baron of Bats", 210, Rarity.MYTHIC, mage.cards.t.TimotharBaronOfBats.class)); cards.add(new SetCardInfo("Tishana, Voice of Thunder", 291, Rarity.MYTHIC, mage.cards.t.TishanaVoiceOfThunder.class)); cards.add(new SetCardInfo("Topiary Stomper", 261, Rarity.RARE, mage.cards.t.TopiaryStomper.class)); + cards.add(new SetCardInfo("Topography Tracker", 95, Rarity.RARE, mage.cards.t.TopographyTracker.class)); cards.add(new SetCardInfo("Tributary Instructor", 96, Rarity.RARE, mage.cards.t.TributaryInstructor.class)); cards.add(new SetCardInfo("Twilight Prophet", 211, Rarity.MYTHIC, mage.cards.t.TwilightProphet.class)); cards.add(new SetCardInfo("Unclaimed Territory", 366, Rarity.UNCOMMON, mage.cards.u.UnclaimedTerritory.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/ExploreTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/ExploreTest.java index b014614a749..1dd8fd13dc2 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/ExploreTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/ExploreTest.java @@ -33,6 +33,8 @@ public class ExploreTest extends CardTestPlayerBase { private static final String enter = "Enter the Unknown"; // G Sorcery - Target creature you control explores. private static final String quicksand = "Quicksand"; // Land private static final String gg = "Giant Growth"; // Nonland + private static final String twists = "Twists and Turns"; // Replace Explore with Scry 1, Explore + private static final String topography = "Topography Tracker"; // Replace Explore with Explore, Explore @Test @@ -242,10 +244,8 @@ public class ExploreTest extends CardTestPlayerBase { } @Test - public void exploreReplacement() { - String twists = "Twists and Turns"; + public void exploreReplacementScry() { // If a creature you control would explore, instead you scry 1, then that creature explores. - addCard(Zone.BATTLEFIELD, playerA, ww); addCard(Zone.BATTLEFIELD, playerA, twists); addCard(Zone.HAND, playerA, mb); @@ -268,5 +268,104 @@ public class ExploreTest extends CardTestPlayerBase { assertGraveyardCount(playerA, 0); assertLibraryCount(playerA, 0); } - + + @Test + public void exploreReplacementTwice() { + // If a creature you control would explore, instead it explores, then it explores again. + addCard(Zone.BATTLEFIELD, playerA, ww); + addCard(Zone.BATTLEFIELD, playerA, topography); + addCard(Zone.HAND, playerA, mb); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); + removeAllCardsFromLibrary(playerA); + skipInitShuffling(); + addCard(Zone.LIBRARY, playerA, quicksand, 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, mb); + setChoice(playerA, "Whenever a creature you control explores"); // order trigger + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + setStrictChooseMode(true); + execute(); + + assertCounterCount(mb, CounterType.P1P1, 0); + assertCounterCount(ww, CounterType.P1P1, 2); + assertLife(playerA, 26); + assertHandCount(playerA, quicksand, 2); + assertGraveyardCount(playerA, 0); + assertLibraryCount(playerA, 0); + } + + + @Test + public void exploreReplacementScryOnce() { + String flamespeaker = "Flamespeaker Adept"; + // If a creature you control would explore, instead you scry 1, then that creature explores. + // If a creature you control would explore, instead it explores, then it explores again. + addCard(Zone.BATTLEFIELD, playerA, ww); + addCard(Zone.BATTLEFIELD, playerA, twists); + addCard(Zone.BATTLEFIELD, playerA, topography); + addCard(Zone.BATTLEFIELD, playerA, flamespeaker); + addCard(Zone.HAND, playerA, mb); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); + removeAllCardsFromLibrary(playerA); + skipInitShuffling(); + addCard(Zone.LIBRARY, playerA, quicksand, 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, mb); + // Order Twists and Turns replacement effect first + setChoice(playerA, twists); + addTarget(playerA, TestPlayer.TARGET_SKIP); // scry to top (no targets to bottom) + setChoice(playerA, "Whenever a creature you control explores"); // order trigger + setChoice(playerA, "Whenever a creature you control explores"); // order trigger + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + setStrictChooseMode(true); + execute(); + + assertCounterCount(mb, CounterType.P1P1, 0); + assertCounterCount(ww, CounterType.P1P1, 2); + assertPowerToughness(playerA, flamespeaker, 4,3); + assertLife(playerA, 26); + assertHandCount(playerA, quicksand, 2); + assertGraveyardCount(playerA, 0); + assertLibraryCount(playerA, 0); + } + + + @Test + public void exploreReplacementScryTwice() { + String flamespeaker = "Flamespeaker Adept"; + // If a creature you control would explore, instead it explores, then it explores again. + // If a creature you control would explore, instead you scry 1, then that creature explores. + addCard(Zone.BATTLEFIELD, playerA, ww); + addCard(Zone.BATTLEFIELD, playerA, twists); + addCard(Zone.BATTLEFIELD, playerA, topography); + addCard(Zone.BATTLEFIELD, playerA, flamespeaker); + addCard(Zone.HAND, playerA, mb); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); + removeAllCardsFromLibrary(playerA); + skipInitShuffling(); + addCard(Zone.LIBRARY, playerA, quicksand, 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, mb); + // Order Topography Tracker replacement effect first + setChoice(playerA, topography); + addTarget(playerA, TestPlayer.TARGET_SKIP); // scry to top (no targets to bottom) + addTarget(playerA, TestPlayer.TARGET_SKIP); // scry to top again (no targets to bottom) + setChoice(playerA, "Whenever a creature you control explores"); // order trigger + setChoice(playerA, "Whenever a creature you control explores"); // order trigger + setChoice(playerA, "Whenever you scry"); // order trigger + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + setStrictChooseMode(true); + execute(); + + assertCounterCount(mb, CounterType.P1P1, 0); + assertCounterCount(ww, CounterType.P1P1, 2); + assertPowerToughness(playerA, flamespeaker, 6,3); + assertLife(playerA, 26); + assertHandCount(playerA, quicksand, 2); + assertGraveyardCount(playerA, 0); + assertLibraryCount(playerA, 0); + } } diff --git a/Mage/src/main/java/mage/abilities/effects/keyword/ExploreSourceEffect.java b/Mage/src/main/java/mage/abilities/effects/keyword/ExploreSourceEffect.java index ebd7bc8f682..054346508ee 100644 --- a/Mage/src/main/java/mage/abilities/effects/keyword/ExploreSourceEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/keyword/ExploreSourceEffect.java @@ -10,6 +10,7 @@ import mage.constants.Outcome; import mage.constants.Zone; import mage.counters.CounterType; import mage.game.Game; +import mage.game.events.ExploreEvent; import mage.game.events.ExploredEvent; import mage.game.events.GameEvent; import mage.game.permanent.Permanent; @@ -70,41 +71,57 @@ public class ExploreSourceEffect extends OneShotEffect { return false; } - for (int i = 0; i < amount; ++i) { - GameEvent event = new GameEvent(GameEvent.EventType.EXPLORE, permanentId, source, permanent.getControllerId()); - if (game.replaceEvent(event)) { - continue; + // Because of Topography Tracker and Twists and Turns, we need to be able to have a mix of Explores and Scry 1s + // The ExploreEvent generates with a queue of EventType.EXPLORE equal to amount, the replacement effects + // can then replace each EXPLORE with either two EXPLOREs or a SCRY and an EXPLORE. + ExploreEvent event = new ExploreEvent(permanent, source, amount); + if (game.replaceEvent(event)) { + return false; + } + for (GameEvent.EventType eventType : event.getEventQueue()) { + switch (eventType) { + case SCRY: + controller.scry(1, source, game); + break; + case EXPLORE: + doOneExplore(game, permanent, source, controller); + break; + default: + throw new IllegalArgumentException("Wrong code usage: unrecognized event type in explore event"); } - // 701.40a Certain abilities instruct a permanent to explore. To do so, that permanent’s controller reveals - // the top card of their library. If a land card is revealed this way, that player puts that card into their - // hand. Otherwise, that player puts a +1/+1 counter on the exploring permanent and may put the revealed - // card into their graveyard. - Card card = controller.getLibrary().getFromTop(game); - if (card != null) { - controller.revealCards("Explored card", new CardsImpl(card), game); - if (card.isLand(game)) { - controller.moveCards(card, Zone.HAND, source, game); - } else { - addCounter(game, permanent, source); - if (controller.chooseUse(Outcome.Neutral, "Put " + card.getLogName() + " in your graveyard?", source, game)) { - controller.moveCards(card, Zone.GRAVEYARD, source, game); - } else { - game.informPlayers(controller.getLogName() + " leaves " + card.getLogName() + " on top of their library."); - } - } - } else { - // If no card is revealed, most likely because that player's library is empty, - // the exploring creature receives a +1/+1 counter. - addCounter(game, permanent, source); - } - game.getState().processAction(game); - // 701.40b A permanent “explores” after the process described in rule 701.40a is complete, even if some or all of - // those actions were impossible. - game.fireEvent(new ExploredEvent(permanent, source, card)); } return true; } + private static void doOneExplore(Game game, Permanent permanent, Ability source, Player controller) { + // 701.40a Certain abilities instruct a permanent to explore. To do so, that permanent’s controller reveals + // the top card of their library. If a land card is revealed this way, that player puts that card into their + // hand. Otherwise, that player puts a +1/+1 counter on the exploring permanent and may put the revealed + // card into their graveyard. + Card card = controller.getLibrary().getFromTop(game); + if (card != null) { + controller.revealCards("Explored card", new CardsImpl(card), game); + if (card.isLand(game)) { + controller.moveCards(card, Zone.HAND, source, game); + } else { + addCounter(game, permanent, source); + if (controller.chooseUse(Outcome.Neutral, "Put " + card.getLogName() + " in your graveyard?", source, game)) { + controller.moveCards(card, Zone.GRAVEYARD, source, game); + } else { + game.informPlayers(controller.getLogName() + " leaves " + card.getLogName() + " on top of their library."); + } + } + } else { + // If no card is revealed, most likely because that player's library is empty, + // the exploring creature receives a +1/+1 counter. + addCounter(game, permanent, source); + } + game.getState().processAction(game); + // 701.40b A permanent “explores” after the process described in rule 701.40a is complete, even if some or all of + // those actions were impossible. + game.fireEvent(new ExploredEvent(permanent, source, card)); + } + private static void addCounter(Game game, Permanent permanent, Ability source) { if (game.getState().getZone(permanent.getId()) == Zone.BATTLEFIELD) { // needed in case LKI object is used permanent.addCounters(CounterType.P1P1.createInstance(), source.getControllerId(), source, game); diff --git a/Mage/src/main/java/mage/game/events/ExploreEvent.java b/Mage/src/main/java/mage/game/events/ExploreEvent.java new file mode 100644 index 00000000000..230d508edf5 --- /dev/null +++ b/Mage/src/main/java/mage/game/events/ExploreEvent.java @@ -0,0 +1,51 @@ +package mage.game.events; + +import mage.abilities.Ability; +import mage.game.permanent.Permanent; + +import java.util.ArrayDeque; +import java.util.Queue; + +/** + * @author Grath + */ +public class ExploreEvent extends GameEvent { + + private Queue eventQueue = new ArrayDeque<>(); + + public ExploreEvent(Permanent permanent, Ability source, int amount) { + super(EventType.EXPLORE, permanent.getId(), source, permanent.getControllerId(), amount, false); + // Populate the queue with a number of EXPLOREs equal to the initial number of EXPLOREs. + for (int i = 0; i < amount; ++i) { + eventQueue.add(EventType.EXPLORE); + } + } + + public void doubleExplores() { + // Process through the Queue, when we find an EXPLORE add another EXPLORE. + Queue newQueue = new ArrayDeque<>(); + for (EventType eventType : eventQueue) { + if (eventType == EventType.EXPLORE) { + newQueue.add(EventType.EXPLORE); + } + newQueue.add(eventType); + } + eventQueue = newQueue; + } + + public void addScry() { + // Process through the Queue, when we find an EXPLORE add a SCRY before it. + Queue newQueue = new ArrayDeque<>(); + for (EventType eventType : eventQueue) { + if (eventType == EventType.EXPLORE) { + newQueue.add(EventType.SCRY); + } + newQueue.add(eventType); + } + eventQueue = newQueue; + } + + public Queue getEventQueue() { + return eventQueue; + } +}