[FIC] Implement G'raha Tia, Scion Reborn, rework DoIfCostPaid and "do only once" effects (#13660)

* rework effects with DoIfCostPaid and "do this only once each turn"

* [FIC] Implement G'raha Tia, Scion Reborn

* [FIC] Implement Emet Selch of the Third Seat

* rework Emet-Selch

* add test

* add static methods to handle whether ability was used this turn
This commit is contained in:
Evan Kranzler 2025-05-30 21:28:11 -04:00 committed by Failure
parent 71c4be03fb
commit 40d24869a8
11 changed files with 361 additions and 50 deletions

View file

@ -0,0 +1,137 @@
package mage.cards.e;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.BatchTriggeredAbility;
import mage.abilities.TriggeredAbility;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.cost.SpellsCostReductionControllerEffect;
import mage.abilities.effects.common.replacement.ThatSpellGraveyardExileReplacementEffect;
import mage.cards.Card;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.*;
import mage.filter.FilterCard;
import mage.filter.StaticFilters;
import mage.filter.predicate.card.CastFromZonePredicate;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.events.LifeLostBatchEvent;
import mage.game.events.LifeLostEvent;
import mage.players.Player;
import mage.target.common.TargetCardInYourGraveyard;
import mage.target.targetpointer.FixedTarget;
import mage.util.CardUtil;
import java.util.List;
import java.util.UUID;
/**
* @author TheElk801
*/
public final class EmetSelchOfTheThirdSeat extends CardImpl {
private static final FilterCard filter = new FilterCard("spells you cast from your graveyard");
static {
filter.add(new CastFromZonePredicate(Zone.GRAVEYARD));
}
public EmetSelchOfTheThirdSeat(UUID ownerId, CardSetInfo setInfo) {
super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{U}{B}");
this.supertype.add(SuperType.LEGENDARY);
this.subtype.add(SubType.ELDER);
this.subtype.add(SubType.WIZARD);
this.power = new MageInt(3);
this.toughness = new MageInt(4);
// Spells you cast from your graveyard cost {2} less to cast.
this.addAbility(new SimpleStaticAbility(new SpellsCostReductionControllerEffect(filter, 2)));
// Whenever one or more opponents lose life, you may cast target instant or sorcery card from your graveyard. If that spell would be put into your graveyard, exile it instead. Do this only once each turn.
this.addAbility(new EmetSelchOfTheThirdSeatAbility());
}
private EmetSelchOfTheThirdSeat(final EmetSelchOfTheThirdSeat card) {
super(card);
}
@Override
public EmetSelchOfTheThirdSeat copy() {
return new EmetSelchOfTheThirdSeat(this);
}
}
class EmetSelchOfTheThirdSeatAbility extends TriggeredAbilityImpl implements BatchTriggeredAbility<LifeLostEvent> {
EmetSelchOfTheThirdSeatAbility() {
super(Zone.BATTLEFIELD, new EmetSelchOfTheThirdSeatEffect());
this.setTriggerPhrase("Whenever one or more opponents lose life, ");
this.addTarget(new TargetCardInYourGraveyard(StaticFilters.FILTER_CARD_INSTANT_OR_SORCERY_FROM_YOUR_GRAVEYARD));
this.setDoOnlyOnceEachTurn(true);
}
private EmetSelchOfTheThirdSeatAbility(final EmetSelchOfTheThirdSeatAbility ability) {
super(ability);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.LOST_LIFE_BATCH;
}
@Override
public boolean checkEvent(LifeLostEvent event, Game game) {
return game.getOpponents(getControllerId()).contains(event.getTargetId());
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
List<LifeLostEvent> filteredEvents = getFilteredEvents((LifeLostBatchEvent) event, game);
return !filteredEvents.isEmpty()
&& CardUtil
.getEventTargets(event)
.stream()
.anyMatch(uuid -> LifeLostBatchEvent.getLifeLostByPlayer(filteredEvents, uuid) > 0);
}
@Override
public EmetSelchOfTheThirdSeatAbility copy() {
return new EmetSelchOfTheThirdSeatAbility(this);
}
}
class EmetSelchOfTheThirdSeatEffect extends OneShotEffect {
EmetSelchOfTheThirdSeatEffect() {
super(Outcome.Benefit);
staticText = "cast target instant or sorcery card from your graveyard. " +
"If that spell would be put into your graveyard, exile it instead";
}
private EmetSelchOfTheThirdSeatEffect(final EmetSelchOfTheThirdSeatEffect effect) {
super(effect);
}
@Override
public EmetSelchOfTheThirdSeatEffect copy() {
return new EmetSelchOfTheThirdSeatEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player player = game.getPlayer(source.getControllerId());
Card card = game.getCard(getTargetPointer().getFirst(game, source));
if (player == null || card == null || !CardUtil.castSingle(player, source, game, card)) {
// if the spell isn't cast then the ability can be used again in the same turn
TriggeredAbility.clearDidThisTurn(source, game);
return false;
}
game.addEffect(new ThatSpellGraveyardExileReplacementEffect(true)
.setTargetPointer(new FixedTarget(card, game)), source);
return true;
}
}

