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;