[LCC] Implement Topography Tracker (#11504)

* Refactor replacement effects on ExploreEvent by using a queue of events
This commit is contained in:
Grath 2023-12-04 00:46:21 -05:00 committed by GitHub
parent 15fcc22bb3
commit 5c83818cba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 289 additions and 37 deletions

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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));

View file

@ -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);
}
}

View file

@ -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 permanents 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 permanents 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);

View file

@ -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<EventType> 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<EventType> 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<EventType> newQueue = new ArrayDeque<>();
for (EventType eventType : eventQueue) {
if (eventType == EventType.EXPLORE) {
newQueue.add(EventType.SCRY);
}
newQueue.add(eventType);
}
eventQueue = newQueue;
}
public Queue<EventType> getEventQueue() {
return eventQueue;
}
}