Improve handling of abilities that let you cast spells from not hand zone once per turn (#11946)

* fix name of existing test

* adjust class name

* generate text

* use common class for Danitha

* add test

* update Gisa and Geralf

* rework Cemetery Illuminator, add test

* simplify effects allowing play from top of library

* simplify common classes which were bloated to support a single card

* simplify another

* remove unused import from test

* rework to check spell ability

* add more test cases

* add test cases

* add failing test case

* common watcher and hint for "once each turn, you may cast..."

* fix Assemble the Players to check spell ability, add test

* apply same improvements to Johann, Apprentice Sorcerer
This commit is contained in:
xenohedron 2024-03-18 23:09:31 -04:00 committed by GitHub
parent f04fcc43ef
commit d5295b0de3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 910 additions and 899 deletions

View file

@ -20,12 +20,9 @@ public enum MageIdentifier {
// "Once each turn, you may cast an instant or sorcery spell from the top of your library."
//
CastFromGraveyardOnceWatcher,
CemeteryIlluminatorWatcher,
GisaAndGeralfWatcher,
DanithaNewBenaliasLightWatcher,
OnceEachTurnCastWatcher,
HaukensInsightWatcher,
IntrepidPaleontologistWatcher,
KaradorGhostChieftainWatcher,
KessDissidentMageWatcher,
MuldrothaTheGravetideWatcher,
ShareTheSpoilsWatcher,
@ -33,8 +30,6 @@ public enum MageIdentifier {
GlimpseTheCosmosWatcher,
SerraParagonWatcher,
OneWithTheMultiverseWatcher("Without paying manacost"),
JohannApprenticeSorcererWatcher,
AssembleThePlayersWatcher,
KaghaShadowArchdruidWatcher,
CourtOfLocthwainWatcher("Without paying manacost"),
LaraCroftTombRaiderWatcher,

View file

@ -3,6 +3,7 @@ package mage.abilities.common;
import mage.MageIdentifier;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.effects.AsThoughEffectImpl;
import mage.cards.Card;
import mage.constants.*;
@ -10,6 +11,7 @@ import mage.filter.FilterCard;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.watchers.Watcher;
import java.util.HashSet;
@ -18,26 +20,26 @@ import java.util.UUID;
/**
* Once during each of your turns, you may cast... from your graveyard
*
* <p>
* See Lurrus of the Dream Den and Rivaz of the Claw
*
* @author weirddan455
*/
public class CastFromGraveyardOnceStaticAbility extends SimpleStaticAbility {
public class CastFromGraveyardOnceEachTurnAbility extends SimpleStaticAbility {
public CastFromGraveyardOnceStaticAbility(FilterCard filter, String text) {
super(new CastFromGraveyardOnceEffect(filter, text));
public CastFromGraveyardOnceEachTurnAbility(FilterCard filter) {
super(new CastFromGraveyardOnceEffect(filter));
this.addWatcher(new CastFromGraveyardOnceWatcher());
this.setIdentifier(MageIdentifier.CastFromGraveyardOnceWatcher);
}
private CastFromGraveyardOnceStaticAbility(final CastFromGraveyardOnceStaticAbility ability) {
private CastFromGraveyardOnceEachTurnAbility(final CastFromGraveyardOnceEachTurnAbility ability) {
super(ability);
}
@Override
public CastFromGraveyardOnceStaticAbility copy() {
return new CastFromGraveyardOnceStaticAbility(this);
public CastFromGraveyardOnceEachTurnAbility copy() {
return new CastFromGraveyardOnceEachTurnAbility(this);
}
}
@ -45,10 +47,10 @@ class CastFromGraveyardOnceEffect extends AsThoughEffectImpl {
private final FilterCard filter;
CastFromGraveyardOnceEffect(FilterCard filter, String text) {
CastFromGraveyardOnceEffect(FilterCard filter) {
super(AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, Duration.WhileOnBattlefield, Outcome.Benefit);
this.filter = filter;
this.staticText = text;
this.staticText = "Once during each of your turns, you may cast " + filter.getMessage() + " from your graveyard";
}
private CastFromGraveyardOnceEffect(final CastFromGraveyardOnceEffect effect) {
@ -68,19 +70,30 @@ class CastFromGraveyardOnceEffect extends AsThoughEffectImpl {
@Override
public boolean applies(UUID objectId, Ability source, UUID affectedControllerId, Game game) {
if (source.isControlledBy(affectedControllerId)
&& Zone.GRAVEYARD.equals(game.getState().getZone(objectId))
&& game.isActivePlayer(affectedControllerId)) {
Card card = game.getCard(objectId);
Permanent sourceObject = source.getSourcePermanentIfItStillExists(game);
if (card != null && sourceObject != null
&& card.isOwnedBy(affectedControllerId)
&& card.getSpellAbility() != null
&& card.getSpellAbility().spellCanBeActivatedRegularlyNow(affectedControllerId, game)
&& filter.match(card, affectedControllerId, source, game)) {
CastFromGraveyardOnceWatcher watcher = game.getState().getWatcher(CastFromGraveyardOnceWatcher.class);
return watcher != null && watcher.abilityNotUsed(new MageObjectReference(sourceObject, game));
throw new IllegalArgumentException("Wrong code usage: can't call applies method on empty affectedAbility");
}
@Override
public boolean applies(UUID objectId, Ability affectedAbility, Ability source, Game game, UUID playerId) {
Player controller = game.getPlayer(source.getControllerId());
Permanent sourcePermanent = source.getSourcePermanentIfItStillExists(game);
CastFromGraveyardOnceWatcher watcher = game.getState().getWatcher(CastFromGraveyardOnceWatcher.class);
if (controller == null || sourcePermanent == null || watcher == null) {
return false;
}
if (game.isActivePlayer(playerId) // only during your turn
&& source.isControlledBy(playerId) // only you may cast
&& Zone.GRAVEYARD.equals(game.getState().getZone(objectId)) // from graveyard
&& affectedAbility instanceof SpellAbility // characteristics to check
&& watcher.abilityNotUsed(new MageObjectReference(sourcePermanent, game)) // once per turn
) {
SpellAbility spellAbility = (SpellAbility) affectedAbility;
Card cardToCheck = spellAbility.getCharacteristics(game);
if (spellAbility.getManaCosts().isEmpty()) {
return false;
}
return spellAbility.spellCanBeActivatedRegularlyNow(playerId, game)
&& filter.match(cardToCheck, playerId, source, game);
}
return false;
}

View file

@ -1,63 +1,32 @@
package mage.abilities.effects.common.continuous;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.cards.Card;
import mage.constants.*;
import mage.constants.Duration;
import mage.constants.Layer;
import mage.constants.Outcome;
import mage.constants.SubLayer;
import mage.game.Game;
import mage.players.Player;
import mage.util.CardUtil;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* @author TheElk801
*/
public class LookAtTopCardOfLibraryAnyTimeEffect extends ContinuousEffectImpl {
private final TargetController targetLibrary;
public LookAtTopCardOfLibraryAnyTimeEffect() {
this(TargetController.YOU, Duration.WhileOnBattlefield);
this(Duration.WhileOnBattlefield);
}
public LookAtTopCardOfLibraryAnyTimeEffect(TargetController targetLibrary, Duration duration) {
public LookAtTopCardOfLibraryAnyTimeEffect(Duration duration) {
super(duration, Layer.PlayerEffects, SubLayer.NA, Outcome.Benefit);
this.targetLibrary = targetLibrary;
String libInfo;
switch (this.targetLibrary) {
case YOU:
libInfo = "your library";
break;
case OPPONENT:
libInfo = "opponents libraries";
break;
case SOURCE_TARGETS:
libInfo = "target player's library";
break;
default:
throw new IllegalArgumentException("Unknown target library type: " + targetLibrary);
}
StringBuilder sb = new StringBuilder();
String durationString = duration.toString();
if (durationString != null && !durationString.isEmpty()) {
sb.append(durationString);
sb.append(", ");
}
sb.append("you may look at the top card of ");
sb.append(libInfo);
sb.append(" any time");
staticText = sb.toString();
staticText = (duration.toString().isEmpty() ? "" : duration.toString() + ", ") +
"you may look at the top care of your library any time";
}
protected LookAtTopCardOfLibraryAnyTimeEffect(final LookAtTopCardOfLibraryAnyTimeEffect effect) {
super(effect);
this.targetLibrary = effect.targetLibrary;
}
@Override
@ -72,43 +41,11 @@ public class LookAtTopCardOfLibraryAnyTimeEffect extends ContinuousEffectImpl {
if (!canLookAtNextTopLibraryCard(game)) {
return false;
}
MageObject obj = source.getSourceObject(game);
if (obj == null) {
Card topCard = controller.getLibrary().getFromTop(game);
if (topCard == null) {
return false;
}
Set<UUID> needPlayers = new HashSet<>();
switch (this.targetLibrary) {
case YOU: {
needPlayers.add(source.getControllerId());
break;
}
case OPPONENT: {
needPlayers.addAll(game.getOpponents(source.getControllerId()));
break;
}
case SOURCE_TARGETS: {
needPlayers.addAll(CardUtil.getAllSelectedTargets(source, game));
break;
}
}
Set<Card> needCards = new HashSet<>();
needPlayers.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)
.map(player -> player.getLibrary().getFromTop(game))
.filter(Objects::nonNull)
.forEach(needCards::add);
if (needCards.isEmpty()) {
return false;
}
// all fine, can show top card
needCards.forEach(topCard -> {
Player owner = game.getPlayer(topCard.getOwnerId());
controller.lookAtCards(String.format("%s: top card of %s", obj.getName(), owner == null ? "error" : owner.getName()), topCard, game);
});
controller.lookAtCards("Top card of your library", topCard, game);
return true;
}

View file

@ -1,23 +0,0 @@
package mage.abilities.effects.common.continuous;
import mage.constants.Duration;
import mage.constants.TargetController;
/**
* @author JayDi85
*/
public class LookAtTopCardOfLibraryAnyTimeTargetEffect extends LookAtTopCardOfLibraryAnyTimeEffect {
public LookAtTopCardOfLibraryAnyTimeTargetEffect(Duration duration) {
super(TargetController.SOURCE_TARGETS, duration);
}
private LookAtTopCardOfLibraryAnyTimeTargetEffect(final LookAtTopCardOfLibraryAnyTimeTargetEffect effect) {
super(effect);
}
@Override
public LookAtTopCardOfLibraryAnyTimeTargetEffect copy() {
return new LookAtTopCardOfLibraryAnyTimeTargetEffect(this);
}
}

View file

@ -0,0 +1,99 @@
package mage.abilities.effects.common.continuous;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.effects.AsThoughEffectImpl;
import mage.cards.Card;
import mage.constants.AsThoughEffectType;
import mage.constants.Duration;
import mage.constants.Outcome;
import mage.filter.FilterCard;
import mage.game.Game;
import mage.players.Player;
import java.util.Locale;
import java.util.UUID;
/**
* @author nantuko, JayDi85, xenohedron
*/
public class PlayFromTopOfLibraryEffect extends AsThoughEffectImpl {
private final FilterCard filter;
private static final FilterCard defaultFilter = new FilterCard("play lands and cast spells");
/**
* You may play lands and cast spells from the top of your library
*/
public PlayFromTopOfLibraryEffect() {
this(defaultFilter);
}
/**
* You may [play lands and/or cast spells, according to filter] from the top of your library
*/
public PlayFromTopOfLibraryEffect(FilterCard filter) {
super(AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, Duration.WhileOnBattlefield, Outcome.Benefit);
this.filter = filter;
this.staticText = "you may " + filter.getMessage() + " from the top of your library";
// verify check: this ability is to allow playing lands or casting spells, not playing a "card"
if (filter.getMessage().toLowerCase(Locale.ENGLISH).contains("card")) {
throw new IllegalArgumentException("Wrong code usage or wrong filter text: PlayTheTopCardEffect");
}
}
protected PlayFromTopOfLibraryEffect(final PlayFromTopOfLibraryEffect effect) {
super(effect);
this.filter = effect.filter;
}
@Override
public boolean apply(Game game, Ability source) {
return true;
}
@Override
public PlayFromTopOfLibraryEffect copy() {
return new PlayFromTopOfLibraryEffect(this);
}
@Override
public boolean applies(UUID objectId, Ability source, UUID affectedControllerId, Game game) {
throw new IllegalArgumentException("Wrong code usage: can't call applies method on empty affectedAbility");
}
@Override
public boolean applies(UUID objectId, Ability affectedAbility, Ability source, Game game, UUID playerId) {
// can play lands/spells (must check specific part and allows specific part)
Card cardToCheck = game.getCard(objectId); // maybe this should be removed and only check SpellAbility characteristics
if (cardToCheck == null) {
return false;
}
if (affectedAbility instanceof SpellAbility) {
SpellAbility spell = (SpellAbility) affectedAbility;
cardToCheck = spell.getCharacteristics(game);
if (spell.getManaCosts().isEmpty()){
return false;
}
}
// only permits you to cast
if (!playerId.equals(source.getControllerId())) {
return false;
}
Player cardOwner = game.getPlayer(cardToCheck.getOwnerId());
Player controller = game.getPlayer(source.getControllerId());
if (cardOwner == null || controller == null) {
return false;
}
// main card of spell must be on top of your library
Card topCard = controller.getLibrary().getFromTop(game);
if (topCard == null || !topCard.getId().equals(cardToCheck.getMainCard().getId())) {
return false;
}
// spell characteristics must match filter
return filter.match(cardToCheck, playerId, source, game);
}
}

View file

@ -1,170 +0,0 @@
package mage.abilities.effects.common.continuous;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.effects.AsThoughEffectImpl;
import mage.cards.Card;
import mage.constants.AsThoughEffectType;
import mage.constants.Duration;
import mage.constants.Outcome;
import mage.constants.TargetController;
import mage.filter.FilterCard;
import mage.game.Game;
import mage.players.Player;
import mage.util.CardUtil;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
/**
* @author nantuko, JayDi85
*/
public class PlayTheTopCardEffect extends AsThoughEffectImpl {
private final FilterCard filter;
private final TargetController targetLibrary;
// can play card or can play lands/cast spells, see two modes below
private final boolean canPlayCardOnly;
/**
* Support targets, use TargetController.SOURCE_TARGETS
*/
public PlayTheTopCardEffect() {
this(TargetController.YOU);
}
public PlayTheTopCardEffect(TargetController targetLibrary) {
this(targetLibrary, new FilterCard("play lands and cast spells"), false);
}
public PlayTheTopCardEffect(TargetController targetLibrary, FilterCard filter, boolean canPlayCardOnly) {
super(AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE, Duration.WhileOnBattlefield, Outcome.Benefit);
this.filter = filter;
this.targetLibrary = targetLibrary;
this.canPlayCardOnly = canPlayCardOnly;
String libInfo;
switch (this.targetLibrary) {
case YOU:
libInfo = "your library";
break;
case OPPONENT:
libInfo = "opponents libraries";
break;
case SOURCE_TARGETS:
libInfo = "target player's library";
break;
default:
throw new IllegalArgumentException("Unknown target library type: " + targetLibrary);
}
this.staticText = "you may " + filter.getMessage() + " from the top of " + libInfo;
// verify check: if you see "card" text in the rules then use card mode
// (there aren't any real cards after oracle update, but can be added in the future)
if (this.canPlayCardOnly != filter.getMessage().toLowerCase(Locale.ENGLISH).contains("card")) {
throw new IllegalArgumentException("Wrong usage of card mode settings");
}
}
protected PlayTheTopCardEffect(final PlayTheTopCardEffect effect) {
super(effect);
this.filter = effect.filter;
this.targetLibrary = effect.targetLibrary;
this.canPlayCardOnly = effect.canPlayCardOnly;
}
@Override
public boolean apply(Game game, Ability source) {
return true;
}
@Override
public PlayTheTopCardEffect copy() {
return new PlayTheTopCardEffect(this);
}
@Override
public boolean applies(UUID objectId, Ability source, UUID affectedControllerId, Game game) {
throw new IllegalArgumentException("Wrong code usage: can't call applies method on empty affectedAbility");
}
@Override
public boolean applies(UUID objectId, Ability affectedAbility, Ability source, Game game, UUID playerId) {
// main card and all parts are checks in different calls.
// two modes:
// * can play cards (must check main card and allows any parts)
// * can play lands/spells (must check specific part and allows specific part)
// current card's part
Card cardToCheck = game.getCard(objectId);
if (cardToCheck == null) {
return false;
}
if (this.canPlayCardOnly) {
// check whole card instead part
cardToCheck = cardToCheck.getMainCard();
} else if (affectedAbility instanceof SpellAbility) {
SpellAbility spell = (SpellAbility) affectedAbility;
cardToCheck = spell.getCharacteristics(game);
if (spell.getManaCosts().isEmpty()){
return false;
}
}
// must be you
if (!playerId.equals(source.getControllerId())) {
return false;
}
Player cardOwner = game.getPlayer(cardToCheck.getOwnerId());
Player controller = game.getPlayer(source.getControllerId());
if (cardOwner == null || controller == null) {
return false;
}
// must be your or opponents library
switch (this.targetLibrary) {
case YOU: {
Card topCard = controller.getLibrary().getFromTop(game);
if (topCard == null || !topCard.getId().equals(cardToCheck.getMainCard().getId())) {
return false;
}
break;
}
case OPPONENT: {
if (!game.getOpponents(controller.getId()).contains(cardOwner.getId())) {
return false;
}
Card topCard = cardOwner.getLibrary().getFromTop(game);
if (topCard == null || !topCard.getId().equals(cardToCheck.getMainCard().getId())) {
return false;
}
break;
}
case SOURCE_TARGETS: {
UUID needCardId = cardToCheck.getMainCard().getId();
if (CardUtil.getAllSelectedTargets(source, game).stream()
.map(game::getPlayer)
.filter(Objects::nonNull)
.noneMatch(player -> {
Card topCard = player.getLibrary().getFromTop(game);
return topCard != null && topCard.getId().equals(needCardId);
})) {
return false;
}
break;
}
default: {
return false;
}
}
// must be correct card
return filter.match(cardToCheck, playerId, source, game);
}
}

View file

@ -1,22 +0,0 @@
package mage.abilities.effects.common.continuous;
import mage.constants.TargetController;
/**
* @author JayDi85
*/
public class PlayTheTopCardTargetEffect extends PlayTheTopCardEffect {
public PlayTheTopCardTargetEffect() {
super(TargetController.SOURCE_TARGETS);
}
protected PlayTheTopCardTargetEffect(final PlayTheTopCardTargetEffect effect) {
super(effect);
}
@Override
public PlayTheTopCardTargetEffect copy() {
return new PlayTheTopCardTargetEffect(this);
}
}

View file

@ -0,0 +1,77 @@
package mage.watchers.common;
import mage.MageIdentifier;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.hint.Hint;
import mage.constants.WatcherScope;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.watchers.Watcher;
import java.util.*;
/**
* @author xenohedron
*/
public class OnceEachTurnCastWatcher extends Watcher {
private final Map<UUID, Set<MageObjectReference>> usedFrom = new HashMap<>();
/**
* For abilities that permit the casting of a spell from not own hand zone once each turn (per player)
*/
public OnceEachTurnCastWatcher() {
super(WatcherScope.GAME);
}
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.SPELL_CAST
&& event.getPlayerId() != null
&& event.hasApprovingIdentifier(MageIdentifier.OnceEachTurnCastWatcher)) {
usedFrom.computeIfAbsent(event.getPlayerId(), k -> new HashSet<>())
.add(event.getAdditionalReference().getApprovingMageObjectReference());
}
}
@Override
public void reset() {
super.reset();
usedFrom.clear();
}
public boolean isAbilityUsed(UUID playerId, MageObjectReference mor) {
return usedFrom.getOrDefault(playerId, Collections.emptySet()).contains(mor);
}
public static Hint getHint() {
return OnceEachTurnCastHint.instance;
}
}
enum OnceEachTurnCastHint implements Hint {
instance;
@Override
public String getText(Game game, Ability ability) {
OnceEachTurnCastWatcher watcher = game.getState().getWatcher(OnceEachTurnCastWatcher.class);
if (watcher != null) {
boolean used = watcher.isAbilityUsed(ability.getControllerId(), new MageObjectReference(ability.getSourceId(), game));
if (used) {
Player player = game.getPlayer(ability.getControllerId());
if (player != null) {
return "A spell has been cast by " + player.getLogName() + " with {this} this turn.";
}
}
}
return "";
}
@Override
public OnceEachTurnCastHint copy() {
return this;
}
}