diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/asthough/PlayTopCardFromLibraryTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/asthough/PlayTopCardFromLibraryTest.java new file mode 100644 index 00000000000..b4013c696b1 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/asthough/PlayTopCardFromLibraryTest.java @@ -0,0 +1,128 @@ +package org.mage.test.cards.asthough; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author JayDi85 + */ +public class PlayTopCardFromLibraryTest extends CardTestPlayerBase { + + /* + Bolas's Citadel + {3}{B}{B}{B} + You may look at the top card of your library any time. + You may play the top card of your library. If you cast a spell this way, pay life equal to its converted mana cost rather than pay its mana cost. + {T}, Sacrifice ten nonland permanents: Each opponent loses 10 life. + */ + + @Test + public void test_CreaturePlay() { + removeAllCardsFromLibrary(playerA); + addCard(Zone.LIBRARY, playerA, "Balduvian Bears", 1); + addCard(Zone.BATTLEFIELD, playerA, "Bolas's Citadel", 1); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Balduvian Bears"); // 2 CMC + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertPermanentCount(playerA, "Balduvian Bears", 1); + assertLife(playerA, 20 - 2); + } + + @Test + public void test_CreaturePlay2() { + removeAllCardsFromLibrary(playerA); + addCard(Zone.LIBRARY, playerA, "Balduvian Bears", 1); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 2); + addCard(Zone.BATTLEFIELD, playerA, "Vizier of the Menagerie", 1); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Balduvian Bears"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertPermanentCount(playerA, "Balduvian Bears", 1); + } + + @Test + public void test_ManaCostmodifications() { + // + // {5}{B}{B} + // You may cast Scourge of Nel Toth from your graveyard by paying {B}{B} and sacrificing two creatures rather than paying its mana cost. + addCard(Zone.GRAVEYARD, playerA, "Scourge of Nel Toth", 1); + addCard(Zone.BATTLEFIELD, playerA, "Kitesail Corsair", 2); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 2); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Scourge of Nel Toth"); + setChoice(playerA, "Kitesail Corsair"); + setChoice(playerA, "Kitesail Corsair"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertPermanentCount(playerA, "Scourge of Nel Toth", 1); + assertLife(playerA, 20); + } + + @Test + public void test_SplitRightPlay() { + // https://github.com/magefree/mage/issues/5912 + // Bolas's citadel requires you to pay mana instead of life for a split card on top of library. + // + // Steps to reproduce: + // + // Bolas's Citadel in play, Revival//Revenge on top of library. + // Cast Revenge, choose target + // receive prompt to pay 4WB. + // + // Expected outcome + // + // No prompt for mana payment, payment of six life instead. + + removeAllCardsFromLibrary(playerA); + addCard(Zone.LIBRARY, playerA, "Revival // Revenge", 1); + addCard(Zone.BATTLEFIELD, playerA, "Bolas's Citadel", 1); + + // Double your life total. Target opponent loses half their life, rounded up. + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Revenge", playerB); // {4}{W}{B} = 6 life + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertLife(playerA, (20 - 6) * 2); + assertLife(playerB, 20 / 2); + } + + @Test + public void test_SplitLeftPlay() { + removeAllCardsFromLibrary(playerA); + addCard(Zone.LIBRARY, playerA, "Revival // Revenge", 1); + addCard(Zone.BATTLEFIELD, playerA, "Bolas's Citadel", 1); + addCard(Zone.GRAVEYARD, playerA, "Balduvian Bears", 1); + + // Return target creature card with converted mana cost 3 or less from your graveyard to the battlefield. + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Revival", "Balduvian Bears"); // {W/B}{W/B} = 2 life + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + assertAllCommandsUsed(); + + assertLife(playerA, 20 - 2); + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Balduvian Bears", 0); + assertPermanentCount(playerA, "Balduvian Bears", 1); + } +} diff --git a/Mage/src/main/java/mage/abilities/effects/AsThoughEffectImpl.java b/Mage/src/main/java/mage/abilities/effects/AsThoughEffectImpl.java index 03ed5c0b3d9..a21b0e3a25e 100644 --- a/Mage/src/main/java/mage/abilities/effects/AsThoughEffectImpl.java +++ b/Mage/src/main/java/mage/abilities/effects/AsThoughEffectImpl.java @@ -1,6 +1,5 @@ package mage.abilities.effects; -import java.util.UUID; import mage.abilities.Ability; import mage.constants.AsThoughEffectType; import mage.constants.Duration; @@ -8,8 +7,9 @@ import mage.constants.EffectType; import mage.constants.Outcome; import mage.game.Game; +import java.util.UUID; + /** - * * @author BetaSteward_at_googlemail.com */ public abstract class AsThoughEffectImpl extends ContinuousEffectImpl implements AsThoughEffect { @@ -29,10 +29,11 @@ public abstract class AsThoughEffectImpl extends ContinuousEffectImpl implements @Override public boolean applies(UUID objectId, Ability affectedAbility, Ability source, Game game, UUID playerId) { + // affectedControllerId = player to check if (getAsThoughEffectType().equals(AsThoughEffectType.LOOK_AT_FACE_DOWN)) { return applies(objectId, source, playerId, game); } else { - return applies(objectId, source, affectedAbility.getControllerId(), game); + return applies(objectId, source, playerId, game); } } diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java index 598a4c2eabc..192328bc1d5 100644 --- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java +++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java @@ -505,12 +505,19 @@ public class ContinuousEffects implements Serializable { UUID idToCheck; if (affectedAbility != null && affectedAbility.getSourceObject(game) instanceof SplitCardHalf) { idToCheck = ((SplitCardHalf) affectedAbility.getSourceObject(game)).getParentCard().getId(); + } else if (affectedAbility != null && affectedAbility.getSourceObject(game) instanceof AdventureCardSpell + && type != AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE + && type != AsThoughEffectType.CAST_AS_INSTANT) { + // adventure spell uses alternative characteristics for spell/stack + idToCheck = ((AdventureCardSpell) affectedAbility.getSourceObject(game)).getParentCard().getId(); } else { Card card = game.getCard(objectId); - if (card != null && card instanceof SplitCardHalf) { + if (card instanceof SplitCardHalf) { idToCheck = ((SplitCardHalf) card).getParentCard().getId(); - } else if (card != null && type == AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE - && card instanceof AdventureCardSpell) { + } else if (card instanceof AdventureCardSpell + && type != AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE + && type != AsThoughEffectType.CAST_AS_INSTANT) { + // adventure spell uses alternative characteristics for spell/stack idToCheck = ((AdventureCardSpell) card).getParentCard().getId(); } else { idToCheck = objectId; diff --git a/Mage/src/main/java/mage/cards/AdventureCard.java b/Mage/src/main/java/mage/cards/AdventureCard.java index 3df44282d8d..d100ae27705 100644 --- a/Mage/src/main/java/mage/cards/AdventureCard.java +++ b/Mage/src/main/java/mage/cards/AdventureCard.java @@ -4,7 +4,8 @@ import mage.abilities.Abilities; import mage.abilities.AbilitiesImpl; import mage.abilities.Ability; import mage.abilities.SpellAbility; -import mage.constants.*; +import mage.constants.CardType; +import mage.constants.Zone; import mage.game.Game; import java.util.List; @@ -26,7 +27,7 @@ public abstract class AdventureCard extends CardImpl { public AdventureCard(AdventureCard card) { super(card); this.spellCard = card.getSpellCard().copy(); - ((AdventureCardSpell)this.spellCard).setParentCard(this); + ((AdventureCardSpell) this.spellCard).setParentCard(this); } public Card getSpellCard() { @@ -91,6 +92,11 @@ public abstract class AdventureCard extends CardImpl { return allAbilities; } + public Abilities getSharedAbilities() { + // abilities without spellcard + return super.getAbilities(); + } + @Override public void setOwnerId(UUID ownerId) { super.setOwnerId(ownerId); diff --git a/Mage/src/main/java/mage/constants/AsThoughEffectType.java b/Mage/src/main/java/mage/constants/AsThoughEffectType.java index de81133a057..808a99277d8 100644 --- a/Mage/src/main/java/mage/constants/AsThoughEffectType.java +++ b/Mage/src/main/java/mage/constants/AsThoughEffectType.java @@ -1,7 +1,6 @@ package mage.constants; /** - * * @author North */ public enum AsThoughEffectType { @@ -19,15 +18,29 @@ public enum AsThoughEffectType { BLOCK_FORESTWALK, DAMAGE_NOT_BLOCKED, BE_BLOCKED, - PLAY_FROM_NOT_OWN_HAND_ZONE, // do not use dialogs in "applies" method for that type of effect (it calls multiple times) + + // PLAY_FROM_NOT_OWN_HAND_ZONE + CAST_AS_INSTANT: + // 1. Do not use dialogs in "applies" method for that type of effect (it calls multiple times and will freeze the game) + // 2. All effects in "applies" must checks affectedControllerId.equals(source.getControllerId()) (if not then all players will be able to play it) + // 3. Target points to mainCard, but card's characteristics from objectId (split, adventure) + // TODO: search all PLAY_FROM_NOT_OWN_HAND_ZONE and CAST_AS_INSTANT effects and add support of mainCard and objectId + PLAY_FROM_NOT_OWN_HAND_ZONE, CAST_AS_INSTANT, + ACTIVATE_AS_INSTANT, DAMAGE, SHROUD, HEXPROOF, PAY_0_ECHO, LOOK_AT_FACE_DOWN, + + // SPEND_OTHER_MANA: + // 1. It's uses for mana calcs at any zone, not stack only + // 2. Compare zone change counter as "objectZCC <= targetZCC + 1" + // 3. Compare zone with original (like exiled) and stack, not stack only + // TODO: search all SPEND_ONLY_MANA effects and improve counters compare as SPEND_OTHER_MANA SPEND_OTHER_MANA, + SPEND_ONLY_MANA, TARGET } diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index 33584270c14..ccf467dfe32 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -3046,7 +3046,7 @@ public abstract class PlayerImpl implements Player, Serializable { game.getContinuousEffects().costModification(copy, game); } - Card card = game.getCard(ability.getSourceId()); + Card card = game.getCard(copy.getSourceId()); if (card != null) { for (Ability ability0 : card.getAbilities()) { if (ability0 instanceof AdjustingSourceCosts) { @@ -3072,10 +3072,16 @@ public abstract class PlayerImpl implements Player, Serializable { if (available == null) { return true; } - MageObjectReference permittingObject = game.getContinuousEffects().asThough(ability.getSourceId(), - AsThoughEffectType.SPEND_OTHER_MANA, ability, ability.getControllerId(), game); + MageObjectReference permittingObject = game.getContinuousEffects().asThough(copy.getSourceId(), + AsThoughEffectType.SPEND_OTHER_MANA, copy, copy.getControllerId(), game); for (Mana mana : abilityOptions) { for (Mana avail : available) { + // TODO: SPEND_OTHER_MANA effects with getAsThoughManaType can change mana type to pay, + // but that code processing it as any color, need to test and fix another use cases + // (example: Sunglasses of Urza - may spend white mana as though it were red mana) + + // + // add tests for non any color like Sunglasses of Urza if (permittingObject != null && mana.count() <= avail.count()) { return true; } @@ -3089,7 +3095,7 @@ public abstract class PlayerImpl implements Player, Serializable { for (Ability objectAbility : sourceObject.getAbilities()) { if (objectAbility instanceof AlternativeCostSourceAbility) { - if (objectAbility.getCosts().canPay(ability, ability.getSourceId(), playerId, game)) { + if (objectAbility.getCosts().canPay(copy, copy.getSourceId(), playerId, game)) { return true; } } @@ -3215,35 +3221,85 @@ public abstract class PlayerImpl implements Player, Serializable { } } - private List cardPlayableAbilities(Game game, Card card, boolean setControllerId) { - List playable = new ArrayList(); - if (card != null) { - for (ActivatedAbility ability : card.getAbilities().getActivatedAbilities(Zone.HAND)) { - if (!ability.canActivate(playerId, game).canActivate()) { - continue; - } + private void getPlayableFromNonHandCardAll(Game game, Zone fromZone, Card card, ManaOptions availableMana, List output) { + if (fromZone == null) { + return; + } - UUID savedControllerId = null; - if (setControllerId) { - // For when owner != caster, e.g. with Psychic Intrusion and similar effects. - savedControllerId = getId(); - ability.setControllerId(getId()); - } - if (ability instanceof SpellAbility - && null != game.getContinuousEffects().asThough(card.getId(), - AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, ability, getId(), game)) { - playable.add(ability); - } else if (ability instanceof PlayLandAbility - && null != game.getContinuousEffects().asThough(card.getId(), - AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, card.getSpellAbility(), getId(), game)) { - playable.add(ability); - } - if (setControllerId) { - ability.setControllerId(savedControllerId); - } + // BASIC abilities + if (card instanceof SplitCard) { + SplitCard splitCard = (SplitCard) card; + getPlayableFromNonHandCardSingle(game, fromZone, splitCard.getLeftHalfCard(), splitCard.getLeftHalfCard().getAbilities(), availableMana, output); + getPlayableFromNonHandCardSingle(game, fromZone, splitCard.getRightHalfCard(), splitCard.getRightHalfCard().getAbilities(), availableMana, output); + getPlayableFromNonHandCardSingle(game, fromZone, splitCard, splitCard.getSharedAbilities(), availableMana, output); + } else if (card instanceof AdventureCard) { + // adventure must use different card characteristics for different spells (main or adventure) + AdventureCard adventureCard = (AdventureCard) card; + getPlayableFromNonHandCardSingle(game, fromZone, adventureCard.getSpellCard(), adventureCard.getSpellCard().getAbilities(), availableMana, output); + getPlayableFromNonHandCardSingle(game, fromZone, adventureCard, adventureCard.getSharedAbilities(), availableMana, output); + } else { + getPlayableFromNonHandCardSingle(game, fromZone, card, card.getAbilities(), availableMana, output); + } + + // DYNAMIC ADDED abilities + if (fromZone != Zone.ALL) { // TODO: test revealed cards with dynamic added abilities + // Other activated abilities (added dynamic by effects) + LinkedHashMap useable; + if (card instanceof AdventureCard) { + // adventure cards (contains two different cards: main and adventure spell) + useable = new LinkedHashMap<>(); + getOtherUseableActivatedAbilities(((AdventureCard) card).getSpellCard(), fromZone, game, useable); + output.addAll(useable.values()); + + useable = new LinkedHashMap<>(); + getOtherUseableActivatedAbilities(card, fromZone, game, useable); + output.addAll(useable.values()); + } else { + // all other cards (TODO: check split cards with dynamic added abilities) + useable = new LinkedHashMap<>(); + getOtherUseableActivatedAbilities(card, fromZone, game, useable); + output.addAll(useable.values()); + } + } + } + + private void getPlayableFromNonHandCardSingle(Game game, Zone fromZone, Card card, Abilities candidateAbilities, ManaOptions availableMana, List output) { + + // check "can play from hand" condition as original controller (effects checks affected controller with source controller) + // TODO: remove card.getSpellAbility() ? + MageObjectReference permittingObject = game.getContinuousEffects().asThough(card.getId(), + AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, card.getSpellAbility(), this.getId(), game); + boolean canActivateAsHandZone = permittingObject != null + || (fromZone == Zone.GRAVEYARD && canPlayCardsFromGraveyard()); + + // check "can play" condition as affected controller + for (ActivatedAbility ability : candidateAbilities.getActivatedAbilities(Zone.ALL)) { + UUID savedControllerId = ability.getControllerId(); + ability.setControllerId(this.getId()); + try { + boolean possibleToPlay = false; + + // spell/hand abilities (play from all zones) + // need permitingObject or canPlayCardsFromGraveyard + if (canActivateAsHandZone + && ability.getZone().match(Zone.HAND) + && (ability instanceof SpellAbility || ability instanceof PlayLandAbility)) { + possibleToPlay = true; + } + + // zone's abilities (play from specific zone) + // no need in permitingObject + if (fromZone != Zone.ALL && ability.getZone().match(fromZone)) { + possibleToPlay = true; + } + + if (possibleToPlay && canPlay(ability, availableMana, card, game)) { + output.add(ability); + } + } finally { + ability.setControllerId(savedControllerId); } } - return playable; } @Override @@ -3262,11 +3318,9 @@ public abstract class PlayerImpl implements Player, Serializable { } boolean fromAll = fromZone.equals(Zone.ALL); - Collection cards; if (hidden && (fromAll || fromZone == Zone.HAND)) { - cards = hideDuplicatedAbilities ? hand.getUniqueCards(game) : hand.getCards(game); - for (Card card : cards) { + for (Card card : hand.getCards(game)) { for (Ability ability : card.getAbilities(game)) { // gets this activated ability from hand? (Morph?) if (ability.getZone().match(Zone.HAND)) { if (ability instanceof ActivatedAbility) { @@ -3297,37 +3351,15 @@ public abstract class PlayerImpl implements Player, Serializable { } if (fromAll || fromZone == Zone.GRAVEYARD) { - cards = hideDuplicatedAbilities ? graveyard.getUniqueCards(game) : graveyard.getCards(game); - for (Card card : cards) { - // Handle split cards in graveyard to support Aftermath - if (card instanceof SplitCard) { - SplitCard splitCard = (SplitCard) card; - getPlayableFromGraveyardCard(game, splitCard.getLeftHalfCard(), - splitCard.getLeftHalfCard().getAbilities(), availableMana, playable); - getPlayableFromGraveyardCard(game, splitCard.getRightHalfCard(), - splitCard.getRightHalfCard().getAbilities(), availableMana, playable); - getPlayableFromGraveyardCard(game, splitCard, splitCard.getSharedAbilities(), - availableMana, playable); - } else if (card instanceof AdventureCard) { - AdventureCard adventureCard = (AdventureCard) card; - getPlayableFromGraveyardCard(game, adventureCard.getSpellCard(), - adventureCard.getSpellCard().getAbilities(), availableMana, playable); - getPlayableFromGraveyardCard(game, adventureCard, adventureCard.getAbilities(), availableMana, playable); - } else { - getPlayableFromGraveyardCard(game, card, card.getAbilities(), availableMana, playable); - } - - // Other activated abilities - LinkedHashMap useable = new LinkedHashMap<>(); - getOtherUseableActivatedAbilities(card, Zone.GRAVEYARD, game, useable); - playable.addAll(useable.values()); + for (Card card : graveyard.getCards(game)) { + getPlayableFromNonHandCardAll(game, Zone.GRAVEYARD, card, availableMana, playable); } } if (fromAll || fromZone == Zone.EXILED) { for (ExileZone exile : game.getExile().getExileZones()) { for (Card card : exile.getCards(game)) { - playable.addAll(cardPlayableAbilities(game, card, true)); + getPlayableFromNonHandCardAll(game, Zone.EXILED, card, availableMana, playable); } } } @@ -3336,7 +3368,8 @@ public abstract class PlayerImpl implements Player, Serializable { if (fromAll) { for (Cards revealedCards : game.getState().getRevealed().values()) { for (Card card : revealedCards.getCards(game)) { - playable.addAll(cardPlayableAbilities(game, card, false)); + // revealed cards can be from any zones + getPlayableFromNonHandCardAll(game, game.getState().getZone(card.getId()), card, availableMana, playable); } } } @@ -3348,7 +3381,7 @@ public abstract class PlayerImpl implements Player, Serializable { if (player != null) { if (/*player.isTopCardRevealed() &&*/player.getLibrary().hasCards()) { Card card = player.getLibrary().getFromTop(game); - playable.addAll(cardPlayableAbilities(game, card, false)); + getPlayableFromNonHandCardAll(game, Zone.LIBRARY, card, availableMana, playable); } } }