Fix bugs associated with Foretell ability (#13879)

* add foretell tests

* rework foretell events and watcher

* refactor: not static inner classes

* refactor: move becomes foretold code from Ethereal Valkyrie to ForetellAbility

* add watcher for edge cases

* fix Ethereal Valkyrie to not leak face down card name in log

* fix some access modifiers

* refactor: make copy-pasted code common
This commit is contained in:
xenohedron 2025-07-27 00:26:30 -04:00 committed by GitHub
parent 4a74353b0c
commit e8cd6dbdad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 784 additions and 601 deletions

View file

@ -4,18 +4,14 @@ import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.CastSecondSpellTriggeredAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.common.combat.GoadTargetEffect;
import mage.abilities.keyword.ForetellAbility;
import mage.cards.*;
import mage.constants.*;
import mage.filter.common.FilterNonlandCard;
import mage.filter.predicate.Predicates;
import mage.filter.predicate.mageobject.AbilityPredicate;
import mage.game.Game;
import mage.players.Player;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.target.common.TargetOpponentsCreaturePermanent;
import mage.util.CardUtil;
import java.util.UUID;
@ -34,7 +30,7 @@ public final class BohnBeguilingBalladeer extends CardImpl {
this.toughness = new MageInt(3);
// Each nonland card in your hand without foretell has foretell. Its foretell cost is equal to its mana cost reduced by {2}.
this.addAbility(new SimpleStaticAbility(new EdginLarcenousLutenistEffect()));
this.addAbility(new SimpleStaticAbility(ForetellAbility.makeAddForetellEffect()));
// Whenever you cast your second spell each turn, goad target creature an opponent controls.
Ability ability = new CastSecondSpellTriggeredAbility(new GoadTargetEffect());
@ -51,69 +47,3 @@ public final class BohnBeguilingBalladeer extends CardImpl {
return new BohnBeguilingBalladeer(this);
}
}
class EdginLarcenousLutenistEffect extends ContinuousEffectImpl {
private static final FilterNonlandCard filter = new FilterNonlandCard();
static {
filter.add(Predicates.not(new AbilityPredicate(ForetellAbility.class)));
}
EdginLarcenousLutenistEffect() {
super(Duration.WhileOnBattlefield, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.staticText = "Each nonland card in your hand without foretell has foretell. Its foretell cost is equal to its mana cost reduced by {2}";
}
private EdginLarcenousLutenistEffect(final EdginLarcenousLutenistEffect effect) {
super(effect);
}
@Override
public EdginLarcenousLutenistEffect copy() {
return new EdginLarcenousLutenistEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller == null) {
return false;
}
for (Card card : controller.getHand().getCards(filter, game)) {
ForetellAbility foretellAbility = null;
if (card instanceof SplitCard) {
String leftHalfCost = CardUtil.reduceCost(((SplitCard) card).getLeftHalfCard().getManaCost(), 2).getText();
String rightHalfCost = CardUtil.reduceCost(((SplitCard) card).getRightHalfCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
} else if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
// If front side of MDFC is land, do nothing as Dream Devourer does not apply to lands
// MDFC cards in hand are considered lands if front side is land
if (!leftHalfCard.isLand(game)) {
String leftHalfCost = CardUtil.reduceCost(leftHalfCard.getManaCost(), 2).getText();
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
if (rightHalfCard.isLand(game)) {
foretellAbility = new ForetellAbility(card, leftHalfCost);
} else {
String rightHalfCost = CardUtil.reduceCost(rightHalfCard.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
}
}
} else if (card instanceof CardWithSpellOption) {
String creatureCost = CardUtil.reduceCost(card.getMainCard().getManaCost(), 2).getText();
String spellCost = CardUtil.reduceCost(((CardWithSpellOption) card).getSpellCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, creatureCost, spellCost);
} else {
String costText = CardUtil.reduceCost(card.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, costText);
}
if (foretellAbility != null) {
foretellAbility.setSourceId(card.getId());
foretellAbility.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, foretellAbility);
}
}
return true;
}
}

View file

@ -1,27 +1,17 @@
package mage.cards.d;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.ForetellSourceControllerTriggeredAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.common.continuous.BoostSourceEffect;
import mage.abilities.keyword.ForetellAbility;
import mage.cards.*;
import mage.constants.SubType;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Duration;
import mage.constants.Layer;
import mage.constants.Outcome;
import mage.constants.SubLayer;
import mage.constants.Zone;
import mage.filter.common.FilterNonlandCard;
import mage.filter.predicate.Predicates;
import mage.filter.predicate.mageobject.AbilityPredicate;
import mage.game.Game;
import mage.players.Player;
import mage.util.CardUtil;
import mage.constants.SubType;
import java.util.UUID;
/**
*
@ -38,7 +28,7 @@ public final class DreamDevourer extends CardImpl {
this.toughness = new MageInt(3);
// Each nonland card in your hand without foretell has foretell. Its foretell cost is equal to its mana cost reduced by 2.
this.addAbility(new SimpleStaticAbility(new DreamDevourerAddAbilityEffect()));
this.addAbility(new SimpleStaticAbility(ForetellAbility.makeAddForetellEffect()));
// Whenever you foretell a card, Dream Devourer gets +2/+0 until end of turn.
this.addAbility(new ForetellSourceControllerTriggeredAbility(new BoostSourceEffect(2, 0, Duration.EndOfTurn)));
@ -54,69 +44,3 @@ public final class DreamDevourer extends CardImpl {
return new DreamDevourer(this);
}
}
class DreamDevourerAddAbilityEffect extends ContinuousEffectImpl {
private static final FilterNonlandCard filter = new FilterNonlandCard();
static {
filter.add(Predicates.not(new AbilityPredicate(ForetellAbility.class)));
}
DreamDevourerAddAbilityEffect() {
super(Duration.WhileOnBattlefield, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.staticText = "Each nonland card in your hand without foretell has foretell. Its foretell cost is equal to its mana cost reduced by {2}";
}
private DreamDevourerAddAbilityEffect(final DreamDevourerAddAbilityEffect effect) {
super(effect);
}
@Override
public DreamDevourerAddAbilityEffect copy() {
return new DreamDevourerAddAbilityEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller == null) {
return false;
}
for (Card card : controller.getHand().getCards(filter, game)) {
ForetellAbility foretellAbility = null;
if (card instanceof SplitCard) {
String leftHalfCost = CardUtil.reduceCost(((SplitCard) card).getLeftHalfCard().getManaCost(), 2).getText();
String rightHalfCost = CardUtil.reduceCost(((SplitCard) card).getRightHalfCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
} else if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
// If front side of MDFC is land, do nothing as Dream Devourer does not apply to lands
// MDFC cards in hand are considered lands if front side is land
if (!leftHalfCard.isLand(game)) {
String leftHalfCost = CardUtil.reduceCost(leftHalfCard.getManaCost(), 2).getText();
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
if (rightHalfCard.isLand(game)) {
foretellAbility = new ForetellAbility(card, leftHalfCost);
} else {
String rightHalfCost = CardUtil.reduceCost(rightHalfCard.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
}
}
} else if (card instanceof CardWithSpellOption) {
String creatureCost = CardUtil.reduceCost(card.getMainCard().getManaCost(), 2).getText();
String spellCost = CardUtil.reduceCost(((CardWithSpellOption) card).getSpellCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, creatureCost, spellCost);
} else {
String costText = CardUtil.reduceCost(card.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, costText);
}
if (foretellAbility != null) {
foretellAbility.setSourceId(card.getId());
foretellAbility.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, foretellAbility);
}
}
return true;
}
}

View file

@ -1,23 +1,22 @@
package mage.cards.e;
import mage.MageInt;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.common.EntersBattlefieldOrAttacksSourceTriggeredAbility;
import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.keyword.FlyingAbility;
import mage.abilities.keyword.ForetellAbility;
import mage.cards.*;
import mage.cards.Card;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.filter.FilterCard;
import mage.filter.StaticFilters;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.target.common.TargetCardInHand;
import mage.util.CardUtil;
import mage.watchers.common.ForetoldWatcher;
import java.util.UUID;
@ -38,7 +37,7 @@ public final class EtherealValkyrie extends CardImpl {
this.addAbility(FlyingAbility.getInstance());
// Whenever Ethereal Valkyrie enters the battlefield or attacks, draw a card, then exile a card from your hand face down. It becomes foretold. Its foretell cost is its mana cost reduced by {2}.
this.addAbility(new EntersBattlefieldOrAttacksSourceTriggeredAbility(new EtherealValkyrieEffect()));
this.addAbility(new EntersBattlefieldOrAttacksSourceTriggeredAbility(new EtherealValkyrieEffect()), new ForetoldWatcher());
}
private EtherealValkyrie(final EtherealValkyrie card) {
@ -75,77 +74,15 @@ class EtherealValkyrieEffect extends OneShotEffect {
if (controller == null) {
return false;
}
controller.drawCards(1, source, game);
TargetCardInHand targetCard = new TargetCardInHand(new FilterCard("card to exile face down. It becomes foretold."));
TargetCardInHand targetCard = new TargetCardInHand(StaticFilters.FILTER_CARD).withChooseHint("to exile face down; it becomes foretold");
if (!controller.chooseTarget(Outcome.Benefit, targetCard, source, game)) {
return false;
}
Card exileCard = game.getCard(targetCard.getFirstTarget());
if (exileCard == null) {
Card card = game.getCard(targetCard.getFirstTarget());
if (card == null) {
return false;
}
// process Split, MDFC, and Adventure cards first
// note that 'Foretell Cost' refers to the main card (left) and 'Foretell Split Cost' refers to the (right) card if it exists
ForetellAbility foretellAbility = null;
if (exileCard instanceof SplitCard) {
String leftHalfCost = CardUtil.reduceCost(((SplitCard) exileCard).getLeftHalfCard().getManaCost(), 2).getText();
String rightHalfCost = CardUtil.reduceCost(((SplitCard) exileCard).getRightHalfCard().getManaCost(), 2).getText();
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Cost", leftHalfCost);
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Split Cost", rightHalfCost);
foretellAbility = new ForetellAbility(exileCard, leftHalfCost, rightHalfCost);
} else if (exileCard instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) exileCard).getLeftHalfCard();
if (!leftHalfCard.isLand(game)) { // Only MDFC cards with a left side a land have a land on the right side too
String leftHalfCost = CardUtil.reduceCost(leftHalfCard.getManaCost(), 2).getText();
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Cost", leftHalfCost);
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) exileCard).getRightHalfCard();
if (rightHalfCard.isLand(game)) {
foretellAbility = new ForetellAbility(exileCard, leftHalfCost);
} else {
String rightHalfCost = CardUtil.reduceCost(rightHalfCard.getManaCost(), 2).getText();
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Split Cost", rightHalfCost);
foretellAbility = new ForetellAbility(exileCard, leftHalfCost, rightHalfCost);
}
}
} else if (exileCard instanceof CardWithSpellOption) {
String creatureCost = CardUtil.reduceCost(exileCard.getMainCard().getManaCost(), 2).getText();
String spellCost = CardUtil.reduceCost(((CardWithSpellOption) exileCard).getSpellCard().getManaCost(), 2).getText();
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Cost", creatureCost);
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Split Cost", spellCost);
foretellAbility = new ForetellAbility(exileCard, creatureCost, spellCost);
} else if (!exileCard.isLand(game)) {
// normal card
String costText = CardUtil.reduceCost(exileCard.getManaCost(), 2).getText();
game.getState().setValue(exileCard.getId().toString() + "Foretell Cost", costText);
foretellAbility = new ForetellAbility(exileCard, costText);
}
// All card types (including lands) must be exiled
UUID exileId = CardUtil.getExileZoneId(exileCard.getMainCard().getId().toString() + "foretellAbility", game);
controller.moveCardsToExile(exileCard, source, game, true, exileId, " Foretell Turn Number: " + game.getTurnNum());
exileCard.setFaceDown(true, game);
// all done pre-processing so stick the foretell cost effect onto the main card
// note that the card is not foretell'd into exile, it is put into exile and made foretold
// If the card is a non-land, it will not be exiled.
if (foretellAbility != null) {
// copy source and use it for the foretold effect on the exiled card
// bug #8673
Ability copiedSource = source.copy();
copiedSource.newId();
copiedSource.setSourceId(exileCard.getId());
game.getState().setValue(exileCard.getMainCard().getId().toString() + "Foretell Turn Number", game.getTurnNum());
foretellAbility.setSourceId(exileCard.getId());
foretellAbility.setControllerId(exileCard.getOwnerId());
game.getState().addOtherAbility(exileCard, foretellAbility);
foretellAbility.activate(game, true);
ContinuousEffect effect = new ForetellAbility.ForetellAddCostEffect(new MageObjectReference(exileCard, game));
game.addEffect(effect, copiedSource);
game.fireEvent(GameEvent.getEvent(GameEvent.EventType.FORETOLD, exileCard.getId(), null, null));
}
return true;
return ForetellAbility.doExileBecomesForetold(card, game, source, 2);
}
}

View file

@ -89,7 +89,7 @@ class RanarTheEverWatchfulCostReductionEffect extends CostModificationEffectImpl
public boolean applies(Ability abilityToModify, Ability source, Game game) {
ForetoldWatcher watcher = game.getState().getWatcher(ForetoldWatcher.class);
return (watcher != null
&& watcher.countNumberForetellThisTurn() == 0
&& watcher.getPlayerForetellCountThisTurn(source.getControllerId()) == 0
&& abilityToModify.isControlledBy(source.getControllerId())
&& abilityToModify instanceof ForetellAbility);
}

View file

@ -2,6 +2,7 @@ package org.mage.test.cards.abilities.keywords;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import mage.counters.CounterType;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
@ -81,4 +82,243 @@ public class ForetellTest extends CardTestPlayerBase {
assertExileCount(playerA, "Lightning Bolt", 1); // foretold card in exile
assertPowerToughness(playerA, "Dream Devourer", 2, 3); // +2 power boost from trigger due to foretell of Lightning Bolt
}
// Tests needed to check watcher scope issue (see issue #7493 and issue #13774)
private static final String scornEffigy = "Scorn Effigy"; // {3} 2/3 foretell {0}
private static final String poisonCup = "Poison the Cup"; // {1}{B}{B} instant destroy target creature
// Foretell {1}{B}, if spell was foretold, scry 2
private static final String flamespeaker = "Flamespeaker Adept"; // {2}{R} 2/3
// Whenever you scry, gets +2/+0 and first strike until end of turn
private static final String chancemetElves = "Chance-Met Elves"; // {2}{G} 3/2
// Whenever you scry, gets a +1/+1 counter, triggers once per turn
private static final String cardE = "Elite Vanguard";
private static final String cardD = "Devilthorn Fox";
private static final String cardC = "Canopy Gorger";
private static final String cardB = "Barbtooth Wurm";
private static final String cardA = "Alaborn Trooper";
private void setupLibrariesEtc() {
// make a library of 5 cards, bottom : E D C B A : top
skipInitShuffling();
removeAllCardsFromLibrary(playerA);
addCard(Zone.LIBRARY, playerA, cardE);
addCard(Zone.LIBRARY, playerA, cardD);
addCard(Zone.LIBRARY, playerA, cardC);
addCard(Zone.LIBRARY, playerA, cardB);
addCard(Zone.LIBRARY, playerA, cardA);
removeAllCardsFromLibrary(playerB);
addCard(Zone.LIBRARY, playerB, cardE);
addCard(Zone.LIBRARY, playerB, cardD);
addCard(Zone.LIBRARY, playerB, cardC);
addCard(Zone.LIBRARY, playerB, cardB);
addCard(Zone.LIBRARY, playerB, cardA);
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5);
addCard(Zone.BATTLEFIELD, playerB, "Swamp", 5);
addCard(Zone.BATTLEFIELD, playerA, flamespeaker);
addCard(Zone.BATTLEFIELD, playerB, chancemetElves);
addCard(Zone.HAND, playerA, scornEffigy);
}
@Test
public void testForetellWatcherPlayerA() {
setupLibrariesEtc();
addCard(Zone.HAND, playerA, poisonCup);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, scornEffigy);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Foretell");
checkExileCount("foretold in exile", 2, PhaseStep.PRECOMBAT_MAIN, playerA, poisonCup, 1);
// turn 3, draw card A
activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Foretell {1}{B}", chancemetElves);
// foretold, so scry 2 (cards B and C)
addTarget(playerA, cardB); // scrying B bottom (C remains on top)
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertPowerToughness(playerA, scornEffigy, 2, 3);
assertGraveyardCount(playerA, poisonCup, 1);
assertGraveyardCount(playerB, chancemetElves, 1);
assertPowerToughness(playerA, flamespeaker, 4, 3);
}
@Test
public void testForetellWatcherPlayerB() {
setupLibrariesEtc();
addCard(Zone.HAND, playerB, poisonCup);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, scornEffigy);
// turn 2, draw card A
activateAbility(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Foretell");
checkExileCount("foretold in exile", 2, PhaseStep.PRECOMBAT_MAIN, playerB, poisonCup, 1);
// turn 4, draw card B
activateAbility(4, PhaseStep.PRECOMBAT_MAIN, playerB, "Foretell {1}{B}", flamespeaker);
// foretold, so scry 2 (cards C and D)
addTarget(playerB, cardD); // scrying D bottom (C remains on top)
setStrictChooseMode(true);
setStopAt(4, PhaseStep.END_TURN);
execute();
assertPowerToughness(playerA, scornEffigy, 2, 3);
assertGraveyardCount(playerB, poisonCup, 1);
assertGraveyardCount(playerA, flamespeaker, 1);
assertPowerToughness(playerB, chancemetElves, 4, 3);
}
@Test
public void testRanar() {
skipInitShuffling();
String ranar = "Ranar the Ever-Watchful"; // 2WU 2/3 Flying Vigilance
// The first card you foretell each turn costs {0} to foretell.
// Whenever one or more cards are put into exile from your hand or a spell or ability you control exiles
// one or more permanents from the battlefield, create a 1/1 white Spirit creature token with flying.
addCard(Zone.BATTLEFIELD, playerA, ranar);
addCard(Zone.BATTLEFIELD, playerA, "Sage of the Falls"); // may loot on creature ETB
addCard(Zone.HAND, playerA, poisonCup);
addCard(Zone.LIBRARY, playerA, scornEffigy);
addCard(Zone.HAND, playerA, "Wastes");
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Foretell"); // poison the cup
setChoice(playerA, true); // yes to loot
setChoice(playerA, "Wastes"); // discard
checkExileCount("Poison the Cup foretold", 1, PhaseStep.BEGIN_COMBAT, playerA, poisonCup, 1);
checkHandCardCount("scorn effigy drawn", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, scornEffigy, 1);
checkPlayableAbility("can't foretell another for free", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Foretell", false);
activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Foretell"); // scorn effigy
setChoice(playerA, false); // no loot
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertPermanentCount(playerA, "Spirit Token", 2);
assertExileCount(playerA, 2);
assertGraveyardCount(playerA, "Wastes", 1);
}
@Test
public void testCosmosCharger() {
addCard(Zone.BATTLEFIELD, playerA, "Cosmos Charger");
// Foretelling cards from your hand costs {1} less and can be done on any players turn.
addCard(Zone.HAND, playerA, scornEffigy);
addCard(Zone.BATTLEFIELD, playerA, "Wastes");
activateAbility(2, PhaseStep.UPKEEP, playerA, "Foretell");
setStrictChooseMode(true);
setStopAt(2, PhaseStep.END_TURN);
execute();
assertExileCount(playerA, scornEffigy, 1);
}
@Test
public void testAlrund() {
String alrund = "Alrund, God of the Cosmos";
// Alrund gets +1/+1 for each card in your hand and each foretold card you own in exile.
addCard(Zone.BATTLEFIELD, playerA, alrund); // 1/1
addCard(Zone.HAND, playerA, scornEffigy);
addCard(Zone.HAND, playerA, "Lightning Bolt");
addCard(Zone.BATTLEFIELD, playerA, "Cadaverous Bloom");
// Exile a card from your hand: Add {B}{B} or {G}{G}.
activateAbility(1, PhaseStep.BEGIN_COMBAT, playerA, "Exile a card from your hand: Add {B}{B}");
setChoice(playerA, "Lightning Bolt");
activateAbility(1, PhaseStep.BEGIN_COMBAT, playerA, "Foretell");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertHandCount(playerA, 0);
assertExileCount(playerA, scornEffigy, 1);
assertPowerToughness(playerA, alrund, 2, 2);
}
private static final String valkyrie = "Ethereal Valkyrie"; // 4/4 flying
// Whenever this creature enters or attacks, draw a card, then exile a card from your hand face down.
// It becomes foretold. Its foretell cost is its mana cost reduced by {2}.
@Test
public void testEtherealValkyrie() {
skipInitShuffling();
removeAllCardsFromLibrary(playerA);
String saga = "Niko Defies Destiny";
// I - You gain 2 life for each foretold card you own in exile.
// II - Add {W}{U}. Spend this mana only to foretell cards or cast spells that have foretell.
String crab = "Fortress Crab"; // 3U 1/6
String puma = "Stonework Puma"; // {3} 2/2
addCard(Zone.BATTLEFIELD, playerA, valkyrie);
addCard(Zone.HAND, playerA, saga);
addCard(Zone.HAND, playerA, crab);
addCard(Zone.BATTLEFIELD, playerA, "Tundra", 5);
addCard(Zone.LIBRARY, playerA, "Wastes");
addCard(Zone.LIBRARY, playerA, puma);
attack(1, playerA, valkyrie, playerB);
addTarget(playerA, crab); // exile becomes foretold
castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, saga); // gain 2 life
waitStackResolved(1, PhaseStep.POSTCOMBAT_MAIN);
checkExileCount("crab foretold", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, crab, 1);
checkPlayableAbility("can't cast foretold same turn", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Foretell", false);
waitStackResolved(3, PhaseStep.PRECOMBAT_MAIN);
castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, puma);
activateAbility(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "Foretell");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertLife(playerA, 22);
assertLife(playerB, 16);
assertCounterCount(saga, CounterType.LORE, 2);
assertPowerToughness(playerA, crab, 1, 6);
assertPowerToughness(playerA, valkyrie, 4, 4);
assertPowerToughness(playerA, puma, 2, 2);
assertHandCount(playerA, 1);
assertHandCount(playerA, "Wastes", 1);
assertTappedCount("Tundra", true, 5);
}
@Test
public void testForetoldNotForetell() {
skipInitShuffling();
removeAllCardsFromLibrary(playerA);
addCard(Zone.LIBRARY, playerA, "Wastes");
addCard(Zone.LIBRARY, playerA, "Darksteel Citadel");
addCard(Zone.BATTLEFIELD, playerA, valkyrie);
addCard(Zone.BATTLEFIELD, playerA, "Dream Devourer");
addCard(Zone.HAND, playerA, "Papercraft Decoy");
attack(1, playerA, valkyrie, playerB);
addTarget(playerA, "Papercraft Decoy"); // exile becomes foretold
checkPT("Dream Devourer not boosted", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Dream Devourer", 0, 3);
checkPlayableAbility("Can't cast this turn", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Foretell", false);
checkHandCardCount("card drawn", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Darksteel Citadel", 1);
activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "Foretell");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertLife(playerA, 20);
assertLife(playerB, 16);
assertPowerToughness(playerA, "Papercraft Decoy", 2, 1);
assertPowerToughness(playerA, "Dream Devourer", 0, 3);
assertHandCount(playerA, 2);
}
}

View file

@ -7,6 +7,7 @@ import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.watchers.common.ForetoldWatcher;
/**
* @author jeffwadsworth
@ -16,6 +17,7 @@ public class ForetellSourceControllerTriggeredAbility extends TriggeredAbilityIm
public ForetellSourceControllerTriggeredAbility(Effect effect) {
super(Zone.BATTLEFIELD, effect, false);
setTriggerPhrase("Whenever you foretell a card, ");
addWatcher(new ForetoldWatcher());
}
protected ForetellSourceControllerTriggeredAbility(final ForetellSourceControllerTriggeredAbility ability) {
@ -24,16 +26,14 @@ public class ForetellSourceControllerTriggeredAbility extends TriggeredAbilityIm
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.FORETELL;
return event.getType() == GameEvent.EventType.CARD_FORETOLD;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
Card card = game.getCard(event.getTargetId());
Player player = game.getPlayer(event.getPlayerId());
return (card != null
&& player != null
&& isControlledBy(player.getId()));
return event.getFlag() && card != null && player != null && isControlledBy(player.getId());
}
@Override

View file

@ -17,7 +17,7 @@ public enum ForetoldCondition implements Condition {
public boolean apply(Game game, Ability source) {
ForetoldWatcher watcher = game.getState().getWatcher(ForetoldWatcher.class);
if (watcher != null) {
return watcher.cardWasForetold(source.getSourceId());
return watcher.checkForetold(source.getSourceId(), game);
}
return false;
}

View file

@ -11,11 +11,15 @@ import mage.abilities.costs.Costs;
import mage.abilities.costs.mana.GenericManaCost;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.abilities.effects.AsThoughEffectImpl;
import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.ExileTargetEffect;
import mage.cards.*;
import mage.constants.*;
import mage.filter.common.FilterNonlandCard;
import mage.filter.predicate.Predicates;
import mage.filter.predicate.mageobject.AbilityPredicate;
import mage.game.ExileZone;
import mage.game.Game;
import mage.game.events.GameEvent;
@ -90,132 +94,236 @@ public class ForetellAbility extends SpecialAction {
return " foretells a card from hand";
}
static class ForetellExileEffect extends OneShotEffect {
private final Card card;
String foretellCost;
String foretellSplitCost;
ForetellExileEffect(Card card, String foretellCost, String foretellSplitCost) {
super(Outcome.Neutral);
this.card = card;
this.foretellCost = foretellCost;
this.foretellSplitCost = foretellSplitCost;
}
protected ForetellExileEffect(final ForetellExileEffect effect) {
super(effect);
this.card = effect.card;
this.foretellCost = effect.foretellCost;
this.foretellSplitCost = effect.foretellSplitCost;
}
@Override
public ForetellExileEffect copy() {
return new ForetellExileEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller != null
&& card != null) {
// get main card id
UUID mainCardId = card.getMainCard().getId();
// retrieve the exileId of the foretold card
UUID exileId = CardUtil.getExileZoneId(mainCardId.toString() + "foretellAbility", game);
// foretell turn number shows up on exile window
ExileTargetEffect effect = new ExileTargetEffect(exileId, " Foretell Turn Number: " + game.getTurnNum());
// remember turn number it was cast
game.getState().setValue(mainCardId.toString() + "Foretell Turn Number", game.getTurnNum());
// remember the foretell cost
game.getState().setValue(mainCardId.toString() + "Foretell Cost", foretellCost);
game.getState().setValue(mainCardId.toString() + "Foretell Split Cost", foretellSplitCost);
// exile the card face-down
effect.setWithName(false);
effect.setTargetPointer(new FixedTarget(card.getId(), game));
effect.apply(game, source);
card.setFaceDown(true, game);
game.addEffect(new ForetellAddCostEffect(new MageObjectReference(card, game)), source);
game.fireEvent(GameEvent.getEvent(GameEvent.EventType.FORETELL, card.getId(), null, source.getControllerId()));
return true;
}
return false;
}
public static boolean isCardInForetell(Card card, Game game) {
// searching ForetellCostAbility - it adds for foretelled cards only after exile
return card.getAbilities(game).containsClass(ForetellCostAbility.class);
}
static class ForetellLookAtCardEffect extends AsThoughEffectImpl {
public static ContinuousEffect makeAddForetellEffect() {
return new ForetellAddAbilityEffect();
}
ForetellLookAtCardEffect() {
super(AsThoughEffectType.LOOK_AT_FACE_DOWN, Duration.EndOfGame, Outcome.AIDontUseIt);
/**
* For use in apply() method of OneShotEffect
* Exile the target card. It becomes foretold.
* Its foretell cost is its mana cost reduced by [amountToReduceCost]
*/
public static boolean doExileBecomesForetold(Card card, Game game, Ability source, int amountToReduceCost) {
Player controller = game.getPlayer(source.getControllerId());
if (controller == null) {
return false;
}
protected ForetellLookAtCardEffect(final ForetellLookAtCardEffect effect) {
super(effect);
}
@Override
public boolean apply(Game game, Ability source) {
return true;
}
@Override
public ForetellLookAtCardEffect copy() {
return new ForetellLookAtCardEffect(this);
}
@Override
public boolean applies(UUID objectId, Ability source, UUID affectedControllerId, Game game) {
if (affectedControllerId.equals(source.getControllerId())) {
Card card = game.getCard(objectId);
if (card != null) {
MageObject sourceObject = game.getObject(source);
if (sourceObject == null) {
return false;
}
UUID mainCardId = card.getMainCard().getId();
UUID exileId = CardUtil.getExileZoneId(mainCardId.toString() + "foretellAbility", game);
ExileZone exile = game.getExile().getExileZone(exileId);
return exile != null
&& exile.contains(mainCardId);
// process Split, MDFC, and Adventure cards first
// note that 'Foretell Cost' refers to the main card (left) and 'Foretell Split Cost' refers to the (right) card if it exists
ForetellAbility foretellAbility = null;
if (card instanceof SplitCard) {
String leftHalfCost = CardUtil.reduceCost(((SplitCard) card).getLeftHalfCard().getManaCost(), amountToReduceCost).getText();
String rightHalfCost = CardUtil.reduceCost(((SplitCard) card).getRightHalfCard().getManaCost(), amountToReduceCost).getText();
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Cost", leftHalfCost);
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Split Cost", rightHalfCost);
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
} else if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
if (!leftHalfCard.isLand(game)) { // Only MDFC cards with a left side a land have a land on the right side too
String leftHalfCost = CardUtil.reduceCost(leftHalfCard.getManaCost(), amountToReduceCost).getText();
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Cost", leftHalfCost);
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
if (rightHalfCard.isLand(game)) {
foretellAbility = new ForetellAbility(card, leftHalfCost);
} else {
String rightHalfCost = CardUtil.reduceCost(rightHalfCard.getManaCost(), amountToReduceCost).getText();
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Split Cost", rightHalfCost);
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
}
}
return false;
} else if (card instanceof CardWithSpellOption) {
String creatureCost = CardUtil.reduceCost(card.getMainCard().getManaCost(), amountToReduceCost).getText();
String spellCost = CardUtil.reduceCost(((CardWithSpellOption) card).getSpellCard().getManaCost(), amountToReduceCost).getText();
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Cost", creatureCost);
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Split Cost", spellCost);
foretellAbility = new ForetellAbility(card, creatureCost, spellCost);
} else if (!card.isLand(game)) {
// normal card
String costText = CardUtil.reduceCost(card.getManaCost(), amountToReduceCost).getText();
game.getState().setValue(card.getId().toString() + "Foretell Cost", costText);
foretellAbility = new ForetellAbility(card, costText);
}
// All card types (including lands) must be exiled
UUID exileId = CardUtil.getExileZoneId(card.getMainCard().getId().toString() + "foretellAbility", game);
controller.moveCardsToExile(card, source, game, false, exileId, " Foretell Turn Number: " + game.getTurnNum());
card.setFaceDown(true, game);
// all done pre-processing so stick the foretell cost effect onto the main card
// note that the card is not foretell'd into exile, it is put into exile and made foretold
// If the card is a non-land, it will not be exiled.
if (foretellAbility != null) {
// copy source and use it for the foretold effect on the exiled card
// bug #8673
Ability copiedSource = source.copy();
copiedSource.newId();
copiedSource.setSourceId(card.getId());
game.getState().setValue(card.getMainCard().getId().toString() + "Foretell Turn Number", game.getTurnNum());
foretellAbility.setSourceId(card.getId());
foretellAbility.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, foretellAbility);
foretellAbility.activate(game, true);
game.addEffect(new ForetellAddCostEffect(new MageObjectReference(card, game)), copiedSource);
game.fireEvent(new GameEvent(GameEvent.EventType.CARD_FORETOLD, card.getId(), copiedSource, copiedSource.getControllerId(), 0, false));
}
return true;
}
public static class ForetellAddCostEffect extends ContinuousEffectImpl {
}
private final MageObjectReference mor;
class ForetellExileEffect extends OneShotEffect {
public ForetellAddCostEffect(MageObjectReference mor) {
super(Duration.EndOfGame, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.mor = mor;
staticText = "Foretold card";
private final Card card;
String foretellCost;
String foretellSplitCost;
ForetellExileEffect(Card card, String foretellCost, String foretellSplitCost) {
super(Outcome.Neutral);
this.card = card;
this.foretellCost = foretellCost;
this.foretellSplitCost = foretellSplitCost;
}
private ForetellExileEffect(final ForetellExileEffect effect) {
super(effect);
this.card = effect.card;
this.foretellCost = effect.foretellCost;
this.foretellSplitCost = effect.foretellSplitCost;
}
@Override
public ForetellExileEffect copy() {
return new ForetellExileEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller != null
&& card != null) {
// get main card id
UUID mainCardId = card.getMainCard().getId();
// retrieve the exileId of the foretold card
UUID exileId = CardUtil.getExileZoneId(mainCardId.toString() + "foretellAbility", game);
// foretell turn number shows up on exile window
ExileTargetEffect effect = new ExileTargetEffect(exileId, " Foretell Turn Number: " + game.getTurnNum());
// remember turn number it was cast
game.getState().setValue(mainCardId.toString() + "Foretell Turn Number", game.getTurnNum());
// remember the foretell cost
game.getState().setValue(mainCardId.toString() + "Foretell Cost", foretellCost);
game.getState().setValue(mainCardId.toString() + "Foretell Split Cost", foretellSplitCost);
// exile the card face-down
effect.setWithName(false);
effect.setTargetPointer(new FixedTarget(card.getId(), game));
effect.apply(game, source);
card.setFaceDown(true, game);
game.addEffect(new ForetellAddCostEffect(new MageObjectReference(card, game)), source);
game.fireEvent(new GameEvent(GameEvent.EventType.CARD_FORETOLD, card.getId(), source, source.getControllerId(), 0, true));
return true;
}
return false;
}
}
protected ForetellAddCostEffect(final ForetellAddCostEffect effect) {
super(effect);
this.mor = effect.mor;
}
class ForetellLookAtCardEffect extends AsThoughEffectImpl {
@Override
public boolean apply(Game game, Ability source) {
Card card = mor.getCard(game);
ForetellLookAtCardEffect() {
super(AsThoughEffectType.LOOK_AT_FACE_DOWN, Duration.EndOfGame, Outcome.AIDontUseIt);
}
private ForetellLookAtCardEffect(final ForetellLookAtCardEffect effect) {
super(effect);
}
@Override
public boolean apply(Game game, Ability source) {
return true;
}
@Override
public ForetellLookAtCardEffect copy() {
return new ForetellLookAtCardEffect(this);
}
@Override
public boolean applies(UUID objectId, Ability source, UUID affectedControllerId, Game game) {
if (affectedControllerId.equals(source.getControllerId())) {
Card card = game.getCard(objectId);
if (card != null) {
MageObject sourceObject = game.getObject(source);
if (sourceObject == null) {
return false;
}
UUID mainCardId = card.getMainCard().getId();
if (game.getState().getZone(mainCardId) == Zone.EXILED) {
String foretellCost = (String) game.getState().getValue(mainCardId.toString() + "Foretell Cost");
String foretellSplitCost = (String) game.getState().getValue(mainCardId.toString() + "Foretell Split Cost");
if (card instanceof SplitCard) {
if (foretellCost != null) {
SplitCardHalf leftHalfCard = ((SplitCard) card).getLeftHalfCard();
UUID exileId = CardUtil.getExileZoneId(mainCardId.toString() + "foretellAbility", game);
ExileZone exile = game.getExile().getExileZone(exileId);
return exile != null
&& exile.contains(mainCardId);
}
}
return false;
}
}
class ForetellAddCostEffect extends ContinuousEffectImpl {
private final MageObjectReference mor;
ForetellAddCostEffect(MageObjectReference mor) {
super(Duration.EndOfGame, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.mor = mor;
staticText = "Foretold card";
}
private ForetellAddCostEffect(final ForetellAddCostEffect effect) {
super(effect);
this.mor = effect.mor;
}
@Override
public boolean apply(Game game, Ability source) {
Card card = mor.getCard(game);
if (card != null) {
UUID mainCardId = card.getMainCard().getId();
if (game.getState().getZone(mainCardId) == Zone.EXILED) {
String foretellCost = (String) game.getState().getValue(mainCardId.toString() + "Foretell Cost");
String foretellSplitCost = (String) game.getState().getValue(mainCardId.toString() + "Foretell Split Cost");
if (card instanceof SplitCard) {
if (foretellCost != null) {
SplitCardHalf leftHalfCard = ((SplitCard) card).getLeftHalfCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(leftHalfCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(leftHalfCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(leftHalfCard.getName());
game.getState().addOtherAbility(leftHalfCard, ability);
}
if (foretellSplitCost != null) {
SplitCardHalf rightHalfCard = ((SplitCard) card).getRightHalfCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellSplitCost);
ability.setSourceId(rightHalfCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(rightHalfCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(rightHalfCard.getName());
game.getState().addOtherAbility(rightHalfCard, ability);
}
} else if (card instanceof ModalDoubleFacedCard) {
if (foretellCost != null) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
// some MDFC's are land IE: sea gate restoration
if (!leftHalfCard.isLand(game)) {
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(leftHalfCard.getId());
ability.setControllerId(source.getControllerId());
@ -223,8 +331,11 @@ public class ForetellAbility extends SpecialAction {
ability.setAbilityName(leftHalfCard.getName());
game.getState().addOtherAbility(leftHalfCard, ability);
}
if (foretellSplitCost != null) {
SplitCardHalf rightHalfCard = ((SplitCard) card).getRightHalfCard();
}
if (foretellSplitCost != null) {
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
// some MDFC's are land IE: sea gate restoration
if (!rightHalfCard.isLand(game)) {
ForetellCostAbility ability = new ForetellCostAbility(foretellSplitCost);
ability.setSourceId(rightHalfCard.getId());
ability.setControllerId(source.getControllerId());
@ -232,240 +343,276 @@ public class ForetellAbility extends SpecialAction {
ability.setAbilityName(rightHalfCard.getName());
game.getState().addOtherAbility(rightHalfCard, ability);
}
} else if (card instanceof ModalDoubleFacedCard) {
if (foretellCost != null) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
// some MDFC's are land IE: sea gate restoration
if (!leftHalfCard.isLand(game)) {
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(leftHalfCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(leftHalfCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(leftHalfCard.getName());
game.getState().addOtherAbility(leftHalfCard, ability);
}
}
if (foretellSplitCost != null) {
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
// some MDFC's are land IE: sea gate restoration
if (!rightHalfCard.isLand(game)) {
ForetellCostAbility ability = new ForetellCostAbility(foretellSplitCost);
ability.setSourceId(rightHalfCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(rightHalfCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(rightHalfCard.getName());
game.getState().addOtherAbility(rightHalfCard, ability);
}
}
} else if (card instanceof CardWithSpellOption) {
if (foretellCost != null) {
Card creatureCard = card.getMainCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(creatureCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(creatureCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(creatureCard.getName());
game.getState().addOtherAbility(creatureCard, ability);
}
if (foretellSplitCost != null) {
Card spellCard = ((CardWithSpellOption) card).getSpellCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellSplitCost);
ability.setSourceId(spellCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(spellCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(spellCard.getName());
game.getState().addOtherAbility(spellCard, ability);
}
} else if (foretellCost != null) {
}
} else if (card instanceof CardWithSpellOption) {
if (foretellCost != null) {
Card creatureCard = card.getMainCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(card.getId());
ability.setSourceId(creatureCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(card.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(card.getName());
game.getState().addOtherAbility(card, ability);
ability.setSpellAbilityType(creatureCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(creatureCard.getName());
game.getState().addOtherAbility(creatureCard, ability);
}
return true;
if (foretellSplitCost != null) {
Card spellCard = ((CardWithSpellOption) card).getSpellCard();
ForetellCostAbility ability = new ForetellCostAbility(foretellSplitCost);
ability.setSourceId(spellCard.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(spellCard.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(spellCard.getName());
game.getState().addOtherAbility(spellCard, ability);
}
} else if (foretellCost != null) {
ForetellCostAbility ability = new ForetellCostAbility(foretellCost);
ability.setSourceId(card.getId());
ability.setControllerId(source.getControllerId());
ability.setSpellAbilityType(card.getSpellAbility().getSpellAbilityType());
ability.setAbilityName(card.getName());
game.getState().addOtherAbility(card, ability);
}
return true;
}
discard();
return true;
}
@Override
public ForetellAddCostEffect copy() {
return new ForetellAddCostEffect(this);
}
discard();
return true;
}
static class ForetellCostAbility extends SpellAbility {
private String abilityName;
private SpellAbility spellAbilityToResolve;
ForetellCostAbility(String foretellCost) {
super(null, "Testing", Zone.EXILED, SpellAbilityType.BASE_ALTERNATE, SpellAbilityCastMode.NORMAL);
// Needed for Dream Devourer and Ethereal Valkyrie reducing the cost of a colorless CMC 2 or less spell to 0
// CardUtil.reduceCost returns an empty string in that case so we add a cost of 0 here
// https://github.com/magefree/mage/issues/7607
if (foretellCost != null && foretellCost.isEmpty()) {
foretellCost = "{0}";
}
this.setAdditionalCostsRuleVisible(false);
this.name = "Foretell " + foretellCost;
this.addCost(new ManaCostsImpl<>(foretellCost));
}
protected ForetellCostAbility(final ForetellCostAbility ability) {
super(ability);
this.spellAbilityType = ability.spellAbilityType;
this.abilityName = ability.abilityName;
this.spellAbilityToResolve = ability.spellAbilityToResolve;
}
@Override
public ActivationStatus canActivate(UUID playerId, Game game) {
if (super.canActivate(playerId, game).canActivate()) {
Card card = game.getCard(getSourceId());
if (card != null) {
UUID mainCardId = card.getMainCard().getId();
// Card must be in the exile zone
if (game.getState().getZone(mainCardId) != Zone.EXILED) {
return ActivationStatus.getFalse();
}
Integer foretoldTurn = (Integer) game.getState().getValue(mainCardId.toString() + "Foretell Turn Number");
UUID exileId = (UUID) game.getState().getValue(mainCardId.toString() + "foretellAbility");
// Card must be Foretold
if (foretoldTurn == null || exileId == null) {
return ActivationStatus.getFalse();
}
// Can't be cast if the turn it was Foretold is the same
if (foretoldTurn == game.getTurnNum()) {
return ActivationStatus.getFalse();
}
// Check that the card is actually in the exile zone (ex: Oblivion Ring exiles it after it was Foretold, etc)
ExileZone exileZone = game.getState().getExile().getExileZone(exileId);
if (exileZone != null
&& exileZone.isEmpty()) {
return ActivationStatus.getFalse();
}
if (card instanceof SplitCard) {
if (((SplitCard) card).getLeftHalfCard().getName().equals(abilityName)) {
return ((SplitCard) card).getLeftHalfCard().getSpellAbility().canActivate(playerId, game);
} else if (((SplitCard) card).getRightHalfCard().getName().equals(abilityName)) {
return ((SplitCard) card).getRightHalfCard().getSpellAbility().canActivate(playerId, game);
}
} else if (card instanceof ModalDoubleFacedCard) {
if (((ModalDoubleFacedCard) card).getLeftHalfCard().getName().equals(abilityName)) {
return ((ModalDoubleFacedCard) card).getLeftHalfCard().getSpellAbility().canActivate(playerId, game);
} else if (((ModalDoubleFacedCard) card).getRightHalfCard().getName().equals(abilityName)) {
return ((ModalDoubleFacedCard) card).getRightHalfCard().getSpellAbility().canActivate(playerId, game);
}
} else if (card instanceof CardWithSpellOption) {
if (card.getMainCard().getName().equals(abilityName)) {
return card.getMainCard().getSpellAbility().canActivate(playerId, game);
} else if (((CardWithSpellOption) card).getSpellCard().getName().equals(abilityName)) {
return ((CardWithSpellOption) card).getSpellCard().getSpellAbility().canActivate(playerId, game);
}
}
return card.getSpellAbility().canActivate(playerId, game);
}
}
return ActivationStatus.getFalse();
}
@Override
public SpellAbility getSpellAbilityToResolve(Game game) {
Card card = game.getCard(getSourceId());
if (card != null) {
if (spellAbilityToResolve == null) {
SpellAbility spellAbilityCopy = null;
if (card instanceof SplitCard) {
if (((SplitCard) card).getLeftHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((SplitCard) card).getLeftHalfCard().getSpellAbility().copy();
} else if (((SplitCard) card).getRightHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((SplitCard) card).getRightHalfCard().getSpellAbility().copy();
}
} else if (card instanceof ModalDoubleFacedCard) {
if (((ModalDoubleFacedCard) card).getLeftHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((ModalDoubleFacedCard) card).getLeftHalfCard().getSpellAbility().copy();
} else if (((ModalDoubleFacedCard) card).getRightHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((ModalDoubleFacedCard) card).getRightHalfCard().getSpellAbility().copy();
}
} else if (card instanceof CardWithSpellOption) {
if (card.getMainCard().getName().equals(abilityName)) {
spellAbilityCopy = card.getMainCard().getSpellAbility().copy();
} else if (((CardWithSpellOption) card).getSpellCard().getName().equals(abilityName)) {
spellAbilityCopy = ((CardWithSpellOption) card).getSpellCard().getSpellAbility().copy();
}
} else {
spellAbilityCopy = card.getSpellAbility().copy();
}
if (spellAbilityCopy == null) {
return null;
}
spellAbilityCopy.setId(this.getId());
spellAbilityCopy.clearManaCosts();
spellAbilityCopy.clearManaCostsToPay();
spellAbilityCopy.addCost(this.getCosts().copy());
spellAbilityCopy.addCost(this.getManaCosts().copy());
spellAbilityCopy.setSpellAbilityCastMode(this.getSpellAbilityCastMode());
spellAbilityToResolve = spellAbilityCopy;
}
}
return spellAbilityToResolve;
}
@Override
public Costs<Cost> getCosts() {
if (spellAbilityToResolve == null) {
return super.getCosts();
}
return spellAbilityToResolve.getCosts();
}
@Override
public ForetellCostAbility copy() {
return new ForetellCostAbility(this);
}
@Override
public String getRule(boolean all) {
StringBuilder sbRule = new StringBuilder("Foretell");
if (!getCosts().isEmpty()) {
sbRule.append("&mdash;");
} else {
sbRule.append(' ');
}
if (!getManaCosts().isEmpty()) {
sbRule.append(getManaCosts().getText());
}
if (!getCosts().isEmpty()) {
if (!getManaCosts().isEmpty()) {
sbRule.append(", ");
}
sbRule.append(getCosts().getText());
sbRule.append('.');
}
if (abilityName != null) {
sbRule.append(' ');
sbRule.append(abilityName);
}
sbRule.append(" <i>(You may cast this card from exile for its foretell cost.)</i>");
return sbRule.toString();
}
/**
* Used for split card in PlayerImpl method:
* getOtherUseableActivatedAbilities
*/
public void setAbilityName(String abilityName) {
this.abilityName = abilityName;
}
}
public static boolean isCardInForetell(Card card, Game game) {
// searching ForetellCostAbility - it adds for foretelled cards only after exile
return card.getAbilities(game).containsClass(ForetellCostAbility.class);
@Override
public ForetellAddCostEffect copy() {
return new ForetellAddCostEffect(this);
}
}
class ForetellCostAbility extends SpellAbility {
private String abilityName;
private SpellAbility spellAbilityToResolve;
ForetellCostAbility(String foretellCost) {
super(null, "Testing", Zone.EXILED, SpellAbilityType.BASE_ALTERNATE, SpellAbilityCastMode.NORMAL);
// Needed for Dream Devourer and Ethereal Valkyrie reducing the cost of a colorless CMC 2 or less spell to 0
// CardUtil.reduceCost returns an empty string in that case so we add a cost of 0 here
// https://github.com/magefree/mage/issues/7607
if (foretellCost != null && foretellCost.isEmpty()) {
foretellCost = "{0}";
}
this.setAdditionalCostsRuleVisible(false);
this.name = "Foretell " + foretellCost;
this.addCost(new ManaCostsImpl<>(foretellCost));
}
private ForetellCostAbility(final ForetellCostAbility ability) {
super(ability);
this.spellAbilityType = ability.spellAbilityType;
this.abilityName = ability.abilityName;
this.spellAbilityToResolve = ability.spellAbilityToResolve;
}
@Override
public ActivationStatus canActivate(UUID playerId, Game game) {
if (super.canActivate(playerId, game).canActivate()) {
Card card = game.getCard(getSourceId());
if (card != null) {
UUID mainCardId = card.getMainCard().getId();
// Card must be in the exile zone
if (game.getState().getZone(mainCardId) != Zone.EXILED) {
return ActivationStatus.getFalse();
}
Integer foretoldTurn = (Integer) game.getState().getValue(mainCardId.toString() + "Foretell Turn Number");
UUID exileId = (UUID) game.getState().getValue(mainCardId.toString() + "foretellAbility");
// Card must be Foretold
if (foretoldTurn == null || exileId == null) {
return ActivationStatus.getFalse();
}
// Can't be cast if the turn it was Foretold is the same
if (foretoldTurn == game.getTurnNum()) {
return ActivationStatus.getFalse();
}
// Check that the card is actually in the exile zone (ex: Oblivion Ring exiles it after it was Foretold, etc)
ExileZone exileZone = game.getState().getExile().getExileZone(exileId);
if (exileZone != null
&& exileZone.isEmpty()) {
return ActivationStatus.getFalse();
}
if (card instanceof SplitCard) {
if (((SplitCard) card).getLeftHalfCard().getName().equals(abilityName)) {
return ((SplitCard) card).getLeftHalfCard().getSpellAbility().canActivate(playerId, game);
} else if (((SplitCard) card).getRightHalfCard().getName().equals(abilityName)) {
return ((SplitCard) card).getRightHalfCard().getSpellAbility().canActivate(playerId, game);
}
} else if (card instanceof ModalDoubleFacedCard) {
if (((ModalDoubleFacedCard) card).getLeftHalfCard().getName().equals(abilityName)) {
return ((ModalDoubleFacedCard) card).getLeftHalfCard().getSpellAbility().canActivate(playerId, game);
} else if (((ModalDoubleFacedCard) card).getRightHalfCard().getName().equals(abilityName)) {
return ((ModalDoubleFacedCard) card).getRightHalfCard().getSpellAbility().canActivate(playerId, game);
}
} else if (card instanceof CardWithSpellOption) {
if (card.getMainCard().getName().equals(abilityName)) {
return card.getMainCard().getSpellAbility().canActivate(playerId, game);
} else if (((CardWithSpellOption) card).getSpellCard().getName().equals(abilityName)) {
return ((CardWithSpellOption) card).getSpellCard().getSpellAbility().canActivate(playerId, game);
}
}
return card.getSpellAbility().canActivate(playerId, game);
}
}
return ActivationStatus.getFalse();
}
@Override
public SpellAbility getSpellAbilityToResolve(Game game) {
Card card = game.getCard(getSourceId());
if (card != null) {
if (spellAbilityToResolve == null) {
SpellAbility spellAbilityCopy = null;
if (card instanceof SplitCard) {
if (((SplitCard) card).getLeftHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((SplitCard) card).getLeftHalfCard().getSpellAbility().copy();
} else if (((SplitCard) card).getRightHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((SplitCard) card).getRightHalfCard().getSpellAbility().copy();
}
} else if (card instanceof ModalDoubleFacedCard) {
if (((ModalDoubleFacedCard) card).getLeftHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((ModalDoubleFacedCard) card).getLeftHalfCard().getSpellAbility().copy();
} else if (((ModalDoubleFacedCard) card).getRightHalfCard().getName().equals(abilityName)) {
spellAbilityCopy = ((ModalDoubleFacedCard) card).getRightHalfCard().getSpellAbility().copy();
}
} else if (card instanceof CardWithSpellOption) {
if (card.getMainCard().getName().equals(abilityName)) {
spellAbilityCopy = card.getMainCard().getSpellAbility().copy();
} else if (((CardWithSpellOption) card).getSpellCard().getName().equals(abilityName)) {
spellAbilityCopy = ((CardWithSpellOption) card).getSpellCard().getSpellAbility().copy();
}
} else {
spellAbilityCopy = card.getSpellAbility().copy();
}
if (spellAbilityCopy == null) {
return null;
}
spellAbilityCopy.setId(this.getId());
spellAbilityCopy.clearManaCosts();
spellAbilityCopy.clearManaCostsToPay();
spellAbilityCopy.addCost(this.getCosts().copy());
spellAbilityCopy.addCost(this.getManaCosts().copy());
spellAbilityCopy.setSpellAbilityCastMode(this.getSpellAbilityCastMode());
spellAbilityToResolve = spellAbilityCopy;
}
}
return spellAbilityToResolve;
}
@Override
public Costs<Cost> getCosts() {
if (spellAbilityToResolve == null) {
return super.getCosts();
}
return spellAbilityToResolve.getCosts();
}
@Override
public ForetellCostAbility copy() {
return new ForetellCostAbility(this);
}
@Override
public String getRule(boolean all) {
StringBuilder sbRule = new StringBuilder("Foretell");
if (!getCosts().isEmpty()) {
sbRule.append("&mdash;");
} else {
sbRule.append(' ');
}
if (!getManaCosts().isEmpty()) {
sbRule.append(getManaCosts().getText());
}
if (!getCosts().isEmpty()) {
if (!getManaCosts().isEmpty()) {
sbRule.append(", ");
}
sbRule.append(getCosts().getText());
sbRule.append('.');
}
if (abilityName != null) {
sbRule.append(' ');
sbRule.append(abilityName);
}
sbRule.append(" <i>(You may cast this card from exile for its foretell cost.)</i>");
return sbRule.toString();
}
/**
* Used for split card in PlayerImpl method:
* getOtherUseableActivatedAbilities
*/
void setAbilityName(String abilityName) {
this.abilityName = abilityName;
}
}
class ForetellAddAbilityEffect extends ContinuousEffectImpl {
private static final FilterNonlandCard filter = new FilterNonlandCard();
static {
filter.add(Predicates.not(new AbilityPredicate(ForetellAbility.class)));
}
ForetellAddAbilityEffect() {
super(Duration.WhileOnBattlefield, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.staticText = "Each nonland card in your hand without foretell has foretell. Its foretell cost is equal to its mana cost reduced by {2}";
}
private ForetellAddAbilityEffect(final ForetellAddAbilityEffect effect) {
super(effect);
}
@Override
public ForetellAddAbilityEffect copy() {
return new ForetellAddAbilityEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller == null) {
return false;
}
for (Card card : controller.getHand().getCards(filter, game)) {
ForetellAbility foretellAbility = null;
if (card instanceof SplitCard) {
String leftHalfCost = CardUtil.reduceCost(((SplitCard) card).getLeftHalfCard().getManaCost(), 2).getText();
String rightHalfCost = CardUtil.reduceCost(((SplitCard) card).getRightHalfCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
} else if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
// If front side of MDFC is land, do nothing as Dream Devourer does not apply to lands
// MDFC cards in hand are considered lands if front side is land
if (!leftHalfCard.isLand(game)) {
String leftHalfCost = CardUtil.reduceCost(leftHalfCard.getManaCost(), 2).getText();
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
if (rightHalfCard.isLand(game)) {
foretellAbility = new ForetellAbility(card, leftHalfCost);
} else {
String rightHalfCost = CardUtil.reduceCost(rightHalfCard.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, leftHalfCost, rightHalfCost);
}
}
} else if (card instanceof CardWithSpellOption) {
String creatureCost = CardUtil.reduceCost(card.getMainCard().getManaCost(), 2).getText();
String spellCost = CardUtil.reduceCost(((CardWithSpellOption) card).getSpellCard().getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, creatureCost, spellCost);
} else {
String costText = CardUtil.reduceCost(card.getManaCost(), 2).getText();
foretellAbility = new ForetellAbility(card, costText);
}
if (foretellAbility != null) {
foretellAbility.setSourceId(card.getId());
foretellAbility.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, foretellAbility);
}
}
return true;
}
}

View file

@ -615,8 +615,12 @@ public class GameEvent implements Serializable {
DUNGEON_COMPLETED,
TEMPTED_BY_RING, RING_BEARER_CHOSEN,
REMOVED_FROM_COMBAT, // targetId id of permanent removed from combat
FORETOLD, // targetId id of card foretold
FORETELL, // targetId id of card foretell playerId id of the controller
/* card foretold
targetId id of card foretold
playerId id of player foretelling card
flag true if player did foretell, false if became foretold without foretell
*/
CARD_FORETOLD,
/* villainous choice
targetId player making the choice
sourceId sourceId of the ability forcing the choice

View file

@ -1,12 +1,13 @@
package mage.watchers.common;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.*;
import mage.MageObjectReference;
import mage.cards.Card;
import mage.constants.WatcherScope;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.util.CardUtil;
import mage.watchers.Watcher;
@ -16,9 +17,12 @@ import mage.watchers.Watcher;
*/
public class ForetoldWatcher extends Watcher {
// If foretell was activated or a card was Foretold by the controller this turn, this list stores it. Cleared at the end of the turn.
private final Set<UUID> foretellCardsThisTurn = new HashSet<>();
private final Set<UUID> foretoldCards = new HashSet<>();
private final Set<MageObjectReference> foretoldCards = new HashSet<>();
// cards foretold - ZCC stored to reference from stack (exile zone plus 1)
private final Map<UUID, Integer> playerForetellCount = new HashMap<>();
// map of player id to number of times they foretell a card, cleared each turn
public ForetoldWatcher() {
super(WatcherScope.GAME);
@ -26,35 +30,32 @@ public class ForetoldWatcher extends Watcher {
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.FORETELL) {
Card card = game.getCard(event.getTargetId());
if (card != null
&& controllerId == event.getPlayerId()) {
foretellCardsThisTurn.add(card.getId());
foretoldCards.add(card.getId());
}
if (event.getType() != GameEvent.EventType.CARD_FORETOLD) {
return;
}
// Ethereal Valkyrie
if (event.getType() == GameEvent.EventType.FORETOLD) {
Card card = game.getCard(event.getTargetId());
if (card != null) {
// Ethereal Valkyrie does not Foretell the card, it becomes Foretold, so don't add it to the Foretell list
foretoldCards.add(card.getId());
Card card = game.getCard(event.getTargetId());
if (card != null) {
foretoldCards.add(new MageObjectReference(card, game, 1));
}
if (event.getFlag()) {
Player player = game.getPlayer(event.getPlayerId());
if (player != null) {
playerForetellCount.compute(player.getId(), CardUtil::setOrIncrementValue);
}
}
}
public boolean cardWasForetold(UUID sourceId) {
return foretoldCards.contains(sourceId);
public boolean checkForetold(UUID sourceId, Game game) {
return foretoldCards.contains(new MageObjectReference(sourceId, game));
}
public int countNumberForetellThisTurn() {
return foretellCardsThisTurn.size();
public int getPlayerForetellCountThisTurn(UUID playerId) {
return playerForetellCount.getOrDefault(playerId, 0);
}
@Override
public void reset() {
super.reset();
foretellCardsThisTurn.clear();
playerForetellCount.clear();
}
}