[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:
Matthew Wilson 2024-02-22 03:55:51 +02:00 committed by GitHub
parent 4ce2e7debe
commit 9bad12e6cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 406 additions and 0 deletions

View 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()));
}
}
}

View file

@ -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));

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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);
}
}