Skip to content

Commit 9bad12e

Browse files
DominionSpyMatthewVaadin
andauthoredFeb 22, 2024··
[MKM] Implement Kaya, Spirits' Justice and new zone change batch event (#11753)
--------- Co-authored-by: Matthew Wilson <matthew_w@vaadin.com>
1 parent 4ce2e7d commit 9bad12e

File tree

6 files changed

+406
-0
lines changed

6 files changed

+406
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
package mage.cards.k;
2+
3+
import java.util.HashSet;
4+
import java.util.Objects;
5+
import java.util.Set;
6+
import java.util.UUID;
7+
import java.util.stream.Collectors;
8+
9+
import mage.MageObject;
10+
import mage.abilities.Ability;
11+
import mage.abilities.LoyaltyAbility;
12+
import mage.abilities.TriggeredAbilityImpl;
13+
import mage.abilities.effects.OneShotEffect;
14+
import mage.abilities.effects.common.CopyEffect;
15+
import mage.abilities.effects.common.CreateTokenEffect;
16+
import mage.abilities.effects.common.ExileTargetEffect;
17+
import mage.abilities.effects.keyword.SurveilEffect;
18+
import mage.abilities.keyword.FlyingAbility;
19+
import mage.cards.Card;
20+
import mage.cards.Cards;
21+
import mage.cards.CardsImpl;
22+
import mage.constants.Duration;
23+
import mage.constants.Outcome;
24+
import mage.constants.SubType;
25+
import mage.constants.SuperType;
26+
import mage.cards.CardImpl;
27+
import mage.cards.CardSetInfo;
28+
import mage.constants.CardType;
29+
import mage.constants.Zone;
30+
import mage.filter.FilterPermanent;
31+
import mage.filter.StaticFilters;
32+
import mage.filter.common.FilterCreaturePermanent;
33+
import mage.filter.predicate.permanent.ControllerIdPredicate;
34+
import mage.game.Game;
35+
import mage.game.events.GameEvent;
36+
import mage.game.events.ZoneChangeBatchEvent;
37+
import mage.game.events.ZoneChangeEvent;
38+
import mage.game.permanent.Permanent;
39+
import mage.game.permanent.PermanentCard;
40+
import mage.game.permanent.token.WhiteBlackSpiritToken;
41+
import mage.players.Player;
42+
import mage.target.TargetCard;
43+
import mage.target.TargetPermanent;
44+
import mage.target.common.TargetCardInExile;
45+
import mage.target.common.TargetCardInGraveyard;
46+
import mage.target.targetadjustment.TargetAdjuster;
47+
import mage.target.targetpointer.EachTargetPointer;
48+
import mage.target.targetpointer.FixedTargets;
49+
import mage.util.CardUtil;
50+
import mage.util.functions.CopyApplier;
51+
52+
/**
53+
*
54+
* @author DominionSpy
55+
*/
56+
public final class KayaSpiritsJustice extends CardImpl {
57+
58+
public KayaSpiritsJustice(UUID ownerId, CardSetInfo setInfo) {
59+
super(ownerId, setInfo, new CardType[]{CardType.PLANESWALKER}, "{2}{W}{B}");
60+
61+
this.supertype.add(SuperType.LEGENDARY);
62+
this.subtype.add(SubType.KAYA);
63+
this.setStartingLoyalty(3);
64+
65+
// 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.
66+
// Until end of turn, target token you control becomes a copy of it, except it has flying.
67+
Ability ability = new KayaSpiritsJusticeTriggeredAbility();
68+
ability.addTarget(new TargetPermanent(StaticFilters.FILTER_PERMANENT_TOKEN));
69+
this.addAbility(ability);
70+
71+
// +2: Surveil 2, then exile a card from a graveyard.
72+
ability = new LoyaltyAbility(new SurveilEffect(2, false), 2);
73+
ability.addEffect(new KayaSpiritsJusticeExileEffect().concatBy(", then"));
74+
this.addAbility(ability);
75+
// +1: Create a 1/1 white and black Spirit creature token with flying.
76+
ability = new LoyaltyAbility(new CreateTokenEffect(new WhiteBlackSpiritToken()), 1);
77+
this.addAbility(ability);
78+
// -2: Exile target creature you control. For each other player, exile up to one target creature that player controls.
79+
ability = new LoyaltyAbility(new ExileTargetEffect()
80+
.setText("exile target creature you control. For each other player, " +
81+
"exile up to one target creature that player controls")
82+
.setTargetPointer(new EachTargetPointer()), -2);
83+
ability.setTargetAdjuster(KayaSpiritsJusticeAdjuster.instance);
84+
this.addAbility(ability);
85+
}
86+
87+
private KayaSpiritsJustice(final KayaSpiritsJustice card) {
88+
super(card);
89+
}
90+
91+
@Override
92+
public KayaSpiritsJustice copy() {
93+
return new KayaSpiritsJustice(this);
94+
}
95+
}
96+
97+
class KayaSpiritsJusticeTriggeredAbility extends TriggeredAbilityImpl {
98+
99+
KayaSpiritsJusticeTriggeredAbility() {
100+
super(Zone.BATTLEFIELD, new KayaSpiritsJusticeCopyEffect(), false);
101+
setTriggerPhrase("Whenever one or more creatures you control and/or creature cards in your graveyard are put into exile, " +
102+
"you may choose a creature card from among them. Until end of turn, target token you control becomes a copy of it, " +
103+
"except it has flying.");
104+
}
105+
106+
private KayaSpiritsJusticeTriggeredAbility(final KayaSpiritsJusticeTriggeredAbility ability) {
107+
super(ability);
108+
}
109+
110+
@Override
111+
public KayaSpiritsJusticeTriggeredAbility copy() {
112+
return new KayaSpiritsJusticeTriggeredAbility(this);
113+
}
114+
115+
@Override
116+
public boolean checkEventType(GameEvent event, Game game) {
117+
return event.getType() == GameEvent.EventType.ZONE_CHANGE_BATCH;
118+
}
119+
120+
@Override
121+
public boolean checkTrigger(GameEvent event, Game game) {
122+
ZoneChangeBatchEvent zEvent = (ZoneChangeBatchEvent) event;
123+
if (zEvent == null) {
124+
return false;
125+
}
126+
127+
Set<Card> battlefieldCards = zEvent.getEvents()
128+
.stream()
129+
.filter(e -> e.getFromZone() == Zone.BATTLEFIELD)
130+
.filter(e -> e.getToZone() == Zone.EXILED)
131+
.map(ZoneChangeEvent::getTargetId)
132+
.filter(Objects::nonNull)
133+
.map(game::getCard)
134+
.filter(Objects::nonNull)
135+
.filter(card -> {
136+
Permanent permanent = game.getPermanentOrLKIBattlefield(card.getId());
137+
return StaticFilters.FILTER_PERMANENT_CREATURE
138+
.match(permanent, getControllerId(), this, game);
139+
})
140+
.collect(Collectors.toSet());
141+
142+
Set<Card> graveyardCards = zEvent.getEvents()
143+
.stream()
144+
.filter(e -> e.getFromZone() == Zone.GRAVEYARD)
145+
.filter(e -> e.getToZone() == Zone.EXILED)
146+
.map(ZoneChangeEvent::getTargetId)
147+
.filter(Objects::nonNull)
148+
.map(game::getCard)
149+
.filter(Objects::nonNull)
150+
.filter(card -> StaticFilters.FILTER_CARD_CREATURE
151+
.match(card, getControllerId(), this, game))
152+
.collect(Collectors.toSet());
153+
154+
Set<Card> cards = new HashSet<>(battlefieldCards);
155+
cards.addAll(graveyardCards);
156+
if (cards.isEmpty()) {
157+
return false;
158+
}
159+
160+
getEffects().setTargetPointer(new FixedTargets(new CardsImpl(cards), game));
161+
return true;
162+
}
163+
}
164+
165+
class KayaSpiritsJusticeCopyEffect extends OneShotEffect {
166+
167+
KayaSpiritsJusticeCopyEffect() {
168+
super(Outcome.Copy);
169+
}
170+
171+
private KayaSpiritsJusticeCopyEffect(final KayaSpiritsJusticeCopyEffect effect) {
172+
super(effect);
173+
}
174+
175+
@Override
176+
public KayaSpiritsJusticeCopyEffect copy() {
177+
return new KayaSpiritsJusticeCopyEffect(this);
178+
}
179+
180+
@Override
181+
public boolean apply(Game game, Ability source) {
182+
Player controller = game.getPlayer(source.getControllerId());
183+
Permanent copyToPermanent = game.getPermanent(source.getFirstTarget());
184+
Cards exiledCards = new CardsImpl(getTargetPointer().getTargets(game, source));
185+
if (controller == null || copyToPermanent == null || exiledCards.isEmpty()) {
186+
return false;
187+
}
188+
189+
TargetCard target = new TargetCardInExile(0, 1, StaticFilters.FILTER_CARD_CREATURE, null);
190+
if (!controller.chooseTarget(outcome, exiledCards, target, source, game)) {
191+
return false;
192+
}
193+
194+
Card copyFromCard = game.getCard(target.getFirstTarget());
195+
if (copyFromCard == null) {
196+
return false;
197+
}
198+
199+
Permanent newBlueprint = new PermanentCard(copyFromCard, source.getControllerId(), game);
200+
newBlueprint.assignNewId();
201+
CopyApplier applier = new KayaSpiritsJusticeCopyApplier();
202+
applier.apply(game, newBlueprint, source, copyToPermanent.getId());
203+
CopyEffect copyEffect = new CopyEffect(Duration.EndOfTurn, newBlueprint, copyToPermanent.getId());
204+
copyEffect.newId();
205+
copyEffect.setApplier(applier);
206+
Ability newAbility = source.copy();
207+
copyEffect.init(newAbility, game);
208+
game.addEffect(copyEffect, source);
209+
210+
return true;
211+
}
212+
}
213+
214+
class KayaSpiritsJusticeCopyApplier extends CopyApplier {
215+
216+
@Override
217+
public boolean apply(Game game, MageObject blueprint, Ability source, UUID copyToObjectId) {
218+
blueprint.getAbilities().add(FlyingAbility.getInstance());
219+
return true;
220+
}
221+
}
222+
223+
class KayaSpiritsJusticeExileEffect extends OneShotEffect {
224+
225+
KayaSpiritsJusticeExileEffect() {
226+
super(Outcome.Exile);
227+
staticText = "exile a card from a graveyard";
228+
}
229+
230+
private KayaSpiritsJusticeExileEffect(final KayaSpiritsJusticeExileEffect effect) {
231+
super(effect);
232+
}
233+
234+
@Override
235+
public KayaSpiritsJusticeExileEffect copy() {
236+
return new KayaSpiritsJusticeExileEffect(this);
237+
}
238+
239+
@Override
240+
public boolean apply(Game game, Ability source) {
241+
Player controller = game.getPlayer(source.getControllerId());
242+
if (controller != null) {
243+
TargetCardInGraveyard target = new TargetCardInGraveyard();
244+
target.withNotTarget(true);
245+
controller.choose(outcome, target, source, game);
246+
Card card = game.getCard(target.getFirstTarget());
247+
if (card != null) {
248+
UUID exileId = CardUtil.getExileZoneId(game, source.getSourceId(), source.getSourceObjectZoneChangeCounter());
249+
MageObject sourceObject = source.getSourceObject(game);
250+
String exileName = sourceObject == null ? null : sourceObject.getIdName();
251+
return controller.moveCardsToExile(card, source, game, true, exileId, exileName);
252+
}
253+
}
254+
return false;
255+
}
256+
}
257+
258+
enum KayaSpiritsJusticeAdjuster implements TargetAdjuster {
259+
instance;
260+
261+
@Override
262+
public void adjustTargets(Ability ability, Game game) {
263+
ability.getTargets().clear();
264+
265+
Player controller = game.getPlayer(ability.getControllerId());
266+
if (controller == null) {
267+
return;
268+
}
269+
ability.addTarget(new TargetPermanent(StaticFilters.FILTER_CONTROLLED_CREATURE));
270+
271+
for (UUID playerId : game.getState().getPlayersInRange(ability.getControllerId(), game)) {
272+
Player player = game.getPlayer(playerId);
273+
if (player == null || player == controller) {
274+
continue;
275+
}
276+
FilterPermanent filter = new FilterCreaturePermanent("creature that player controls");
277+
filter.add(new ControllerIdPredicate(playerId));
278+
ability.addTarget(new TargetPermanent(0, 1, filter)
279+
.withChooseHint("from " + player.getLogName()));
280+
}
281+
}
282+
}

