diff --git a/Mage.Sets/src/mage/cards/t/TheMillenniumCalendar.java b/Mage.Sets/src/mage/cards/t/TheMillenniumCalendar.java new file mode 100644 index 00000000000..5416d216305 --- /dev/null +++ b/Mage.Sets/src/mage/cards/t/TheMillenniumCalendar.java @@ -0,0 +1,147 @@ +package mage.cards.t; + +import mage.abilities.Ability; +import mage.abilities.StateTriggeredAbility; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.common.SimpleActivatedAbility; +import mage.abilities.costs.common.TapSourceCost; +import mage.abilities.costs.mana.ManaCostsImpl; +import mage.abilities.effects.common.DoubleCountersSourceEffect; +import mage.abilities.effects.common.LoseLifeOpponentsEffect; +import mage.abilities.effects.common.SacrificeSourceEffect; +import mage.abilities.effects.common.counter.AddCountersSourceEffect; +import mage.abilities.hint.StaticHint; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.SuperType; +import mage.constants.Zone; +import mage.counters.CounterType; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.events.UntappedBatchEvent; +import mage.game.events.UntappedEvent; +import mage.game.permanent.Permanent; + +import java.util.Objects; +import java.util.UUID; + +/** + * @author Susucr + */ +public final class TheMillenniumCalendar extends CardImpl { + + public TheMillenniumCalendar(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ARTIFACT}, "{1}"); + + this.supertype.add(SuperType.LEGENDARY); + + // Whenever you untap one or more permanents during your untap step, put that many time counters on The Millennium Calendar. + this.addAbility(new TheMillenniumCalendarTriggeredAbility()); + + // {2}, {T}: Double the number of time counters on The Millennium Calendar. + Ability ability = new SimpleActivatedAbility( + new DoubleCountersSourceEffect(CounterType.TIME), + new ManaCostsImpl<>("{2}") + ); + ability.addCost(new TapSourceCost()); + this.addAbility(ability); + + // When there are 1,000 or more time counters on The Millennium Calendar, sacrifice it and each opponent loses 1,000 life. + this.addAbility(new TheMillenniumCalendarStateTriggeredAbility()); + } + + private TheMillenniumCalendar(final TheMillenniumCalendar card) { + super(card); + } + + @Override + public TheMillenniumCalendar copy() { + return new TheMillenniumCalendar(this); + } +} + +class TheMillenniumCalendarTriggeredAbility extends TriggeredAbilityImpl { + + public TheMillenniumCalendarTriggeredAbility() { + super(Zone.BATTLEFIELD, null, false); + } + + protected TheMillenniumCalendarTriggeredAbility(final TheMillenniumCalendarTriggeredAbility ability) { + super(ability); + } + + @Override + public TheMillenniumCalendarTriggeredAbility copy() { + return new TheMillenniumCalendarTriggeredAbility(this); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.UNTAPPED_BATCH; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (!game.isActivePlayer(getControllerId())) { + return false; + } + UntappedBatchEvent batchEvent = (UntappedBatchEvent) event; + int count = batchEvent + .getEvents() + .stream() + .filter(UntappedEvent::isAnUntapStepEvent) + .map(UntappedEvent::getTargetId) + .map(game::getPermanent) + .filter(Objects::nonNull) + .filter(p -> p.getControllerId().equals(getControllerId())) + .mapToInt(p -> 1) + .sum(); + + if (count <= 0) { + return false; + } + + this.getEffects().clear(); + this.addEffect(new AddCountersSourceEffect(CounterType.TIME.createInstance(count))); + this.getHints().clear(); + this.addHint(new StaticHint("Number of untapped permanents: " + count)); + return true; + } + + private static final String infoKey = "number_untapped"; + + @Override + public String getRule() { + return "Whenever you untap one or more permanents during your untap step, " + + "put that many time counters on {this}."; + } +} + +/** + * Inspired by {@link mage.cards.n.NineLives} + */ +class TheMillenniumCalendarStateTriggeredAbility extends StateTriggeredAbility { + + public TheMillenniumCalendarStateTriggeredAbility() { + super(Zone.BATTLEFIELD, new SacrificeSourceEffect()); + withRuleTextReplacement(true); + addEffect(new LoseLifeOpponentsEffect(1000).concatBy("and")); + setTriggerPhrase("When there are 1,000 or more time counters on {this}, "); + } + + private TheMillenniumCalendarStateTriggeredAbility(final TheMillenniumCalendarStateTriggeredAbility ability) { + super(ability); + } + + @Override + public TheMillenniumCalendarStateTriggeredAbility copy() { + return new TheMillenniumCalendarStateTriggeredAbility(this); + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + Permanent permanent = game.getPermanent(getSourceId()); + return permanent != null && permanent.getCounters(game).getCount(CounterType.TIME) >= 1000; + } +} diff --git a/Mage.Sets/src/mage/sets/TheLostCavernsOfIxalan.java b/Mage.Sets/src/mage/sets/TheLostCavernsOfIxalan.java index 31219592e18..ac9b94e3047 100644 --- a/Mage.Sets/src/mage/sets/TheLostCavernsOfIxalan.java +++ b/Mage.Sets/src/mage/sets/TheLostCavernsOfIxalan.java @@ -123,6 +123,7 @@ public final class TheLostCavernsOfIxalan extends ExpansionSet { cards.add(new SetCardInfo("The Belligerent", 225, Rarity.RARE, mage.cards.t.TheBelligerent.class)); cards.add(new SetCardInfo("The Core", 256, Rarity.RARE, mage.cards.t.TheCore.class)); cards.add(new SetCardInfo("The Grim Captain", 266, Rarity.RARE, mage.cards.t.TheGrimCaptain.class)); + cards.add(new SetCardInfo("The Millennium Calendar", 257, Rarity.MYTHIC, mage.cards.t.TheMillenniumCalendar.class)); cards.add(new SetCardInfo("The Skullspore Nexus", 212, Rarity.MYTHIC, mage.cards.t.TheSkullsporeNexus.class)); cards.add(new SetCardInfo("Thrashing Brontodon", 216, Rarity.UNCOMMON, mage.cards.t.ThrashingBrontodon.class)); cards.add(new SetCardInfo("Threefold Thunderhulk", 265, Rarity.RARE, mage.cards.t.ThreefoldThunderhulk.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/lci/TheMillenniumCalendarTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/lci/TheMillenniumCalendarTest.java new file mode 100644 index 00000000000..9f1a2714632 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/lci/TheMillenniumCalendarTest.java @@ -0,0 +1,56 @@ +package org.mage.test.cards.single.lci; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import mage.counters.CounterType; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author Susucr + */ +public class TheMillenniumCalendarTest extends CardTestPlayerBase { + + /** + * {@link mage.cards.t.TheMillenniumCalendar}
+ * The Millennium Calendar {1}
+ * Legendary Artifact
+ * Whenever you untap one or more permanents during your untap step, put that many time counters on The Millennium Calendar.
+ * {2}, {T}: Double the number of time counters on The Millennium Calendar.
+ * When there are 1,000 or more time counters on The Millennium Calendar, sacrifice it and each opponent loses 1,000 life.
+ */ + private static final String calendar = "The Millennium Calendar"; + + @Test + public void test_untap_effect_not_triggering() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, calendar); + addCard(Zone.BATTLEFIELD, playerA, "Aphetto Alchemist"); // {T}: Untap target artifact or creature. + + activateAbility(1, PhaseStep.UPKEEP, playerA, "{T}: Untap target artifact or creature.", "Aphetto Alchemist"); + + setStopAt(1, PhaseStep.DRAW); + execute(); + + assertCounterCount(calendar, CounterType.TIME, 0); + } + + @Test + public void test_untap_trigger() { + setStrictChooseMode(true); + + addCard(Zone.BATTLEFIELD, playerA, calendar); + addCard(Zone.BATTLEFIELD, playerA, "Raging Goblin", 10); + + for (int i = 0; i < 10; ++i) { + attack(1, playerA, "Raging Goblin", playerB); + } + + setStopAt(3, PhaseStep.DRAW); + execute(); + + assertCounterCount(calendar, CounterType.TIME, 10); + assertLife(playerB, 20 - 10); + } +} diff --git a/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java b/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java index 1c2400991d9..3584fd205ef 100644 --- a/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java +++ b/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java @@ -36,9 +36,7 @@ import mage.server.util.SystemUtil; import mage.sets.TherosBeyondDeath; import mage.target.targetpointer.TargetPointer; import mage.util.CardUtil; -import mage.verify.mtgjson.MtgJsonCard; -import mage.verify.mtgjson.MtgJsonService; -import mage.verify.mtgjson.MtgJsonSet; +import mage.verify.mtgjson.*; import mage.watchers.Watcher; import org.apache.log4j.Logger; import org.junit.Assert; diff --git a/Mage/src/main/java/mage/game/GameState.java b/Mage/src/main/java/mage/game/GameState.java index cc0ba1f0fd2..5ca06a6b860 100644 --- a/Mage/src/main/java/mage/game/GameState.java +++ b/Mage/src/main/java/mage/game/GameState.java @@ -880,6 +880,27 @@ public class GameState implements Serializable, Copyable { } } + public void addSimultaneousUntapped(UntappedEvent untappedEvent, Game game) { + // Combine multiple untapped events in the single event (batch) + + boolean isUntappedBatchUsed = false; + for (GameEvent event : simultaneousEvents) { + if (event instanceof UntappedBatchEvent) { + // Adding to the existing batch + ((UntappedBatchEvent) event).addEvent(untappedEvent); + isUntappedBatchUsed = true; + break; + } + } + + // new batch + if (!isUntappedBatchUsed) { + UntappedBatchEvent batch = new UntappedBatchEvent(); + batch.addEvent(untappedEvent); + addSimultaneousEvent(batch, game); + } + } + public void handleEvent(GameEvent event, Game game) { watchers.watch(event, game); delayed.checkTriggers(event, game); diff --git a/Mage/src/main/java/mage/game/events/GameEvent.java b/Mage/src/main/java/mage/game/events/GameEvent.java index b0c8bc7348f..a372dcd5d0d 100644 --- a/Mage/src/main/java/mage/game/events/GameEvent.java +++ b/Mage/src/main/java/mage/game/events/GameEvent.java @@ -360,8 +360,19 @@ public class GameEvent implements Serializable { combine all TAPPED events occuring at the same time in a single event */ TAPPED_BATCH, - - UNTAP, UNTAPPED, + UNTAP, + /* UNTAPPED, + targetId untapped permanent + sourceId not used for this event // TODO: add source for untap? + playerId controller of permanent // TODO: replace by source controller of untap? need to check every usage if so. + amount not used for this event + flag true if untapped during untap step (event is checked at upkeep so can't trust the current Phase) + */ + UNTAPPED, + /* UNTAPPED_BATCH + combine all UNTAPPED events occuring at the same time in a single event + */ + UNTAPPED_BATCH, FLIP, FLIPPED, TRANSFORMING, TRANSFORMED, ADAPT, diff --git a/Mage/src/main/java/mage/game/events/UntappedBatchEvent.java b/Mage/src/main/java/mage/game/events/UntappedBatchEvent.java new file mode 100644 index 00000000000..8fbd1f9975d --- /dev/null +++ b/Mage/src/main/java/mage/game/events/UntappedBatchEvent.java @@ -0,0 +1,56 @@ +package mage.game.events; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * @author Susucr + */ +public class UntappedBatchEvent extends GameEvent implements BatchGameEvent { + + private final Set events = new HashSet<>(); + + public UntappedBatchEvent() { + super(EventType.UNTAPPED_BATCH, null, null, null); + } + + @Override + public Set getEvents() { + return events; + } + + @Override + public Set getTargets() { + return events.stream() + .map(GameEvent::getTargetId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + @Override + public int getAmount() { + return events + .stream() + .mapToInt(GameEvent::getAmount) + .sum(); + } + + @Override + @Deprecated // events can store a diff value, so search it from events list instead + public UUID getTargetId() { + throw new IllegalStateException("Wrong code usage. Must search value from a getEvents list or use CardUtil.getEventTargets(event)"); + } + + @Override + @Deprecated // events can store a diff value, so search it from events list instead + public UUID getSourceId() { + throw new IllegalStateException("Wrong code usage. Must search value from a getEvents list."); + } + + public void addEvent(UntappedEvent event) { + this.events.add(event); + } +} diff --git a/Mage/src/main/java/mage/game/events/UntappedEvent.java b/Mage/src/main/java/mage/game/events/UntappedEvent.java new file mode 100644 index 00000000000..d279cde56bc --- /dev/null +++ b/Mage/src/main/java/mage/game/events/UntappedEvent.java @@ -0,0 +1,16 @@ +package mage.game.events; + +import java.util.UUID; + +/** + * @author Susucr + */ +public class UntappedEvent extends GameEvent { + public UntappedEvent(UUID targetId, UUID playerId, boolean duringUntapPhase) { + super(EventType.UNTAPPED, targetId, null, playerId, 0, duringUntapPhase); + } + + public boolean isAnUntapStepEvent() { + return this.flag; + } +} diff --git a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java index 97194267e41..5b72d560dc4 100644 --- a/Mage/src/main/java/mage/game/permanent/PermanentImpl.java +++ b/Mage/src/main/java/mage/game/permanent/PermanentImpl.java @@ -545,7 +545,14 @@ public abstract class PermanentImpl extends CardImpl implements Permanent { //20091005 - 701.15b if (tapped && !replaceEvent(EventType.UNTAP, game)) { this.tapped = false; - fireEvent(EventType.UNTAPPED, game); + UntappedEvent event = new UntappedEvent( + objectId, this.controllerId, + // Since triggers are not checked until the next step, + // we use the event flag to know if untapping was done during the untap step + game.getTurnStepType() == PhaseStep.UNTAP + ); + game.fireEvent(event); + game.getState().addSimultaneousUntapped(event, game); return true; } return false;