implement [EOE] Kav Landseeker; fix & test Meandering Towershell along the way

This commit is contained in:
Susucre 2025-07-11 20:40:50 +02:00
parent 2b7f8869dd
commit 0dceeb78bd
8 changed files with 298 additions and 53 deletions

View file

@ -0,0 +1,82 @@
package mage.cards.k;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.EntersBattlefieldTriggeredAbility;
import mage.abilities.common.delayed.AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.SacrificeTargetEffect;
import mage.abilities.keyword.MenaceAbility;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.permanent.token.LanderToken;
import mage.game.permanent.token.Token;
import mage.target.targetpointer.FixedTargets;
import java.util.UUID;
/**
* @author Susucr
*/
public final class KavLandseeker extends CardImpl {
public KavLandseeker(UUID ownerId, CardSetInfo setInfo) {
super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{3}{R}");
this.subtype.add(SubType.KAVU);
this.subtype.add(SubType.SOLDIER);
this.power = new MageInt(4);
this.toughness = new MageInt(3);
// Menace
this.addAbility(new MenaceAbility());
// When this creature enters, create a Lander token. At the beginning of the end step on your next turn, sacrifice that token.
this.addAbility(new EntersBattlefieldTriggeredAbility(new KavLandseekerEffect()));
}
private KavLandseeker(final KavLandseeker card) {
super(card);
}
@Override
public KavLandseeker copy() {
return new KavLandseeker(this);
}
}
class KavLandseekerEffect extends OneShotEffect {
KavLandseekerEffect() {
super(Outcome.Benefit);
staticText = "create a Lander token. "
+ "At the beginning of the end step on your next turn, sacrifice that token";
}
private KavLandseekerEffect(final KavLandseekerEffect effect) {
super(effect);
}
@Override
public KavLandseekerEffect copy() {
return new KavLandseekerEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Token token = new LanderToken();
token.putOntoBattlefield(1, game, source, source.getControllerId());
game.addDelayedTriggeredAbility(new AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(
new SacrificeTargetEffect()
.setTargetPointer(new FixedTargets(token, game))
.setText("sacrifice that token"),
GameEvent.EventType.END_TURN_STEP_PRE
), source);
return true;
}
}

View file