‎Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java

+1
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ private MurdersAtKarlovManor() {
140140
cards.add(new SetCardInfo("Jaded Analyst", 62, Rarity.COMMON, mage.cards.j.JadedAnalyst.class));
141141
cards.add(new SetCardInfo("Judith, Carnage Connoisseur", 210, Rarity.RARE, mage.cards.j.JudithCarnageConnoisseur.class));
142142
cards.add(new SetCardInfo("Karlov Watchdog", 20, Rarity.UNCOMMON, mage.cards.k.KarlovWatchdog.class));
143+
cards.add(new SetCardInfo("Kaya, Spirits' Justice", 211, Rarity.MYTHIC, mage.cards.k.KayaSpiritsJustice.class));
143144
cards.add(new SetCardInfo("Kellan, Inquisitive Prodigy", 212, Rarity.RARE, mage.cards.k.KellanInquisitiveProdigy.class));
144145
cards.add(new SetCardInfo("Knife", 134, Rarity.UNCOMMON, mage.cards.k.Knife.class));
145146
cards.add(new SetCardInfo("Kraul Whipcracker", 213, Rarity.UNCOMMON, mage.cards.k.KraulWhipcracker.class));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package org.mage.test.cards.single.mkm;
2+
3+
import mage.abilities.keyword.FlyingAbility;
4+
import mage.constants.PhaseStep;
5+
import mage.constants.Zone;
6+
import org.junit.Test;
7+
import org.mage.test.serverside.base.CardTestPlayerBase;
8+
9+
/**
10+
* {@link mage.cards.k.KayaSpiritsJustice}
11+
* @author DominionSpy
12+
*/
13+
public class KayaSpiritsJusticeTest extends CardTestPlayerBase {
14+
15+
// Test first ability of Kaya
16+
@Test
17+
public void test_TriggeredAbility() {
18+
addCard(Zone.BATTLEFIELD, playerA, "Plains", 1 + 2 + 6);
19+
addCard(Zone.BATTLEFIELD, playerA, "Kaya, Spirits' Justice");
20+
addCard(Zone.BATTLEFIELD, playerA, "Llanowar Elves");
21+
addCard(Zone.GRAVEYARD, playerA, "Fyndhorn Elves");
22+
addCard(Zone.HAND, playerA, "Thraben Inspector");
23+
addCard(Zone.HAND, playerA, "Astrid Peth");
24+
addCard(Zone.HAND, playerA, "Farewell");
25+
26+
// Creates a Clue token
27+
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Thraben Inspector");
28+
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA);
29+
30+
// Creates a Food token
31+
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Astrid Peth");
32+
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA);
33+
34+
// Exile all creatures and graveyards
35+
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Farewell");
36+
setModeChoice(playerA, "2");
37+
setModeChoice(playerA, "4");
38+
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, 1);
39+
40+
// Kaya's first ability triggers twice, so choose which is put on the stack:
41+
// Whenever one or more creatures you control and/or creature cards in your graveyard are put into exile,
42+
// you may choose a creature card from among them. Until end of turn, target token you control becomes a copy of it,
43+
// except it has flying.
44+
setChoice(playerA, "Whenever", 1);
45+
// Trigger targets
46+
addTarget(playerA, "Clue Token");
47+
addTarget(playerA, "Food Token");
48+
// Copy choices
49+
addTarget(playerA, "Fyndhorn Elves");
50+
addTarget(playerA, "Llanowar Elves");
51+
52+
setStrictChooseMode(true);
53+
setStopAt(1, PhaseStep.POSTCOMBAT_MAIN);
54+
execute();
55+
56+
assertPermanentCount(playerA, "Clue Token", 0);
57+
assertPermanentCount(playerA, "Fyndhorn Elves", 1);
58+
assertAbility(playerA, "Fyndhorn Elves", FlyingAbility.getInstance(), true);
59+
assertPermanentCount(playerA, "Food Token", 0);
60+
assertPermanentCount(playerA, "Llanowar Elves", 1);
61+
assertAbility(playerA, "Llanowar Elves", FlyingAbility.getInstance(), true);
62+
}
63+
}

