Reworked cost adjuster logic for better support of X and cost modification effects:

Improves:
* refactor: split CostAdjuster logic in multiple parts - prepare X, prepare cost, increase cost, reduce cost;
* refactor: improved VariableManaCost to support min/max values, playable and AI calculations, test framework;
* refactor: improved EarlyTargetCost to support mana costs too (related to #13023);
* refactor: migrated some cards with CostAdjuster and X to EarlyTargetCost (Knollspine Invocation, etc - related to #13023);
* refactor: added shared code for "As an additional cost to cast this spell, discard X creature cards";
* refactor: added shared code for "X is the converted mana cost of the exiled card";
* tests: added dozens tests with cost adjusters;

Bug fixes:
* game: fixed that some cards with CostAdjuster ignore min/max limits for X (allow to choose any X, example: Scorched Earth, Open The Way);
* game: fixed that some cards ask to announce already defined X values (example: Bargaining Table);
* game: fixed that some cards with CostAdjuster do not support combo with other cost modification effects;
* game, gui: fixed missing game logs about predefined X values;
* game, gui: fixed wrong X icon for predefined X values;

Test framework:
* test framework: added X min/max check for wrong values;
* test framework: added X min/max info in miss X value announce;
* test framework: added check to find duplicated effect bugs (see assertNoDuplicatedEffects);

Cards:
* Open The Way - fixed that it allow to choose any X without limits (close #12810);
* Unbound Flourishing - improved combo support for activated abilities with predefined X mana costs like Bargaining Table;
This commit is contained in:
Oleg Agafonov 2025-04-08 22:39:10 +04:00
parent 13a832ae00
commit bae3089abb
100 changed files with 1519 additions and 449 deletions

View file

@ -126,7 +126,7 @@ public interface Ability extends Controllable, Serializable {
/**
* Gets all {@link ManaCosts} associated with this ability. These returned
* costs should never be modified as they represent the base costs before
* any modifications.
* any modifications (only cost adjusters can change it, e.g. set min/max values)
*
* @return All {@link ManaCosts} that must be paid.
*/
@ -151,6 +151,17 @@ public interface Ability extends Controllable, Serializable {
void addManaCostsToPay(ManaCost manaCost);
/**
* Helper method to setup actual min/max limits of current X costs BEFORE player's X announcement
*/
void setVariableCostsMinMax(int min, int max);
/**
* Helper method to replace X by direct value BEFORE player's X announcement
* If you need additional target for X then use CostAdjuster + EarlyTargetCost (example: Bargaining Table)
*/
void setVariableCostsValue(int xValue);
/**
* Gets a map of the cost tags (set while casting/activating) of this ability, can be null if no tags have been set yet.
* Does NOT return the source permanent's tags.
@ -356,7 +367,7 @@ public interface Ability extends Controllable, Serializable {
* - for dies triggers - override and use TriggeredAbilityImpl.isInUseableZoneDiesTrigger inside + set setLeavesTheBattlefieldTrigger(true)
*
* @param sourceObject can be null for static continues effects checking like rules modification (example: Yixlid Jailer)
* @param event can be null for state base effects checking like "when you control seven or more" (example: Endrek Sahr, Master Breeder)
* @param event can be null for state base effects checking like "when you control seven or more" (example: Endrek Sahr, Master Breeder)
*/
boolean isInUseableZone(Game game, MageObject sourceObject, GameEvent event);
@ -533,11 +544,25 @@ public interface Ability extends Controllable, Serializable {
void adjustTargets(Game game);
/**
* Dynamic X and cost modification, see CostAdjuster for more details on usage
*/
Ability setCostAdjuster(CostAdjuster costAdjuster);
CostAdjuster getCostAdjuster();
/**
* Prepare {X} settings for announce
*/
void adjustX(Game game);
void adjustCosts(Game game);
/**
* Prepare costs (generate due game state or announce)
*/
void adjustCostsPrepare(Game game);
/**
* Apply additional cost modifications logic/effects
*/
void adjustCostsModify(Game game, CostModificationType costModificationType);
List<Hint> getHints();

View file

@ -291,6 +291,8 @@ public abstract class AbilityImpl implements Ability {
// fused or spliced spells contain multiple abilities (e.g. fused, left, right)
// optional costs and cost modification must be applied only to the first/main ability
// TODO: need tests with X announced costs, cost modification effects, CostAdjuster, early cost target, etc
// can be bugged due multiple calls (not all code parts below use isMainPartAbility)
boolean isMainPartAbility = !CardUtil.isFusedPartAbility(this, game);
/* 20220908 - 601.2b
@ -326,6 +328,16 @@ public abstract class AbilityImpl implements Ability {
return false;
}
// 20241022 - 601.2b
// Choose targets for costs that have to be chosen early
// Not yet included in 601.2b but this is where it will be
handleChooseCostTargets(game, controller);
// prepare dynamic costs (must be called before any x announce)
if (isMainPartAbility) {
adjustX(game);
}
// 20121001 - 601.2b
// If the spell has a variable cost that will be paid as it's being cast (such as an {X} in
// its mana cost; see rule 107.3), the player announces the value of that variable.
@ -402,7 +414,7 @@ public abstract class AbilityImpl implements Ability {
// Note: ActivatedAbility does include SpellAbility & PlayLandAbility, but those should be able to be canceled too.
boolean canCancel = this instanceof ActivatedAbility && controller.isHuman();
if (!getTargets().chooseTargets(outcome, this.controllerId, this, noMana, game, canCancel)) {
// was canceled during targer selection
// was canceled during target selection
return false;
}
}
@ -428,7 +440,7 @@ public abstract class AbilityImpl implements Ability {
//20101001 - 601.2e
if (isMainPartAbility) {
adjustCosts(game); // still needed for CostAdjuster objects (to handle some types of dynamic costs)
// adjustX already called before any announces
game.getContinuousEffects().costModification(this, game);
}
@ -726,6 +738,11 @@ public abstract class AbilityImpl implements Ability {
((EarlyTargetCost) cost).chooseTarget(game, this, controller);
}
}
for (ManaCost cost : getManaCostsToPay()) {
if (cost instanceof EarlyTargetCost && cost.getTargets().isEmpty()) {
((EarlyTargetCost) cost).chooseTarget(game, this, controller);
}
}
}
/**
@ -764,8 +781,15 @@ public abstract class AbilityImpl implements Ability {
if (!variableManaCost.isPaid()) { // should only happen for human players
int xValue;
if (!noMana || variableManaCost.getCostType().canUseAnnounceOnFreeCast()) {
xValue = controller.announceXMana(variableManaCost.getMinX(), variableManaCost.getMaxX(),
"Announce the value for " + variableManaCost.getText(), game, this);
if (variableManaCost.wasAnnounced()) {
// announce by rules
xValue = variableManaCost.getAmount();
} else {
// announce by player
xValue = controller.announceXMana(variableManaCost.getMinX(), variableManaCost.getMaxX(),
"Announce the value for " + variableManaCost.getText(), game, this);
}
int amountMana = xValue * variableManaCost.getXInstancesCount();
StringBuilder manaString = threadLocalBuilder.get();
if (variableManaCost.getFilter() == null || variableManaCost.getFilter().isGeneric()) {
@ -1072,6 +1096,60 @@ public abstract class AbilityImpl implements Ability {
}
}
@Override
public void setVariableCostsMinMax(int min, int max) {
// modify all values (mtg rules allow only one type of X, so min/max must be shared between all X instances)
// base cost
for (ManaCost cost : getManaCosts()) {
if (cost instanceof MinMaxVariableCost) {
MinMaxVariableCost minMaxCost = (MinMaxVariableCost) cost;
minMaxCost.setMinX(min);
minMaxCost.setMaxX(max);
}
}
// prepared cost
for (ManaCost cost : getManaCostsToPay()) {
if (cost instanceof MinMaxVariableCost) {
MinMaxVariableCost minMaxCost = (MinMaxVariableCost) cost;
minMaxCost.setMinX(min);
minMaxCost.setMaxX(max);
}
}
}
@Override
public void setVariableCostsValue(int xValue) {
// only mana cost supported
// base cost
boolean foundBaseCost = false;
for (ManaCost cost : getManaCosts()) {
if (cost instanceof VariableManaCost) {
foundBaseCost = true;
((VariableManaCost) cost).setMinX(xValue);
((VariableManaCost) cost).setMaxX(xValue);
((VariableManaCost) cost).setAmount(xValue, xValue, false);
}
}
// prepared cost
boolean foundPreparedCost = false;
for (ManaCost cost : getManaCostsToPay()) {
if (cost instanceof VariableManaCost) {
foundPreparedCost = true;
((VariableManaCost) cost).setMinX(xValue);
((VariableManaCost) cost).setMaxX(xValue);
((VariableManaCost) cost).setAmount(xValue, xValue, false);
}
}
if (!foundPreparedCost || !foundBaseCost) {
throw new IllegalArgumentException("Wrong code usage: auto-announced X values allowed in mana costs only");
}
}
@Override
public void addEffect(Effect effect) {
if (effect != null) {
@ -1676,17 +1754,6 @@ public abstract class AbilityImpl implements Ability {
}
}
/**
* Dynamic cost modification for ability.<br>
* Example: if it need stack related info (like real targets) then must
* check two states (game.inCheckPlayableState): <br>
* 1. In playable state it must check all possible use cases (e.g. allow to
* reduce on any available target and modes) <br>
* 2. In real cast state it must check current use case (e.g. real selected
* targets and modes)
*
* @param costAdjuster
*/
@Override
public AbilityImpl setCostAdjuster(CostAdjuster costAdjuster) {
this.costAdjuster = costAdjuster;
@ -1694,14 +1761,23 @@ public abstract class AbilityImpl implements Ability {
}
@Override
public CostAdjuster getCostAdjuster() {
return costAdjuster;
public void adjustX(Game game) {
if (costAdjuster != null) {
costAdjuster.prepareX(this, game);
}
}
@Override
public void adjustCosts(Game game) {
public void adjustCostsPrepare(Game game) {
if (costAdjuster != null) {
costAdjuster.adjustCosts(this, game);
costAdjuster.prepareCost(this, game);
}
}
@Override
public void adjustCostsModify(Game game, CostModificationType costModificationType) {
if (costAdjuster != null) {
costAdjuster.modifyCost(this, game, costModificationType);
}
}

View file

@ -1,24 +1,86 @@
package mage.abilities.costs;
import mage.abilities.Ability;
import mage.constants.CostModificationType;
import mage.game.Game;
import java.io.Serializable;
/**
* @author TheElk801
* Dynamic costs implementation to control {X} or other costs, can be used in spells and abilities
* <p>
* Possible use cases:
* - define {X} costs like X cards to discard (mana and non-mana values);
* - define {X} limits before announce (to help in UX and AI logic)
* - define any dynamic costs
* - use as simple cost increase/reduce effect
* <p>
* Calls order by game engine:
* - ... early cost target selection for EarlyTargetCost ...
* - prepareX
* - ... x announce ...
* - prepareCost
* - increaseCost
* - reduceCost
* - ... normal target selection and payment ...
*
* @author TheElk801, JayDi85
*/
@FunctionalInterface
public interface CostAdjuster extends Serializable {
/**
* Must check playable and real cast states.
* Example: if it need stack related info (like real targets) then must check two states (game.inCheckPlayableState):
* 1. In playable state it must check all possible use cases (e.g. allow to reduce on any available target and modes)
* 2. In real cast state it must check current use case (e.g. real selected targets and modes)
*
* @param ability
* @param game
* Prepare {X} costs settings or define auto-announced mana values
* <p>
* Usage example:
* - define auto-announced mana value {X} by ability.setVariableCostsValue
* - define possible {X} settings by ability.setVariableCostsMinMax
*/
void adjustCosts(Ability ability, Game game);
default void prepareX(Ability ability, Game game) {
// do nothing
}
/**
* Prepare any dynamic costs
* <p>
* Usage example:
* - add real cost after {X} mana value announce by CardUtil.getSourceCostsTagX
* - add dynamic cost from game data
*/
default void prepareCost(Ability ability, Game game) {
// do nothing
}
/**
* Simple cost reduction effect
*/
default void reduceCost(Ability ability, Game game) {
// do nothing
}
/**
* Simple cost increase effect
*/
default void increaseCost(Ability ability, Game game) {
// do nothing
}
/**
* Default implementation. Override reduceCost or increaseCost instead
* TODO: make it private after java 9+ migrate
*/
default void modifyCost(Ability ability, Game game, CostModificationType costModificationType) {
switch (costModificationType) {
case REDUCE_COST:
reduceCost(ability, game);
break;
case INCREASE_COST:
increaseCost(ability, game);
break;
case SET_COST:
// do nothing
break;
default:
throw new IllegalArgumentException("Unknown mod type: " + costModificationType);
}
}
}

View file

@ -6,20 +6,11 @@ import mage.players.Player;
/**
* @author Grath
* Costs which extend this class need to have targets chosen, and those targets must be chosen during 601.2b step.
* <p>
* Support 601.2b rules for ealry target choice before X announce and other actions
*/
public abstract class EarlyTargetCost extends CostImpl {
public interface EarlyTargetCost {
protected EarlyTargetCost() {
super();
}
void chooseTarget(Game game, Ability source, Player controller);
protected EarlyTargetCost(final EarlyTargetCost cost) {
super(cost);
}
@Override
public abstract EarlyTargetCost copy();
public abstract void chooseTarget(Game game, Ability source, Player controller);
}

View file

@ -0,0 +1,15 @@
package mage.abilities.costs;
/**
* @author jayDi85
*/
public interface MinMaxVariableCost extends VariableCost {
int getMinX();
void setMinX(int minX);
int getMaxX();
void setMaxX(int maxX);
}

View file

@ -10,11 +10,20 @@ import mage.players.Player;
import mage.target.common.TargetCardInHand;
/**
* Used to setup discard cost WITHOUT {X} mana cost
* <p>
* If you have {X} in spell's mana cost then use DiscardXCardsCostAdjuster instead
* <p>
* Example:
* - {2}{U}{R}
* - As an additional cost to cast this spell, discard X cards.
*
* @author LevelX2
*/
public class DiscardXTargetCost extends VariableCostImpl {
protected FilterCard filter;
protected boolean isRandom = false;
public DiscardXTargetCost(FilterCard filter) {
this(filter, false);
@ -30,6 +39,12 @@ public class DiscardXTargetCost extends VariableCostImpl {
protected DiscardXTargetCost(final DiscardXTargetCost cost) {
super(cost);
this.filter = cost.filter;
this.isRandom = cost.isRandom;
}
public DiscardXTargetCost withRandom() {
this.isRandom = true;
return this;
}
@Override
@ -49,6 +64,6 @@ public class DiscardXTargetCost extends VariableCostImpl {
@Override
public Cost getFixedCostsFromAnnouncedValue(int xValue) {
TargetCardInHand target = new TargetCardInHand(xValue, filter);
return new DiscardTargetCost(target);
return new DiscardTargetCost(target, this.isRandom);
}
}

View file

@ -35,10 +35,10 @@ public class PayLoyaltyCost extends CostImpl {
int loyaltyCost = amount;
// apply cost modification
// apply dynamic costs and cost modification
if (ability instanceof LoyaltyAbility) {
LoyaltyAbility copiedAbility = ((LoyaltyAbility) ability).copy();
copiedAbility.adjustCosts(game);
copiedAbility.adjustX(game);
game.getContinuousEffects().costModification(copiedAbility, game);
loyaltyCost = 0;
for (Cost cost : copiedAbility.getCosts()) {

View file

@ -59,10 +59,10 @@ public class PayVariableLoyaltyCost extends VariableCostImpl {
int maxValue = permanent.getCounters(game).getCount(CounterType.LOYALTY);
// apply cost modification
// apply dynamic costs and cost modification
if (source instanceof LoyaltyAbility) {
LoyaltyAbility copiedAbility = ((LoyaltyAbility) source).copy();
copiedAbility.adjustCosts(game);
copiedAbility.adjustX(game);
game.getContinuousEffects().costModification(copiedAbility, game);
for (Cost cost : copiedAbility.getCosts()) {
if (cost instanceof PayVariableLoyaltyCost) {

View file

@ -13,7 +13,7 @@ public enum CommanderManaValueAdjuster implements CostAdjuster {
instance;
@Override
public void adjustCosts(Ability ability, Game game) {
public void reduceCost(Ability ability, Game game) {
CardUtil.reduceCost(ability, GreatestCommanderManaValue.instance.calculate(game, ability, null));
}
}

View file

@ -0,0 +1,73 @@
package mage.abilities.costs.costadjusters;
import mage.abilities.Ability;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.costs.CostAdjuster;
import mage.abilities.costs.common.DiscardTargetCost;
import mage.abilities.effects.common.InfoEffect;
import mage.cards.Card;
import mage.constants.Zone;
import mage.filter.FilterCard;
import mage.game.Game;
import mage.players.Player;
import mage.target.common.TargetCardInHand;
import mage.util.CardUtil;
/**
* Used to setup discard cost with {X} mana cost
* <p>
* If you don't have {X} then use DiscardXTargetCost instead
* <p>
* Example:
* - {X}{1}{B}
* - As an additional cost to cast this spell, discard X cards.
*
* @author JayDi85
*/
public class DiscardXCardsCostAdjuster implements CostAdjuster {
private final FilterCard filter;
private DiscardXCardsCostAdjuster(FilterCard filter) {
this.filter = filter;
}
@Override
public void prepareX(Ability ability, Game game) {
Player controller = game.getPlayer(ability.getControllerId());
if (controller == null) {
return;
}
int minX = 0;
int maxX = controller.getHand().getCards(this.filter, ability.getControllerId(), ability, game).size();
ability.setVariableCostsMinMax(minX, maxX);
}
@Override
public void prepareCost(Ability ability, Game game) {
int x = CardUtil.getSourceCostsTagX(game, ability, -1);
if (x >= 0) {
ability.addCost(new DiscardTargetCost(new TargetCardInHand(x, x, this.filter)));
}
}
public static void addAdjusterAndMessage(Card card, FilterCard filter) {
addAdjusterAndMessage(card, filter, false);
}
public static void addAdjusterAndMessage(Card card, FilterCard filter, boolean isRandom) {
if (card.getSpellAbility().getManaCosts().getVariableCosts().isEmpty()) {
// how to fix: use DiscardXTargetCost
throw new IllegalArgumentException("Wrong code usage: that's cost adjuster must be used with {X} in mana costs only - " + card);
}
Ability ability = new SimpleStaticAbility(
Zone.ALL, new InfoEffect("As an additional cost to cast this spell, discard X " + filter.getMessage())
);
ability.setRuleAtTheTop(true);
card.addAbility(ability);
card.getSpellAbility().setCostAdjuster(new DiscardXCardsCostAdjuster(filter));
}
}

View file

@ -13,7 +13,7 @@ public enum DomainAdjuster implements CostAdjuster {
instance;
@Override
public void adjustCosts(Ability ability, Game game) {
public void reduceCost(Ability ability, Game game) {
CardUtil.reduceCost(ability, DomainValue.REGULAR.calculate(game, ability, null));
}
}

View file

@ -25,22 +25,29 @@ public class ExileCardsFromHandAdjuster implements CostAdjuster {
}
@Override
public void adjustCosts(Ability ability, Game game) {
if (game.inCheckPlayableState()) {
return;
}
public void reduceCost(Ability ability, Game game) {
Player player = game.getPlayer(ability.getControllerId());
if (player == null) {
return;
}
int cardCount = player.getHand().count(filter, game);
int toExile = cardCount > 0 ? player.getAmount(
0, cardCount, "Choose how many " + filter.getMessage() + " to exile", game
) : 0;
if (toExile > 0) {
ability.addCost(new ExileFromHandCost(new TargetCardInHand(toExile, filter)));
CardUtil.reduceCost(ability, 2 * toExile);
int reduceCount;
if (game.inCheckPlayableState()) {
// possible
reduceCount = 2 * cardCount;
} else {
// real - need to choose
// TODO: need early target cost instead dialog here
int toExile = cardCount == 0 ? 0 : player.getAmount(
0, cardCount, "Choose how many " + filter.getMessage() + " to exile", game
);
reduceCount = 2 * toExile;
if (toExile > 0) {
ability.addCost(new ExileFromHandCost(new TargetCardInHand(toExile, filter)));
}
}
CardUtil.reduceCost(ability, 2 * reduceCount);
}
public static final void addAdjusterAndMessage(Card card, FilterCard filter) {

View file

@ -0,0 +1,37 @@
package mage.abilities.costs.costadjusters;
import mage.abilities.Ability;
import mage.abilities.costs.CostAdjuster;
import mage.cards.Card;
import mage.game.Game;
import mage.game.permanent.Permanent;
/**
* Used for {X} mana cost that must be replaced by imprinted mana value
* <p>
* Example:
* - Elite Arcanist
* - {X}, {T}: Copy the exiled card. ... X is the converted mana cost of the exiled card.
*
* @author JayDi85
*/
public enum ImprintedManaValueXCostAdjuster implements CostAdjuster {
instance;
@Override
public void prepareX(Ability ability, Game game) {
int manaValue = Integer.MAX_VALUE;
Permanent sourcePermanent = game.getPermanent(ability.getSourceId());
if (sourcePermanent != null
&& sourcePermanent.getImprinted() != null
&& !sourcePermanent.getImprinted().isEmpty()) {
Card imprintedInstant = game.getCard(sourcePermanent.getImprinted().get(0));
if (imprintedInstant != null) {
manaValue = imprintedInstant.getManaValue();
}
}
ability.setVariableCostsValue(manaValue);
}
}

View file

@ -29,10 +29,8 @@ public enum LegendaryCreatureCostAdjuster implements CostAdjuster {
);
@Override
public void adjustCosts(Ability ability, Game game) {
int count = game.getBattlefield().count(
filter, ability.getControllerId(), ability, game
);
public void reduceCost(Ability ability, Game game) {
int count = game.getBattlefield().count(filter, ability.getControllerId(), ability, game);
if (count > 0) {
CardUtil.reduceCost(ability, count);
}

View file

@ -32,7 +32,7 @@ public interface ManaCost extends Cost {
ManaOptions getOptions();
/**
* Return all options for paying the mana cost (this) while taking into accoutn if the player can pay life.
* Return all options for paying the mana cost (this) while taking into account if the player can pay life.
* Used to correctly highlight (or not) spells with Phyrexian mana depending on if the player can pay life costs.
* <p>
* E.g. Tezzeret's Gambit has a cost of {3}{U/P}.

View file

@ -72,7 +72,7 @@ public abstract class ManaCostImpl extends CostImpl implements ManaCost {
}
@Override
public ManaOptions getOptions() {
public final ManaOptions getOptions() {
return getOptions(true);
}

View file

@ -439,7 +439,7 @@ public class ManaCostsImpl<T extends ManaCost> extends ArrayList<T> implements M
this.add(new ColoredManaCost(ColoredManaSymbol.lookup(symbol.charAt(0))));
} else // check X wasn't added before
if (modifierForX == 0) {
// count X occurence
// count X occurrence
for (String s : symbols) {
if (s.equals("X")) {
modifierForX++;

View file

@ -3,17 +3,19 @@ package mage.abilities.costs.mana;
import mage.Mana;
import mage.abilities.Ability;
import mage.abilities.costs.Cost;
import mage.abilities.costs.VariableCost;
import mage.abilities.costs.MinMaxVariableCost;
import mage.abilities.costs.VariableCostType;
import mage.abilities.mana.ManaOptions;
import mage.constants.ColoredManaSymbol;
import mage.filter.FilterMana;
import mage.game.Game;
import mage.players.ManaPool;
import mage.util.CardUtil;
/**
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public final class VariableManaCost extends ManaCostImpl implements VariableCost {
public class VariableManaCost extends ManaCostImpl implements MinMaxVariableCost {
// variable mana cost usage on 2019-06-20:
// 1. as X value in spell/ability cast (announce X, set VariableManaCost as paid and add generic mana to pay instead)
@ -23,7 +25,7 @@ public final class VariableManaCost extends ManaCostImpl implements VariableCost
protected int xInstancesCount; // number of {X} instances in cost like {X} or {X}{X}
protected int xValue = 0; // final X value after announce and replace events
protected int xPay = 0; // final/total need pay after announce and replace events (example: {X}{X}, X=3, xPay = 6)
protected boolean wasAnnounced = false;
protected boolean wasAnnounced = false; // X was announced by player or auto-defined by CostAdjuster
protected FilterMana filter; // mana filter that can be used for that cost
protected int minX = 0;
@ -59,6 +61,18 @@ public final class VariableManaCost extends ManaCostImpl implements VariableCost
return 0;
}
@Override
public ManaOptions getOptions(boolean canPayLifeCost) {
ManaOptions res = new ManaOptions();
// limit mana options for better performance
CardUtil.distributeValues(10, getMinX(), getMaxX()).forEach(value -> {
res.add(Mana.GenericMana(value));
});
return res;
}
@Override
public void assignPayment(Game game, Ability ability, ManaPool pool, Cost costToPay) {
// X mana cost always pays as generic mana
@ -86,6 +100,10 @@ public final class VariableManaCost extends ManaCostImpl implements VariableCost
return this.isColorlessPaid(xPay);
}
public boolean wasAnnounced() {
return this.wasAnnounced;
}
@Override
public VariableManaCost getUnpaid() {
return this;
@ -122,18 +140,22 @@ public final class VariableManaCost extends ManaCostImpl implements VariableCost
return this.xInstancesCount;
}
@Override
public int getMinX() {
return minX;
}
@Override
public void setMinX(int minX) {
this.minX = minX;
}
@Override
public int getMaxX() {
return maxX;
}
@Override
public void setMaxX(int maxX) {
this.maxX = maxX;
}

View file

@ -3,11 +3,27 @@ package mage.abilities.dynamicvalue.common;
import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.abilities.hint.Hint;
import mage.abilities.hint.ValueHint;
import mage.filter.FilterCard;
import mage.filter.StaticFilters;
import mage.game.Game;
import mage.players.Player;
public enum CardsInControllerHandCount implements DynamicValue {
instance;
instance(StaticFilters.FILTER_CARD_CARDS), // TODO: replace usage to ANY
ANY(StaticFilters.FILTER_CARD_CARDS),
CREATURES(StaticFilters.FILTER_CARD_CREATURES),
LANDS(StaticFilters.FILTER_CARD_LANDS);
FilterCard filter;
ValueHint hint;
CardsInControllerHandCount(FilterCard filter) {
this.filter = filter;
this.hint = new ValueHint(filter.getMessage() + " in your hand", this);
}
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
@ -22,16 +38,20 @@ public enum CardsInControllerHandCount implements DynamicValue {
@Override
public CardsInControllerHandCount copy() {
return CardsInControllerHandCount.instance;
return this;
}
@Override
public String getMessage() {
return "cards in your hand";
return this.filter.getMessage() + " in your hand";
}
@Override
public String toString() {
return "1";
}
public Hint getHint() {
return this.hint;
}
}

View file

@ -688,6 +688,8 @@ public class ContinuousEffects implements Serializable {
* the battlefield for
* {@link CostModificationEffect cost modification effects} and applies them
* if necessary.
* <p>
* Warning, don't forget to call ability.adjustX before any cost modifications
*
* @param abilityToModify
* @param game
@ -695,6 +697,10 @@ public class ContinuousEffects implements Serializable {
public void costModification(Ability abilityToModify, Game game) {
List<CostModificationEffect> costEffects = getApplicableCostModificationEffects(game);
// add dynamic costs from X and other places
abilityToModify.adjustCostsPrepare(game);
abilityToModify.adjustCostsModify(game, CostModificationType.INCREASE_COST);
for (CostModificationEffect effect : costEffects) {
if (effect.getModificationType() == CostModificationType.INCREASE_COST) {
Set<Ability> abilities = costModificationEffects.getAbility(effect.getId());
@ -706,6 +712,7 @@ public class ContinuousEffects implements Serializable {
}
}
abilityToModify.adjustCostsModify(game, CostModificationType.REDUCE_COST);
for (CostModificationEffect effect : costEffects) {
if (effect.getModificationType() == CostModificationType.REDUCE_COST) {
Set<Ability> abilities = costModificationEffects.getAbility(effect.getId());
@ -717,6 +724,7 @@ public class ContinuousEffects implements Serializable {
}
}
abilityToModify.adjustCostsModify(game, CostModificationType.SET_COST);
for (CostModificationEffect effect : costEffects) {
if (effect.getModificationType() == CostModificationType.SET_COST) {
Set<Ability> abilities = costModificationEffects.getAbility(effect.getId());

View file

@ -62,6 +62,9 @@ public class LookTargetHandChooseDiscardEffect extends OneShotEffect {
}
TargetCard target = new TargetCardInHand(upTo ? 0 : num, num, filter);
if (controller.choose(Outcome.Discard, player.getHand(), target, source, game)) {
// TODO: must fizzle discard effect on not full choice
// - tests: affected (allow to choose and discard 1 instead 2)
// - real game: need to check
player.discard(new CardsImpl(target.getTargets()), false, source, game);
}
return true;

View file

@ -132,6 +132,8 @@ public class SuspendAbility extends SpecialAction {
this.addEffect(new SuspendExileEffect(suspend));
this.usesStack = false;
if (suspend == Integer.MAX_VALUE) {
// example: Suspend X-{X}{W}{W}. X can't be 0.
// TODO: replace by costAdjuster for shared logic
VariableManaCost xCosts = new VariableManaCost(VariableCostType.ALTERNATIVE);
xCosts.setMinX(1);
this.addCost(xCosts);

View file

@ -426,6 +426,16 @@ public class StackAbility extends StackObjectImpl implements Ability {
// Do nothing
}
@Override
public void setVariableCostsMinMax(int min, int max) {
ability.setVariableCostsMinMax(min, max);
}
@Override
public void setVariableCostsValue(int xValue) {
ability.setVariableCostsValue(xValue);
}
@Override
public Map<String, Object> getCostsTagMap() {
return ability.getCostsTagMap();
@ -778,14 +788,23 @@ public class StackAbility extends StackObjectImpl implements Ability {
}
@Override
public CostAdjuster getCostAdjuster() {
return costAdjuster;
public void adjustX(Game game) {
if (costAdjuster != null) {
costAdjuster.prepareX(this, game);
}
}
@Override
public void adjustCosts(Game game) {
public void adjustCostsPrepare(Game game) {
if (costAdjuster != null) {
costAdjuster.adjustCosts(this, game);
costAdjuster.prepareCost(this, game);
}
}
@Override
public void adjustCostsModify(Game game, CostModificationType costModificationType) {
if (costAdjuster != null) {
costAdjuster.modifyCost(this, game, costModificationType);
}
}

View file

@ -743,10 +743,14 @@ public interface Player extends MageItem, Copyable<Player> {
boolean shuffleCardsToLibrary(Card card, Game game, Ability source);
// set the value for X mana spells and abilities
/**
* Set the value for X mana spells and abilities
*/
int announceXMana(int min, int max, String message, Game game, Ability ability);
// set the value for non mana X costs
/**
* Set the value for non mana X costs
*/
int announceXCost(int min, int max, String message, Game game, Ability ability, VariableCost variableCost);
// TODO: rework to use pair's list of effect + ability instead string's map

View file

@ -3679,8 +3679,11 @@ public abstract class PlayerImpl implements Player, Serializable {
if (!copy.canActivate(playerId, game).canActivate()) {
return false;
}
// apply dynamic costs and cost modification
copy.adjustX(game);
if (availableMana != null) {
copy.adjustCosts(game);
// TODO: need research, why it look at availableMana here - can delete condition?
game.getContinuousEffects().costModification(copy, game);
}
boolean canBeCastRegularly = true;
@ -3891,7 +3894,8 @@ public abstract class PlayerImpl implements Player, Serializable {
copyAbility = ability.copy();
copyAbility.clearManaCostsToPay();
copyAbility.addManaCostsToPay(manaCosts.copy());
copyAbility.adjustCosts(game);
// apply dynamic costs and cost modification
copyAbility.adjustX(game);
game.getContinuousEffects().costModification(copyAbility, game);
// reduced all cost
@ -3963,12 +3967,9 @@ public abstract class PlayerImpl implements Player, Serializable {
// alternative cost reduce
copyAbility = ability.copy();
copyAbility.clearManaCostsToPay();
// TODO: IDE warning:
// Unchecked assignment: 'mage.abilities.costs.mana.ManaCosts' to
// 'java.util.Collection<? extends mage.abilities.costs.mana.ManaCost>'.
// Reason: 'manaCosts' has raw type, so result of copy is erased
copyAbility.addManaCostsToPay(manaCosts.copy());
copyAbility.adjustCosts(game);
// apply dynamic costs and cost modification
copyAbility.adjustX(game);
game.getContinuousEffects().costModification(copyAbility, game);
// reduced all cost

View file

@ -25,8 +25,13 @@ public class TargetsCountAdjuster extends GenericTargetAdjuster {
@Override
public void adjustTargets(Ability ability, Game game) {
Target newTarget = blueprintTarget.copy();
int count = dynamicValue.calculate(game, ability, ability.getEffects().get(0));
ability.getTargets().clear();
if (count <= 0) {
return;
}
Target newTarget = blueprintTarget.copy();
newTarget.setMaxNumberOfTargets(count);
Filter filter = newTarget.getFilter();
if (blueprintTarget.getMinNumberOfTargets() != 0) {
@ -35,7 +40,6 @@ public class TargetsCountAdjuster extends GenericTargetAdjuster {
} else {
newTarget.withTargetName(filter.getMessage() + " (up to " + count + " targets)");
}
ability.getTargets().clear();
ability.addTarget(newTarget);
}
}

View file

@ -1079,6 +1079,53 @@ public final class CardUtil {
.collect(Collectors.toSet());
}
public static Set<UUID> getAllPossibleTargets(Cost cost, Game game, Ability source) {
return cost.getTargets()
.stream()
.map(t -> t.possibleTargets(source.getControllerId(), source, game))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
/**
* Distribute values between min and max and make sure that the values will be evenly distributed
* Use it to limit possible values list like mana options
*/
public static List<Integer> distributeValues(int count, int min, int max) {
List<Integer> res = new ArrayList<>();
if (count <= 0 || min > max) {
return res;
}
if (min == max) {
res.add(min);
return res;
}
int range = max - min + 1;
// low possible amount
if (range <= count) {
for (int i = 0; i < range; i++) {
res.add(min + i);
}
return res;
}
// big possible amount, so skip some values
double step = (double) (max - min) / (count - 1);
for (int i = 0; i < count; i++) {
res.add(min + (int) Math.round(i * step));
}
// make sure first and last elements are good
res.set(0, min);
if (res.size() > 1) {
res.set(res.size() - 1, max);
}
return res;
}
/**
* For finding the spell or ability on the stack for "becomes the target" triggers.
*
@ -1908,6 +1955,10 @@ public final class CardUtil {
return defaultValue;
}
public static int getSourceCostsTagX(Game game, Ability source, int defaultValue) {
return getSourceCostsTag(game, source, "X", defaultValue);
}
public static String addCostVerb(String text) {
if (costWords.stream().anyMatch(text.toLowerCase(Locale.ENGLISH)::startsWith)) {
return text;