diff --git a/Mage.Sets/src/mage/cards/k/KayaSpiritsJustice.java b/Mage.Sets/src/mage/cards/k/KayaSpiritsJustice.java new file mode 100644 index 000000000000..531853ca51a2 --- /dev/null +++ b/Mage.Sets/src/mage/cards/k/KayaSpiritsJustice.java @@ -0,0 +1,282 @@ +package mage.cards.k; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import mage.MageObject; +import mage.abilities.Ability; +import mage.abilities.LoyaltyAbility; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.common.CopyEffect; +import mage.abilities.effects.common.CreateTokenEffect; +import mage.abilities.effects.common.ExileTargetEffect; +import mage.abilities.effects.keyword.SurveilEffect; +import mage.abilities.keyword.FlyingAbility; +import mage.cards.Card; +import mage.cards.Cards; +import mage.cards.CardsImpl; +import mage.constants.Duration; +import mage.constants.Outcome; +import mage.constants.SubType; +import mage.constants.SuperType; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.Zone; +import mage.filter.FilterPermanent; +import mage.filter.StaticFilters; +import mage.filter.common.FilterCreaturePermanent; +import mage.filter.predicate.permanent.ControllerIdPredicate; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.events.ZoneChangeBatchEvent; +import mage.game.events.ZoneChangeEvent; +import mage.game.permanent.Permanent; +import mage.game.permanent.PermanentCard; +import mage.game.permanent.token.WhiteBlackSpiritToken; +import mage.players.Player; +import mage.target.TargetCard; +import mage.target.TargetPermanent; +import mage.target.common.TargetCardInExile; +import mage.target.common.TargetCardInGraveyard; +import mage.target.targetadjustment.TargetAdjuster; +import mage.target.targetpointer.EachTargetPointer; +import mage.target.targetpointer.FixedTargets; +import mage.util.CardUtil; +import mage.util.functions.CopyApplier; + +/** + * + * @author DominionSpy + */ +public final class KayaSpiritsJustice extends CardImpl { + + public KayaSpiritsJustice(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.PLANESWALKER}, "{2}{W}{B}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.KAYA); + this.setStartingLoyalty(3); + + // Whenever one or more creatures you control and/or creature cards in your graveyard are put into exile, you may choose a creature card from among them. + // Until end of turn, target token you control becomes a copy of it, except it has flying. + Ability ability = new KayaSpiritsJusticeTriggeredAbility(); + ability.addTarget(new TargetPermanent(StaticFilters.FILTER_PERMANENT_TOKEN)); + this.addAbility(ability); + + // +2: Surveil 2, then exile a card from a graveyard. + ability = new LoyaltyAbility(new SurveilEffect(2, false), 2); + ability.addEffect(new KayaSpiritsJusticeExileEffect().concatBy(", then")); + this.addAbility(ability); + // +1: Create a 1/1 white and black Spirit creature token with flying. + ability = new LoyaltyAbility(new CreateTokenEffect(new WhiteBlackSpiritToken()), 1); + this.addAbility(ability); + // -2: Exile target creature you control. For each other player, exile up to one target creature that player controls. + ability = new LoyaltyAbility(new ExileTargetEffect() + .setText("exile target creature you control. For each other player, " + + "exile up to one target creature that player controls") + .setTargetPointer(new EachTargetPointer()), -2); + ability.setTargetAdjuster(KayaSpiritsJusticeAdjuster.instance); + this.addAbility(ability); + } + + private KayaSpiritsJustice(final KayaSpiritsJustice card) { + super(card); + } + + @Override + public KayaSpiritsJustice copy() { + return new KayaSpiritsJustice(this); + } +} + +class KayaSpiritsJusticeTriggeredAbility extends TriggeredAbilityImpl { + + KayaSpiritsJusticeTriggeredAbility() { + super(Zone.BATTLEFIELD, new KayaSpiritsJusticeCopyEffect(), false); + setTriggerPhrase("Whenever one or more creatures you control and/or creature cards in your graveyard are put into exile, " + + "you may choose a creature card from among them. Until end of turn, target token you control becomes a copy of it, " + + "except it has flying."); + } + + private KayaSpiritsJusticeTriggeredAbility(final KayaSpiritsJusticeTriggeredAbility ability) { + super(ability); + } + + @Override + public KayaSpiritsJusticeTriggeredAbility copy() { + return new KayaSpiritsJusticeTriggeredAbility(this); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.ZONE_CHANGE_BATCH; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + ZoneChangeBatchEvent zEvent = (ZoneChangeBatchEvent) event; + if (zEvent == null) { + return false; + } + + Set battlefieldCards = zEvent.getEvents() + .stream() + .filter(e -> e.getFromZone() == Zone.BATTLEFIELD) + .filter(e -> e.getToZone() == Zone.EXILED) + .map(ZoneChangeEvent::getTargetId) + .filter(Objects::nonNull) + .map(game::getCard) + .filter(Objects::nonNull) + .filter(card -> { + Permanent permanent = game.getPermanentOrLKIBattlefield(card.getId()); + return StaticFilters.FILTER_PERMANENT_CREATURE + .match(permanent, getControllerId(), this, game); + }) + .collect(Collectors.toSet()); + + Set graveyardCards = zEvent.getEvents() + .stream() + .filter(e -> e.getFromZone() == Zone.GRAVEYARD) + .filter(e -> e.getToZone() == Zone.EXILED) + .map(ZoneChangeEvent::getTargetId) + .filter(Objects::nonNull) + .map(game::getCard) + .filter(Objects::nonNull) + .filter(card -> StaticFilters.FILTER_CARD_CREATURE + .match(card, getControllerId(), this, game)) + .collect(Collectors.toSet()); + + Set cards = new HashSet<>(battlefieldCards); + cards.addAll(graveyardCards); + if (cards.isEmpty()) { + return false; + } + + getEffects().setTargetPointer(new FixedTargets(new CardsImpl(cards), game)); + return true; + } +} + +class KayaSpiritsJusticeCopyEffect extends OneShotEffect { + + KayaSpiritsJusticeCopyEffect() { + super(Outcome.Copy); + } + + private KayaSpiritsJusticeCopyEffect(final KayaSpiritsJusticeCopyEffect effect) { + super(effect); + } + + @Override + public KayaSpiritsJusticeCopyEffect copy() { + return new KayaSpiritsJusticeCopyEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + Permanent copyToPermanent = game.getPermanent(source.getFirstTarget()); + Cards exiledCards = new CardsImpl(getTargetPointer().getTargets(game, source)); + if (controller == null || copyToPermanent == null || exiledCards.isEmpty()) { + return false; + } + + TargetCard target = new TargetCardInExile(0, 1, StaticFilters.FILTER_CARD_CREATURE, null); + if (!controller.chooseTarget(outcome, exiledCards, target, source, game)) { + return false; + } + + Card copyFromCard = game.getCard(target.getFirstTarget()); + if (copyFromCard == null) { + return false; + } + + Permanent newBlueprint = new PermanentCard(copyFromCard, source.getControllerId(), game); + newBlueprint.assignNewId(); + CopyApplier applier = new KayaSpiritsJusticeCopyApplier(); + applier.apply(game, newBlueprint, source, copyToPermanent.getId()); + CopyEffect copyEffect = new CopyEffect(Duration.EndOfTurn, newBlueprint, copyToPermanent.getId()); + copyEffect.newId(); + copyEffect.setApplier(applier); + Ability newAbility = source.copy(); + copyEffect.init(newAbility, game); + game.addEffect(copyEffect, source); + + return true; + } +} + +class KayaSpiritsJusticeCopyApplier extends CopyApplier { + + @Override + public boolean apply(Game game, MageObject blueprint, Ability source, UUID copyToObjectId) { + blueprint.getAbilities().add(FlyingAbility.getInstance()); + return true; + } +} + +class KayaSpiritsJusticeExileEffect extends OneShotEffect { + + KayaSpiritsJusticeExileEffect() { + super(Outcome.Exile); + staticText = "exile a card from a graveyard"; + } + + private KayaSpiritsJusticeExileEffect(final KayaSpiritsJusticeExileEffect effect) { + super(effect); + } + + @Override + public KayaSpiritsJusticeExileEffect copy() { + return new KayaSpiritsJusticeExileEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + if (controller != null) { + TargetCardInGraveyard target = new TargetCardInGraveyard(); + target.withNotTarget(true); + controller.choose(outcome, target, source, game); + Card card = game.getCard(target.getFirstTarget()); + if (card != null) { + UUID exileId = CardUtil.getExileZoneId(game, source.getSourceId(), source.getSourceObjectZoneChangeCounter()); + MageObject sourceObject = source.getSourceObject(game); + String exileName = sourceObject == null ? null : sourceObject.getIdName(); + return controller.moveCardsToExile(card, source, game, true, exileId, exileName); + } + } + return false; + } +} + +enum KayaSpiritsJusticeAdjuster implements TargetAdjuster { + instance; + + @Override + public void adjustTargets(Ability ability, Game game) { + ability.getTargets().clear(); + + Player controller = game.getPlayer(ability.getControllerId()); + if (controller == null) { + return; + } + ability.addTarget(new TargetPermanent(StaticFilters.FILTER_CONTROLLED_CREATURE)); + + for (UUID playerId : game.getState().getPlayersInRange(ability.getControllerId(), game)) { + Player player = game.getPlayer(playerId); + if (player == null || player == controller) { + continue; + } + FilterPermanent filter = new FilterCreaturePermanent("creature that player controls"); + filter.add(new ControllerIdPredicate(playerId)); + ability.addTarget(new TargetPermanent(0, 1, filter) + .withChooseHint("from " + player.getLogName())); + } + } +} diff --git a/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java b/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java index 3e62a5d6b029..f10f806a1b12 100644 --- a/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java +++ b/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java @@ -140,6 +140,7 @@ private MurdersAtKarlovManor() { cards.add(new SetCardInfo("Jaded Analyst", 62, Rarity.COMMON, mage.cards.j.JadedAnalyst.class)); cards.add(new SetCardInfo("Judith, Carnage Connoisseur", 210, Rarity.RARE, mage.cards.j.JudithCarnageConnoisseur.class)); cards.add(new SetCardInfo("Karlov Watchdog", 20, Rarity.UNCOMMON, mage.cards.k.KarlovWatchdog.class)); + cards.add(new SetCardInfo("Kaya, Spirits' Justice", 211, Rarity.MYTHIC, mage.cards.k.KayaSpiritsJustice.class)); cards.add(new SetCardInfo("Kellan, Inquisitive Prodigy", 212, Rarity.RARE, mage.cards.k.KellanInquisitiveProdigy.class)); cards.add(new SetCardInfo("Knife", 134, Rarity.UNCOMMON, mage.cards.k.Knife.class)); cards.add(new SetCardInfo("Kraul Whipcracker", 213, Rarity.UNCOMMON, mage.cards.k.KraulWhipcracker.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/KayaSpiritsJusticeTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/KayaSpiritsJusticeTest.java new file mode 100644 index 000000000000..79a6ec97e27b --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/KayaSpiritsJusticeTest.java @@ -0,0 +1,63 @@ +package org.mage.test.cards.single.mkm; + +import mage.abilities.keyword.FlyingAbility; +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * {@link mage.cards.k.KayaSpiritsJustice} + * @author DominionSpy + */ +public class KayaSpiritsJusticeTest extends CardTestPlayerBase { + + // Test first ability of Kaya + @Test + public void test_TriggeredAbility() { + addCard(Zone.BATTLEFIELD, playerA, "Plains", 1 + 2 + 6); + addCard(Zone.BATTLEFIELD, playerA, "Kaya, Spirits' Justice"); + addCard(Zone.BATTLEFIELD, playerA, "Llanowar Elves"); + addCard(Zone.GRAVEYARD, playerA, "Fyndhorn Elves"); + addCard(Zone.HAND, playerA, "Thraben Inspector"); + addCard(Zone.HAND, playerA, "Astrid Peth"); + addCard(Zone.HAND, playerA, "Farewell"); + + // Creates a Clue token + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Thraben Inspector"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA); + + // Creates a Food token + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Astrid Peth"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA); + + // Exile all creatures and graveyards + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Farewell"); + setModeChoice(playerA, "2"); + setModeChoice(playerA, "4"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, 1); + + // Kaya's first ability triggers twice, so choose which is put on the stack: + // Whenever one or more creatures you control and/or creature cards in your graveyard are put into exile, + // you may choose a creature card from among them. Until end of turn, target token you control becomes a copy of it, + // except it has flying. + setChoice(playerA, "Whenever", 1); + // Trigger targets + addTarget(playerA, "Clue Token"); + addTarget(playerA, "Food Token"); + // Copy choices + addTarget(playerA, "Fyndhorn Elves"); + addTarget(playerA, "Llanowar Elves"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, "Clue Token", 0); + assertPermanentCount(playerA, "Fyndhorn Elves", 1); + assertAbility(playerA, "Fyndhorn Elves", FlyingAbility.getInstance(), true); + assertPermanentCount(playerA, "Food Token", 0); + assertPermanentCount(playerA, "Llanowar Elves", 1); + assertAbility(playerA, "Llanowar Elves", FlyingAbility.getInstance(), true); + } +} diff --git a/Mage/src/main/java/mage/game/GameState.java b/Mage/src/main/java/mage/game/GameState.java index c4c3b17a6cf7..3db6d1429ff1 100644 --- a/Mage/src/main/java/mage/game/GameState.java +++ b/Mage/src/main/java/mage/game/GameState.java @@ -952,9 +952,11 @@ public boolean equals(Object obj) { Map> eventsByKey = new HashMap<>(); List groupEvents = new LinkedList<>(); + ZoneChangeBatchEvent batchEvent = new ZoneChangeBatchEvent(); for (GameEvent event : events) { if (event instanceof ZoneChangeEvent) { ZoneChangeEvent castEvent = (ZoneChangeEvent) event; + batchEvent.addEvent(castEvent); ZoneChangeData key = new ZoneChangeData( castEvent.getSource(), castEvent.getSourceId(), @@ -999,6 +1001,9 @@ public boolean equals(Object obj) { groupEvents.add(event); } } + if (!batchEvent.getEvents().isEmpty()) { + groupEvents.add(batchEvent); + } return groupEvents; } diff --git a/Mage/src/main/java/mage/game/events/GameEvent.java b/Mage/src/main/java/mage/game/events/GameEvent.java index 2db01f66da44..489c3c16810c 100644 --- a/Mage/src/main/java/mage/game/events/GameEvent.java +++ b/Mage/src/main/java/mage/game/events/GameEvent.java @@ -73,6 +73,7 @@ public enum EventType { */ ZONE_CHANGE, ZONE_CHANGE_GROUP, + ZONE_CHANGE_BATCH, DRAW_CARDS, // event calls for multi draws only (if player draws 2+ cards at once) DRAW_CARD, DREW_CARD, EXPLORE, EXPLORED, // targetId is exploring permanent, playerId is its controller diff --git a/Mage/src/main/java/mage/game/events/ZoneChangeBatchEvent.java b/Mage/src/main/java/mage/game/events/ZoneChangeBatchEvent.java new file mode 100644 index 000000000000..da2b0bae48a7 --- /dev/null +++ b/Mage/src/main/java/mage/game/events/ZoneChangeBatchEvent.java @@ -0,0 +1,54 @@ +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; + +public class ZoneChangeBatchEvent extends GameEvent implements BatchGameEvent { + + private final Set events = new HashSet<>(); + + public ZoneChangeBatchEvent() { + super(EventType.ZONE_CHANGE_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(ZoneChangeEvent event) { + this.events.add(event); + } +}