Playable mana calculation improved:

* server: fixed server crashes on usage of multiple permanents with {Any} mana abilities (example: Energy Refractor, related to #11285);
* AI: fixed game freezes and errors on computer's {Any} mana usage (closes #9467, closes #6419);
This commit is contained in:
Oleg Agafonov 2024-05-27 22:24:58 +04:00
parent 19f7ba8937
commit 2298ebc5f5
11 changed files with 504 additions and 221 deletions

View file

@ -90,17 +90,17 @@ public final class RateCard {
}
String name = card.getName();
if (useCache && allowedColors == null && ratedCard.containsKey(name)) {
int rate = ratedCard.get(name);
return rate;
if (useCache && allowedColors.isEmpty() && ratedCard.containsKey(name)) {
return ratedCard.get(name);
}
int typeMultiplier = typeMultiplier(card);
int score = getBaseCardScore(card) + 2 * typeMultiplier + getManaCostScore(card, allowedColors)
+ 40 * isRemoval(card);
if (useCache && allowedColors == null)
if (useCache && allowedColors.isEmpty()) {
ratedCard.put(name, score);
}
return score;
}
@ -111,17 +111,17 @@ public final class RateCard {
}
String name = cardview.getName();
if (useCache && allowedColors == null && ratedCardView.containsKey(name)) {
int rate = ratedCardView.get(name);
return rate;
if (useCache && allowedColors.isEmpty() && ratedCardView.containsKey(name)) {
return ratedCardView.get(name);
}
int typeMultiplier = typeMultiplier(cardview);
int score = getBaseCardScore(cardview) + 2 * typeMultiplier + getManaCostScore(cardview, allowedColors);
// Cardview does not have enough info to know the card is a removal.
if (useCache && allowedColors == null)
if (useCache && allowedColors.isEmpty()) {
ratedCardView.put(name, score);
}
return score;
}
@ -402,7 +402,7 @@ public final class RateCard {
}
private static int getManaCostScore(String name, int manaValue, List<String> manaCostSymbols, List<ColoredManaSymbol> allowedColors) {
if (allowedColors == null) {
if (allowedColors.isEmpty()) {
int colorPenalty = 0;
for (String symbol : manaCostSymbols) {
if (isColoredMana(symbol)) {

View file

@ -53,7 +53,6 @@ import mage.target.common.*;
import mage.util.*;
import org.apache.log4j.Logger;
import java.io.IOException;
import java.io.Serializable;
import java.util.*;
import java.util.Map.Entry;
@ -75,14 +74,17 @@ public class ComputerPlayer extends PlayerImpl {
final static int COMPUTER_MAX_THREADS_FOR_SIMULATIONS = 1; // TODO: rework simulations logic to use multiple calcs instead one by one
private transient Map<Mana, Card> unplayable = new TreeMap<>();
private transient List<Card> playableNonInstant = new ArrayList<>();
private transient List<Card> playableInstant = new ArrayList<>();
private transient List<ActivatedAbility> playableAbilities = new ArrayList<>();
private transient List<PickedCard> pickedCards;
private transient List<ColoredManaSymbol> chosenColors;
private final transient Map<Mana, Card> unplayable = new TreeMap<>();
private final transient List<Card> playableNonInstant = new ArrayList<>();
private final transient List<Card> playableInstant = new ArrayList<>();
private final transient List<ActivatedAbility> playableAbilities = new ArrayList<>();
private final transient List<PickedCard> pickedCards = new ArrayList<>();
private final transient List<ColoredManaSymbol> chosenColors = new ArrayList<>();
private transient ManaCost currentUnpaidMana;
// keep current paying cost info for choose dialogs
// mana abilities must ask payment too, so keep full chain
// TODO: make sure it thread safe for AI simulations (all transient fields above and bottom)
private final transient Map<UUID, ManaCost> lastUnpaidMana = new LinkedHashMap<>();
// For stopping infinite loops when trying to pay Phyrexian mana when the player can't spend life and no other sources are available
private transient boolean alreadyTryingToPayPhyrexian;
@ -94,7 +96,6 @@ public class ComputerPlayer extends PlayerImpl {
userData.setAvatarId(64);
userData.setGroupId(UserGroup.COMPUTER.getGroupId());
userData.setFlagName("computer.png");
pickedCards = new ArrayList<>();
}
protected ComputerPlayer(UUID id) {
@ -104,7 +105,6 @@ public class ComputerPlayer extends PlayerImpl {
userData.setAvatarId(64);
userData.setGroupId(UserGroup.COMPUTER.getGroupId());
userData.setFlagName("computer.png");
pickedCards = new ArrayList<>();
}
public ComputerPlayer(final ComputerPlayer player) {
@ -653,7 +653,7 @@ public class ComputerPlayer extends PlayerImpl {
while (!target.isChosen(game)
&& !cardsInHand.isEmpty()
&& target.getMaxNumberOfTargets() > target.getTargets().size()) {
Card card = pickBestCard(cardsInHand, null, target, source, game);
Card card = pickBestCard(cardsInHand, Collections.emptyList(), target, source, game);
if (card != null) {
if (target.canTarget(abilityControllerId, card.getId(), source, game)) {
target.addTarget(card.getId(), source, game);
@ -1139,9 +1139,9 @@ public class ComputerPlayer extends PlayerImpl {
while (!cards.isEmpty()) {
if (outcome.isGood()) {
card = pickBestCard(cards, null, target, source, game);
card = pickBestCard(cards, Collections.emptyList(), target, source, game);
} else {
card = pickWorstCard(cards, null, target, source, game);
card = pickWorstCard(cards, Collections.emptyList(), target, source, game);
}
if (!target.getTargets().contains(card.getId())) {
if (source != null) {
@ -1152,7 +1152,7 @@ public class ComputerPlayer extends PlayerImpl {
return card;
}
}
cards.remove(card);
cards.remove(card); // TODO: research parent code - is it depends on original list? Can be bugged
}
return null;
}
@ -1550,11 +1550,12 @@ public class ComputerPlayer extends PlayerImpl {
@Override
public boolean playMana(Ability ability, ManaCost unpaid, String promptText, Game game) {
payManaMode = true;
currentUnpaidMana = unpaid;
lastUnpaidMana.put(ability.getId(), unpaid.copy());
try {
return playManaHandling(ability, unpaid, game);
} finally {
currentUnpaidMana = null;
lastUnpaidMana.remove(ability.getId());
payManaMode = false;
}
}
@ -1575,19 +1576,27 @@ public class ComputerPlayer extends PlayerImpl {
producers = this.getAvailableManaProducers(game);
producers.addAll(this.getAvailableManaProducersWithCost(game));
}
// use fully compatible colored mana producers first
for (MageObject mageObject : producers) {
// use color producing mana abilities with costs first that produce all color manas that are needed to pay
// otherwise the computer may not be able to pay the cost for that source
ManaAbility:
for (ActivatedManaAbilityImpl manaAbility : getManaAbilitiesSortedByManaCount(mageObject, game)) {
int colored = 0;
boolean canPayColoredMana = false;
for (Mana mana : manaAbility.getNetMana(game)) {
// if mana ability can produce non-useful mana then ignore whole ability here (example: {R} or {G})
// (AI can't choose a good mana option, so make sure any selection option will be compatible with cost)
// AI support {Any} choice by lastUnpaidMana, so it can safly used in includesMana
if (!unpaid.getMana().includesMana(mana)) {
continue ManaAbility;
} else if (mana.getAny() > 0) {
throw new IllegalArgumentException("Wrong mana calculation: AI do not support color choosing from {Any}");
}
if (mana.countColored() > 0) {
canPayColoredMana = true;
}
colored = CardUtil.overflowInc(colored, mana.countColored());
}
if (colored > 1 && (cost instanceof ColoredManaCost)) {
// found compatible source - try to pay
if (canPayColoredMana && (cost instanceof ColoredManaCost)) {
for (Mana netMana : manaAbility.getNetMana(game)) {
if (cost.testPay(netMana)) {
if (netMana instanceof ConditionalMana && !((ConditionalMana) netMana).apply(ability, game, getId(), cost)) {
@ -1605,6 +1614,7 @@ public class ComputerPlayer extends PlayerImpl {
}
}
// use any other mana produces
for (MageObject mageObject : producers) {
// pay all colored costs first
for (ActivatedManaAbilityImpl manaAbility : getManaAbilitiesSortedByManaCount(mageObject, game)) {
@ -1723,6 +1733,7 @@ public class ComputerPlayer extends PlayerImpl {
// pay phyrexian life costs
if (cost.isPhyrexian()) {
alreadyTryingToPayPhyrexian = true;
// TODO: make sure it's thread safe and protected from modifications (cost/unpaid can be shared between AI simulation threads?)
boolean paidPhyrexian = cost.pay(ability, game, ability, playerId, false, null) || hasApprovingObject;
alreadyTryingToPayPhyrexian = false;
return paidPhyrexian;
@ -1956,29 +1967,33 @@ public class ComputerPlayer extends PlayerImpl {
chooseCreatureType(outcome, choice, game);
}
// choose the correct color to pay a spell
if (outcome == Outcome.PutManaInPool && choice.isManaColorChoice() && currentUnpaidMana != null) {
if (currentUnpaidMana.containsColor(ColoredManaSymbol.W) && choice.getChoices().contains("White")) {
// choose the correct color to pay a spell (use last unpaid ability for color hint)
ManaCost unpaid = null;
if (!lastUnpaidMana.isEmpty()) {
unpaid = new ArrayList<>(lastUnpaidMana.values()).get(lastUnpaidMana.size() - 1);
}
if (outcome == Outcome.PutManaInPool && unpaid != null && choice.isManaColorChoice()) {
if (unpaid.containsColor(ColoredManaSymbol.W) && choice.getChoices().contains("White")) {
choice.setChoice("White");
return true;
}
if (currentUnpaidMana.containsColor(ColoredManaSymbol.R) && choice.getChoices().contains("Red")) {
if (unpaid.containsColor(ColoredManaSymbol.R) && choice.getChoices().contains("Red")) {
choice.setChoice("Red");
return true;
}
if (currentUnpaidMana.containsColor(ColoredManaSymbol.G) && choice.getChoices().contains("Green")) {
if (unpaid.containsColor(ColoredManaSymbol.G) && choice.getChoices().contains("Green")) {
choice.setChoice("Green");
return true;
}
if (currentUnpaidMana.containsColor(ColoredManaSymbol.U) && choice.getChoices().contains("Blue")) {
if (unpaid.containsColor(ColoredManaSymbol.U) && choice.getChoices().contains("Blue")) {
choice.setChoice("Blue");
return true;
}
if (currentUnpaidMana.containsColor(ColoredManaSymbol.B) && choice.getChoices().contains("Black")) {
if (unpaid.containsColor(ColoredManaSymbol.B) && choice.getChoices().contains("Black")) {
choice.setChoice("Black");
return true;
}
if (currentUnpaidMana.getMana().getColorless() > 0 && choice.getChoices().contains("Colorless")) {
if (unpaid.getMana().getColorless() > 0 && choice.getChoices().contains("Colorless")) {
choice.setChoice("Colorless");
return true;
}
@ -2411,11 +2426,14 @@ public class ComputerPlayer extends PlayerImpl {
int deckMinSize = deckValidator != null ? deckValidator.getDeckMinSize() : 0;
if (deck != null && deck.getMaindeckCards().size() < deckMinSize && !deck.getSideboard().isEmpty()) {
if (chosenColors == null) {
if (chosenColors.isEmpty()) {
for (Card card : deck.getSideboard()) {
rememberPick(card, RateCard.rateCard(card, null));
rememberPick(card, RateCard.rateCard(card, Collections.emptyList()));
}
List<ColoredManaSymbol> deckColors = chooseDeckColorsIfPossible();
if (deckColors != null) {
chosenColors.addAll(deckColors);
}
chosenColors = chooseDeckColorsIfPossible();
}
deck = buildDeck(deckMinSize, new ArrayList<>(deck.getSideboard()), chosenColors);
}
@ -2525,7 +2543,7 @@ public class ComputerPlayer extends PlayerImpl {
if (pickedCardRate <= 30) {
// if card is bad
// try to counter pick without any color restriction
Card counterPick = pickBestCard(cards, null);
Card counterPick = pickBestCard(cards, Collections.emptyList());
int counterPickScore = RateCard.getBaseCardScore(counterPick);
// card is really good
// take it!
@ -2537,11 +2555,14 @@ public class ComputerPlayer extends PlayerImpl {
String colors = "not chosen yet";
// remember card if colors are not chosen yet
if (chosenColors == null) {
if (chosenColors.isEmpty()) {
rememberPick(bestCard, maxScore);
chosenColors = chooseDeckColorsIfPossible();
List<ColoredManaSymbol> chosen = chooseDeckColorsIfPossible();
if (chosen != null) {
chosenColors.addAll(chosen);
}
}
if (chosenColors != null) {
if (!chosenColors.isEmpty()) {
colors = "";
for (ColoredManaSymbol symbol : chosenColors) {
colors += symbol.toString();
@ -2612,7 +2633,7 @@ public class ComputerPlayer extends PlayerImpl {
}
if (colorsChosen.size() > 1) {
// no need to remember picks anymore
pickedCards = null;
pickedCards.clear();
return colorsChosen;
}
}
@ -2915,14 +2936,6 @@ public class ComputerPlayer extends PlayerImpl {
}
}
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
unplayable = new TreeMap<>();
playableNonInstant = new ArrayList<>();
playableInstant = new ArrayList<>();
playableAbilities = new ArrayList<>();
}
@Override
public void cleanUpOnMatchEnd() {
super.cleanUpOnMatchEnd();

View file

@ -1,8 +1,5 @@
package mage.cards.g;
import java.util.UUID;
import mage.abilities.effects.Effect;
import mage.abilities.effects.common.continuous.BoostTargetEffect;
import mage.cards.CardImpl;
@ -12,20 +9,21 @@ import mage.constants.Duration;
import mage.constants.Outcome;
import mage.target.common.TargetCreaturePermanent;
import java.util.UUID;
/**
*
* @author BetaSteward_at_googlemail.com
*/
public final class GiantGrowth extends CardImpl {
public GiantGrowth(UUID ownerId, CardSetInfo setInfo) {
super(ownerId,setInfo,new CardType[]{CardType.INSTANT},"{G}");
super(ownerId, setInfo, new CardType[]{CardType.INSTANT}, "{G}");
this.getSpellAbility().addTarget(new TargetCreaturePermanent());
// Target creature gets +3/+3 until end of turn.
Effect effect = new BoostTargetEffect(3, 3, Duration.EndOfTurn);
effect.setOutcome(Outcome.Benefit);
this.getSpellAbility().addEffect(effect);
this.getSpellAbility().addTarget(new TargetCreaturePermanent());
}
private GiantGrowth(final GiantGrowth card) {

View file

@ -16,13 +16,12 @@ public class SummonersEggTest extends CardTestPlayerBase {
/**
* Summoner's Egg
* Artifact Creature Construct 0/4, 4 (4)
* Imprint When Summoner's Egg enters the battlefield, you may exile a
* Imprint When Summoner's Egg enters the battlefield, you may exile a
* card from your hand face down.
* When Summoner's Egg dies, turn the exiled card face up. If it's a creature
* When Summoner's Egg dies, turn the exiled card face up. If it's a creature
* card, put it onto the battlefield under your control.
*
*/
// test that cards imprinted using Summoner's Egg are face down
@Test
public void testSummonersEggImprint() {
@ -32,6 +31,10 @@ public class SummonersEggTest extends CardTestPlayerBase {
addCard(Zone.BATTLEFIELD, playerA, "Island", 4);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Summoner's Egg");
setChoice(playerA, true); // use imprint
setChoice(playerA, "Goblin Roughrider");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
@ -40,7 +43,7 @@ public class SummonersEggTest extends CardTestPlayerBase {
assertHandCount(playerA, "Goblin Roughrider", 0);
assertExileCount("Goblin Roughrider", 1);
for (Card card :currentGame.getExile().getAllCards(currentGame)){
for (Card card : currentGame.getExile().getAllCards(currentGame)) {
if (card.getName().equals("Goblin Roughrider")) {
Assert.assertTrue("Exiled card is not face down", card.isFaceDown(currentGame));
}
@ -57,23 +60,26 @@ public class SummonersEggTest extends CardTestPlayerBase {
addCard(Zone.BATTLEFIELD, playerA, "Island", 4);
addCard(Zone.HAND, playerB, "Char");
addCard(Zone.BATTLEFIELD, playerB, "Mountain", 3);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Summoner's Egg");
setChoice(playerA, true); // use imprint
setChoice(playerA, "Goblin Roughrider");
castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerB, "Char", "Summoner's Egg");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertHandCount(playerA, 1);
assertHandCount(playerA, "Maritime Guard", 1);
assertHandCount(playerA, "Goblin Roughrider", 0);
assertGraveyardCount(playerA, "Summoner's Egg", 1);
assertExileCount("Goblin Roughrider", 0);
assertPermanentCount(playerA, "Goblin Roughrider", 1);
for (Permanent p :currentGame.getBattlefield().getAllActivePermanents()){
for (Permanent p : currentGame.getBattlefield().getAllActivePermanents()) {
if (p.getName().equals("Goblin Roughrider")) {
Assert.assertTrue("Permanent is not face up", !p.isFaceDown(currentGame));
}
@ -89,26 +95,29 @@ public class SummonersEggTest extends CardTestPlayerBase {
addCard(Zone.BATTLEFIELD, playerA, "Island", 4);
addCard(Zone.HAND, playerB, "Char");
addCard(Zone.BATTLEFIELD, playerB, "Mountain", 3);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Summoner's Egg");
setChoice(playerA, true); // use imprint
setChoice(playerA, "Forest");
castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerB, "Char", "Summoner's Egg");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertHandCount(playerA, 1);
assertHandCount(playerA, "Forest", 1);
assertGraveyardCount(playerA, "Summoner's Egg", 1);
assertExileCount("Forest", 1);
for (Card card :currentGame.getExile().getAllCards(currentGame)){
for (Card card : currentGame.getExile().getAllCards(currentGame)) {
if (card.getName().equals("Forest")) {
Assert.assertTrue("Exiled card is not face up", !card.isFaceDown(currentGame));
}
}
}
}

View file

@ -1,52 +0,0 @@
package org.mage.test.cards.mana;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Ignore;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author JayDi85
*/
public class EnergyRefractorManaCalculationsTest extends CardTestPlayerBase {
@Test
public void test_Single() {
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2);
// make sure it works
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}: ");
setChoice(playerA, "Red");
checkManaPool("mana", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "R", 1);
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
}
@Test
@Ignore // TODO: must fix infinite mana calculation
public void test_Multiple() {
int cardsAmount = 2; // after fix make it 10+ for testing
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", cardsAmount);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2 * cardsAmount);
runCode("simple way to cause freeze", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
player.getManaAvailable(game);
});
// make sure it works
//activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}: ");
//setChoice(playerA, "Red");
//checkManaPool("mana", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "R", 1);
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
}
}

View file

@ -0,0 +1,85 @@
package org.mage.test.cards.mana;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBaseWithAIHelps;
/**
* @author JayDi85
*/
public class InfiniteManaUsagesTest extends CardTestPlayerBaseWithAIHelps {
@Test
public void test_EnergyRefractor_Single_AddToPool() {
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2);
// make sure it works
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}: ");
setChoice(playerA, "Red");
checkManaPool("mana", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "R", 1);
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
}
@Test
public void test_EnergyRefractor_Multiple_AddToPool() {
int cardsAmount = 20;
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", cardsAmount);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2 * cardsAmount);
// make sure it works
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}: ");
setChoice(playerA, "Red");
checkManaPool("mana", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "R", 1);
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
}
@Test
public void test_EnergyRefractor_CastBears_Manual() {
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2 + 1);
//
addCard(Zone.HAND, playerA, "Grizzly Bears", 1); // {1}{G}
// make sure it works
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Grizzly Bears");
setChoice(playerA, "Green");
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
assertPermanentCount(playerA, "Grizzly Bears", 1);
}
@Test
public void test_EnergyRefractor_CastBears_AI() {
// possible bug: StackOverflowError on mana usage
// {2}: Add one mana of any color.
addCard(Zone.BATTLEFIELD, playerA, "Energy Refractor", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2 + 1);
//
addCard(Zone.HAND, playerA, "Grizzly Bears", 1); // {1}{G}
// ai must see playable card and cast it
aiPlayPriority(1, PhaseStep.PRECOMBAT_MAIN, playerA);
setStopAt(1, PhaseStep.END_TURN);
setStrictChooseMode(true);
execute();
assertPermanentCount(playerA, "Grizzly Bears", 1);
}
}

View file

@ -1,4 +1,3 @@
package org.mage.test.cards.triggers;
import mage.constants.PhaseStep;
@ -6,37 +5,40 @@ import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
*
* @author jeffwadsworth
*/
/**
* Selvala, Heart of the Wilds {1}{G}{G} Whenever another creature enters the
* battlefield, its controller may draw a card if its power is greater than each
* other creature's power Add X mana in any combination of colors to your mana
* pool, where X is the greatest power among creatures you control
*
* @author jeffwadsworth
*/
public class SelvalaHeartOfTheWildsTest extends CardTestPlayerBase {
@Test
public void testTrigger() {
// No card will be drawn due to the Memnite having a lower power than any other permanent on the battlefield
addCard(Zone.LIBRARY, playerA, "Island", 2);
public void test_NoTriggerOnLowerPower() {
skipInitShuffling();
addCard(Zone.LIBRARY, playerA, "Island", 1);
// Whenever another creature enters the battlefield, its controller may draw a card if its
// power is greater than each other creature's power.
addCard(Zone.BATTLEFIELD, playerA, "Selvala, Heart of the Wilds", 1); // 2/3
addCard(Zone.BATTLEFIELD, playerA, "Shivan Dragon", 1); // 5/5
//
addCard(Zone.HAND, playerA, "Memnite"); // 1/1
addCard(Zone.BATTLEFIELD, playerA, "Shivan Dragon", 1); // 5/5
addCard(Zone.BATTLEFIELD, playerB, "Blinking Spirit", 1); // 2/2
// Nightmare's power and toughness are each equal to the number of Swamps you control.
addCard(Zone.BATTLEFIELD, playerB, "Nightmare", 1); // 4/4
addCard(Zone.BATTLEFIELD, playerB, "Swamp", 4);
// cast low power memnite - no draw trigger
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Memnite");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertHandCount(playerA, 0); // no cards drawn
assertHandCount(playerA, 0); // no draw
}
/**
@ -44,40 +46,45 @@ public class SelvalaHeartOfTheWildsTest extends CardTestPlayerBase {
* pumps its power to the highest on the battlefield allowing the controller to draw a card.
*/
@Test
public void testTriggerWithGiantGrowth() {
addCard(Zone.LIBRARY, playerA, "Island", 2);
// Whenever another creature enters the battlefield, its controller may draw a card if its power is greater than each other creature's power.
// {G}, {T}: Add X mana in any combination of colors, where X is the greatest power among creatures you control.
public void test_TriggerOnBigBoosted() {
skipInitShuffling();
addCard(Zone.LIBRARY, playerA, "Island", 1);
// Whenever another creature enters the battlefield, its controller may draw a card if its
// power is greater than each other creature's power.
addCard(Zone.BATTLEFIELD, playerA, "Selvala, Heart of the Wilds", 1); // 2/3
addCard(Zone.BATTLEFIELD, playerA, "Shivan Dragon", 1); // 5/5
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
//
addCard(Zone.HAND, playerA, "Memnite"); // 1/1
addCard(Zone.HAND, playerA, "Giant Growth", 2);
addCard(Zone.BATTLEFIELD, playerA, "Shivan Dragon", 1); // 5/5
addCard(Zone.BATTLEFIELD, playerB, "Blinking Spirit", 1); // 2/2
// Flying
// Nightmare's power and toughness are each equal to the number of Swamps you control.
addCard(Zone.BATTLEFIELD, playerB, "Nightmare", 1); // 4/4
addCard(Zone.BATTLEFIELD, playerB, "Swamp", 4);
//
// Target creature gets +3/+3 until end of turn.
addCard(Zone.HAND, playerA, "Giant Growth", 2); // {G}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
// prepare etb trigger
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Memnite");
setChoice(playerA, "X=0");
setChoice(playerA, "X=0");
setChoice(playerA, "X=0");
setChoice(playerA, "X=0");
setChoice(playerA, "X=5");
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN ,1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Giant Growth", "Memnite");
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Giant Growth", "Memnite"); // a whopping 7/7
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, 1);
checkStackSize("must have etb", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 1);
checkStackObject("must have etb", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Whenever another creature", 1);
setChoice(playerA, true);
// boost before trigger resolve (make memnite 7/7)
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Giant Growth", "Memnite", "Whenever another creature");
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Giant Growth", "Memnite", "Whenever another creature");
checkStackSize("must have etb + boost spells", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 3);
// trigger on grater power
setChoice(playerA, true); // draw card
setStrictChooseMode(true);
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPowerToughness(playerA, "Memnite", 7, 7);
assertGraveyardCount(playerA, "Giant Growth", 2);
assertHandCount(playerA, 1); // 2 cards drawn
assertHandCount(playerA, "Island", 1); // after draw
}
}

View file

@ -0,0 +1,115 @@
package org.mage.test.utils;
import mage.Mana;
import mage.abilities.costs.mana.ManaCostsImpl;
import org.junit.Assert;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author JayDi85
*/
public class ManaIncludesTest extends CardTestPlayerBase {
private void assertFullyIncludes(boolean canPay, String cost, String pool) {
// workaround to add {Any} mana by string param
String strictMain = pool.replace("{Any}", "");
String stringPart = cost.replace("{Any}", "");
Mana manaMain = new ManaCostsImpl<>(strictMain).getMana();
Mana manaPart = new ManaCostsImpl<>(stringPart).getMana();
manaMain.add(new Mana(0, 0, 0, 0, 0, 0, (pool.length() - strictMain.length()) / 5, 0));
manaPart.add(new Mana(0, 0, 0, 0, 0, 0, (cost.length() - stringPart.length()) / 5, 0));
Assert.assertEquals(canPay, manaMain.includesMana(manaPart));
}
@Test
public void test_ManaIncludes() {
assertFullyIncludes(true, "", "");
assertFullyIncludes(true, "", "{R}");
assertFullyIncludes(true, "", "{C}");
assertFullyIncludes(true, "", "{1}");
assertFullyIncludes(true, "", "{Any}");
assertFullyIncludes(true, "", "{R}{G}");
assertFullyIncludes(true, "", "{C}{G}");
assertFullyIncludes(true, "", "{1}{G}");
assertFullyIncludes(true, "", "{Any}{G}");
assertFullyIncludes(false, "{B}", "");
assertFullyIncludes(false, "{B}", "{R}");
assertFullyIncludes(false, "{B}", "{C}");
assertFullyIncludes(false, "{B}", "{1}");
assertFullyIncludes(true, "{B}", "{Any}");
assertFullyIncludes(false, "{B}", "{R}{G}");
assertFullyIncludes(false, "{B}", "{C}{G}");
assertFullyIncludes(false, "{B}", "{1}{G}");
assertFullyIncludes(true, "{B}", "{Any}{G}");
assertFullyIncludes(false, "{G}", "");
assertFullyIncludes(false, "{G}", "{R}");
assertFullyIncludes(false, "{G}", "{C}");
assertFullyIncludes(false, "{G}", "{1}");
assertFullyIncludes(true, "{G}", "{Any}");
assertFullyIncludes(true, "{G}", "{R}{G}");
assertFullyIncludes(true, "{G}", "{C}{G}");
assertFullyIncludes(true, "{G}", "{1}{G}");
assertFullyIncludes(true, "{G}", "{Any}{G}");
assertFullyIncludes(false, "{C}", "");
assertFullyIncludes(false, "{C}", "{R}");
assertFullyIncludes(true, "{C}", "{C}");
assertFullyIncludes(false, "{C}", "{1}");
assertFullyIncludes(false, "{C}", "{Any}");
assertFullyIncludes(false, "{C}", "{R}{G}");
assertFullyIncludes(true, "{C}", "{C}{G}");
assertFullyIncludes(false, "{C}", "{1}{G}");
assertFullyIncludes(false, "{C}", "{Any}{G}");
assertFullyIncludes(false, "{1}", "");
assertFullyIncludes(true, "{1}", "{R}");
assertFullyIncludes(true, "{1}", "{C}");
assertFullyIncludes(true, "{1}", "{1}");
assertFullyIncludes(true, "{1}", "{Any}");
assertFullyIncludes(true, "{1}", "{R}{G}");
assertFullyIncludes(true, "{1}", "{C}{G}");
assertFullyIncludes(true, "{1}", "{1}{G}");
assertFullyIncludes(true, "{1}", "{Any}{G}");
assertFullyIncludes(false, "{Any}", "");
assertFullyIncludes(false, "{Any}", "{R}");
assertFullyIncludes(false, "{Any}", "{C}");
assertFullyIncludes(false, "{Any}", "{1}");
assertFullyIncludes(true, "{Any}", "{Any}");
assertFullyIncludes(false, "{Any}", "{R}{G}");
assertFullyIncludes(false, "{Any}", "{C}{G}");
assertFullyIncludes(false, "{Any}", "{1}{G}");
assertFullyIncludes(true, "{Any}", "{Any}{G}");
// possible integer overflow problems
String maxGeneric = "{" + Integer.MAX_VALUE + "}";
assertFullyIncludes(false, maxGeneric, "");
assertFullyIncludes(false, maxGeneric, "{1}");
assertFullyIncludes(false, maxGeneric, "{R}");
assertFullyIncludes(true, "", maxGeneric);
assertFullyIncludes(true, "{1}", maxGeneric);
assertFullyIncludes(false, "{R}", maxGeneric);
assertFullyIncludes(true, maxGeneric, maxGeneric);
// data from infinite mana calcs bug
assertFullyIncludes(false, "{R}{R}", "{R}");
assertFullyIncludes(true, "{R}{R}", "{R}{R}");
assertFullyIncludes(true, "{R}{R}", "{R}{R}{R}");
assertFullyIncludes(true, "{R}{R}", "{R}{R}{R}{R}");
assertFullyIncludes(true, "{R}{R}", "{R}{R}{R}{R}{R}");
assertFullyIncludes(false, "{Any}{Any}", "{Any}");
assertFullyIncludes(true, "{Any}{Any}", "{Any}{Any}");
assertFullyIncludes(true, "{Any}{Any}", "{Any}{Any}{Any}");
assertFullyIncludes(true, "{Any}{Any}", "{Any}{Any}{Any}{Any}");
assertFullyIncludes(true, "{Any}{Any}", "{Any}{Any}{Any}{Any}{Any}");
// additional use cases
assertFullyIncludes(false, "{W}{W}", "{W}{B}");
}
}

View file

@ -2765,7 +2765,7 @@ public class VerifyCardDataTest {
List<Card> cardsList = new ArrayList<>(CardScanner.getAllCards());
Map<String, Integer> cardRates = new HashMap<>();
for (Card card : cardsList) {
int curRate = RateCard.rateCard(card, null, false);
int curRate = RateCard.rateCard(card, Collections.emptyList(), false);
int prevRate = cardRates.getOrDefault(card.getName(), 0);
if (prevRate == 0) {
cardRates.putIfAbsent(card.getName(), curRate);

View file

@ -9,17 +9,20 @@ import mage.util.Copyable;
import org.apache.log4j.Logger;
import java.io.Serializable;
import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiFunction;
/**
* WARNING, all mana operations must use overflow check, see usage of CardUtil.addWithOverflowCheck and same methods
*
* @author BetaSteward_at_googlemail.com
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class Mana implements Comparable<Mana>, Serializable, Copyable<Mana> {
private static final transient Logger logger = Logger.getLogger(Mana.class);
private static final Logger logger = Logger.getLogger(Mana.class);
protected int white;
protected int blue;
@ -442,6 +445,20 @@ public class Mana implements Comparable<Mana>, Serializable, Copyable<Mana> {
any = CardUtil.overflowDec(any, mana.any);
}
/**
* Mana must contains only positive values
*/
public boolean isValid() {
return white >= 0
&& blue >= 0
&& black >= 0
&& red >= 0
&& green >= 0
&& generic >= 0
&& colorless >= 0
&& any >= 0;
}
/**
* Subtracts the passed in mana values from this instance. The difference
* between this and {@code subtract()} is that if we do not have the
@ -1211,24 +1228,99 @@ public class Mana implements Comparable<Mana>, Serializable, Copyable<Mana> {
}
/**
* Returns if this {@link Mana} object has more than or equal values of mana
* as the passed in {@link Mana} object. Ignores {Any} mana to prevent
* endless iterations.
*
* @param mana the mana to compare with
* @return if this object has more than or equal mana to the passed in
* {@link Mana}.
* Compare two mana - is one part includes into another part. Support any mana types and uses a payment logic.
* <p>
* Used for AI and mana optimizations to remove duplicated mana options.
*/
public boolean includesMana(Mana mana) {
return this.white >= mana.white
&& this.blue >= mana.blue
&& this.black >= mana.black
&& this.red >= mana.red
&& this.green >= mana.green
&& this.colorless >= mana.colorless
&& (this.generic >= mana.generic
|| CardUtil.overflowInc(this.countColored(), this.colorless) >= mana.count());
public boolean includesMana(Mana manaPart) {
if (!this.isValid() || !manaPart.isValid()) {
// how-to fix: make sure mana calculations do not add or subtract values without result checks or isValid call
throw new IllegalArgumentException("Wrong code usage: found negative values in mana calculations: main " + this + ", part " + manaPart);
}
if (this.count() < manaPart.count()) {
return false;
}
if (manaPart.count() == 0) {
return true;
}
// it's uses pay logic with additional {any} mana support:
// - {any} in cost - can be paid by {any} mana only
// - {any} in pay - can be used to pay {any}, {1} and colored mana (but not {C})
Mana pool = this.copy();
Mana cost = manaPart.copy();
// first pay type by type (it's important to pay {any} first)
// 10 - 3 = 7 in pool, 0 in cost
// 5 - 7 = 0 in pool, 2 in cost
pool.subtract(cost);
cost.white = Math.max(0, -1 * pool.white);
pool.white = Math.max(0, pool.white);
cost.blue = Math.max(0, -1 * pool.blue);
pool.blue = Math.max(0, pool.blue);
cost.black = Math.max(0, -1 * pool.black);
pool.black = Math.max(0, pool.black);
cost.red = Math.max(0, -1 * pool.red);
pool.red = Math.max(0, pool.red);
cost.green = Math.max(0, -1 * pool.green);
pool.green = Math.max(0, pool.green);
cost.generic = Math.max(0, -1 * pool.generic);
pool.generic = Math.max(0, pool.generic);
cost.colorless = Math.max(0, -1 * pool.colorless);
pool.colorless = Math.max(0, pool.colorless);
cost.any = Math.max(0, -1 * pool.any);
pool.any = Math.max(0, pool.any);
if (cost.count() > pool.count()) {
throw new IllegalArgumentException("Wrong mana calculation: " + cost + " - " + pool);
}
// can't pay {any} or {C}
if (cost.any > 0 || cost.colorless > 0) {
return false;
}
// then pay colored by {any}
if (pool.any > 0 && cost.white > 0) {
int diff = Math.min(pool.any, cost.white);
pool.any -= diff;
cost.white -= diff;
}
if (pool.any > 0 && cost.blue > 0) {
int diff = Math.min(pool.any, cost.blue);
pool.any -= diff;
cost.blue -= diff;
}
if (pool.any > 0 && cost.black > 0) {
int diff = Math.min(pool.any, cost.black);
pool.any -= diff;
cost.black -= diff;
}
if (pool.any > 0 && cost.red > 0) {
int diff = Math.min(pool.any, cost.red);
pool.any -= diff;
cost.red -= diff;
}
if (pool.any > 0 && cost.green > 0) {
int diff = Math.min(pool.any, cost.green);
pool.any -= diff;
cost.green -= diff;
}
// can't pay colored
if (cost.countColored() > 0) {
return false;
}
// then pay generic by {any}, colored or {C}
int leftPool = pool.count();
if (leftPool > 0 && cost.generic > 0) {
int diff = Math.min(leftPool, cost.generic);
cost.generic -= diff;
}
return cost.count() == 0;
}
public boolean isMoreValuableThan(Mana that) {
@ -1350,19 +1442,19 @@ public class Mana implements Comparable<Mana>, Serializable, Copyable<Mana> {
public int getDifferentColors() {
int count = 0;
if (white > 0) {
count = CardUtil.overflowInc(count, 1);
count++;
}
if (blue > 0) {
count = CardUtil.overflowInc(count, 1);
count++;
}
if (black > 0) {
count = CardUtil.overflowInc(count, 1);
count++;
}
if (red > 0) {
count = CardUtil.overflowInc(count, 1);
count++;
}
if (green > 0) {
count = CardUtil.overflowInc(count, 1);
count++;
}
return count;
}

View file

@ -14,7 +14,6 @@ import org.apache.log4j.Logger;
import java.util.*;
/**
* @author BetaSteward_at_googlemail.com
* <p>
* this class is used to build a list of all possible mana combinations it can
* be used to find all the ways to pay a mana cost or all the different mana
@ -25,6 +24,8 @@ import java.util.*;
* <p>
* A LinkedHashSet is used to get the performance benefits of automatic de-duplication of the Mana
* to avoid performance issues related with manual de-duplication (see https://github.com/magefree/mage/issues/7710).
*
* @author BetaSteward_at_googlemail.com, JayDi85
*/
public class ManaOptions extends LinkedHashSet<Mana> {
@ -378,54 +379,69 @@ public class ManaOptions extends LinkedHashSet<Mana> {
/**
* Performs the simulation of a mana ability with costs
*
* @param cost cost to use the ability
* @param manaToAdd one mana variation that can be added by using
* this ability
* @param onlyManaCosts flag to know if the costs are mana costs only
* @param currentMana the mana available before the usage of the
* ability
* @param oldManaWasReplaced returns the info if the new complete mana does
* replace the current mana completely
* @param cost cost to use the ability
* @param manaToAdd one mana variation that can be added by using
* this ability
* @param onlyManaCosts flag to know if the costs are mana costs only (will try to use ability multiple times)
* @param startingMana the mana available before the usage of the
* ability
* @return true if the new complete mana does replace the current mana completely
*/
private boolean subtractCostAddMana(Mana cost, Mana manaToAdd, boolean onlyManaCosts, final Mana currentMana, ManaAbility manaAbility, Game game) {
private boolean subtractCostAddMana(Mana cost, Mana manaToAdd, boolean onlyManaCosts, final Mana startingMana, ManaAbility manaAbility, Game game) {
boolean oldManaWasReplaced = false; // True if the newly created mana includes all mana possibilities of the old
boolean repeatable = manaToAdd != null // TODO: re-write "only replace to any with mana costs only will be repeated if able"
&& onlyManaCosts
&& (manaToAdd.getAny() > 0 || manaToAdd.countColored() > 0)
&& manaToAdd.count() > 0;
boolean newCombinations;
&& manaToAdd.countColored() > 0;
boolean canHaveBetterValues;
Mana newMana = new Mana();
Mana currentManaCopy = new Mana();
Mana possibleMana = new Mana();
Mana improvedMana = new Mana();
for (Mana payCombination : ManaOptions.getPossiblePayCombinations(cost, currentMana)) {
currentManaCopy.setToMana(currentMana); // copy start mana because in iteration it will be updated
do { // loop for multiple usage if possible
newCombinations = false;
// simulate multiple calls of mana abilities and replace mana pool by better values
// example: {G}: Add one mana of any color
for (Mana possiblePay : ManaOptions.getPossiblePayCombinations(cost, startingMana)) {
improvedMana.setToMana(startingMana);
do {
// loop until all mana replaced by better values
canHaveBetterValues = false;
newMana.setToMana(currentManaCopy);
newMana.subtract(payCombination);
// Get the mana to iterate over.
// If manaToAdd is specified add it, otherwise add the mana produced by the mana ability
List<Mana> manasToAdd = (manaToAdd != null) ? Collections.singletonList(manaToAdd) : manaAbility.getNetMana(game, newMana);
for (Mana mana : manasToAdd) {
newMana.add(mana);
if (this.contains(newMana)) {
// it's impossible to analyse all payment order (pay {R} for {1}, {Any} for {G}, etc)
// so use simple cost simulation by subtract
possibleMana.setToMana(improvedMana);
possibleMana.subtract(possiblePay);
if (!possibleMana.isValid()) {
//if (possibleMana.canPayMana(possiblePay)) {
// TODO: canPayMana/includesMana uses better pay logic, so subtract can be improved somehow
//logger.warn("found un-supported payment combination: pool " + possibleMana + ", cost " + possiblePay);
//}
continue;
}
// find resulting mana (it can have multiple options)
List<Mana> addingManaOptions = (manaToAdd != null) ? Collections.singletonList(manaToAdd) : manaAbility.getNetMana(game, possibleMana);
for (Mana addingMana : addingManaOptions) {
// TODO: is it bugged on addingManaOptions.size() > 1 (adding multiple times)?
possibleMana.add(addingMana);
// already found that combination before
if (this.contains(possibleMana)) {
continue;
}
this.add(newMana.copy()); // add the new combination
newCombinations = true; // repeat the while as long there are new combinations and usage is repeatable
// found new combination - add it to final options
this.add(possibleMana.copy());
canHaveBetterValues = true;
if (newMana.isMoreValuableThan(currentManaCopy)) {
oldManaWasReplaced = true; // the new mana includes all possible mana of the old one, so no need to add it after return
if (!currentMana.equalManaValue(currentManaCopy)) {
this.removeEqualMana(currentManaCopy);
// remove old worse options
if (possibleMana.isMoreValuableThan(improvedMana)) {
oldManaWasReplaced = true;
if (!startingMana.equalManaValue(improvedMana)) {
this.removeEqualMana(improvedMana);
}
}
currentManaCopy.setToMana(newMana);
improvedMana.setToMana(possibleMana);
}
} while (repeatable && newCombinations && currentManaCopy.includesMana(payCombination));
} while (repeatable && canHaveBetterValues && improvedMana.includesMana(possiblePay));
}
return oldManaWasReplaced;
}