From 6e65db284cf9b49f81f8557633c7cae76bdaf9c2 Mon Sep 17 00:00:00 2001
From: Evan Kranzler <theelk801@gmail.com>
Date: Sun, 10 Apr 2022 17:57:58 -0400
Subject: [PATCH] Implemented "Until your next end step" duration (#8831)

* initial implementation of until next end step duration

* added test, reworked effect duration
---
 .../src/mage/cards/r/RiveteersCharm.java      | 88 ++-----------------
 .../continuous/UntilNextEndStepTest.java      | 77 ++++++++++++++++
 .../abilities/effects/ContinuousEffect.java   |  2 +
 .../effects/ContinuousEffectImpl.java         | 35 ++++++--
 .../effects/ContinuousEffectsList.java        |  8 +-
 .../main/java/mage/constants/Duration.java    |  1 +
 Mage/src/main/java/mage/game/GameImpl.java    |  1 +
 .../watchers/common/EndStepCountWatcher.java  | 38 ++++++++
 8 files changed, 160 insertions(+), 90 deletions(-)
 create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/continuous/UntilNextEndStepTest.java
 create mode 100644 Mage/src/main/java/mage/watchers/common/EndStepCountWatcher.java

diff --git a/Mage.Sets/src/mage/cards/r/RiveteersCharm.java b/Mage.Sets/src/mage/cards/r/RiveteersCharm.java
index f81d402cc3..fbc1535837 100644
--- a/Mage.Sets/src/mage/cards/r/RiveteersCharm.java
+++ b/Mage.Sets/src/mage/cards/r/RiveteersCharm.java
@@ -1,26 +1,19 @@
 package mage.cards.r;
 
-import mage.abilities.Ability;
 import mage.abilities.Mode;
-import mage.abilities.condition.Condition;
-import mage.abilities.effects.OneShotEffect;
 import mage.abilities.effects.common.ExileGraveyardAllTargetPlayerEffect;
+import mage.abilities.effects.common.ExileTopXMayPlayUntilEndOfTurnEffect;
 import mage.abilities.effects.common.SacrificeEffect;
-import mage.cards.*;
-import mage.constants.*;
+import mage.cards.CardImpl;
+import mage.cards.CardSetInfo;
+import mage.constants.CardType;
+import mage.constants.Duration;
 import mage.filter.FilterPermanent;
 import mage.filter.common.FilterCreatureOrPlaneswalkerPermanent;
 import mage.filter.predicate.permanent.MaxManaValueControlledPermanentPredicate;
-import mage.game.Game;
-import mage.game.events.GameEvent;
-import mage.players.Player;
 import mage.target.TargetPlayer;
 import mage.target.common.TargetOpponent;
-import mage.util.CardUtil;
-import mage.watchers.Watcher;
 
-import java.util.HashMap;
-import java.util.Map;
 import java.util.UUID;
 
 /**
@@ -46,8 +39,9 @@ public final class RiveteersCharm extends CardImpl {
         this.getSpellAbility().addTarget(new TargetOpponent());
 
         // • Exile the top three cards of your library. Until your next end step, you may play those cards.
-        this.getSpellAbility().addMode(new Mode(new RiveteersCharmEffect()));
-        this.getSpellAbility().addWatcher(new RiveteersCharmWatcher());
+        this.getSpellAbility().addMode(new Mode(new ExileTopXMayPlayUntilEndOfTurnEffect(
+                3, false, Duration.UntilYourNextEndStep
+        )));
 
         // • Exile target player's graveyard.
         this.getSpellAbility().addMode(new Mode(new ExileGraveyardAllTargetPlayerEffect()).addTarget(new TargetPlayer()));
@@ -62,69 +56,3 @@ public final class RiveteersCharm extends CardImpl {
         return new RiveteersCharm(this);
     }
 }
-
-class RiveteersCharmEffect extends OneShotEffect {
-
-    RiveteersCharmEffect() {
-        super(Outcome.Benefit);
-        staticText = "exile the top three cards of your library. Until your next end step, you may play those cards";
-    }
-
-    private RiveteersCharmEffect(final RiveteersCharmEffect effect) {
-        super(effect);
-    }
-
-    @Override
-    public RiveteersCharmEffect copy() {
-        return new RiveteersCharmEffect(this);
-    }
-
-    @Override
-    public boolean apply(Game game, Ability source) {
-        Player player = game.getPlayer(source.getControllerId());
-        if (player == null) {
-            return false;
-        }
-        Cards cards = new CardsImpl(player.getLibrary().getTopCards(game, 3));
-        if (cards.isEmpty()) {
-            return false;
-        }
-        player.moveCards(cards, Zone.EXILED, source, game);
-        int count = RiveteersCharmWatcher.getCount(game, source);
-        Condition condition = (g, s) -> RiveteersCharmWatcher.getCount(g, s) == count;
-        for (Card card : cards.getCards(game)) {
-            CardUtil.makeCardPlayable(game, source, card, Duration.Custom, false, null, condition);
-        }
-        return true;
-    }
-}
-
-class RiveteersCharmWatcher extends Watcher {
-
-    private final Map<UUID, Integer> playerMap = new HashMap<>();
-
-    RiveteersCharmWatcher() {
-        super(WatcherScope.GAME);
-    }
-
-    @Override
-    public void watch(GameEvent event, Game game) {
-        switch (event.getType()) {
-            case END_TURN_STEP_PRE:
-                playerMap.compute(game.getActivePlayerId(), CardUtil::setOrIncrementValue);
-                return;
-            case BEGINNING_PHASE_PRE:
-                if (game.getTurnNum() == 1) {
-                    playerMap.clear();
-                }
-        }
-    }
-
-    static int getCount(Game game, Ability source) {
-        return game
-                .getState()
-                .getWatcher(RiveteersCharmWatcher.class)
-                .playerMap
-                .getOrDefault(source.getControllerId(), 0);
-    }
-}
diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UntilNextEndStepTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UntilNextEndStepTest.java
new file mode 100644
index 0000000000..8578b05557
--- /dev/null
+++ b/Mage.Tests/src/test/java/org/mage/test/cards/continuous/UntilNextEndStepTest.java
@@ -0,0 +1,77 @@
+package org.mage.test.cards.continuous;
+
+import mage.abilities.common.SimpleActivatedAbility;
+import mage.abilities.costs.mana.ManaCostsImpl;
+import mage.abilities.effects.common.continuous.BoostSourceEffect;
+import mage.constants.CardType;
+import mage.constants.Duration;
+import mage.constants.PhaseStep;
+import mage.constants.Zone;
+import org.junit.Test;
+import org.mage.test.serverside.base.CardTestPlayerBase;
+
+/**
+ * @author TheElk801
+ */
+public class UntilNextEndStepTest extends CardTestPlayerBase {
+
+    public void doTest(int startTurnNum, PhaseStep startPhaseStep, int endTurnNum, PhaseStep endPhaseStep, boolean stillActive) {
+        addCustomCardWithAbility(
+                "tester", playerA,
+                new SimpleActivatedAbility(new BoostSourceEffect(
+                        1, 1, Duration.UntilYourNextEndStep
+                ), new ManaCostsImpl<>("{0}")), null,
+                CardType.CREATURE, "", Zone.BATTLEFIELD
+        );
+
+        activateAbility(startTurnNum, startPhaseStep, playerA, "{0}");
+
+        setStrictChooseMode(true);
+        setStopAt(endTurnNum, endPhaseStep);
+        execute();
+        assertAllCommandsUsed();
+
+        int powerToughness = stillActive ? 2 : 1;
+        assertPowerToughness(playerA, "tester", powerToughness, powerToughness);
+    }
+
+    @Test
+    public void testSameTurnTrue() {
+        doTest(1, PhaseStep.PRECOMBAT_MAIN, 1, PhaseStep.POSTCOMBAT_MAIN, true);
+    }
+
+    @Test
+    public void testSameTurnFalse() {
+        doTest(1, PhaseStep.PRECOMBAT_MAIN, 1, PhaseStep.END_TURN, false);
+    }
+
+    @Test
+    public void testNextTurnTrue() {
+        doTest(1, PhaseStep.END_TURN, 2, PhaseStep.PRECOMBAT_MAIN, true);
+    }
+
+    @Test
+    public void testNextTurnFalse() {
+        doTest(1, PhaseStep.PRECOMBAT_MAIN, 2, PhaseStep.PRECOMBAT_MAIN, false);
+    }
+
+    @Test
+    public void testTurnCycleTrue() {
+        doTest(1, PhaseStep.END_TURN, 3, PhaseStep.PRECOMBAT_MAIN, true);
+    }
+
+    @Test
+    public void testTurnCycleFalse() {
+        doTest(1, PhaseStep.END_TURN, 3, PhaseStep.END_TURN, false);
+    }
+
+    @Test
+    public void testOpponentTurnTrue() {
+        doTest(2, PhaseStep.PRECOMBAT_MAIN, 3, PhaseStep.PRECOMBAT_MAIN, true);
+    }
+
+    @Test
+    public void testOpponentTurnFalse() {
+        doTest(2, PhaseStep.PRECOMBAT_MAIN, 3, PhaseStep.END_TURN, false);
+    }
+}
diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffect.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffect.java
index 77faf553bf..11356d4fe7 100644
--- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffect.java
+++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffect.java
@@ -67,6 +67,8 @@ public interface ContinuousEffect extends Effect {
 
     boolean isYourNextTurn(Game game);
 
+    boolean isYourNextEndStep(Game game);
+
     @Override
     void newId();
 
diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffectImpl.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffectImpl.java
index bde84fce90..172003483f 100644
--- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffectImpl.java
+++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffectImpl.java
@@ -4,7 +4,6 @@ import mage.MageObjectReference;
 import mage.abilities.Ability;
 import mage.abilities.CompoundAbility;
 import mage.abilities.MageSingleton;
-import mage.abilities.costs.mana.ActivationManaAbilityStep;
 import mage.abilities.dynamicvalue.DynamicValue;
 import mage.abilities.dynamicvalue.common.DomainValue;
 import mage.abilities.dynamicvalue.common.SignInversionDynamicValue;
@@ -19,6 +18,7 @@ import mage.game.stack.Spell;
 import mage.game.stack.StackObject;
 import mage.players.Player;
 import mage.target.targetpointer.TargetPointer;
+import mage.watchers.common.EndStepCountWatcher;
 
 import java.util.*;
 
@@ -61,6 +61,7 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
     private UUID startingControllerId; // player to check for turn duration (can't different with real controller ability)
     private boolean startingTurnWasActive; // effect started during related players turn and related players turn was already active
     private int effectStartingOnTurn = 0; // turn the effect started
+    private int effectStartingEndStep = 0;
 
     public ContinuousEffectImpl(Duration duration, Outcome outcome) {
         super(outcome);
@@ -91,6 +92,7 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
         this.startingControllerId = effect.startingControllerId;
         this.startingTurnWasActive = effect.startingTurnWasActive;
         this.effectStartingOnTurn = effect.effectStartingOnTurn;
+        this.effectStartingEndStep = effect.effectStartingEndStep;
         this.dependencyTypes = effect.dependencyTypes;
         this.dependendToTypes = effect.dependendToTypes;
         this.characterDefining = effect.characterDefining;
@@ -211,6 +213,7 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
         this.startingTurnWasActive = activePlayerId != null
                 && activePlayerId.equals(startingController); // you can't use "game" for active player cause it's called from tests/cheat too
         this.effectStartingOnTurn = game.getTurnNum();
+        this.effectStartingEndStep = EndStepCountWatcher.getCount(startingController, game);
     }
 
     @Override
@@ -219,6 +222,11 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
                 && game.isActivePlayer(startingControllerId);
     }
 
+    @Override
+    public boolean isYourNextEndStep(Game game) {
+        return EndStepCountWatcher.getCount(startingControllerId, game) > effectStartingEndStep;
+    }
+
     @Override
     public boolean isInactive(Ability source, Game game) {
         // YOUR turn checks
@@ -227,6 +235,7 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
         switch (duration) {
             case UntilYourNextTurn:
             case UntilEndOfYourNextTurn:
+            case UntilYourNextEndStep:
                 break;
             default:
                 return false;
@@ -237,7 +246,7 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
             return false;
         }
 
-        boolean canDelete = false;
+        boolean canDelete;
         Player player = game.getPlayer(startingControllerId);
 
         // discard on start of turn for leaved player
@@ -247,18 +256,26 @@ public abstract class ContinuousEffectImpl extends EffectImpl implements Continu
         switch (duration) {
             case UntilYourNextTurn:
             case UntilEndOfYourNextTurn:
-                canDelete = player == null
-                        || (!player.isInGame()
-                        && player.hasReachedNextTurnAfterLeaving());
+                canDelete = player == null || (!player.isInGame() && player.hasReachedNextTurnAfterLeaving());
+                break;
+            default:
+                canDelete = false;
+        }
+
+        if (canDelete) {
+            return true;
         }
 
         // discard on another conditions (start of your turn)
         switch (duration) {
             case UntilYourNextTurn:
-                if (player != null
-                        && player.isInGame()) {
-                    canDelete = canDelete
-                            || this.isYourNextTurn(game);
+                if (player != null && player.isInGame()) {
+                    return this.isYourNextTurn(game);
+                }
+                break;
+            case UntilYourNextEndStep:
+                if (player != null && player.isInGame()) {
+                    return this.isYourNextEndStep(game);
                 }
         }
 
diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffectsList.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffectsList.java
index 404401843c..e6ed614520 100644
--- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffectsList.java
+++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffectsList.java
@@ -50,7 +50,7 @@ public class ContinuousEffectsList<T extends ContinuousEffect> extends ArrayList
         // rules 514.2
         for (Iterator<T> i = this.iterator(); i.hasNext(); ) {
             T entry = i.next();
-            boolean canRemove = false;
+            boolean canRemove;
             switch (entry.getDuration()) {
                 case EndOfTurn:
                     canRemove = true;
@@ -58,6 +58,11 @@ public class ContinuousEffectsList<T extends ContinuousEffect> extends ArrayList
                 case UntilEndOfYourNextTurn:
                     canRemove = entry.isYourNextTurn(game);
                     break;
+                case UntilYourNextEndStep:
+                    canRemove = entry.isYourNextEndStep(game);
+                    break;
+                default:
+                    canRemove = false;
             }
             if (canRemove) {
                 i.remove();
@@ -149,6 +154,7 @@ public class ContinuousEffectsList<T extends ContinuousEffect> extends ArrayList
                     case Custom:
                     case UntilYourNextTurn:
                     case UntilEndOfYourNextTurn:
+                    case UntilYourNextEndStep:
                         // until your turn effects continue until real turn reached, their used it's own inactive method
                         // 514.2 Second, the following actions happen simultaneously: all damage marked on permanents
                         // (including phased-out permanents) is removed and all "until end of turn" and "this turn" effects end.
diff --git a/Mage/src/main/java/mage/constants/Duration.java b/Mage/src/main/java/mage/constants/Duration.java
index 243cb7b28e..84bf0aa1fb 100644
--- a/Mage/src/main/java/mage/constants/Duration.java
+++ b/Mage/src/main/java/mage/constants/Duration.java
@@ -12,6 +12,7 @@ public enum Duration {
     WhileInGraveyard("", false, false),
     EndOfTurn("until end of turn", true, true),
     UntilYourNextTurn("until your next turn", true, true),
+    UntilYourNextEndStep("until your next end step", true, true),
     UntilEndOfYourNextTurn("until the end of your next turn", true, true),
     UntilSourceLeavesBattlefield("until {this} leaves the battlefield", true, false), // supported for continuous layered effects
     EndOfCombat("until end of combat", true, true),
diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java
index 078d9f2d25..7729961158 100644
--- a/Mage/src/main/java/mage/game/GameImpl.java
+++ b/Mage/src/main/java/mage/game/GameImpl.java
@@ -1299,6 +1299,7 @@ public abstract class GameImpl implements Game {
         newWatchers.add(new ManaSpentToCastWatcher());
         newWatchers.add(new ManaPaidSourceWatcher());
         newWatchers.add(new BlockingOrBlockedWatcher());
+        newWatchers.add(new EndStepCountWatcher());
         newWatchers.add(new CommanderPlaysCountWatcher()); // commander plays count uses in non commander games by some cards
 
         // runtime check - allows only GAME scope (one watcher per game)
diff --git a/Mage/src/main/java/mage/watchers/common/EndStepCountWatcher.java b/Mage/src/main/java/mage/watchers/common/EndStepCountWatcher.java
new file mode 100644
index 0000000000..2527cc48bf
--- /dev/null
+++ b/Mage/src/main/java/mage/watchers/common/EndStepCountWatcher.java
@@ -0,0 +1,38 @@
+package mage.watchers.common;
+
+import mage.constants.WatcherScope;
+import mage.game.Game;
+import mage.game.events.GameEvent;
+import mage.util.CardUtil;
+import mage.watchers.Watcher;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * @author TheElk801
+ */
+public class EndStepCountWatcher extends Watcher {
+
+    private final Map<UUID, Integer> playerMap = new HashMap<>();
+
+    public EndStepCountWatcher() {
+        super(WatcherScope.GAME);
+    }
+
+    @Override
+    public void watch(GameEvent event, Game game) {
+        if (event.getType() == GameEvent.EventType.END_TURN_STEP_PRE) {
+            playerMap.compute(game.getActivePlayerId(), CardUtil::setOrIncrementValue);
+        }
+    }
+
+    public static int getCount(UUID playerId, Game game) {
+        return game
+                .getState()
+                .getWatcher(EndStepCountWatcher.class)
+                .playerMap
+                .getOrDefault(playerId, 0);
+    }
+}