diff --git a/Mage.Sets/src/mage/cards/l/LatticeLibrary.java b/Mage.Sets/src/mage/cards/l/LatticeLibrary.java new file mode 100644 index 000000000000..39e14868bae6 --- /dev/null +++ b/Mage.Sets/src/mage/cards/l/LatticeLibrary.java @@ -0,0 +1,93 @@ +package mage.cards.l; + +import java.util.UUID; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.common.EntersBattlefieldAbility; +import mage.abilities.dynamicvalue.common.CountersSourceCount; +import mage.abilities.effects.common.EntersBattlefieldWithXCountersEffect; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.Zone; +import mage.counters.CounterType; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.permanent.token.FractalToken; +import mage.game.stack.Spell; +import mage.watchers.common.FirstXSpellCastThisTurnWatcher; + +/** + * + * @author muz + */ +public final class LatticeLibrary extends CardImpl { + + public LatticeLibrary(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ENCHANTMENT}, "{X}{G}{G}"); + + // This enchantment enters with X study counters on it. + this.addAbility(new EntersBattlefieldAbility(new EntersBattlefieldWithXCountersEffect(CounterType.STUDY.createInstance()))); + + // When this enchantment enters and whenever you cast your first spell with {X} in its mana cost each turn, + // create a 0/0 green and blue Fractal creature token. Put a number of +1/+1 counters on it equal to the + // number of study counters on this enchantment. + this.addAbility(new LatticeLibraryTriggeredAbility(), new FirstXSpellCastThisTurnWatcher()); + } + + private LatticeLibrary(final LatticeLibrary card) { + super(card); + } + + @Override + public LatticeLibrary copy() { + return new LatticeLibrary(this); + } +} + +class LatticeLibraryTriggeredAbility extends TriggeredAbilityImpl { + + LatticeLibraryTriggeredAbility() { + super( + Zone.BATTLEFIELD, + FractalToken.getEffect( + new CountersSourceCount(CounterType.STUDY), + ". Put a number of +1/+1 counters on it equal to the number of study counters on {this}" + ), + false + ); + setTriggerPhrase("When {this} enters and whenever you cast your first spell with {X} in its mana cost each turn, "); + } + + private LatticeLibraryTriggeredAbility(final LatticeLibraryTriggeredAbility ability) { + super(ability); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD || event.getType() == GameEvent.EventType.SPELL_CAST; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (event.getType() == GameEvent.EventType.ENTERS_THE_BATTLEFIELD) { + return event.getTargetId().equals(getSourceId()); + } + + if (!event.getPlayerId().equals(getControllerId())) { + return false; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell == null) { + return false; + } + // Watcher fires before triggers; it records first X spell via putIfAbsent. + // Matching the current spell's ID ensures we only trigger on the first one. + FirstXSpellCastThisTurnWatcher watcher = game.getState().getWatcher(FirstXSpellCastThisTurnWatcher.class); + return watcher != null && spell.getId().equals(watcher.getFirstXSpellId(getControllerId())); + } + + @Override + public LatticeLibraryTriggeredAbility copy() { + return new LatticeLibraryTriggeredAbility(this); + } +} diff --git a/Mage.Sets/src/mage/cards/n/NevThePracticalDean.java b/Mage.Sets/src/mage/cards/n/NevThePracticalDean.java new file mode 100644 index 000000000000..9c128bbb57ba --- /dev/null +++ b/Mage.Sets/src/mage/cards/n/NevThePracticalDean.java @@ -0,0 +1,102 @@ +package mage.cards.n; + +import java.util.UUID; +import mage.MageInt; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.dynamicvalue.common.EffectKeyValue; +import mage.abilities.effects.common.continuous.GainAbilityControlledEffect; +import mage.abilities.effects.common.counter.AddCountersSourceEffect; +import mage.abilities.keyword.TrampleAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.counters.CounterType; +import mage.filter.common.FilterCreaturePermanent; +import mage.filter.predicate.permanent.CounterAnyPredicate; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.stack.Spell; +import mage.util.CardUtil; +import mage.watchers.common.FirstXSpellCastThisTurnWatcher; + +/** + * + * @author muz + */ +public final class NevThePracticalDean extends CardImpl { + + private static final FilterCreaturePermanent filter = + new FilterCreaturePermanent("creatures you control with counters on them"); + + static { + filter.add(CounterAnyPredicate.instance); + } + + public NevThePracticalDean(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{G}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.MERFOLK); + this.subtype.add(SubType.WIZARD); + this.power = new MageInt(2); + this.toughness = new MageInt(2); + + // Creatures you control with counters on them have trample. + this.addAbility(new SimpleStaticAbility(new GainAbilityControlledEffect( + TrampleAbility.getInstance(), Duration.WhileOnBattlefield, filter + ).setText("creatures you control with counters on them have trample"))); + + // Whenever you cast your first spell with {X} in its mana cost each turn, put X +1/+1 counters on Nev. + this.addAbility(new NevTriggeredAbility(), new FirstXSpellCastThisTurnWatcher()); + } + + private NevThePracticalDean(final NevThePracticalDean card) { + super(card); + } + + @Override + public NevThePracticalDean copy() { + return new NevThePracticalDean(this); + } +} + +class NevTriggeredAbility extends TriggeredAbilityImpl { + + NevTriggeredAbility() { + super(Zone.BATTLEFIELD, new AddCountersSourceEffect(CounterType.P1P1.createInstance(), new EffectKeyValue("xValue", "X")), false); + setTriggerPhrase("Whenever you cast your first spell with {X} in its mana cost each turn, "); + } + + private NevTriggeredAbility(final NevTriggeredAbility ability) { + super(ability); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.SPELL_CAST; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (!event.getPlayerId().equals(getControllerId())) { + return false; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell == null) { + return false; + } + FirstXSpellCastThisTurnWatcher watcher = game.getState().getWatcher(FirstXSpellCastThisTurnWatcher.class); + if (watcher == null || !spell.getId().equals(watcher.getFirstXSpellId(getControllerId()))) { + return false; + } + int xValue = CardUtil.getSourceCostsTag(game, spell.getSpellAbility(), "X", 0); + getEffects().setValue("xValue", xValue); + return xValue > 0; + } + + @Override + public NevTriggeredAbility copy() { + return new NevTriggeredAbility(this); + } +} diff --git a/Mage.Sets/src/mage/cards/o/OwlinSpiralmancer.java b/Mage.Sets/src/mage/cards/o/OwlinSpiralmancer.java new file mode 100644 index 000000000000..d1952e9579cd --- /dev/null +++ b/Mage.Sets/src/mage/cards/o/OwlinSpiralmancer.java @@ -0,0 +1,95 @@ +package mage.cards.o; + +import java.util.UUID; +import mage.MageInt; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.effects.common.CopyStackObjectEffect; +import mage.abilities.keyword.FlyingAbility; +import mage.abilities.keyword.VigilanceAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.SubType; +import mage.constants.Zone; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.stack.Spell; +import mage.target.targetpointer.FixedTarget; +import mage.watchers.common.FirstXSpellCastThisTurnWatcher; + +/** + * + * @author muz + */ +public final class OwlinSpiralmancer extends CardImpl { + + public OwlinSpiralmancer(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{3}{U}"); + + this.subtype.add(SubType.BIRD); + this.subtype.add(SubType.WIZARD); + this.power = new MageInt(3); + this.toughness = new MageInt(4); + + // Flying + this.addAbility(FlyingAbility.getInstance()); + + // Vigilance + this.addAbility(VigilanceAbility.getInstance()); + + // Whenever you cast your first spell with {X} in its mana cost each turn, you may copy it. You may choose new targets for the copy. + this.addAbility(new OwlinSpiralmancerTriggeredAbility(), new FirstXSpellCastThisTurnWatcher()); + } + + private OwlinSpiralmancer(final OwlinSpiralmancer card) { + super(card); + } + + @Override + public OwlinSpiralmancer copy() { + return new OwlinSpiralmancer(this); + } +} + +class OwlinSpiralmancerTriggeredAbility extends TriggeredAbilityImpl { + + OwlinSpiralmancerTriggeredAbility() { + super(Zone.BATTLEFIELD, new CopyStackObjectEffect(), true); + } + + private OwlinSpiralmancerTriggeredAbility(final OwlinSpiralmancerTriggeredAbility ability) { + super(ability); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.SPELL_CAST; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (!event.getPlayerId().equals(getControllerId())) { + return false; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell == null) { + return false; + } + FirstXSpellCastThisTurnWatcher watcher = game.getState().getWatcher(FirstXSpellCastThisTurnWatcher.class); + if (watcher == null || !spell.getId().equals(watcher.getFirstXSpellId(getControllerId()))) { + return false; + } + getAllEffects().setTargetPointer(new FixedTarget(spell.getId(), game)); + return true; + } + + @Override + public String getRule() { + return "Whenever you cast your first spell with {X} in its mana cost each turn, you may copy it. You may choose new targets for the copy."; + } + + @Override + public OwlinSpiralmancerTriggeredAbility copy() { + return new OwlinSpiralmancerTriggeredAbility(this); + } +} diff --git a/Mage.Sets/src/mage/cards/z/ZimoneInfiniteAnalyst.java b/Mage.Sets/src/mage/cards/z/ZimoneInfiniteAnalyst.java new file mode 100644 index 000000000000..92cbf1615a37 --- /dev/null +++ b/Mage.Sets/src/mage/cards/z/ZimoneInfiniteAnalyst.java @@ -0,0 +1,135 @@ +package mage.cards.z; + +import mage.MageInt; +import mage.abilities.Ability; +import mage.abilities.SpellAbility; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.common.counter.AddCountersSourceEffect; +import mage.abilities.effects.common.cost.CostModificationEffectImpl; +import mage.cards.Card; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.counters.CounterType; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.permanent.Permanent; +import mage.game.stack.Spell; +import mage.util.CardUtil; +import mage.watchers.common.FirstXSpellCastThisTurnWatcher; + +import java.util.UUID; + +/** + * @author muz + */ +public final class ZimoneInfiniteAnalyst extends CardImpl { + + public ZimoneInfiniteAnalyst(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{1}{G}{U}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.HUMAN); + this.subtype.add(SubType.WIZARD); + this.power = new MageInt(0); + this.toughness = new MageInt(4); + + // The first spell you cast with {X} in its mana cost each turn costs {1} less to cast for each +1/+1 counter on Zimone. + this.addAbility(new SimpleStaticAbility(new ZimoneCostReductionEffect()), new FirstXSpellCastThisTurnWatcher()); + + // Whenever you cast your first spell with {X} in its mana cost each turn, put two +1/+1 counters on Zimone. + this.addAbility(new ZimoneTriggeredAbility()); + } + + private ZimoneInfiniteAnalyst(final ZimoneInfiniteAnalyst card) { + super(card); + } + + @Override + public ZimoneInfiniteAnalyst copy() { + return new ZimoneInfiniteAnalyst(this); + } +} + +class ZimoneTriggeredAbility extends TriggeredAbilityImpl { + + ZimoneTriggeredAbility() { + super(Zone.BATTLEFIELD, new AddCountersSourceEffect(CounterType.P1P1.createInstance(2)), false); + } + + private ZimoneTriggeredAbility(final ZimoneTriggeredAbility ability) { + super(ability); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.SPELL_CAST; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (!event.getPlayerId().equals(getControllerId())) { + return false; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell == null) { + return false; + } + FirstXSpellCastThisTurnWatcher watcher = game.getState().getWatcher(FirstXSpellCastThisTurnWatcher.class); + return watcher != null && spell.getId().equals(watcher.getFirstXSpellId(getControllerId())); + } + + @Override + public String getRule() { + return "Whenever you cast your first spell with {X} in its mana cost each turn, put two +1/+1 counters on {this}."; + } + + @Override + public ZimoneTriggeredAbility copy() { + return new ZimoneTriggeredAbility(this); + } +} + +class ZimoneCostReductionEffect extends CostModificationEffectImpl { + + ZimoneCostReductionEffect() { + super(Duration.WhileOnBattlefield, Outcome.Benefit, CostModificationType.REDUCE_COST); + staticText = "The first spell you cast with {X} in its mana cost each turn costs {1} less to cast for each +1/+1 counter on {this}"; + } + + private ZimoneCostReductionEffect(final ZimoneCostReductionEffect effect) { + super(effect); + } + + @Override + public boolean apply(Game game, Ability source, Ability abilityToModify) { + Permanent sourcePermanent = source.getSourcePermanentIfItStillExists(game); + if (sourcePermanent != null) { + int counters = sourcePermanent.getCounters(game).getCount(CounterType.P1P1); + if (counters > 0) { + CardUtil.reduceCost(abilityToModify, counters); + } + } + return true; + } + + @Override + public boolean applies(Ability abilityToModify, Ability source, Game game) { + if (!(abilityToModify instanceof SpellAbility) + || !abilityToModify.isControlledBy(source.getControllerId())) { + return false; + } + Card spellCard = ((SpellAbility) abilityToModify).getCharacteristics(game); + if (spellCard == null || !spellCard.getManaCost().containsX()) { + return false; + } + FirstXSpellCastThisTurnWatcher watcher = game.getState().getWatcher(FirstXSpellCastThisTurnWatcher.class); + return watcher != null && watcher.getFirstXSpellId(source.getControllerId()) == null; + } + + @Override + public ZimoneCostReductionEffect copy() { + return new ZimoneCostReductionEffect(this); + } +} diff --git a/Mage.Sets/src/mage/sets/SecretsOfStrixhavenCommander.java b/Mage.Sets/src/mage/sets/SecretsOfStrixhavenCommander.java index 7c397d50946c..a11d9e8bb706 100644 --- a/Mage.Sets/src/mage/sets/SecretsOfStrixhavenCommander.java +++ b/Mage.Sets/src/mage/sets/SecretsOfStrixhavenCommander.java @@ -199,6 +199,8 @@ private SecretsOfStrixhavenCommander() { cards.add(new SetCardInfo("Kor Spiritdancer", 152, Rarity.RARE, mage.cards.k.KorSpiritdancer.class)); cards.add(new SetCardInfo("Laelia, the Blade Reforged", 246, Rarity.RARE, mage.cards.l.LaeliaTheBladeReforged.class)); cards.add(new SetCardInfo("Land Tax", 153, Rarity.MYTHIC, mage.cards.l.LandTax.class)); + cards.add(new SetCardInfo("Lattice Library", 40, Rarity.RARE, mage.cards.l.LatticeLibrary.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Lattice Library", 88, Rarity.RARE, mage.cards.l.LatticeLibrary.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Leitmotif Composer", 20, Rarity.RARE, mage.cards.l.LeitmotifComposer.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Leitmotif Composer", 70, Rarity.RARE, mage.cards.l.LeitmotifComposer.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Lifeblood Hydra", 274, Rarity.RARE, mage.cards.l.LifebloodHydra.class)); @@ -233,6 +235,8 @@ private SecretsOfStrixhavenCommander() { cards.add(new SetCardInfo("Nature's Lore", 278, Rarity.UNCOMMON, mage.cards.n.NaturesLore.class)); cards.add(new SetCardInfo("Necroblossom Snarl", 389, Rarity.RARE, mage.cards.n.NecroblossomSnarl.class)); cards.add(new SetCardInfo("Nether Traitor", 220, Rarity.RARE, mage.cards.n.NetherTraitor.class)); + cards.add(new SetCardInfo("Nev, the Practical Dean", 41, Rarity.RARE, mage.cards.n.NevThePracticalDean.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Nev, the Practical Dean", 89, Rarity.RARE, mage.cards.n.NevThePracticalDean.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Night's Whisper", 221, Rarity.COMMON, mage.cards.n.NightsWhisper.class)); cards.add(new SetCardInfo("Nils, Discipline Enforcer", 158, Rarity.RARE, mage.cards.n.NilsDisciplineEnforcer.class)); cards.add(new SetCardInfo("Ohran Frostfang", 279, Rarity.RARE, mage.cards.o.OhranFrostfang.class)); @@ -244,6 +248,8 @@ private SecretsOfStrixhavenCommander() { cards.add(new SetCardInfo("Oran-Rief, the Vastwood", 391, Rarity.RARE, mage.cards.o.OranRiefTheVastwood.class)); cards.add(new SetCardInfo("Overflowing Basin", 392, Rarity.RARE, mage.cards.o.OverflowingBasin.class)); cards.add(new SetCardInfo("Oversimplify", 322, Rarity.RARE, mage.cards.o.Oversimplify.class)); + cards.add(new SetCardInfo("Owlin Spiralmancer", 22, Rarity.RARE, mage.cards.o.OwlinSpiralmancer.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Owlin Spiralmancer", 72, Rarity.RARE, mage.cards.o.OwlinSpiralmancer.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Ozolith, the Shattered Spire", 281, Rarity.RARE, mage.cards.o.OzolithTheShatteredSpire.class)); cards.add(new SetCardInfo("Parasitic Impetus", 222, Rarity.COMMON, mage.cards.p.ParasiticImpetus.class)); cards.add(new SetCardInfo("Patchwork Banner", 353, Rarity.UNCOMMON, mage.cards.p.PatchworkBanner.class)); @@ -422,6 +428,7 @@ private SecretsOfStrixhavenCommander() { cards.add(new SetCardInfo("Yavimaya Coast", 425, Rarity.RARE, mage.cards.y.YavimayaCoast.class)); cards.add(new SetCardInfo("Zimone's Hypothesis", 206, Rarity.RARE, mage.cards.z.ZimonesHypothesis.class)); cards.add(new SetCardInfo("Zimone, All-Questioning", 340, Rarity.RARE, mage.cards.z.ZimoneAllQuestioning.class)); + cards.add(new SetCardInfo("Zimone, Infinite Analyst", 10, Rarity.MYTHIC, mage.cards.z.ZimoneInfiniteAnalyst.class)); cards.add(new SetCardInfo("Zimone, Quandrix Prodigy", 341, Rarity.UNCOMMON, mage.cards.z.ZimoneQuandrixProdigy.class)); cards.add(new SetCardInfo("Zulaport Cutthroat", 233, Rarity.UNCOMMON, mage.cards.z.ZulaportCutthroat.class)); diff --git a/Mage/src/main/java/mage/watchers/common/FirstXSpellCastThisTurnWatcher.java b/Mage/src/main/java/mage/watchers/common/FirstXSpellCastThisTurnWatcher.java new file mode 100644 index 000000000000..9472bc12aab1 --- /dev/null +++ b/Mage/src/main/java/mage/watchers/common/FirstXSpellCastThisTurnWatcher.java @@ -0,0 +1,49 @@ +package mage.watchers.common; + +import mage.constants.WatcherScope; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.stack.Spell; +import mage.watchers.Watcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Tracks the first spell with {X} in its mana cost that each player casts each turn. + * + * @author muz + */ +public class FirstXSpellCastThisTurnWatcher extends Watcher { + + private final Map firstXSpellPerPlayer = new HashMap<>(); + + public FirstXSpellCastThisTurnWatcher() { + super(WatcherScope.GAME); + } + + @Override + public void watch(GameEvent event, Game game) { + if (event.getType() != GameEvent.EventType.SPELL_CAST) { + return; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell != null && spell.getSpellAbility().getManaCostsToPay().containsX()) { + firstXSpellPerPlayer.putIfAbsent(event.getPlayerId(), spell.getId()); + } + } + + @Override + public void reset() { + super.reset(); + firstXSpellPerPlayer.clear(); + } + + /** + * Returns the ID of the first X spell cast by the given player this turn, or null if none yet. + */ + public UUID getFirstXSpellId(UUID playerId) { + return firstXSpellPerPlayer.get(playerId); + } +}