fix Yasharn, Implacable Earth and Angel of Jubilation (#13753)

* Fix Angel of Jubilation and Yasharn, Implacable Earth

* canPaySacrificeCost filter was not checking if the source ability was a spell or activated ability

* Create common CantPayLifeOrSacrificeEffect

* add some docs for CantPayLifeOrSacrificeEffect

* change player pay life restrictions and remove player sacrifice cost filter

* pay life cost restriction is now an enum set so multiple effects apply together

* sacrifice cost filter was removed and replaced with PAY_SACRIFICE_COST event

* convert CantPayLifeEffect to CantPayLifeOrSacrificeAbility

* Changed to combine life restriction and sacrifice cost restriction

* update bargain ability cost adjustors using canPay

* fix Thran Portal

* Effect was incorrectly adjusting the cost of mana abilities on itself.

* Fixed ability adding type to itself during ETB

* Add additional tests

* update PayLifeCostRestrictions to be mutually exclusive
This commit is contained in:
Jmlundeen 2025-08-09 17:53:43 -05:00 committed by GitHub
parent 2833460e59
commit 574d7f91a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 610 additions and 387 deletions

View file

@ -0,0 +1,151 @@
package mage.abilities.common;
import mage.abilities.Ability;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.ContinuousRuleModifyingEffectImpl;
import mage.constants.*;
import mage.filter.FilterPermanent;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.permanent.Permanent;
import mage.players.Player;
import java.util.Optional;
import java.util.UUID;
/**
* Effect used to prevent paying life and, optionally, sacrificing permanents as a cost for activated abilities and casting spells.
* @author Jmlundeen
*/
public class CantPayLifeOrSacrificeAbility extends SimpleStaticAbility {
private final String rule;
public CantPayLifeOrSacrificeAbility(FilterPermanent sacrificeFilter) {
this(false, sacrificeFilter);
}
/**
* @param onlyNonManaAbilities boolean to set if the restriction should only apply to non-mana abilities
* @param sacrificeFilter filter for types of permanents that cannot be sacrificed, can be null if sacrifice not needed.
* e.g. Karn's Sylex
*/
public CantPayLifeOrSacrificeAbility(boolean onlyNonManaAbilities, FilterPermanent sacrificeFilter) {
super(new CantPayLifeEffect(onlyNonManaAbilities));
if (sacrificeFilter != null) {
addEffect(new CantSacrificeEffect(onlyNonManaAbilities, sacrificeFilter));
}
this.rule = makeRule(onlyNonManaAbilities, sacrificeFilter);
}
private CantPayLifeOrSacrificeAbility(CantPayLifeOrSacrificeAbility effect) {
super(effect);
this.rule = effect.rule;
}
public CantPayLifeOrSacrificeAbility copy() {
return new CantPayLifeOrSacrificeAbility(this);
}
String makeRule(boolean nonManaAbilities, FilterPermanent sacrificeFilter) {
StringBuilder sb = new StringBuilder("Players can't pay life");
if (sacrificeFilter != null) {
sb.append(" or sacrifice ").append(sacrificeFilter.getMessage());
}
sb.append(" to cast spells or activate abilities");
if (nonManaAbilities) {
sb.append(" that aren't mana abilities");
}
sb.append(".");
return sb.toString();
}
@Override
public String getRule() {
return rule;
}
}
class CantPayLifeEffect extends ContinuousEffectImpl {
private final boolean onlyNonManaAbilities;
/**
* @param onlyNonManaAbilities boolean to set if the restriction should only apply to non-mana abilities
*/
CantPayLifeEffect(boolean onlyNonManaAbilities) {
super(Duration.WhileOnBattlefield, Layer.PlayerEffects, SubLayer.NA, Outcome.Detriment);
this.onlyNonManaAbilities = onlyNonManaAbilities;
}
private CantPayLifeEffect(CantPayLifeEffect effect) {
super(effect);
this.onlyNonManaAbilities = effect.onlyNonManaAbilities;
}
public CantPayLifeEffect copy() {
return new CantPayLifeEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
for (UUID playerId : game.getState().getPlayersInRange(source.getControllerId(), game)) {
Player player = game.getPlayer(playerId);
if (player == null) {
return false;
}
player.addPayLifeCostRestriction(Player.PayLifeCostRestriction.CAST_SPELLS);
if (this.onlyNonManaAbilities) {
player.addPayLifeCostRestriction(Player.PayLifeCostRestriction.ACTIVATE_NON_MANA_ABILITIES);
} else {
player.addPayLifeCostRestriction(Player.PayLifeCostRestriction.ACTIVATE_MANA_ABILITIES);
player.addPayLifeCostRestriction(Player.PayLifeCostRestriction.ACTIVATE_NON_MANA_ABILITIES);
}
}
return true;
}
}
class CantSacrificeEffect extends ContinuousRuleModifyingEffectImpl {
private final FilterPermanent sacrificeFilter;
private final boolean onlyNonManaAbilities;
CantSacrificeEffect(boolean onlyNonManaAbilities, FilterPermanent sacrificeFilter) {
super(Duration.WhileOnBattlefield, Outcome.Detriment);
this.sacrificeFilter = sacrificeFilter;
this.onlyNonManaAbilities = onlyNonManaAbilities;
}
private CantSacrificeEffect(CantSacrificeEffect effect) {
super(effect);
this.sacrificeFilter = effect.sacrificeFilter.copy();
this.onlyNonManaAbilities = effect.onlyNonManaAbilities;
}
@Override
public boolean checksEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.PAY_SACRIFICE_COST;
}
@Override
public boolean applies(GameEvent event, Ability source, Game game) {
Permanent permanent = game.getPermanent(event.getTargetId());
Optional<Ability> abilityOptional = game.getAbility(UUID.fromString(event.getData()), event.getSourceId());
if (permanent == null || !abilityOptional.isPresent()) {
return false;
}
Ability abilityWithCost = abilityOptional.get();
boolean isActivatedAbility = (onlyNonManaAbilities && abilityWithCost.isManaActivatedAbility()) ||
(!onlyNonManaAbilities && abilityWithCost.isActivatedAbility());
if (!isActivatedAbility && abilityWithCost.getAbilityType() != AbilityType.SPELL) {
return false;
}
return this.sacrificeFilter.match(permanent, event.getPlayerId(), source, game);
}
@Override
public CantSacrificeEffect copy() {
return new CantSacrificeEffect(this);
}
}