View file

@ -0,0 +1,123 @@
package mage.cards.g;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.SpellCastControllerTriggeredAbility;
import mage.abilities.costs.common.PayLifeCost;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.DoIfCostPaid;
import mage.abilities.keyword.LifelinkAbility;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.counters.CounterType;
import mage.filter.StaticFilters;
import mage.game.Game;
import mage.game.permanent.token.HeroToken;
import mage.game.permanent.token.Token;
import mage.game.stack.Spell;
import mage.util.CardUtil;
import java.util.Optional;
import java.util.UUID;
/**
* @author TheElk801
*/
public final class GrahaTiaScionReborn extends CardImpl {
public GrahaTiaScionReborn(UUID ownerId, CardSetInfo setInfo) {
super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{W}{U}{B}");
this.supertype.add(SuperType.LEGENDARY);
this.subtype.add(SubType.CAT);
this.subtype.add(SubType.WIZARD);
this.power = new MageInt(2);
this.toughness = new MageInt(3);
// Lifelink
this.addAbility(LifelinkAbility.getInstance());
// Throw Wide the Gates -- Whenever you cast a noncreature spell, you may pay X life, where X is that spell's mana value. If you do, create a 1/1 colorless Hero creature token and put X +1/+1 counters on it. Do this only once each turn.
this.addAbility(new SpellCastControllerTriggeredAbility(new DoIfCostPaid(
new GrahaTiaScionRebornEffect(),
new PayLifeCost(
GrahaTiaScionRebornValue.instance,
"pay X life, where X is that spell's mana value"
)
), StaticFilters.FILTER_SPELL_A_NON_CREATURE, false).setDoOnlyOnceEachTurn(true).withFlavorWord("Throw Wide the Gates"));
}
private GrahaTiaScionReborn(final GrahaTiaScionReborn card) {
super(card);
}
@Override
public GrahaTiaScionReborn copy() {
return new GrahaTiaScionReborn(this);
}
}
enum GrahaTiaScionRebornValue implements DynamicValue {
instance;
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
return CardUtil.getEffectValueFromAbility(sourceAbility, "spellCast", Spell.class)
.map(Spell::getManaValue)
.orElse(0);
}
@Override
public GrahaTiaScionRebornValue copy() {
return this;
}
@Override
public String getMessage() {
return "";
}
@Override
public String toString() {
return "X";
}
}
class GrahaTiaScionRebornEffect extends OneShotEffect {
GrahaTiaScionRebornEffect() {
super(Outcome.Benefit);
staticText = "create a 1/1 colorless Hero creature token and put X +1/+1 counters on it";
}
private GrahaTiaScionRebornEffect(final GrahaTiaScionRebornEffect effect) {
super(effect);
}
@Override
public GrahaTiaScionRebornEffect copy() {
return new GrahaTiaScionRebornEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Token token = new HeroToken();
token.putOntoBattlefield(1, game, source);
int count = GrahaTiaScionRebornValue.instance.calculate(game, source, this);
if (count < 1) {
return true;
}
for (UUID tokenId : token.getLastAddedTokenIds()) {
Optional.ofNullable(tokenId)
.map(game::getPermanent)
.ifPresent(permanent -> permanent.addCounters(CounterType.P1P1.createInstance(count), source, game));
}
return true;
}
}

View file

