diff --git a/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java b/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java new file mode 100644 index 000000000000..e19062bc9d89 --- /dev/null +++ b/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java @@ -0,0 +1,90 @@ +package mage.cards.z; + +import mage.MageInt; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.dynamicvalue.common.PermanentsOnBattlefieldCount; +import mage.abilities.effects.common.continuous.BoostSourceEffect; +import mage.abilities.effects.common.continuous.GainAbilityControlledSpellsEffect; +import mage.abilities.keyword.FlyingAbility; +import mage.abilities.keyword.OffspringAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.filter.common.FilterCreaturePermanent; +import mage.filter.common.FilterNonlandCard; +import mage.filter.predicate.mageobject.AnotherPredicate; +import mage.filter.predicate.mageobject.BasePowerPredicate; +import java.util.UUID; + +import static mage.abilities.dynamicvalue.common.StaticValue.get; +import static mage.constants.Duration.WhileOnBattlefield; + + +/** + * Zinnia, Valley's Voice + * + * Legendary Creature — Bird Bard + * + * Flying + * Zinnia, Valley's Voice gets +X/+0, where X is the number of other creatures + * you control with base power 1. + * Creature spells you cast have offspring {2}. + * + * @author DreamWaker and sneddigrolyat + */ +public final class ZinniaValleysVoice extends CardImpl { + + // "other creatures you control with base power 1" + private static final FilterCreaturePermanent bfilter = new FilterCreaturePermanent("other creatures you control with base power 1"); + + static { + bfilter.add(new BasePowerPredicate(ComparisonType.EQUAL_TO, 1)); + bfilter.add(TargetController.YOU.getControllerPredicate()); + bfilter.add(AnotherPredicate.instance); + } + + private static final PermanentsOnBattlefieldCount bcount = new PermanentsOnBattlefieldCount(bfilter); + + // "creature spells you cast" + static final FilterNonlandCard cfilter = new FilterNonlandCard("creature spells you cast"); + + static { + cfilter.add(CardType.CREATURE.getPredicate()); + } + + + public ZinniaValleysVoice(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{U}{R}{W}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.BIRD); + this.subtype.add(SubType.BARD); + this.power = new MageInt(1); + this.toughness = new MageInt(3); + + // Flying + this.addAbility(FlyingAbility.getInstance()); + + // Creature spells you cast have offspring {2}. + this.addAbility(new SimpleStaticAbility( + new GainAbilityControlledSpellsEffect(new OffspringAbility("{2}"), cfilter) + .setText("Creature spells you cast have offspring {2}.") + )); + + // Zinnia, Valley's Voice gets +X/+0, + // where X is the number of other creatures you control with base power 1. + this.addAbility(new SimpleStaticAbility( + new BoostSourceEffect(bcount, get(0), WhileOnBattlefield) + )); + } + + private ZinniaValleysVoice(final ZinniaValleysVoice card) { + super(card); + } + + @Override + public ZinniaValleysVoice copy() { + return new ZinniaValleysVoice(this); + } +} + diff --git a/Mage.Sets/src/mage/sets/BloomburrowCommander.java b/Mage.Sets/src/mage/sets/BloomburrowCommander.java index 4db413a3a5a8..0fc947a4f81a 100644 --- a/Mage.Sets/src/mage/sets/BloomburrowCommander.java +++ b/Mage.Sets/src/mage/sets/BloomburrowCommander.java @@ -371,6 +371,7 @@ private BloomburrowCommander() { cards.add(new SetCardInfo("Wooded Ridgeline", 353, Rarity.COMMON, mage.cards.w.WoodedRidgeline.class)); cards.add(new SetCardInfo("Woodland Cemetery", 354, Rarity.RARE, mage.cards.w.WoodlandCemetery.class)); cards.add(new SetCardInfo("Yavimaya Coast", 355, Rarity.RARE, mage.cards.y.YavimayaCoast.class)); + cards.add(new SetCardInfo("Zinnia, Valley's Voice", 4, Rarity.MYTHIC, mage.cards.z.ZinniaValleysVoice.class)); cards.add(new SetCardInfo("Zulaport Cutthroat", 190, Rarity.UNCOMMON, mage.cards.z.ZulaportCutthroat.class)); } } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java index bb7b1fc91786..27a44fbb6d87 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java @@ -1,7 +1,12 @@ package org.mage.test.cards.abilities.keywords; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.common.continuous.GainAbilityControlledSpellsEffect; +import mage.abilities.keyword.OffspringAbility; +import mage.constants.CardType; import mage.constants.PhaseStep; import mage.constants.Zone; +import mage.filter.common.FilterNonlandCard; import mage.game.permanent.Permanent; import mage.game.permanent.PermanentToken; import org.junit.Assert; @@ -14,6 +19,33 @@ public class OffspringTest extends CardTestPlayerBase { private static final String vinelasher = "Iridescent Vinelasher"; + private static final String bandit = "Prosperous Bandit"; + private static final String lion = "Silvercoat Lion"; + private static final String genericGrantSource = "Generic Offspring Grant Source"; + + // CR 702.175a/b: Offspring is an additional cost and creates a linked ETB trigger; multiple instances are paid separately. + // CR 607.2i, 607.5: linked abilities remain linked per instance, including abilities gained from other effects. + // Keep these mechanic regressions generic; actual Zinnia coverage stays in ZinniaValleysVoiceTest. + + private void addGenericOffspringGrantSource(String name) { + // Zinnia is currently the only printed card in the test card pool that grants Offspring to creature spells. + // This helper isolates the generic granted-Offspring engine path and lets these tests cover multiple + // independent grant sources without mixing in Zinnia-specific copy/legend/creature behavior. + FilterNonlandCard creatureSpells = new FilterNonlandCard("creature spells"); + creatureSpells.add(CardType.CREATURE.getPredicate()); + addCustomCardWithAbility( + name, + playerA, + new SimpleStaticAbility( + Zone.BATTLEFIELD, + new GainAbilityControlledSpellsEffect(new OffspringAbility("{2}"), creatureSpells) + ), + null, + CardType.ENCHANTMENT, + "", + Zone.BATTLEFIELD + ); + } private Permanent getCreature(String name, boolean isToken) { for (Permanent permanent : currentGame.getBattlefield().getActivePermanents(playerA.getId(), currentGame)) { @@ -80,4 +112,209 @@ public void testPay() { checkOffspring(vinelasher, 1, 2, true); } + + @Test + public void testHumilityInResponseNoCopy() { + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 3); + addCard(Zone.HAND, playerA, vinelasher); + + addCard(Zone.BATTLEFIELD, playerB, "Plains", 4); + addCard(Zone.BATTLEFIELD, playerB, "Vedalken Orrery"); + addCard(Zone.HAND, playerB, "Humility"); + + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, vinelasher); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Humility", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, vinelasher, 1); + assertTokenCount(playerA, vinelasher, 0); + assertPowerToughness(playerA, vinelasher, 1, 1); + } + + @Test + public void testHumilityInResponseNoCopyWithPrintedAndGrantedOffspring() { + // Use a generic noncreature grant source so this stays a mechanic regression, not a Zinnia card test. + addGenericOffspringGrantSource(genericGrantSource); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5); + addCard(Zone.HAND, playerA, vinelasher); + + addCard(Zone.BATTLEFIELD, playerB, "Plains", 4); + addCard(Zone.BATTLEFIELD, playerB, "Vedalken Orrery"); + addCard(Zone.HAND, playerB, "Humility"); + + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, vinelasher); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Humility", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, vinelasher, 1); + assertTokenCount(playerA, vinelasher, 0); + assertPowerToughness(playerA, vinelasher, 1, 1); + } + + @Test + public void testTwoGrantedOffspringAbilitiesOnePayment() { + addGenericOffspringGrantSource("Generic Offspring Grant Source A"); + addGenericOffspringGrantSource("Generic Offspring Grant Source B"); + + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.HAND, playerA, lion); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 2); + assertTokenCount(playerA, lion, 1); + } + + @Test + public void testGrantedOffspringSourceRemovedBeforeEtbNoCopy() { + addGenericOffspringGrantSource(genericGrantSource); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.HAND, playerA, lion); + addCard(Zone.HAND, playerA, "Disenchant"); + + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Disenchant", genericGrantSource); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 1); + assertTokenCount(playerA, lion, 0); + } + + @Test + public void testPrintedAndGrantedOffspringOnePayment() { + addGenericOffspringGrantSource(genericGrantSource); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 2); + assertTokenCount(playerA, bandit, 1); + } + + @Test + public void testPrintedAndGrantedOffspringTwoPayments() { + addGenericOffspringGrantSource(genericGrantSource); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, bandit); // stack both offspring triggers (2 triggers -> 1 choice) + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 3); + assertTokenCount(playerA, bandit, 2); + } + + @Test + public void testCopyingSpellMustKeepOffspringStatus() { + addCard(Zone.HAND, playerA, bandit, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 4); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 1); + addCard(Zone.BATTLEFIELD, playerA, "Island", 1); + addCard(Zone.HAND, playerA, "Double Major", 1); + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {R}", 4); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, true); // pay offspring once + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Double Major", bandit, bandit, StackClause.WHILE_ON_STACK); + checkStackSize("before copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // bandit + double major + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + checkStackSize("after copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // spell + copy + + setStrictChooseMode(false); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 4); + assertTokenCount(playerA, bandit, 3); + } + + @Test + public void testCopyingEtbTriggerMustKeepOffspringStatus() { + addCard(Zone.HAND, playerA, bandit, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.BATTLEFIELD, playerA, "Strionic Resonator", 1); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, true); // pay offspring once + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + + // bandit resolved, its offspring trigger is on the stack; copy that trigger + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, + "{2}, {T}: Copy target triggered ability you control. You may choose new targets for the copy."); + + setStrictChooseMode(false); + setStopAt(1, PhaseStep.PRECOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, bandit, 3); + assertTokenCount(playerA, bandit, 2); + } + + @Test + public void testPrintedAndGrantedOffspringRollbackClearsOldPayments() { + addGenericOffspringGrantSource(genericGrantSource); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + // first line: pay both offspring costs, then roll back to the start of the turn + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, bandit); + + rollbackTurns(1, PhaseStep.BEGIN_COMBAT, playerA, 0); + rollbackAfterActionsStart(); + + // after rollback, only the printed offspring payment should be remembered + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + + rollbackAfterActionsEnd(); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 2); + assertTokenCount(playerA, bandit, 1); + } + } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java new file mode 100644 index 000000000000..296950e87428 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java @@ -0,0 +1,157 @@ +package org.mage.test.cards.single.blc; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +public class ZinniaValleysVoiceTest extends CardTestPlayerBase { + + private static final String zinnia = "Zinnia, Valley's Voice"; + private static final String lion = "Silvercoat Lion"; + private static final String bandit = "Prosperous Bandit"; + + // CR 702.175a/b: Offspring is an additional cost and creates a linked ETB trigger; multiple instances are paid separately. + // CR 607.2i, 607.5: linked abilities remain linked per instance, including abilities gained from other effects. + // Keep actual Zinnia integration here; generic granted-Offspring regressions live in OffspringTest. + + @Test + public void testGrantsOffspringToCreatureSpells() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + addCard(Zone.HAND, playerA, lion); + + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 2); + assertTokenCount(playerA, lion, 1); + } + + @Test + public void testZinniaRemovedBeforeGrantedOffspringEtbNoCopy() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 5); + addCard(Zone.HAND, playerA, lion); + addCard(Zone.HAND, playerA, "Path to Exile"); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Path to Exile", zinnia); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 1); + assertTokenCount(playerA, lion, 0); + } + + @Test + public void testSparkDoubleCopyOfZinniaOneGrantedPayment() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 8); + addCard(Zone.BATTLEFIELD, playerA, "Island", 4); + addCard(Zone.HAND, playerA, "Spark Double"); + addCard(Zone.HAND, playerA, lion); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spark Double"); + setChoice(playerA, true); + setChoice(playerA, true); + setChoice(playerA, zinnia); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, zinnia, 2); + assertPermanentCount(playerA, lion, 2); + assertTokenCount(playerA, lion, 1); + } + + @Test + public void testSparkDoubleCopyOfZinniaTwoGrantedPayments() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 8); + addCard(Zone.BATTLEFIELD, playerA, "Island", 4); + addCard(Zone.HAND, playerA, "Spark Double"); + addCard(Zone.HAND, playerA, lion); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spark Double"); + setChoice(playerA, true); + setChoice(playerA, true); + setChoice(playerA, zinnia); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + setChoice(playerA, lion); // stack both granted offspring triggers (2 triggers -> 1 choice) + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, zinnia, 2); + assertPermanentCount(playerA, lion, 3); + assertTokenCount(playerA, lion, 2); + } + + @Test + public void testRemoveZinniaWhileOffspringTriggersOnStackBothStillResolve() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + addCard(Zone.BATTLEFIELD, playerB, "Plains"); + addCard(Zone.HAND, playerB, "Path to Exile"); + + setChoice(playerA, true); // Pay printed offspring {1} + setChoice(playerA, true); // Pay granted offspring {2} + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, bandit); // stack both offspring triggers (2 triggers -> 1 choice) + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Path to Exile", zinnia, "create a 1/1 token copy of it."); + setChoice(playerA, false); // Decline Path's basic land search + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, zinnia, 0); + assertPermanentCount(playerA, bandit, 3); + assertTokenCount(playerA, bandit, 2); + } + + @Test + public void testPanharmoniconWithPrintedAndGrantedOffspring() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Panharmonicon"); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + setChoice(playerA, true); // Pay printed offspring {1} + setChoice(playerA, true); // Pay granted offspring {2} + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, bandit, 3); // stack four offspring triggers (4 triggers -> 3 choices) + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 5); + assertTokenCount(playerA, bandit, 4); + } +} diff --git a/Mage/src/main/java/mage/abilities/Ability.java b/Mage/src/main/java/mage/abilities/Ability.java index c05feea8548f..0deea7a8492f 100644 --- a/Mage/src/main/java/mage/abilities/Ability.java +++ b/Mage/src/main/java/mage/abilities/Ability.java @@ -38,11 +38,24 @@ public interface Ability extends Controllable, Serializable { */ void newId(); + /** + * Assigns a new {@link java.util.UUID} while preserving the linkage id used + * to associate linked abilities. + */ + void newIdKeepingLinkage(); + /** * Assigns a new {@link java.util.UUID} */ void newOriginalId(); // TODO: delete newOriginalId??? + /** + * Remap this ability to a deterministic identity for a specific granting/copy seed. + * Implementations should keep the identity stable for the same seed while making it + * distinct from other sources. + */ + void remapForSource(UUID sourceSeed); + /** * Gets the {@link AbilityType} of this ability. * @@ -460,6 +473,13 @@ default boolean hasTapCost() { */ UUID getOriginalId(); + /** + * Get the linkage id used to associate linked abilities (CR 607). + * + * @return linkage id + */ + UUID getLinkageId(); + /** * Sets the ability word for the given ability. An ability word is a word * that, in essence, groups, and reminds players of, cards that have a diff --git a/Mage/src/main/java/mage/abilities/AbilityImpl.java b/Mage/src/main/java/mage/abilities/AbilityImpl.java index 4d0f3cec9309..6b8cf4691d3d 100644 --- a/Mage/src/main/java/mage/abilities/AbilityImpl.java +++ b/Mage/src/main/java/mage/abilities/AbilityImpl.java @@ -51,6 +51,7 @@ import mage.watchers.Watcher; import org.apache.log4j.Logger; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.stream.Collectors; @@ -65,6 +66,7 @@ public abstract class AbilityImpl implements Ability { protected UUID id; private UUID originalId; // TODO: delete originalId??? + private UUID linkageId; protected AbilityType abilityType; protected UUID controllerId; protected UUID sourceId; @@ -101,6 +103,7 @@ public abstract class AbilityImpl implements Ability { protected AbilityImpl(AbilityType abilityType, Zone zone) { this.id = UUID.randomUUID(); this.originalId = id; + this.linkageId = UUID.randomUUID(); this.abilityType = abilityType; this.zone = zone; this.manaCosts = new ManaCostsImpl<>(); @@ -112,6 +115,7 @@ protected AbilityImpl(AbilityType abilityType, Zone zone) { protected AbilityImpl(final AbilityImpl ability) { this.id = ability.id; this.originalId = ability.originalId; + this.linkageId = ability.linkageId; this.abilityType = ability.abilityType; this.controllerId = ability.controllerId; this.sourceId = ability.sourceId; @@ -154,21 +158,75 @@ public UUID getId() { @Override public void newId() { + newIdInternal(UUID.randomUUID()); + } + + @Override + public void newIdKeepingLinkage() { + newIdInternal(this.linkageId); + } + + @Override + public void newOriginalId() { + newOriginalIdInternal(this.linkageId); + } + + @Override + public void remapForSource(UUID sourceSeed) { + remapForSourceInternal( + sourceSeed, + deterministicId(sourceSeed, "linkage", this.linkageId) + ); + } + + protected void newIdInternal(UUID newLinkageId) { if (!(this instanceof MageSingleton)) { this.id = UUID.randomUUID(); } + setLinkageIdInternal(newLinkageId); getEffects().newId(); - for (Ability sub : getSubAbilities()) { - sub.newId(); + if (sub instanceof AbilityImpl) { + ((AbilityImpl) sub).newIdInternal(newLinkageId); + } else { + sub.newId(); + } } } - @Override - public void newOriginalId() { + protected void newOriginalIdInternal(UUID newLinkageId) { this.id = UUID.randomUUID(); this.originalId = id; + setLinkageIdInternal(newLinkageId); getEffects().newId(); + for (Ability sub : getSubAbilities()) { + if (sub instanceof AbilityImpl) { + ((AbilityImpl) sub).newOriginalIdInternal(newLinkageId); + } else { + sub.newId(); + } + } + } + + protected void remapForSourceInternal(UUID sourceSeed, UUID newLinkageId) { + UUID newOriginalId = deterministicId(sourceSeed, "ability", this.originalId); + this.id = newOriginalId; + this.originalId = newOriginalId; + setLinkageIdInternal(newLinkageId); + getEffects().newId(); + for (Ability sub : getSubAbilities()) { + if (sub instanceof AbilityImpl) { + ((AbilityImpl) sub).remapForSourceInternal(sourceSeed, newLinkageId); + } else { + sub.remapForSource(sourceSeed); + } + } + } + + private static UUID deterministicId(UUID sourceSeed, String kind, UUID baseId) { + return UUID.nameUUIDFromBytes( + (kind + '|' + sourceSeed + '|' + baseId).getBytes(StandardCharsets.UTF_8) + ); } @Override @@ -201,6 +259,20 @@ public boolean isManaAbility() { return this.abilityType.isManaAbility(); } + @Override + public UUID getLinkageId() { + return linkageId; + } + + protected void setLinkageIdInternal(UUID linkageId) { + this.linkageId = linkageId; + for (Ability sub : getSubAbilities()) { + if (sub instanceof AbilityImpl) { + ((AbilityImpl) sub).setLinkageIdInternal(linkageId); + } + } + } + @Override public boolean resolve(Game game) { boolean result = true; @@ -1021,6 +1093,9 @@ public void addSubAbility(Ability ability) { } ability.setSourceId(this.sourceId); ability.setControllerId(this.controllerId); + if (ability instanceof AbilityImpl) { + ((AbilityImpl) ability).setLinkageIdInternal(this.linkageId); + } subAbilities.add(ability); } diff --git a/Mage/src/main/java/mage/abilities/SpellAbility.java b/Mage/src/main/java/mage/abilities/SpellAbility.java index a67afd43f9ab..86f387e9f560 100644 --- a/Mage/src/main/java/mage/abilities/SpellAbility.java +++ b/Mage/src/main/java/mage/abilities/SpellAbility.java @@ -269,7 +269,8 @@ public SpellAbility copySpell(Card originalCard, Card copiedCard) { UUID copiedSourceId = mapOldToNew.getOrDefault(this.getSourceId(), copiedCard).getId(); SpellAbility spell = new SpellAbility(this); - spell.newId(); + // keep linkage id stable so linked-ability tags remain readable on copied spells + spell.newIdKeepingLinkage(); spell.setSourceId(copiedSourceId); return spell; } diff --git a/Mage/src/main/java/mage/abilities/TriggeredAbilities.java b/Mage/src/main/java/mage/abilities/TriggeredAbilities.java index 1e745ea6294f..cedca8ddf079 100644 --- a/Mage/src/main/java/mage/abilities/TriggeredAbilities.java +++ b/Mage/src/main/java/mage/abilities/TriggeredAbilities.java @@ -228,7 +228,12 @@ private void checkTrigger(TriggeredAbility ability, GameEvent event, Game game) .map(p -> "- " + p.toString()) .collect(Collectors.joining("\n")) + "\n"); } - MageObject object = game.getObject(ability.getSourceId()); + // ETB triggers on copied permanent spells must validate against the entering permanent, + // not the copied card/spell object that still exists under the same source id. + MageObject object = game.getPermanentEntering(ability.getSourceId()); + if (object == null) { + object = game.getObject(ability.getSourceId()); + } if (ability.isInUseableZone(game, object, event)) { if (event == null || !game.getContinuousEffects().preventedByRuleModification(event, ability, game, false)) { if (object != null) { diff --git a/Mage/src/main/java/mage/abilities/effects/common/continuous/GainAbilityControlledSpellsEffect.java b/Mage/src/main/java/mage/abilities/effects/common/continuous/GainAbilityControlledSpellsEffect.java index 2947f24523e8..6b1070317e13 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/continuous/GainAbilityControlledSpellsEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/continuous/GainAbilityControlledSpellsEffect.java @@ -1,6 +1,7 @@ package mage.abilities.effects.common.continuous; import mage.abilities.Ability; +import mage.abilities.common.LinkedEffectIdStaticAbility; import mage.abilities.effects.ContinuousEffectImpl; import mage.cards.Card; import mage.constants.*; @@ -11,6 +12,9 @@ import mage.players.Player; import mage.util.CardUtil; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + /** * @author Styxo */ @@ -22,14 +26,14 @@ public class GainAbilityControlledSpellsEffect extends ContinuousEffectImpl { public GainAbilityControlledSpellsEffect(Ability ability, FilterNonlandCard filter) { super(Duration.WhileOnBattlefield, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility); this.ability = ability; - this.filter = filter; + this.filter = filter.copy(); staticText = filter.getMessage() + " have " + CardUtil.getTextWithFirstCharLowerCase(CardUtil.stripReminderText(ability.getRule())); } private GainAbilityControlledSpellsEffect(final GainAbilityControlledSpellsEffect effect) { super(effect); this.ability = effect.ability; - this.filter = effect.filter; + this.filter = effect.filter.copy(); } @Override @@ -46,22 +50,22 @@ public boolean apply(Game game, Ability source) { for (Card card : game.getExile().getCardsInRange(game, source.getControllerId())) { if (filter.match(card, player.getId(), source, game)) { - game.getState().addOtherAbility(card, ability); + game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false); } } for (Card card : player.getLibrary().getCards(game)) { if (filter.match(card, player.getId(), source, game)) { - game.getState().addOtherAbility(card, ability); + game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false); } } for (Card card : player.getHand().getCards(game)) { if (filter.match(card, player.getId(), source, game)) { - game.getState().addOtherAbility(card, ability); + game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false); } } for (Card card : player.getGraveyard().getCards(game)) { if (filter.match(card, player.getId(), source, game)) { - game.getState().addOtherAbility(card, ability); + game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false); } } @@ -69,7 +73,7 @@ public boolean apply(Game game, Ability source) { game.getCommanderCardsFromCommandZone(player, CommanderCardType.ANY) .stream() .filter(card -> filter.match(card, player.getId(), source, game)) - .forEach(card -> game.getState().addOtherAbility(card, ability)); + .forEach(card -> game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false)); for (StackObject stackObject : game.getStack()) { if (!(stackObject instanceof Spell) || !stackObject.isControlledBy(source.getControllerId())) { @@ -78,9 +82,30 @@ public boolean apply(Game game, Ability source) { // TODO: Distinguish "you cast" to exclude copies Card card = game.getCard(stackObject.getSourceId()); if (card != null && filter.match((Spell) stackObject, player.getId(), source, game)) { - game.getState().addOtherAbility(card, ability); + game.getState().addOtherAbility(card, copyAbilityForCard(card, source), false); } } return true; } + + private Ability copyAbilityForCard(Card attachedTo, Ability source) { + Ability abilityToCopy = ability.copy(); + abilityToCopy.remapForSource(buildGrantSeed(attachedTo, source)); + if (abilityToCopy instanceof LinkedEffectIdStaticAbility) { + ((LinkedEffectIdStaticAbility) abilityToCopy).setEffectIdManually(); + } + return abilityToCopy; + } + + private UUID buildGrantSeed(Card attachedTo, Ability source) { + return UUID.nameUUIDFromBytes(( + "GainAbilityControlledSpellsEffect" + + '|' + + source.getSourceId() + + '|' + + source.getOriginalId() + + '|' + + attachedTo.getId() + ).getBytes(StandardCharsets.UTF_8)); + } } diff --git a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java index 8be10098297e..021720de2ab6 100644 --- a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java @@ -4,7 +4,6 @@ import mage.abilities.SpellAbility; import mage.abilities.StaticAbility; import mage.abilities.common.EntersBattlefieldTriggeredAbility; -import mage.abilities.condition.Condition; import mage.abilities.costs.*; import mage.abilities.costs.mana.ManaCostsImpl; import mage.abilities.effects.OneShotEffect; @@ -12,27 +11,40 @@ import mage.constants.Outcome; import mage.constants.Zone; import mage.game.Game; +import mage.game.events.GameEvent; import mage.game.permanent.Permanent; import mage.players.Player; import mage.util.CardUtil; +import java.util.List; +import java.util.UUID; + /** * @author TheElk801 */ public class OffspringAbility extends StaticAbility implements OptionalAdditionalSourceCosts { - + private static final String keywordText = "Offspring"; private static final String reminderText = "You may pay an additional %s as you cast this spell. If you do, when this creature enters, create a 1/1 token copy of it."; private final String rule; + private final OffspringTriggeredAbility offspringTriggeredAbility; public static final String OFFSPRING_ACTIVATION_VALUE_KEY = "offspringActivation"; protected OptionalAdditionalCost additionalCost; - public OffspringAbility(String manaString) { - this(new ManaCostsImpl<>(manaString)); + static String getActivationValueKey(Ability ability) { + return CardUtil.getLinkedCostTag(ability, OFFSPRING_ACTIVATION_VALUE_KEY); } + String getActivationValueKey() { + return getActivationValueKey(offspringTriggeredAbility); + } + + public OffspringAbility(String manaString) { + this(new ManaCostsImpl<>(manaString)); + } + public OffspringAbility(Cost cost) { super(Zone.STACK, null); this.additionalCost = new OptionalAdditionalCostImpl( @@ -41,22 +53,27 @@ public OffspringAbility(Cost cost) { ); this.additionalCost.setRepeatable(false); this.rule = additionalCost.getName() + ' ' + additionalCost.getReminderText(); + this.offspringTriggeredAbility = new OffspringTriggeredAbility(); + this.addSubAbility(offspringTriggeredAbility); this.setRuleAtTheTop(true); - this.addSubAbility(new EntersBattlefieldTriggeredAbility(new OffspringEffect()) - .withInterveningIf(OffspringCondition.instance).setRuleVisible(false)); } private OffspringAbility(final OffspringAbility ability) { super(ability); this.rule = ability.rule; this.additionalCost = ability.additionalCost.copy(); + this.offspringTriggeredAbility = this.getSubAbilities().stream() + .filter(OffspringTriggeredAbility.class::isInstance) + .map(OffspringTriggeredAbility.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Offspring triggered ability wasn't found")); } @Override public OffspringAbility copy() { return new OffspringAbility(this); - } - + } + @Override public void addOptionalAdditionalCosts(Ability ability, Game game) { if (!(ability instanceof SpellAbility)) { @@ -75,39 +92,47 @@ public void addOptionalAdditionalCosts(Ability ability, Game game) { for (Cost cost : ((Costs) additionalCost)) { ability.getCosts().add(cost.copy()); } - ability.setCostsTag(OFFSPRING_ACTIVATION_VALUE_KEY, null); + ability.setCostsTag(getActivationValueKey(), null); } @Override public String getCastMessageSuffix() { return additionalCost.getCastSuffixMessage(0); - } - + } + @Override public String getRule() { return rule; } -} +} + class OffspringEffect extends OneShotEffect { OffspringEffect() { super(Outcome.Benefit); staticText = "create a 1/1 token copy of it"; - } - - private OffspringEffect(final OffspringEffect effect) { - super(effect); - } - - @Override - public OffspringEffect copy() { - return new OffspringEffect(this); - } - + } + + private OffspringEffect(final OffspringEffect effect) { + super(effect); + } + + @Override + public OffspringEffect copy() { + return new OffspringEffect(this); + } + @Override public boolean apply(Game game, Ability source) { - Permanent permanent = source.getSourcePermanentOrLKI(game); + Permanent permanent = null; + List pointerTargets = getTargetPointer().getTargets(game, source); + if (pointerTargets != null && !pointerTargets.isEmpty()) { + permanent = getTargetPointer().getFirstTargetPermanentOrLKI(game, source); + } + if (permanent == null) { + permanent = source.getSourcePermanentOrLKI(game); + } return permanent != null && new CreateTokenCopyTargetEffect( null, null, false, 1, false, false, null, 1, 1, false @@ -115,16 +140,30 @@ public boolean apply(Game game, Ability source) { } } -enum OffspringCondition implements Condition { - instance; +class OffspringTriggeredAbility extends EntersBattlefieldTriggeredAbility { + + OffspringTriggeredAbility() { + super(new OffspringEffect(), false); + setTriggerPhrase("When this permanent enters, "); + this.setRuleVisible(false); + } + + private OffspringTriggeredAbility(final OffspringTriggeredAbility ability) { + super(ability); + } @Override - public boolean apply(Game game, Ability source) { - return CardUtil.checkSourceCostsTagExists(game, source, OffspringAbility.OFFSPRING_ACTIVATION_VALUE_KEY); + public OffspringTriggeredAbility copy() { + return new OffspringTriggeredAbility(this); } @Override - public String toString() { - return "its offspring cost was paid"; + public boolean checkTrigger(GameEvent event, Game game) { + if (!super.checkTrigger(event, game)) { + return false; + } + return CardUtil.checkSourceCostsTagExists( + game, this, OffspringAbility.getActivationValueKey(this) + ); } } diff --git a/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java b/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java index 7ecb7903e15f..6097a2569e48 100644 --- a/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java @@ -98,7 +98,7 @@ public void addOptionalAdditionalCosts(Ability ability, Game game) { again = false; } } - ability.setCostsTag(SQUAD_ACTIVATION_VALUE_KEY, cost.getActivateCount()); + ability.setCostsTag(CardUtil.getLinkedCostTag(this, SQUAD_ACTIVATION_VALUE_KEY), cost.getActivateCount()); } @Override @@ -134,7 +134,8 @@ public SquadTriggerAbility copy() { @Override public boolean checkInterveningIfClause(Game game) { - int squadCount = CardUtil.getSourceCostsTag(game, this, SquadAbility.SQUAD_ACTIVATION_VALUE_KEY, 0); + int squadCount = CardUtil.getSourceCostsTag(game, this, + CardUtil.getLinkedCostTag(this, SquadAbility.SQUAD_ACTIVATION_VALUE_KEY), 0); return (squadCount > 0); } @@ -162,7 +163,8 @@ public SquadEffectETB copy() { @Override public boolean apply(Game game, Ability source) { - int squadCount = CardUtil.getSourceCostsTag(game, source, SquadAbility.SQUAD_ACTIVATION_VALUE_KEY, 0); + int squadCount = CardUtil.getSourceCostsTag(game, source, + CardUtil.getLinkedCostTag(source, SquadAbility.SQUAD_ACTIVATION_VALUE_KEY), 0); CreateTokenCopySourceEffect effect = new CreateTokenCopySourceEffect(squadCount); return effect.apply(game, source); } diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 487e42929592..f4e1ee08b271 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -2189,7 +2189,8 @@ public void addTriggeredAbility(TriggeredAbility ability, GameEvent triggeringEv } } else { TriggeredAbility newAbility = ability.copy(); - newAbility.newId(); + // keep linkage id stable for linked-ability tags across stack copies + newAbility.newIdKeepingLinkage(); newAbility.initSourceObjectZoneChangeCounter(this, false); if (!(newAbility instanceof DelayedTriggeredAbility)) { newAbility.setSourcePermanentTransformCount(this); @@ -2246,7 +2247,8 @@ public UUID addDelayedTriggeredAbility(DelayedTriggeredAbility delayedAbility, A delayedAbility.setControllerId(source.getControllerId()); } DelayedTriggeredAbility newAbility = delayedAbility.copy(); - newAbility.newId(); + // keep linkage id stable for linked-ability tags across stack copies + newAbility.newIdKeepingLinkage(); if (source != null) { // Relevant ruling: // 603.7e If an activated or triggered ability creates a delayed triggered ability, diff --git a/Mage/src/main/java/mage/game/GameState.java b/Mage/src/main/java/mage/game/GameState.java index 35059221190f..0050df20bcc1 100644 --- a/Mage/src/main/java/mage/game/GameState.java +++ b/Mage/src/main/java/mage/game/GameState.java @@ -103,6 +103,7 @@ public class GameState implements Serializable, Copyable { private List simultaneousEvents = new ArrayList<>(); private Map cardState = new HashMap<>(); private Map> permanentCostsTags = new HashMap<>(); // Permanent reference -> map of (tag -> values) describing how the permanent's spell was cast + private Map> enteringAbilities = new HashMap<>(); // Abilities granted to a spell that should persist through ETB private Map mageObjectAttribute = new HashMap<>(); private Map zoneChangeCounter = new HashMap<>(); private Map copiedCards = new HashMap<>(); @@ -177,6 +178,7 @@ protected GameState(final GameState state) { this.simultaneousEvents.addAll(state.simultaneousEvents); this.cardState = CardUtil.deepCopyObject(state.cardState); this.permanentCostsTags = CardUtil.deepCopyObject(state.permanentCostsTags); + this.enteringAbilities = CardUtil.deepCopyObject(state.enteringAbilities); this.mageObjectAttribute = CardUtil.deepCopyObject(state.mageObjectAttribute); this.zoneChangeCounter.putAll(state.zoneChangeCounter); this.copiedCards.putAll(state.copiedCards); @@ -219,6 +221,7 @@ public void clearOnGameRestart() { specialActions.clear(); cardState.clear(); permanentCostsTags.clear(); + enteringAbilities.clear(); combat.clear(); turnMods.clear(); watchers.clear(); @@ -271,6 +274,7 @@ public void restore(GameState state) { this.simultaneousEvents = state.simultaneousEvents; this.cardState = state.cardState; this.permanentCostsTags = state.permanentCostsTags; + this.enteringAbilities = state.enteringAbilities; this.mageObjectAttribute = state.mageObjectAttribute; this.zoneChangeCounter = state.zoneChangeCounter; this.copiedCards = state.copiedCards; @@ -672,6 +676,7 @@ void applyEffects(Game game) { } this.reset(); battlefield.reset(game); + applyEnteringAbilities(game); combat.reset(game); effects.apply(game); combat.checkForRemoveFromCombat(game); @@ -1453,7 +1458,8 @@ && getAllOtherAbilities(attachedTo.getId()).contains(ability))) { // must use new id, so you can add multiple instances of the same ability // (example: gained Cascade from multiple Imoti, Celebrant of Bounty) newAbility = ability.copy(); - newAbility.newId(); + // keep linkage id stable across applyEffects cycles so linked-ability tags stay consistent (CR 607) + newAbility.newIdKeepingLinkage(); } newAbility.setSourceId(attachedTo.getId()); newAbility.setControllerId(attachedTo.getOwnerId()); @@ -1546,6 +1552,55 @@ void storePermanentCostsTags(MageObjectReference permanentMOR, Ability source) { } } + public void storeEnteringAbilities(MageObjectReference permanentMOR, Abilities abilities) { + if (abilities == null || abilities.isEmpty()) { + return; + } + enteringAbilities.put(permanentMOR, abilities.copy()); + } + + public Abilities getEnteringAbilities(MageObjectReference permanentMOR) { + return enteringAbilities.get(permanentMOR); + } + + public Abilities getAndRemoveEnteringAbilities(MageObjectReference permanentMOR) { + return enteringAbilities.remove(permanentMOR); + } + + private void applyEnteringAbilities(Game game) { + if (enteringAbilities.isEmpty()) { + return; + } + + // Apply entering abilities for this applyEffects cycle, then clear them. + Map> snapshot = new HashMap<>(enteringAbilities); + enteringAbilities.clear(); + for (Map.Entry> entry : snapshot.entrySet()) { + Abilities abilities = entry.getValue(); + if (abilities == null || abilities.isEmpty()) { + continue; + } + + Permanent permanent = entry.getKey().getPermanent(game); + if (permanent == null) { + continue; + } + + UUID sourceId = entry.getKey().getSourceId(); + // Add only top-level abilities; sub abilities will be handled by addAbility recursion. + Set subAbilities = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Ability ability : abilities) { + subAbilities.addAll(ability.getSubAbilities()); + } + for (Ability ability : abilities) { + if (subAbilities.contains(ability)) { + continue; + } + addAbility(ability, sourceId, permanent); + } + } + } + /** * Removes the cost tags if the corresponding permanent is no longer on the battlefield. * Only use if the stack is empty and nothing can refer to them anymore (such as at EOT, the current behavior) diff --git a/Mage/src/main/java/mage/game/permanent/PermanentCard.java b/Mage/src/main/java/mage/game/permanent/PermanentCard.java index 476b9587882a..5942cfd5ec00 100644 --- a/Mage/src/main/java/mage/game/permanent/PermanentCard.java +++ b/Mage/src/main/java/mage/game/permanent/PermanentCard.java @@ -1,6 +1,7 @@ package mage.game.permanent; import mage.MageObject; +import mage.MageObjectReference; import mage.ObjectColor; import mage.abilities.Abilities; import mage.abilities.Ability; @@ -98,6 +99,15 @@ private void init(Card card, Game game) { if (otherAbilities != null) { abilities.addAll(otherAbilities); } + MageObjectReference enteringMOR = new MageObjectReference(card.getId(), card.getZoneChangeCounter(game), game); + Abilities enteringAbilities = game.getState().getEnteringAbilities(enteringMOR); + if (enteringAbilities != null) { + for (Ability ability : enteringAbilities) { + if (!abilities.contains(ability)) { + abilities.add(ability); + } + } + } if (card instanceof LevelerCard) { maxLevelCounters = ((LevelerCard) card).getMaxLevelCounters(); } @@ -121,6 +131,17 @@ public void reset(Game game) { } else { copyFromCard(card, game, true); } + if (game != null) { + MageObjectReference enteringMOR = new MageObjectReference(this.getId(), this.getZoneChangeCounter(game), game); + Abilities enteringAbilities = game.getState().getEnteringAbilities(enteringMOR); + if (enteringAbilities != null) { + for (Ability ability : enteringAbilities) { + if (!this.abilities.contains(ability)) { + this.abilities.add(ability); + } + } + } + } power.resetToBaseValue(); toughness.resetToBaseValue(); super.reset(game); diff --git a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java index 31576b72282a..685fd4f265e8 100644 --- a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java +++ b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java @@ -449,11 +449,21 @@ public Ability addAbility(Ability ability, UUID sourceId, Game game) { */ @Override public Ability addAbility(Ability ability, UUID sourceId, Game game, boolean fromExistingObject) { + return addAbility(ability, sourceId, game, fromExistingObject, false); + } + + protected Ability addAbility(Ability ability, UUID sourceId, Game game, boolean fromExistingObject, boolean preserveLinkage) { // singleton abilities -- only one instance // other abilities -- any amount of instances if (!abilities.containsKey(ability.getId())) { Ability copyAbility = ability.copy(); - copyAbility.newId(); // needed so that source can get an ability multiple times (e.g. Raging Ravine) + if (preserveLinkage) { + // copied permanent spells need fresh ids on the token permanent while keeping the + // same linkage grouping that was set up when the spell was cast or copied. + copyAbility.newIdKeepingLinkage(); + } else { + copyAbility.newId(); // needed so that source can get an ability multiple times (e.g. Raging Ravine) + } copyAbility.setControllerId(controllerId); copyAbility.setSourceId(objectId); // triggered abilities must be added to the state().triggers diff --git a/Mage/src/main/java/mage/game/permanent/PermanentToken.java b/Mage/src/main/java/mage/game/permanent/PermanentToken.java index 024be2a22758..a00355702c08 100644 --- a/Mage/src/main/java/mage/game/permanent/PermanentToken.java +++ b/Mage/src/main/java/mage/game/permanent/PermanentToken.java @@ -24,9 +24,15 @@ public class PermanentToken extends PermanentImpl { // this PermanentToken resets to it on each game cycle // TODO: see PermanentCard.card for usage research and fixes protected Token token; + private final boolean preserveCopiedSpellLinkage; public PermanentToken(Token token, UUID controllerId, Game game) { + this(token, controllerId, game, false); + } + + public PermanentToken(Token token, UUID controllerId, Game game, boolean preserveCopiedSpellLinkage) { super(controllerId, controllerId, token.getName()); // random id + this.preserveCopiedSpellLinkage = preserveCopiedSpellLinkage; this.token = token.copy(); this.token.getAbilities().newOriginalId(); // neccessary if token has ability like DevourAbility() this.token.getAbilities().setSourceId(objectId); @@ -49,6 +55,7 @@ public PermanentToken(Token token, UUID controllerId, Game game) { protected PermanentToken(final PermanentToken permanent) { super(permanent); this.token = permanent.token.copy(); + this.preserveCopiedSpellLinkage = permanent.preserveCopiedSpellLinkage; } @Override @@ -91,8 +98,9 @@ private void copyFromToken(Token token, Game game, boolean reset) { // first time -> create ContinuousEffects only once // so sourceId must be null (keep triggered abilities forever?) for (Ability ability : token.getAbilities()) { - //Don't add subabilities since the original token already has them in its abilities list - this.addAbility(ability, null, game, true); + // Don't add subabilities since the original token already has them in its abilities list. + // Copied permanent spells still need that behavior; they just keep the spell's linkage grouping. + this.addAbility(ability, null, game, true, preserveCopiedSpellLinkage); } } this.abilities.setControllerId(this.controllerId); diff --git a/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java b/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java index db1d66668d7d..abb172f3fbf2 100644 --- a/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java +++ b/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java @@ -321,7 +321,7 @@ private static void putOntoBattlefieldHelper(CreateTokenEvent event, Game game, // must use same image for all tokens for (int i = 0; i < amount; i++) { // use event.getPlayerId() as controller because it can be replaced by replacement effect - PermanentToken newPermanent = new PermanentToken(token, event.getPlayerId(), game); + PermanentToken newPermanent = new PermanentToken(token, event.getPlayerId(), game, !created); game.getState().addCard(newPermanent); needTokens.add(newPermanent); game.getPermanentsEntering().put(newPermanent.getId(), newPermanent); diff --git a/Mage/src/main/java/mage/game/stack/Spell.java b/Mage/src/main/java/mage/game/stack/Spell.java index f79d89a8f66e..3d200809ae12 100644 --- a/Mage/src/main/java/mage/game/stack/Spell.java +++ b/Mage/src/main/java/mage/game/stack/Spell.java @@ -358,6 +358,13 @@ public boolean resolve(Game game) { permId = card.getId(); MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); + MageObjectReference enteringMor = new MageObjectReference( + card.getId(), card.getZoneChangeCounter(game) + 1, game + ); + Abilities enteringAbilities = game.getState().getAllOtherAbilities(card.getId()); + if (enteringAbilities != null && !enteringAbilities.isEmpty()) { + game.getState().storeEnteringAbilities(enteringMor, enteringAbilities); + } permanentCreated = controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); } if (permanentCreated) { @@ -391,6 +398,13 @@ public boolean resolve(Game game) { if (bestow) { MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); + MageObjectReference enteringMor = new MageObjectReference( + card.getId(), card.getZoneChangeCounter(game) + 1, game + ); + Abilities enteringAbilities = game.getState().getAllOtherAbilities(card.getId()); + if (enteringAbilities != null && !enteringAbilities.isEmpty()) { + game.getState().storeEnteringAbilities(enteringMor, enteringAbilities); + } return controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); } else { //20091005 - 608.2b @@ -408,6 +422,13 @@ public boolean resolve(Game game) { } else { MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); + MageObjectReference enteringMor = new MageObjectReference( + card.getId(), card.getZoneChangeCounter(game) + 1, game + ); + Abilities enteringAbilities = game.getState().getAllOtherAbilities(card.getId()); + if (enteringAbilities != null && !enteringAbilities.isEmpty()) { + game.getState().storeEnteringAbilities(enteringMor, enteringAbilities); + } return controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); } } @@ -867,7 +888,7 @@ public Spell copySpell(Game game, Ability source, UUID newController) { continue; } SpellAbility newAbility = spellAbility.copy(); // e.g. spliced spell - newAbility.newId(); + newAbility.newIdKeepingLinkage(); newAbility.setSourceId(copiedSourceId); spellCopy.addSpellAbility(newAbility); } diff --git a/Mage/src/main/java/mage/game/stack/StackAbility.java b/Mage/src/main/java/mage/game/stack/StackAbility.java index 66211b601473..6857199a05b6 100644 --- a/Mage/src/main/java/mage/game/stack/StackAbility.java +++ b/Mage/src/main/java/mage/game/stack/StackAbility.java @@ -498,10 +498,20 @@ public void newId() { } } + @Override + public void newIdKeepingLinkage() { + this.ability.newIdKeepingLinkage(); + } + @Override public void newOriginalId() { } + @Override + public void remapForSource(UUID sourceSeed) { + ability.remapForSource(sourceSeed); + } + @Override public Ability getStackAbility() { return ability; @@ -573,6 +583,11 @@ public UUID getOriginalId() { return this.ability.getOriginalId(); } + @Override + public UUID getLinkageId() { + return this.ability.getLinkageId(); + } + @Override public Ability setAbilityWord(AbilityWord abilityWord) { throw new UnsupportedOperationException("Not supported."); @@ -740,7 +755,8 @@ public Ability withCanBeCopied(boolean canBeCopied) { @Override public void createSingleCopy(UUID newControllerId, StackObjectCopyApplier applier, MageObjectReferencePredicate newTargetFilterPredicate, Game game, Ability source, boolean chooseNewTargets) { Ability newAbility = this.ability.copy(); - newAbility.newId(); + // keep linkage id stable for linked-ability tags across stack copies + newAbility.newIdKeepingLinkage(); newAbility.setControllerId(newControllerId); StackAbility newStackAbility = new StackAbility(newAbility, newControllerId); diff --git a/Mage/src/main/java/mage/util/CardUtil.java b/Mage/src/main/java/mage/util/CardUtil.java index ff87862a041b..e69e4a2b4d01 100644 --- a/Mage/src/main/java/mage/util/CardUtil.java +++ b/Mage/src/main/java/mage/util/CardUtil.java @@ -1964,6 +1964,21 @@ public static int getSourceCostsTagX(Game game, Ability source, int defaultValue return getSourceCostsTag(game, source, "X", defaultValue); } + /** + * Build a costs tag key that is unique per linked-ability instance (CR 607). + */ + public static String getLinkedCostTag(Ability ability, String baseTag) { + return baseTag + "|" + ability.getLinkageId(); + } + + /** + * Build a costs tag key that is unique per linked-ability instance (CR 607), + * with a suffix to differentiate multiple costs within the same ability. + */ + public static String getLinkedCostTag(Ability ability, String baseTag, String suffix) { + return baseTag + "|" + ability.getLinkageId() + "|" + suffix; + } + public static String addCostVerb(String text) { if (costWords.stream().anyMatch(text.toLowerCase(Locale.ENGLISH)::startsWith)) { return text;