forked from External/mage
[MKM] Implement Kaya, Spirits' Justice and new zone change batch event (#11753)
--------- Co-authored-by: Matthew Wilson <matthew_w@vaadin.com>
This commit is contained in:
parent
4ce2e7debe
commit
9bad12e6cd
6 changed files with 406 additions and 0 deletions
282
Mage.Sets/src/mage/cards/k/KayaSpiritsJustice.java
Normal file
282
Mage.Sets/src/mage/cards/k/KayaSpiritsJustice.java
Normal file
|
|
@ -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<Card> 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<Card> 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<Card> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +140,7 @@ public final class MurdersAtKarlovManor extends ExpansionSet {
|
|||
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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -952,9 +952,11 @@ public class GameState implements Serializable, Copyable<GameState> {
|
|||
|
||||
Map<ZoneChangeData, List<GameEvent>> eventsByKey = new HashMap<>();
|
||||
List<GameEvent> 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 class GameState implements Serializable, Copyable<GameState> {
|
|||
groupEvents.add(event);
|
||||
}
|
||||
}
|
||||
if (!batchEvent.getEvents().isEmpty()) {
|
||||
groupEvents.add(batchEvent);
|
||||
}
|
||||
return groupEvents;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public class GameEvent implements Serializable {
|
|||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ZoneChangeEvent> {
|
||||
|
||||
private final Set<ZoneChangeEvent> events = new HashSet<>();
|
||||
|
||||
public ZoneChangeBatchEvent() {
|
||||
super(EventType.ZONE_CHANGE_BATCH, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<ZoneChangeEvent> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<UUID> 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue