* Copy spells - improved combo support with other abilities like Kicker or Entwine (#7192):

* Now ZCC of copied spells syncs with source card or coping spell (allows to keep ability settings that depends on ZCC);
  * Fixed bug that allows to lost kicked status in copied spells after counter the original spell or moves the original card (see #7192);
  * Test framework: improved support of targeting copy or non copy spells on stack;
This commit is contained in:
Oleg Agafonov 2020-12-15 20:06:53 +04:00
parent 936be75a66
commit 4d362d7edc
32 changed files with 522 additions and 302 deletions

View file

@ -1,5 +1,3 @@
package mage.abilities.condition.common;
import mage.abilities.Ability;
@ -21,10 +19,15 @@ public enum KickedCondition implements Condition {
@Override
public boolean apply(Game game, Ability source) {
Card card = game.getCard(source.getSourceId());
if (card == null) {
// if permanent spell was copied then it enters with sourceId = PermanentToken instead Card (example: Lithoform Engine)
card = game.getPermanentEntering(source.getSourceId());
}
if (card != null) {
for (Ability ability: card.getAbilities()) {
for (Ability ability : card.getAbilities()) {
if (ability instanceof KickerAbility) {
if(((KickerAbility) ability).isKicked(game, source, "")) {
if (((KickerAbility) ability).isKicked(game, source, "")) {
return true;
}
}

View file

@ -11,7 +11,6 @@ import mage.filter.FilterInPlay;
import mage.filter.predicate.mageobject.FromSetPredicate;
import mage.game.Game;
import mage.game.events.CopiedStackObjectEvent;
import mage.game.events.GameEvent;
import mage.game.stack.Spell;
import mage.players.Player;
import mage.target.Target;
@ -82,7 +81,7 @@ public abstract class CopySpellForEachItCouldTargetEffect<T extends MageItem> ex
}
// collect objects that can be targeted
Spell copy = spell.copySpell(source.getControllerId());
Spell copy = spell.copySpell(source.getControllerId(), game);
modifyCopy(copy, game, source);
Target sampleTarget = targetsToBeChanged.iterator().next().getTarget(copy);
sampleTarget.setNotTarget(true);
@ -94,7 +93,7 @@ public abstract class CopySpellForEachItCouldTargetEffect<T extends MageItem> ex
obj = game.getPlayer(objId);
}
if (obj != null) {
copy = spell.copySpell(source.getControllerId());
copy = spell.copySpell(source.getControllerId(), game);
try {
modifyCopy(copy, (T) obj, game, source);
if (!filter.match((T) obj, source.getSourceId(), actingPlayer.getId(), game)) {

View file

@ -34,15 +34,15 @@ public class CreateTokenCopyTargetEffect extends OneShotEffect {
private final CardType additionalCardType;
private boolean hasHaste;
private final int number;
private List<Permanent> addedTokenPermanents;
private final List<Permanent> addedTokenPermanents;
private SubType additionalSubType;
private SubType onlySubType;
private boolean tapped;
private boolean attacking;
private UUID attackedPlayer;
private final boolean tapped;
private final boolean attacking;
private final UUID attackedPlayer;
private final int tokenPower;
private final int tokenToughness;
private boolean gainsFlying;
private final boolean gainsFlying;
private boolean becomesArtifact;
private ObjectColor color;
private boolean useLKI = false;
@ -170,7 +170,7 @@ public class CreateTokenCopyTargetEffect extends OneShotEffect {
}
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(copyFrom); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
CardUtil.copyTo(token).from(copyFrom, game); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
applier.apply(game, token, source, targetId);
if (becomesArtifact) {
token.addCardType(CardType.ARTIFACT);

View file

@ -4,7 +4,6 @@
*/
package mage.abilities.effects.common;
import java.util.Objects;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.DelayedTriggeredAbility;
@ -20,10 +19,11 @@ import mage.game.stack.Spell;
import mage.game.stack.StackObject;
import mage.players.Player;
import java.util.Objects;
/**
*
* @author jeffwadsworth
*
* <p>
* 702.49. Epic 702.49a Epic represents two spell abilities, one of which
* creates a delayed triggered ability. Epic means For the rest of the game,
* you can't cast spells, and At the beginning of each of your upkeeps for the
@ -55,7 +55,7 @@ public class EpicEffect extends OneShotEffect {
if (spell == null) {
return false;
}
spell = spell.copySpell(source.getControllerId());
spell = spell.copySpell(source.getControllerId(), game);
// Remove Epic effect from the spell
Effect epicEffect = null;
for (Effect effect : spell.getSpellAbility().getEffects()) {
@ -118,9 +118,7 @@ class EpicReplacementEffect extends ContinuousRuleModifyingEffectImpl {
public boolean applies(GameEvent event, Ability source, Game game) {
if (Objects.equals(source.getControllerId(), event.getPlayerId())) {
MageObject object = game.getObject(event.getSourceId());
if (object != null) {
return true;
}
return object != null;
}
return false;
}

View file

@ -22,7 +22,7 @@ import mage.util.CardUtil;
*/
public class EmbalmAbility extends ActivatedAbilityImpl {
private String rule;
private final String rule;
public EmbalmAbility(Cost cost, Card card) {
super(Zone.GRAVEYARD, new EmbalmEffect(), cost);
@ -85,7 +85,7 @@ class EmbalmEffect extends OneShotEffect {
return false;
}
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(card); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
CardUtil.copyTo(token).from(card, game); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
token.getColor(game).setColor(ObjectColor.WHITE);
token.addSubType(game, SubType.ZOMBIE);
token.getManaCost().clear();

View file

@ -85,7 +85,7 @@ class EncoreEffect extends OneShotEffect {
return false;
}
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(card);
CardUtil.copyTo(token).from(card, game);
Set<MageObjectReference> addedTokens = new HashSet<>();
int opponentCount = game.getOpponents(source.getControllerId()).size();
if (opponentCount < 1) {

View file

@ -8,7 +8,6 @@ import mage.abilities.costs.OptionalAdditionalCost;
import mage.abilities.costs.OptionalAdditionalCostImpl;
import mage.abilities.costs.OptionalAdditionalModeSourceCosts;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.constants.AbilityType;
import mage.constants.Outcome;
import mage.constants.Zone;
import mage.game.Game;
@ -144,17 +143,7 @@ public class EntwineAbility extends StaticAbility implements OptionalAdditionalM
}
private String getActivationKey(Ability source, Game game) {
// same logic as KickerAbility, uses for source ability only
int zcc = 0;
if (source.getAbilityType() == AbilityType.TRIGGERED) {
zcc = source.getSourceObjectZoneChangeCounter();
}
if (zcc == 0) {
zcc = game.getState().getZoneChangeCounter(source.getSourceId());
}
if (zcc > 0 && (source.getAbilityType() == AbilityType.TRIGGERED)) {
--zcc;
}
return String.valueOf(zcc);
// same logic as KickerAbility
return KickerAbility.getActivationKey(source, game);
}
}

View file

@ -90,7 +90,7 @@ class EternalizeEffect extends OneShotEffect {
return false;
}
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(card); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
CardUtil.copyTo(token).from(card, game); // needed so that entersBattlefied triggered abilities see the attributes (e.g. Master Biomancer)
token.getColor(game).setColor(ObjectColor.BLACK);
token.addSubType(game, SubType.ZOMBIE);
token.getManaCost().clear();

View file

@ -159,18 +159,30 @@ public class KickerAbility extends StaticAbility implements OptionalAdditionalSo
game.fireEvent(GameEvent.getEvent(GameEvent.EventType.KICKED, source.getSourceId(), source, source.getControllerId()));
}
private String getActivationKey(Ability source, String costText, Game game) {
int zcc = 0;
if (source.getAbilityType() == AbilityType.TRIGGERED) {
zcc = source.getSourceObjectZoneChangeCounter();
}
/**
* Return activation zcc key for searching spell's settings in source object
*
* @param source
* @param game
* @return
*/
public static String getActivationKey(Ability source, Game game) {
// must use ZCC from the moment of spell's ability activation
int zcc = source.getSourceObjectZoneChangeCounter();
if (zcc == 0) {
// if ability is not activated yet (example: triggered ability checking the kicker conditional)
zcc = game.getState().getZoneChangeCounter(source.getSourceId());
}
if (zcc > 0 && (source.getAbilityType() == AbilityType.TRIGGERED)) {
// triggers or activated abilities moves to stack and card's ZCC is changed -- so you must use workaround to find spell's zcc
if (source.getAbilityType() == AbilityType.TRIGGERED || source.getAbilityType() == AbilityType.ACTIVATED) {
--zcc;
}
return zcc + ((kickerCosts.size() > 1) ? costText : "");
return zcc + "";
}
private String getActivationKey(Ability source, String costText, Game game) {
return getActivationKey(source, game) + ((kickerCosts.size() > 1) ? costText : "");
}
@Override

View file

@ -5,7 +5,6 @@
*/
package mage.abilities.mana.conditional;
import java.util.UUID;
import mage.ConditionalMana;
import mage.MageObject;
import mage.Mana;
@ -20,8 +19,9 @@ import mage.game.Game;
import mage.game.stack.Spell;
import mage.game.stack.StackObject;
import java.util.UUID;
/**
*
* @author LevelX2
*/
public class ConditionalSpellManaBuilder extends ConditionalManaBuilder {
@ -66,9 +66,9 @@ class SpellCastManaCondition extends ManaCondition implements Condition {
if (source instanceof SpellAbility) {
MageObject object = game.getObject(source.getSourceId());
if (game.inCheckPlayableState() && object instanceof Card) {
Spell spell = new Spell((Card) object, (SpellAbility) source, source.getControllerId(), game.getState().getZone(source.getSourceId()));
return spell != null && filter.match(spell, source.getSourceId(), source.getControllerId(), game);
}
Spell spell = new Spell((Card) object, (SpellAbility) source, source.getControllerId(), game.getState().getZone(source.getSourceId()), game);
return filter.match(spell, source.getSourceId(), source.getControllerId(), game);
}
if ((object instanceof StackObject)) {
return filter.match((StackObject) object, source.getSourceId(), source.getControllerId(), game);
}

View file

@ -453,8 +453,8 @@ public abstract class CardImpl extends MageObjectImpl implements Card {
public boolean cast(Game game, Zone fromZone, SpellAbility ability, UUID controllerId) {
Card mainCard = getMainCard();
ZoneChangeEvent event = new ZoneChangeEvent(mainCard.getId(), ability, controllerId, fromZone, Zone.STACK);
ZoneChangeInfo.Stack info
= new ZoneChangeInfo.Stack(event, new Spell(this, ability.getSpellAbilityToResolve(game), controllerId, event.getFromZone()));
Spell spell = new Spell(this, ability.getSpellAbilityToResolve(game), controllerId, event.getFromZone(), game);
ZoneChangeInfo.Stack info = new ZoneChangeInfo.Stack(event, spell);
return ZonesHandler.cast(info, game, ability);
}

View file

@ -234,8 +234,9 @@ public final class ZonesHandler {
if (info instanceof ZoneChangeInfo.Stack && ((ZoneChangeInfo.Stack) info).spell != null) {
spell = ((ZoneChangeInfo.Stack) info).spell;
} else {
spell = new Spell(card, card.getSpellAbility().copy(), card.getOwnerId(), event.getFromZone());
spell = new Spell(card, card.getSpellAbility().copy(), card.getOwnerId(), event.getFromZone(), game);
}
spell.syncZoneChangeCounterOnStack(card, game);
game.getStack().push(spell);
game.getState().setZone(spell.getId(), Zone.STACK);
game.getState().setZone(card.getId(), Zone.STACK);

View file

@ -81,7 +81,7 @@ class MomirEffect extends OneShotEffect {
Card card = options.get(index).getCard();
if (card != null) {
token = new EmptyToken();
CardUtil.copyTo(token).from(card);
CardUtil.copyTo(token).from(card, game);
break;
} else {
options.remove(index);

View file

@ -25,6 +25,10 @@ public class PermanentToken extends PermanentImpl {
this.power.modifyBaseValue(token.getPower().getBaseValueModified());
this.toughness.modifyBaseValue(token.getToughness().getBaseValueModified());
this.copyFromToken(this.token, game, false); // needed to have at this time (e.g. for subtypes for entersTheBattlefield replacement effects)
// token's ZCC must be synced with original token to keep abilities settings
// Example: kicker ability and kicked status
this.setZoneChangeCounter(this.token.getZoneChangeCounter(game), game);
}
public PermanentToken(final PermanentToken permanent) {

View file

@ -25,7 +25,6 @@ import mage.game.GameState;
import mage.game.events.CopiedStackObjectEvent;
import mage.game.events.CopyStackObjectEvent;
import mage.game.events.GameEvent;
import mage.game.events.GameEvent.EventType;
import mage.game.events.ZoneChangeEvent;
import mage.game.permanent.Permanent;
import mage.game.permanent.PermanentCard;
@ -58,6 +57,7 @@ public class Spell extends StackObjImpl implements Card {
private final SpellAbility ability;
private final Zone fromZone;
private final UUID id;
protected int zoneChangeCounter; // spell's ZCC must be synced with card's on stack or another copied spell
private UUID controllerId;
private boolean copy;
@ -69,12 +69,13 @@ public class Spell extends StackObjImpl implements Card {
private ActivationManaAbilityStep currentActivatingManaAbilitiesStep = ActivationManaAbilityStep.BEFORE;
public Spell(Card card, SpellAbility ability, UUID controllerId, Zone fromZone) {
public Spell(Card card, SpellAbility ability, UUID controllerId, Zone fromZone, Game game) {
this.card = card;
this.color = card.getColor(null).copy();
this.frameColor = card.getFrameColor(null).copy();
this.frameStyle = card.getFrameStyle();
id = ability.getId();
this.id = ability.getId();
this.zoneChangeCounter = card.getZoneChangeCounter(game); // sync card's ZCC with spell (copy spell settings)
this.ability = ability;
this.ability.setControllerId(controllerId);
if (ability.getSpellAbilityType() == SpellAbilityType.SPLIT_FUSED) {
@ -93,6 +94,7 @@ public class Spell extends StackObjImpl implements Card {
public Spell(final Spell spell) {
this.id = spell.id;
this.zoneChangeCounter = spell.zoneChangeCounter;
for (SpellAbility spellAbility : spell.spellAbilities) {
this.spellAbilities.add(spellAbility.copy());
}
@ -265,7 +267,7 @@ public class Spell extends StackObjImpl implements Card {
flag = controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null);
} else {
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(card);
CardUtil.copyTo(token).from(card, game, this);
// The token that a resolving copy of a spell becomes isnt said to have been created. (2020-09-25)
if (token.putOntoBattlefield(1, game, ability, getControllerId(), false, false, null, false)) {
permId = token.getLastAddedToken();
@ -325,7 +327,7 @@ public class Spell extends StackObjImpl implements Card {
}
} else if (isCopy()) {
EmptyToken token = new EmptyToken();
CardUtil.copyTo(token).from(card);
CardUtil.copyTo(token).from(card, game, this);
// The token that a resolving copy of a spell becomes isnt said to have been created. (2020-09-25)
token.putOntoBattlefield(1, game, ability, getControllerId(), false, false, null, false);
return true;
@ -744,8 +746,8 @@ public class Spell extends StackObjImpl implements Card {
return new Spell(this);
}
public Spell copySpell(UUID newController) {
Spell spellCopy = new Spell(this.card, this.ability.copySpell(), this.controllerId, this.fromZone);
public Spell copySpell(UUID newController, Game game) {
Spell spellCopy = new Spell(this.card, this.ability.copySpell(), this.controllerId, this.fromZone, game);
boolean firstDone = false;
for (SpellAbility spellAbility : this.getSpellAbilities()) {
if (!firstDone) {
@ -758,6 +760,7 @@ public class Spell extends StackObjImpl implements Card {
}
spellCopy.setCopy(true, this);
spellCopy.setControllerId(newController);
spellCopy.syncZoneChangeCounterOnStack(this, game);
return spellCopy;
}
@ -850,16 +853,6 @@ public class Spell extends StackObjImpl implements Card {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public void updateZoneChangeCounter(Game game, ZoneChangeEvent event) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public void setZoneChangeCounter(int value, Game game) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public Ability getStackAbility() {
return this.ability;
@ -877,7 +870,38 @@ public class Spell extends StackObjImpl implements Card {
@Override
public int getZoneChangeCounter(Game game) {
return card.getZoneChangeCounter(game);
// spell's zcc can't be changed after put to stack
return zoneChangeCounter;
}
@Override
public void updateZoneChangeCounter(Game game, ZoneChangeEvent event) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public void setZoneChangeCounter(int value, Game game) {
throw new UnsupportedOperationException("Unsupported operation");
}
/**
* Sync ZCC with card on stack
*
* @param card
* @param game
*/
public void syncZoneChangeCounterOnStack(Card card, Game game) {
this.zoneChangeCounter = card.getZoneChangeCounter(game);
}
/**
* Sync ZCC with copy spell on stack
*
* @param spell
* @param game
*/
public void syncZoneChangeCounterOnStack(Spell spell, Game game) {
this.zoneChangeCounter = spell.getZoneChangeCounter(game);
}
@Override
@ -1048,7 +1072,7 @@ public class Spell extends StackObjImpl implements Card {
return null;
}
for (int i = 0; i < gameEvent.getAmount(); i++) {
spellCopy = this.copySpell(newControllerId);
spellCopy = this.copySpell(newControllerId, game);
game.getState().setZone(spellCopy.getId(), Zone.STACK); // required for targeting ex: Nivmagus Elemental
game.getStack().push(spellCopy);
if (chooseNewTargets) {

View file

@ -1,4 +1,3 @@
package mage.util.functions;
import mage.MageObject;
@ -8,9 +7,11 @@ import mage.cards.Card;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.game.Game;
import mage.game.permanent.PermanentCard;
import mage.game.permanent.PermanentToken;
import mage.game.permanent.token.Token;
import mage.game.stack.Spell;
/**
* @author nantuko
@ -27,7 +28,7 @@ public class CopyTokenFunction implements Function<Token, Card> {
}
@Override
public Token apply(Card source) {
public Token apply(Card source, Game game) {
if (target == null) {
throw new IllegalArgumentException("Target can't be null");
}
@ -94,7 +95,22 @@ public class CopyTokenFunction implements Function<Token, Card> {
return target;
}
public Token from(Card source) {
return apply(source);
public Token from(Card source, Game game) {
return from(source, game, null);
}
public Token from(Card source, Game game, Spell spell) {
apply(source, game);
// token's ZCC must be synced with original card to keep abilities settings
// Example: kicker ability and kicked status
if (spell != null) {
// copied spell puts to battlefield as token, so that token's ZCC must be synced with spell instead card (card can be moved before resolve)
target.setZoneChangeCounter(spell.getZoneChangeCounter(game), game);
} else {
target.setZoneChangeCounter(source.getZoneChangeCounter(game), game);
}
return target;
}
}

View file

@ -1,10 +1,11 @@
package mage.util.functions;
import mage.game.Game;
/**
* @author nantuko
*/
@FunctionalInterface
public interface Function<X, Y> {
X apply(Y in);
X apply(Y in, Game game);
}