Implementing "Start your engines!" mechanic (#13259)

* add initial speed handling

* finish speed implementation

* remove skip list

* add initial test

* add some more tests

* change speed initialization to state-based action

* add opponent speed check

* add control change test

* add check for speed 5
This commit is contained in:
Evan Kranzler 2025-02-01 13:49:47 -05:00 committed by GitHub
parent 655af10b2e
commit ef213b1bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 393 additions and 15 deletions

View file

@ -4,15 +4,11 @@ import mage.cards.ExpansionSet;
import mage.constants.Rarity;
import mage.constants.SetType;
import java.util.Arrays;
import java.util.List;
/**
* @author TheElk801
*/
public final class Aetherdrift extends ExpansionSet {
private static final List<String> unfinished = Arrays.asList("Aether Syphon", "Amonkhet Raceway", "Avishkar Raceway", "Burnout Bashtronaut", "Embalmed Ascendant", "Endrider Catalyzer", "Endrider Spikespitter", "Far Fortune, End Boss", "Gas Guzzler", "Gastal Raider", "Gastal Thrillseeker", "Glitch Ghost Surveyor", "Goblin Surveyor", "Hazoret, Godseeker", "Hour of Victory", "Howlsquad Heavy", "Kickoff Celebrations", "Leonin Surveyor", "Lightwheel Enhancements", "Loxodon Surveyor", "Mendicant Core, Guidelight", "Momentum Breaker", "Muraganda Raceway", "Mutant Surveyor", "Nesting Bot", "Outpace Oblivion", "Perilous Snare", "Point the Way", "Pride of the Road", "Racers' Scoreboard", "Risen Necroregent", "Samut, the Driving Force", "Slick Imitator", "Starting Column", "Streaking Oilgorger", "Swiftwing Assailant", "The Speed Demon", "Vnwxt, Verbose Host", "Walking Sarcophagus", "Zahur, Glory's Past");
private static final Aetherdrift instance = new Aetherdrift();
public static Aetherdrift getInstance() {
@ -211,7 +207,5 @@ public final class Aetherdrift extends ExpansionSet {
cards.add(new SetCardInfo("Wreck Remover", 247, Rarity.COMMON, mage.cards.w.WreckRemover.class));
cards.add(new SetCardInfo("Wreckage Wickerfolk", 110, Rarity.COMMON, mage.cards.w.WreckageWickerfolk.class));
cards.add(new SetCardInfo("Wretched Doll", 111, Rarity.UNCOMMON, mage.cards.w.WretchedDoll.class));
cards.removeIf(setCardInfo -> unfinished.contains(setCardInfo.getName()));
}
}

View file

@ -0,0 +1,202 @@
package org.mage.test.cards.designations;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import mage.players.Player;
import org.junit.Assert;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author TheElk801
*/
public class StartYourEnginesTest extends CardTestPlayerBase {
private static final String sarcophagus = "Walking Sarcophagus";
private void assertSpeed(Player player, int speed) {
Assert.assertEquals(player.getName() + " speed should be " + speed, speed, player.getSpeed());
}
@Test
public void testRegular() {
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 0);
assertSpeed(playerB, 0);
}
@Test
public void testSpeed1() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 1);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2, 1);
}
private static final String goblet = "Onyx Goblet";
@Test
public void testSpeed2() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerA, goblet);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 2);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2, 1);
}
@Test
public void testSpeed1OppTurn() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerA, goblet);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
activateAbility(2, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
setStrictChooseMode(true);
setStopAt(2, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 1);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2, 1);
}
@Test
public void testSpeed3() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerA, goblet);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
setStrictChooseMode(true);
setStopAt(3, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 3);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2, 1);
}
@Test
public void testSpeed4() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerA, goblet);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(5, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
setStrictChooseMode(true);
setStopAt(5, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 4);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2 + 1, 1 + 2);
}
@Test
public void testSpeed5() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerA, goblet);
addCard(Zone.HAND, playerA, sarcophagus);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(5, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
activateAbility(7, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Target", playerB);
setStrictChooseMode(true);
setStopAt(7, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 4);
assertSpeed(playerB, 0);
assertPowerToughness(playerA, sarcophagus, 2 + 1, 1 + 2);
}
private static final String surveyor = "Loxodon Surveyor";
@Test
public void testSpeed4Graveyard() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 3);
addCard(Zone.GRAVEYARD, playerA, surveyor);
runCode("Increase player speed", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
player.initSpeed(game);
player.increaseSpeed(game);
player.increaseSpeed(game);
player.increaseSpeed(game);
});
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{3}");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 4);
assertSpeed(playerB, 0);
assertGraveyardCount(playerA, surveyor, 0);
assertExileCount(playerA, surveyor, 1);
}
private static final String mindControl = "Mind Control";
@Test
public void testSpeedChangeControl() {
addCard(Zone.BATTLEFIELD, playerA, "Wastes", 2);
addCard(Zone.BATTLEFIELD, playerB, "Island", 5);
addCard(Zone.HAND, playerA, sarcophagus);
addCard(Zone.HAND, playerB, mindControl);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, sarcophagus);
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, mindControl, sarcophagus);
setStrictChooseMode(true);
setStopAt(2, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertSpeed(playerA, 1);
assertSpeed(playerB, 1);
assertPowerToughness(playerB, sarcophagus, 2, 1);
}
}

