* Gain abilities - fixed that objects can get only one instance of triggered ability instead multiple (example: 2+ cascades from copies of Imoti, Celebrant of Bounty, f52753ad61);

This commit is contained in:
Oleg Agafonov 2021-03-05 16:22:46 +04:00
parent 4e79c83784
commit 712cf4576d
8 changed files with 97 additions and 14 deletions

View file

@ -404,4 +404,62 @@ public class CopySpellTest extends CardTestPlayerBase {
assertPermanentCount(playerA, "Mountain", 1); assertPermanentCount(playerA, "Mountain", 1);
assertPermanentCount(playerA, "Island", 1); assertPermanentCount(playerA, "Island", 1);
} }
@Test
public void test_AllowsMultipleInstancesOfGainedTriggers() {
// bug: multiple copies of Imoti, Celebrant of Bounty only giving cascade once
// reason: gained ability used same id, so only one trigger were possible (now it uses new ids)
removeAllCardsFromHand(playerA);
removeAllCardsFromLibrary(playerA);
skipInitShuffling();
// Spells you cast with converted mana cost 6 or greater have cascade.
// Cascade
// (When you cast this spell exile cards from the top of your library until you exile a
// nonland card whose converted mana cost is less than this spell's converted mana cost. You may cast
// that spell without paying its mana cost if its converted mana cost is less than this spell's
// converted mana cost. Then put all cards exiled this way that weren't cast on the bottom of
// your library in a random order.)
addCard(Zone.BATTLEFIELD, playerA, "Imoti, Celebrant of Bounty", 1); // {3}{G}{U}
//
addCard(Zone.LIBRARY, playerA, "Swamp", 1);
addCard(Zone.LIBRARY, playerA, "Lightning Bolt", 1);
addCard(Zone.LIBRARY, playerA, "Swamp", 1);
addCard(Zone.LIBRARY, playerA, "Lightning Bolt", 1);
addCard(Zone.LIBRARY, playerA, "Swamp", 1);
//
// You may have Spark Double enter the battlefield as a copy of a creature or planeswalker you control,
// except it enters with an additional +1/+1 counter on it if its a creature, it enters with an
// additional loyalty counter on it if its a planeswalker, and it isnt legendary if that
// permanent is legendary.
addCard(Zone.HAND, playerA, "Spark Double", 1); // {3}{U}
addCard(Zone.BATTLEFIELD, playerA, "Island", 4);
//
addCard(Zone.HAND, playerA, "Alpha Tyrranax", 1); // {4}{G}{G}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 6);
// cast spark and make imoti's copy
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Spark Double");
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);
// cast big spell and catch cascade 2x times (from two copies)
// possible bug: cascade activates only 1x times
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Alpha Tyrranax");
checkStackSize("afer big spell", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 3);
setChoice(playerA, "cascade"); // choice between 2x gained cascades
setChoice(playerA, "Yes"); // cast first bolt by first cascade
addTarget(playerA, playerB); // target for first bolt
setChoice(playerA, "Yes"); // cast second bold by second cascade
addTarget(playerA, playerB); // target for second bolt
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
assertAllCommandsUsed();
assertLife(playerB, 20 - 3 * 2); // 2x bolts from 2x cascades
}
} }

View file

@ -142,6 +142,10 @@ public abstract class AbilityImpl implements Ability {
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
} }
getEffects().newId(); getEffects().newId();
for (Ability sub : getSubAbilities()) {
sub.newId();
}
} }
@Override @Override

View file

