From 8d249aa691b7cdd20e8bbffe811b32fb738137bd Mon Sep 17 00:00:00 2001 From: PurpleCrowbar <26198472+PurpleCrowbar@users.noreply.github.com> Date: Sat, 24 Aug 2024 06:03:33 +0100 Subject: [PATCH] Improve attachment to permanent logic; implement [PIP] Codsworth, Handy Helper (#12098) * [PIP] Implement Codsworth, Handy Helper * Fix Codsworth and Halvar * Write tests for attachments * Fix auras going to graveyard when attaching to illegal targets * Fix Captured by the Consulate interaction * Fix failing tests, add additional test * Add source name to log message * Implement requested changes * Revert removed null check * Remove filter check, clean up code * Add additional test * Fix failing roles test * Account for all current attachment edge cases * Implement rule 303.4g * Apply requested changes --- .../mage/cards/c/CodsworthHandyHelper.java | 168 ++++++++ .../src/mage/cards/h/HalvarGodOfBattle.java | 16 +- Mage.Sets/src/mage/sets/Fallout.java | 2 + .../mage/test/cards/rules/AttachmentTest.java | 374 ++++++++++++++++++ .../abilities/effects/ContinuousEffects.java | 2 +- .../mage/game/permanent/PermanentImpl.java | 17 +- .../main/java/mage/players/PlayerImpl.java | 9 + Mage/src/main/java/mage/target/Target.java | 2 +- .../src/main/java/mage/target/TargetImpl.java | 6 +- .../TargetTappedPermanentAsYouCast.java | 4 +- 10 files changed, 580 insertions(+), 20 deletions(-) create mode 100644 Mage.Sets/src/mage/cards/c/CodsworthHandyHelper.java create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/rules/AttachmentTest.java diff --git a/Mage.Sets/src/mage/cards/c/CodsworthHandyHelper.java b/Mage.Sets/src/mage/cards/c/CodsworthHandyHelper.java new file mode 100644 index 00000000000..e49386eff30 --- /dev/null +++ b/Mage.Sets/src/mage/cards/c/CodsworthHandyHelper.java @@ -0,0 +1,168 @@ +package mage.cards.c; + +import mage.ConditionalMana; +import mage.MageInt; +import mage.Mana; +import mage.abilities.Ability; +import mage.abilities.SpellAbility; +import mage.abilities.common.ActivateAsSorceryActivatedAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.condition.Condition; +import mage.abilities.costs.common.TapSourceCost; +import mage.abilities.costs.mana.GenericManaCost; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.common.continuous.GainAbilityControlledEffect; +import mage.abilities.keyword.WardAbility; +import mage.abilities.mana.ConditionalColoredManaAbility; +import mage.abilities.mana.builder.ConditionalManaBuilder; +import mage.cards.Card; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.filter.FilterPermanent; +import mage.filter.common.FilterControlledPermanent; +import mage.filter.predicate.Predicates; +import mage.filter.predicate.mageobject.CommanderPredicate; +import mage.game.Game; +import mage.game.permanent.Permanent; +import mage.players.Player; +import mage.target.TargetPermanent; +import mage.target.common.TargetControlledCreaturePermanent; +import mage.util.CardUtil; + +import java.util.UUID; + +/** + * @author PurpleCrowbar + */ +public final class CodsworthHandyHelper extends CardImpl { + + private static final FilterPermanent filter = new FilterControlledPermanent("commanders"); + private static final FilterControlledPermanent filter2 = new FilterControlledPermanent("Aura or Equipment you control"); + + static { + filter.add(CommanderPredicate.instance); + filter2.add(Predicates.or(SubType.AURA.getPredicate(), SubType.EQUIPMENT.getPredicate())); + } + + public CodsworthHandyHelper(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ARTIFACT, CardType.CREATURE}, "{2}{W}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.ROBOT); + this.power = new MageInt(2); + this.toughness = new MageInt(3); + + // Commanders you control have ward {2}. + this.addAbility(new SimpleStaticAbility(new GainAbilityControlledEffect( + new WardAbility(new GenericManaCost(2)), Duration.WhileOnBattlefield, filter + ))); + + // {T}: Add {W}{W}. Spend this mana only to cast Aura and/or Equipment spells. + this.addAbility(new ConditionalColoredManaAbility( + new TapSourceCost(), Mana.WhiteMana(2), + new CodsworthHandyHelperManaBuilder() + )); + + // {T}: Attach target Aura or Equipment you control to target creature you control. Activate only as a sorcery. + Ability ability = new ActivateAsSorceryActivatedAbility(new CodsworthHandyHelperEffect(), new TapSourceCost()); + ability.addTarget(new TargetPermanent(filter2)); + ability.addTarget(new TargetControlledCreaturePermanent()); + this.addAbility(ability); + } + + private CodsworthHandyHelper(final CodsworthHandyHelper card) { + super(card); + } + + @Override + public CodsworthHandyHelper copy() { + return new CodsworthHandyHelper(this); + } +} + +class CodsworthHandyHelperManaBuilder extends ConditionalManaBuilder { + + @Override + public ConditionalMana build(Object... options) { + return new CodsworthHandyHelperConditionalMana(this.mana); + } + + @Override + public String getRule() { + return "Spend this mana only to cast Aura and/or Equipment spells"; + } +} + +class CodsworthHandyHelperConditionalMana extends ConditionalMana { + + public CodsworthHandyHelperConditionalMana(Mana mana) { + super(mana); + addCondition(new CodsworthHandyHelperManaCondition()); + } + + private CodsworthHandyHelperConditionalMana(final CodsworthHandyHelperConditionalMana conditionalMana) { + super(conditionalMana); + } + + @Override + public CodsworthHandyHelperConditionalMana copy() { + return new CodsworthHandyHelperConditionalMana(this); + } +} + +class CodsworthHandyHelperManaCondition implements Condition { + + @Override + public boolean apply(Game game, Ability source) { + if (source instanceof SpellAbility) { + Card card = game.getCard(source.getSourceId()); + return card != null && ( + card.getSubtype(game).contains(SubType.AURA) || card.getSubtype(game).contains(SubType.EQUIPMENT) + ); + } + return false; + } +} +class CodsworthHandyHelperEffect extends OneShotEffect { + + CodsworthHandyHelperEffect() { + super(Outcome.Benefit); + staticText = "Attach target Aura or Equipment you control to target creature you control"; + } + + private CodsworthHandyHelperEffect(final CodsworthHandyHelperEffect effect) { + super(effect); + } + + @Override + public CodsworthHandyHelperEffect copy() { + return new CodsworthHandyHelperEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + Permanent attachment = game.getPermanent(source.getTargets().get(0).getFirstTarget()); + Permanent creature = game.getPermanent(source.getTargets().get(1).getFirstTarget()); + if (controller == null || attachment == null || creature == null) { + return false; + } + + if (creature.cantBeAttachedBy(attachment, source, game, true)) { + game.informPlayers(attachment.getLogName() + " was not attached to " + creature.getLogName() + + " because it's not a legal target" + CardUtil.getSourceLogName(game, source)); + return false; + } + Permanent oldCreature = game.getPermanent(attachment.getAttachedTo()); + if (oldCreature != null) { + oldCreature.removeAttachment(attachment.getId(), source, game); + } + creature.addAttachment(attachment.getId(), source, game); + game.informPlayers(attachment.getLogName() + " was " + + (oldCreature != null ? "unattached from " + oldCreature.getLogName() + " and " : "") + + "attached to " + creature.getLogName() + ); + return true; + } +} diff --git a/Mage.Sets/src/mage/cards/h/HalvarGodOfBattle.java b/Mage.Sets/src/mage/cards/h/HalvarGodOfBattle.java index 04ac3da5c06..16d294a0a0e 100644 --- a/Mage.Sets/src/mage/cards/h/HalvarGodOfBattle.java +++ b/Mage.Sets/src/mage/cards/h/HalvarGodOfBattle.java @@ -28,7 +28,6 @@ import mage.filter.predicate.permanent.EquippedPredicate; import mage.game.Game; import mage.game.permanent.Permanent; import mage.players.Player; -import mage.target.Target; import mage.target.TargetPermanent; import mage.target.common.TargetControlledCreaturePermanent; @@ -126,17 +125,12 @@ class HalvarGodOfBattleEffect extends OneShotEffect { if (controller != null && attachment != null && creature != null && creature.isControlledBy(controller.getId())) { Permanent oldCreature = game.getPermanent(attachment.getAttachedTo()); if (oldCreature != null && oldCreature.isControlledBy(controller.getId()) && !oldCreature.equals(creature)) { - boolean canAttach = true; - if (attachment.hasSubtype(SubType.AURA, game)) { - Target auraTarget = attachment.getSpellAbility().getTargets().get(0); - if (!auraTarget.canTarget(creature.getId(), game)) { - canAttach = false; - } - } - if (!canAttach) { + if (creature.cantBeAttachedBy(attachment, source, game, true)) { game.informPlayers(attachment.getLogName() + " was not attached to " + creature.getLogName() - + " because it's not a legal target for the aura"); - } else if (controller.chooseUse(Outcome.BoostCreature, "Attach " + attachment.getLogName() + + " because it's not a legal target"); + return false; + } + if (controller.chooseUse(Outcome.BoostCreature, "Attach " + attachment.getLogName() + " to " + creature.getLogName() + "?", source, game)) { oldCreature.removeAttachment(attachment.getId(), source, game); creature.addAttachment(attachment.getId(), source, game); diff --git a/Mage.Sets/src/mage/sets/Fallout.java b/Mage.Sets/src/mage/sets/Fallout.java index 88c37da9175..88883a5d672 100644 --- a/Mage.Sets/src/mage/sets/Fallout.java +++ b/Mage.Sets/src/mage/sets/Fallout.java @@ -85,6 +85,8 @@ public final class Fallout extends ExpansionSet { cards.add(new SetCardInfo("Charisma Bobblehead", 130, Rarity.UNCOMMON, mage.cards.c.CharismaBobblehead.class)); cards.add(new SetCardInfo("Cinder Glade", 257, Rarity.RARE, mage.cards.c.CinderGlade.class)); cards.add(new SetCardInfo("Clifftop Retreat", 258, Rarity.RARE, mage.cards.c.ClifftopRetreat.class)); + cards.add(new SetCardInfo("Codsworth, Handy Helper", 14, Rarity.RARE, mage.cards.c.CodsworthHandyHelper.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Codsworth, Handy Helper", 366, Rarity.RARE, mage.cards.c.CodsworthHandyHelper.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Colonel Autumn", 98, Rarity.RARE, mage.cards.c.ColonelAutumn.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Colonel Autumn", 411, Rarity.RARE, mage.cards.c.ColonelAutumn.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Colonel Autumn", 626, Rarity.RARE, mage.cards.c.ColonelAutumn.class, NON_FULL_USE_VARIOUS)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/rules/AttachmentTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/rules/AttachmentTest.java new file mode 100644 index 00000000000..7da50462f5d --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/rules/AttachmentTest.java @@ -0,0 +1,374 @@ +package org.mage.test.cards.rules; + +import mage.constants.CardType; +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Ignore; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author PurpleCrowbar + */ +public class AttachmentTest extends CardTestPlayerBase { + + // {T}: Attach target Aura or Equipment you control to target creature you control. Activate only as a sorcery. + private static final String codsworth = "Codsworth, Handy Helper"; + + /** + * Tests that a permanent that becomes non-attachable (i.e., non-aura, non-equipment, non-fortification) + * due to loss of card type before resolution of an ability is not still attached. + */ + @Test + public void testAttachNonAttachable() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.BATTLEFIELD, playerA, "Lion Sash"); // Reconfigurable equipment creature + addCard(Zone.BATTLEFIELD, playerA, "Vedalken Orrery"); // You may cast spells as though they had flash + addCard(Zone.BATTLEFIELD, playerA, "Plains", 2); + addCard(Zone.HAND, playerA, "Darksteel Mutation"); // Enchanted creature loses equipment type + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Lion Sash"); + addTarget(playerA, codsworth); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Darksteel Mutation", "Lion Sash"); + + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertAttachedTo(playerA, "Lion Sash", codsworth, false); + assertGraveyardCount(playerA, "Darksteel Mutation", 0); + assertType("Lion Sash", CardType.CREATURE, true); + } + + /** + * Tests that protection prevents attachment. Attachment should remain attached to whatever it was attached to. + */ + @Test + public void testProtectionPreventsAttachment() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.BATTLEFIELD, playerA, "Candlestick"); // Blue equipment + addCard(Zone.HAND, playerA, "Agoraphobia"); // Blue aura + addCard(Zone.BATTLEFIELD, playerA, "Bloated Toad"); // Protection from blue + addCard(Zone.BATTLEFIELD, playerA, "Island", 4); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Equip", codsworth); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Agoraphobia", codsworth); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Candlestick"); + addTarget(playerA, "Bloated Toad"); + activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Agoraphobia"); + addTarget(playerA, "Bloated Toad"); + + setStopAt(3, PhaseStep.END_TURN); + execute(); + + assertAttachedTo(playerA, "Candlestick", codsworth, true); + assertAttachedTo(playerA, "Agoraphobia", codsworth, true); + } + + /** + * Tests that attachments fall off a permanent that gains the appropriate protection. + */ + @Test + public void testProtectionShedsAttachments() { + setStrictChooseMode(true); + + // {R}: {this} gains protection from red until end of turn. + addCard(Zone.BATTLEFIELD, playerA, "Keeper of Kookus"); + addCard(Zone.BATTLEFIELD, playerA, "Jackhammer"); // Red equipment + addCard(Zone.HAND, playerA, "Agility"); // Red aura + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 5); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Equip", "Keeper of Kookus"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Agility", "Keeper of Kookus"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{R}:"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Agility", 1); + assertPermanentCount(playerA, "Jackhammer", 1); + assertAttachedTo(playerA, "Jackhammer", "Keeper of Kookus", false); + } + + /** + * Tests that attachments can only attach to legal targets. Specifically, + * that an "Enchant land" aura cannot be attached to a nonland creature. + */ + @Test + public void testDeniedMoveIllegalAuraTarget() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.BATTLEFIELD, playerA, "Forest"); + addCard(Zone.HAND, playerA, "Wild Growth"); // Enchant land aura + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Wild Growth", "Forest"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Wild Growth"); + addTarget(playerA, codsworth); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Wild Growth", 0); + assertAttachedTo(playerA, "Wild Growth", "Forest", true); + } + + /** + * Tests that an equipment cannot be moved to a noncreature permanent. + * Equipment should remain attached to whatever it was attached to. + */ + @Test + public void testDeniedMoveIllegalEquipmentTarget() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.BATTLEFIELD, playerA, "Bonesplitter"); // Equip {1} + addCard(Zone.BATTLEFIELD, playerA, "Bonebreaker Giant"); // Arbitrary creature + addCard(Zone.BATTLEFIELD, playerA, "Vedalken Orrery"); // You may cast spells as though they had flash + addCard(Zone.BATTLEFIELD, playerA, "Island", 5); + addCard(Zone.HAND, playerA, "One with the Stars"); // Enchanted permanent loses creature type + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Equip {1}", codsworth); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Bonesplitter"); + addTarget(playerA, "Bonebreaker Giant"); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "One with the Stars", "Bonebreaker Giant"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertAttachedTo(playerA, "Bonesplitter", codsworth, true); + assertGraveyardCount(playerA, 0); + } + + /** + * Tests that an enchant ability prohibition not pertaining to card type is respected when trying to move an aura. + * In this case, an "Enchant creature you don't control" aura shouldn't be movable to a controlled creature. + */ + @Test + public void testDeniedMoveIllegalAuraTargetNotCardType() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + addCard(Zone.HAND, playerA, "Captured by the Consulate"); // Enchant creature you don't control + addCard(Zone.BATTLEFIELD, playerB, "Pia Nalaar"); // Arbitrary creature + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {W}.", 4); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Captured by the Consulate", "Pia Nalaar"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Captured by the Consulate"); + addTarget(playerA, codsworth); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, 0); + assertAttachedTo(playerB, "Captured by the Consulate", "Pia Nalaar", true); + } + + /** + * Tests that an aura that attaches to a player (specifically an opponent in this case) cannot attach to a permanent. + */ + @Test + public void testDeniedMoveIllegalPlayerAuraTarget() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.HAND, playerA, "Psychic Possession"); // Enchant opponent + addCard(Zone.BATTLEFIELD, playerA, "Island", 4); + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}.", 4); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Psychic Possession", playerB); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Psychic Possession"); + addTarget(playerA, codsworth); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Psychic Possession", 0); // TODO: Currently, the aura goes to graveyard. Must fix + assertAttachedTo(playerA, "Psychic Possession", codsworth, false); + } + + /** + * Tests that an aura that attaches to a card in the graveyard cannot attach to a permanent. + */ + @Test + public void testDeniedMoveIllegalCardAuraTarget() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, codsworth); + addCard(Zone.HAND, playerA, "Spellweaver Volute"); // Enchant instant card in graveyard + addCard(Zone.BATTLEFIELD, playerA, "Island", 5); + addCard(Zone.GRAVEYARD, playerA, "Counterspell"); // Arbitrary instant + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}.", 5); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spellweaver Volute", "Counterspell"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Attach", "Spellweaver Volute"); + addTarget(playerA, codsworth); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Spellweaver Volute", 0); // TODO: Currently goes to graveyard. Must fix + assertAttachedTo(playerA, "Spellweaver Volute", codsworth, false); + } + + /** + * Tests that when a player gains protection from an aura, it falls off (is moved to the graveyard). + */ + @Test + public void testCurseFallsOffFromGainedProtection() { + setStrictChooseMode(true); + + addCard(Zone.HAND, playerA, "Curse of Opulence"); // Arbitrary curse (enchant player) + addCard(Zone.HAND, playerB, "Runed Halo"); // You have protection from the chosen card name. + addCard(Zone.BATTLEFIELD, playerA, "Mountain"); + addCard(Zone.BATTLEFIELD, playerB, "Plains", 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Curse of Opulence", playerB); + castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, "Runed Halo"); + setChoice(playerB, "Curse of Opulence"); + + setStopAt(2, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Curse of Opulence", 1); + } + + /** + * Tests that when an aura is cast and the target becomes illegal (in this + * case, due to a change in card type), the aura goes to the graveyard. + */ + @Test + public void testCastAuraIllegalTarget() { + setStrictChooseMode(true); + + addCard(Zone.HAND, playerA, "Arcane Flight"); // Arbitrary "enchant creature" aura + addCard(Zone.HAND, playerA, "One with the Stars"); // Enchanted permanent loses creature type + addCard(Zone.BATTLEFIELD, playerA, "Runeclaw Bear"); // Arbitrary creature + addCard(Zone.BATTLEFIELD, playerA, "Vedalken Orrery"); // You may cast spells as though they had flash + addCard(Zone.BATTLEFIELD, playerA, "Island", 5); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Arcane Flight", "Runeclaw Bear"); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "One with the Stars", "Runeclaw Bear"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Arcane Flight", 1); + } + + /** + * Tests that when an aura tries to move from a player's hand without being cast, and + * it has no legal objects to attach to, it instead remains in the player's hand. + */ + @Test + public void testAuraMoveFromHandWithNoAttachableObject() { + setStrictChooseMode(true); + + addCard(Zone.HAND, playerA, "Show and Tell"); // Puts an aura from hand onto the battlefield without casting + addCard(Zone.HAND, playerA, "Aether Tunnel"); // Enchant creature + addCard(Zone.BATTLEFIELD, playerA, "Island", 3); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Show and Tell"); + setChoice(playerA, true); + setChoice(playerB, false); + addTarget(playerA, "Aether Tunnel"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Aether Tunnel", 0); + assertHandCount(playerA, "Aether Tunnel", 1); + } + + /** + * Tests that when an aura tries to move from a player's graveyard without being cast, and + * it has no legal objects to attach to, it instead remains in the player's graveyard. + */ + @Test + public void testAuraMoveFromGraveyardWithNoAttachableObject() { + setStrictChooseMode(true); + + addCard(Zone.HAND, playerA, "Replenish"); // Put all enchantments from graveyard onto battlefield + addCard(Zone.GRAVEYARD, playerA, "Divine Favor"); // Enchant creature, gain 3 life on ETB + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Replenish"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Divine Favor", 1); + assertLife(playerA, 20); + } + + /** + * Tests that when an aura tries to move from exile without being cast, and + * it has no legal objects to attach to, it instead remains in exile. + */ + @Test + public void testAuraMoveFromExileWithNoAttachableObject() { + setStrictChooseMode(true); + + addCard(Zone.HAND, playerA, "Sudden Disappearance"); // Exile nonland permanents, return at end of turn + addCard(Zone.HAND, playerA, "Spiritual Visit"); // Create a 1/1 token + addCard(Zone.HAND, playerA, "Divine Favor"); // Enchant creature, gain 3 life on ETB + addCard(Zone.BATTLEFIELD, playerA, "Plains", 9); + + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spiritual Visit"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Divine Favor", "Spirit Token"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Sudden Disappearance", playerA); + waitStackResolved(1, PhaseStep.END_TURN, playerA); + + setStopAt(2, PhaseStep.UNTAP); + execute(); + + assertGraveyardCount(playerA, "Divine Favor", 0); + assertExileCount(playerA, "Divine Favor", 1); + assertLife(playerA, 23); + } + + /** + * Tests that Dream Leash can correctly attach to untapped permanents when it enters through non-casting means (e.g., via Show and Tell) + */ + @Ignore + @Test + public void testDreamLeashNoUntappedPermanents() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, "Omniscience"); + // Enchant permanent + // You can't choose an untapped permanent as Dream Leash's target as you cast Dream Leash. + // You control enchanted permanent. + addCard(Zone.HAND, playerA, "Dream Leash"); + addCard(Zone.HAND, playerA, "Show and Tell"); // Puts a permanent directly into play without casting + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Show and Tell"); + setChoice(playerA, true, 2); // 1. Cast without paying cost? 2. Put permanent into play? + setChoice(playerB, false); // Put permanent into play? + addTarget(playerA, "Dream Leash"); // Show and Tell's target + setChoice(playerA, "Omniscience"); // Dream Leash's choice + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertGraveyardCount(playerA, "Dream Leash", 0); + assertAttachedTo(playerA, "Dream Leash", "Omniscience", true); + assertHandCount(playerA, "Dream Leash", 0); + } +} diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java index 1d979a1931a..35a309dff83 100644 --- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java +++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java @@ -774,7 +774,7 @@ public class ContinuousEffects implements Serializable { } /** - * Checks if an event won't happen because of an rule modifying effect + * Checks if an event won't happen because of a rule modifying effect * * @param event * @param targetAbility ability the event is attached to. can be null. diff --git a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java index 76291b671de..d5e175f6495 100644 --- a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java +++ b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java @@ -21,7 +21,7 @@ import mage.constants.*; import mage.counters.Counter; import mage.counters.CounterType; import mage.counters.Counters; -import mage.filter.FilterOpponent; +import mage.filter.*; import mage.game.Game; import mage.game.GameState; import mage.game.ZoneChangeInfo; @@ -1355,7 +1355,20 @@ public abstract class PermanentImpl extends CardImpl implements Permanent { return !ability.getDoesntRemoveControlled() || isControlledBy(game.getControllerId(attachment.getId())); } } - return game.getContinuousEffects().preventedByRuleModification(new StayAttachedEvent(this.getId(), attachment.getId(), source), null, game, silentMode); + + boolean canAttach = true; + Permanent attachmentPermanent = game.getPermanent(attachment.getId()); + // If attachment is an aura, ensures this permanent can still be legally enchanted, according to the enchantment's Enchant ability + if (attachment.hasSubtype(SubType.AURA, game) + && attachmentPermanent != null + && attachmentPermanent.getSpellAbility() != null + && !attachmentPermanent.getSpellAbility().getTargets().isEmpty()) { + // Line of code below functionally gets the target of the aura's Enchant ability, then compares to this permanent. Enchant improperly implemented in XMage, see #9583 + // Note: stillLegalTarget used exclusively to account for Dream Leash. Can be made canTarget in the event that that card is rewritten (and "stillLegalTarget" removed from TargetImpl). + canAttach = attachmentPermanent.getSpellAbility().getTargets().get(0).copy().withNotTarget(true).stillLegalTarget(attachmentPermanent.getControllerId(), this.getId(), source, game); + } + + return !canAttach || game.getContinuousEffects().preventedByRuleModification(new StayAttachedEvent(this.getId(), attachment.getId(), source), null, game, silentMode); } @Override diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index bc9a622517e..03e45945f97 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -4836,6 +4836,15 @@ public abstract class PlayerImpl implements Player, Serializable { if (enterTransformed != null && enterTransformed && !card.isTransformable()) { continue; } + // 303.4g. If an Aura is entering the battlefield and there is no legal object or player for it to enchant, + // the Aura remains in its current zone, unless that zone is the stack. In that case, the Aura is put into + // its owner's graveyard instead of entering the battlefield. If the Aura is a token, it isn't created. + if (card.hasSubtype(SubType.AURA, game) + && card.getSpellAbility() != null + && !card.getSpellAbility().getTargets().isEmpty() + && !card.getSpellAbility().getTargets().get(0).copy().withNotTarget(true).canChoose(byOwner ? card.getOwnerId() : getId(), game)) { + continue; + } ZoneChangeEvent event = new ZoneChangeEvent(card.getId(), source, byOwner ? card.getOwnerId() : getId(), fromZone, Zone.BATTLEFIELD, appliedEffects); infoList.add(new ZoneChangeInfo.Battlefield(event, faceDown, tapped, source)); diff --git a/Mage/src/main/java/mage/target/Target.java b/Mage/src/main/java/mage/target/Target.java index de0f4a49d7f..8ef2e97d69c 100644 --- a/Mage/src/main/java/mage/target/Target.java +++ b/Mage/src/main/java/mage/target/Target.java @@ -73,7 +73,7 @@ public interface Target extends Serializable { */ boolean canTarget(UUID id, Ability source, Game game); - boolean stillLegalTarget(UUID id, Ability source, Game game); + boolean stillLegalTarget(UUID playerId, UUID id, Ability source, Game game); boolean canTarget(UUID playerId, UUID id, Ability source, Game game); diff --git a/Mage/src/main/java/mage/target/TargetImpl.java b/Mage/src/main/java/mage/target/TargetImpl.java index b9da8b9b5f6..3fde07ecd1e 100644 --- a/Mage/src/main/java/mage/target/TargetImpl.java +++ b/Mage/src/main/java/mage/target/TargetImpl.java @@ -409,7 +409,7 @@ public abstract class TargetImpl implements Target { illegalTargets.add(targetId); continue; } - if (!stillLegalTarget(targetId, source, game)) { + if (!stillLegalTarget(source.getControllerId(), targetId, source, game)) { illegalTargets.add(targetId); } } @@ -546,8 +546,8 @@ public abstract class TargetImpl implements Target { } @Override - public boolean stillLegalTarget(UUID id, Ability source, Game game) { - return canTarget(id, source, game); + public boolean stillLegalTarget(UUID controllerId, UUID id, Ability source, Game game) { + return canTarget(controllerId, id, source, game); } @Override diff --git a/Mage/src/main/java/mage/target/common/TargetTappedPermanentAsYouCast.java b/Mage/src/main/java/mage/target/common/TargetTappedPermanentAsYouCast.java index 539b477fac9..4fa993cba4a 100644 --- a/Mage/src/main/java/mage/target/common/TargetTappedPermanentAsYouCast.java +++ b/Mage/src/main/java/mage/target/common/TargetTappedPermanentAsYouCast.java @@ -53,10 +53,10 @@ public class TargetTappedPermanentAsYouCast extends TargetPermanent { // See ruling: https://www.mtgsalvation.com/forums/magic-fundamentals/magic-rulings/magic-rulings-archives/253345-dream-leash @Override - public boolean stillLegalTarget(UUID id, Ability source, Game game) { + public boolean stillLegalTarget(UUID controllerId, UUID id, Ability source, Game game) { Permanent permanent = game.getPermanent(id); return permanent != null && getFilter().match(permanent, game) - && super.canTarget(id, game); // check everything but leave out the tapped requirement + && super.canTarget(controllerId, id, source, game); // check everything but leave out the tapped requirement } }