Token's zone change counter (ZCC) improves:

* Now token's zcc uses same logic as card's zcc: enters to battlefield with +1 zcc instead +0 zcc
* It can improve support of copied spells that store zcc related data (bug example: lost kicked status for copied spell/token);
* Kicker abilities - improved support with copied creature spells (example: Verazol, the Split Current, #7431, #7433);
* Refactor: simplified kicker code;
This commit is contained in:
Oleg Agafonov 2021-02-04 19:15:54 +04:00
parent 3eb57eade9
commit f38639e1db
24 changed files with 262 additions and 177 deletions

View file

@ -1,9 +1,6 @@
package mage.cards.b;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.common.continuous.BoostSourceEffect;
import mage.abilities.effects.common.continuous.GainAbilitySourceEffect;
@ -17,10 +14,10 @@ import mage.constants.SubType;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.stack.Spell;
import java.util.UUID;
/**
*
* @author TheElk801
*/
public final class BloodstoneGoblin extends CardImpl {
@ -70,15 +67,7 @@ class BloodstoneGoblinTriggeredAbility extends TriggeredAbilityImpl {
@Override
public boolean checkTrigger(GameEvent event, Game game) {
Spell spell = game.getStack().getSpell(event.getTargetId());
if (spell != null && spell.isControlledBy(controllerId)) {
for (Ability ability : spell.getAbilities()) {
if (ability instanceof KickerAbility && ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility()) > 0) {
return true;
}
}
}
return false;
return KickerAbility.getSpellKickedCount(game, event.getTargetId()) > 0;
}
@Override

View file

@ -1,11 +1,8 @@
package mage.cards.d;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.EntersBattlefieldTriggeredAbility;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.dynamicvalue.common.MultikickerCount;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.DamageTargetEffect;
@ -13,13 +10,14 @@ import mage.abilities.keyword.MultikickerAbility;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.game.Game;
import mage.target.common.TargetPlayerOrPlaneswalker;
import java.util.UUID;
/**
*
* @author jeffwadsworth
*/
public final class DeathforgeShaman extends CardImpl {
@ -69,8 +67,7 @@ class DeathforgeShamanEffect extends OneShotEffect {
@Override
public boolean apply(Game game, Ability source) {
DynamicValue value = MultikickerCount.instance;
int damage = value.calculate(game, source, this) * 2;
int damage = MultikickerCount.instance.calculate(game, source, this) * 2;
return new DamageTargetEffect(damage).apply(game, source);
}

View file

@ -1,30 +1,22 @@
package mage.cards.h;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.dynamicvalue.common.CountersSourceCount;
import mage.abilities.effects.common.DamagePlayersEffect;
import mage.abilities.effects.common.counter.AddCountersSourceEffect;
import mage.abilities.keyword.KickerAbility;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.abilities.keyword.TrampleAbility;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Outcome;
import mage.constants.TargetController;
import mage.constants.Zone;
import mage.constants.*;
import mage.counters.CounterType;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.stack.Spell;
import java.util.UUID;
/**
*
* @author TheElk801
*/
public final class HallarTheFirefletcher extends CardImpl {
@ -80,15 +72,10 @@ class HallarTheFirefletcherTriggeredAbility extends TriggeredAbilityImpl {
@Override
public boolean checkTrigger(GameEvent event, Game game) {
Spell spell = game.getStack().getSpell(event.getTargetId());
if (spell != null && spell.isControlledBy(controllerId)) {
for (Ability ability : spell.getAbilities()) {
if (ability instanceof KickerAbility && ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility()) > 0) {
return true;
}
}
if (!isControlledBy(event.getPlayerId())) {
return false;
}
return false;
return KickerAbility.getSpellKickedCount(game, event.getTargetId()) > 0;
}
@Override

View file

@ -13,9 +13,7 @@ import mage.constants.Zone;
import mage.filter.common.FilterCreaturePermanent;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.events.GameEvent.EventType;
import mage.game.permanent.Permanent;
import mage.game.stack.Spell;
import mage.players.Player;
import mage.target.common.TargetAnyTarget;
@ -75,18 +73,10 @@ class RumblingAftershocksTriggeredAbility extends TriggeredAbilityImpl {
@Override
public boolean checkTrigger(GameEvent event, Game game) {
Spell spell = game.getStack().getSpell(event.getTargetId());
if (spell != null && spell.isControlledBy(controllerId)) {
int damageAmount = 0;
for (Ability ability : spell.getAbilities()) {
if (ability instanceof KickerAbility) {
damageAmount += ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility());
}
}
if (damageAmount > 0) {
this.getEffects().get(0).setValue("damageAmount", damageAmount);
return true;
}
int kickedCount = KickerAbility.getSpellKickedCount(game, event.getTargetId());
if (kickedCount > 0) {
this.getEffects().get(0).setValue("damageAmount", kickedCount);
return true;
}
return false;
}

View file

@ -15,7 +15,6 @@ import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.*;
import mage.game.Game;
import mage.game.stack.Spell;
import mage.players.Player;
import java.util.Objects;
@ -64,17 +63,7 @@ enum ScourgeOfTheSkyclavesCondition implements Condition {
@Override
public boolean apply(Game game, Ability source) {
Spell spell = game.getSpell(source.getSourceId());
if (spell == null) {
return false;
}
for (Ability ability : spell.getAbilities()) {
if (ability instanceof KickerAbility
&& ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility()) > 0) {
return true;
}
}
return false;
return KickerAbility.getSpellKickedCount(game, source.getSourceId()) > 0;
}
}

View file

@ -76,7 +76,8 @@ enum VineGeckoPredicate implements ObjectPlayerPredicate<ObjectPlayer<Card>> {
if (watcher == null || watcher.checkPlayer(input.getPlayerId())) {
return false;
}
for (Ability ability : input.getObject().getAbilities()) {
for (Ability ability : input.getObject().getAbilities(game)) {
if (ability instanceof KickerAbility
&& ((KickerAbility) ability).getKickedCounter(game, input.getObject().getSpellAbility()) > 0) {
return true;

View file

@ -508,6 +508,51 @@ public class KickerTest extends CardTestPlayerBase {
assertCounterCount(playerA, "Academy Drake", CounterType.P1P1, 2);
}
@Test
public void test_ZCC_CopiedCreaturesSpellMustWork() {
// bug:
// Lost kicker status after creature spell copy by Verazol, the Split Current
// Verazol, the Split Current enters the battlefield with a +1/+1 counter on it for each mana spent to cast it.
// Whenever you cast a kicked spell, you may remove two +1/+1 counters from Verazol, the Split Current. If you
// do, copy that spell. You may choose new targets for that copy.
addCard(Zone.HAND, playerA, "Verazol, the Split Current", 1); // {X}{G}{U}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2 + 1);
addCard(Zone.BATTLEFIELD, playerA, "Island", 1);
//
// Multikicker {R}
// When Deathforge Shaman enters the battlefield, it deals damage to target player equal to twice the number of times it was kicked.
addCard(Zone.HAND, playerA, "Deathforge Shaman", 1); // {4}{R}
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 5 + 2 * 2); // for 2x kicker
// prepare varazol with 4x counters
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 3);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Verazol, the Split Current");
setChoice(playerA, "X=2"); // add 2 + 2 counters
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("prepare", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Verazol, the Split Current", 1);
checkPermanentCounters("prepare", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Verazol, the Split Current", CounterType.P1P1, 4);
// cast 2x kicked spell for 4x damage
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Deathforge Shaman");
setChoice(playerA, "Yes"); // 1x kick
setChoice(playerA, "Yes"); // 2x kick
setChoice(playerA, "No"); // stop multikicker
setChoice(playerA, "Yes"); // remove counters and activate verazol's copy
addTarget(playerA, playerA); // on resolve: target for copied spell
addTarget(playerA, playerB); // on resolve: target for original spell
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
//
checkLife("after", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 20 - 4);
checkLife("after", 1, PhaseStep.PRECOMBAT_MAIN, playerB, 20 - 4);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertAllCommandsUsed();
}
@Test
public void test_Single_OrimsChants() {
// bug:

View file

@ -1,4 +1,3 @@
package org.mage.test.cards.copy;
import mage.constants.PhaseStep;
@ -7,7 +6,6 @@ import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
*
* @author LevelX2
*/
public class MimicVatTest extends CardTestPlayerBase {
@ -25,31 +23,40 @@ public class MimicVatTest extends CardTestPlayerBase {
public void TestClone() {
addCard(Zone.BATTLEFIELD, playerA, "Island", 6);
// Imprint - Whenever a nontoken creature dies, you may exile that card. If you do, return each other card exiled with Mimic Vat to its owner's graveyard.
// {3}, {T}: Create a tokenonto the battlefield that's a copy of the exiled card. It gains haste. Exile it at the beginning of the next end step.
// {3}, {T}: Create a token that's a copy of the exiled card. It gains haste. Exile it at the beginning of the next end step.
addCard(Zone.BATTLEFIELD, playerA, "Mimic Vat", 1); // Artifact {3}
// {2}, {T}, Sacrifice a creature: Draw a card.
addCard(Zone.BATTLEFIELD, playerA, "Phyrexian Vault", 1);
// You may have Clone enter the battlefield as a copy of any creature on the battlefield.
addCard(Zone.HAND, playerA, "Clone", 1);// Creature {3}{U}
addCard(Zone.BATTLEFIELD, playerB, "Silvercoat Lion", 1);
// clone the opponent's creature
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Clone");
setChoice(playerA, "Yes"); // use clone on etb
setChoice(playerA, "Silvercoat Lion");
// kill clone and exile it (imprint into vat)
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}, {T}, Sacrifice a creature");
setChoice(playerA, "Yes");
setChoice(playerA, "Silvercoat Lion");
setChoice(playerA, "Yes"); // exile killed card by vat
// turn 3
// create a token from exile (imprinted card: clone)
activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "{3}, {T}: Create a token");
setChoice(playerA, "Yes"); // use clone on etb
setChoice(playerA, "Silvercoat Lion");
setStrictChooseMode(true);
setStopAt(3, PhaseStep.BEGIN_COMBAT);
execute();
assertAllCommandsUsed();
assertExileCount("Clone", 1);
assertPermanentCount(playerB, "Silvercoat Lion", 1);
assertPermanentCount(playerA, "Silvercoat Lion", 1);
}
@Test

View file

@ -20,11 +20,14 @@ import mage.game.MageObjectAttribute;
import mage.game.events.ZoneChangeEvent;
import mage.util.GameLog;
import mage.util.SubTypes;
import org.apache.log4j.Logger;
import java.util.*;
public abstract class MageObjectImpl implements MageObject {
private static final Logger logger = Logger.getLogger(MageObjectImpl.class);
protected UUID objectId;
protected String name;

View file

@ -3,7 +3,6 @@ package mage.abilities.condition.common;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.abilities.keyword.KickerAbility;
import mage.cards.Card;
import mage.game.Game;
@ -18,22 +17,7 @@ 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()) {
if (ability instanceof KickerAbility) {
if (((KickerAbility) ability).isKicked(game, source)) {
return true;
}
}
}
}
return false;
return KickerAbility.getSourceObjectKickedCount(game, source) > 0;
}
@Override

View file

@ -10,6 +10,8 @@ import mage.game.stack.Spell;
/**
* Kicker {X}
*
* @author JayDi85
*/
public enum GetKickerXValue implements DynamicValue {
@ -23,7 +25,7 @@ public enum GetKickerXValue implements DynamicValue {
// only one X value per card possible
// kicker can be calls multiple times (use getKickedCounter)
int finalValue = 0;
int countX = 0;
Spell spell = game.getSpellOrLKIStack(sourceAbility.getSourceId());
if (spell != null && spell.getSpellAbility() != null) {
int xValue = spell.getSpellAbility().getManaCostsToPay().getX();
@ -39,13 +41,13 @@ public enum GetKickerXValue implements DynamicValue {
if (haveVarCost) {
int kickedCount = ((KickerAbility) ability).getKickedCounter(game, sourceAbility);
if (kickedCount > 0) {
finalValue += kickedCount * xValue;
countX += kickedCount * xValue;
}
}
}
}
}
return finalValue;
return countX;
}
@Override

View file

@ -1,14 +1,14 @@
package mage.abilities.dynamicvalue.common;
import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.abilities.keyword.KickerAbility;
import mage.cards.Card;
import mage.game.Game;
/**
* Find permanent/spell kicked stats, can be used in ETB effects.
*
* @author LevelX2
*/
public enum MultikickerCount implements DynamicValue {
@ -16,16 +16,7 @@ public enum MultikickerCount implements DynamicValue {
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
int count = 0;
Card card = game.getCard(sourceAbility.getSourceId());
if (card != null) {
for (Ability ability : card.getAbilities(game)) {
if (ability instanceof KickerAbility) {
count += ((KickerAbility) ability).getKickedCounter(game, sourceAbility);
}
}
}
return count;
return KickerAbility.getSourceObjectKickedCount(game, sourceAbility);
}
@Override

View file

@ -8,9 +8,9 @@ import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.target.TargetCard;
import mage.util.CardUtil;
/**
*
* @author BetaSteward_at_googlemail.com
*/
public class AttachEffect extends OneShotEffect {
@ -37,10 +37,10 @@ public class AttachEffect extends OneShotEffect {
public boolean apply(Game game, Ability source) {
Permanent sourcePermanent = game.getPermanent(source.getSourceId());
if (sourcePermanent != null) {
// if it activating on the stack then allow +1 zcc
int zcc = game.getState().getZoneChangeCounter(sourcePermanent.getId());
if (zcc == source.getSourceObjectZoneChangeCounter()
|| zcc == source.getSourceObjectZoneChangeCounter() + 1
|| zcc == source.getSourceObjectZoneChangeCounter() + 2) {
if (zcc == CardUtil.getActualSourceObjectZoneChangeCounter(game, source)
|| zcc == CardUtil.getActualSourceObjectZoneChangeCounter(game, source) + 1) {
Permanent permanent = game.getPermanent(getTargetPointer().getFirst(game, source));
if (permanent != null) {
return permanent.addAttachment(source.getSourceId(), source, game);

View file

@ -1,21 +1,21 @@
package mage.abilities.keyword;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.StaticAbility;
import mage.abilities.costs.*;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.constants.AbilityType;
import mage.cards.Card;
import mage.constants.Outcome;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.stack.Spell;
import mage.players.Player;
import mage.util.CardUtil;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -45,7 +45,7 @@ import java.util.concurrent.ConcurrentHashMap;
* Otherwise, the spell is cast as if it did not have those targets. See rule
* 601.2c.
*
* @author LevelX2
* @author LevelX2, JayDi85
*/
public class KickerAbility extends StaticAbility implements OptionalAdditionalSourceCosts {
@ -203,17 +203,25 @@ public class KickerAbility extends StaticAbility implements OptionalAdditionalSo
* @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());
}
// Kicker activates in STACK zone so all zcc must be from "stack moment"
// Use cases:
// * resolving spell have same zcc (example: check kicker status in sorcery/instant);
// * copied spell have same zcc as source spell (see Spell.copySpell and zcc sync);
// * creature/token from resolved spell have +1 zcc after moved to battlefield (example: check kicker status in ETB triggers/effects);
// 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) {
// find object info from the source ability (it can be a permanent or a spell on stack, on the moment of trigger/resolve)
MageObject sourceObject = source.getSourceObject(game);
Zone sourceObjectZone = game.getState().getZone(sourceObject.getId());
int zcc = CardUtil.getActualSourceObjectZoneChangeCounter(game, source);
// find "stack moment" zcc:
// * permanent cards enters from STACK to BATTLEFIELD (+1 zcc)
// * permanent tokens enters from OUTSIDE to BATTLEFIELD (+1 zcc, see prepare code in TokenImpl.putOntoBattlefieldHelper)
// * spells and copied spells resolves on STACK (zcc not changes)
if (sourceObjectZone != Zone.STACK) {
--zcc;
}
return zcc + "";
}
@ -308,4 +316,44 @@ public class KickerAbility extends StaticAbility implements OptionalAdditionalSo
}
return sb.toString();
}
/**
* Find spell's kicked stats. Must be used on stack only, e.g. for SPELL_CAST events
*
* @param game
* @param spellId
* @return
*/
public static int getSpellKickedCount(Game game, UUID spellId) {
int count = 0;
Spell spell = game.getSpellOrLKIStack(spellId);
if (spell != null) {
for (Ability ability : spell.getAbilities(game)) {
if (ability instanceof KickerAbility) {
count += ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility());
}
}
}
return count;
}
/**
* Find source object's kicked stats. Can be used in any places, e.g. in ETB effects
*
* @param game
* @param abilityToCheck
* @return
*/
public static int getSourceObjectKickedCount(Game game, Ability abilityToCheck) {
MageObject sourceObject = abilityToCheck.getSourceObject(game);
int count = 0;
if (sourceObject instanceof Card) {
for (Ability ability : ((Card) sourceObject).getAbilities(game)) {
if (ability instanceof KickerAbility) {
count += ((KickerAbility) ability).getKickedCounter(game, abilityToCheck);
}
}
}
return count;
}
}

View file

@ -115,7 +115,7 @@ public abstract class ModalDoubleFacesCard extends CardImpl {
@Override
public Counters getCounters(Game game) {
return leftHalfCard.getCounters(game.getState());
return getCounters(game.getState());
}
@Override

View file

@ -6,7 +6,7 @@ import mage.constants.SuperType;
import mage.constants.TargetController;
import mage.filter.common.*;
import mage.filter.predicate.Predicates;
import mage.filter.predicate.mageobject.KickedPredicate;
import mage.filter.predicate.mageobject.KickedSpellPredicate;
import mage.filter.predicate.mageobject.MulticoloredPredicate;
import mage.filter.predicate.permanent.AnotherPredicate;
import mage.filter.predicate.permanent.AttackingPredicate;
@ -646,7 +646,7 @@ public final class StaticFilters {
public static final FilterSpell FILTER_SPELL_KICKED_A = new FilterSpell("a kicked spell");
static {
FILTER_SPELL_KICKED_A.add(KickedPredicate.instance);
FILTER_SPELL_KICKED_A.add(KickedSpellPredicate.instance);
FILTER_SPELL_KICKED_A.setLockedFilter(true);
}

View file

@ -1,35 +0,0 @@
package mage.filter.predicate.mageobject;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.keyword.KickerAbility;
import mage.filter.predicate.Predicate;
import mage.game.Game;
import mage.game.stack.Spell;
/**
* @author TheElk801
*/
public enum KickedPredicate implements Predicate<MageObject> {
instance;
@Override
public boolean apply(MageObject input, Game game) {
Spell spell = game.getSpell(input.getId());
if (spell == null) {
return false;
}
for (Ability ability : spell.getAbilities()) {
if (ability instanceof KickerAbility
&& ((KickerAbility) ability).getKickedCounter(game, spell.getSpellAbility()) > 0) {
return true;
}
}
return false;
}
@Override
public String toString() {
return "Kicked";
}
}

View file

@ -0,0 +1,29 @@
package mage.filter.predicate.mageobject;
import mage.MageObject;
import mage.abilities.keyword.KickerAbility;
import mage.filter.predicate.Predicate;
import mage.game.Game;
/**
* Find spell's kicked stats.
* <p>
* Warning, must be used for SPELL_CAST events only
* (if you need kicked stats in ETB effects then search object's abilities instead spell,
* see MultikickerCount as example)
*
* @author TheElk801
*/
public enum KickedSpellPredicate implements Predicate<MageObject> {
instance;
@Override
public boolean apply(MageObject input, Game game) {
return KickerAbility.getSpellKickedCount(game, input.getId()) > 0;
}
@Override
public String toString() {
return "Kicked";
}
}

View file

@ -35,6 +35,7 @@ import mage.util.Copyable;
import mage.util.ThreadLocalStringBuilder;
import mage.watchers.Watcher;
import mage.watchers.Watchers;
import org.apache.log4j.Logger;
import java.io.Serializable;
import java.util.*;
@ -52,6 +53,7 @@ import static java.util.Collections.emptyList;
*/
public class GameState implements Serializable, Copyable<GameState> {
private static final Logger logger = Logger.getLogger(GameState.class);
private static final ThreadLocalStringBuilder threadLocalBuilder = new ThreadLocalStringBuilder(1024);
public static final String COPIED_FROM_CARD_KEY = "CopiedFromCard";
@ -1225,9 +1227,10 @@ public class GameState implements Serializable, Copyable<GameState> {
}
public void updateZoneChangeCounter(UUID objectId) {
Integer value = getZoneChangeCounter(objectId);
int value = getZoneChangeCounter(objectId);
value++;
this.zoneChangeCounter.put(objectId, value);
setZoneChangeCounter(objectId, value);
// card is changing zone so clear state
if (cardState.containsKey(objectId)) {
this.cardState.get(objectId).clear();

View file

@ -328,6 +328,7 @@ public final class ZonesHandler {
return false;
}
}
if (!game.replaceEvent(event)) {
Zone fromZone = event.getFromZone();
if (event.getToZone() == Zone.BATTLEFIELD) {
@ -382,6 +383,11 @@ public final class ZonesHandler {
}
}
if (success) {
// change ZCC on real enter
// warning, tokens creation code uses same zcc logic as cards (+1 zcc on enter to battlefield)
// so if you want to change zcc logic here (but I know you don't) then change token code
// too in TokenImpl.putOntoBattlefieldHelper
// KickerTest do many tests for token's zcc
if (event.getToZone() == Zone.BATTLEFIELD && event.getTarget() != null) {
event.getTarget().updateZoneChangeCounter(game, event);
} else if (!(card instanceof Permanent)) {

View file

@ -6,6 +6,7 @@ import mage.abilities.costs.mana.ManaCost;
import mage.cards.Card;
import mage.constants.EmptyNames;
import mage.game.Game;
import mage.game.events.ZoneChangeEvent;
import mage.game.permanent.token.Token;
import java.util.UUID;
@ -119,6 +120,14 @@ public class PermanentToken extends PermanentImpl {
}
}
@Override
public void updateZoneChangeCounter(Game game, ZoneChangeEvent event) {
// token must change zcc on enters to battlefield (like cards do with stack),
// so it can keep abilities settings synced with copied spell/card
// example: kicker ability of copied creature spell
super.updateZoneChangeCounter(game, event);
}
@Override
public Card getMainCard() {
// token don't have game card, so return itself

View file

@ -198,51 +198,68 @@ public abstract class TokenImpl extends MageObjectImpl implements Token {
Token token = event.getToken();
int amount = event.getAmount();
List<Permanent> permanents = new ArrayList<>();
List<Permanent> permanentsEntered = new ArrayList<>();
String setCode = token instanceof TokenImpl ? ((TokenImpl) token).getSetCode(game, event.getSourceId()) : null;
List<Permanent> needTokens = new ArrayList<>();
List<Permanent> allowedTokens = new ArrayList<>();
// prepare tokens to enter
for (int i = 0; i < amount; i++) {
PermanentToken newToken = new PermanentToken(token, event.getPlayerId(), setCode, game); // use event.getPlayerId() because it can be replaced by replacement effect
game.getState().addCard(newToken);
permanents.add(newToken);
game.getPermanentsEntering().put(newToken.getId(), newToken);
newToken.setTapped(tapped);
// use event.getPlayerId() as controller cause it can be replaced by replacement effect
PermanentToken newPermanent = new PermanentToken(token, event.getPlayerId(), setCode, game);
game.getState().addCard(newPermanent);
needTokens.add(newPermanent);
game.getPermanentsEntering().put(newPermanent.getId(), newPermanent);
newPermanent.setTapped(tapped);
ZoneChangeEvent emptyEvent = new ZoneChangeEvent(newPermanent, newPermanent.getControllerId(), Zone.OUTSIDE, Zone.BATTLEFIELD);
// tokens zcc must simulate card's zcc too keep copied card/spell settings
// (example: etb's kicker ability of copied creature spell, see tests with Deathforge Shaman)
newPermanent.updateZoneChangeCounter(game, emptyEvent);
}
// check ETB effects
game.setScopeRelevant(true);
for (Permanent permanent : permanents) {
for (Permanent permanent : needTokens) {
if (permanent.entersBattlefield(source, game, Zone.OUTSIDE, true)) {
permanentsEntered.add(permanent);
allowedTokens.add(permanent);
} else {
game.getPermanentsEntering().remove(permanent.getId());
}
}
game.setScopeRelevant(false);
// put allowed tokens to play
int createOrder = game.getState().getNextPermanentOrderNumber();
for (Permanent permanent : permanentsEntered) {
for (Permanent permanent : allowedTokens) {
game.addPermanent(permanent, createOrder);
permanent.setZone(Zone.BATTLEFIELD, game);
game.getPermanentsEntering().remove(permanent.getId());
// keep tokens ids
if (token instanceof TokenImpl) {
((TokenImpl) token).lastAddedTokenIds.add(permanent.getId());
((TokenImpl) token).lastAddedTokenId = permanent.getId();
}
game.addSimultaneousEvent(new ZoneChangeEvent(permanent, permanent.getControllerId(), Zone.OUTSIDE, Zone.BATTLEFIELD));
// created token events
ZoneChangeEvent zccEvent = new ZoneChangeEvent(permanent, permanent.getControllerId(), Zone.OUTSIDE, Zone.BATTLEFIELD);
game.addSimultaneousEvent(zccEvent);
if (permanent instanceof PermanentToken && created) {
game.addSimultaneousEvent(new CreatedTokenEvent(source, (PermanentToken) permanent));
}
// must attack
if (attacking && game.getCombat() != null && game.getActivePlayerId().equals(permanent.getControllerId())) {
game.getCombat().addAttackingCreature(permanent.getId(), game, attackedPlayer);
}
// game logs
if (created) {
game.informPlayers(controller.getLogName() + " creates a " + permanent.getLogName() + " token");
} else {
game.informPlayers(permanent.getLogName() + " enters the battlefield as a token under " + controller.getLogName() + "'s control'");
}
}
game.getState().applyEffects(game); // Needed to do it here without LKIReset i.e. do get SwordOfTheMeekTest running correctly.
}

View file

@ -35,6 +35,7 @@ import mage.util.CardUtil;
import mage.util.GameLog;
import mage.util.ManaUtil;
import mage.util.SubTypes;
import org.apache.log4j.Logger;
import java.util.*;
@ -43,6 +44,8 @@ import java.util.*;
*/
public class Spell extends StackObjImpl implements Card {
private static final Logger logger = Logger.getLogger(Spell.class);
private final List<SpellAbility> spellAbilities = new ArrayList<>();
private final List<Card> spellCards = new ArrayList<>();

View file

@ -1149,4 +1149,24 @@ public final class CardUtil {
}
return CardUtil.getSourceLogName(game, " (source: ", source, ")", "");
}
/**
* Find actual ZCC of source object, works in any moment (even before source ability activated)
* <p>
* Use case for usage: if you want to get actual object ZCC before ability resolve
* (ability gets zcc after resolve/activate/trigger only -- ?wtf workaround to targets setup I think?)
*
* @param game
* @param source
* @return
*/
public static int getActualSourceObjectZoneChangeCounter(Game game, Ability source) {
// current object zcc, find from source object (it can be permanent or spell on stack)
int zcc = source.getSourceObjectZoneChangeCounter();
if (zcc == 0) {
// if ability is not activated yet then use current object's zcc (example: triggered etb ability checking the kicker conditional)
zcc = game.getState().getZoneChangeCounter(source.getSourceId());
}
return zcc;
}
}