From ac314b9cc25d3d2684dea27381128cddbd2a8937 Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Tue, 3 Mar 2026 01:43:31 -0500 Subject: [PATCH 1/7] Add files via upload OffspringAbility.java updated with new OffspringDelayedTriggeredAbility for implementation of ZinniaValleysVoice --- .../abilities/keyword/OffspringAbility.java | 199 ++++++++++-------- 1 file changed, 114 insertions(+), 85 deletions(-) diff --git a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java index 8be10098297e..9b5cd8d96d7b 100644 --- a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java @@ -1,38 +1,40 @@ -package mage.abilities.keyword; - +package mage.abilities.keyword; + import mage.abilities.Ability; +import mage.abilities.DelayedTriggeredAbility; import mage.abilities.SpellAbility; import mage.abilities.StaticAbility; -import mage.abilities.common.EntersBattlefieldTriggeredAbility; -import mage.abilities.condition.Condition; import mage.abilities.costs.*; import mage.abilities.costs.mana.ManaCostsImpl; import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.common.CreateTokenCopyTargetEffect; +import mage.constants.Duration; import mage.constants.Outcome; import mage.constants.Zone; import mage.game.Game; +import mage.game.events.EntersTheBattlefieldEvent; +import mage.game.events.GameEvent; import mage.game.permanent.Permanent; import mage.players.Player; -import mage.util.CardUtil; - -/** - * @author TheElk801 - */ -public class OffspringAbility extends StaticAbility implements OptionalAdditionalSourceCosts { - - private static final String keywordText = "Offspring"; - private static final String reminderText = "You may pay an additional %s as you cast this spell. If you do, when this creature enters, create a 1/1 token copy of it."; - private final String rule; - - public static final String OFFSPRING_ACTIVATION_VALUE_KEY = "offspringActivation"; - - protected OptionalAdditionalCost additionalCost; - - public OffspringAbility(String manaString) { - this(new ManaCostsImpl<>(manaString)); - } - +import mage.target.targetpointer.FixedTarget; + +/** + * @author TheElk801 + */ +public class OffspringAbility extends StaticAbility implements OptionalAdditionalSourceCosts { + + private static final String keywordText = "Offspring"; + private static final String reminderText = "You may pay an additional %s as you cast this spell. If you do, when this creature enters, create a 1/1 token copy of it."; + private final String rule; + + public static final String OFFSPRING_ACTIVATION_VALUE_KEY = "offspringActivation"; + + protected OptionalAdditionalCost additionalCost; + + public OffspringAbility(String manaString) { + this(new ManaCostsImpl<>(manaString)); + } + public OffspringAbility(Cost cost) { super(Zone.STACK, null); this.additionalCost = new OptionalAdditionalCostImpl( @@ -42,89 +44,116 @@ public OffspringAbility(Cost cost) { this.additionalCost.setRepeatable(false); this.rule = additionalCost.getName() + ' ' + additionalCost.getReminderText(); this.setRuleAtTheTop(true); - this.addSubAbility(new EntersBattlefieldTriggeredAbility(new OffspringEffect()) - .withInterveningIf(OffspringCondition.instance).setRuleVisible(false)); - } - - private OffspringAbility(final OffspringAbility ability) { - super(ability); - this.rule = ability.rule; - this.additionalCost = ability.additionalCost.copy(); } - - @Override - public OffspringAbility copy() { - return new OffspringAbility(this); - } - - @Override - public void addOptionalAdditionalCosts(Ability ability, Game game) { - if (!(ability instanceof SpellAbility)) { - return; - } - Player player = game.getPlayer(ability.getControllerId()); - if (player == null) { - return; - } - additionalCost.reset(); - if (!additionalCost.canPay(ability, this, ability.getControllerId(), game) - || !player.chooseUse(Outcome.PutCreatureInPlay, "Pay " + additionalCost.getText(true) + " for offspring?", ability, game)) { - return; - } - additionalCost.activate(); + + private OffspringAbility(final OffspringAbility ability) { + super(ability); + this.rule = ability.rule; + this.additionalCost = ability.additionalCost.copy(); + } + + @Override + public OffspringAbility copy() { + return new OffspringAbility(this); + } + + @Override + public void addOptionalAdditionalCosts(Ability ability, Game game) { + if (!(ability instanceof SpellAbility)) { + return; + } + Player player = game.getPlayer(ability.getControllerId()); + if (player == null) { + return; + } + additionalCost.reset(); + if (!additionalCost.canPay(ability, this, ability.getControllerId(), game) + || !player.chooseUse(Outcome.PutCreatureInPlay, "Pay " + additionalCost.getText(true) + " for offspring?", ability, game)) { + return; + } + additionalCost.activate(); for (Cost cost : ((Costs) additionalCost)) { ability.getCosts().add(cost.copy()); } ability.setCostsTag(OFFSPRING_ACTIVATION_VALUE_KEY, null); + game.addDelayedTriggeredAbility(new OffspringDelayedTriggeredAbility(), ability); } - - @Override - public String getCastMessageSuffix() { - return additionalCost.getCastSuffixMessage(0); - } - + + @Override + public String getCastMessageSuffix() { + return additionalCost.getCastSuffixMessage(0); + } + + @Override + public String getRule() { + return rule; + } +} + +class OffspringEffect extends OneShotEffect { + + OffspringEffect() { + super(Outcome.Benefit); + staticText = "create a 1/1 token copy of it"; + } + + private OffspringEffect(final OffspringEffect effect) { + super(effect); + } + + @Override + public OffspringEffect copy() { + return new OffspringEffect(this); + } + @Override - public String getRule() { - return rule; + public boolean apply(Game game, Ability source) { + Permanent permanent = getTargetPointer().getFirstTargetPermanentOrLKI(game, source); + if (permanent == null) { + permanent = source.getSourcePermanentOrLKI(game); + } + return permanent != null && new CreateTokenCopyTargetEffect( + null, null, false, 1, false, + false, null, 1, 1, false + ).setSavedPermanent(permanent).apply(game, source); } } -class OffspringEffect extends OneShotEffect { +class OffspringDelayedTriggeredAbility extends DelayedTriggeredAbility { - OffspringEffect() { - super(Outcome.Benefit); - staticText = "create a 1/1 token copy of it"; + OffspringDelayedTriggeredAbility() { + super(new OffspringEffect(), Duration.EndOfTurn, true); + this.setRuleVisible(false); } - private OffspringEffect(final OffspringEffect effect) { - super(effect); + private OffspringDelayedTriggeredAbility(final OffspringDelayedTriggeredAbility ability) { + super(ability); } @Override - public OffspringEffect copy() { - return new OffspringEffect(this); + public OffspringDelayedTriggeredAbility copy() { + return new OffspringDelayedTriggeredAbility(this); } @Override - public boolean apply(Game game, Ability source) { - Permanent permanent = source.getSourcePermanentOrLKI(game); - return permanent != null && new CreateTokenCopyTargetEffect( - null, null, false, 1, false, - false, null, 1, 1, false - ).setSavedPermanent(permanent).apply(game, source); + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD; } -} - -enum OffspringCondition implements Condition { - instance; @Override - public boolean apply(Game game, Ability source) { - return CardUtil.checkSourceCostsTagExists(game, source, OffspringAbility.OFFSPRING_ACTIVATION_VALUE_KEY); - } - - @Override - public String toString() { - return "its offspring cost was paid"; + public boolean checkTrigger(GameEvent event, Game game) { + if (!event.getTargetId().equals(getSourceId())) { + return false; + } + if (!(event instanceof EntersTheBattlefieldEvent)) { + return false; + } + Permanent permanent = ((EntersTheBattlefieldEvent) event).getTarget(); + if (permanent == null + || permanent.getZoneChangeCounter(game) != getStackMomentSourceZCC() + 1) { + return false; + } + getEffects().setTargetPointer(new FixedTarget(permanent, game)); + return true; } } From f20b8f2dc511fe7703fa42bd3d0db2268600b6c0 Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Tue, 3 Mar 2026 01:47:10 -0500 Subject: [PATCH 2/7] Update BloomburrowCommander.java Add Zinnia, Valley's Voice --- Mage.Sets/src/mage/sets/BloomburrowCommander.java | 1 + 1 file changed, 1 insertion(+) diff --git a/Mage.Sets/src/mage/sets/BloomburrowCommander.java b/Mage.Sets/src/mage/sets/BloomburrowCommander.java index 4db413a3a5a8..0fc947a4f81a 100644 --- a/Mage.Sets/src/mage/sets/BloomburrowCommander.java +++ b/Mage.Sets/src/mage/sets/BloomburrowCommander.java @@ -371,6 +371,7 @@ private BloomburrowCommander() { cards.add(new SetCardInfo("Wooded Ridgeline", 353, Rarity.COMMON, mage.cards.w.WoodedRidgeline.class)); cards.add(new SetCardInfo("Woodland Cemetery", 354, Rarity.RARE, mage.cards.w.WoodlandCemetery.class)); cards.add(new SetCardInfo("Yavimaya Coast", 355, Rarity.RARE, mage.cards.y.YavimayaCoast.class)); + cards.add(new SetCardInfo("Zinnia, Valley's Voice", 4, Rarity.MYTHIC, mage.cards.z.ZinniaValleysVoice.class)); cards.add(new SetCardInfo("Zulaport Cutthroat", 190, Rarity.UNCOMMON, mage.cards.z.ZulaportCutthroat.class)); } } From 14bda0a112f274513f3555f46eb4d182521abd15 Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Tue, 3 Mar 2026 01:50:43 -0500 Subject: [PATCH 3/7] Add files via upload Add ZinniaValleysVoice.java in conjunction with changes to OffspringAbility.java with new OffspringDelayedTriggeredAbility --- .../src/mage/cards/z/ZinniaValleysVoice.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java diff --git a/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java b/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java new file mode 100644 index 000000000000..e19062bc9d89 --- /dev/null +++ b/Mage.Sets/src/mage/cards/z/ZinniaValleysVoice.java @@ -0,0 +1,90 @@ +package mage.cards.z; + +import mage.MageInt; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.dynamicvalue.common.PermanentsOnBattlefieldCount; +import mage.abilities.effects.common.continuous.BoostSourceEffect; +import mage.abilities.effects.common.continuous.GainAbilityControlledSpellsEffect; +import mage.abilities.keyword.FlyingAbility; +import mage.abilities.keyword.OffspringAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.filter.common.FilterCreaturePermanent; +import mage.filter.common.FilterNonlandCard; +import mage.filter.predicate.mageobject.AnotherPredicate; +import mage.filter.predicate.mageobject.BasePowerPredicate; +import java.util.UUID; + +import static mage.abilities.dynamicvalue.common.StaticValue.get; +import static mage.constants.Duration.WhileOnBattlefield; + + +/** + * Zinnia, Valley's Voice + * + * Legendary Creature — Bird Bard + * + * Flying + * Zinnia, Valley's Voice gets +X/+0, where X is the number of other creatures + * you control with base power 1. + * Creature spells you cast have offspring {2}. + * + * @author DreamWaker and sneddigrolyat + */ +public final class ZinniaValleysVoice extends CardImpl { + + // "other creatures you control with base power 1" + private static final FilterCreaturePermanent bfilter = new FilterCreaturePermanent("other creatures you control with base power 1"); + + static { + bfilter.add(new BasePowerPredicate(ComparisonType.EQUAL_TO, 1)); + bfilter.add(TargetController.YOU.getControllerPredicate()); + bfilter.add(AnotherPredicate.instance); + } + + private static final PermanentsOnBattlefieldCount bcount = new PermanentsOnBattlefieldCount(bfilter); + + // "creature spells you cast" + static final FilterNonlandCard cfilter = new FilterNonlandCard("creature spells you cast"); + + static { + cfilter.add(CardType.CREATURE.getPredicate()); + } + + + public ZinniaValleysVoice(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{U}{R}{W}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.BIRD); + this.subtype.add(SubType.BARD); + this.power = new MageInt(1); + this.toughness = new MageInt(3); + + // Flying + this.addAbility(FlyingAbility.getInstance()); + + // Creature spells you cast have offspring {2}. + this.addAbility(new SimpleStaticAbility( + new GainAbilityControlledSpellsEffect(new OffspringAbility("{2}"), cfilter) + .setText("Creature spells you cast have offspring {2}.") + )); + + // Zinnia, Valley's Voice gets +X/+0, + // where X is the number of other creatures you control with base power 1. + this.addAbility(new SimpleStaticAbility( + new BoostSourceEffect(bcount, get(0), WhileOnBattlefield) + )); + } + + private ZinniaValleysVoice(final ZinniaValleysVoice card) { + super(card); + } + + @Override + public ZinniaValleysVoice copy() { + return new ZinniaValleysVoice(this); + } +} + From a3ab925b9a5c89915378fef43813760e606f682a Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Sat, 7 Mar 2026 02:08:19 -0500 Subject: [PATCH 4/7] Fix offspring delayed trigger when granted source is removed before ETB --- .../abilities/keywords/OffspringTest.java | 110 +++++++++ .../abilities/keyword/OffspringAbility.java | 209 ++++++++++++++---- Mage/src/main/java/mage/game/stack/Spell.java | 4 + 3 files changed, 279 insertions(+), 44 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java index bb7b1fc91786..6b320727db2e 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java @@ -14,6 +14,8 @@ public class OffspringTest extends CardTestPlayerBase { private static final String vinelasher = "Iridescent Vinelasher"; + private static final String bandit = "Prosperous Bandit"; + private static final String lion = "Silvercoat Lion"; private Permanent getCreature(String name, boolean isToken) { for (Permanent permanent : currentGame.getBattlefield().getActivePermanents(playerA.getId(), currentGame)) { @@ -80,4 +82,112 @@ public void testPay() { checkOffspring(vinelasher, 1, 2, true); } + + @Test + public void testHumilityInResponseNoCopy() { + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 3); + addCard(Zone.HAND, playerA, vinelasher); + + addCard(Zone.BATTLEFIELD, playerB, "Plains", 4); + addCard(Zone.BATTLEFIELD, playerB, "Vedalken Orrery"); + addCard(Zone.HAND, playerB, "Humility"); + + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, vinelasher); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Humility", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, vinelasher, 1); + assertTokenCount(playerA, vinelasher, 0); + assertPowerToughness(playerA, vinelasher, 1, 1); + } + + @Test + public void testHumilityInResponseNoCopyWithPrintedAndGrantedOffspring() { + addCard(Zone.BATTLEFIELD, playerA, "Zinnia, Valley's Voice"); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5); + addCard(Zone.HAND, playerA, vinelasher); + + addCard(Zone.BATTLEFIELD, playerB, "Plains", 4); + addCard(Zone.BATTLEFIELD, playerB, "Vedalken Orrery"); + addCard(Zone.HAND, playerB, "Humility"); + + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, vinelasher); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Humility", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, vinelasher, 1); + assertTokenCount(playerA, vinelasher, 0); + assertPowerToughness(playerA, vinelasher, 1, 1); + } + + @Test + public void testGrantedOffspringSourceRemovedBeforeEtbNoCopy() { + addCard(Zone.BATTLEFIELD, playerA, "Zinnia, Valley's Voice"); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 5); + addCard(Zone.HAND, playerA, lion); + addCard(Zone.HAND, playerA, "Path to Exile"); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Path to Exile", "Zinnia, Valley's Voice"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 1); + assertTokenCount(playerA, lion, 0); + } + + @Test + public void testPrintedAndGrantedOffspringOnePayment() { + addCard(Zone.BATTLEFIELD, playerA, "Zinnia, Valley's Voice"); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 2); + assertTokenCount(playerA, bandit, 1); + } + + @Test + public void testPrintedAndGrantedOffspringTwoPayments() { + addCard(Zone.BATTLEFIELD, playerA, "Zinnia, Valley's Voice"); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, "When this permanent enters"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, bandit, 3); + assertTokenCount(playerA, bandit, 2); + } + } diff --git a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java index 9b5cd8d96d7b..b15f09118157 100644 --- a/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java +++ b/Mage/src/main/java/mage/abilities/keyword/OffspringAbility.java @@ -1,13 +1,15 @@ -package mage.abilities.keyword; - +package mage.abilities.keyword; + import mage.abilities.Ability; import mage.abilities.DelayedTriggeredAbility; import mage.abilities.SpellAbility; import mage.abilities.StaticAbility; +import mage.abilities.common.EntersBattlefieldTriggeredAbility; import mage.abilities.costs.*; import mage.abilities.costs.mana.ManaCostsImpl; import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.common.CreateTokenCopyTargetEffect; +import mage.cards.Card; import mage.constants.Duration; import mage.constants.Outcome; import mage.constants.Zone; @@ -15,24 +17,41 @@ import mage.game.events.EntersTheBattlefieldEvent; import mage.game.events.GameEvent; import mage.game.permanent.Permanent; +import mage.game.stack.Spell; import mage.players.Player; import mage.target.targetpointer.FixedTarget; +import mage.util.CardUtil; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * @author TheElk801 + */ +public class OffspringAbility extends StaticAbility implements OptionalAdditionalSourceCosts { -/** - * @author TheElk801 - */ -public class OffspringAbility extends StaticAbility implements OptionalAdditionalSourceCosts { - - private static final String keywordText = "Offspring"; - private static final String reminderText = "You may pay an additional %s as you cast this spell. If you do, when this creature enters, create a 1/1 token copy of it."; - private final String rule; - - public static final String OFFSPRING_ACTIVATION_VALUE_KEY = "offspringActivation"; - - protected OptionalAdditionalCost additionalCost; - - public OffspringAbility(String manaString) { - this(new ManaCostsImpl<>(manaString)); + private static final String keywordText = "Offspring"; + private static final String reminderText = "You may pay an additional %s as you cast this spell. If you do, when this creature enters, create a 1/1 token copy of it."; + private final String rule; + private final OffspringTriggeredAbility offspringTriggeredAbility; + + public static final String OFFSPRING_ACTIVATION_VALUE_KEY = "offspringActivation"; + + protected OptionalAdditionalCost additionalCost; + + static String getActivationValueKey(UUID abilityOriginalId) { + return OFFSPRING_ACTIVATION_VALUE_KEY + abilityOriginalId; + } + + String getActivationValueKey() { + return getActivationValueKey(offspringTriggeredAbility.getOriginalId()); + } + + public OffspringAbility(String manaString) { + this(new ManaCostsImpl<>(manaString)); } public OffspringAbility(Cost cost) { @@ -43,18 +62,25 @@ public OffspringAbility(Cost cost) { ); this.additionalCost.setRepeatable(false); this.rule = additionalCost.getName() + ' ' + additionalCost.getReminderText(); + this.offspringTriggeredAbility = new OffspringTriggeredAbility(); + this.addSubAbility(offspringTriggeredAbility); this.setRuleAtTheTop(true); } - - private OffspringAbility(final OffspringAbility ability) { - super(ability); - this.rule = ability.rule; - this.additionalCost = ability.additionalCost.copy(); - } - - @Override - public OffspringAbility copy() { - return new OffspringAbility(this); + + private OffspringAbility(final OffspringAbility ability) { + super(ability); + this.rule = ability.rule; + this.additionalCost = ability.additionalCost.copy(); + this.offspringTriggeredAbility = this.getSubAbilities().stream() + .filter(OffspringTriggeredAbility.class::isInstance) + .map(OffspringTriggeredAbility.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Offspring triggered ability wasn't found")); + } + + @Override + public OffspringAbility copy() { + return new OffspringAbility(this); } @Override @@ -71,30 +97,76 @@ public void addOptionalAdditionalCosts(Ability ability, Game game) { || !player.chooseUse(Outcome.PutCreatureInPlay, "Pay " + additionalCost.getText(true) + " for offspring?", ability, game)) { return; } - additionalCost.activate(); + additionalCost.activate(); for (Cost cost : ((Costs) additionalCost)) { ability.getCosts().add(cost.copy()); } - ability.setCostsTag(OFFSPRING_ACTIVATION_VALUE_KEY, null); - game.addDelayedTriggeredAbility(new OffspringDelayedTriggeredAbility(), ability); + String activationValueKey = getActivationValueKey(); + ability.setCostsTag(activationValueKey, null); + if (!isIntrinsicOffspringAbility(game, ability)) { + game.addDelayedTriggeredAbility( + new OffspringDelayedTriggeredAbility( + sourceCardHasIntrinsicOffspring(game, ability), + activationValueKey + ), + ability + ); + } } - - @Override - public String getCastMessageSuffix() { - return additionalCost.getCastSuffixMessage(0); + + @Override + public String getCastMessageSuffix() { + return additionalCost.getCastSuffixMessage(0); } @Override - public String getRule() { - return rule; - } -} + public String getRule() { + return rule; + } + + private boolean isIntrinsicOffspringAbility(Game game, Ability spellAbility) { + Card sourceCard = game.getCard(spellAbility.getSourceId()); + return sourceCard != null && sourceCard + .getAbilities() + .stream() + .filter(OffspringAbility.class::isInstance) + .map(Ability::getId) + .anyMatch(this.getId()::equals); + } + + private boolean sourceCardHasIntrinsicOffspring(Game game, Ability spellAbility) { + Card sourceCard = game.getCard(spellAbility.getSourceId()); + return sourceCard != null + && sourceCard.getAbilities().stream().anyMatch(OffspringAbility.class::isInstance); + } + + public static void syncActivationTagsWithCurrentSpellAbilities(Spell spell, Game game) { + SpellAbility spellAbility = spell.getSpellAbility(); + Map costsTagMap = spellAbility.getCostsTagMap(); + if (costsTagMap == null || costsTagMap.isEmpty()) { + return; + } + + Set activeOffspringActivationKeys = spell + .getAbilities(game) + .stream() + .filter(OffspringAbility.class::isInstance) + .map(OffspringAbility.class::cast) + .map(OffspringAbility::getActivationValueKey) + .collect(Collectors.toSet()); + + costsTagMap.keySet().removeIf(tag -> + tag.startsWith(OFFSPRING_ACTIVATION_VALUE_KEY) + && !activeOffspringActivationKeys.contains(tag) + ); + } +} class OffspringEffect extends OneShotEffect { - - OffspringEffect() { - super(Outcome.Benefit); - staticText = "create a 1/1 token copy of it"; + + OffspringEffect() { + super(Outcome.Benefit); + staticText = "create a 1/1 token copy of it"; } private OffspringEffect(final OffspringEffect effect) { @@ -108,7 +180,11 @@ public OffspringEffect copy() { @Override public boolean apply(Game game, Ability source) { - Permanent permanent = getTargetPointer().getFirstTargetPermanentOrLKI(game, source); + Permanent permanent = null; + List pointerTargets = getTargetPointer().getTargets(game, source); + if (pointerTargets != null && !pointerTargets.isEmpty()) { + permanent = getTargetPointer().getFirstTargetPermanentOrLKI(game, source); + } if (permanent == null) { permanent = source.getSourcePermanentOrLKI(game); } @@ -119,15 +195,52 @@ public boolean apply(Game game, Ability source) { } } +class OffspringTriggeredAbility extends EntersBattlefieldTriggeredAbility { + + OffspringTriggeredAbility() { + super(new OffspringEffect(), false); + setTriggerPhrase("When this permanent enters, "); + this.setRuleVisible(false); + } + + private OffspringTriggeredAbility(final OffspringTriggeredAbility ability) { + super(ability); + } + + @Override + public OffspringTriggeredAbility copy() { + return new OffspringTriggeredAbility(this); + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (!super.checkTrigger(event, game)) { + return false; + } + return CardUtil.checkSourceCostsTagExists( + game, + this, + OffspringAbility.getActivationValueKey(this.getOriginalId()) + ); + } +} + class OffspringDelayedTriggeredAbility extends DelayedTriggeredAbility { - OffspringDelayedTriggeredAbility() { + private final boolean skipIfNoAbilitiesOnEtb; + private final String activationValueKey; + + OffspringDelayedTriggeredAbility(boolean skipIfNoAbilitiesOnEtb, String activationValueKey) { super(new OffspringEffect(), Duration.EndOfTurn, true); + this.skipIfNoAbilitiesOnEtb = skipIfNoAbilitiesOnEtb; + this.activationValueKey = activationValueKey; this.setRuleVisible(false); } private OffspringDelayedTriggeredAbility(final OffspringDelayedTriggeredAbility ability) { super(ability); + this.skipIfNoAbilitiesOnEtb = ability.skipIfNoAbilitiesOnEtb; + this.activationValueKey = ability.activationValueKey; } @Override @@ -153,6 +266,14 @@ public boolean checkTrigger(GameEvent event, Game game) { || permanent.getZoneChangeCounter(game) != getStackMomentSourceZCC() + 1) { return false; } + if (!CardUtil.checkSourceCostsTagExists(game, this, activationValueKey)) { + return false; + } + if (skipIfNoAbilitiesOnEtb && permanent.getAbilities().isEmpty()) { + // Printed offspring was stripped from the entering creature (e.g. by Humility), + // so the granted offspring fallback must also not trigger. + return false; + } getEffects().setTargetPointer(new FixedTarget(permanent, game)); return true; } diff --git a/Mage/src/main/java/mage/game/stack/Spell.java b/Mage/src/main/java/mage/game/stack/Spell.java index f79d89a8f66e..7cbe50dc5505 100644 --- a/Mage/src/main/java/mage/game/stack/Spell.java +++ b/Mage/src/main/java/mage/game/stack/Spell.java @@ -7,6 +7,7 @@ import mage.abilities.costs.mana.ManaCost; import mage.abilities.costs.mana.ManaCosts; import mage.abilities.keyword.BestowAbility; +import mage.abilities.keyword.OffspringAbility; import mage.abilities.keyword.PrototypeAbility; import mage.cards.*; import mage.constants.*; @@ -356,6 +357,7 @@ public boolean resolve(Game game) { } } else { permId = card.getId(); + OffspringAbility.syncActivationTagsWithCurrentSpellAbilities(this, game); MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); permanentCreated = controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); @@ -389,6 +391,7 @@ public boolean resolve(Game game) { } // Aura has no legal target and its a bestow enchantment -> Add it to battlefield as creature if (bestow) { + OffspringAbility.syncActivationTagsWithCurrentSpellAbilities(this, game); MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); return controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); @@ -406,6 +409,7 @@ public boolean resolve(Game game) { token.putOntoBattlefield(1, game, ability, getControllerId(), false, false, null, null, false); return true; } else { + OffspringAbility.syncActivationTagsWithCurrentSpellAbilities(this, game); MageObjectReference mor = new MageObjectReference(getSpellAbility()); game.storePermanentCostsTags(mor, getSpellAbility()); return controller.moveCards(card, Zone.BATTLEFIELD, ability, game, false, faceDown, false, null); From 0d87cdb5364252a5f4c961cf6a5ae64b92d97efd Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Sat, 7 Mar 2026 23:53:09 -0500 Subject: [PATCH 5/7] Add Zinnia Valley's Voice tests for granted Offspring behavior --- .../single/blb/ZinniaValleysVoiceTest.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java new file mode 100644 index 000000000000..b96f0d980b19 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java @@ -0,0 +1,49 @@ +package org.mage.test.cards.single.blb; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +public class ZinniaValleysVoiceTest extends CardTestPlayerBase { + + private static final String zinnia = "Zinnia, Valley's Voice"; + private static final String lion = "Silvercoat Lion"; + + @Test + public void testGrantsOffspringToCreatureSpells() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 4); + addCard(Zone.HAND, playerA, lion); + + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 2); + assertTokenCount(playerA, lion, 1); + } + + @Test + public void testGrantedOffspringSourceRemovedBeforeEtbNoCopy() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 5); + addCard(Zone.HAND, playerA, lion); + addCard(Zone.HAND, playerA, "Path to Exile"); + + setChoice(playerA, true); + setChoice(playerA, false); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, lion); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Path to Exile", zinnia); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, lion, 1); + assertTokenCount(playerA, lion, 0); + } +} From 739377a2344c574afccd9da70fe3a4b7bf9761d4 Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Sun, 8 Mar 2026 00:15:55 -0500 Subject: [PATCH 6/7] Move ZinniaValleysVoiceTest to blc --- .../{blb => blc}/ZinniaValleysVoiceTest.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) rename Mage.Tests/src/test/java/org/mage/test/cards/single/{blb => blc}/ZinniaValleysVoiceTest.java (55%) diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java similarity index 55% rename from Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java rename to Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java index b96f0d980b19..5aa4f1cd326a 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/ZinniaValleysVoiceTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/blc/ZinniaValleysVoiceTest.java @@ -1,4 +1,4 @@ -package org.mage.test.cards.single.blb; +package org.mage.test.cards.single.blc; import mage.constants.PhaseStep; import mage.constants.Zone; @@ -9,6 +9,7 @@ public class ZinniaValleysVoiceTest extends CardTestPlayerBase { private static final String zinnia = "Zinnia, Valley's Voice"; private static final String lion = "Silvercoat Lion"; + private static final String bandit = "Prosperous Bandit"; @Test public void testGrantsOffspringToCreatureSpells() { @@ -46,4 +47,30 @@ public void testGrantedOffspringSourceRemovedBeforeEtbNoCopy() { assertPermanentCount(playerA, lion, 1); assertTokenCount(playerA, lion, 0); } + + @Test + public void testRemoveZinniaWhileOffspringTriggersOnStackBothStillResolve() { + addCard(Zone.BATTLEFIELD, playerA, zinnia); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 6); + addCard(Zone.BATTLEFIELD, playerA, "Island", 6); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 6); + addCard(Zone.HAND, playerA, bandit); + addCard(Zone.BATTLEFIELD, playerB, "Plains"); + addCard(Zone.HAND, playerB, "Path to Exile"); + + setChoice(playerA, true); // Pay printed offspring {1} + setChoice(playerA, true); // Pay granted offspring {2} + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bandit); + setChoice(playerA, "When this permanent enters"); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Path to Exile", zinnia, "create a 1/1 token copy of it."); + setChoice(playerA, false); // Decline Path's basic land search + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, zinnia, 0); + assertPermanentCount(playerA, bandit, 3); + assertTokenCount(playerA, bandit, 2); + } } From 2ed3852d1af6aa3e5cc2b7a24dfa127153905a7c Mon Sep 17 00:00:00 2001 From: sneddigrolyat Date: Sun, 8 Mar 2026 23:48:28 -0400 Subject: [PATCH 7/7] Add offspring test for Humility branch skip path --- .../abilities/keywords/OffspringTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java index 6b320727db2e..bb498736758f 100644 --- a/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/cards/abilities/keywords/OffspringTest.java @@ -1,7 +1,12 @@ package org.mage.test.cards.abilities.keywords; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.common.continuous.GainAbilityControlledSpellsEffect; +import mage.abilities.keyword.OffspringAbility; +import mage.constants.CardType; import mage.constants.PhaseStep; import mage.constants.Zone; +import mage.filter.common.FilterNonlandCard; import mage.game.permanent.Permanent; import mage.game.permanent.PermanentToken; import org.junit.Assert; @@ -129,6 +134,45 @@ public void testHumilityInResponseNoCopyWithPrintedAndGrantedOffspring() { assertPowerToughness(playerA, vinelasher, 1, 1); } + @Test + public void testHumilityInResponseNoCopyWithPrintedAndGrantedOffspringFromNonCreatureSource() { + FilterNonlandCard creatureSpells = new FilterNonlandCard("creature spells"); + creatureSpells.add(CardType.CREATURE.getPredicate()); + addCustomCardWithAbility( + "offspring grant source", + playerA, + new SimpleStaticAbility( + Zone.BATTLEFIELD, + new GainAbilityControlledSpellsEffect(new OffspringAbility("{2}"), creatureSpells) + ), + null, + CardType.ENCHANTMENT, + "", + Zone.BATTLEFIELD + ); + + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 5); + addCard(Zone.HAND, playerA, vinelasher); + + addCard(Zone.BATTLEFIELD, playerB, "Plains", 4); + addCard(Zone.BATTLEFIELD, playerB, "Vedalken Orrery"); + addCard(Zone.HAND, playerB, "Humility"); + + // pay both offspring costs so the granted delayed trigger is definitely created + setChoice(playerA, true); + setChoice(playerA, true); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, vinelasher); + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Humility", true); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, vinelasher, 1); + assertTokenCount(playerA, vinelasher, 0); + assertPowerToughness(playerA, vinelasher, 1, 1); + } + @Test public void testGrantedOffspringSourceRemovedBeforeEtbNoCopy() { addCard(Zone.BATTLEFIELD, playerA, "Zinnia, Valley's Voice");