fix [OTJ] Fortune, Loyal Steed — DelayedAbility's zcc was wrong when started from another trigger (#12154)

This commit is contained in:
Susucre 2024-05-04 19:26:11 +02:00 committed by GitHub
parent fa728eafb1
commit d8959f1588
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 312 additions and 73 deletions

View file

@ -1,9 +1,11 @@
package org.mage.test.cards.abilities.keywords;
import mage.MageObjectReference;
import mage.abilities.keyword.MenaceAbility;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import mage.game.permanent.Permanent;
import mage.watchers.common.SaddledMountWatcher;
import org.junit.Assert;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
@ -20,7 +22,7 @@ public class SaddleTest extends CardTestPlayerBase {
Permanent permanent = getPermanent(name);
Assert.assertEquals(
name + " should " + (saddled ? "" : "not ") + "be saddled",
saddled, permanent.isSaddled()
saddled, SaddledMountWatcher.hasBeenSaddledThisTurn(new MageObjectReference(permanent.getId(), currentGame), currentGame)
);
}
@ -78,13 +80,10 @@ public class SaddleTest extends CardTestPlayerBase {
setChoice(playerA, bear);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.PRECOMBAT_MAIN);
execute();
attack(1, playerA, possum, playerB);
setChoice(playerA, bear);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();

View file

@ -0,0 +1,223 @@
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 FortuneLoyalSteedTest extends CardTestPlayerBase {
/**
* {@link mage.cards.f.FortuneLoyalSteed Fortune, Loyal Steed} {W}
* Legendary Creature Beast Mount
* When Fortune, Loyal Steed enters the battlefield, scry 2.
* Whenever Fortune attacks while saddled, at end of combat, exile it and up to one creature that saddled it this turn, then return those cards to the battlefield under their owners control.
* Saddle 1
* 2/4
*/
private static final String fortune = "Fortune, Loyal Steed";
@Test
public void test_Saddling() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
attack(1, playerA, fortune, playerB);
setChoice(playerA, "Lone Missionary"); // Choose to blink Lone Missionary
setChoice(playerA, "When {this} enters the battlefield, you gain 4 life"); // stack triggers
addTarget(playerA, "Taiga"); // for the scry trigger
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20 + 4);
assertHandCount(playerA, 0);
assertTapped(fortune, false);
assertTapped("Lone Missionary", false);
}
@Test
public void test_Saddling_FortuneDies() {
setStrictChooseMode(true);
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerB, "Ankle Biter"); // 1/1 Deathtouch
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
attack(1, playerA, fortune, playerB);
block(1, playerB, "Ankle Biter", fortune);
setChoice(playerA, "Lone Missionary"); // Choose to blink Lone Missionary
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20 + 4);
assertGraveyardCount(playerA, fortune, 1);
assertTapped("Lone Missionary", false);
}
@Test
public void test_Saddling_FortuneBlinks() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerA, "Plains");
addCard(Zone.HAND, playerA, "Ephemerate");
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
attack(1, playerA, fortune, playerB);
castSpell(1, PhaseStep.DECLARE_ATTACKERS, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
setChoice(playerA, "Lone Missionary"); // Choose to blink Lone Missionary
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20 + 4);
assertTapped(fortune, false);
assertTapped("Lone Missionary", false);
}
@Test
public void test_Saddling_FortuneBlinksBefore() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Fervor"); // To give haste
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerA, "Plains", 2);
addCard(Zone.HAND, playerA, "Ephemerate", 2);
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
// Just to check zcc
castSpell(1, PhaseStep.UPKEEP, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
attack(1, playerA, fortune, playerB);
castSpell(1, PhaseStep.DECLARE_ATTACKERS, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
setChoice(playerA, "Lone Missionary"); // Choose to blink Lone Missionary
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20 + 4);
assertTapped(fortune, false);
assertTapped("Lone Missionary", false);
}
@Test
public void test_Saddling_FortuneBlinksAfterSaddlingBeforeCombat() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Fervor"); // To give haste
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerA, "Plains");
addCard(Zone.HAND, playerA, "Ephemerate");
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA);
// That would make Fortune no longer saddled, so not trigger at beginning of combat
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
attack(1, playerA, fortune, playerB);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20);
assertTapped(fortune, true);
assertTapped("Lone Missionary", true);
}
@Test
public void test_Saddling_FortuneBlinksInResponseOfSaddling() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Fervor"); // To give haste
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerA, "Plains");
addCard(Zone.HAND, playerA, "Ephemerate");
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
// That would make Fortune no longer saddled, so not trigger at beginning of combat
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
attack(1, playerA, fortune, playerB);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20);
assertTapped(fortune, true);
assertTapped("Lone Missionary", true);
}
@Test
public void test_Saddling_FortuneBlinksTwice() {
setStrictChooseMode(true);
skipInitShuffling();
addCard(Zone.BATTLEFIELD, playerA, fortune);
addCard(Zone.BATTLEFIELD, playerA, "Lone Missionary"); // ETB, gain 4 life
addCard(Zone.BATTLEFIELD, playerA, "Plains", 2);
addCard(Zone.HAND, playerA, "Ephemerate", 2);
addCard(Zone.LIBRARY, playerA, "Taiga", 2);
setChoice(playerA, "Lone Missionary"); // Saddling choice
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Saddle");
attack(1, playerA, fortune, playerB);
castSpell(1, PhaseStep.DECLARE_ATTACKERS, playerA, "Ephemerate", fortune, true);
addTarget(playerA, "Taiga"); // for the scry trigger
castSpell(1, PhaseStep.DECLARE_ATTACKERS, playerA, "Ephemerate", fortune);
addTarget(playerA, "Taiga"); // for the scry trigger
setChoice(playerA, "Lone Missionary"); // Choose to blink Lone Missionary
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerA, 20 + 4);
assertTapped(fortune, false);
assertTapped("Lone Missionary", false);
}
}