@ -11,6 +11,7 @@ import mage.game.events.GameEvent;
import mage.game.events.NumberOfTriggersEvent; import mage.game.events.NumberOfTriggersEvent;
import mage.game.permanent.Permanent; import mage.game.permanent.Permanent;
import mage.game.stack.Spell; import mage.game.stack.Spell;
import org.apache.log4j.Logger;
/** /**
* @author BetaSteward_at_googlemail.com * @author BetaSteward_at_googlemail.com
@ -21,6 +22,8 @@ import mage.game.stack.Spell;
*/ */
public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbility> { public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbility> {
private static final Logger logger = Logger.getLogger(TriggeredAbilities.class);
private final Map<String, List<UUID>> sources = new HashMap<>(); private final Map<String, List<UUID>> sources = new HashMap<>();
public TriggeredAbilities() { public TriggeredAbilities() {
@ -115,7 +118,7 @@ public class TriggeredAbilities extends ConcurrentHashMap<String, TriggeredAbili
this.add(ability, attachedTo); this.add(ability, attachedTo);
List<UUID> uuidList = new LinkedList<>(); List<UUID> uuidList = new LinkedList<>();
uuidList.add(sourceId); uuidList.add(sourceId);
// if the object that gained the ability moves zone, also then the triggered ability must be removed // if the object that gained the ability moves from zone then the triggered ability must be removed
uuidList.add(attachedTo.getId()); uuidList.add(attachedTo.getId());
sources.put(getKey(ability, attachedTo), uuidList); sources.put(getKey(ability, attachedTo), uuidList);
} }

View file

@ -77,9 +77,6 @@ public class GainAbilitySpellsEffect extends ContinuousEffectImpl {
if (card == null || !filter.match(stackObject, game)) { if (card == null || !filter.match(stackObject, game)) {
continue; continue;
} }
if (ability instanceof MageSingleton && card.hasAbility(ability, game)) {
continue;
}
game.getState().addOtherAbility(card, ability); game.getState().addOtherAbility(card, ability);
} }
return true; return true;

View file

@ -65,7 +65,8 @@ public class GainAbilityControlledSpellsEffect extends ContinuousEffectImpl {
} }
// workaround to gain cost reduction abilities to commanders before cast (make it playable) // workaround to gain cost reduction abilities to commanders before cast (make it playable)
game.getCommanderCardsFromCommandZone(player, CommanderCardType.ANY).stream() game.getCommanderCardsFromCommandZone(player, CommanderCardType.ANY)
.stream()
.filter(card -> filter.match(card, game)) .filter(card -> filter.match(card, game))
.forEach(card -> { .forEach(card -> {
game.getState().addOtherAbility(card, ability); game.getState().addOtherAbility(card, ability);
@ -77,12 +78,9 @@ public class GainAbilityControlledSpellsEffect extends ContinuousEffectImpl {
&& !stackObject.isCopy() && !stackObject.isCopy()
&& stackObject.isControlledBy(source.getControllerId())) { && stackObject.isControlledBy(source.getControllerId())) {
Card card = game.getCard(stackObject.getSourceId()); Card card = game.getCard(stackObject.getSourceId());
if (card != null if (card != null && filter.match(card, game)) {
&& filter.match(card, game)) { game.getState().addOtherAbility(card, ability);
if (!card.hasAbility(ability, game)) { return true;
game.getState().addOtherAbility(card, ability);
return true;
}
} }
} }
} }

View file

@ -20,12 +20,33 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* Cascade
* A keyword ability that may let a player cast a random extra spell for no cost. See rule 702.84, Cascade.
* <p>
* 702.84. Cascade
* <p>
* 702.84a Cascade is a triggered ability that functions only while the spell with cascade is on the stack.
* Cascade means When you cast this spell, exile cards from the top of your library until you exile a
* nonland card whose converted mana cost is less than this spells converted mana cost. You may cast that
* card without paying its mana cost. Then put all cards exiled this way that werent cast on the bottom
* of your library in a random order.
* <p>
* 702.84b If an effect allows a player to take an action with one or more of the exiled cards as you cascade,
* the player may take that action after they have finished exiling cards due to the cascade ability. This action
* is taken before choosing whether to cast the last exiled card or, if no appropriate card was exiled, before
* putting the exiled cards on the bottom of their library in a random order.
* <p>
* 702.84c If a spell has multiple instances of cascade, each triggers separately.
*
* @author BetaSteward_at_googlemail.com * @author BetaSteward_at_googlemail.com
*/ */
public class CascadeAbility extends TriggeredAbilityImpl { public class CascadeAbility extends TriggeredAbilityImpl {
//20091005 - 702.82 //20091005 - 702.82
//20210215 - 702.84a - Updated Cascade rule //20210215 - 702.84a - Updated Cascade rule
// can't use singletone due rules:
// 702.84c If a spell has multiple instances of cascade, each triggers separately.
private static final String REMINDERTEXT = " <i>(When you cast this spell, " private static final String REMINDERTEXT = " <i>(When you cast this spell, "
+ "exile cards from the top of your library until you exile a " + "exile cards from the top of your library until you exile a "
+ "nonland card whose converted mana cost is less than this spell's converted mana cost. " + "nonland card whose converted mana cost is less than this spell's converted mana cost. "

View file

@ -180,13 +180,11 @@ public class SuspendAbility extends SpecialAction {
ability1.setSourceId(card.getId()); ability1.setSourceId(card.getId());
ability1.setControllerId(card.getOwnerId()); ability1.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, ability1); game.getState().addOtherAbility(card, ability1);
game.getState().addAbility(ability1, source.getSourceId(), card);
SuspendPlayCardAbility ability2 = new SuspendPlayCardAbility(); SuspendPlayCardAbility ability2 = new SuspendPlayCardAbility();
ability2.setSourceId(card.getId()); ability2.setSourceId(card.getId());
ability2.setControllerId(card.getOwnerId()); ability2.setControllerId(card.getOwnerId());
game.getState().addOtherAbility(card, ability2); game.getState().addOtherAbility(card, ability2);
game.getState().addAbility(ability2, source.getSourceId(), card);
} }
public static UUID getSuspendExileId(UUID controllerId, Game game) { public static UUID getSuspendExileId(UUID controllerId, Game game) {

View file

@ -1081,7 +1081,8 @@ public class GameState implements Serializable, Copyable<GameState> {
* @param attachedTo * @param attachedTo
* @param ability * @param ability
* @param copyAbility copies non MageSingleton abilities before adding to * @param copyAbility copies non MageSingleton abilities before adding to
* state * state (allows to have multiple instances in one object,
* e.g. false param will simulate keyword/singletone)
*/ */
public void addOtherAbility(Card attachedTo, Ability ability, boolean copyAbility) { public void addOtherAbility(Card attachedTo, Ability ability, boolean copyAbility) {
checkWrongDynamicAbilityUsage(attachedTo, ability); checkWrongDynamicAbilityUsage(attachedTo, ability);
@ -1090,7 +1091,10 @@ public class GameState implements Serializable, Copyable<GameState> {
if (ability instanceof MageSingleton || !copyAbility) { if (ability instanceof MageSingleton || !copyAbility) {
newAbility = ability; newAbility = ability;
} else { } else {
// must use new id, so you can add multiple instances of the same ability
// (example: gained Cascade from multiple Imoti, Celebrant of Bounty)
newAbility = ability.copy(); newAbility = ability.copy();
newAbility.newId();
} }
newAbility.setSourceId(attachedTo.getId()); newAbility.setSourceId(attachedTo.getId());
newAbility.setControllerId(attachedTo.getOwnerId()); newAbility.setControllerId(attachedTo.getOwnerId());