View file

@ -688,6 +688,13 @@ public class GameEvent implements Serializable {
/* rad counter life loss/gain effect
*/
RADIATION_GAIN_LIFE,
/* for checking sacrifice as a cost
targetId the permanent to be sacrificed
sourceId of the ability
playerId controller of ability
data id of the ability being paid for
*/
PAY_SACRIFICE_COST,
// custom events - must store some unique data to track
CUSTOM_EVENT;

View file

@ -20,7 +20,6 @@ import mage.designations.Designation;
import mage.designations.DesignationType;
import mage.filter.FilterCard;
import mage.filter.FilterMana;
import mage.filter.FilterPermanent;
import mage.game.*;
import mage.game.draft.Draft;
import mage.game.events.GameEvent;
@ -37,10 +36,7 @@ import mage.util.Copyable;
import mage.util.MultiAmountMessage;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
/**
@ -57,16 +53,13 @@ import java.util.stream.Collectors;
public interface Player extends MageItem, Copyable<Player> {
/**
* Enum used to indicate what each player is allowed to spend life on.
* By default it is set to `allAbilities`, but can be changed by effects.
* E.g. Angel of Jubilation sets it to `nonSpellnonActivatedAbilities`,
* and Karn's Sylex sets it to `onlyManaAbilities`.
* <p>
* <p>
* Default is PayLifeCostLevel.allAbilities.
* Enum used to indicate what each player is not allowed to spend life on.
* By default a player has no restrictions, but can be changed by effects.
* E.g. Angel of Jubilation adds `CAST_SPELLS` and 'ACTIVATE_ABILITIES',
* and Karn's Sylex adds `CAST_SPELLS` and 'ACTIVATE_NON_MANA_ABILITIES'.
*/
enum PayLifeCostLevel {
allAbilities, nonSpellnonActivatedAbilities, onlyManaAbilities, none
enum PayLifeCostRestriction {
CAST_SPELLS, ACTIVATE_NON_MANA_ABILITIES, ACTIVATE_MANA_ABILITIES
}
/**
@ -177,14 +170,14 @@ public interface Player extends MageItem, Copyable<Player> {
boolean isCanGainLife();
/**
* Is the player allowed to pay life for casting spells or activate activated abilities
* Adds a {@link PayLifeCostRestriction} to the set of restrictions.
*
* @param payLifeCostLevel
* @param payLifeCostRestriction
*/
void setPayLifeCostLevel(PayLifeCostLevel payLifeCostLevel);
void addPayLifeCostRestriction(PayLifeCostRestriction payLifeCostRestriction);
PayLifeCostLevel getPayLifeCostLevel();
EnumSet<PayLifeCostRestriction> getPayLifeCostRestrictions();
/**
* Can the player pay life to cast or activate the given ability
@ -194,10 +187,6 @@ public interface Player extends MageItem, Copyable<Player> {
*/
boolean canPayLifeCost(Ability Ability);
void setCanPaySacrificeCostFilter(FilterPermanent filter);
FilterPermanent getSacrificeCostFilter();
boolean canPaySacrificeCost(Permanent permanent, Ability source, UUID controllerId, Game game);
void setLifeTotalCanChange(boolean lifeTotalCanChange);

View file

@ -30,7 +30,6 @@ import mage.designations.DesignationType;
import mage.designations.Speed;
import mage.filter.FilterCard;
import mage.filter.FilterMana;
import mage.filter.FilterPermanent;
import mage.filter.StaticFilters;
import mage.filter.common.FilterControlledPermanent;
import mage.filter.common.FilterCreatureForCombat;
@ -150,14 +149,13 @@ public abstract class PlayerImpl implements Player, Serializable {
protected boolean isFastFailInTestMode = true;
protected boolean canGainLife = true;
protected boolean canLoseLife = true;
protected PayLifeCostLevel payLifeCostLevel = PayLifeCostLevel.allAbilities;
protected EnumSet<PayLifeCostRestriction> payLifeCostRestrictions = EnumSet.noneOf(PayLifeCostRestriction.class);
protected boolean loseByZeroOrLessLife = true;
protected boolean canPlotFromTopOfLibrary = false;
protected boolean drawsFromBottom = false;
protected boolean drawsOnOpponentsTurn = false;
protected int speed = 0;
protected FilterPermanent sacrificeCostFilter;
protected List<AlternativeSourceCosts> alternativeSourceCosts = new ArrayList<>();
// TODO: rework turn controller to use single list (see other todos)
@ -264,8 +262,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.userData = player.userData;
this.matchPlayer = player.matchPlayer;
this.payLifeCostLevel = player.payLifeCostLevel;
this.sacrificeCostFilter = player.sacrificeCostFilter;
this.payLifeCostRestrictions = player.payLifeCostRestrictions;
this.alternativeSourceCosts = CardUtil.deepCopyObject(player.alternativeSourceCosts);
this.storedBookmark = player.storedBookmark;
@ -364,9 +361,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.inRange.clear();
this.inRange.addAll(((PlayerImpl) player).inRange);
this.payLifeCostLevel = player.getPayLifeCostLevel();
this.sacrificeCostFilter = player.getSacrificeCostFilter() != null
? player.getSacrificeCostFilter().copy() : null;
this.payLifeCostRestrictions = player.getPayLifeCostRestrictions();
this.loseByZeroOrLessLife = player.canLoseByZeroOrLessLife();
this.canPlotFromTopOfLibrary = player.canPlotFromTopOfLibrary();
this.drawsFromBottom = player.isDrawsFromBottom();
@ -481,14 +476,13 @@ public abstract class PlayerImpl implements Player, Serializable {
//this.isTestMode // must keep
this.canGainLife = true;
this.canLoseLife = true;
this.payLifeCostLevel = PayLifeCostLevel.allAbilities;
this.payLifeCostRestrictions.clear();
this.loseByZeroOrLessLife = true;
this.canPlotFromTopOfLibrary = false;
this.drawsFromBottom = false;
this.drawsOnOpponentsTurn = false;
this.speed = 0;
this.sacrificeCostFilter = null;
this.alternativeSourceCosts.clear();
this.isGameUnderControl = true;
@ -524,8 +518,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.maxAttackedBy = Integer.MAX_VALUE;
this.canGainLife = true;
this.canLoseLife = true;
this.payLifeCostLevel = PayLifeCostLevel.allAbilities;
this.sacrificeCostFilter = null;
this.payLifeCostRestrictions.clear();
this.loseByZeroOrLessLife = true;
this.canPlotFromTopOfLibrary = false;
this.drawsFromBottom = false;
@ -4683,46 +4676,41 @@ public abstract class PlayerImpl implements Player, Serializable {
return false;
}
switch (payLifeCostLevel) {
case allAbilities:
return true;
case onlyManaAbilities:
return ability.isManaAbility();
case nonSpellnonActivatedAbilities:
return !ability.getAbilityType().isActivatedAbility()
&& ability.getAbilityType() != AbilityType.SPELL;
case none:
default:
return false;
boolean canPay = true;
for (PayLifeCostRestriction restriction : payLifeCostRestrictions) {
switch (restriction) {
case CAST_SPELLS:
canPay &= ability.getAbilityType() != AbilityType.SPELL;
break;
case ACTIVATE_NON_MANA_ABILITIES:
canPay &= !ability.isNonManaActivatedAbility();
break;
case ACTIVATE_MANA_ABILITIES:
canPay &= !ability.isManaActivatedAbility();
break;
}
}
return canPay;
}
@Override
public PayLifeCostLevel getPayLifeCostLevel() {
return payLifeCostLevel;
public EnumSet<PayLifeCostRestriction> getPayLifeCostRestrictions() {
return payLifeCostRestrictions;
}
@Override
public void setPayLifeCostLevel(PayLifeCostLevel payLifeCostLevel) {
this.payLifeCostLevel = payLifeCostLevel;
public void addPayLifeCostRestriction(PayLifeCostRestriction payLifeCostRestriction) {
this.payLifeCostRestrictions.add(payLifeCostRestriction);
}
@Override
public boolean canPaySacrificeCost(Permanent permanent, Ability source, UUID controllerId, Game game) {
return permanent.canBeSacrificed() &&
(sacrificeCostFilter == null || !sacrificeCostFilter.match(permanent, controllerId, source, game));
}
@Override
public void setCanPaySacrificeCostFilter(FilterPermanent filter
) {
this.sacrificeCostFilter = filter;
}
@Override
public FilterPermanent getSacrificeCostFilter() {
return sacrificeCostFilter;
if (!permanent.canBeSacrificed()) {
return false;
}
String sourceIdString = source.getId().toString();
return !(game.replaceEvent(GameEvent.getEvent(GameEvent.EventType.PAY_SACRIFICE_COST, permanent.getId(), source, controllerId, sourceIdString, 1)));
}
@Override