* GUI: added card icon for announced X value (card cast);

This commit is contained in:
Oleg Agafonov 2021-07-19 13:40:21 +04:00
parent fc0ff6c22d
commit 76082e1d7a
9 changed files with 353 additions and 12 deletions

View file

@ -7,10 +7,12 @@ import mage.abilities.Abilities;
import mage.abilities.Ability;
import mage.abilities.Mode;
import mage.abilities.SpellAbility;
import mage.abilities.dynamicvalue.common.ManacostVariableValue;
import mage.abilities.effects.Effect;
import mage.abilities.effects.Effects;
import mage.abilities.icon.CardIcon;
import mage.abilities.icon.other.FaceDownStatusIcon;
import mage.abilities.icon.other.FaceDownCardIcon;
import mage.abilities.icon.other.VariableCostCardIcon;
import mage.abilities.keyword.AftermathAbility;
import mage.cards.*;
import mage.cards.mock.MockCard;
@ -369,7 +371,7 @@ public class CardView extends SimpleCardView {
this.manaCostRightStr = String.join("", mainCard.getRightHalfCard().getManaCostSymbols());
} else if (card instanceof AdventureCard) {
AdventureCard adventureCard = ((AdventureCard) card);
AdventureCardSpell adventureCardSpell = ((AdventureCardSpell) adventureCard.getSpellCard());
AdventureCardSpell adventureCardSpell = adventureCard.getSpellCard();
fullCardName = adventureCard.getName() + MockCard.ADVENTURE_NAME_SEPARATOR + adventureCardSpell.getName();
this.manaCostLeftStr = String.join("", adventureCardSpell.getManaCostSymbols());
this.manaCostRightStr = String.join("", adventureCard.getManaCostSymbols());
@ -420,11 +422,10 @@ public class CardView extends SimpleCardView {
permanent.getAbilities(game).forEach(ability -> {
this.cardIcons.addAll(ability.getIcons(game));
});
// other
// face down
if (permanent.isFaceDown(game)) {
this.cardIcons.add(FaceDownStatusIcon.instance);
this.cardIcons.add(FaceDownCardIcon.instance);
}
} else {
if (card.isCopy()) {
this.mageObjectType = MageObjectType.COPY_CARD;
@ -439,6 +440,25 @@ public class CardView extends SimpleCardView {
}
}
}
// card icons for any permanents and cards
if (game != null) {
// x cost
Zone cardZone = game.getState().getZone(card.getId());
if (card.getManaCost().containsX()
&& (cardZone.match(Zone.BATTLEFIELD) || cardZone.match(Zone.STACK))) {
int costX;
if (card instanceof Permanent) {
// permanent on battlefield
costX = ManacostVariableValue.ETB.calculate(game, card.getSpellAbility(), null);
} else {
// other like Stack
costX = ManacostVariableValue.REGULAR.calculate(game, card.getSpellAbility(), null);
}
this.cardIcons.add(new VariableCostCardIcon(costX));
}
}
this.power = Integer.toString(card.getPower().getValue());
this.toughness = Integer.toString(card.getToughness().getValue());
this.cardTypes = card.getCardType(game);

View file

@ -9,6 +9,7 @@ import mage.cards.SplitCard;
import mage.cards.repository.CardRepository;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import mage.counters.CounterType;
import mage.util.CardUtil;
import org.junit.Assert;
import org.junit.Test;
@ -522,6 +523,138 @@ public class CopySpellTest extends CardTestPlayerBase {
assertAllCommandsUsed();
}
@Test
public void test_CopiedSpellsAndX_1() {
// testing:
// 1. x in copied instant spell (copy X)
// 2. x in copied creature (X=0)
// test use case with rules:
// https://tappedout.net/mtg-questions/copying-a-creature-with-x-in-its-mana-cost/#c3561513
// 107.3f If a card in any zone other than the stack has an {X} in its mana cost, the value of {X} is
// treated as 0, even if the value of X is defined somewhere within its text.
// Whenever you cast an instant or sorcery spell, you may pay {U}{R}. If you do, copy that spell. You may choose new targets for the copy.
// Whenever another nontoken creature enters the battlefield under your control, you may pay {G}{U}. If you do, create a token thats a copy of that creature.
addCard(Zone.BATTLEFIELD, playerA, "Riku of Two Reflections", 1);
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
addCard(Zone.BATTLEFIELD, playerA, "Island", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
// Banefire deals X damage to any target.
addCard(Zone.HAND, playerA, "Banefire", 1); // {X}{R}
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3);
//
// 0/0
// Capricopian enters the battlefield with X +1/+1 counters on it.
addCard(Zone.HAND, playerA, "Capricopian", 1); // {X}{G}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
// 1
// cast banefire and make copy
// announced X=2 must be copied
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {R}", 3);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Banefire", playerB);
setChoice(playerA, "X=2");
setChoice(playerA, "Yes"); // make copy
setChoice(playerA, "No"); // keep target same
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkLife("after spell copy", 1, PhaseStep.PRECOMBAT_MAIN, playerB, 20 - 2 * 2);
// 2
// cast creature and copy it as token
// token must have x=0 (dies)
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Capricopian");
setChoice(playerA, "X=1");
setChoice(playerA, "Yes"); // make copy
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after creature copy", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Capricopian", 1);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
@Test
public void test_CopiedSpellsHasntETB() {
// testing:
// - x in copied creature spell (copy x)
// - copied spells enters as tokens and it hasn't ETB, see rules below
// 0/0
// Capricopian enters the battlefield with X +1/+1 counters on it.
addCard(Zone.HAND, playerA, "Capricopian", 1); // {X}{G}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 3);
//
// Grenzo, Dungeon Warden enters the battlefield with X +1/+1 counters on it.
addCard(Zone.HAND, playerA, "Grenzo, Dungeon Warden", 1);// {X}{B}{R}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
// Copy target creature spell you control, except it isn't legendary if the spell is legendary.
// (A copy of a creature spell becomes a token.)
addCard(Zone.HAND, playerA, "Double Major", 2); // {G}{U}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
addCard(Zone.BATTLEFIELD, playerA, "Island", 2);
// 1. Capricopian
// cast and put on stack
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 3);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Capricopian");
setChoice(playerA, "X=2");
// copy of spell
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 1);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Double Major", "Capricopian", "Capricopian");
// 2. Grenzo, Dungeon Warden
// cast and put on stack
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 2);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 1);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {R}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Grenzo, Dungeon Warden");
setChoice(playerA, "X=2");
// copy of spell
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 1);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Double Major", "Grenzo, Dungeon Warden", "Grenzo, Dungeon Warden");
// ETB triggers will not trigger here due not normal cast. From rules:
// - The token that a resolving copy of a spell becomes isnt said to have been created. (2021-04-16)
// - A nontoken permanent enters the battlefield when its moved onto the battlefield from another zone.
// A token enters the battlefield when its created. See rules 403.3, 603.6a, 603.6d, and 614.12.
//
// So both copies enters without counters:
// - Capricopian copy must die
// - Grenzo, Dungeon Warden must have default PT
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Capricopian", 1); // copy dies
checkPermanentCount("after", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Grenzo, Dungeon Warden", 2);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
// counters checks
int originalCounters = currentGame.getBattlefield().getAllActivePermanents().stream()
.filter(p -> p.getName().equals("Grenzo, Dungeon Warden"))
.filter(p -> !p.isCopy())
.mapToInt(p -> p.getCounters(currentGame).getCount(CounterType.P1P1))
.sum();
int copyCounters = currentGame.getBattlefield().getAllActivePermanents().stream()
.filter(p -> p.getName().equals("Grenzo, Dungeon Warden"))
.filter(p -> p.isCopy())
.mapToInt(p -> p.getCounters(currentGame).getCount(CounterType.P1P1))
.sum();
Assert.assertEquals("original grenzo must have 2x counters", 2, originalCounters);
Assert.assertEquals("copied grenzo must have 0x counters", 0, copyCounters);
}
@Test
public void test_SimpleCopy_Card() {
Card sourceCard = CardRepository.instance.findCard("Grizzly Bears").getCard();
@ -628,7 +761,7 @@ public class CopySpellTest extends CardTestPlayerBase {
return;
}
Assert.fail(infoPrefix + " - " + "ability source must be same: " + ability.toString());
Assert.fail(infoPrefix + " - " + "ability source must be same: " + ability);
}
});
}

