From 918a8931e6060e2d3aaf1ed5663553fb124f8670 Mon Sep 17 00:00:00 2001 From: ssk97 Date: Mon, 26 Jun 2023 17:47:26 -0700 Subject: [PATCH] Implement [40K] Squad ability (#10509) * First try at Squad Mechanic * Fully functional now, removed cards from unfinished list in 40k * Improved implementation, removing the generic conditional and replacing it with a separated ETB trigger and effect. * Add tests from Susucre (using Strionic Resonator for now, two others currently failing) * Update comments on tests Closes #9774 ------ Co-authored by: Susucre <34709007+susucre@users.noreply.github.com> --- Mage.Sets/src/mage/sets/Warhammer40000.java | 6 +- .../cards/abilities/keywords/SquadTest.java | 364 ++++++++++++++++++ .../mage/abilities/keyword/SquadAbility.java | 175 ++++++++- 3 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SquadTest.java diff --git a/Mage.Sets/src/mage/sets/Warhammer40000.java b/Mage.Sets/src/mage/sets/Warhammer40000.java index a9385e2fb3..34257f950a 100644 --- a/Mage.Sets/src/mage/sets/Warhammer40000.java +++ b/Mage.Sets/src/mage/sets/Warhammer40000.java @@ -11,9 +11,7 @@ import java.util.List; * @author TheElk801 */ public final class Warhammer40000 extends ExpansionSet { - - private static final List unfinished = Arrays.asList("Arco-Flagellant", "Sicarian Infiltrator", "Space Marine Devastator", "Ultramarines Honour Guard", "Vanguard Suppressor", "Zephyrim"); - + private static final Warhammer40000 instance = new Warhammer40000(); public static Warhammer40000 getInstance() { @@ -300,7 +298,5 @@ public final class Warhammer40000 extends ExpansionSet { cards.add(new SetCardInfo("Worn Powerstone", 263, Rarity.UNCOMMON, mage.cards.w.WornPowerstone.class)); cards.add(new SetCardInfo("Zephyrim", 20, Rarity.RARE, mage.cards.z.Zephyrim.class)); cards.add(new SetCardInfo("Zoanthrope", 149, Rarity.RARE, mage.cards.z.Zoanthrope.class)); - - cards.removeIf(setCardInfo -> unfinished.contains(setCardInfo.getName())); // remove when mechanic is implemented } } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SquadTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SquadTest.java new file mode 100644 index 0000000000..a1c5a6ea6c --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/SquadTest.java @@ -0,0 +1,364 @@ +package org.mage.test.cards.abilities.keywords; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Ignore; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author Susucr + */ +public class SquadTest extends CardTestPlayerBase { + + /* + * Rulling at 2022-11-15 + * 702.157. Squad + * 702.157a Squad is a keyword that represents two linked abilities. + * The first is a static ability that functions while the creature + * spell with squad is on the stack. The second is a triggered ability + * that functions when the creature with squad enters the battlefield. + * “Squad [cost]” means “As an additional cost to cast this spell, + * youmay pay [cost] any number of times” and “When this creature + * enters the battlefield, if its squad cost was paid, create a token + * that’s a copy of it for each time its squad cost was paid.” Paying + * a spell’s squad cost follows the rules for paying additional costs + * in rules 601.2b and 601.2f–h. + * 702.157b If a spell has multiple instances of squad, each is paid separately. + * If a permanent has multiple instances of squad, each triggers based + * on the payments made for that squad ability as it was cast, not based + * on payments for any other instance of squad. + */ + + /** + * Arco-Flagellant + * {2}{B} 3/1 + * Creature — Human + * + * Squad {2} (As an additional cost to cast this spell, you may pay {2} any number of times. When this creature enters the battlefield, create that many tokens that are copies of it.) + * Arco-Flagellant can’t block. + * Endurant — Pay 3 life: Arco-Flagellant gains indestructible until end of turn. + */ + private final static String flagellant = "Arco-Flagellant"; + + private final static String swamp = "Swamp"; + + @Test + public void test_Squad_NotUsed_Manual() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 5); + addCard(Zone.HAND, playerA, flagellant); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, false); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, flagellant, 1); + } + + @Test + @Ignore + // TODO: enable test after squad/replicate ability will be supported by AI + public void test_Squad_NotUsed_AI() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 5 - 1); // haven't all mana + addCard(Zone.HAND, playerA, flagellant); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + //setChoice(playerA, false); - AI must choose + + //setStrictChooseMode(true); - AI must choose + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, flagellant, 1); + } + + @Test + public void test_Squad_UseOnce() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 5); + addCard(Zone.HAND, playerA, flagellant); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); + setChoice(playerA, false); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, flagellant, 2); + } + + @Test + public void test_Squad_UseTwice() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 7); + addCard(Zone.HAND, playerA, flagellant); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); + setChoice(playerA, true); + setChoice(playerA, false); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, flagellant, 3); + } + + @Test + public void test_ZCC_ReturnedFromGraveyardMustNotRememberSquad() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 9); // 3 + 2 + 4 + addCard(Zone.HAND, playerA, flagellant, 1); + // Return target creature card from your graveyard to the battlefield. + addCard(Zone.HAND, playerA, "Zombify", 1); + + addCard(Zone.BATTLEFIELD, playerB, swamp, 1); + // Target creature gets -2/-2 until end of turn. + addCard(Zone.HAND, playerB, "Disfigure", 1); + + // casting with squad + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // use squad once. + setChoice(playerA, false); + + // poor flagellant dies a horrible death + addTarget(playerB, flagellant + "[no copy]"); + castSpell(1, PhaseStep.BEGIN_COMBAT, playerB, "Disfigure"); + + // return the flagellant from graveyard + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Zombify", flagellant); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertGraveyardCount(playerB, "Disfigure", 1); + assertGraveyardCount(playerA, "Zombify", 1); + assertPermanentCount(playerA, flagellant, 2); // The first token of squad + the zombified original + } + + @Test + public void test_ZCC_ReturnedToHandPermanentMustNotRememberSquad() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 8); // 3 + 2 + 3 + addCard(Zone.HAND, playerA, flagellant, 1); + + addCard(Zone.BATTLEFIELD, playerB, "Island", 2); + // Return target permanent to its owner's hand. + addCard(Zone.HAND, playerB, "Boomerang", 1); + + // first cast paying for squad + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 5); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // use squad once. + setChoice(playerA, false); + + // return to hand + addTarget(playerB, flagellant+"[no copy]"); + castSpell(1, PhaseStep.BEGIN_COMBAT, playerB, "Boomerang"); + + // second cast not paying for squad + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, false); // no squad + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertGraveyardCount(playerB, "Boomerang", 1); + assertPermanentCount(playerA, flagellant, 2); // The first token of squad + the recasted original + } + + @Test + public void test_ZCC_BlinkMustNotRememberSquad() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 5); // 3 + 2 + addCard(Zone.BATTLEFIELD, playerA, "Plains", 1); + addCard(Zone.HAND, playerA, flagellant, 1); + // Exile target creature you control, then return it to the battlefield under its owner’s control. + // Rebound + addCard(Zone.HAND, playerA, "Ephemerate", 1); + + // first cast paying for squad + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 5); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // use squad once. + setChoice(playerA, false); + + // casting Ephemerate + addTarget(playerA, flagellant+"[no copy]"); + castSpell(1, PhaseStep.BEGIN_COMBAT, playerA, "Ephemerate"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, flagellant, 2); // The first token of squad + the blinked back original + } + + // The squad status is a copiable value of the spell, and should be carried over on copy. + @Ignore + @Test + //TODO: Enable after fixing subability copying twice bug + public void test_CopyingSpellMustKeepSquadStatus() { + + addCard(Zone.HAND, playerA, flagellant, 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5); // 3 + 2 + addCard(Zone.BATTLEFIELD, playerA, "Island", 1); + addCard(Zone.BATTLEFIELD, playerA, "Forest", 1); + + // Copy target creature spell you control, except it isn’t legendary if the spell is legendary. + addCard(Zone.HAND, playerA, "Double Major", 1); + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 5); + // cast and pay once for squad, then copy it (squad status must be copied) + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // pay squad once + setChoice(playerA, false); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Double Major", flagellant, flagellant, StackClause.WHILE_ON_STACK); + checkStackSize("before copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // flagellant + double major + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + checkStackSize("after copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2); // spell + copy + + //setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, flagellant, 4); // One original + its squad buddy + the copy + the copy's squad buddy + } + + // Copying the trigger remembers the squad status. + @Test + public void test_CopyingETBTriggerMustKeepSquadStatus() { + + addCard(Zone.HAND, playerA, flagellant, 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 7); // 3 + 2 + 2 + + /* + * Strionic Resonator + * {2} + * Artifact + * {2}, {T}: Copy target triggered ability you control. You may choose new targets for the copy. + */ + addCard(Zone.BATTLEFIELD, playerA, "Strionic Resonator", 1); + + // cast and pay once for squad, then copy it (squad status must be copied) + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // pay squad once + setChoice(playerA, false); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, true); + // flagellant does resolve, its squad trigger goes in the stack. + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, + "{2}, {T}: Copy target triggered ability you control. You may choose new targets for the copy."); + + //setStrictChooseMode(true); // Could not make it work for explicitly target the trigger with Lithoform Engine + setStopAt(1, PhaseStep.PRECOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, flagellant, 3); // One original + its squad buddy + the squad buddy from the copied trigger + } + + @Test + public void test_PanharmoniconDoubleSquadETBStatus() { + + addCard(Zone.HAND, playerA, flagellant, 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5); // 3 + 2 + + // If an artifact or creature entering the battlefield causes a triggered ability + // of a permanent you control to trigger, that ability triggers an additional time. + addCard(Zone.BATTLEFIELD, playerA, "Panharmonicon", 1); + + // cast and pay once for squad, then copy it (squad status must be copied) + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // pay squad once + setChoice(playerA, false); + + //setStrictChooseMode(true); // There is a double trigger to put in the stack, not sure how to order them. + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, flagellant, 3); // One original + its squad buddy + the squad buddy from the additional trigger + } + + @Ignore + @Test + //TODO: Enable after fixing clones activating it if they have the same zcc. Also affects Kicker + public void test_CloneMustNotCopySquad() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 8); // 3 + 2 + 3 + addCard(Zone.BATTLEFIELD, playerA, "Island", 1); + addCard(Zone.HAND, playerA, flagellant + "@flaggy", 1); + // You may have Clone enter the battlefield as a copy of any creature on the battlefield. + addCard(Zone.HAND, playerA, "Clone", 1); + + activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 5); + + // cast paying for squad + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // use squad once. + setChoice(playerA, false); + + // cloning the flagellant + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Clone"); + setChoice(playerA, true); // yes to the 'may copy' + setChoice(playerA, "@flaggy"); // cloning the original flagellant. + + //setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, flagellant, 3); // The original + its token + the clone + } + + @Test + public void test_CopyMustNotCopySquad() { + addCard(Zone.BATTLEFIELD, playerA, swamp, 8); // 3 + 2 + addCard(Zone.HAND, playerA, flagellant + "@flaggy", 1); + + //{T}: Create a token that’s a copy of target nonlegendary creature you control, + // except it has haste. Sacrifice it at the beginning of the next end step. + addCard(Zone.BATTLEFIELD, playerA, "Kiki-Jiki, Mirror Breaker", 1); + + // casting with a squad buddy + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, flagellant); + setChoice(playerA, true); // use squad once. + setChoice(playerA, false); + + // copying the original flagellant with kiki-jiki + activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}: Create a token", "@flaggy"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, flagellant, 3); // The original + the first token of squad + the copy token + } + + @Test + public void test_Squad_FreeCast() { + skipInitShuffling(); + + addCard(Zone.LIBRARY, playerA, flagellant, 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 4); + // + // Whenever Etali, Primal Storm attacks, exile the top card of each player's library, + // then you may cast any number of nonland cards exiled this way without paying their mana costs. + addCard(Zone.BATTLEFIELD, playerA, "Etali, Primal Storm", 1); + + checkPlayableAbility("before", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Arco-Flagellant", false); + + // attack and prepare free cast, pay squad + attack(1, playerA, "Etali, Primal Storm", playerB); + setChoice(playerA, true); // cast for free + setChoice(playerA, true); // pay squad once. + setChoice(playerA, true); // pay squad twice. + setChoice(playerA, false); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, flagellant, 3); // card + 2 buddies + } +} diff --git a/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java b/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java index 2bd13d8b6b..a640f797b5 100644 --- a/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/SquadAbility.java @@ -1,26 +1,45 @@ package mage.abilities.keyword; +import mage.MageObject; +import mage.abilities.Ability; +import mage.abilities.SpellAbility; import mage.abilities.StaticAbility; -import mage.abilities.costs.Cost; +import mage.abilities.common.EntersBattlefieldTriggeredAbility; +import mage.abilities.costs.*; import mage.abilities.costs.mana.GenericManaCost; +import mage.abilities.costs.mana.ManaCostsImpl; +import mage.abilities.effects.CreateTokenCopySourceEffect; +import mage.abilities.effects.Effect; +import mage.abilities.effects.OneShotEffect; +import mage.constants.Outcome; import mage.constants.Zone; +import mage.game.Game; +import mage.players.Player; +import mage.util.CardUtil; /** - * @author TheElk801 + * @author notgreat */ -public class SquadAbility extends StaticAbility { +public class SquadAbility extends StaticAbility implements OptionalAdditionalSourceCosts { + protected OptionalAdditionalCost cost; + protected static final String SQUAD_KEYWORD = "Squad"; + protected static final String SQUAD_REMINDER = "You may pay an additional " + + "{cost} any number of times as you cast this spell."; public SquadAbility() { this(new GenericManaCost(2)); } public SquadAbility(Cost cost) { super(Zone.STACK, null); - // TODO: implement this + setSquadCost(cost); + addSubAbility(new SquadTriggerAbility()); + //Note that I get subabilities list's position 0 to modify the zcc/count references } private SquadAbility(final SquadAbility ability) { super(ability); + this.cost = ability.cost.copy(); } @Override @@ -28,8 +47,154 @@ public class SquadAbility extends StaticAbility { return new SquadAbility(this); } + public final void setSquadCost(Cost cost) { + OptionalAdditionalCost newCost = new OptionalAdditionalCostImpl( + SQUAD_KEYWORD, SQUAD_REMINDER, cost); + newCost.setRepeatable(true); + newCost.setCostType(VariableCostType.ADDITIONAL); + this.cost = newCost; + } + + private void reset() { + cost.reset(); + } + + protected static int get_zcc(Ability source, Game game) { + // Squad/Kicker activates in STACK zone so all zcc must be from "stack moment" + // Use cases: + // * resolving spell have same zcc (example: check kicker status in sorcery/instant); + // * copied spell have same zcc as source spell (see Spell.copySpell and zcc sync); + // * creature/token from resolved spell have +1 zcc after moved to battlefield (example: check kicker status in ETB triggers/effects); + + // find object info from the source ability (it can be a permanent or a spell on stack, on the moment of trigger/resolve) + MageObject sourceObject = source.getSourceObject(game); + Zone sourceObjectZone = game.getState().getZone(sourceObject.getId()); + int zcc = CardUtil.getActualSourceObjectZoneChangeCounter(game, source); + + // find "stack moment" zcc: + // * permanent cards enters from STACK to BATTLEFIELD (+1 zcc) + // * permanent tokens enters from OUTSIDE to BATTLEFIELD (+1 zcc, see prepare code in TokenImpl.putOntoBattlefieldHelper) + // * spells and copied spells resolves on STACK (zcc not changes) + if (sourceObjectZone != Zone.STACK) { + --zcc; + } + return zcc; + } + + @Override + public void addOptionalAdditionalCosts(Ability ability, Game game) { + if (!(ability instanceof SpellAbility)) { + return; + } + Player player = game.getPlayer(ability.getControllerId()); + if (player == null) { + return; + } + this.reset(); + boolean again = true; + while (player.canRespond() && again) { + String times = ""; + int activatedCount = getSquadCount(); + times = (activatedCount + 1) + (activatedCount == 0 ? " time " : " times "); + // TODO: add AI support to find max number of possible activations (from available mana) + // canPay checks only single mana available, not total mana usage + if (cost.canPay(ability, this, ability.getControllerId(), game) + && player.chooseUse(/*Outcome.Benefit*/Outcome.AIDontUseIt, + "Pay " + times + cost.getText(false) + " ?", ability, game)) { + cost.activate(); + if (cost instanceof ManaCostsImpl) { + ability.getManaCostsToPay().add((ManaCostsImpl) cost.copy()); + } else { + ability.getCosts().add(cost.copy()); + } + } else { + again = false; + } + } + SquadTriggerAbility squadETB = (SquadTriggerAbility)getSubAbilities().get(0); + squadETB.zcc = get_zcc(ability, game); + SquadEffectETB squadEffect = (SquadEffectETB)squadETB.getEffects().get(0); + squadEffect.activationCount = cost.getActivateCount(); + } + + @Override + public String getCastMessageSuffix() { + if (cost.isActivated()) { + return cost.getCastSuffixMessage(0); + } + return ""; + } + @Override public String getRule() { - return "Squad"; + return "Squad "+cost.getText()+" (As an additional cost to cast this spell, you may pay "+ + cost.getText()+"any number of times. When this creature enters the battlefield, "+ + "create that many tokens that are copies of it.)"; + } + + /** + * Number of times squad cost was paid + * + * @return int activation count + */ + public int getSquadCount() { + return cost.getActivateCount(); + } +} +class SquadTriggerAbility extends EntersBattlefieldTriggeredAbility { + protected Integer zcc; + public SquadTriggerAbility() { + super(new SquadEffectETB()); + this.setRuleVisible(false); + } + + private SquadTriggerAbility(final SquadTriggerAbility ability) { + super(ability); + this.zcc = ability.zcc; + } + @Override + public SquadTriggerAbility copy() { + return new SquadTriggerAbility(this); + } + + @Override + public boolean checkInterveningIfClause(Game game) { + if (zcc != null && zcc == SquadAbility.get_zcc(this, game)){ + SquadEffectETB effect = (SquadEffectETB)getEffects().get(0); + return effect.activationCount > 0; + } + return false; + } + @Override + public String getRule() { + return "Squad (When this creature enters the battlefield, if its squad cost was paid, " + + "create a token that’s a copy of it for each time its squad cost was paid.)"; + } +} + +class SquadEffectETB extends OneShotEffect { + protected Integer activationCount; + + SquadEffectETB() { + super(Outcome.Benefit); + } + + private SquadEffectETB(final SquadEffectETB effect) { + super(effect); + this.activationCount = effect.activationCount; + } + + @Override + public SquadEffectETB copy() { + return new SquadEffectETB(this); + } + + @Override + public boolean apply(Game game, Ability source) { + if (activationCount != null) { + CreateTokenCopySourceEffect effect = new CreateTokenCopySourceEffect(activationCount); + return effect.apply(game, source); + } + return true; } }