View file

@ -1,9 +1,10 @@
package mage.abilities.common;
import mage.MageObjectReference;
import mage.abilities.effects.Effect;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.permanent.Permanent;
import mage.watchers.common.SaddledMountWatcher;
import java.util.Optional;
@ -15,6 +16,7 @@ public class AttacksWhileSaddledTriggeredAbility extends AttacksTriggeredAbility
public AttacksWhileSaddledTriggeredAbility(Effect effect) {
super(effect);
this.setTriggerPhrase("Whenever {this} attacks while saddled, ");
this.addWatcher(new SaddledMountWatcher());
}
private AttacksWhileSaddledTriggeredAbility(final AttacksWhileSaddledTriggeredAbility ability) {
@ -31,7 +33,7 @@ public class AttacksWhileSaddledTriggeredAbility extends AttacksTriggeredAbility
return super.checkTrigger(event, game)
&& Optional
.ofNullable(getSourcePermanentIfItStillExists(game))
.map(Permanent::isSaddled)
.map(p -> SaddledMountWatcher.hasBeenSaddledThisTurn(new MageObjectReference(p, game), game))
.orElse(false);
}
}

View file

@ -1,9 +1,10 @@
package mage.abilities.condition.common;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.watchers.common.SaddledMountWatcher;
import java.util.Optional;
@ -17,7 +18,7 @@ public enum SaddledCondition implements Condition {
public boolean apply(Game game, Ability source) {
return Optional
.ofNullable(source.getSourcePermanentIfItStillExists(game))
.map(Permanent::isSaddled)
.map(p -> SaddledMountWatcher.hasBeenSaddledThisTurn(new MageObjectReference(p, game), game))
.orElse(false);
}
}

View file