View file

@ -0,0 +1,155 @@
package org.mage.test.serverside;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import mage.view.CardView;
import mage.view.GameView;
import mage.view.PlayerView;
import org.junit.Assert;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* GUI tests: card icons for cards
*
* @author JayDi85
*/
public class CardIconsTest extends CardTestPlayerBase {
@Test
public void test_CostX_Spells() {
// Chalice of the Void enters the battlefield with X charge counters on it.
// Whenever a player casts a spell with converted mana cost equal to the number of charge counters on Chalice of the Void, counter that spell.
addCard(Zone.HAND, playerA, "Chalice of the Void", 1); // {X}{X}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 4);
// hand (not visible)
runCode("card icons in hand", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
GameView gameView = getGameView(player);
Assert.assertEquals("must have 1 card in hand", 1, gameView.getHand().values().size());
CardView cardView = gameView.getHand().values().stream().findFirst().get();
Assert.assertEquals("must have non x cost card icons in hand", 0, cardView.getCardIcons().size());
});
// cast and put on stack
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Chalice of the Void");
setChoice(playerA, "X=2");
// stack (visible)
runCode("card icons on stack (spell)", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
GameView gameView = getGameView(player);
Assert.assertEquals("must have 1 card in stack", 1, gameView.getStack().values().size());
CardView cardView = gameView.getStack().values().stream().findFirst().get();
Assert.assertEquals("must have x cost card icons in stack", 1, cardView.getCardIcons().size());
Assert.assertEquals("x cost text", "x=2", cardView.getCardIcons().get(0).getText());
});
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after cast", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Chalice of the Void", 1);
// battlefield (card, not visible)
runCode("card icons in battlefield (card)", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
GameView gameView = getGameView(player);
PlayerView playerView = gameView.getPlayers().get(0);
Assert.assertEquals("player", player.getName(), playerView.getName());
CardView cardView = playerView.getBattlefield().values().stream().filter(p -> p.getName().equals("Chalice of the Void")).findFirst().orElse(null);
Assert.assertNotNull("must have 1 chalice in battlefield", cardView);
Assert.assertEquals("must have x cost card icons in battlefield (card)", 1, cardView.getCardIcons().size());
Assert.assertEquals("x cost text", "x=2", cardView.getCardIcons().get(0).getText());
});
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
@Test
public void test_CostX_Copies() {
// Grenzo, Dungeon Warden enters the battlefield with X +1/+1 counters on it.
addCard(Zone.HAND, playerA, "Grenzo, Dungeon Warden", 1);// {X}{B}{R}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 2);
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1);
addCard(Zone.BATTLEFIELD, playerA, "Mountain", 1);
//
// Copy target creature spell you control, except it isn't legendary if the spell is legendary.
addCard(Zone.HAND, playerA, "Double Major", 1); // {G}{U}
addCard(Zone.BATTLEFIELD, playerA, "Forest", 1);
addCard(Zone.BATTLEFIELD, playerA, "Island", 1);
// cast and put on stack
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 2);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {B}", 1);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {R}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Grenzo, Dungeon Warden");
setChoice(playerA, "X=2");
// prepare copy of spell
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {G}", 1);
activateManaAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: Add {U}", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Double Major", "Grenzo, Dungeon Warden", "Grenzo, Dungeon Warden");
checkStackSize("before copy spell", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2);
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, playerA, true);
checkStackSize("after copy spell", 1, PhaseStep.PRECOMBAT_MAIN, playerA, 2);
// stack (copied spell)
runCode("card icons on stack (copied spell)", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
GameView gameView = getGameView(player);
Assert.assertEquals("must have 2 cards in stack", 2, gameView.getStack().values().size());
CardView originalCardView = gameView.getStack().values()
.stream()
.filter(c -> !c.getOriginalCard().isCopy())
.findFirst()
.get();
CardView copiedCardView = gameView.getStack().values()
.stream()
.filter(c -> c.getOriginalCard().isCopy())
.findFirst()
.get();
Assert.assertNotNull("stack must have original spell", originalCardView);
Assert.assertNotNull("stack must have copied spell", copiedCardView);
Assert.assertNotEquals("must find two spells on stack", originalCardView.getId(), copiedCardView.getId());
Assert.assertEquals("original spell must have x cost card icons", 1, originalCardView.getCardIcons().size());
Assert.assertEquals("copied spell must have x cost card icons", 1, copiedCardView.getCardIcons().size());
Assert.assertEquals("original x cost text", "x=2", originalCardView.getCardIcons().get(0).getText());
Assert.assertEquals("copied x cost text", "x=2", copiedCardView.getCardIcons().get(0).getText());
});
// must resolve copied creature spell as a token
waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN);
checkPermanentCount("after cast", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Grenzo, Dungeon Warden", 2);
// battlefield (card and copied card as token)
runCode("card icons in battlefield (copied)", 1, PhaseStep.PRECOMBAT_MAIN, playerA, (info, player, game) -> {
GameView gameView = getGameView(player);
PlayerView playerView = gameView.getPlayers().get(0);
Assert.assertEquals("player", player.getName(), playerView.getName());
// copied spell goes as token to battlefield, not copied card - so must check isToken
// original
CardView originalCardView = playerView.getBattlefield().values()
.stream()
.filter(p -> p.getName().equals("Grenzo, Dungeon Warden"))
.filter(p -> !p.isToken())
.findFirst()
.orElse(null);
Assert.assertNotNull("original card must be in battlefield", originalCardView);
Assert.assertEquals("original must have x cost card icons", 1, originalCardView.getCardIcons().size());
Assert.assertEquals("original x cost text", "x=2", originalCardView.getCardIcons().get(0).getText());
//
CardView copiedCardView = playerView.getBattlefield().values()
.stream()
.filter(p -> p.getName().equals("Grenzo, Dungeon Warden"))
.filter(p -> p.isToken())
.findFirst()
.orElse(null);
Assert.assertNotNull("copied card must be in battlefield", copiedCardView);
Assert.assertEquals("copied must have x cost card icons", 1, copiedCardView.getCardIcons().size());
Assert.assertEquals("copied x cost text", "x=0", copiedCardView.getCardIcons().get(0).getText());
});
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertAllCommandsUsed();
}
}

