diff --git a/Mage.Sets/src/mage/cards/a/ApproachOfTheSecondSun.java b/Mage.Sets/src/mage/cards/a/ApproachOfTheSecondSun.java index 6c19eadc8e..6daf1c6c1f 100644 --- a/Mage.Sets/src/mage/cards/a/ApproachOfTheSecondSun.java +++ b/Mage.Sets/src/mage/cards/a/ApproachOfTheSecondSun.java @@ -64,12 +64,11 @@ class ApproachOfTheSecondSunEffect extends OneShotEffect { @Override public boolean apply(Game game, Ability source) { Player controller = game.getPlayer(source.getControllerId()); - Spell spell = game.getStack().getSpell(source.getSourceId()); + Spell spell = game.getStack().getSpell(source.getSourceId(), false); if (controller != null && spell != null) { ApproachOfTheSecondSunWatcher watcher = game.getState().getWatcher(ApproachOfTheSecondSunWatcher.class); if (watcher != null - && !spell.isCopy() && watcher.getApproachesCast(controller.getId()) > 1 && spell.getFromZone() == Zone.HAND) { // Win the game @@ -79,10 +78,7 @@ class ApproachOfTheSecondSunEffect extends OneShotEffect { controller.gainLife(7, game, source); // Put this into the library as the 7th from the top - if (spell.isCopy()) { - return true; - } - Card spellCard = game.getStack().getSpell(source.getSourceId()).getCard(); + Card spellCard = game.getStack().getSpell(source.getSourceId(), false).getCard(); if (spellCard != null) { controller.putCardOnTopXOfLibrary(spellCard, game, source, 7, true); } diff --git a/Mage.Sets/src/mage/cards/b/BeamsplitterMage.java b/Mage.Sets/src/mage/cards/b/BeamsplitterMage.java index c4bcdc69a7..a0209d9eff 100644 --- a/Mage.Sets/src/mage/cards/b/BeamsplitterMage.java +++ b/Mage.Sets/src/mage/cards/b/BeamsplitterMage.java @@ -16,6 +16,7 @@ import mage.filter.common.FilterControlledPermanent; import mage.filter.predicate.permanent.ControllerIdPredicate; import mage.game.Game; import mage.game.events.CopiedStackObjectEvent; +import mage.game.events.CopyStackObjectEvent; import mage.game.events.GameEvent; import mage.game.permanent.Permanent; import mage.game.stack.Spell; @@ -166,7 +167,13 @@ class BeamsplitterMageEffect extends OneShotEffect { if (creature == null) { return false; } - Spell copy = spell.copySpell(source.getControllerId(), game); + // TODO: add support if multiple copies? See Twinning Staff + GameEvent gameEvent = new CopyStackObjectEvent(source, spell, source.getControllerId(), 1); + if (game.replaceEvent(gameEvent)) { + return false; + } + Spell copy = spell.copySpell(game, source, source.getControllerId()); + copy.setZone(Zone.STACK, game); game.getStack().push(copy); setTarget: for (UUID modeId : copy.getSpellAbility().getModes().getSelectedModes()) { diff --git a/Mage.Sets/src/mage/cards/f/Fork.java b/Mage.Sets/src/mage/cards/f/Fork.java index afe08dedb0..b6803712dc 100644 --- a/Mage.Sets/src/mage/cards/f/Fork.java +++ b/Mage.Sets/src/mage/cards/f/Fork.java @@ -7,9 +7,12 @@ import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.CardType; import mage.constants.Outcome; +import mage.constants.Zone; import mage.filter.StaticFilters; import mage.game.Game; import mage.game.events.CopiedStackObjectEvent; +import mage.game.events.CopyStackObjectEvent; +import mage.game.events.GameEvent; import mage.game.stack.Spell; import mage.players.Player; import mage.target.TargetSpell; @@ -57,8 +60,14 @@ class ForkEffect extends OneShotEffect { Player controller = game.getPlayer(source.getControllerId()); Spell spell = game.getStack().getSpell(targetPointer.getFirst(game, source)); if (spell != null && controller != null) { - Spell copy = spell.copySpell(source.getControllerId(), game); + // TODO: add support if multiple copies? See Twinning Staff + GameEvent gameEvent = new CopyStackObjectEvent(source, spell, source.getControllerId(), 1); + if (game.replaceEvent(gameEvent)) { + return false; + } + Spell copy = spell.copySpell(game, source, source.getControllerId()); copy.getColor(game).setRed(true); + copy.setZone(Zone.STACK, game); game.getStack().push(copy); copy.chooseNewTargets(game, controller.getId()); game.fireEvent(new CopiedStackObjectEvent(spell, copy, source.getControllerId())); diff --git a/Mage.Sets/src/mage/cards/g/Guile.java b/Mage.Sets/src/mage/cards/g/Guile.java index 12c03cedc8..d4ddadcd01 100644 --- a/Mage.Sets/src/mage/cards/g/Guile.java +++ b/Mage.Sets/src/mage/cards/g/Guile.java @@ -1,7 +1,5 @@ - package mage.cards.g; -import java.util.UUID; import mage.ApprovingObject; import mage.MageInt; import mage.abilities.Ability; @@ -13,20 +11,16 @@ import mage.abilities.effects.common.combat.CantBeBlockedByOneEffect; import mage.cards.Card; import mage.cards.CardImpl; import mage.cards.CardSetInfo; -import mage.constants.CardType; -import mage.constants.Duration; -import mage.constants.Outcome; -import mage.constants.SubType; -import mage.constants.Zone; +import mage.constants.*; import mage.game.Game; import mage.game.events.GameEvent; -import mage.game.events.GameEvent.EventType; import mage.game.stack.Spell; import mage.game.stack.StackObject; import mage.players.Player; +import java.util.UUID; + /** - * * @author emerald000 */ public final class Guile extends CardImpl { @@ -85,12 +79,12 @@ class GuileReplacementEffect extends ReplacementEffectImpl { public boolean replaceEvent(GameEvent event, Ability source, Game game) { Spell spell = game.getStack().getSpell(event.getTargetId()); Player controller = game.getPlayer(source.getControllerId()); - if (spell != null + if (spell != null && controller != null) { controller.moveCards(spell, Zone.EXILED, source, game); - if (!spell.isCopy()) { + if (!spell.isCopy()) { // copies doesn't exists in exile zone Card spellCard = spell.getCard(); - if (spellCard != null + if (spellCard != null && controller.chooseUse(Outcome.PlayForFree, "Play " + spellCard.getIdName() + " for free?", source, game)) { controller.playCard(spellCard, game, true, true, new ApprovingObject(source, game)); } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/other/SoulfireGrandMasterTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/other/SoulfireGrandMasterTest.java index fc3cf81945..f7670572ad 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/other/SoulfireGrandMasterTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/other/SoulfireGrandMasterTest.java @@ -1,4 +1,3 @@ - package org.mage.test.cards.abilities.other; import mage.constants.PhaseStep; @@ -7,7 +6,6 @@ import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBase; /** - * * @author BetaSteward */ public class SoulfireGrandMasterTest extends CardTestPlayerBase { @@ -17,7 +15,6 @@ public class SoulfireGrandMasterTest extends CardTestPlayerBase { * and sorcery spells you control have lifelink. {2}{U/R}{U/R}: The next * time you cast an instant or sorcery spell from your hand this turn, put * that card into your hand instead of into your graveyard as it resolves. - * */ @Test public void testSpellsGainLifelink() { @@ -120,19 +117,23 @@ public class SoulfireGrandMasterTest extends CardTestPlayerBase { /** * Test copied instant spell gives also life - * */ @Test - public void testCopySpell() { + public void test_CopiesMustHaveGainedLifelink() { addCard(Zone.BATTLEFIELD, playerA, "Mountain", 4); addCard(Zone.BATTLEFIELD, playerA, "Island", 1); addCard(Zone.HAND, playerA, "Lightning Bolt"); + // + // Instant and sorcery spells you control have lifelink. addCard(Zone.BATTLEFIELD, playerA, "Soulfire Grand Master", 1); + // // {2}{U}{R}: Copy target instant or sorcery spell you control. You may choose new targets for the copy. addCard(Zone.BATTLEFIELD, playerA, "Nivix Guildmage", 1); castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Lightning Bolt", playerB); activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}{U}{R}:"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + checkStackSize("after copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // 2x bolts setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); @@ -143,12 +144,10 @@ public class SoulfireGrandMasterTest extends CardTestPlayerBase { assertLife(playerB, 14); assertLife(playerA, 26); - } /** * Test damage of activated ability of a permanent does not gain lifelink - * */ @Test public void testActivatedAbility() { @@ -235,7 +234,6 @@ public class SoulfireGrandMasterTest extends CardTestPlayerBase { /** * Check if second ability resolved, the next spell that is counterer won't * go to hand back because it did not resolve - * */ @Test public void testSoulfireCounteredSpellDontGoesBack() { @@ -269,7 +267,6 @@ public class SoulfireGrandMasterTest extends CardTestPlayerBase { * caster life. It should as it has lifelink, and it's Deflecting Palm (an * instant) dealing damage. I was playing against a human in Standard * Constructed. - * */ @Test public void testWithDeflectingPalm() { diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/copy/CopySpellTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/copy/CopySpellTest.java index 774033a9c9..4dc44d1b3c 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/copy/CopySpellTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/copy/CopySpellTest.java @@ -1,12 +1,23 @@ package org.mage.test.cards.copy; +import mage.abilities.MageSingleton; import mage.abilities.keyword.FlyingAbility; +import mage.cards.AdventureCard; +import mage.cards.Card; +import mage.cards.ModalDoubleFacesCard; +import mage.cards.SplitCard; +import mage.cards.repository.CardRepository; import mage.constants.PhaseStep; import mage.constants.Zone; +import mage.util.CardUtil; +import org.junit.Assert; import org.junit.Test; import org.mage.test.player.TestPlayer; import org.mage.test.serverside.base.CardTestPlayerBase; +import java.util.Set; +import java.util.UUID; + /** * @author LevelX2 */ @@ -55,24 +66,31 @@ public class CopySpellTest extends CardTestPlayerBase { // Target creature gets +3/+3 and gains flying until end of turn. addCard(Zone.HAND, playerA, "Angelic Blessing", 1); // {2}{W} addCard(Zone.BATTLEFIELD, playerA, "Plains", 3); + // + // Whenever you cast an instant or sorcery spell that targets only Zada, Hedron Grinder, + // copy that spell for each other creature you control that the spell could target. + // Each copy targets a different one of those creatures. + addCard(Zone.BATTLEFIELD, playerA, "Zada, Hedron Grinder", 1); // 3/3 + // + addCard(Zone.BATTLEFIELD, playerA, "Pillarfield Ox", 1); // 2/4 + addCard(Zone.BATTLEFIELD, playerB, "Silvercoat Lion", 1); // 2/2 - // Whenever you cast an instant or sorcery spell that targets only Zada, Hedron Grinder, copy that spell for each other creature you control that the spell could target. Each copy targets a different one of those creatures. - addCard(Zone.BATTLEFIELD, playerA, "Zada, Hedron Grinder", 1); - addCard(Zone.BATTLEFIELD, playerA, "Pillarfield Ox", 1); - - addCard(Zone.BATTLEFIELD, playerB, "Silvercoat Lion", 1); - + // cast boost and copy it for another target (lion will not get boost cause can't be targeted) castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Angelic Blessing", "Zada, Hedron Grinder"); + setStrictChooseMode(true); setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); + assertAllCommandsUsed(); assertGraveyardCount(playerA, "Angelic Blessing", 1); - assertPowerToughness(playerA, "Pillarfield Ox", 5, 7); - assertAbility(playerA, "Pillarfield Ox", FlyingAbility.getInstance(), true); - assertPowerToughness(playerA, "Zada, Hedron Grinder", 6, 6); + // original target + assertPowerToughness(playerA, "Zada, Hedron Grinder", 3 + 3, 3 + 3); assertAbility(playerA, "Zada, Hedron Grinder", FlyingAbility.getInstance(), true); - + // copied target + assertPowerToughness(playerA, "Pillarfield Ox", 2 + 3, 4 + 3); + assertAbility(playerA, "Pillarfield Ox", FlyingAbility.getInstance(), true); + // can't target lion, so no boost assertPowerToughness(playerB, "Silvercoat Lion", 2, 2); assertAbility(playerB, "Silvercoat Lion", FlyingAbility.getInstance(), false); } @@ -205,8 +223,8 @@ public class CopySpellTest extends CardTestPlayerBase { assertPermanentCount(playerA, "Wolf", 1); // created from Silverfur ability } */ - - + + @Test public void ZadaHedronGrinderBoostWithCharm() { // Choose two - @@ -443,7 +461,7 @@ public class CopySpellTest extends CardTestPlayerBase { setChoice(playerA, "Yes"); // use copy setChoice(playerA, "Imoti, Celebrant of Bounty"); // copy of imoti waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); - checkPermanentCount("after copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Imoti, Celebrant of Bounty", 2); + checkPermanentCount("after copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Imoti, Celebrant of Bounty", 2); // cast big spell and catch cascade 2x times (from two copies) // possible bug: cascade activates only 1x times @@ -462,4 +480,173 @@ public class CopySpellTest extends CardTestPlayerBase { assertLife(playerB, 20 - 3 * 2); // 2x bolts from 2x cascades } + + @Test + public void test_CopiedSpellsMustUseIndependentCards() { + // possible bug: copied spell on stack depends on the original spell/card + // https://github.com/magefree/mage/issues/7634 + + // Return any number of cards with different converted mana costs from your graveyard to your hand. + // Put Seasons Past on the bottom of its owner's library. + addCard(Zone.HAND, playerA, "Seasons Past", 1); // {4}{G}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 6); + // + addCard(Zone.GRAVEYARD, playerA, "Grizzly Bears", 5); // for return + // + // Copy target instant or sorcery spell, except that the copy is red. You may choose new targets for the copy. + addCard(Zone.HAND, playerA, "Fork", 1); // {R}{R} + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + + // cast season and make copy of it on stack + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 6); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Seasons Past"); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Fork", "Seasons Past", "Cast Seasons Past"); + checkStackSize("after copy cast", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // season + fork + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + checkStackSize("after copy resolve", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // season + copied season + checkStackObject("after copy resolve", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Cast Seasons Past", 2); + + // resolve copied season (possible bug: after copied resolve it will return an original card too, so original spell will be fizzled) + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA, true); + setChoice(playerA, "Grizzly Bears"); // return to hand + checkStackSize("after copied resolve", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 1); + + // resolve original season + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA, true); + setChoice(playerA, "Grizzly Bears"); // return to hand + checkStackSize("after original resolve", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 0); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + setStrictChooseMode(true); + execute(); + assertAllCommandsUsed(); + } + + @Test + public void test_SimpleCopy_Card() { + Card sourceCard = CardRepository.instance.findCard("Grizzly Bears").getCard(); + Card originalCard = CardRepository.instance.findCard("Grizzly Bears").getCard(); + prepareZoneAndZCC(originalCard); + Card copiedCard = currentGame.copyCard(originalCard, null, playerA.getId()); + // main + Assert.assertNotEquals("main - id must be different", originalCard.getId(), copiedCard.getId()); + Assert.assertEquals("main - rules must be same", originalCard.getRules(), copiedCard.getRules()); + abilitySourceMustBeSame(sourceCard, "main source"); + abilitySourceMustBeSame(originalCard, "main original"); // original card can be broken after copyCard call + abilitySourceMustBeSame(copiedCard, "main copied"); + //cardsMustHaveSameZoneAndZCC(originalCard, copiedCard, "main"); + } + + @Test + public void test_SimpleCopy_SplitCard() { + SplitCard sourceCard = (SplitCard) CardRepository.instance.findCard("Alive // Well").getCard(); + SplitCard originalCard = (SplitCard) CardRepository.instance.findCard("Alive // Well").getCard(); + prepareZoneAndZCC(originalCard); + SplitCard copiedCard = (SplitCard) currentGame.copyCard(originalCard, null, playerA.getId()); + // main + Assert.assertNotEquals("main - id must be different", originalCard.getId(), copiedCard.getId()); + Assert.assertEquals("main - rules must be same", originalCard.getRules(), copiedCard.getRules()); + abilitySourceMustBeSame(sourceCard, "main source"); + abilitySourceMustBeSame(originalCard, "main original"); + abilitySourceMustBeSame(copiedCard, "main copied"); + //cardsMustHaveSameZoneAndZCC(originalCard, copiedCard, "main"); + // left + Assert.assertNotEquals("left - id must be different", originalCard.getLeftHalfCard().getId(), copiedCard.getLeftHalfCard().getId()); + Assert.assertEquals("left - rules must be same", originalCard.getLeftHalfCard().getRules(), copiedCard.getLeftHalfCard().getRules()); + Assert.assertEquals("left - parent ref", copiedCard.getLeftHalfCard().getParentCard().getId(), copiedCard.getId()); + abilitySourceMustBeSame(originalCard.getLeftHalfCard(), "left original"); + abilitySourceMustBeSame(copiedCard.getLeftHalfCard(), "left copied"); + //cardsMustHaveSameZoneAndZCC(originalCard.getLeftHalfCard(), copiedCard.getLeftHalfCard(), "left"); + // right + Assert.assertNotEquals("right - id must be different", originalCard.getRightHalfCard().getId(), copiedCard.getRightHalfCard().getId()); + Assert.assertEquals("right - rules must be same", originalCard.getRightHalfCard().getRules(), copiedCard.getRightHalfCard().getRules()); + Assert.assertEquals("right - parent ref", copiedCard.getRightHalfCard().getParentCard().getId(), copiedCard.getId()); + abilitySourceMustBeSame(originalCard.getRightHalfCard(), "right original"); + abilitySourceMustBeSame(copiedCard.getRightHalfCard(), "right copied"); + //cardsMustHaveSameZoneAndZCC(originalCard.getRightHalfCard(), copiedCard.getRightHalfCard(), "right"); + } + + @Test + public void test_SimpleCopy_AdventureCard() { + AdventureCard sourceCard = (AdventureCard) CardRepository.instance.findCard("Animating Faerie").getCard(); + AdventureCard originalCard = (AdventureCard) CardRepository.instance.findCard("Animating Faerie").getCard(); + prepareZoneAndZCC(originalCard); + AdventureCard copiedCard = (AdventureCard) currentGame.copyCard(originalCard, null, playerA.getId()); + // main + Assert.assertNotEquals("main - id must be different", originalCard.getId(), copiedCard.getId()); + Assert.assertEquals("main - rules must be same", originalCard.getRules(), copiedCard.getRules()); + abilitySourceMustBeSame(sourceCard, "main source"); + abilitySourceMustBeSame(originalCard, "main original"); + abilitySourceMustBeSame(copiedCard, "main copied"); + //cardsMustHaveSameZoneAndZCC(originalCard, copiedCard, "main"); + // right (spell) + Assert.assertNotEquals("right - id must be different", originalCard.getSpellCard().getId(), copiedCard.getSpellCard().getId()); + Assert.assertEquals("right - rules must be same", originalCard.getSpellCard().getRules(), copiedCard.getSpellCard().getRules()); + Assert.assertEquals("right - parent ref", copiedCard.getSpellCard().getParentCard().getId(), copiedCard.getId()); + abilitySourceMustBeSame(originalCard.getSpellCard(), "right original"); + abilitySourceMustBeSame(copiedCard.getSpellCard(), "right copied"); + //cardsMustHaveSameZoneAndZCC(originalCard.getSpellCard(), copiedCard.getSpellCard(), "right"); + } + + @Test + public void test_SimpleCopy_MDFC() { + ModalDoubleFacesCard sourceCard = (ModalDoubleFacesCard) CardRepository.instance.findCard("Agadeem's Awakening").getCard(); + ModalDoubleFacesCard originalCard = (ModalDoubleFacesCard) CardRepository.instance.findCard("Agadeem's Awakening").getCard(); + prepareZoneAndZCC(originalCard); + ModalDoubleFacesCard copiedCard = (ModalDoubleFacesCard) currentGame.copyCard(originalCard, null, playerA.getId()); + // main + Assert.assertNotEquals("main - id must be different", originalCard.getId(), copiedCard.getId()); + Assert.assertEquals("main - rules must be same", originalCard.getRules(), copiedCard.getRules()); + abilitySourceMustBeSame(sourceCard, "main source"); + abilitySourceMustBeSame(originalCard, "main original"); + abilitySourceMustBeSame(copiedCard, "main copied"); + //cardsMustHaveSameZoneAndZCC(originalCard, copiedCard, "main"); + // left + Assert.assertNotEquals("left - id must be different", originalCard.getLeftHalfCard().getId(), copiedCard.getLeftHalfCard().getId()); + Assert.assertEquals("left - rules must be same", originalCard.getLeftHalfCard().getRules(), copiedCard.getLeftHalfCard().getRules()); + Assert.assertEquals("left - parent ref", copiedCard.getLeftHalfCard().getParentCard().getId(), copiedCard.getId()); + abilitySourceMustBeSame(originalCard.getLeftHalfCard(), "left original"); + abilitySourceMustBeSame(copiedCard.getLeftHalfCard(), "left copied"); + //cardsMustHaveSameZoneAndZCC(originalCard.getLeftHalfCard(), copiedCard.getLeftHalfCard(), "left"); + // right + Assert.assertNotEquals("right - id must be different", originalCard.getRightHalfCard().getId(), copiedCard.getRightHalfCard().getId()); + Assert.assertEquals("right - rules must be same", originalCard.getRightHalfCard().getRules(), copiedCard.getRightHalfCard().getRules()); + Assert.assertEquals("right - parent ref", copiedCard.getRightHalfCard().getParentCard().getId(), copiedCard.getId()); + abilitySourceMustBeSame(originalCard.getRightHalfCard(), "right original"); + abilitySourceMustBeSame(copiedCard.getRightHalfCard(), "right copied"); + //cardsMustHaveSameZoneAndZCC(originalCard.getRightHalfCard(), copiedCard.getRightHalfCard(), "right"); + } + + private void abilitySourceMustBeSame(Card card, String infoPrefix) { + Set partIds = CardUtil.getObjectParts(card); + + card.getAbilities(currentGame).forEach(ability -> { + // ability can refs to part or main card only + if (!partIds.contains(ability.getSourceId())) { + if (ability instanceof MageSingleton) { + // sourceId don't work with MageSingleton abilities + return; + } + + Assert.fail(infoPrefix + " - " + "ability source must be same: " + ability.toString()); + } + }); + } + + private void prepareZoneAndZCC(Card originalCard) { + // prepare custom zcc and zone for copy testing + originalCard.setZoneChangeCounter(5, currentGame); + originalCard.setZone(Zone.STACK, currentGame); + } + + private void cardsMustHaveSameZoneAndZCC(Card originalCard, Card copiedCard, String infoPrefix) { + // zcc and zone are not copied, so you don't need it here yet + Zone zone1 = currentGame.getState().getZone(originalCard.getId()); + Zone zone2 = currentGame.getState().getZone(copiedCard.getId()); + int zcc1 = currentGame.getState().getZoneChangeCounter(originalCard.getId()); + int zcc2 = currentGame.getState().getZoneChangeCounter(copiedCard.getId()); + if (zone1 != zone2 || zcc1 != zcc2) { + Assert.fail(infoPrefix + " - " + "cards must have same zone and zcc: " + zcc1 + " - " + zone1 + " != " + zcc2 + " - " + zone2); + } + } } diff --git a/Mage/src/main/java/mage/abilities/Ability.java b/Mage/src/main/java/mage/abilities/Ability.java index 244a4d33b1..cd411416cc 100644 --- a/Mage/src/main/java/mage/abilities/Ability.java +++ b/Mage/src/main/java/mage/abilities/Ability.java @@ -88,6 +88,8 @@ public interface Ability extends Controllable, Serializable { /** * Gets the id of the object which put this ability in motion. * + * WARNING, MageSingleton abilities contains dirty data here, so you can't use sourceId with it + * * @return The {@link java.util.UUID} of the object this ability is * associated with. */ diff --git a/Mage/src/main/java/mage/abilities/SpellAbility.java b/Mage/src/main/java/mage/abilities/SpellAbility.java index 75b0904d38..956d5fab00 100644 --- a/Mage/src/main/java/mage/abilities/SpellAbility.java +++ b/Mage/src/main/java/mage/abilities/SpellAbility.java @@ -13,11 +13,9 @@ import mage.constants.*; import mage.game.Game; import mage.game.events.GameEvent; import mage.players.Player; +import mage.util.CardUtil; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import java.util.*; /** * @author BetaSteward_at_googlemail.com @@ -176,9 +174,17 @@ public class SpellAbility extends ActivatedAbilityImpl { return new SpellAbility(this); } - public SpellAbility copySpell() { + public SpellAbility copySpell(Card originalCard, Card copiedCard) { + // all copied spells must have own copied card + Map mapOldToNew = CardUtil.getOriginalToCopiedPartsMap(originalCard, copiedCard); + if (!mapOldToNew.containsKey(this.getSourceId())) { + throw new IllegalStateException("Can't find source id after copy: " + originalCard.getName() + " -> " + copiedCard.getName()); + } + UUID copiedSourceId = mapOldToNew.getOrDefault(this.getSourceId(), copiedCard).getId(); + SpellAbility spell = new SpellAbility(this); - spell.id = UUID.randomUUID(); + spell.newId(); + spell.setSourceId(copiedSourceId); return spell; } diff --git a/Mage/src/main/java/mage/abilities/condition/common/AddendumCondition.java b/Mage/src/main/java/mage/abilities/condition/common/AddendumCondition.java index b982b23060..bc634265c1 100644 --- a/Mage/src/main/java/mage/abilities/condition/common/AddendumCondition.java +++ b/Mage/src/main/java/mage/abilities/condition/common/AddendumCondition.java @@ -6,6 +6,8 @@ import mage.game.Game; import mage.game.stack.Spell; /** + * Addendum — If you cast this spell during your main phase, you get some boost + * * @author LevelX2 */ @@ -23,6 +25,6 @@ public enum AddendumCondition implements Condition { return true; } Spell spell = game.getSpell(source.getSourceId()); - return spell != null && !spell.isCopy(); + return spell != null && !spell.isCopy(); // copies are not casted } } diff --git a/Mage/src/main/java/mage/abilities/effects/common/CopySpellForEachItCouldTargetEffect.java b/Mage/src/main/java/mage/abilities/effects/common/CopySpellForEachItCouldTargetEffect.java index 66c0873e2c..dad596c355 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/CopySpellForEachItCouldTargetEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/CopySpellForEachItCouldTargetEffect.java @@ -80,8 +80,9 @@ public abstract class CopySpellForEachItCouldTargetEffect ex return false; } - // generate copies for each possible target, but do not put it to stack (use must choose targets in custom order later) - Spell copy = spell.copySpell(source.getControllerId(), game); + // TODO: add support if multiple copies? See Twinning Staff + // generate copies for each possible target, but do not put it to stack (must choose targets in custom order later) + Spell copy = spell.copySpell(game, source, source.getControllerId()); modifyCopy(copy, game, source); Target sampleTarget = targetsToBeChanged.iterator().next().getTarget(copy); sampleTarget.setNotTarget(true); @@ -93,7 +94,8 @@ public abstract class CopySpellForEachItCouldTargetEffect ex obj = game.getPlayer(objId); } if (obj != null) { - copy = spell.copySpell(source.getControllerId(), game); + // TODO: add support if multiple copies? See Twinning Staff + copy = spell.copySpell(game, source, source.getControllerId()); try { modifyCopy(copy, (T) obj, game, source); if (!filter.match((T) obj, source.getSourceId(), actingPlayer.getId(), game)) { @@ -168,6 +170,8 @@ public abstract class CopySpellForEachItCouldTargetEffect ex for (UUID chosenId : chosenIds) { Spell chosenCopy = targetCopyMap.get(chosenId); if (chosenCopy != null) { + // COPY DONE, can put to stack + chosenCopy.setZone(Zone.STACK, game); game.getStack().push(chosenCopy); game.fireEvent(new CopiedStackObjectEvent(spell, chosenCopy, source.getControllerId())); toDelete.add(chosenId); diff --git a/Mage/src/main/java/mage/abilities/effects/common/EpicEffect.java b/Mage/src/main/java/mage/abilities/effects/common/EpicEffect.java index 3bf0e41e88..8e73198b2a 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/EpicEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/EpicEffect.java @@ -55,7 +55,7 @@ public class EpicEffect extends OneShotEffect { if (spell == null) { return false; } - spell = spell.copySpell(source.getControllerId(), game); + spell = spell.copySpell(game, source, source.getControllerId()); // it's a fake copy, real copy with events in EpicPushEffect // Remove Epic effect from the spell Effect epicEffect = null; for (Effect effect : spell.getSpellAbility().getEffects()) { diff --git a/Mage/src/main/java/mage/abilities/effects/common/ExileAdventureSpellEffect.java b/Mage/src/main/java/mage/abilities/effects/common/ExileAdventureSpellEffect.java index d6a5c84953..d2a7dd9b2e 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/ExileAdventureSpellEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/ExileAdventureSpellEffect.java @@ -1,6 +1,5 @@ package mage.abilities.effects.common; -import java.util.UUID; import mage.abilities.Ability; import mage.abilities.MageSingleton; import mage.abilities.effects.AsThoughEffectImpl; @@ -18,6 +17,8 @@ import mage.players.Player; import mage.target.targetpointer.FixedTarget; import mage.util.CardUtil; +import java.util.UUID; + /** * @author phulin */ @@ -48,7 +49,7 @@ public class ExileAdventureSpellEffect extends OneShotEffect implements MageSing Player controller = game.getPlayer(source.getControllerId()); if (controller != null) { Spell spell = game.getStack().getSpell(source.getId()); - if (spell != null && !spell.isCopy()) { + if (spell != null) { Card spellCard = spell.getCard(); if (spellCard instanceof AdventureCardSpell) { UUID exileId = adventureExileId(controller.getId(), game); diff --git a/Mage/src/main/java/mage/abilities/effects/common/ExileSpellEffect.java b/Mage/src/main/java/mage/abilities/effects/common/ExileSpellEffect.java index f828e713ef..c8648f4aff 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/ExileSpellEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/ExileSpellEffect.java @@ -36,7 +36,7 @@ public class ExileSpellEffect extends OneShotEffect implements MageSingleton { Player controller = game.getPlayer(source.getControllerId()); if (controller != null) { Spell spell = game.getStack().getSpell(source.getId()); - if (spell != null && !spell.isCopy()) { + if (spell != null) { Card spellCard = spell.getCard(); if (spellCard != null) { controller.moveCards(spellCard, Zone.EXILED, source, game); diff --git a/Mage/src/main/java/mage/abilities/effects/common/ReturnToLibrarySpellEffect.java b/Mage/src/main/java/mage/abilities/effects/common/ReturnToLibrarySpellEffect.java index 8ad7213840..b1df8205ab 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/ReturnToLibrarySpellEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/ReturnToLibrarySpellEffect.java @@ -11,7 +11,6 @@ import mage.game.stack.Spell; import mage.players.Player; /** - * * @author LevelX2 */ public class ReturnToLibrarySpellEffect extends OneShotEffect { @@ -20,7 +19,7 @@ public class ReturnToLibrarySpellEffect extends OneShotEffect { public ReturnToLibrarySpellEffect(boolean top) { super(Outcome.Neutral); - staticText = "Put {this} on "+ (top ? "top":"the bottom") + " of its owner's library"; + staticText = "Put {this} on " + (top ? "top" : "the bottom") + " of its owner's library"; this.toTop = top; } @@ -34,7 +33,7 @@ public class ReturnToLibrarySpellEffect extends OneShotEffect { Player controller = game.getPlayer(source.getControllerId()); if (controller != null) { Spell spell = game.getStack().getSpell(source.getSourceId()); - if (spell != null && !spell.isCopy()) { + if (spell != null) { Card spellCard = spell.getCard(); if (spellCard != null) { controller.moveCardToLibraryWithInfo(spellCard, source, game, Zone.STACK, toTop, true); diff --git a/Mage/src/main/java/mage/abilities/effects/common/ShuffleSpellEffect.java b/Mage/src/main/java/mage/abilities/effects/common/ShuffleSpellEffect.java index 105b9c7de9..933ba1be0c 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/ShuffleSpellEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/ShuffleSpellEffect.java @@ -1,4 +1,3 @@ - package mage.abilities.effects.common; import mage.abilities.Ability; @@ -11,7 +10,6 @@ import mage.game.stack.Spell; import mage.players.Player; /** - * * @author nantuko */ public class ShuffleSpellEffect extends OneShotEffect implements MageSingleton { @@ -34,7 +32,7 @@ public class ShuffleSpellEffect extends OneShotEffect implements MageSingleton { // We have to use the spell id because in case of copied spells, the sourceId can be multiple times on the stack Spell spell = game.getStack().getSpell(source.getId()); if (spell != null) { - if (controller.moveCards(spell, Zone.LIBRARY, source, game) && !spell.isCopy()) { + if (controller.moveCards(spell, Zone.LIBRARY, source, game)) { Player owner = game.getPlayer(spell.getCard().getOwnerId()); if (owner != null) { owner.shuffleLibrary(source, game); diff --git a/Mage/src/main/java/mage/cards/AdventureCard.java b/Mage/src/main/java/mage/cards/AdventureCard.java index 473d53636a..bc3bab1775 100644 --- a/Mage/src/main/java/mage/cards/AdventureCard.java +++ b/Mage/src/main/java/mage/cards/AdventureCard.java @@ -18,7 +18,7 @@ import java.util.UUID; public abstract class AdventureCard extends CardImpl { /* The adventure spell card, i.e. Swift End. */ - protected Card spellCard; + protected AdventureCardSpell spellCard; public AdventureCard(UUID ownerId, CardSetInfo setInfo, CardType[] types, CardType[] typesSpell, String costs, String adventureName, String costsSpell) { super(ownerId, setInfo, types, costs); @@ -28,13 +28,19 @@ public abstract class AdventureCard extends CardImpl { public AdventureCard(AdventureCard card) { super(card); this.spellCard = card.getSpellCard().copy(); - ((AdventureCardSpell) this.spellCard).setParentCard(this); + this.spellCard.setParentCard(this); } - public Card getSpellCard() { + public AdventureCardSpell getSpellCard() { return spellCard; } + public void setParts(AdventureCardSpell cardSpell) { + // for card copy only - set new parts + this.spellCard = cardSpell; + cardSpell.setParentCard(this); + } + @Override public void assignNewId() { super.assignNewId(); diff --git a/Mage/src/main/java/mage/cards/ModalDoubleFacesCard.java b/Mage/src/main/java/mage/cards/ModalDoubleFacesCard.java index 34ae3def46..8932ce9a10 100644 --- a/Mage/src/main/java/mage/cards/ModalDoubleFacesCard.java +++ b/Mage/src/main/java/mage/cards/ModalDoubleFacesCard.java @@ -55,6 +55,14 @@ public abstract class ModalDoubleFacesCard extends CardImpl { return (ModalDoubleFacesCardHalf) rightHalfCard; } + public void setParts(ModalDoubleFacesCardHalf leftHalfCard, ModalDoubleFacesCardHalf rightHalfCard) { + // for card copy only - set new parts + this.leftHalfCard = leftHalfCard; + leftHalfCard.setParentCard(this); + this.rightHalfCard = rightHalfCard; + rightHalfCard.setParentCard(this); + } + @Override public void assignNewId() { super.assignNewId(); diff --git a/Mage/src/main/java/mage/cards/SplitCard.java b/Mage/src/main/java/mage/cards/SplitCard.java index 9bc7a9abc9..3c7f3170cf 100644 --- a/Mage/src/main/java/mage/cards/SplitCard.java +++ b/Mage/src/main/java/mage/cards/SplitCard.java @@ -42,6 +42,14 @@ public abstract class SplitCard extends CardImpl { ((SplitCardHalf) rightHalfCard).setParentCard(this); } + public void setParts(SplitCardHalf leftHalfCard, SplitCardHalf rightHalfCard) { + // for card copy only - set new parts + this.leftHalfCard = leftHalfCard; + leftHalfCard.setParentCard(this); + this.rightHalfCard = rightHalfCard; + rightHalfCard.setParentCard(this); + } + public SplitCardHalf getLeftHalfCard() { return (SplitCardHalf) leftHalfCard; } diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 98b1e439fa..24f3b5c5ab 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -74,6 +74,7 @@ import java.io.IOException; import java.io.Serializable; import java.util.*; import java.util.Map.Entry; +import java.util.stream.Collectors; public abstract class GameImpl implements Game, Serializable { @@ -552,7 +553,7 @@ public abstract class GameImpl implements Game, Serializable { // copied cards removes, but delayed triggered possible from it, see https://github.com/magefree/mage/issues/5437 // TODO: remove that workround after LKI rework, see GameState.copyCard if (card == null) { - card = (Card) state.getValue(GameState.COPIED_FROM_CARD_KEY + cardId.toString()); + card = (Card) state.getValue(GameState.COPIED_CARD_KEY + cardId.toString()); } return card; } @@ -1421,7 +1422,7 @@ public abstract class GameImpl implements Game, Serializable { errorContinueCounter++; continue; } else { - throw new MageException("Error in testclass"); + throw new MageException("Error in unit tests"); } } finally { setCheckPlayableState(false); @@ -1734,7 +1735,7 @@ public abstract class GameImpl implements Game, Serializable { @Override public Card copyCard(Card cardToCopy, Ability source, UUID newController) { - return state.copyCard(cardToCopy, source, this); + return state.copyCard(cardToCopy, newController, this); } /** @@ -1947,48 +1948,67 @@ public abstract class GameImpl implements Game, Serializable { somethingHappened = true; } - // 704.5e If a copy of a spell is in a zone other than the stack, it ceases to exist. If a copy of a card is in any zone other than the stack or the battlefield, it ceases to exist. - // (Isochron Scepter) 12/1/2004: If you don't want to cast the copy, you can choose not to; the copy ceases to exist the next time state-based actions are checked. - Iterator copiedCards = this.getState().getCopiedCards().iterator(); - while (copiedCards.hasNext()) { - Card card = copiedCards.next(); - if (card instanceof SplitCardHalf || card instanceof AdventureCardSpell || card instanceof ModalDoubleFacesCardHalf) { - continue; // only the main card is moves, not the halves (cause halfes is not copied - it uses original card -- TODO: need to fix (bugs with same card copy)? + // 704.5e + // If a copy of a spell is in a zone other than the stack, it ceases to exist. + // If a copy of a card is in any zone other than the stack or the battlefield, it ceases to exist. + // (Isochron Scepter) 12/1/2004: If you don't want to cast the copy, you can choose not to; the copy ceases + // to exist the next time state-based actions are checked. + // + // Copied cards can be stored in GameState.copiedCards or in game state value (until LKI rework) + Set allCopiedCards = new HashSet<>(); + allCopiedCards.addAll(this.getState().getCopiedCards()); + Map stateSavedCopiedCards = this.getState().getValues(GameState.COPIED_CARD_KEY); + allCopiedCards.addAll(stateSavedCopiedCards.values() + .stream() + .map(object -> (Card) object) + .filter(Objects::nonNull) + .collect(Collectors.toList()) + ); + for (Card copiedCard : allCopiedCards) { + // 1. Zone must be checked from main card only cause mdf parts can have different zones + // (one side on battlefield, another side on outsize) + // 2. Copied card creates in OUTSIDE zone and put to stack manually in the same code, + // so no SBA calls before real zone change (you will see here only unused cards like Isochron Scepter) + // (Isochron Scepter) 12/1/2004: If you don't want to cast the copy, you can choose not to; the copy ceases + // to exist the next time state-based actions are checked. + Zone zone = state.getZone(copiedCard.getMainCard().getId()); + if (zone == Zone.BATTLEFIELD || zone == Zone.STACK) { + continue; } - Zone zone = state.getZone(card.getId()); - if (zone != Zone.BATTLEFIELD && zone != Zone.STACK) { - // TODO: remember LKI of copied cards here after LKI rework - switch (zone) { - case GRAVEYARD: - for (Player player : getPlayers().values()) { - if (player.getGraveyard().contains(card.getId())) { - player.getGraveyard().remove(card); - break; - } + // TODO: remember LKI of copied cards here after LKI rework + switch (zone) { + case GRAVEYARD: + for (Player player : getPlayers().values()) { + if (player.getGraveyard().contains(copiedCard.getId())) { + player.getGraveyard().remove(copiedCard); + break; } - break; - case HAND: - for (Player player : getPlayers().values()) { - if (player.getHand().contains(card.getId())) { - player.getHand().remove(card); - break; - } + } + break; + case HAND: + for (Player player : getPlayers().values()) { + if (player.getHand().contains(copiedCard.getId())) { + player.getHand().remove(copiedCard); + break; } - break; - case LIBRARY: - for (Player player : getPlayers().values()) { - if (player.getLibrary().getCard(card.getId(), this) != null) { - player.getLibrary().remove(card.getId(), this); - break; - } + } + break; + case LIBRARY: + for (Player player : getPlayers().values()) { + if (player.getLibrary().getCard(copiedCard.getId(), this) != null) { + player.getLibrary().remove(copiedCard.getId(), this); + break; } - break; - case EXILED: - getExile().removeCard(card, this); - break; - } - copiedCards.remove(); + } + break; + case EXILED: + getExile().removeCard(copiedCard, this); + break; } + + // remove copied card info + this.getState().getCopiedCards().remove(copiedCard); + this.getState().removeValue(GameState.COPIED_CARD_KEY + copiedCard.getId().toString()); } List legendary = new ArrayList<>(); diff --git a/Mage/src/main/java/mage/game/GameState.java b/Mage/src/main/java/mage/game/GameState.java index 62b4c047f1..bc89379638 100644 --- a/Mage/src/main/java/mage/game/GameState.java +++ b/Mage/src/main/java/mage/game/GameState.java @@ -6,10 +6,7 @@ import mage.abilities.*; import mage.abilities.effects.ContinuousEffect; import mage.abilities.effects.ContinuousEffects; import mage.abilities.effects.Effect; -import mage.cards.AdventureCard; -import mage.cards.Card; -import mage.cards.ModalDoubleFacesCard; -import mage.cards.SplitCard; +import mage.cards.*; import mage.constants.Zone; import mage.designations.Designation; import mage.filter.common.FilterCreaturePermanent; @@ -56,7 +53,9 @@ public class GameState implements Serializable, Copyable { 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"; + // save copied cards between game cycles (lki workaround) + // warning, do not use another keys with same starting text cause copy code search and clean all related values + public static final String COPIED_CARD_KEY = "CopiedCard"; private final Players players; private final PlayerList playerList; @@ -845,7 +844,12 @@ public class GameState implements Serializable, Copyable { } public void addCard(Card card) { - setZone(card.getId(), Zone.OUTSIDE); + // all new cards and tokens must enter from outside + addCard(card, Zone.OUTSIDE); + } + + private void addCard(Card card, Zone zone) { + setZone(card.getId(), zone); // add card specific abilities to game for (Ability ability : card.getInitAbilities()) { @@ -853,28 +857,6 @@ public class GameState implements Serializable, Copyable { } } - public void removeCopiedCard(Card card) { - if (copiedCards.containsKey(card.getId())) { - copiedCards.remove(card.getId()); - cardState.remove(card.getId()); - zones.remove(card.getId()); - zoneChangeCounter.remove(card.getId()); - } - // TODO Watchers? - // TODO Abilities? - if (card instanceof SplitCard) { - removeCopiedCard(((SplitCard) card).getLeftHalfCard()); - removeCopiedCard(((SplitCard) card).getRightHalfCard()); - } - if (card instanceof ModalDoubleFacesCard) { - removeCopiedCard(((ModalDoubleFacesCard) card).getLeftHalfCard()); - removeCopiedCard(((ModalDoubleFacesCard) card).getRightHalfCard()); - } - if (card instanceof AdventureCard) { - removeCopiedCard(((AdventureCard) card).getSpellCard()); - } - } - /** * Used for adding abilities that exist permanent on cards/permanents and * are not only gained for a certain time (e.g. until end of turn). @@ -1021,6 +1003,27 @@ public class GameState implements Serializable, Copyable { return values.get(valueId); } + /** + * Return values list starting with searching key. + *

+ * Usage example: if you want to find all saved values from related ability/effect + * + * @param startWithValue + * @return + */ + public Map getValues(String startWithValue) { + if (startWithValue == null || startWithValue.isEmpty()) { + throw new IllegalArgumentException("Can't use empty search value"); + } + Map res = new HashMap<>(); + for (Map.Entry entry : this.values.entrySet()) { + if (entry.getKey().startsWith(startWithValue)) { + res.put(entry.getKey(), entry.getValue()); + } + } + return res; + } + /** * Best only use immutable objects, otherwise the states/values of the * object may be changed by AI simulation or rollbacks, because the Value @@ -1035,6 +1038,15 @@ public class GameState implements Serializable, Copyable { values.put(valueId, value); } + /** + * Remove saved value + * + * @param valueId + */ + public void removeValue(String valueId) { + values.remove(valueId); + } + /** * Other abilities are used to implement some special kind of continuous * effects that give abilities to non permanents. @@ -1251,47 +1263,92 @@ public class GameState implements Serializable, Copyable { return copiedCards.values(); } - public Card copyCard(Card cardToCopy, Ability source, Game game) { - // main card - Card copiedCard = cardToCopy.copy(); - copiedCard.assignNewId(); - copiedCard.setOwnerId(source.getControllerId()); - copiedCard.setCopy(true, cardToCopy); - copiedCards.put(copiedCard.getId(), copiedCard); - addCard(copiedCard); + /** + * Make full copy of the card and all of the card's parts and put to the game. + * + * @param mainCardToCopy + * @param newController + * @param game + * @return + */ + public Card copyCard(Card mainCardToCopy, UUID newController, Game game) { + // runtime check + if (!mainCardToCopy.getId().equals(mainCardToCopy.getMainCard().getId())) { + // copyCard allows for main card only, if you catch it then check your targeting code + throw new IllegalArgumentException("Wrong code usage. You can copy only main card."); + } - // other faces + // must copy all card's parts + // zcc and zone must be new cause zcc copy logic need card usage info here, but it haven't: + // * reason 1: copied land must be played (+1 zcc), but copied spell must be put on stack and cast (+2 zcc) + // * reason 2: copied card or spell can be used later as blueprint for real copies (see Epic ability) + List copiedParts = new ArrayList<>(); + + // main part (prepare must be called after other parts) + Card copiedCard = mainCardToCopy.copy(); + copiedParts.add(copiedCard); + + // other parts if (copiedCard instanceof SplitCard) { // left - Card leftCard = ((SplitCard) copiedCard).getLeftHalfCard(); // TODO: must be new ID (bugs with same card copy)? - copiedCards.put(leftCard.getId(), leftCard); - addCard(leftCard); + SplitCardHalf leftOriginal = ((SplitCard) copiedCard).getLeftHalfCard(); + SplitCardHalf leftCopied = leftOriginal.copy(); + prepareCardForCopy(leftOriginal, leftCopied, newController); + copiedParts.add(leftCopied); // right - Card rightCard = ((SplitCard) copiedCard).getRightHalfCard(); - copiedCards.put(rightCard.getId(), rightCard); - addCard(rightCard); + SplitCardHalf rightOriginal = ((SplitCard) copiedCard).getRightHalfCard(); + SplitCardHalf rightCopied = rightOriginal.copy(); + prepareCardForCopy(rightOriginal, rightCopied, newController); + copiedParts.add(rightCopied); + // sync parts + ((SplitCard) copiedCard).setParts(leftCopied, rightCopied); } else if (copiedCard instanceof ModalDoubleFacesCard) { // left - Card leftCard = ((ModalDoubleFacesCard) copiedCard).getLeftHalfCard(); // TODO: must be new ID (bugs with same card copy)? - copiedCards.put(leftCard.getId(), leftCard); - addCard(leftCard); + ModalDoubleFacesCardHalf leftOriginal = ((ModalDoubleFacesCard) copiedCard).getLeftHalfCard(); + ModalDoubleFacesCardHalf leftCopied = leftOriginal.copy(); + prepareCardForCopy(leftOriginal, leftCopied, newController); + copiedParts.add(leftCopied); // right - Card rightCard = ((ModalDoubleFacesCard) copiedCard).getRightHalfCard(); - copiedCards.put(rightCard.getId(), rightCard); - addCard(rightCard); + ModalDoubleFacesCardHalf rightOriginal = ((ModalDoubleFacesCard) copiedCard).getRightHalfCard(); + ModalDoubleFacesCardHalf rightCopied = rightOriginal.copy(); + prepareCardForCopy(rightOriginal, rightCopied, newController); + copiedParts.add(rightCopied); + // sync parts + ((ModalDoubleFacesCard) copiedCard).setParts(leftCopied, rightCopied); } else if (copiedCard instanceof AdventureCard) { - Card spellCard = ((AdventureCard) copiedCard).getSpellCard(); - copiedCards.put(spellCard.getId(), spellCard); - addCard(spellCard); + // right + AdventureCardSpell rightOriginal = ((AdventureCard) copiedCard).getSpellCard(); + AdventureCardSpell rightCopied = rightOriginal.copy(); + prepareCardForCopy(rightOriginal, rightCopied, newController); + copiedParts.add(rightCopied); + // sync parts + ((AdventureCard) copiedCard).setParts(rightCopied); } + // main part prepare (must be called after other parts cause it change ids for all) + prepareCardForCopy(mainCardToCopy, copiedCard, newController); + + // add all parts to the game + copiedParts.forEach(card -> { + copiedCards.put(card.getId(), card); + addCard(card); + }); + // copied cards removes from game after battlefield/stack leaves, so remember it here as workaround to fix freeze, see https://github.com/magefree/mage/issues/5437 // TODO: remove that workaround after LKI will be rewritten to support cross-steps/turns data transition and support copied cards - this.setValue(COPIED_FROM_CARD_KEY + copiedCard.getId(), cardToCopy.copy()); + copiedParts.forEach(card -> { + this.setValue(COPIED_CARD_KEY + card.getId(), card.copy()); + }); return copiedCard; } + private void prepareCardForCopy(Card originalCard, Card copiedCard, UUID newController) { + copiedCard.assignNewId(); + copiedCard.setOwnerId(newController); + copiedCard.setCopy(true, originalCard); + } + public int getNextPermanentOrderNumber() { return permanentOrderNumber++; } diff --git a/Mage/src/main/java/mage/game/stack/Spell.java b/Mage/src/main/java/mage/game/stack/Spell.java index a66b13fedf..2fa39cce0f 100644 --- a/Mage/src/main/java/mage/game/stack/Spell.java +++ b/Mage/src/main/java/mage/game/stack/Spell.java @@ -766,8 +766,31 @@ public class Spell extends StackObjImpl implements Card { return new Spell(this); } - public Spell copySpell(UUID newController, Game game) { - Spell spellCopy = new Spell(this.card, this.ability.copySpell(), this.controllerId, this.fromZone, game); + /** + * Copy current spell on stack, but do not put copy back to stack (you can modify and put it later) + *

+ * Warning, don't forget to call CopyStackObjectEvent and CopiedStackObjectEvent before and after copy + * CopyStackObjectEvent can change new copies amount, see Twinning Staff + *

+ * Warning, don't forget to call spell.setZone before push to stack + * + * @param game + * @param newController controller of the copied spell + * @return + */ + public Spell copySpell(Game game, Ability source, UUID newController) { + // copied spells must use copied cards + // spell can be from card's part (mdf/adventure), but you must copy FULL card + Card copiedMainCard = game.copyCard(this.card.getMainCard(), source, newController); + // find copied part + Map mapOldToNew = CardUtil.getOriginalToCopiedPartsMap(this.card.getMainCard(), copiedMainCard); + if (!mapOldToNew.containsKey(this.card.getId())) { + throw new IllegalStateException("Can't find card id after main card copy: " + copiedMainCard.getName()); + } + Card copiedPart = (Card) mapOldToNew.get(this.card.getId()); + + // copy spell + Spell spellCopy = new Spell(copiedPart, this.ability.copySpell(this.card, copiedPart), this.controllerId, this.fromZone, game); boolean firstDone = false; for (SpellAbility spellAbility : this.getSpellAbilities()) { if (!firstDone) { @@ -939,6 +962,12 @@ public class Spell extends StackObjImpl implements Card { this.copyFrom = (copyFrom != null ? copyFrom.copy() : null); } + /** + * Game processing a copies as normal cards, so you don't need to check spell's copy for move/exile + * Use this only in exceptional situations or skip unaffected code/choices + * + * @return + */ @Override public boolean isCopy() { return this.copy; @@ -1006,6 +1035,7 @@ public class Spell extends StackObjImpl implements Card { @Override public void setZone(Zone zone, Game game) { card.setZone(zone, game); + game.getState().setZone(this.getId(), Zone.STACK); } @Override @@ -1039,8 +1069,8 @@ public class Spell extends StackObjImpl implements Card { return null; } for (int i = 0; i < gameEvent.getAmount(); i++) { - spellCopy = this.copySpell(newControllerId, game); - game.getState().setZone(spellCopy.getId(), Zone.STACK); // required for targeting ex: Nivmagus Elemental + spellCopy = this.copySpell(game, source, newControllerId); + spellCopy.setZone(Zone.STACK, game); // required for targeting ex: Nivmagus Elemental game.getStack().push(spellCopy); if (chooseNewTargets) { spellCopy.chooseNewTargets(game, newControllerId); diff --git a/Mage/src/main/java/mage/util/CardUtil.java b/Mage/src/main/java/mage/util/CardUtil.java index ad4f01e1ed..3311b89a20 100644 --- a/Mage/src/main/java/mage/util/CardUtil.java +++ b/Mage/src/main/java/mage/util/CardUtil.java @@ -1228,39 +1228,66 @@ public final class CardUtil { * @return */ public static Set getObjectParts(MageObject object) { - Set res = new HashSet<>(); + Set res = new LinkedHashSet<>(); // set must be ordered + List allParts = getObjectPartsAsObjects(object); + allParts.forEach(part -> { + res.add(part.getId()); + }); + return res; + } + + public static List getObjectPartsAsObjects(MageObject object) { + List res = new ArrayList<>(); if (object == null) { return res; } if (object instanceof SplitCard || object instanceof SplitCardHalf) { SplitCard mainCard = (SplitCard) ((Card) object).getMainCard(); - res.add(object.getId()); - res.add(mainCard.getId()); - res.add(mainCard.getLeftHalfCard().getId()); - res.add(mainCard.getRightHalfCard().getId()); + res.add(mainCard); + res.add(mainCard.getLeftHalfCard()); + res.add(mainCard.getRightHalfCard()); } else if (object instanceof ModalDoubleFacesCard || object instanceof ModalDoubleFacesCardHalf) { ModalDoubleFacesCard mainCard = (ModalDoubleFacesCard) ((Card) object).getMainCard(); - res.add(object.getId()); - res.add(mainCard.getId()); - res.add(mainCard.getLeftHalfCard().getId()); - res.add(mainCard.getRightHalfCard().getId()); + res.add(mainCard); + res.add(mainCard.getLeftHalfCard()); + res.add(mainCard.getRightHalfCard()); } else if (object instanceof AdventureCard || object instanceof AdventureCardSpell) { AdventureCard mainCard = (AdventureCard) ((Card) object).getMainCard(); - res.add(object.getId()); - res.add(mainCard.getId()); - res.add(mainCard.getSpellCard().getId()); + res.add(mainCard); + res.add(mainCard.getSpellCard()); } else if (object instanceof Spell) { // example: activate Lightning Storm's ability from the spell on the stack - res.add(object.getId()); - res.addAll(getObjectParts(((Spell) object).getCard())); + res.add(object); + res.addAll(getObjectPartsAsObjects(((Spell) object).getCard())); } else if (object instanceof Commander) { // commander can contains double sides - res.add(object.getId()); - res.addAll(getObjectParts(((Commander) object).getSourceObject())); + res.add(object); + res.addAll(getObjectPartsAsObjects(((Commander) object).getSourceObject())); } else { - res.add(object.getId()); + res.add(object); } return res; } + + /** + * Find mapping from original to copied card (e.g. map left side with copied left side, etc) + * + * @param originalCard + * @param copiedCard + * @return + */ + public static Map getOriginalToCopiedPartsMap(Card originalCard, Card copiedCard) { + List oldIds = new ArrayList<>(CardUtil.getObjectParts(originalCard)); + List newObjects = new ArrayList<>(CardUtil.getObjectPartsAsObjects(copiedCard)); + if (oldIds.size() != newObjects.size()) { + throw new IllegalStateException("Found wrong card parts after copy: " + originalCard.getName() + " -> " + copiedCard.getName()); + } + + Map mapOldToNew = new HashMap<>(); + for (int i = 0; i < oldIds.size(); i++) { + mapOldToNew.put(oldIds.get(i), newObjects.get(i)); + } + return mapOldToNew; + } }