@ -7,11 +7,12 @@ import mage.abilities.common.SimpleActivatedAbility;
import mage.abilities.condition.common.SaddledCondition;
import mage.abilities.costs.Cost;
import mage.abilities.costs.CostImpl;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.hint.ConditionHint;
import mage.abilities.hint.Hint;
import mage.abilities.hint.HintUtils;
import mage.constants.*;
import mage.constants.Outcome;
import mage.constants.TimingRule;
import mage.filter.common.FilterControlledCreaturePermanent;
import mage.filter.predicate.mageobject.AnotherPredicate;
import mage.filter.predicate.permanent.TappedPredicate;
@ -24,11 +25,10 @@ import mage.watchers.common.SaddledMountWatcher;
import java.awt.*;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* @author TheElk801
* @author TheElk801, Susucr
*/
public class SaddleAbility extends SimpleActivatedAbility {
@ -36,7 +36,7 @@ public class SaddleAbility extends SimpleActivatedAbility {
private static final Hint hint = new ConditionHint(SaddledCondition.instance, "This permanent is saddled");
public SaddleAbility(int value) {
super(new SaddleEffect(), new SaddleCost(value));
super(new SaddleEventEffect(), new SaddleCost(value));
this.value = value;
this.addHint(hint);
this.setTiming(TimingRule.SORCERY);
@ -60,42 +60,36 @@ public class SaddleAbility extends SimpleActivatedAbility {
}
}
class SaddleEffect extends ContinuousEffectImpl {
class SaddleEventEffect extends OneShotEffect {
SaddleEffect() {
super(Duration.EndOfTurn, Layer.RulesEffects, SubLayer.NA, Outcome.Benefit);
SaddleEventEffect() {
super(Outcome.Benefit);
}
private SaddleEffect(final SaddleEffect effect) {
private SaddleEventEffect(final SaddleEventEffect effect) {
super(effect);
}
@Override
public SaddleEffect copy() {
return new SaddleEffect(this);
public SaddleEventEffect copy() {
return new SaddleEventEffect(this);
}
@Override
public void init(Ability source, Game game) {
super.init(source, game);
public boolean apply(Game game, Ability source) {
if (source.getSourcePermanentIfItStillExists(game) != null) {
game.fireEvent(GameEvent.getEvent(
GameEvent.EventType.MOUNT_SADDLED,
source.getSourceId(),
source, source.getControllerId()
));
}
@Override
public boolean apply(Game game, Ability source) {
Optional.ofNullable(source.getSourcePermanentIfItStillExists(game))
.ifPresent(permanent -> permanent.setSaddled(true));
return true;
}
}
class SaddleCost extends CostImpl {
private static final FilterControlledCreaturePermanent filter
= new FilterControlledCreaturePermanent("another untapped creature you control");

View file

@ -1,12 +1,13 @@
package mage.abilities.keyword;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.ActivatedAbilityImpl;
import mage.abilities.DelayedTriggeredAbility;
import mage.abilities.costs.Cost;
import mage.abilities.effects.ReplacementEffectImpl;
import mage.abilities.effects.common.CreateDelayedTriggeredAbilityEffect;
import mage.abilities.effects.common.ExileSourceEffect;
import mage.abilities.effects.common.ExileTargetEffect;
import mage.abilities.effects.common.ReturnSourceFromGraveyardToBattlefieldEffect;
import mage.constants.Duration;
import mage.constants.Outcome;
@ -15,6 +16,8 @@ import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.events.ZoneChangeEvent;
import mage.game.permanent.Permanent;
import mage.target.targetpointer.FixedTarget;
/**
* @author BetaSteward_at_googlemail.com
@ -60,7 +63,7 @@ public class UnearthAbility extends ActivatedAbilityImpl {
class UnearthDelayedTriggeredAbility extends DelayedTriggeredAbility {
public UnearthDelayedTriggeredAbility() {
super(new ExileSourceEffect());
super(new ExileTargetEffect());
}
protected UnearthDelayedTriggeredAbility(final UnearthDelayedTriggeredAbility ability) {
@ -79,7 +82,19 @@ class UnearthDelayedTriggeredAbility extends DelayedTriggeredAbility {
@Override
public boolean checkTrigger(GameEvent event, Game game) {
return event.getPlayerId().equals(this.controllerId);
if (!event.getPlayerId().equals(this.controllerId)) {
return false;
}
// The delayed trigger source is the card in the graveyard.
// So we need to exile the zcc + 1 permanent
MageObjectReference object = new MageObjectReference(getSourceId(), getSourceObjectZoneChangeCounter() + 1, game);
Permanent permanent = object.getPermanent(game);
if (permanent == null || !permanent.isPhasedIn()) {
// Triggers, but do nothing.
return true;
}
getEffects().setTargetPointer(new FixedTarget(permanent, game));
return true;
}
@Override

View file

@ -1,5 +1,6 @@
package mage.filter.predicate.permanent;
import mage.MageObjectReference;
import mage.filter.predicate.ObjectSourcePlayer;
import mage.filter.predicate.ObjectSourcePlayerPredicate;
import mage.game.Game;
@ -17,7 +18,7 @@ public enum SaddledSourceThisTurnPredicate implements ObjectSourcePlayerPredicat
@Override
public boolean apply(ObjectSourcePlayer<Permanent> input, Game game) {
return SaddledMountWatcher.checkIfSaddledThisTurn(
input.getObject(), input.getSource().getSourcePermanentOrLKI(game), game
input.getObject(), new MageObjectReference(input.getSourceId(), input.getSource().getSourceObjectZoneChangeCounter(), game), game
);
}

View file

@ -2162,11 +2162,27 @@ public abstract class GameImpl implements Game {
delayedAbility.setSourceId(source.getSourceId());
delayedAbility.setControllerId(source.getControllerId());
}
// return addDelayedTriggeredAbility(delayedAbility);
DelayedTriggeredAbility newAbility = delayedAbility.copy();
newAbility.newId();
if (source != null) {
newAbility.setSourceObjectZoneChangeCounter(getState().getZoneChangeCounter(source.getSourceId()));
// Relevant ruling:
// 603.7e If an activated or triggered ability creates a delayed triggered ability,
// the source of that delayed triggered ability is the same as the source of that other ability.
// The controller of that delayed triggered ability is the player who controlled that other ability as it resolved.
// 603.7f If a static ability generates a replacement effect which causes a delayed triggered ability to be created,
// the source of that delayed triggered ability is the object with that static ability.
// The controller of that delayed triggered ability is the same as the controller of that object at the time
// the replacement effect was applied.
//
// There are two possibility for the zcc:
// 1/ the source is an Ability with a valid (not 0) zcc, and we must use the same.
int zcc = source.getSourceObjectZoneChangeCounter();
if (zcc == 0) {
// 2/ the source has not a valid zcc (it is most likely a StaticAbility instantiated at beginning of game)
// we use the source objects's zcc
zcc = getState().getZoneChangeCounter(source.getSourceId());
}
newAbility.setSourceObjectZoneChangeCounter(zcc);
newAbility.setSourcePermanentTransformCount(this);
}
newAbility.init(this);

View file

@ -78,10 +78,6 @@ public interface Permanent extends Card, Controllable {
void setSuspected(boolean value, Game game, Ability source);
boolean isSaddled();
void setSaddled(boolean value);
boolean isPrototyped();
void setPrototyped(boolean value);

View file

@ -72,7 +72,6 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
protected boolean monstrous;
protected boolean renowned;
protected boolean suspected;
protected boolean saddled;
protected boolean manifested = false;
protected boolean morphed = false;
protected boolean disguised = false;
@ -176,7 +175,6 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
this.monstrous = permanent.monstrous;
this.renowned = permanent.renowned;
this.suspected = permanent.suspected;
this.saddled = permanent.saddled;
this.ringBearerFlag = permanent.ringBearerFlag;
this.classLevel = permanent.classLevel;
this.goadingPlayers.addAll(permanent.goadingPlayers);
@ -239,7 +237,6 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
this.maxBlockedBy = 0;
this.copy = false;
this.goadingPlayers.clear();
this.saddled = false;
this.loyaltyActivationsAvailable = 1;
this.legendRuleApplies = true;
this.canBeSacrificed = true;
@ -1730,16 +1727,6 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
}
}
@Override
public boolean isSaddled() {
return saddled;
}
@Override
public void setSaddled(boolean saddled) {
this.saddled = saddled;
}
// Used as key for the ring bearer info.
private static final String ringbearerInfoKey = "IS_RINGBEARER";

View file

@ -10,22 +10,30 @@ import mage.watchers.Watcher;
import java.util.*;
/**
* @author TheElk801
* @author TheElk801, Susucr
*/
public class SaddledMountWatcher extends Watcher {
// key: the mount, value: set of creatures which saddled
// key: the mount mor, value: set of creatures which saddled (on Saddle Cost payment)
private final Map<MageObjectReference, Set<MageObjectReference>> saddleMap = new HashMap<>();
// set of mount mor actually saddled (on Saddle Ability resolution)
private final Set<MageObjectReference> saddledSet = new HashSet<>();
public SaddledMountWatcher() {
super(WatcherScope.GAME);
}
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.SADDLED_MOUNT) {
switch (event.getType()) {
case SADDLED_MOUNT:
saddleMap.computeIfAbsent(new MageObjectReference(event.getSourceId(), game), x -> new HashSet<>())
.add(new MageObjectReference(event.getTargetId(), game));
break;
case MOUNT_SADDLED:
saddledSet.add(new MageObjectReference(event.getSourceId(), game));
break;
}
}
@ -33,24 +41,21 @@ public class SaddledMountWatcher extends Watcher {
public void reset() {
super.reset();
saddleMap.clear();
saddledSet.clear();
}
public static boolean checkIfSaddledThisTurn(Permanent saddler, Permanent mount, Game game) {
return game
public static boolean hasBeenSaddledThisTurn(MageObjectReference mountMOR, Game game) {
SaddledMountWatcher watcher = game.getState().getWatcher(SaddledMountWatcher.class);
return watcher != null && watcher.saddledSet.contains(mountMOR);
}
public static boolean checkIfSaddledThisTurn(Permanent saddler, MageObjectReference mountMOR, Game game) {
return hasBeenSaddledThisTurn(mountMOR, game) && game
.getState()
.getWatcher(SaddledMountWatcher.class)
.saddleMap
.getOrDefault(new MageObjectReference(mount, game), Collections.emptySet())
.getOrDefault(mountMOR, Collections.emptySet())
.stream()
.anyMatch(mor -> mor.refersTo(saddler, game));
}
public static int getSaddleCount(Permanent vehicle, Game game) {
return game
.getState()
.getWatcher(SaddledMountWatcher.class)
.saddleMap
.getOrDefault(new MageObjectReference(vehicle, game), Collections.emptySet())
.size();
}
}