View file

@ -7,7 +7,9 @@ import mage.game.Game;
import mage.watchers.common.ManaSpentToCastWatcher;
public enum ManacostVariableValue implements DynamicValue {
REGULAR, ETB;
REGULAR, // if you need X on cast/activate (in stack)
ETB; // if you need X after ETB (in battlefield)
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
@ -15,7 +17,10 @@ public enum ManacostVariableValue implements DynamicValue {
return sourceAbility.getManaCostsToPay().getX();
}
ManaSpentToCastWatcher watcher = game.getState().getWatcher(ManaSpentToCastWatcher.class);
return watcher != null ? watcher.getAndResetLastXValue(sourceAbility.getSourceId()) : sourceAbility.getManaCostsToPay().getX();
if (watcher != null) {
return watcher.getAndResetLastXValue(sourceAbility);
}
return 0;
}
@Override

View file

@ -32,6 +32,7 @@ public enum CardIconType {
ABILITY_CLASS_LEVEL("prepared/hexagon-fill.svg", CardIconCategory.ABILITY, 100),
//
OTHER_FACEDOWN("prepared/reply-fill.svg", CardIconCategory.ABILITY, 100),
OTHER_COST_X("prepared/square-fill.svg", CardIconCategory.ABILITY, 100),
//
SYSTEM_COMBINED("prepared/square-fill.svg", CardIconCategory.SYSTEM, 1000), // inner usage, must use last order
SYSTEM_DEBUG("prepared/link.svg", CardIconCategory.SYSTEM, 1000); // used for test render dialog

View file

@ -6,7 +6,7 @@ import mage.abilities.icon.CardIconType;
/**
* @author JayDi85
*/
public enum FaceDownStatusIcon implements CardIcon {
public enum FaceDownCardIcon implements CardIcon {
instance;
@Override

View file

@ -0,0 +1,16 @@
package mage.abilities.icon.other;
import mage.abilities.icon.CardIconImpl;
import mage.abilities.icon.CardIconType;
/**
* Showing x cost value
*
* @author JayDi85
*/
public class VariableCostCardIcon extends CardIconImpl {
public VariableCostCardIcon(int costX) {
super(CardIconType.OTHER_COST_X, "Announced X = " + costX, "x=" + costX);
}
}

View file

@ -81,7 +81,11 @@ public class CopyTokenFunction implements Function<Token, Card> {
for (Ability ability0 : sourceObj.getAbilities()) {
Ability ability = ability0.copy();
ability.newOriginalId(); // The token is independant from the copy from object so it need a new original Id, otherwise there are problems to check for created continuous effects to check if the source (the Token) has still this ability
// The token is independant from the copy from object so it need a new original Id,
// otherwise there are problems to check for created continuous effects to check if
// the source (the Token) has still this ability
ability.newOriginalId();
target.addAbility(ability);
}

View file

@ -1,6 +1,7 @@
package mage.watchers.common;
import mage.Mana;
import mage.abilities.Ability;
import mage.constants.WatcherScope;
import mage.constants.Zone;
import mage.game.Game;
@ -51,8 +52,14 @@ public class ManaSpentToCastWatcher extends Watcher {
return manaMap.getOrDefault(sourceId, null);
}
public int getAndResetLastXValue(UUID sourceId) {
return xValueMap.getOrDefault(sourceId, 0);
public int getAndResetLastXValue(Ability source) {
if (xValueMap.containsKey(source.getSourceId())) {
// cast normal way
return xValueMap.get(source.getSourceId());
} else {
// put to battlefield without cast (example: copied spell must keep announced X)
return source.getManaCostsToPay().getX();
}
}
@Override