View file

@ -3977,6 +3977,21 @@ public class TestPlayer implements Player {
return computerPlayer.isDrawsOnOpponentsTurn();
}
@Override
public int getSpeed() {
return computerPlayer.getSpeed();
}
@Override
public void initSpeed(Game game) {
computerPlayer.initSpeed(game);
}
@Override
public void increaseSpeed(Game game) {
computerPlayer.increaseSpeed(game);
}
@Override
public void setPayManaMode(boolean payManaMode) {
computerPlayer.setPayManaMode(payManaMode);

View file

@ -38,9 +38,21 @@ class MaxSpeedAbilityEffect extends ContinuousEffectImpl {
private final Ability ability;
private static Duration getDuration(Ability ability) {
switch (ability.getZone()) {
case BATTLEFIELD:
return Duration.WhileOnBattlefield;
case GRAVEYARD:
return Duration.WhileInGraveyard;
default:
return Duration.Custom;
}
}
MaxSpeedAbilityEffect(Ability ability) {
super(Duration.Custom, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
super(getDuration(ability), Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.ability = ability;
this.ability.setRuleVisible(false);
}
private MaxSpeedAbilityEffect(final MaxSpeedAbilityEffect effect) {

View file

@ -4,6 +4,9 @@ import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.game.Game;
import mage.players.Player;
import java.util.Optional;
/**
* @author TheElk801
@ -13,8 +16,10 @@ public enum ControllerSpeedCount implements DynamicValue {
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
// TODO: Implement this
return 0;
return Optional
.ofNullable(game.getPlayer(sourceAbility.getControllerId()))
.map(Player::getSpeed)
.orElse(0);
}
@Override

View file

@ -1,17 +1,21 @@
package mage.abilities.keyword;
import mage.abilities.StaticAbility;
import mage.abilities.dynamicvalue.common.ControllerSpeedCount;
import mage.abilities.hint.Hint;
import mage.abilities.hint.ValueHint;
import mage.constants.Zone;
/**
* TODO: Implement this
*
* @author TheElk801
*/
public class StartYourEnginesAbility extends StaticAbility {
private static final Hint hint = new ValueHint("Your current speed", ControllerSpeedCount.instance);
public StartYourEnginesAbility() {
super(Zone.BATTLEFIELD, null);
this.addHint(hint);
}
private StartYourEnginesAbility(final StartYourEnginesAbility ability) {
@ -25,6 +29,6 @@ public class StartYourEnginesAbility extends StaticAbility {
@Override
public String getRule() {
return "Start your engines!";
return "start your engines!";
}
}

View file

@ -6,8 +6,8 @@ package mage.designations;
public enum DesignationType {
THE_MONARCH("The Monarch"), // global
CITYS_BLESSING("City's Blessing"), // per player
THE_INITIATIVE("The Initiative"); // global
THE_INITIATIVE("The Initiative"), // global
SPEED("Speed"); // per player
private final String text;
DesignationType(String text) {
@ -18,5 +18,4 @@ public enum DesignationType {
public String toString() {
return text;
}
}

View file

@ -0,0 +1,108 @@
package mage.designations;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.OneShotEffect;
import mage.constants.Outcome;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import java.util.Optional;
/**
* @author TheElk801
*/
public class Speed extends Designation {
public Speed() {
super(DesignationType.SPEED);
addAbility(new SpeedTriggeredAbility());
}
private Speed(final Speed card) {
super(card);
}
@Override
public Speed copy() {
return new Speed(this);
}
}
class SpeedTriggeredAbility extends TriggeredAbilityImpl {
SpeedTriggeredAbility() {
super(Zone.ALL, new SpeedEffect());
setTriggersLimitEachTurn(1);
}
private SpeedTriggeredAbility(final SpeedTriggeredAbility ability) {
super(ability);
}
@Override
public SpeedTriggeredAbility copy() {
return new SpeedTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.LOST_LIFE_BATCH_FOR_ONE_PLAYER;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
return game.isActivePlayer(getControllerId())
&& game
.getOpponents(getControllerId())
.contains(event.getTargetId());
}
@Override
public boolean checkInterveningIfClause(Game game) {
return Optional
.ofNullable(getControllerId())
.map(game::getPlayer)
.map(Player::getSpeed)
.map(x -> x < 4)
.orElse(false);
}
@Override
public boolean isInUseableZone(Game game, MageObject sourceObject, GameEvent event) {
return true;
}
@Override
public String getRule() {
return "Whenever one or more opponents lose life during your turn, if your speed is less than 4, " +
"increase your speed by 1. This ability triggers only once each turn.";
}
}
class SpeedEffect extends OneShotEffect {
SpeedEffect() {
super(Outcome.Benefit);
}
private SpeedEffect(final SpeedEffect effect) {
super(effect);
}
@Override
public SpeedEffect copy() {
return new SpeedEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Optional.ofNullable(source.getControllerId())
.map(game::getPlayer)
.ifPresent(player -> player.increaseSpeed(game));
return true;
}
}

View file

@ -2862,6 +2862,11 @@ public abstract class GameImpl implements Game {
}
}
}
if (perm.getAbilities(this).containsClass(StartYourEnginesAbility.class)) {
Optional.ofNullable(perm.getControllerId())
.map(this::getPlayer)
.ifPresent(player -> player.initSpeed(this));
}
}
//201300713 - 704.5k
// If a player controls two or more legendary permanents with the same name, that player

View file

@ -216,6 +216,12 @@ public interface Player extends MageItem, Copyable<Player> {
boolean isDrawsOnOpponentsTurn();
int getSpeed();
void initSpeed(Game game);
void increaseSpeed(Game game);
/**
* Returns alternative casting costs a player can cast spells for
*

View file

@ -27,6 +27,7 @@ import mage.counters.CounterType;
import mage.counters.Counters;
import mage.designations.Designation;
import mage.designations.DesignationType;
import mage.designations.Speed;
import mage.filter.FilterCard;
import mage.filter.FilterMana;
import mage.filter.FilterPermanent;
@ -153,6 +154,7 @@ public abstract class PlayerImpl implements Player, Serializable {
protected boolean canPlotFromTopOfLibrary = false;
protected boolean drawsFromBottom = false;
protected boolean drawsOnOpponentsTurn = false;
protected int speed = 0;
protected FilterPermanent sacrificeCostFilter;
protected List<AlternativeSourceCosts> alternativeSourceCosts = new ArrayList<>();
@ -252,6 +254,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.canPlotFromTopOfLibrary = player.canPlotFromTopOfLibrary;
this.drawsFromBottom = player.drawsFromBottom;
this.drawsOnOpponentsTurn = player.drawsOnOpponentsTurn;
this.speed = player.speed;
this.attachments.addAll(player.attachments);
@ -367,6 +370,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.drawsFromBottom = player.isDrawsFromBottom();
this.drawsOnOpponentsTurn = player.isDrawsOnOpponentsTurn();
this.alternativeSourceCosts = CardUtil.deepCopyObject(((PlayerImpl) player).alternativeSourceCosts);
this.speed = player.getSpeed();
this.topCardRevealed = player.isTopCardRevealed();
@ -480,6 +484,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.canPlotFromTopOfLibrary = false;
this.drawsFromBottom = false;
this.drawsOnOpponentsTurn = false;
this.speed = 0;
this.sacrificeCostFilter = null;
this.alternativeSourceCosts.clear();
@ -4674,6 +4679,29 @@ public abstract class PlayerImpl implements Player, Serializable {
return drawsOnOpponentsTurn;
}
@Override
public int getSpeed() {
return speed;
}
@Override
public void initSpeed(Game game) {
if (speed > 0) {
return;
}
speed = 1;
game.getState().addDesignation(new Speed(), game, getId());
game.informPlayers(this.getLogName() + "'s speed is now 1.");
}
@Override
public void increaseSpeed(Game game) {
if (speed < 4) {
speed++;
game.informPlayers(this.getLogName() + "'s speed has increased to " + speed);
}
}
@Override
public boolean autoLoseGame() {
return false;