@ -1,39 +1,40 @@
package mage.cards.m; package mage.cards.m;
import java.util.UUID;
import mage.MageInt; import mage.MageInt;
import mage.abilities.Ability; import mage.abilities.Ability;
import mage.abilities.DelayedTriggeredAbility;
import mage.abilities.common.AttacksTriggeredAbility; import mage.abilities.common.AttacksTriggeredAbility;
import mage.abilities.common.delayed.AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility;
import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.OneShotEffect;
import mage.abilities.keyword.IslandwalkAbility; import mage.abilities.keyword.IslandwalkAbility;
import mage.cards.Card; import mage.cards.Card;
import mage.cards.CardImpl; import mage.cards.CardImpl;
import mage.cards.CardSetInfo; import mage.cards.CardSetInfo;
import mage.constants.CardType; import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.Outcome; import mage.constants.Outcome;
import mage.constants.SubType;
import mage.constants.Zone; import mage.constants.Zone;
import mage.game.Game; import mage.game.Game;
import mage.game.events.GameEvent; import mage.game.events.GameEvent;
import mage.game.permanent.Permanent; import mage.game.permanent.Permanent;
import mage.players.Player; import mage.players.Player;
import java.util.UUID;
/** /**
* As Meandering Towershell returns to the battlefield because of the delayed * As Meandering Towershell returns to the battlefield because of the delayed
* triggered ability, you choose which opponent or opposing planeswalker it's * triggered ability, you choose which opponent or opposing planeswalker it's
* attacking. It doesn't have to attack the same opponent or opposing * attacking. It doesn't have to attack the same opponent or opposing
* planeswalker that it was when it was exiled. * planeswalker that it was when it was exiled.
* * --
* If Meandering Towershell enters the battlefield attacking, it wasn't declared * If Meandering Towershell enters the battlefield attacking, it wasn't declared
* as an attacking creature that turn. Abilities that trigger when a creature * as an attacking creature that turn. Abilities that trigger when a creature
* attacks, including its own triggered ability, won't trigger. * attacks, including its own triggered ability, won't trigger.
* * --
* On the turn Meandering Towershell attacks and is exiled, raid abilities will * On the turn Meandering Towershell attacks and is exiled, raid abilities will
* see it as a creature that attacked. Conversely, on the turn Meandering * see it as a creature that attacked. Conversely, on the turn Meandering
* Towershell enters the battlefield attacking, raid abilities will not. * Towershell enters the battlefield attacking, raid abilities will not.
* * --
* If you attack with a Meandering Towershell that you don't own, you'll control * If you attack with a Meandering Towershell that you don't own, you'll control
* it when it returns to the battlefield. * it when it returns to the battlefield.
* *
@ -90,58 +91,13 @@ class MeanderingTowershellEffect extends OneShotEffect {
Permanent sourcePermanent = game.getPermanent(source.getSourceId()); Permanent sourcePermanent = game.getPermanent(source.getSourceId());
if (controller != null && sourcePermanent != null) { if (controller != null && sourcePermanent != null) {
controller.moveCardToExileWithInfo(sourcePermanent, null, "", source, game, Zone.BATTLEFIELD, true); controller.moveCardToExileWithInfo(sourcePermanent, null, "", source, game, Zone.BATTLEFIELD, true);
game.addDelayedTriggeredAbility(new AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility(), source); game.addDelayedTriggeredAbility(new AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(new MeanderingTowershellReturnEffect(), GameEvent.EventType.DECLARE_ATTACKERS_STEP_PRE), source);
return true; return true;
} }
return false; return false;
} }
} }
class AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility extends DelayedTriggeredAbility {
private int startingTurn;
public AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility() {
super(new MeanderingTowershellReturnEffect());
}
private AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility(final AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility ability) {
super(ability);
this.startingTurn = ability.startingTurn;
}
@Override
public void init(Game game) {
startingTurn = game.getTurnNum();
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.DECLARED_ATTACKERS;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (event.getPlayerId().equals(this.controllerId)) {
if (game.getTurnNum() != startingTurn) {
return true;
}
}
return false;
}
@Override
public String getRule() {
return "Return it to the battlefield under your control tapped and attacking at the beginning of the next declare attackers step on your next turn.";
}
@Override
public AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility copy() {
return new AtBeginningNextDeclareAttackersStepNextTurnDelayedTriggeredAbility(this);
}
}
class MeanderingTowershellReturnEffect extends OneShotEffect { class MeanderingTowershellReturnEffect extends OneShotEffect {
MeanderingTowershellReturnEffect() { MeanderingTowershellReturnEffect() {

View file

@ -114,6 +114,7 @@ public final class EdgeOfEternities extends ExpansionSet {
cards.add(new SetCardInfo("Island", 269, Rarity.LAND, mage.cards.basiclands.Island.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Island", 269, Rarity.LAND, mage.cards.basiclands.Island.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Island", 270, Rarity.LAND, mage.cards.basiclands.Island.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Island", 270, Rarity.LAND, mage.cards.basiclands.Island.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Island", 368, Rarity.LAND, mage.cards.basiclands.Island.class, FULL_ART_BFZ_VARIOUS)); cards.add(new SetCardInfo("Island", 368, Rarity.LAND, mage.cards.basiclands.Island.class, FULL_ART_BFZ_VARIOUS));
cards.add(new SetCardInfo("Kav Landseeker", 138, Rarity.COMMON, mage.cards.k.KavLandseeker.class));
cards.add(new SetCardInfo("Kavaron Harrier", 139, Rarity.UNCOMMON, mage.cards.k.KavaronHarrier.class)); cards.add(new SetCardInfo("Kavaron Harrier", 139, Rarity.UNCOMMON, mage.cards.k.KavaronHarrier.class));
cards.add(new SetCardInfo("Kavaron, Memorial World", 255, Rarity.MYTHIC, mage.cards.k.KavaronMemorialWorld.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Kavaron, Memorial World", 255, Rarity.MYTHIC, mage.cards.k.KavaronMemorialWorld.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Kavaron, Memorial World", 281, Rarity.MYTHIC, mage.cards.k.KavaronMemorialWorld.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Kavaron, Memorial World", 281, Rarity.MYTHIC, mage.cards.k.KavaronMemorialWorld.class, NON_FULL_USE_VARIOUS));

View file

@ -0,0 +1,56 @@
package org.mage.test.cards.single.eoe;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author Susucr
*/
public class KavLandseekerTest extends CardTestPlayerBase {
/**
* {@link mage.cards.k.KavLandseeker Kav Landseeker} {3}{R}
* Creature Kavu Soldier
* Menace (This creature cant be blocked except by two or more creatures.)
* When this creature enters, create a Lander token. At the beginning of the end step on your next turn, sacrifice that token. (Its an artifact with {2}, {T}, Sacrifice this token: Search your library for a basic land card, put it onto the battlefield tapped, then shuffle.)
* 4/3
*/
private static final String kav = "Kav Landseeker";
@Test
public void test_Simple() {
addCard(Zone.HAND, playerA, kav);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 4);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, kav);
checkPermanentCount("T3: playerA has a Lander Token", 3, PhaseStep.POSTCOMBAT_MAIN, playerA, playerA, "Lander Token", 1);
setStopAt(4, PhaseStep.PRECOMBAT_MAIN);
setStrictChooseMode(true);
execute();
assertPermanentCount(playerA, "Lander Token", 0);
}
@Test
public void test_TimeStop() {
addCard(Zone.HAND, playerA, kav);
addCard(Zone.HAND, playerA, "Time Stop"); // end the turn
addCard(Zone.BATTLEFIELD, playerA, "Volcanic Island", 6);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, kav);
checkPermanentCount("T3: playerA has a Lander Token", 3, PhaseStep.POSTCOMBAT_MAIN, playerA, playerA, "Lander Token", 1);
castSpell(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "Time Stop");
setStopAt(6, PhaseStep.PRECOMBAT_MAIN);
setStrictChooseMode(true);
execute();
// the delayed trigger has been cleaned up since the "next turn" had no end step.
assertPermanentCount(playerA, "Lander Token", 1);
}
}