@ -1,7 +1,5 @@
package mage.cards.i;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.common.EntersBattlefieldAllTriggeredAbility;
import mage.abilities.costs.common.DiscardCardCost;
@ -18,6 +16,8 @@ import mage.filter.common.FilterControlledCreaturePermanent;
import mage.filter.predicate.mageobject.AnotherPredicate;
import mage.filter.predicate.mageobject.PowerPredicate;
import java.util.UUID;
/**
* @author paasar
*/
@ -41,15 +41,9 @@ public final class IrreverentGremlin extends CardImpl {
this.addAbility(new MenaceAbility());
// Whenever another creature you control with power 2 or less enters, you may discard a card. If you do, draw a card. Do this only once each turn.
this.addAbility(
new EntersBattlefieldAllTriggeredAbility(
new DoIfCostPaid(
new DrawCardSourceControllerEffect(1),
new DiscardCardCost(),
null,
false), // since triggered ability is optional (do only once), DoIfCostPaid must not be
filter)
.setDoOnlyOnceEachTurn(true));
this.addAbility(new EntersBattlefieldAllTriggeredAbility(
new DoIfCostPaid(new DrawCardSourceControllerEffect(1), new DiscardCardCost()), filter
).setDoOnlyOnceEachTurn(true));
}
private IrreverentGremlin(final IrreverentGremlin card) {

View file

@ -1,9 +1,7 @@
package mage.cards.s;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.TriggeredAbility;
import mage.abilities.Ability;
import mage.abilities.common.AttacksWithCreaturesTriggeredAbility;
import mage.abilities.common.LeavesBattlefieldTriggeredAbility;
import mage.abilities.effects.Effect;
@ -14,6 +12,8 @@ import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.constants.Zone;
import mage.filter.StaticFilters;
import mage.filter.common.FilterControlledCreaturePermanent;
import mage.filter.common.FilterControlledPermanent;
import mage.filter.predicate.mageobject.AnotherPredicate;
@ -21,8 +21,9 @@ import mage.filter.predicate.permanent.AttackingPredicate;
import mage.filter.predicate.permanent.TokenPredicate;
import mage.target.TargetPermanent;
import java.util.UUID;
/**
*
* @author alexander-novo
*/
public class ShaunFatherOfSynths extends CardImpl {
@ -41,7 +42,7 @@ public class ShaunFatherOfSynths extends CardImpl {
}
public ShaunFatherOfSynths(UUID ownerId, CardSetInfo setInfo) {
super(ownerId, setInfo, new CardType[] { CardType.CREATURE }, "{3}{U}{R}");
super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{3}{U}{R}");
this.supertype.add(SuperType.LEGENDARY);
this.subtype.add(SubType.HUMAN);
@ -58,11 +59,14 @@ public class ShaunFatherOfSynths extends CardImpl {
token.addCardType(CardType.CREATURE);
token.addCardType(CardType.ARTIFACT);
token.addSubType(SubType.SYNTH);
}).setText(
"create a tapped and attacking token that's a copy of target attacking legendary creature you control other than Shaun, except it's not legendary and it's a Synth artifact creature in addition to its other types");
TriggeredAbility ability = new AttacksWithCreaturesTriggeredAbility(effect, 1);
}).setText("create a tapped and attacking token that's a copy of target " +
"attacking legendary creature you control other than {this}, " +
"except it's not legendary and it's a Synth artifact creature in addition to its other types");
Ability ability = new AttacksWithCreaturesTriggeredAbility(
Zone.BATTLEFIELD, effect, 1,
StaticFilters.FILTER_PERMANENT_CREATURES, false, true
);
ability.addTarget(new TargetPermanent(attackFilter));
ability.setOptional();
this.addAbility(ability);
// When Shaun leaves the battlefield, exile all Synth tokens you control.

View file