‎Mage/src/main/java/mage/game/GameState.java

+5
Original file line numberDiff line numberDiff line change
@@ -952,9 +952,11 @@ public boolean equals(Object obj) {
952952

953953
Map<ZoneChangeData, List<GameEvent>> eventsByKey = new HashMap<>();
954954
List<GameEvent> groupEvents = new LinkedList<>();
955+
ZoneChangeBatchEvent batchEvent = new ZoneChangeBatchEvent();
955956
for (GameEvent event : events) {
956957
if (event instanceof ZoneChangeEvent) {
957958
ZoneChangeEvent castEvent = (ZoneChangeEvent) event;
959+
batchEvent.addEvent(castEvent);
958960
ZoneChangeData key = new ZoneChangeData(
959961
castEvent.getSource(),
960962
castEvent.getSourceId(),
@@ -999,6 +1001,9 @@ public boolean equals(Object obj) {
9991001
groupEvents.add(event);
10001002
}
10011003
}
1004+
if (!batchEvent.getEvents().isEmpty()) {
1005+
groupEvents.add(batchEvent);
1006+
}
10021007
return groupEvents;
10031008
}
10041009

‎Mage/src/main/java/mage/game/events/GameEvent.java

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public enum EventType {
7373
*/
7474
ZONE_CHANGE,
7575
ZONE_CHANGE_GROUP,
76+
ZONE_CHANGE_BATCH,
7677
DRAW_CARDS, // event calls for multi draws only (if player draws 2+ cards at once)
7778
DRAW_CARD, DREW_CARD,
7879
EXPLORE, EXPLORED, // targetId is exploring permanent, playerId is its controller
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package mage.game.events;
2+
3+
import java.util.HashSet;
4+
import java.util.Objects;
5+
import java.util.Set;
6+
import java.util.UUID;
7+
import java.util.stream.Collectors;
8+
9+
public class ZoneChangeBatchEvent extends GameEvent implements BatchGameEvent<ZoneChangeEvent> {
10+
11+
private final Set<ZoneChangeEvent> events = new HashSet<>();
12+
13+
public ZoneChangeBatchEvent() {
14+
super(EventType.ZONE_CHANGE_BATCH, null, null, null);
15+
}
16+
17+
@Override
18+
public Set<ZoneChangeEvent> getEvents() {
19+
return events;
20+
}
21+
22+
@Override
23+
public Set<UUID> getTargets() {
24+
return events
25+
.stream()
26+
.map(GameEvent::getTargetId)
27+
.filter(Objects::nonNull)
28+
.collect(Collectors.toSet());
29+
}
30+
31+
@Override
32+
public int getAmount() {
33+
return events
34+
.stream()
35+
.mapToInt(GameEvent::getAmount)
36+
.sum();
37+
}
38+
39+
@Override
40+
@Deprecated // events can store a diff value, so search it from events list instead
41+
public UUID getTargetId() {
42+
throw new IllegalStateException("Wrong code usage. Must search value from a getEvents list or use CardUtil.getEventTargets(event)");
43+
}
44+
45+
@Override
46+
@Deprecated // events can store a diff value, so search it from events list instead
47+
public UUID getSourceId() {
48+
throw new IllegalStateException("Wrong code usage. Must search value from a getEvents list.");
49+
}
50+
51+
public void addEvent(ZoneChangeEvent event) {
52+
this.events.add(event);
53+
}
54+
}

0 commit comments

Comments
 (0)
Please sign in to comment.