View file

@ -0,0 +1,64 @@
package org.mage.test.cards.single.ktk;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author Susucr
*/
public class MeanderingTowershellTest extends CardTestPlayerBase {
/**
* {@link mage.cards.m.MeanderingTowershell Meandering Towershell} {3}{G}{G}
* Creature Turtle
* Islandwalk (This creature cant be blocked as long as defending player controls an Island.)
* Whenever this creature attacks, exile it. Return it to the battlefield under your control tapped and attacking at the beginning of the declare attackers step on your next turn.
* 5/9
*/
private static final String towershell = "Meandering Towershell";
@Test
public void test_Simple() {
addCard(Zone.BATTLEFIELD, playerA, towershell);
attack(1, playerA, towershell, playerB);
checkPermanentCount("T3 First Main: no Towershell", 3, PhaseStep.PRECOMBAT_MAIN, playerA, playerA, "Meandering Powershell", 0);
checkLife("T3 First Main: playerB at 20 life", 3, PhaseStep.PRECOMBAT_MAIN, playerB, 20);
// Meandering Towershell comes back attacking.
setStrictChooseMode(true);
setStopAt(3, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertPermanentCount(playerA, towershell, 1);
assertLife(playerB, 20 - 5);
}
@Test
public void test_TimeStop() {
addCard(Zone.BATTLEFIELD, playerA, towershell);
addCard(Zone.BATTLEFIELD, playerA, "Island", 6);
addCard(Zone.HAND, playerA, "Time Stop");
attack(1, playerA, towershell, playerB);
checkPermanentCount("T3 First Main: no Towershell", 3, PhaseStep.PRECOMBAT_MAIN, playerA, playerA, "Meandering Powershell", 0);
checkLife("T3 First Main: playerB at 20 life", 3, PhaseStep.PRECOMBAT_MAIN, playerB, 20);
castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Time Stop");
// Meandering Towershell never comes back on future turns.
setStrictChooseMode(true);
setStopAt(6, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertExileCount(playerA, towershell, 1);
assertPermanentCount(playerA, towershell, 0);
assertLife(playerB, 20);
}
}

View file

@ -0,0 +1,83 @@
package mage.abilities.common.delayed;
import mage.abilities.DelayedTriggeredAbility;
import mage.abilities.effects.Effect;
import mage.constants.Duration;
import mage.game.Game;
import mage.game.events.GameEvent;
/**
* @author Susucr
*/
public class AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility extends DelayedTriggeredAbility {
private GameEvent.EventType stepEvent;
private int nextTurn = -1; // once the controller starts a new turn, register it to trigger that turn.
private boolean isActive = true;
public AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(Effect effect, GameEvent.EventType stepEvent) {
this(effect, stepEvent, false);
}
public AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(Effect effect, GameEvent.EventType stepEvent, boolean optional) {
super(effect, Duration.Custom, true, optional);
this.stepEvent = stepEvent;
this.setTriggerPhrase(generateTriggerPhrase());
}
private AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(final AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility ability) {
super(ability);
this.nextTurn = ability.nextTurn;
this.isActive = ability.isActive;
this.stepEvent = ability.stepEvent;
}
@Override
public AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility copy() {
return new AtTheBeginOfStepOfYourNextTurnDelayedTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == stepEvent
|| event.getType() == GameEvent.EventType.BEGIN_TURN;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (!isControlledBy(event.getPlayerId())) {
// not your turn.
return false;
}
int turn = game.getTurnNum();
switch (event.getType()) {
case BEGIN_TURN:
// We register the turn number at the start of your next turn.
// This is in order to not trigger if that turn ends without an end step.
if (this.nextTurn == -1) {
this.nextTurn = turn;
} else if (turn > this.nextTurn) {
this.isActive = false; // to have the delayed trigger being cleaned up
}
return false;
default:
return turn == this.nextTurn && event.getType() == stepEvent;
}
}
@Override
public boolean isInactive(Game game) {
return super.isInactive(game) || !isActive;
}
private String generateTriggerPhrase() {
switch (stepEvent) {
case END_TURN_STEP_PRE:
return "At the beginning of the end step on your next turn, ";
case DECLARE_ATTACKERS_STEP_PRE:
return "At the beginning of the declare attackers step on your next turn, ";
}
throw new IllegalArgumentException("stepEvent only supports steps events");
}
}

View file

@ -40,6 +40,7 @@ public class GameEvent implements Serializable {
PREVENT_DAMAGE, PREVENTED_DAMAGE, PREVENT_DAMAGE, PREVENTED_DAMAGE,
//Turn-based events //Turn-based events
PLAY_TURN, EXTRA_TURN, PLAY_TURN, EXTRA_TURN,
BEGIN_TURN, // event fired on actual begin of turn.
CHANGE_PHASE, PHASE_CHANGED, CHANGE_PHASE, PHASE_CHANGED,
CHANGE_STEP, STEP_CHANGED, CHANGE_STEP, STEP_CHANGED,
BEGINNING_PHASE, BEGINNING_PHASE_PRE, BEGINNING_PHASE_POST, // The normal beginning phase -- at the beginning of turn BEGINNING_PHASE, BEGINNING_PHASE_PRE, BEGINNING_PHASE_POST, // The normal beginning phase -- at the beginning of turn

View file

@ -547,6 +547,8 @@ public abstract class PlayerImpl implements Player, Serializable {
resetLandsPlayed(); resetLandsPlayed();
updateRange(game); updateRange(game);
game.getState().removeTurnStartEffect(game); game.getState().removeTurnStartEffect(game);
GameEvent event = new GameEvent(GameEvent.EventType.BEGIN_TURN, null, null, getId());
game.fireEvent(event);
} }
@Override @Override