@ -133,6 +133,8 @@ public final class FinalFantasyCommander extends ExpansionSet {
cards.add(new SetCardInfo("Edgar, Master Machinist", 80, Rarity.RARE, mage.cards.e.EdgarMasterMachinist.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Elena, Turk Recruit", 133, Rarity.RARE, mage.cards.e.ElenaTurkRecruit.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Elena, Turk Recruit", 18, Rarity.RARE, mage.cards.e.ElenaTurkRecruit.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Emet-Selch of the Third Seat", 170, Rarity.RARE, mage.cards.e.EmetSelchOfTheThirdSeat.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Emet-Selch of the Third Seat", 81, Rarity.RARE, mage.cards.e.EmetSelchOfTheThirdSeat.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Endless Detour", 324, Rarity.RARE, mage.cards.e.EndlessDetour.class));
cards.add(new SetCardInfo("Espers to Magicite", 114, Rarity.RARE, mage.cards.e.EspersToMagicite.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Espers to Magicite", 43, Rarity.RARE, mage.cards.e.EspersToMagicite.class, NON_FULL_USE_VARIOUS));
@ -160,6 +162,11 @@ public final class FinalFantasyCommander extends ExpansionSet {
cards.add(new SetCardInfo("Fortified Village", 396, Rarity.RARE, mage.cards.f.FortifiedVillage.class));
cards.add(new SetCardInfo("Furious Rise", 294, Rarity.UNCOMMON, mage.cards.f.FuriousRise.class));
cards.add(new SetCardInfo("Furycalm Snarl", 397, Rarity.RARE, mage.cards.f.FurycalmSnarl.class));
cards.add(new SetCardInfo("G'raha Tia, Scion Reborn", 172, Rarity.MYTHIC, mage.cards.g.GrahaTiaScionReborn.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("G'raha Tia, Scion Reborn", 203, Rarity.MYTHIC, mage.cards.g.GrahaTiaScionReborn.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("G'raha Tia, Scion Reborn", 211, Rarity.MYTHIC, mage.cards.g.GrahaTiaScionReborn.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("G'raha Tia, Scion Reborn", 222, Rarity.MYTHIC, mage.cards.g.GrahaTiaScionReborn.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("G'raha Tia, Scion Reborn", 3, Rarity.MYTHIC, mage.cards.g.GrahaTiaScionReborn.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Game Trail", 398, Rarity.RARE, mage.cards.g.GameTrail.class));
cards.add(new SetCardInfo("Gatta and Luzzu", 134, Rarity.RARE, mage.cards.g.GattaAndLuzzu.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Gatta and Luzzu", 19, Rarity.RARE, mage.cards.g.GattaAndLuzzu.class, NON_FULL_USE_VARIOUS));

View file

@ -124,4 +124,31 @@ public class DoThisOnlyOnceEachTurnTest extends CardTestPlayerBase {
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
}
private static final String emetSelch = "Emet-Selch of the Third Seat";
private static final String hazard = "Tectonic Hazard";
private static final String slinger = "Goblin Fireslinger";
@Test
public void testEmetSelch() {
addCard(Zone.HAND, playerA, "Mountain");
addCard(Zone.BATTLEFIELD, playerA, emetSelch);
addCard(Zone.BATTLEFIELD, playerA, slinger, 2);
addCard(Zone.GRAVEYARD, playerA, hazard);
// player is unable to cast spell despite choosing to, choice is reset
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}:", playerB);
addTarget(playerA, hazard);
setChoice(playerA, true);
// this time player can cast and does, so ability doesn't trigger again
playLand(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Mountain");
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}:", playerB);
addTarget(playerA, hazard);
setChoice(playerA, true);
setStrictChooseMode(true);
execute();
assertLife(playerB, 20 - 1 - 1 - 1);
}
}

View file

@ -3,7 +3,9 @@ package mage.abilities;
import mage.abilities.condition.Condition;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.util.CardUtil;
import java.util.Optional;
import java.util.UUID;
/**
@ -97,8 +99,6 @@ public interface TriggeredAbility extends Ability {
boolean isOptional();
TriggeredAbility setOptional();
/**
* Allow trigger to fire after source leave the battlefield (example: will use LKI on itself sacrifice)
*/
@ -131,4 +131,34 @@ public interface TriggeredAbility extends Ability {
TriggeredAbility setTriggerPhrase(String triggerPhrase);
String getTriggerPhrase();
static String makeDidThisTurnString(Ability ability, Game game) {
return CardUtil.getCardZoneString("lastTurnUsed" + ability.getOriginalId(), ability.getSourceId(), game);
}
static void setDidThisTurn(Ability ability, Game game) {
game.getState().setValue(makeDidThisTurnString(ability, game), game.getTurnNum());
}
/**
* For abilities which say "Do this only once each turn".
* Most of the time this is handled automatically by calling setDoOnlyOnceEachTurn(true),
* but sometimes the ability will need a way to clear whether it's been used this turn within an effect.
*
* @param ability
* @param game
*/
static void clearDidThisTurn(Ability ability, Game game) {
game.getState().removeValue(makeDidThisTurnString(ability, game));
}
static boolean checkDidThisTurn(Ability ability, Game game) {
return Optional
.ofNullable(makeDidThisTurnString(ability, game))
.map(game.getState()::getValue)
.filter(Integer.class::isInstance)
.map(Integer.class::cast)
.filter(x -> x == game.getTurnNum())
.isPresent();
}
}

View file

@ -156,13 +156,7 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
@Override
public boolean checkUsedAlready(Game game) {
if (!doOnlyOnceEachTurn) {
return false;
}
Integer lastTurnUsed = (Integer) game.getState().getValue(
CardUtil.getCardZoneString("lastTurnUsed" + getOriginalId(), sourceId, game)
);
return lastTurnUsed != null && lastTurnUsed == game.getTurnNum();
return doOnlyOnceEachTurn && TriggeredAbility.checkDidThisTurn(this, game);
}
@Override
@ -209,7 +203,9 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
@Override
public TriggeredAbility setDoOnlyOnceEachTurn(boolean doOnlyOnce) {
this.doOnlyOnceEachTurn = doOnlyOnce;
setOptional();
if (CardUtil.castStream(this.getAllEffects(), DoIfCostPaid.class).noneMatch(DoIfCostPaid::isOptional)) {
this.optional = true;
}
return this;
}
@ -268,11 +264,9 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
)) {
return false;
}
}
if (doOnlyOnceEachTurn) {
game.getState().setValue(CardUtil.getCardZoneString(
"lastTurnUsed" + getOriginalId(), sourceId, game
), game.getTurnNum());
if (doOnlyOnceEachTurn) {
TriggeredAbility.setDidThisTurn(this, game);
}
}
//20091005 - 603.4
if (!super.resolve(game)) {
@ -387,6 +381,7 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
private static boolean startsWithVerb(String ruleLow) {
return ruleLow.startsWith("attach")
|| ruleLow.startsWith("cast")
|| ruleLow.startsWith("change")
|| ruleLow.startsWith("counter")
|| ruleLow.startsWith("create")
@ -501,20 +496,6 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
return optional;
}
@Override
public TriggeredAbility setOptional() {
this.optional = true;
if (getEffects().stream().anyMatch(
effect -> effect instanceof DoIfCostPaid && ((DoIfCostPaid) effect).isOptional())) {
throw new IllegalArgumentException(
"DoIfCostPaid effect must have only one optional settings, but it have two (trigger + DoIfCostPaid): "
+ this.getClass().getSimpleName());
}
return this;
}
@Override
public TriggeredAbilityImpl setAbilityWord(AbilityWord abilityWord) {
super.setAbilityWord(abilityWord);

View file

@ -41,7 +41,11 @@ public class AttacksWithCreaturesTriggeredAbility extends TriggeredAbilityImpl {
}
public AttacksWithCreaturesTriggeredAbility(Zone zone, Effect effect, int minAttackers, FilterPermanent filter, boolean setTargetPointer) {
super(zone, effect);
this(zone, effect, minAttackers, filter, setTargetPointer, false);
}
public AttacksWithCreaturesTriggeredAbility(Zone zone, Effect effect, int minAttackers, FilterPermanent filter, boolean setTargetPointer, boolean optional) {
super(zone, effect, optional);
this.filter = filter;
this.minAttackers = minAttackers;
this.setTargetPointer = setTargetPointer;

View file

@ -3,6 +3,7 @@ package mage.abilities.effects.common;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.Mode;
import mage.abilities.TriggeredAbility;
import mage.abilities.costs.Cost;
import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.Effect;
@ -110,6 +111,7 @@ public class DoIfCostPaid extends OneShotEffect {
didPay = true;
game.informPlayers(player.getLogName() + " paid for " + mageObject.getLogName() + " - " + message);
applyEffects(game, source, executingEffects);
TriggeredAbility.setDidThisTurn(source, game);
player.resetStoredBookmark(game); // otherwise you can e.g. undo card drawn with Mentor of the Meek
} else {
// Paying cost was cancels so try to undo payment so far

View file

@ -1710,6 +1710,8 @@ public final class CardUtil {
player.setCastSourceIdWithAlternateMana(card.getMainCard().getId(), manaCost, additionalCostsNormalCard, MageIdentifier.Default);
}
game.getState().setValue("PlayFromNotOwnHandZone" + card.getMainCard().getId(), Boolean.TRUE);
// cast it
boolean result = player.cast(player.chooseAbilityForCast(card.getMainCard(), game, noMana),
game, noMana, new ApprovingObject(source, game));