Merge pull request 'master' (#17) from External/mage:master into master
All checks were successful
/ example-docker-compose (push) Successful in 15m36s

Reviewed-on: #17
This commit is contained in:
Failure 2025-02-12 10:32:03 -08:00
commit 5347eea94b
433 changed files with 8704 additions and 1435 deletions

View file

@ -104,4 +104,6 @@ public interface TriggeredAbility extends Ability {
GameEvent getTriggerEvent();
TriggeredAbility setTriggerPhrase(String triggerPhrase);
String getTriggerPhrase();
}

View file

@ -132,6 +132,11 @@ public abstract class TriggeredAbilityImpl extends AbilityImpl implements Trigge
return this;
}
@Override
public String getTriggerPhrase() {
return this.triggerPhrase;
}
@Override
public void setTriggerEvent(GameEvent triggerEvent) {
this.triggerEvent = triggerEvent;

View file

@ -9,6 +9,8 @@ import mage.constants.Zone;
public class ActivateAsSorceryActivatedAbility extends ActivatedAbilityImpl {
private boolean showActivateText = true;
public ActivateAsSorceryActivatedAbility(Effect effect, Cost cost) {
this(Zone.BATTLEFIELD, effect, cost);
}
@ -20,6 +22,7 @@ public class ActivateAsSorceryActivatedAbility extends ActivatedAbilityImpl {
protected ActivateAsSorceryActivatedAbility(final ActivateAsSorceryActivatedAbility ability) {
super(ability);
this.showActivateText = ability.showActivateText;
}
@Override
@ -27,9 +30,18 @@ public class ActivateAsSorceryActivatedAbility extends ActivatedAbilityImpl {
return new ActivateAsSorceryActivatedAbility(this);
}
public ActivateAsSorceryActivatedAbility withShowActivateText(boolean showActivateText) {
this.showActivateText = showActivateText;
return this;
}
@Override
public String getRule() {
String superRule = super.getRule();
if (!showActivateText) {
return superRule;
}
String newText = (mayActivate == TargetController.OPPONENT
? " Only your opponents may activate this ability and only as a sorcery."
: " Activate only as a sorcery.");

View file

@ -2,22 +2,33 @@ package mage.abilities.common;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.Effect;
import mage.cards.Card;
import mage.constants.SubType;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import java.util.Arrays;
import java.util.List;
/**
* @author BetaSteward_at_googlemail.com
*/
public class EntersBattlefieldTriggeredAbility extends TriggeredAbilityImpl {
static public boolean ENABLE_TRIGGER_PHRASE_AUTO_FIX = false;
public EntersBattlefieldTriggeredAbility(Effect effect) {
this(effect, false);
}
public EntersBattlefieldTriggeredAbility(Effect effect, boolean optional) {
super(Zone.ALL, effect, optional); // Zone.All because a creature with trigger can be put into play and be sacrificed during the resolution of an effect (discard Obstinate Baloth with Smallpox)
this.withRuleTextReplacement(true); // default true to replace "{this}" with "it"
this.withRuleTextReplacement(true); // default true to replace "{this}" with "it" or "this creature"
// warning, it's impossible to add text auto-replacement for creatures here (When this creature enters),
// so it was implemented in CardImpl.addAbility instead
// see https://github.com/magefree/mage/issues/12791
setTriggerPhrase("When {this} enters, ");
}
@ -43,4 +54,59 @@ public class EntersBattlefieldTriggeredAbility extends TriggeredAbilityImpl {
public EntersBattlefieldTriggeredAbility copy() {
return new EntersBattlefieldTriggeredAbility(this);
}
@Override
public EntersBattlefieldTriggeredAbility setTriggerPhrase(String triggerPhrase) {
super.setTriggerPhrase(triggerPhrase);
return this;
}
/**
* Find description of "{this}" like "this creature"
*/
static public String getThisObjectDescription(Card card) {
// prepare {this} description
// short names like Aatchik for Aatchik, Emerald Radian
// except: Mu Yanling, Wind Rider (maybe related to spaces in name)
List<String> parts = Arrays.asList(card.getName().split(","));
if (parts.size() > 1 && !parts.get(0).contains(" ")) {
return parts.get(0);
}
// some types have priority, e.g. Vehicle instead artifact, example: Boommobile
if (card.getSubtype().contains(SubType.VEHICLE)) {
return "this Vehicle";
}
if (card.getSubtype().contains(SubType.AURA)) {
return "this Aura";
}
// by priority
if (card.isCreature()) {
return "this creature";
} else if (card.isPlaneswalker()) {
return "this planeswalker";
} else if (card.isLand()) {
return "this land";
} else if (card.isEnchantment()) {
return "this enchantment";
} else if (card.isArtifact()) {
return "this artifact";
} else {
return "this permanent";
}
}
public static List<String> getPossibleTriggerPhrases() {
// for verify tests - must be same list as above (only {this} relates phrases)
return Arrays.asList(
"when this creature enters",
"when this planeswalker enters",
"when this land enters",
"when this enchantment enters",
"when this artifact enters",
"when this permanent enters"
);
}
}

View file

@ -13,6 +13,10 @@ import mage.util.CardUtil;
*/
public class LimitedTimesPerTurnActivatedAbility extends ActivatedAbilityImpl {
public LimitedTimesPerTurnActivatedAbility(Effect effect, Cost cost) {
this(Zone.BATTLEFIELD, effect, cost);
}
public LimitedTimesPerTurnActivatedAbility(Zone zone, Effect effect, Cost cost) {
this(zone, effect, cost, 1);
}

View file

@ -10,6 +10,7 @@ import mage.cards.Card;
import mage.constants.*;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.util.CardUtil;
/**
* @author TheElk801
@ -38,9 +39,21 @@ class MaxSpeedAbilityEffect extends ContinuousEffectImpl {
private final Ability ability;
private static Duration getDuration(Ability ability) {
switch (ability.getZone()) {
case BATTLEFIELD:
return Duration.WhileOnBattlefield;
case GRAVEYARD:
return Duration.WhileInGraveyard;
default:
return Duration.Custom;
}
}
MaxSpeedAbilityEffect(Ability ability) {
super(Duration.Custom, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
super(getDuration(ability), Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.ability = ability;
this.ability.setRuleVisible(false);
}
private MaxSpeedAbilityEffect(final MaxSpeedAbilityEffect effect) {
@ -73,6 +86,6 @@ class MaxSpeedAbilityEffect extends ContinuousEffectImpl {
@Override
public String getText(Mode mode) {
return "Max speed &mdash; " + ability.getRule();
return "Max speed &mdash; " + CardUtil.getTextWithFirstCharUpperCase(ability.getRule());
}
}

View file

@ -35,7 +35,7 @@ class MayCastFromGraveyardEffect extends AsThoughEffectImpl {
MayCastFromGraveyardEffect() {
super(AsThoughEffectType.CAST_FROM_NOT_OWN_HAND_ZONE, Duration.EndOfGame, Outcome.PutCreatureInPlay);
staticText = "you may cast {this} from your graveyard";
staticText = "you may cast this card from your graveyard";
}
private MayCastFromGraveyardEffect(final MayCastFromGraveyardEffect effect) {

View file

@ -18,7 +18,6 @@ import mage.util.CardUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* @author nantuko
@ -27,6 +26,7 @@ public class ExileFromGraveCost extends CostImpl {
private final List<Card> exiledCards = new ArrayList<>();
private boolean setTargetPointer = false;
private boolean useSourceExileZone = true;
public ExileFromGraveCost(TargetCardInYourGraveyard target) {
target.withNotTarget(true);
@ -73,6 +73,7 @@ public class ExileFromGraveCost extends CostImpl {
super(cost);
this.exiledCards.addAll(cost.getExiledCards());
this.setTargetPointer = cost.setTargetPointer;
this.useSourceExileZone = cost.useSourceExileZone;
}
@Override
@ -90,11 +91,23 @@ public class ExileFromGraveCost extends CostImpl {
}
Cards cardsToExile = new CardsImpl();
cardsToExile.addAllCards(exiledCards);
UUID exileZoneId = null;
String exileZoneName = "";
if (useSourceExileZone) {
exileZoneId = CardUtil.getExileZoneId(game, source);
exileZoneName = CardUtil.getSourceName(game, source);
}
controller.moveCardsToExile(
cardsToExile.getCards(game), source, game, true,
CardUtil.getExileZoneId(game, source),
CardUtil.getSourceName(game, source)
cardsToExile.getCards(game),
source,
game,
true,
exileZoneId,
exileZoneName
);
if (setTargetPointer) {
source.getEffects().setTargetPointer(new FixedTargets(cardsToExile.getCards(game), game));
}
@ -118,4 +131,12 @@ public class ExileFromGraveCost extends CostImpl {
public List<Card> getExiledCards() {
return exiledCards;
}
/**
* Put exiled cards to source zone, so next linked ability can find it
*/
public ExileFromGraveCost withSourceExileZone(boolean useSourceExileZone) {
this.useSourceExileZone = useSourceExileZone;
return this;
}
}

View file

@ -16,7 +16,7 @@ import java.util.UUID;
public class ExileSourceFromGraveCost extends CostImpl {
public ExileSourceFromGraveCost() {
this.text = "exile {this} from your graveyard";
this.text = "exile this card from your graveyard";
}
private ExileSourceFromGraveCost(final ExileSourceFromGraveCost cost) {

View file

@ -0,0 +1,88 @@
package mage.abilities.costs.common;
import java.util.Locale;
import java.util.UUID;
import mage.abilities.Ability;
import mage.abilities.costs.Cost;
import mage.abilities.costs.CostImpl;
import mage.abilities.effects.common.continuous.GainSuspendEffect;
import mage.abilities.keyword.SuspendAbility;
import mage.cards.Card;
import mage.constants.Zone;
import mage.counters.CounterType;
import mage.game.Game;
import mage.MageObjectReference;
import mage.players.Player;
/**
* @author padfoot
*/
public class ExileSourceWithTimeCountersCost extends CostImpl {
private final int counters;
private final boolean checksSuspend;
private final boolean givesSuspend;
private final Zone fromZone;
public ExileSourceWithTimeCountersCost(int counters) {
this (counters, true, false, null);
}
public ExileSourceWithTimeCountersCost(int counters, boolean givesSuspend, boolean checksSuspend, Zone fromZone) {
this.counters = counters;
this.givesSuspend = givesSuspend;
this.checksSuspend = checksSuspend;
this.fromZone = fromZone;
this.text = "exile {this} " +
((fromZone != null) ? " from your " + fromZone.toString().toLowerCase(Locale.ENGLISH) : "") +
" and put " + counters + " time counters on it" +
(givesSuspend ? ". It gains suspend" : "") +
(checksSuspend ? ". If it doesn't have suspend, it gains suspend" : "");
}
private ExileSourceWithTimeCountersCost(final ExileSourceWithTimeCountersCost cost) {
super(cost);
this.counters = cost.counters;
this.givesSuspend = cost.givesSuspend;
this.checksSuspend = cost.checksSuspend;
this.fromZone = cost.fromZone;
}
@Override
public boolean pay(Ability ability, Game game, Ability source, UUID controllerId, boolean noMana, Cost costToPay) {
Player controller = game.getPlayer(controllerId);
if (controller == null) {
return paid;
}
Card card = game.getCard(source.getSourceId());
boolean hasSuspend = card.getAbilities(game).containsClass(SuspendAbility.class);
if (card != null && (fromZone == null || fromZone == game.getState().getZone(source.getSourceId()))) {
UUID exileId = SuspendAbility.getSuspendExileId(controller.getId(), game);
if (controller.moveCardsToExile(card, source, game, true, exileId, "Suspended cards of " + controller.getName())) {
card.addCounters(CounterType.TIME.createInstance(counters), controller.getId(), source, game);
game.informPlayers(controller.getLogName() + " exiles " + card.getLogName() + ((fromZone != null) ? " from their " + fromZone.toString().toLowerCase(Locale.ENGLISH) : "") + " with " + counters + " time counters on it.");
if (givesSuspend || (checksSuspend && !hasSuspend)) {
game.addEffect(new GainSuspendEffect(new MageObjectReference(card, game)), source);
}
}
// 117.11. The actions performed when paying a cost may be modified by effects.
// Even if they are, meaning the actions that are performed don't match the actions
// that are called for, the cost has still been paid.
// so return state here is not important because the user indended to exile the target anyway
paid = true;
}
return paid;
}
@Override
public boolean canPay(Ability ability, Ability source, UUID controllerId, Game game) {
return (game.getCard(source.getSourceId()) != null && (fromZone == null || fromZone == game.getState().getZone(source.getSourceId())));
}
@Override
public ExileSourceWithTimeCountersCost copy() {
return new ExileSourceWithTimeCountersCost(this);
}
}

View file

@ -3,14 +3,16 @@ 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.cards.Card;
import mage.constants.CardType;
import mage.game.Game;
import mage.game.permanent.PermanentToken;
import mage.players.Player;
import java.util.Collection;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
@ -20,16 +22,18 @@ public enum CardTypesInGraveyardCount implements DynamicValue {
YOU("your graveyard"),
ALL("all graveyards"),
OPPONENTS("your opponents' graveyards");
private final String message;
private final CardTypesInGraveyardHint hint;
CardTypesInGraveyardCount(String message) {
this.message = "the number of card types among cards in " + message;
this.hint = new CardTypesInGraveyardHint(this);
}
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
return getStream(game, sourceAbility)
.filter(card -> !card.isCopy() && !(card instanceof PermanentToken))
return getGraveyardCards(game, sourceAbility)
.map(card -> card.getCardType(game))
.flatMap(Collection::stream)
.distinct()
@ -52,16 +56,16 @@ public enum CardTypesInGraveyardCount implements DynamicValue {
return message;
}
private final Stream<Card> getStream(Game game, Ability ability) {
public Hint getHint() {
return hint;
}
public Stream<Card> getGraveyardCards(Game game, Ability ability) {
Collection<UUID> playerIds;
switch (this) {
case YOU:
Player player = game.getPlayer(ability.getControllerId());
return player == null
? null : player
.getGraveyard()
.getCards(game)
.stream();
playerIds = Collections.singletonList(ability.getControllerId());
break;
case OPPONENTS:
playerIds = game.getOpponents(ability.getControllerId());
break;
@ -69,13 +73,47 @@ public enum CardTypesInGraveyardCount implements DynamicValue {
playerIds = game.getState().getPlayersInRange(ability.getControllerId(), game);
break;
default:
return null;
throw new IllegalArgumentException("Wrong code usage: miss implementation for " + this);
}
return playerIds.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)
.map(Player::getGraveyard)
.map(graveyard -> graveyard.getCards(game))
.flatMap(Collection::stream);
.flatMap(Collection::stream)
.filter(Objects::nonNull)
.filter(card -> !card.isCopy() && !(card instanceof PermanentToken));
}
}
class CardTypesInGraveyardHint implements Hint {
CardTypesInGraveyardCount value;
CardTypesInGraveyardHint(CardTypesInGraveyardCount value) {
this.value = value;
}
private CardTypesInGraveyardHint(final CardTypesInGraveyardHint hint) {
this.value = hint.value;
}
@Override
public String getText(Game game, Ability ability) {
Stream<Card> stream = this.value.getGraveyardCards(game, ability);
List<String> types = stream
.map(card -> card.getCardType(game))
.flatMap(Collection::stream)
.distinct()
.map(CardType::toString)
.sorted()
.collect(Collectors.toList());
return "Card types in " + this.value.getMessage() + ": " + types.size()
+ (types.size() > 0 ? " (" + String.join(", ", types) + ')' : "");
}
@Override
public CardTypesInGraveyardHint copy() {
return new CardTypesInGraveyardHint(this);
}
}

View file

@ -4,6 +4,9 @@ import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.game.Game;
import mage.players.Player;
import java.util.Optional;
/**
* @author TheElk801
@ -13,8 +16,10 @@ public enum ControllerSpeedCount implements DynamicValue {
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
// TODO: Implement this
return 0;
return Optional
.ofNullable(game.getPlayer(sourceAbility.getControllerId()))
.map(Player::getSpeed)
.orElse(0);
}
@Override

View file

@ -0,0 +1,40 @@
package mage.abilities.dynamicvalue.common;
import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.game.Game;
/**
* @author TheElk801
*/
public enum SavedDiscardValue implements DynamicValue {
MANY("many"),
MUCH("much");
private final String message;
SavedDiscardValue(String message) {
this.message = "that " + message;
}
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
return (Integer) effect.getValue("discarded");
}
@Override
public SavedDiscardValue copy() {
return this;
}
@Override
public String toString() {
return message;
}
@Override
public String getMessage() {
return "";
}
}

View file

@ -1487,8 +1487,12 @@ public class ContinuousEffects implements Serializable {
}
}
public int getTotalEffectsCount() {
return allEffectsLists.stream().mapToInt(ContinuousEffectsList::size).sum();
}
@Override
public String toString() {
return "Effects: " + allEffectsLists.stream().mapToInt(ContinuousEffectsList::size).sum();
return "Effects: " + getTotalEffectsCount();
}
}

View file

@ -13,6 +13,7 @@ import mage.constants.Outcome;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.util.CardUtil;
/**
* @author LevelX2
@ -49,7 +50,7 @@ public class ChooseModeEffect extends OneShotEffect {
}
if (controller != null && sourcePermanent != null) {
Choice choice = new ChoiceImpl(true);
choice.setMessage(choiceMessage);
choice.setMessage(choiceMessage + CardUtil.getSourceLogName(game, source));
choice.getChoices().addAll(modes);
if (controller.choose(Outcome.Neutral, choice, game)) {
if (!game.isSimulation()) {

View file

@ -0,0 +1,49 @@
package mage.abilities.effects.common;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.Effect;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
/**
* @author TheElk801
*/
public class DiscardOneOrMoreCardsTriggeredAbility extends TriggeredAbilityImpl {
public DiscardOneOrMoreCardsTriggeredAbility(Effect effect) {
this(effect, false);
}
public DiscardOneOrMoreCardsTriggeredAbility(Effect effect, boolean optional) {
this(Zone.BATTLEFIELD, effect, optional);
}
public DiscardOneOrMoreCardsTriggeredAbility(Zone zone, Effect effect, boolean optional) {
super(zone, effect, optional);
setTriggerPhrase("Whenever you discard one or more cards, ");
}
private DiscardOneOrMoreCardsTriggeredAbility(final DiscardOneOrMoreCardsTriggeredAbility ability) {
super(ability);
}
@Override
public DiscardOneOrMoreCardsTriggeredAbility copy() {
return new DiscardOneOrMoreCardsTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.DISCARDED_CARDS;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (!isControlledBy(event.getPlayerId())) {
return false;
}
this.getEffects().setValue("discarded", event.getAmount());
return true;
}
}

View file

@ -43,7 +43,7 @@ public class LoseLifeOpponentsYouGainLifeLostEffect extends OneShotEffect {
return true;
}
int totalLifeLost = game
.getOpponents(source.getControllerId())
.getOpponents(source.getControllerId(), true)
.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)

View file

@ -36,8 +36,9 @@ public class GainAbilitySourceEffect extends ContinuousEffectImpl {
super(duration, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.ability = ability;
this.onCard = onCard;
this.staticText = "{this} gains " + CardUtil.stripReminderText(ability.getRule())
+ (duration.toString().isEmpty() ? "" : ' ' + duration.toString());
this.staticText = "{this} " + (duration == Duration.WhileOnBattlefield ? "has" : "gains") +
' ' + CardUtil.stripReminderText(ability.getRule()) +
(duration.toString().isEmpty() ? "" : ' ' + duration.toString());
this.generateGainAbilityDependencies(ability, null);
}

View file

@ -58,7 +58,7 @@ import java.util.Set;
* entering the battlefield, that card isnt manifested. Its characteristics remain unmodified and it remains in
* its previous zone. If it was face up, it remains face up.
* <p>
* 701.34g TODO: need support it
* 701.34g
* If a manifested permanent thats represented by an instant or sorcery card would turn face up, its controller
* reveals it and leaves it face down. Abilities that trigger whenever a permanent is turned face up wont trigger.
*

View file

@ -52,7 +52,7 @@ public class ConditionTrueHint implements Hint {
}
@Override
public Hint copy() {
public ConditionTrueHint copy() {
return new ConditionTrueHint(this);
}
}

View file

@ -30,7 +30,7 @@ public class StaticHint implements Hint {
}
@Override
public Hint copy() {
public StaticHint copy() {
return new StaticHint(this);
}
}

View file

@ -1,79 +0,0 @@
package mage.abilities.hint.common;
import mage.abilities.Ability;
import mage.abilities.hint.Hint;
import mage.cards.Card;
import mage.constants.CardType;
import mage.game.Game;
import mage.players.Player;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author JayDi85
*/
public enum CardTypesInGraveyardHint implements Hint {
YOU("your graveyard"),
ALL("all graveyards"),
OPPONENTS("your opponents' graveyards");
private final String message;
CardTypesInGraveyardHint(String message) {
this.message = message;
}
@Override
public String getText(Game game, Ability ability) {
Stream<Card> stream = getStream(game, ability);
if (stream == null) {
return null;
}
List<String> types = stream
.map(card -> card.getCardType(game))
.flatMap(Collection::stream)
.distinct()
.map(CardType::toString)
.sorted()
.collect(Collectors.toList());
return "Card types in " + this.message + ": " + types.size()
+ (types.size() > 0 ? " (" + String.join(", ", types) + ')' : "");
}
@Override
public Hint copy() {
return this;
}
private final Stream<Card> getStream(Game game, Ability ability) {
Collection<UUID> playerIds;
switch (this) {
case YOU:
Player player = game.getPlayer(ability.getControllerId());
return player == null
? null : player
.getGraveyard()
.getCards(game)
.stream();
case OPPONENTS:
playerIds = game.getOpponents(ability.getControllerId());
break;
case ALL:
playerIds = game.getState().getPlayersInRange(ability.getControllerId(), game);
break;
default:
return null;
}
return playerIds.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)
.map(Player::getGraveyard)
.map(graveyard -> graveyard.getCards(game))
.flatMap(Collection::stream);
}
}

View file

@ -1,19 +1,19 @@
package mage.abilities.hint.common;
import mage.abilities.Ability;
import mage.abilities.condition.common.CountersOnPermanentsCondition;
import mage.abilities.hint.Hint;
import mage.counters.Counter;
import mage.counters.CounterType;
import mage.filter.FilterPermanent;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.abilities.condition.common.CountersOnPermanentsCondition;
import mage.util.CardUtil;
/**
* A hint which keeps track of how many counters of a specific type there are
* among some type of permanents
*
*
* @author alexander-novo
*/
public class CountersOnPermanentsHint implements Hint {
@ -23,6 +23,10 @@ public class CountersOnPermanentsHint implements Hint {
// Which counter type to count
public final CounterType counterType;
public CountersOnPermanentsHint(CountersOnPermanentsCondition condition) {
this(condition.filter, condition.counterType);
}
/**
* @param filter Which permanents to consider counters on
* @param counterType Which counter type to count
@ -32,12 +36,9 @@ public class CountersOnPermanentsHint implements Hint {
this.counterType = counterType;
}
/**
* Copy parameters from a {@link CountersOnPermanentsCondition}
*/
public CountersOnPermanentsHint(CountersOnPermanentsCondition condition) {
this.filter = condition.filter;
this.counterType = condition.counterType;
public CountersOnPermanentsHint(final CountersOnPermanentsHint hint) {
this.filter = hint.filter.copy();
this.counterType = hint.counterType;
}
@Override
@ -56,7 +57,7 @@ public class CountersOnPermanentsHint implements Hint {
}
@Override
public Hint copy() {
return this;
public CountersOnPermanentsHint copy() {
return new CountersOnPermanentsHint(this);
}
}

View file

@ -47,6 +47,7 @@ import java.util.UUID;
public class ChampionAbility extends StaticAbility {
protected final String objectDescription;
protected final String etbObjectDescription;
/**
* Champion one or more creature types or if the subtype array is empty
@ -59,6 +60,8 @@ public class ChampionAbility extends StaticAbility {
public ChampionAbility(Card card, SubType... subtypes) {
super(Zone.BATTLEFIELD, null);
this.etbObjectDescription = EntersBattlefieldTriggeredAbility.getThisObjectDescription(card);
List<SubType> subTypes = Arrays.asList(subtypes);
FilterControlledPermanent filter;
switch (subTypes.size()) {
@ -105,6 +108,7 @@ public class ChampionAbility extends StaticAbility {
protected ChampionAbility(final ChampionAbility ability) {
super(ability);
this.objectDescription = ability.objectDescription;
this.etbObjectDescription = ability.etbObjectDescription;
}
@Override
@ -115,7 +119,7 @@ public class ChampionAbility extends StaticAbility {
@Override
public String getRule() {
return "Champion " + CardUtil.addArticle(objectDescription)
+ " <i>(When this enters the battlefield, sacrifice it unless you exile another " + objectDescription
+ " <i>(When " + etbObjectDescription + " enters, sacrifice it unless you exile another " + objectDescription
+ " you control. When this leaves the battlefield, that card returns to the battlefield.)</i>";
}
}

View file

@ -18,7 +18,7 @@ public class DecayedAbility extends StaticAbility {
super(Zone.BATTLEFIELD, new CantBlockSourceEffect(Duration.WhileOnBattlefield));
this.addSubAbility(new AttacksTriggeredAbility(new CreateDelayedTriggeredAbilityEffect(
new AtTheEndOfCombatDelayedTriggeredAbility(new SacrificeSourceEffect())
).setText("sacrifice it at end of combat")).setTriggerPhrase("When {this} attacks, "));
).setText("sacrifice it at end of combat")).setTriggerPhrase("When {this} attacks, ").setRuleVisible(false));
}
private DecayedAbility(final DecayedAbility ability) {
@ -32,6 +32,6 @@ public class DecayedAbility extends StaticAbility {
@Override
public String getRule() {
return "decayed";
return "decayed <i>(This creature can't block. When it attacks, sacrifice it at end of combat.)</i>";
}
}

View file

@ -63,10 +63,16 @@ public class DelveAbility extends SimpleStaticAbility implements AlternateManaPa
private static final DynamicValue cardsInGraveyard = new CardsInControllerGraveyardCount();
public DelveAbility() {
private boolean useSourceExileZone;
/**
* @param useSourceExileZone - keep exiled cards in linked source zone, so next ability can find it
*/
public DelveAbility(boolean useSourceExileZone) {
super(Zone.ALL, null);
this.setRuleAtTheTop(true);
this.addHint(new ValueHint("Cards in your graveyard", cardsInGraveyard));
this.useSourceExileZone = useSourceExileZone;
}
protected DelveAbility(final DelveAbility ability) {
@ -101,8 +107,11 @@ public class DelveAbility extends SimpleStaticAbility implements AlternateManaPa
unpaidAmount = 1;
}
specialAction.addCost(new ExileFromGraveCost(new TargetCardInYourGraveyard(
0, Math.min(controller.getGraveyard().size(), unpaidAmount),
new FilterCard("cards from your graveyard"), true)));
0,
Math.min(controller.getGraveyard().size(), unpaidAmount),
new FilterCard("cards from your graveyard"),
true
)).withSourceExileZone(this.useSourceExileZone));
if (specialAction.canActivate(source.getControllerId(), game).canActivate()) {
game.getState().getSpecialActions().add(specialAction);
}

View file

@ -9,7 +9,6 @@ import mage.constants.Outcome;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.events.GameEvent.EventType;
import mage.players.Player;
import mage.util.CardUtil;
@ -63,13 +62,16 @@ class DredgeEffect extends ReplacementEffectImpl {
if (sourceCard == null) {
return false;
}
Player owner = game.getPlayer(game.getCard(source.getSourceId()).getOwnerId());
if (owner != null
&& owner.getLibrary().size() >= amount
&& owner.chooseUse(outcome, new StringBuilder("Dredge ").append(sourceCard.getLogName()).
append("? (").append(amount).append(" cards are milled)").toString(), source, game)) {
Player owner = game.getPlayer(sourceCard.getOwnerId());
if (owner == null) {
return false;
}
String message = "Dredge " + sourceCard.getLogName() + "? (" + amount + " cards are milled)";
if (owner.getLibrary().size() >= amount && owner.chooseUse(outcome, message, source, game)) {
if (!game.isSimulation()) {
game.informPlayers(new StringBuilder(owner.getLogName()).append(" dredges ").append(sourceCard.getLogName()).toString());
game.informPlayers(owner.getLogName() + " dredges " + sourceCard.getLogName() + CardUtil.getSourceLogName(game, source));
}
owner.millCards(amount, source, game);
owner.moveCards(sourceCard, Zone.HAND, source, game);
@ -85,9 +87,14 @@ class DredgeEffect extends ReplacementEffectImpl {
@Override
public boolean applies(GameEvent event, Ability source, Game game) {
Player owner = game.getPlayer(game.getCard(source.getSourceId()).getOwnerId());
return (owner != null
&& event.getPlayerId().equals(owner.getId())
&& owner.getLibrary().size() >= amount);
Card card = game.getCard(source.getSourceId());
if (card == null) {
return false;
}
Player owner = game.getPlayer(card.getOwnerId());
if (owner == null) {
return false;
}
return event.getPlayerId().equals(owner.getId()) && owner.getLibrary().size() >= amount;
}
}

View file

@ -36,21 +36,25 @@ import java.util.*;
public class HideawayAbility extends EntersBattlefieldTriggeredAbility {
private final int amount;
private final String etbObjectDescription;
public HideawayAbility(int amount) {
public HideawayAbility(Card card, int amount) {
super(new HideawayExileEffect(amount));
this.amount = amount;
this.addWatcher(new HideawayWatcher());
this.etbObjectDescription = EntersBattlefieldTriggeredAbility.getThisObjectDescription(card);
}
private HideawayAbility(final HideawayAbility ability) {
super(ability);
this.amount = ability.amount;
this.etbObjectDescription = ability.etbObjectDescription;
}
@Override
public String getRule() {
return "Hideaway " + this.amount + " <i>(When this permanent enters the battlefield, look at the top "
return "Hideaway " + this.amount + " <i>(When " + this.etbObjectDescription + " enters, look at the top "
+ CardUtil.numberToText(this.amount) + " cards of your library, exile one face down, " +
"then put the rest on the bottom of your library in a random order.)</i>";
}

View file

@ -1,17 +1,21 @@
package mage.abilities.keyword;
import mage.abilities.StaticAbility;
import mage.abilities.dynamicvalue.common.ControllerSpeedCount;
import mage.abilities.hint.Hint;
import mage.abilities.hint.ValueHint;
import mage.constants.Zone;
/**
* TODO: Implement this
*
* @author TheElk801
*/
public class StartYourEnginesAbility extends StaticAbility {
private static final Hint hint = new ValueHint("Your current speed", ControllerSpeedCount.instance);
public StartYourEnginesAbility() {
super(Zone.BATTLEFIELD, null);
this.addHint(hint);
}
private StartYourEnginesAbility(final StartYourEnginesAbility ability) {
@ -25,6 +29,6 @@ public class StartYourEnginesAbility extends StaticAbility {
@Override
public String getRule() {
return "Start your engines!";
return "start your engines!";
}
}

View file

@ -0,0 +1,52 @@
package mage.abilities.mana.builder.common;
import mage.ConditionalMana;
import mage.Mana;
import mage.abilities.Ability;
import mage.abilities.condition.Condition;
import mage.abilities.costs.Cost;
import mage.abilities.mana.builder.ConditionalManaBuilder;
import mage.abilities.mana.conditional.ManaCondition;
import mage.game.Game;
import java.util.UUID;
/**
* @author TheElk801
*/
public class ActivatedAbilityManaBuilder extends ConditionalManaBuilder {
@Override
public ConditionalMana build(Object... options) {
return new ActivatedAbilityConditionalMana(this.mana);
}
@Override
public String getRule() {
return "Spend this mana only to activate abilities";
}
}
class ActivatedAbilityConditionalMana extends ConditionalMana {
public ActivatedAbilityConditionalMana(Mana mana) {
super(mana);
staticText = "Spend this mana only to activate abilities";
addCondition(new ActivatedAbilityManaCondition());
}
}
class ActivatedAbilityManaCondition extends ManaCondition implements Condition {
@Override
public boolean apply(Game game, Ability source) {
return source != null
&& !source.isActivated()
&& source.isActivatedAbility();
}
@Override
public boolean apply(Game game, Ability source, UUID originalId, Cost costsToPay) {
return apply(game, source);
}
}

View file

@ -42,7 +42,7 @@ public class OrTriggeredAbility extends TriggeredAbilityImpl {
//Remove useless data
ability.getEffects().clear();
for(Watcher watcher : ability.getWatchers()) {
for (Watcher watcher : ability.getWatchers()) {
super.addWatcher(watcher);
}
@ -51,6 +51,21 @@ public class OrTriggeredAbility extends TriggeredAbilityImpl {
}
}
setTriggerPhrase(generateTriggerPhrase());
// runtime check: enters and sacrifice must use Zone.ALL, see https://github.com/magefree/mage/issues/12826
boolean haveEnters = false;
boolean haveSacrifice = false;
for (Ability ability : abilities) {
if (ability.getRule().toLowerCase(Locale.ENGLISH).contains("enters")) {
haveEnters = true;
}
if (ability.getRule().toLowerCase(Locale.ENGLISH).contains("sacrifice")) {
haveSacrifice = true;
}
}
if (zone != Zone.ALL && haveEnters && haveSacrifice) {
throw new IllegalArgumentException("Wrong code usage: on enters and sacrifice OrTriggeredAbility must use Zone.ALL");
}
}
public OrTriggeredAbility(OrTriggeredAbility ability) {

View file

@ -4,6 +4,7 @@ import mage.MageObject;
import mage.MageObjectImpl;
import mage.Mana;
import mage.abilities.*;
import mage.abilities.common.EntersBattlefieldTriggeredAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.effects.common.continuous.HasSubtypesSourceEffect;
import mage.abilities.keyword.ChangelingAbility;
@ -343,6 +344,19 @@ public abstract class CardImpl extends MageObjectImpl implements Card {
}
}
}
// rules fix: workaround to fix "When {this} enters" into "When this xxx enters"
if (EntersBattlefieldTriggeredAbility.ENABLE_TRIGGER_PHRASE_AUTO_FIX) {
if (ability instanceof TriggeredAbility) {
TriggeredAbility triggeredAbility = ((TriggeredAbility) ability);
if (triggeredAbility.getTriggerPhrase() != null && triggeredAbility.getTriggerPhrase().startsWith("When {this} enters")) {
// there are old sets with old oracle, but it's ok for newer sets, so keep that rules fix
// see https://github.com/magefree/mage/issues/12791
String etbDescription = EntersBattlefieldTriggeredAbility.getThisObjectDescription(this);
triggeredAbility.setTriggerPhrase(triggeredAbility.getTriggerPhrase().replace("{this}", etbDescription));
}
}
}
}
protected void addAbility(Ability ability, Watcher watcher) {

View file

@ -147,6 +147,9 @@ public enum CardRepository {
if (card.getMeldsToCardName() != null && !card.getMeldsToCardName().isEmpty()) {
namesList.add(card.getMeldsToCardName());
}
if (card.getAdventureSpellName() != null && !card.getAdventureSpellName().isEmpty()) {
namesList.add(card.getAdventureSpellName());
}
}
public static Boolean haveSnowLands(String setCode) {
@ -157,7 +160,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
List<CardInfo> results = cardsDao.query(qb.prepare());
for (CardInfo card : results) {
addNewNames(card, names);
@ -173,7 +176,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
qb.where().not().like("types", new SelectArg('%' + CardType.LAND.name() + '%'));
List<CardInfo> results = cardsDao.query(qb.prepare());
for (CardInfo card : results) {
@ -190,7 +193,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
Where<CardInfo, Object> where = qb.where();
where.and(
where.not().like("supertypes", '%' + SuperType.BASIC.name() + '%'),
@ -211,7 +214,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
qb.where().not().like("supertypes", new SelectArg('%' + SuperType.BASIC.name() + '%'));
List<CardInfo> results = cardsDao.query(qb.prepare());
for (CardInfo card : results) {
@ -228,7 +231,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
qb.where().like("types", new SelectArg('%' + CardType.CREATURE.name() + '%'));
List<CardInfo> results = cardsDao.query(qb.prepare());
for (CardInfo card : results) {
@ -245,7 +248,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
qb.where().like("types", new SelectArg('%' + CardType.ARTIFACT.name() + '%'));
List<CardInfo> results = cardsDao.query(qb.prepare());
for (CardInfo card : results) {
@ -262,7 +265,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
Where<CardInfo, Object> where = qb.where();
where.and(
where.not().like("types", '%' + CardType.CREATURE.name() + '%'),
@ -283,7 +286,7 @@ public enum CardRepository {
Set<String> names = new TreeSet<>();
try {
QueryBuilder<CardInfo, Object> qb = cardsDao.queryBuilder();
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName");
qb.distinct().selectColumns("name", "modalDoubleFacedSecondSideName", "secondSideName", "flipCardName", "adventureSpellName");
Where<CardInfo, Object> where = qb.where();
where.and(
where.not().like("types", '%' + CardType.ARTIFACT.name() + '%'),

View file

@ -35,6 +35,7 @@ public enum TokenRepository {
public static final String XMAGE_IMAGE_NAME_RADIATION = "Radiation";
public static final String XMAGE_IMAGE_NAME_THE_RING = "The Ring";
public static final String XMAGE_IMAGE_NAME_HELPER_EMBLEM = "Helper Emblem";
public static final String XMAGE_IMAGE_NAME_SPEED = "Speed";
private static final Logger logger = Logger.getLogger(TokenRepository.class);
@ -310,6 +311,9 @@ public enum TokenRepository {
// The Ring
res.add(createXmageToken(XMAGE_IMAGE_NAME_THE_RING, 1, "https://api.scryfall.com/cards/tltr/H13/en?format=image"));
// Speed
res.add(createXmageToken(XMAGE_IMAGE_NAME_SPEED, 1, "https://api.scryfall.com/cards/tdft/14/en?format=image&&face=back"));
// Helper emblem (for global card hints)
// use backface for it
res.add(createXmageToken(XMAGE_IMAGE_NAME_HELPER_EMBLEM, 1, "https://upload.wikimedia.org/wikipedia/en/a/aa/Magic_the_gathering-card_back.jpg"));

View file

@ -16,6 +16,7 @@ import java.util.UUID;
public enum TargetController {
ACTIVE,
INACTIVE,
ANY,
YOU,
NOT_YOU,
@ -85,6 +86,8 @@ public enum TargetController {
return card.isOwnedBy(input.getSource().getFirstTarget());
case ACTIVE:
return card.isOwnedBy(game.getActivePlayerId());
case INACTIVE:
return !card.isOwnedBy(game.getActivePlayerId());
case MONARCH:
return card.isOwnedBy(game.getMonarchId());
case ANY:
@ -130,6 +133,8 @@ public enum TargetController {
return player.getId().equals(input.getSource().getFirstTarget());
case ACTIVE:
return game.isActivePlayer(player.getId());
case INACTIVE:
return !game.isActivePlayer(player.getId());
case MONARCH:
return player.getId().equals(game.getMonarchId());
default:
@ -168,6 +173,8 @@ public enum TargetController {
return !object.isControlledBy(playerId);
case ACTIVE:
return object.isControlledBy(game.getActivePlayerId());
case INACTIVE:
return !object.isControlledBy(game.getActivePlayerId());
case ENCHANTED:
Permanent permanent = input.getSource().getSourcePermanentIfItStillExists(game);
return permanent != null && input.getObject().isControlledBy(permanent.getAttachedTo());

View file

@ -6,8 +6,8 @@ package mage.designations;
public enum DesignationType {
THE_MONARCH("The Monarch"), // global
CITYS_BLESSING("City's Blessing"), // per player
THE_INITIATIVE("The Initiative"); // global
THE_INITIATIVE("The Initiative"), // global
SPEED("Speed"); // per player
private final String text;
DesignationType(String text) {
@ -18,5 +18,4 @@ public enum DesignationType {
public String toString() {
return text;
}
}

View file

@ -0,0 +1,122 @@
package mage.designations;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.OneShotEffect;
import mage.cards.repository.TokenInfo;
import mage.cards.repository.TokenRepository;
import mage.constants.Outcome;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import java.util.Optional;
/**
* @author TheElk801
*/
public class Speed extends Designation {
public Speed() {
super(DesignationType.SPEED);
addAbility(new SpeedTriggeredAbility());
TokenInfo foundInfo = TokenRepository.instance.findPreferredTokenInfoForXmage(TokenRepository.XMAGE_IMAGE_NAME_SPEED, null);
if (foundInfo != null) {
this.setExpansionSetCode(foundInfo.getSetCode());
this.setUsesVariousArt(true);
this.setCardNumber("");
this.setImageFileName(""); // use default
this.setImageNumber(foundInfo.getImageNumber());
} else {
// how-to fix: add image to the tokens-database TokenRepository->loadXmageTokens
throw new IllegalArgumentException("Wrong code usage: can't find xmage token info for: " + TokenRepository.XMAGE_IMAGE_NAME_SPEED);
}
}
private Speed(final Speed card) {
super(card);
}
@Override
public Speed copy() {
return new Speed(this);
}
}
class SpeedTriggeredAbility extends TriggeredAbilityImpl {
SpeedTriggeredAbility() {
super(Zone.ALL, new SpeedEffect());
setTriggersLimitEachTurn(1);
}
private SpeedTriggeredAbility(final SpeedTriggeredAbility ability) {
super(ability);
}
@Override
public SpeedTriggeredAbility copy() {
return new SpeedTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.LOST_LIFE_BATCH_FOR_ONE_PLAYER;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
return game.isActivePlayer(getControllerId())
&& game
.getOpponents(getControllerId())
.contains(event.getTargetId());
}
@Override
public boolean checkInterveningIfClause(Game game) {
return Optional
.ofNullable(getControllerId())
.map(game::getPlayer)
.map(Player::getSpeed)
.map(x -> x < 4)
.orElse(false);
}
@Override
public boolean isInUseableZone(Game game, MageObject sourceObject, GameEvent event) {
return true;
}
@Override
public String getRule() {
return "Whenever one or more opponents lose life during your turn, if your speed is less than 4, " +
"increase your speed by 1. This ability triggers only once each turn.";
}
}
class SpeedEffect extends OneShotEffect {
SpeedEffect() {
super(Outcome.Benefit);
}
private SpeedEffect(final SpeedEffect effect) {
super(effect);
}
@Override
public SpeedEffect copy() {
return new SpeedEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Optional.ofNullable(source.getControllerId())
.map(game::getPlayer)
.ifPresent(player -> player.increaseSpeed(game));
return true;
}
}

View file

@ -754,6 +754,28 @@ public final class StaticFilters {
FILTER_PERMANENT_CREATURE_OR_LAND.setLockedFilter(true);
}
public static final FilterPermanent FILTER_PERMANENT_CREATURE_OR_VEHICLE = new FilterPermanent("creature or Vehicle");
static {
FILTER_PERMANENT_CREATURE_OR_VEHICLE.add(
Predicates.or(
CardType.CREATURE.getPredicate(),
SubType.VEHICLE.getPredicate()
));
FILTER_PERMANENT_CREATURE_OR_VEHICLE.setLockedFilter(true);
}
public static final FilterControlledPermanent FILTER_CONTROLLED_PERMANENT_CREATURE_OR_VEHICLE = new FilterControlledPermanent("creature or Vehicle you control");
static {
FILTER_CONTROLLED_PERMANENT_CREATURE_OR_VEHICLE.add(
Predicates.or(
CardType.CREATURE.getPredicate(),
SubType.VEHICLE.getPredicate()
));
FILTER_CONTROLLED_PERMANENT_CREATURE_OR_VEHICLE.setLockedFilter(true);
}
public static final FilterCreaturePermanent FILTER_PERMANENT_A_CREATURE = new FilterCreaturePermanent("a creature");
static {

View file

@ -174,7 +174,7 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
*
* Warning, it will return leaved players until end of turn. For dialogs and one shot effects use excludeLeavedPlayers
*/
// TODO: check usage of getOpponents in cards and replace with correct call of excludeLeavedPlayers
// TODO: check usage of getOpponents in cards and replace with correct call of excludeLeavedPlayers, see #13289
default Set<UUID> getOpponents(UUID playerId) {
return getOpponents(playerId, false);
}
@ -314,7 +314,9 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
Player getLosingPlayer();
int getTotalErrorsCount();
int getTotalErrorsCount(); // debug only
int getTotalEffectsCount(); // debug only
//client event methods
void addTableEventListener(Listener<TableEvent> listener);

View file

@ -2862,6 +2862,13 @@ public abstract class GameImpl implements Game {
}
}
}
// Start Your Engines // Max Speed
if (perm.getAbilities(this).containsClass(StartYourEnginesAbility.class)) {
Optional.ofNullable(perm.getControllerId())
.map(this::getPlayer)
.ifPresent(player -> player.initSpeed(this));
}
}
//201300713 - 704.5k
// If a player controls two or more legendary permanents with the same name, that player
@ -3169,7 +3176,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.INFO, message, this);
}
@ -3178,7 +3185,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.STATUS, message, withTime, withTurnInfo, this);
}
@ -3187,7 +3194,7 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.UPDATE, null, this);
getState().clearLookedAt();
getState().clearRevealed();
@ -3198,23 +3205,23 @@ public abstract class GameImpl implements Game {
if (simulation) {
return;
}
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.END_GAME_INFO, null, this);
}
@Override
public void fireErrorEvent(String message, Exception ex) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.ERROR, message, ex, this);
}
private void makeSureCalledOutsideLayersEffects() {
private void makeSureCalledOutsideLayerEffects() {
// very slow, enable/comment it for debug or load/stability tests only
// TODO: enable check and remove/rework all wrong usages
if (true) return;
Arrays.stream(Thread.currentThread().getStackTrace()).forEach(e -> {
if (e.toString().contains("GameState.applyEffects")) {
throw new IllegalStateException("Wrong code usage: client side events can't be called from layers effects (wrong informPlayers usage?");
throw new IllegalStateException("Wrong code usage: client side events can't be called from layers effects (wrong informPlayers usage?)");
}
});
}
@ -3542,11 +3549,6 @@ public abstract class GameImpl implements Game {
}
protected void removeCreaturesFromCombat() {
//20091005 - 511.3
getCombat().endCombat(this);
}
@Override
public ContinuousEffects getContinuousEffects() {
return state.getContinuousEffects();
@ -3689,6 +3691,11 @@ public abstract class GameImpl implements Game {
return this.totalErrorsCount.get();
}
@Override
public int getTotalEffectsCount() {
return this.getContinuousEffects().getTotalEffectsCount();
}
@Override
public void cheat(UUID ownerId, Map<Zone, String> commands) {
if (commands != null) {
@ -3885,7 +3892,7 @@ public abstract class GameImpl implements Game {
@Override
public void initTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.INIT_TIMER, playerId, null, this);
}
}
@ -3893,7 +3900,7 @@ public abstract class GameImpl implements Game {
@Override
public void resumeTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.RESUME_TIMER, playerId, null, this);
}
}
@ -3901,7 +3908,7 @@ public abstract class GameImpl implements Game {
@Override
public void pauseTimer(UUID playerId) {
if (priorityTime > 0) {
makeSureCalledOutsideLayersEffects();
makeSureCalledOutsideLayerEffects();
tableEventSource.fireTableEvent(EventType.PAUSE_TIMER, playerId, null, this);
}
}

View file

@ -312,7 +312,23 @@ public class Combat implements Serializable, Copyable<Combat> {
Player player = game.getPlayer(attackingPlayerId);
if (player != null) {
if (groups.size() > 0) {
game.informPlayers(player.getLogName() + " attacks with " + groups.size() + (groups.size() == 1 ? " creature" : " creatures"));
String defendersInfo = groups.stream()
.map(g -> g.defenderId)
.distinct()
.map(id -> {
Player defPlayer = game.getPlayer(id);
if (defPlayer != null) {
return defPlayer.getLogName();
}
Permanent defPermanent = game.getPermanentOrLKIBattlefield(id);
if (defPermanent != null) {
return defPermanent.getLogName();
}
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.joining(", "));
game.informPlayers(player.getLogName() + " attacks " + defendersInfo + " with " + groups.size() + (groups.size() == 1 ? " creature" : " creatures"));
} else {
game.informPlayers(player.getLogName() + " skip attack");
}
@ -670,9 +686,25 @@ public class Combat implements Serializable, Copyable<Combat> {
}
// choosing until good block configuration
int aiTries = 0;
while (true) {
aiTries++;
if (controller.isComputer() && aiTries > 20) {
// TODO: AI must use real attacker/blocker configuration with all possible combination
// (current human like logic will fail sometime, e.g. with menace and big/low creatures)
// real game: send warning
// test: fast fail
game.informPlayers(controller.getLogName() + ": WARNING - AI can't find good blocker combination and will skip it - report your battlefield to github - " + game.getCombat());
if (controller.isTestsMode()) {
// how-to fix: AI code must support failed abilities or use cases
throw new IllegalArgumentException("AI can't find good blocker combination");
}
break;
}
// declare normal blockers
// TODO: need reseach - is it possible to concede on bad blocker configuration (e.g. user can't continue)
// TODO: need research - is it possible to concede on bad blocker configuration (e.g. user can't continue)
controller.selectBlockers(source, game, defenderId);
if (game.isPaused() || game.checkIfGameIsOver() || game.executingRollback()) {
return;
@ -776,18 +808,16 @@ public class Combat implements Serializable, Copyable<Combat> {
/**
* Check the block restrictions
*
* @param player
* @param game
* @return false - if block restrictions were not complied
*/
public boolean checkBlockRestrictions(Player player, Game game) {
public boolean checkBlockRestrictions(Player defender, Game game) {
int count = 0;
boolean blockWasLegal = true;
for (CombatGroup group : groups) {
count += group.getBlockers().size();
}
for (CombatGroup group : groups) {
blockWasLegal &= group.checkBlockRestrictions(game, count);
blockWasLegal &= group.checkBlockRestrictions(game, defender, count);
}
return blockWasLegal;
}

View file

@ -243,7 +243,7 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
* @param first true for first strike damage step, false for normal damage step
* @return true if permanent should deal damage this step
*/
private boolean dealsDamageThisStep(Permanent perm, boolean first, Game game) {
public static boolean dealsDamageThisStep(Permanent perm, boolean first, Game game) {
if (perm == null) {
return false;
}
@ -773,11 +773,27 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
}
}
public boolean checkBlockRestrictions(Game game, int blockersCount) {
public boolean checkBlockRestrictions(Game game, Player defender, int blockersCount) {
boolean blockWasLegal = true;
if (attackers.isEmpty()) {
return blockWasLegal;
}
// collect possible blockers
Map<UUID, Set<UUID>> possibleBlockers = new HashMap<>();
for (UUID attackerId : attackers) {
Permanent attacker = game.getPermanent(attackerId);
Set<UUID> goodBlockers = new HashSet<>();
for (Permanent blocker : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURES_CONTROLLED, defender.getId(), game)) {
if (blocker.canBlock(attackerId, game)) {
goodBlockers.add(blocker.getId());
}
}
possibleBlockers.put(attacker.getId(), goodBlockers);
}
// effects: can't block alone
// too much blockers
if (blockersCount == 1) {
List<UUID> toBeRemoved = new ArrayList<>();
for (UUID blockerId : getBlockers()) {
@ -802,7 +818,8 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
for (UUID uuid : attackers) {
Permanent attacker = game.getPermanent(uuid);
if (attacker != null && this.blocked) {
// Check if there are enough blockers to have a legal block
// effects: can't be blocked except by xxx or more creatures
// too few blockers
if (attacker.getMinBlockedBy() > 1 && !blockers.isEmpty() && blockers.size() < attacker.getMinBlockedBy()) {
for (UUID blockerId : new ArrayList<>(blockers)) {
game.getCombat().removeBlocker(blockerId, game);
@ -812,9 +829,16 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
if (!game.isSimulation()) {
game.informPlayers(attacker.getLogName() + " can't be blocked except by " + attacker.getMinBlockedBy() + " or more creatures. Blockers discarded.");
}
blockWasLegal = false;
// if there aren't any possible blocker configuration then it's legal due mtg rules
// warning, it's affect AI related logic like other block auto-fixes does, see https://github.com/magefree/mage/pull/13182
if (attacker.getMinBlockedBy() <= possibleBlockers.getOrDefault(attacker.getId(), Collections.emptySet()).size()) {
blockWasLegal = false;
}
}
// Check if there are too many blockers (maxBlockedBy = 0 means no restrictions)
// effects: can't be blocked by more than xxx creature
// too much blockers
if (attacker.getMaxBlockedBy() > 0 && attacker.getMaxBlockedBy() < blockers.size()) {
for (UUID blockerId : new ArrayList<>(blockers)) {
game.getCombat().removeBlocker(blockerId, game);
@ -827,6 +851,7 @@ public class CombatGroup implements Serializable, Copyable<CombatGroup> {
.append(attacker.getMaxBlockedBy() == 1 ? " creature." : " creatures.")
.append(" Blockers discarded.").toString());
}
blockWasLegal = false;
}
}

View file

@ -0,0 +1,38 @@
package mage.game.command.emblems;
import mage.abilities.Ability;
import mage.abilities.common.EntersBattlefieldAllTriggeredAbility;
import mage.abilities.effects.common.DamageTargetEffect;
import mage.constants.Zone;
import mage.filter.StaticFilters;
import mage.game.command.Emblem;
import mage.target.common.TargetAnyTarget;
/**
* @author TheElk801
*/
public final class ChandraSparkHunterEmblem extends Emblem {
/**
* Emblem with "Whenever an artifact you control enters, this emblem deals 3 damage to any target."
*/
public ChandraSparkHunterEmblem() {
super("Emblem Chandra");
Ability ability = new EntersBattlefieldAllTriggeredAbility(
Zone.COMMAND, new DamageTargetEffect(3, "this emblem"),
StaticFilters.FILTER_CONTROLLED_PERMANENT_ARTIFACT, false
);
ability.addTarget(new TargetAnyTarget());
this.getAbilities().add(ability);
}
private ChandraSparkHunterEmblem(final ChandraSparkHunterEmblem card) {
super(card);
}
@Override
public ChandraSparkHunterEmblem copy() {
return new ChandraSparkHunterEmblem(this);
}
}

View file

@ -7,13 +7,11 @@ import mage.abilities.costs.mana.ManaCost;
import mage.abilities.costs.mana.ManaCosts;
import mage.abilities.keyword.NightboundAbility;
import mage.abilities.keyword.TransformAbility;
import mage.cards.Card;
import mage.cards.LevelerCard;
import mage.cards.ModalDoubleFacedCard;
import mage.cards.SplitCard;
import mage.cards.*;
import mage.constants.SpellAbilityType;
import mage.game.Game;
import mage.game.events.ZoneChangeEvent;
import mage.players.Player;
import java.util.UUID;
@ -45,7 +43,7 @@ public class PermanentCard extends PermanentImpl {
}
// usage check: you must put to play only real card's part
// if you use it in test code then call CardUtil.getDefaultCardSideForBattlefield for default side
// if you use it in test code or for permanent's copy effects then call CardUtil.getDefaultCardSideForBattlefield for default side
// it's a basic check and still allows to create permanent from instant or sorcery
boolean goodForBattlefield = true;
if (card instanceof ModalDoubleFacedCard) {
@ -185,7 +183,10 @@ public class PermanentCard extends PermanentImpl {
// 701.34g. If a manifested permanent that's represented by an instant or sorcery card would turn face up,
// its controller reveals it and leaves it face down. Abilities that trigger whenever a permanent
// is turned face up won't trigger.
// TODO: add reveal effect
Player player = game.getPlayer(source.getControllerId());
if (player != null) {
player.revealCards(source, new CardsImpl(this), game);
}
return false;
}
if (super.turnFaceUp(source, game, playerId)) {

View file

@ -66,7 +66,7 @@ class AshiokNightmareMuseTokenEffect extends OneShotEffect {
return false;
}
Set<Card> cards = game
.getOpponents(source.getControllerId())
.getOpponents(source.getControllerId(), true)
.stream()
.map(game::getPlayer)
.filter(Objects::nonNull)

View file

@ -14,8 +14,6 @@ import mage.constants.Zone;
*/
public final class ConsumingBlobOozeToken extends TokenImpl {
private static final DynamicValue powerValue = CardTypesInGraveyardCount.YOU;
public ConsumingBlobOozeToken() {
super("Ooze Token", "green Ooze creature token with \"This creature's power is equal to the number of card types among cards in your graveyard and its toughness is equal to that number plus 1.\"");
cardType.add(CardType.CREATURE);
@ -26,7 +24,9 @@ public final class ConsumingBlobOozeToken extends TokenImpl {
toughness = new MageInt(1);
// This creature's power is equal to the number of card types among cards in your graveyard and its toughness is equal to that number plus 1.
this.addAbility(new SimpleStaticAbility(Zone.ALL, new SetBasePowerToughnessPlusOneSourceEffect(powerValue)));
this.addAbility(new SimpleStaticAbility(Zone.ALL,
new SetBasePowerToughnessPlusOneSourceEffect(CardTypesInGraveyardCount.YOU)
).addHint(CardTypesInGraveyardCount.YOU.getHint()));
}
private ConsumingBlobOozeToken(final ConsumingBlobOozeToken token) {

View file

@ -11,7 +11,7 @@ import mage.constants.SubType;
public final class PilotSaddleCrewToken extends TokenImpl {
public PilotSaddleCrewToken() {
super("Pilot Token", "1/1 colorless Pilot creature token with \"This creature saddles Mounts and crews Vehicles as though its power were 2 greater.\"");
super("Pilot Token", "1/1 colorless Pilot creature token with \"This token saddles Mounts and crews Vehicles as though its power were 2 greater.\"");
cardType.add(CardType.CREATURE);
subtype.add(SubType.PILOT);
power = new MageInt(1);

View file

@ -24,7 +24,9 @@ public final class TarmogoyfToken extends TokenImpl {
toughness = new MageInt(1);
// Tarmogoyf's power is equal to the number of card types among cards in all graveyards and its toughness is equal to that number plus 1.
this.addAbility(new SimpleStaticAbility(Zone.ALL, new SetBasePowerToughnessPlusOneSourceEffect(CardTypesInGraveyardCount.ALL)));
this.addAbility(new SimpleStaticAbility(Zone.ALL,
new SetBasePowerToughnessPlusOneSourceEffect(CardTypesInGraveyardCount.ALL)
).addHint(CardTypesInGraveyardCount.ALL.getHint()));
}
private TarmogoyfToken(final TarmogoyfToken token) {

View file

@ -0,0 +1,30 @@
package mage.game.permanent.token;
import mage.MageInt;
import mage.abilities.keyword.CrewAbility;
import mage.constants.CardType;
import mage.constants.SubType;
/**
* @author TheElk801
*/
public final class VehicleToken extends TokenImpl {
public VehicleToken() {
super("Vehicle Token", "3/2 colorless Vehicle artifact token with crew 1");
cardType.add(CardType.ARTIFACT);
subtype.add(SubType.VEHICLE);
power = new MageInt(3);
toughness = new MageInt(2);
this.addAbility(new CrewAbility(1));
}
private VehicleToken(final VehicleToken token) {
super(token);
}
public VehicleToken copy() {
return new VehicleToken(this);
}
}

View file

@ -216,6 +216,14 @@ public interface Player extends MageItem, Copyable<Player> {
boolean isDrawsOnOpponentsTurn();
int getSpeed();
void initSpeed(Game game);
void increaseSpeed(Game game);
void decreaseSpeed(Game game);
/**
* Returns alternative casting costs a player can cast spells for
*
@ -620,6 +628,7 @@ public interface Player extends MageItem, Copyable<Player> {
* <p>
* Warning, if you use it from continuous effect, then check with extra call
* isCanLookAtNextTopLibraryCard
* If you use revealCards with face-down permanents, they will be revealed face up.
*
* @param source
* @param name

View file

@ -27,6 +27,7 @@ import mage.counters.CounterType;
import mage.counters.Counters;
import mage.designations.Designation;
import mage.designations.DesignationType;
import mage.designations.Speed;
import mage.filter.FilterCard;
import mage.filter.FilterMana;
import mage.filter.FilterPermanent;
@ -153,6 +154,7 @@ public abstract class PlayerImpl implements Player, Serializable {
protected boolean canPlotFromTopOfLibrary = false;
protected boolean drawsFromBottom = false;
protected boolean drawsOnOpponentsTurn = false;
protected int speed = 0;
protected FilterPermanent sacrificeCostFilter;
protected List<AlternativeSourceCosts> alternativeSourceCosts = new ArrayList<>();
@ -252,6 +254,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.canPlotFromTopOfLibrary = player.canPlotFromTopOfLibrary;
this.drawsFromBottom = player.drawsFromBottom;
this.drawsOnOpponentsTurn = player.drawsOnOpponentsTurn;
this.speed = player.speed;
this.attachments.addAll(player.attachments);
@ -367,6 +370,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.drawsFromBottom = player.isDrawsFromBottom();
this.drawsOnOpponentsTurn = player.isDrawsOnOpponentsTurn();
this.alternativeSourceCosts = CardUtil.deepCopyObject(((PlayerImpl) player).alternativeSourceCosts);
this.speed = player.getSpeed();
this.topCardRevealed = player.isTopCardRevealed();
@ -480,6 +484,7 @@ public abstract class PlayerImpl implements Player, Serializable {
this.canPlotFromTopOfLibrary = false;
this.drawsFromBottom = false;
this.drawsOnOpponentsTurn = false;
this.speed = 0;
this.sacrificeCostFilter = null;
this.alternativeSourceCosts.clear();
@ -1905,7 +1910,11 @@ public abstract class PlayerImpl implements Player, Serializable {
int last = cards.size();
for (Card card : cards.getCards(game)) {
current++;
sb.append(GameLog.getColoredObjectName(card)); // TODO: see same usage in OfferingAbility for hide card's id (is it needs for reveal too?!)
if (card instanceof PermanentCard && card.isFaceDown(game)) {
sb.append(GameLog.getColoredObjectName(card.getMainCard()));
} else {
sb.append(GameLog.getColoredObjectName(card)); // TODO: see same usage in OfferingAbility for hide card's id (is it needs for reveal too?!)
}
if (current < last) {
sb.append(", ");
}
@ -4452,11 +4461,7 @@ public abstract class PlayerImpl implements Player, Serializable {
}
/**
* Only used for AIs
*
* @param ability
* @param game
* @return
* AI related code
*/
@Override
public List<Ability> getPlayableOptions(Ability ability, Game game) {
@ -4477,6 +4482,9 @@ public abstract class PlayerImpl implements Player, Serializable {
return options;
}
/**
* AI related code
*/
private void addModeOptions(List<Ability> options, Ability option, Game game) {
// TODO: support modal spells with more than one selectable mode (also must use max modes filter)
for (Mode mode : option.getModes().values()) {
@ -4499,11 +4507,18 @@ public abstract class PlayerImpl implements Player, Serializable {
}
}
/**
* AI related code
*/
protected void addVariableXOptions(List<Ability> options, Ability option, int targetNum, Game game) {
addTargetOptions(options, option, targetNum, game);
}
/**
* AI related code
*/
protected void addTargetOptions(List<Ability> options, Ability option, int targetNum, Game game) {
// TODO: target options calculated for triggered ability too, but do not used in real game
for (Target target : option.getTargets().getUnchosen(game).get(targetNum).getTargetOptions(option, game)) {
Ability newOption = option.copy();
if (target instanceof TargetAmount) {
@ -4516,7 +4531,7 @@ public abstract class PlayerImpl implements Player, Serializable {
newOption.getTargets().get(targetNum).addTarget(targetId, newOption, game, true);
}
}
if (targetNum < option.getTargets().size() - 2) {
if (targetNum < option.getTargets().size() - 2) { // wtf
addTargetOptions(options, newOption, targetNum + 1, game);
} else if (!option.getCosts().getTargets().isEmpty()) {
addCostTargetOptions(options, newOption, 0, game);
@ -4526,6 +4541,9 @@ public abstract class PlayerImpl implements Player, Serializable {
}
}
/**
* AI related code
*/
private void addCostTargetOptions(List<Ability> options, Ability option, int targetNum, Game game) {
for (UUID targetId : option.getCosts().getTargets().get(targetNum).possibleTargets(playerId, option, game)) {
Ability newOption = option.copy();
@ -4674,6 +4692,37 @@ public abstract class PlayerImpl implements Player, Serializable {
return drawsOnOpponentsTurn;
}
@Override
public int getSpeed() {
return speed;
}
@Override
public void initSpeed(Game game) {
if (speed > 0) {
return;
}
speed = 1;
game.getState().addDesignation(new Speed(), game, getId());
game.informPlayers(this.getLogName() + "'s speed is now 1.");
}
@Override
public void increaseSpeed(Game game) {
if (speed < 4) {
speed++;
game.informPlayers(this.getLogName() + "'s speed has increased to " + speed);
}
}
@Override
public void decreaseSpeed(Game game) {
if (speed > 1) {
speed--;
game.informPlayers(this.getLogName() + "'s speed has decreased to " + speed);
}
}
@Override
public boolean autoLoseGame() {
return false;

View file

@ -79,6 +79,9 @@ public interface Target extends Serializable {
boolean isLegal(Ability source, Game game);
/**
* AI related code. Returns all possible different target combinations
*/
List<? extends Target> getTargetOptions(Ability source, Game game);
boolean canChoose(UUID sourceControllerId, Game game);

View file

@ -1,11 +1,16 @@
package mage.target;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.dynamicvalue.common.StaticValue;
import mage.cards.Card;
import mage.constants.Outcome;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.util.DebugUtil;
import mage.util.RandomUtil;
import java.util.*;
import java.util.stream.Collectors;
@ -118,45 +123,229 @@ public abstract class TargetAmount extends TargetImpl {
}
@Override
public List<? extends TargetAmount> getTargetOptions(Ability source, Game game) {
final public List<? extends TargetAmount> getTargetOptions(Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
List<TargetAmount> options = new ArrayList<>();
Set<UUID> possibleTargets = possibleTargets(source.getControllerId(), source, game);
addTargets(this, possibleTargets, options, source, game);
// optimizations for less memory/cpu consumptions
printTargetsTableAndVariations("before optimize", game, possibleTargets, options, false);
optimizePossibleTargets(source, game, possibleTargets);
printTargetsTableAndVariations("after optimize", game, possibleTargets, options, false);
// debug target variations
//printTargetsVariations(possibleTargets, options);
// calc possible amount variations
addTargets(this, possibleTargets, options, source, game);
printTargetsTableAndVariations("after calc", game, possibleTargets, options, true);
return options;
}
private void printTargetsVariations(Set<UUID> possibleTargets, List<TargetAmount> options) {
// debug target variations
// permanent index + amount
// example: 7 -> 2; 8 -> 3; 9 -> 1
/**
* AI related, trying to reduce targets for simulations
*/
private void optimizePossibleTargets(Ability source, Game game, Set<UUID> possibleTargets) {
// remove duplicated/same creatures (example: distribute 3 damage between 10+ same tokens)
// it must have additional threshold to keep more variations for analyse
//
// bad example:
// - Blessings of Nature
// - Distribute four +1/+1 counters among any number of target creatures.
// on low targets threshold AI can put 1/1 to opponent's creature instead own, see TargetAmountAITest.test_AI_SimulateTargets
int maxPossibleTargetsToSimulate = this.remainingAmount * 2;
if (possibleTargets.size() < maxPossibleTargetsToSimulate) {
return;
}
// split targets by groups
Map<UUID, String> targetGroups = new HashMap<>();
possibleTargets.forEach(id -> {
String groupKey = "";
// player
Player player = game.getPlayer(id);
if (player != null) {
groupKey = getTargetGroupKeyAsPlayer(player);
}
// game object
MageObject object = game.getObject(id);
if (object != null) {
groupKey = object.getName();
if (object instanceof Permanent) {
groupKey += getTargetGroupKeyAsPermanent(game, (Permanent) object);
} else if (object instanceof Card) {
groupKey += getTargetGroupKeyAsCard(game, (Card) object);
} else {
groupKey += getTargetGroupKeyAsOther(game, object);
}
}
// unknown - use all
if (groupKey.isEmpty()) {
groupKey = id.toString();
}
targetGroups.put(id, groupKey);
});
Map<String, List<UUID>> groups = new HashMap<>();
targetGroups.forEach((id, groupKey) -> {
groups.computeIfAbsent(groupKey, k -> new ArrayList<>());
groups.get(groupKey).add(id);
});
// optimize logic:
// - use one target from each target group all the time
// - add random target from random group until fill all remainingAmount condition
// use one target per group
Set<UUID> newPossibleTargets = new HashSet<>();
groups.forEach((groupKey, groupTargets) -> {
UUID targetId = RandomUtil.randomFromCollection(groupTargets);
if (targetId != null) {
newPossibleTargets.add(targetId);
groupTargets.remove(targetId);
}
});
// use random target until fill condition
while (newPossibleTargets.size() < maxPossibleTargetsToSimulate) {
String groupKey = RandomUtil.randomFromCollection(groups.keySet());
if (groupKey == null) {
break;
}
List<UUID> groupTargets = groups.getOrDefault(groupKey, null);
if (groupTargets == null || groupTargets.isEmpty()) {
groups.remove(groupKey);
continue;
}
UUID targetId = RandomUtil.randomFromCollection(groupTargets);
if (targetId != null) {
newPossibleTargets.add(targetId);
groupTargets.remove(targetId);
}
}
// keep final result
possibleTargets.clear();
possibleTargets.addAll(newPossibleTargets);
}
private String getTargetGroupKeyAsPlayer(Player player) {
// use all
return String.join(";", Arrays.asList(
player.getName(),
String.valueOf(player.getId().hashCode())
));
}
private String getTargetGroupKeyAsPermanent(Game game, Permanent permanent) {
// split by name and stats
// TODO: rework and combine with PermanentEvaluator (to use battlefield score)
// try to use short text/hash for lesser data on debug
return String.join(";", Arrays.asList(
permanent.getName(),
String.valueOf(permanent.getControllerId().hashCode()),
String.valueOf(permanent.getOwnerId().hashCode()),
String.valueOf(permanent.isTapped()),
String.valueOf(permanent.getPower().getValue()),
String.valueOf(permanent.getToughness().getValue()),
String.valueOf(permanent.getDamage()),
String.valueOf(permanent.getCardType(game).toString().hashCode()),
String.valueOf(permanent.getSubtype(game).toString().hashCode()),
String.valueOf(permanent.getCounters(game).getTotalCount()),
String.valueOf(permanent.getAbilities(game).size()),
String.valueOf(permanent.getRules(game).toString().hashCode())
));
}
private String getTargetGroupKeyAsCard(Game game, Card card) {
// split by name and stats
return String.join(";", Arrays.asList(
card.getName(),
String.valueOf(card.getOwnerId().hashCode()),
String.valueOf(card.getCardType(game).toString().hashCode()),
String.valueOf(card.getSubtype(game).toString().hashCode()),
String.valueOf(card.getCounters(game).getTotalCount()),
String.valueOf(card.getAbilities(game).size()),
String.valueOf(card.getRules(game).toString().hashCode())
));
}
private String getTargetGroupKeyAsOther(Game game, MageObject item) {
// use all
return String.join(";", Arrays.asList(
item.getName(),
String.valueOf(item.getId().hashCode())
));
}
/**
* Debug only. Print targets table and variations.
*/
private void printTargetsTableAndVariations(String info, Game game, Set<UUID> possibleTargets, List<TargetAmount> options, boolean isPrintOptions) {
if (!DebugUtil.AI_SHOW_TARGET_OPTIMIZATION_LOGS) return;
// output example:
//
// Targets (after optimize): 5
// 0. Balduvian Bears [ac8], C, BalduvianBears, DKM:22::0, 2/2
// 1. PlayerA (SimulatedPlayer2)
//
// Target variations (info): 126
// 0 -> 1; 1 -> 1; 2 -> 1; 3 -> 1; 4 -> 1
// 0 -> 1; 1 -> 1; 2 -> 1; 3 -> 2
// 0 -> 1; 1 -> 1; 2 -> 1; 4 -> 2
// print table
List<UUID> list = new ArrayList<>(possibleTargets);
Collections.sort(list);
HashMap<UUID, Integer> targetNumbers = new HashMap<>();
System.out.println();
System.out.println(String.format("Targets (%s): %d", info, list.size()));
for (int i = 0; i < list.size(); i++) {
targetNumbers.put(list.get(i), i);
String targetName;
Player player = game.getPlayer(list.get(i));
if (player != null) {
targetName = player.toString();
} else {
MageObject object = game.getObject(list.get(i));
if (object != null) {
targetName = object.toString();
} else {
targetName = "unknown";
}
}
System.out.println(String.format("%d. %s", i, targetName));
}
System.out.println();
if (!isPrintOptions) {
return;
}
// print amount variations
List<String> res = options
.stream()
.map(t -> t.getTargets()
.stream()
.map(id -> targetNumbers.get(id) + " -> " + t.getTargetAmount(id))
.sorted()
.collect(Collectors.joining("; ")))
.collect(Collectors.toList());
Collections.sort(res);
.collect(Collectors.joining("; "))).sorted().collect(Collectors.toList());
System.out.println();
System.out.println(res.stream().collect(Collectors.joining("\n")));
System.out.println(String.format("Target variations (info): %d", options.size()));
System.out.println(String.join("\n", res));
System.out.println();
}
protected void addTargets(TargetAmount target, Set<UUID> possibleTargets, List<TargetAmount> options, Ability source, Game game) {
if (!amountWasSet) {
setAmount(source, game);
}
final protected void addTargets(TargetAmount target, Set<UUID> possibleTargets, List<TargetAmount> options, Ability source, Game game) {
Set<UUID> usedTargets = new HashSet<>();
for (UUID targetId : possibleTargets) {
usedTargets.add(targetId);

View file

@ -446,13 +446,6 @@ public abstract class TargetImpl implements Target {
return !targets.isEmpty();
}
/**
* Returns all possible different target combinations
*
* @param source
* @param game
* @return
*/
@Override
public List<? extends TargetImpl> getTargetOptions(Ability source, Game game) {
List<TargetImpl> options = new ArrayList<>();

View file

@ -10,6 +10,7 @@ import mage.abilities.costs.VariableCost;
import mage.abilities.costs.mana.*;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.dynamicvalue.common.SavedDamageValue;
import mage.abilities.dynamicvalue.common.SavedDiscardValue;
import mage.abilities.dynamicvalue.common.SavedGainedLifeValue;
import mage.abilities.dynamicvalue.common.StaticValue;
import mage.abilities.effects.ContinuousEffect;
@ -967,7 +968,9 @@ public final class CardUtil {
boolean xValue = amount.toString().equals("X");
if (xValue) {
sb.append("X ").append(counter.getName()).append(" counters");
} else if (amount == SavedDamageValue.MANY || amount == SavedGainedLifeValue.MANY) {
} else if (amount == SavedDamageValue.MANY
|| amount == SavedGainedLifeValue.MANY
|| amount == SavedDiscardValue.MANY) {
sb.append("that many ").append(counter.getName()).append(" counters");
} else {
sb.append(counter.getDescription());
@ -1176,7 +1179,7 @@ public final class CardUtil {
.sum();
int remainingValue = maxValue - selectedValue;
Set<UUID> validTargets = new HashSet<>();
for (UUID id: possibleTargets) {
for (UUID id : possibleTargets) {
MageObject mageObject = game.getObject(id);
if (mageObject != null && valueMapper.applyAsInt(mageObject) <= remainingValue) {
validTargets.add(id);

View file

@ -11,6 +11,12 @@ public class DebugUtil {
public static boolean NETWORK_SHOW_CLIENT_CALLBACK_MESSAGES_LOG = false; // show all callback messages (server commands)
// AI
// game simulations runs in multiple threads, if you stop code to debug then it will be terminated by timeout
// so AI debug mode will make single simulation thread without any timeouts
public static boolean AI_ENABLE_DEBUG_MODE = false;
public static boolean AI_SHOW_TARGET_OPTIMIZATION_LOGS = false; // works with target amount
// cards basic (card panels)
public static boolean GUI_CARD_DRAW_OUTER_BORDER = false;
public static boolean GUI_CARD_DRAW_INNER_BORDER = false;