mirror of
https://github.com/correl/mage.git
synced 2024-11-29 03:00:12 +00:00
* 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:
parent
4e79c83784
commit
712cf4576d
8 changed files with 97 additions and 14 deletions
|
@ -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 it’s a creature, it enters with an
|
||||||
|
// additional loyalty counter on it if it’s a planeswalker, and it isn’t 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,16 +78,13 @@ 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)) {
|
|
||||||
if (!card.hasAbility(ability, game)) {
|
|
||||||
game.getState().addOtherAbility(card, ability);
|
game.getState().addOtherAbility(card, ability);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 spell’s converted mana cost. You may cast that
|
||||||
|
* card without paying its mana cost. Then put all cards exiled this way that weren’t 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. "
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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());
|
||||||
|
|
Loading…
Reference in a new issue