Adding dice rolling trigger/replacement (ready for review) (#7989)

* [AFR] Implemented DiceRolledTriggeredAbility
* [AFR] Implemented Brazen Dwarf
* [AFR] Implemented Feywild Trickster
* [AFC] Implemented Reckless Endeavor
* [AFR] Implemented Pixie Guide
* [AFR] Implemented Critical Hit
* [AFR] Implemented Netherese Puzzle Ward
* [AFC] Implemented Neverwinter Hydra
* [AFR] Implemented Farideh, Devil's Chosen
* [AFR] Implemented Barbarian Class
* [AFC] Implemented Vrondiss, Rage of Ancients
* [AFC] Implemented Arcane Endeavor
* Test framework: added planar die rolls support
* Test framework: added random results set up support in AI simulated games;
* AI: improved roll die results chooses in computer games;
* Roll die: improved combo support for planar die and roll die effects;

Co-authored-by: Daniel Bomar <dbdaniel42@gmail.com>
Co-authored-by: Oleg Agafonov <jaydi85@gmail.com>
This commit is contained in:
Evan Kranzler 2021-08-26 06:06:10 -04:00 committed by GitHub
parent 12219cff01
commit f8d030bef4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 2641 additions and 553 deletions

View file

@ -0,0 +1,63 @@
package mage.abilities.common;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.Effect;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.DiceRolledEvent;
import mage.game.events.GameEvent;
/**
* @author weirddan455
*/
public class OneOrMoreDiceRolledTriggeredAbility extends TriggeredAbilityImpl {
public OneOrMoreDiceRolledTriggeredAbility(Effect effect) {
this(effect, false);
}
public OneOrMoreDiceRolledTriggeredAbility(Effect effect, boolean optional) {
super(Zone.BATTLEFIELD, effect, optional);
}
private OneOrMoreDiceRolledTriggeredAbility(final OneOrMoreDiceRolledTriggeredAbility effect) {
super(effect);
}
@Override
public OneOrMoreDiceRolledTriggeredAbility copy() {
return new OneOrMoreDiceRolledTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.DICE_ROLLED;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (!isControlledBy(event.getPlayerId())) {
return false;
}
int maxRoll = ((DiceRolledEvent) event)
.getResults()
.stream()
.filter(Integer.class::isInstance) // only numerical die result can be masured
.map(Integer.class::cast)
.mapToInt(Integer::intValue)
.max()
.orElse(0);
this.getEffects().setValue("maxDieRoll", maxRoll);
return true;
}
@Override
public String getTriggerPhrase() {
return "Whenever you roll one or more dice, ";
}
@Override
public String getRule() {
return super.getRule();
}
}

View file

@ -1,6 +1,6 @@
package mage.abilities.effects.common;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.effects.OneShotEffect;
import mage.cards.Card;
@ -10,7 +10,6 @@ import mage.game.Game;
import mage.players.Player;
/**
*
* @author BetaSteward_at_googlemail.com
*/
public class ReturnSourceFromGraveyardToHandEffect extends OneShotEffect {
@ -32,11 +31,9 @@ public class ReturnSourceFromGraveyardToHandEffect extends OneShotEffect {
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
Card card = controller.getGraveyard().get(source.getSourceId(), game);
if (card != null) {
return controller.moveCards(card, Zone.HAND, source, game);
}
return false;
MageObject sourceObject = source.getSourceObjectIfItStillExists(game);
return controller != null
&& sourceObject instanceof Card
&& controller.moveCards((Card) sourceObject, Zone.HAND, source, game);
}
}

View file

@ -1,4 +1,3 @@
package mage.abilities.effects.common;
import mage.MageObject;
@ -12,7 +11,6 @@ import mage.game.Game;
import mage.players.Player;
/**
*
* @author spjspj
*/
public class RollDiceEffect extends OneShotEffect {
@ -47,7 +45,7 @@ public class RollDiceEffect extends OneShotEffect {
Player controller = game.getPlayer(source.getControllerId());
MageObject mageObject = game.getObject(source.getSourceId());
if (controller != null && mageObject != null) {
controller.rollDice(source, game, numSides);
controller.rollDice(outcome, source, game, numSides);
return true;
}
return false;
@ -58,8 +56,7 @@ public class RollDiceEffect extends OneShotEffect {
if (!staticText.isEmpty()) {
return staticText;
}
StringBuilder sb = new StringBuilder("Roll a " + numSides + " sided dice");
return sb.toString();
return "Roll a " + numSides + " sided die";
}
@Override

View file

@ -68,7 +68,7 @@ public class RollDieWithResultTableEffect extends OneShotEffect {
if (player == null) {
return false;
}
int result = player.rollDice(source, game, sides) + modifier.calculate(game, source, this);
int result = player.rollDice(outcome, source, game, sides) + modifier.calculate(game, source, this);
this.applyResult(result, game, source);
return true;
}

View file

@ -7,7 +7,7 @@ import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.Effect;
import mage.abilities.effects.OneShotEffect;
import mage.constants.Outcome;
import mage.constants.PlanarDieRoll;
import mage.constants.PlanarDieRollResult;
import mage.constants.Planes;
import mage.game.Game;
import mage.game.command.CommandObject;
@ -61,8 +61,8 @@ public class RollPlanarDieEffect extends OneShotEffect {
Player controller = game.getPlayer(source.getControllerId());
MageObject mageObject = game.getObject(source.getSourceId());
if (controller != null && mageObject != null) {
PlanarDieRoll planarRoll = controller.rollPlanarDie(source, game);
if (planarRoll == PlanarDieRoll.CHAOS_ROLL && chaosEffects != null && chaosTargets != null) {
PlanarDieRollResult planarRoll = controller.rollPlanarDie(outcome, source, game);
if (planarRoll == PlanarDieRollResult.CHAOS_ROLL && chaosEffects != null && chaosTargets != null) {
for (int i = 0; i < chaosTargets.size(); i++) {
Target target = chaosTargets.get(i);
if (target != null) {
@ -95,7 +95,7 @@ public class RollPlanarDieEffect extends OneShotEffect {
done = true;
}
}
} else if (planarRoll == PlanarDieRoll.PLANAR_ROLL) {
} else if (planarRoll == PlanarDieRollResult.PLANAR_ROLL) {
// Steps: 1) Remove the last plane and set its effects to discarded
for (CommandObject cobject : game.getState().getCommand()) {
if (cobject instanceof Plane) {

View file

@ -15,6 +15,7 @@ import mage.game.Game;
import mage.players.Player;
import mage.target.Target;
import mage.target.common.TargetCardInHand;
import mage.util.CardUtil;
import org.apache.log4j.Logger;
/**
@ -24,10 +25,14 @@ import org.apache.log4j.Logger;
* Allows player to choose to cast as card from hand without paying its mana
* cost.
* </p>
* TODO: this doesn't work correctly with MDFCs or Adventures (see https://github.com/magefree/mage/issues/7742)
*/
public class CastWithoutPayingManaCostEffect extends OneShotEffect {
private final DynamicValue manaCost;
private final FilterCard filter;
private static final FilterCard defaultFilter
= new FilterNonlandCard("card with mana value %mv or less from your hand");
/**
* @param maxCost Maximum converted mana cost for this effect to apply to
@ -37,8 +42,13 @@ public class CastWithoutPayingManaCostEffect extends OneShotEffect {
}
public CastWithoutPayingManaCostEffect(DynamicValue maxCost) {
this(maxCost, defaultFilter);
}
public CastWithoutPayingManaCostEffect(DynamicValue maxCost, FilterCard filter) {
super(Outcome.PlayForFree);
this.manaCost = maxCost;
this.filter = filter;
this.staticText = "you may cast a spell with mana value "
+ maxCost + " or less from your hand without paying its mana cost";
}
@ -46,50 +56,54 @@ public class CastWithoutPayingManaCostEffect extends OneShotEffect {
public CastWithoutPayingManaCostEffect(final CastWithoutPayingManaCostEffect effect) {
super(effect);
this.manaCost = effect.manaCost;
this.filter = effect.filter;
}
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller == null) {
return false;
}
int cmc = manaCost.calculate(game, source, this);
FilterCard filter = new FilterNonlandCard("card with mana value "
+ cmc + " or less from your hand");
FilterCard filter = this.filter.copy();
filter.setMessage(filter.getMessage().replace("%mv", "" + cmc));
filter.add(new ManaValuePredicate(ComparisonType.FEWER_THAN, cmc + 1));
Target target = new TargetCardInHand(filter);
if (target.canChoose(source.getSourceId(), controller.getId(), game)
&& controller.chooseUse(Outcome.PlayForFree, "Cast a card with mana value " + cmc
+ " or less from your hand without paying its mana cost?", source, game)) {
Card cardToCast = null;
boolean cancel = false;
while (controller.canRespond()
&& !cancel) {
if (controller.chooseTarget(Outcome.PlayForFree, target, source, game)) {
cardToCast = game.getCard(target.getFirstTarget());
if (cardToCast != null) {
if (cardToCast.getSpellAbility() == null) {
Logger.getLogger(CastWithoutPayingManaCostEffect.class).fatal("Card: "
+ cardToCast.getName() + " is no land and has no spell ability!");
cancel = true;
}
if (cardToCast.getSpellAbility().canChooseTarget(game, controller.getId())) {
cancel = true;
}
if (!target.canChoose(
source.getSourceId(), controller.getId(), game
) || !controller.chooseUse(
Outcome.PlayForFree,
"Cast " + CardUtil.addArticle(filter.getMessage())
+ " without paying its mana cost?", source, game
)) {
return true;
}
Card cardToCast = null;
boolean cancel = false;
while (controller.canRespond()
&& !cancel) {
if (controller.chooseTarget(Outcome.PlayForFree, target, source, game)) {
cardToCast = game.getCard(target.getFirstTarget());
if (cardToCast != null) {
if (cardToCast.getSpellAbility() == null) {
Logger.getLogger(CastWithoutPayingManaCostEffect.class).fatal("Card: "
+ cardToCast.getName() + " is no land and has no spell ability!");
cancel = true;
}
if (cardToCast.getSpellAbility().canChooseTarget(game, controller.getId())) {
cancel = true;
}
} else {
cancel = true;
}
} else {
cancel = true;
}
if (cardToCast != null) {
game.getState().setValue("PlayFromNotOwnHandZone" + cardToCast.getId(), Boolean.TRUE);
controller.cast(controller.chooseAbilityForCast(cardToCast, game, true),
game, true, new ApprovingObject(source, game));
game.getState().setValue("PlayFromNotOwnHandZone" + cardToCast.getId(), null);
}
}
if (cardToCast != null) {
game.getState().setValue("PlayFromNotOwnHandZone" + cardToCast.getId(), Boolean.TRUE);
controller.cast(controller.chooseAbilityForCast(cardToCast, game, true),
game, true, new ApprovingObject(source, game));
game.getState().setValue("PlayFromNotOwnHandZone" + cardToCast.getId(), null);
}
return true;
}

View file

@ -255,8 +255,10 @@ public class ChoiceImpl implements Choice {
for (String needChoice : answers) {
for (Map.Entry<String, String> currentChoice : this.getKeyChoices().entrySet()) {
if (currentChoice.getKey().equals(needChoice)) {
this.setChoiceByKey(needChoice, false);
answers.remove(needChoice);
if (removeSelectAnswerFromList) {
this.setChoiceByKey(needChoice, false);
answers.remove(needChoice);
}
return true;
}
@ -266,8 +268,10 @@ public class ChoiceImpl implements Choice {
for (String needChoice : answers) {
for (Map.Entry<String, String> currentChoice : this.getKeyChoices().entrySet()) {
if (currentChoice.getValue().startsWith(needChoice)) {
this.setChoiceByKey(currentChoice.getKey(), false);
answers.remove(needChoice);
if (removeSelectAnswerFromList) {
this.setChoiceByKey(currentChoice.getKey(), false);
answers.remove(needChoice);
}
return true;
}
}
@ -277,8 +281,10 @@ public class ChoiceImpl implements Choice {
for (String needChoice : answers) {
for (String currentChoice : this.getChoices()) {
if (currentChoice.equals(needChoice)) {
this.setChoice(needChoice, false);
answers.remove(needChoice);
if (removeSelectAnswerFromList) {
this.setChoice(needChoice, false);
answers.remove(needChoice);
}
return true;
}
}

View file

@ -1,23 +0,0 @@
package mage.constants;
/**
*
* @author spjspj
*/
public enum PlanarDieRoll {
NIL_ROLL("Blank Roll"),
CHAOS_ROLL("Chaos Roll"),
PLANAR_ROLL("Planar Roll");
private final String text;
PlanarDieRoll(String text) {
this.text = text;
}
@Override
public String toString() {
return text;
}
}

View file

@ -0,0 +1,29 @@
package mage.constants;
/**
*
* @author spjspj
*/
public enum PlanarDieRollResult {
BLANK_ROLL("Blank Roll", 0),
CHAOS_ROLL("Chaos Roll", 2),
PLANAR_ROLL("Planar Roll", 1);
private final String text;
private final int aiPriority; // priority for AI usage (0 - lower, 2 - higher)
PlanarDieRollResult(String text, int aiPriority) {
this.text = text;
this.aiPriority = aiPriority;
}
@Override
public String toString() {
return text;
}
public int getAIPriority() {
return aiPriority;
}
}

View file

@ -0,0 +1,11 @@
package mage.constants;
/**
* @author JayDi85
*/
public enum RollDieType {
NUMERICAL,
PLANAR
}

View file

@ -52,10 +52,14 @@ public class GameOptions implements Serializable, Copyable<GameOptions> {
*/
public Set<String> bannedUsers = Collections.emptySet();
/**
* Use planechase variant
*/
// PLANECHASE game mode
public boolean planeChase = false;
// xmage uses increased by 1/3 chances (2/2/9) for chaos/planar result, see 1a9f12f5767ce0beeed26a8ff5c8a8f9490c9c47
// if you need combo support with 6-sides rolls then it can be reset to original values
public static final int PLANECHASE_PLANAR_DIE_CHAOS_SIDES = 2; // original: 1
public static final int PLANECHASE_PLANAR_DIE_PLANAR_SIDES = 2; // original: 1
public static final int PLANECHASE_PLANAR_DIE_TOTAL_SIDES = 9; // original: 6
public GameOptions() {
super();

View file

@ -0,0 +1,29 @@
package mage.game.events;
import mage.abilities.Ability;
import java.util.ArrayList;
import java.util.List;
/**
* @author TheElk801
*/
public class DiceRolledEvent extends GameEvent {
private final int sides;
private final List<Object> results = new ArrayList<>(); // Integer for numerical and PlanarDieRollResult for planar
public DiceRolledEvent(int sides, List<Object> results, Ability source) {
super(EventType.DICE_ROLLED, source.getControllerId(), source, source.getControllerId());
this.sides = sides;
this.results.addAll(results);
}
public int getSides() {
return sides;
}
public List<Object> getResults() {
return results;
}
}

View file

@ -0,0 +1,50 @@
package mage.game.events;
import mage.abilities.Ability;
import mage.constants.PlanarDieRollResult;
import mage.constants.RollDieType;
/**
* @author TheElk801, JayDi85
*/
public class DieRolledEvent extends GameEvent {
// 706.2.
// After the roll, the number indicated on the top face of the die before any modifiers is
// the natural result. The instruction may include modifiers to the roll which add to or
// subtract from the natural result. Modifiers may also come from other sources. After
// considering all applicable modifiers, the final number is the result of the die roll.
private final RollDieType rollDieType;
private final int sides;
private final int naturalResult; // planar die returns 0 values in result and natural result
private final PlanarDieRollResult planarResult;
public DieRolledEvent(Ability source, RollDieType rollDieType, int sides, int naturalResult, int modifier, PlanarDieRollResult planarResult) {
super(EventType.DIE_ROLLED, source.getControllerId(), source, source.getControllerId(), naturalResult + modifier, false);
this.rollDieType = rollDieType;
this.sides = sides;
this.naturalResult = naturalResult;
this.planarResult = planarResult;
}
public RollDieType getRollDieType() {
return rollDieType;
}
public int getSides() {
return sides;
}
public int getResult() {
return amount;
}
public int getNaturalResult() {
return naturalResult;
}
public PlanarDieRollResult getPlanarResult() {
return planarResult;
}
}

View file

@ -296,8 +296,9 @@ public class GameEvent implements Serializable {
SURVEIL, SURVEILED,
FATESEALED,
FLIP_COIN, COIN_FLIPPED,
REPLACE_ROLLED_DIE, // for Clam-I-Am workaround only
ROLL_DIE, DIE_ROLLED,
ROLL_DICE, DICE_ROLLED,
ROLL_PLANAR_DIE, PLANAR_DIE_ROLLED,
PLANESWALK, PLANESWALKED,
PAID_CUMULATIVE_UPKEEP,
DIDNT_PAY_CUMULATIVE_UPKEEP,
@ -621,7 +622,7 @@ public class GameEvent implements Serializable {
/**
* used to store which replacement effects were already applied to an event
* or or any modified events that may replace it
* or any modified events that may replace it
* <p>
* 614.5. A replacement effect doesn't invoke itself repeatedly; it gets
* only one opportunity to affect an event or any modified events that may

View file

@ -0,0 +1,41 @@
package mage.game.events;
import mage.abilities.Ability;
import mage.constants.RollDieType;
import mage.util.CardUtil;
/**
* @author TheElk801
*/
public class RollDiceEvent extends GameEvent {
private final int sides;
private int ignoreLowestAmount = 0; // ignore the lowest results
private final RollDieType rollDieType;
public RollDiceEvent(Ability source, RollDieType rollDieType, int sides, int rollsAmount) {
super(EventType.ROLL_DICE, source.getControllerId(), source, source.getControllerId(), rollsAmount, false);
this.sides = sides;
this.rollDieType = rollDieType;
}
public int getSides() {
return sides;
}
public RollDieType getRollDieType() {
return rollDieType;
}
public void incAmount(int additionalAmount) {
this.amount = CardUtil.overflowInc(this.amount, additionalAmount);
}
public void incIgnoreLowestAmount(int additionalCount) {
this.ignoreLowestAmount = CardUtil.overflowInc(this.ignoreLowestAmount, additionalCount);
}
public int getIgnoreLowestAmount() {
return ignoreLowestAmount;
}
}

View file

@ -0,0 +1,56 @@
package mage.game.events;
import mage.abilities.Ability;
import mage.constants.RollDieType;
import mage.util.CardUtil;
/**
* @author TheElk801
*/
public class RollDieEvent extends GameEvent {
private final RollDieType rollDieType;
private final int sides;
private int resultModifier = 0;
private int rollsAmount = 1; // rolls X times and choose result from it
private int bigIdeaRollsAmount = 0; // rolls 2x and sum result
public RollDieEvent(Ability source, RollDieType rollDieType, int sides) {
super(EventType.ROLL_DIE, source.getControllerId(), source, source.getControllerId());
this.rollDieType = rollDieType;
this.sides = sides;
}
public int getResultModifier() {
return resultModifier;
}
public void incResultModifier(int modifier) {
this.resultModifier = CardUtil.overflowInc(this.resultModifier, modifier);
}
public RollDieType getRollDieType() {
return rollDieType;
}
public int getSides() {
return sides;
}
public int getRollsAmount() {
return rollsAmount;
}
public void doubleRollsAmount() {
this.rollsAmount = CardUtil.overflowMultiply(this.rollsAmount, 2);
}
public int getBigIdeaRollsAmount() {
return bigIdeaRollsAmount;
}
public void incBigIdeaRollsAmount() {
this.bigIdeaRollsAmount = CardUtil.overflowInc(this.bigIdeaRollsAmount, 1);
}
}

View file

@ -0,0 +1,33 @@
package mage.game.permanent.token;
import mage.MageInt;
import mage.abilities.keyword.FlyingAbility;
import mage.constants.CardType;
import mage.constants.SubType;
/**
*
* @author weirddan455
*/
public class FaerieDragonToken extends TokenImpl {
public FaerieDragonToken() {
super("Faerie Dragon", "1/1 blue Faerie Dragon creature token with flying");
cardType.add(CardType.CREATURE);
color.setBlue(true);
subtype.add(SubType.FAERIE);
subtype.add(SubType.DRAGON);
power = new MageInt(1);
toughness = new MageInt(1);
this.addAbility(FlyingAbility.getInstance());
}
private FaerieDragonToken(final FaerieDragonToken token) {
super(token);
}
@Override
public FaerieDragonToken copy() {
return new FaerieDragonToken(this);
}
}

View file

@ -0,0 +1,65 @@
package mage.game.permanent.token;
import mage.MageInt;
import mage.abilities.TriggeredAbilityImpl;
import mage.abilities.effects.common.SacrificeSourceEffect;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
public final class VrondissRageOfAncientsToken extends TokenImpl {
public VrondissRageOfAncientsToken() {
super("Dragon Spirit", "5/4 red and green Dragon Spirit creature token with \"When this creature deals damage, sacrifice it.\"");
cardType.add(CardType.CREATURE);
color.setRed(true);
color.setGreen(true);
subtype.add(SubType.DRAGON);
subtype.add(SubType.SPIRIT);
power = new MageInt(5);
toughness = new MageInt(4);
this.addAbility(new VrondissRageOfAncientsTokenTriggeredAbility());
}
public VrondissRageOfAncientsToken(final VrondissRageOfAncientsToken token) {
super(token);
}
public VrondissRageOfAncientsToken copy() {
return new VrondissRageOfAncientsToken(this);
}
}
class VrondissRageOfAncientsTokenTriggeredAbility extends TriggeredAbilityImpl {
public VrondissRageOfAncientsTokenTriggeredAbility() {
super(Zone.BATTLEFIELD, new SacrificeSourceEffect(), false);
}
public VrondissRageOfAncientsTokenTriggeredAbility(final VrondissRageOfAncientsTokenTriggeredAbility ability) {
super(ability);
}
@Override
public VrondissRageOfAncientsTokenTriggeredAbility copy() {
return new VrondissRageOfAncientsTokenTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.DAMAGED_PLAYER
|| event.getType() == GameEvent.EventType.DAMAGED_PERMANENT;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
return event.getSourceId().equals(this.getSourceId());
}
@Override
public String getRule() {
return "When this creature deals damage, sacrifice it.";
}
}

View file

@ -24,10 +24,7 @@ import mage.designations.DesignationType;
import mage.filter.FilterCard;
import mage.filter.FilterMana;
import mage.filter.FilterPermanent;
import mage.game.Game;
import mage.game.GameState;
import mage.game.Graveyard;
import mage.game.Table;
import mage.game.*;
import mage.game.combat.CombatGroup;
import mage.game.draft.Draft;
import mage.game.events.GameEvent;
@ -487,19 +484,21 @@ public interface Player extends MageItem, Copyable<Player> {
boolean flipCoin(Ability source, Game game, boolean winnable);
boolean flipCoin(Ability source, Game game, boolean winnable, List<UUID> appliedEffects);
boolean flipCoinResult(Game game);
int rollDice(Ability source, Game game, int numSides);
default int rollDice(Outcome outcome, Ability source, Game game, int numSides) {
return rollDice(outcome, source, game, numSides, 1, 0).stream().findFirst().orElse(0);
}
int rollDice(Ability source, Game game, List<UUID> appliedEffects, int numSides);
List<Integer> rollDice(Outcome outcome, Ability source, Game game, int numSides, int numDice, int ignoreLowestAmount);
PlanarDieRoll rollPlanarDie(Ability source, Game game);
int rollDieResult(int sides, Game game);
PlanarDieRoll rollPlanarDie(Ability source, Game game, List<UUID> appliedEffects);
default PlanarDieRollResult rollPlanarDie(Outcome outcome, Ability source, Game game) {
return rollPlanarDie(outcome, source, game, GameOptions.PLANECHASE_PLANAR_DIE_CHAOS_SIDES, GameOptions.PLANECHASE_PLANAR_DIE_PLANAR_SIDES);
}
PlanarDieRoll rollPlanarDie(Ability source, Game game, List<UUID> appliedEffects, int numberChaosSides, int numberPlanarSides);
PlanarDieRollResult rollPlanarDie(Outcome outcome, Ability source, Game game, int numberChaosSides, int numberPlanarSides);
Card discardOne(boolean random, boolean payForCost, Ability source, Game game);

View file

@ -22,6 +22,8 @@ import mage.abilities.mana.ManaOptions;
import mage.actions.MageDrawAction;
import mage.cards.*;
import mage.cards.decks.Deck;
import mage.choices.Choice;
import mage.choices.ChoiceImpl;
import mage.constants.*;
import mage.counters.Counter;
import mage.counters.CounterType;
@ -2438,7 +2440,7 @@ public abstract class PlayerImpl implements Player, Serializable {
userData.resetRequestedHandPlayersList(game.getId()); // users can send request again
break;
}
logger.trace("PASS Priority: " + playerAction.toString());
logger.trace("PASS Priority: " + playerAction);
}
@Override
@ -2786,21 +2788,15 @@ public abstract class PlayerImpl implements Player, Serializable {
return casted;
}
@Override
public boolean flipCoin(Ability source, Game game, boolean winnable) {
return this.flipCoin(source, game, winnable, null);
}
/**
* @param source
* @param game
* @param winnable
* @param appliedEffects
* @return if winnable, true if player won the toss, if not winnable, true
* for heads and false for tails
*/
@Override
public boolean flipCoin(Ability source, Game game, boolean winnable, List<UUID> appliedEffects) {
public boolean flipCoin(Ability source, Game game, boolean winnable) {
boolean chosen = false;
if (winnable) {
chosen = this.chooseUse(Outcome.Benefit, "Heads or tails?", "", "Heads", "Tails", source, game);
@ -2808,7 +2804,6 @@ public abstract class PlayerImpl implements Player, Serializable {
}
boolean result = this.flipCoinResult(game);
FlipCoinEvent event = new FlipCoinEvent(playerId, source, result, chosen, winnable);
event.addAppliedEffects(appliedEffects);
game.replaceEvent(event);
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(event.getResult())
+ CardUtil.getSourceLogName(game, source));
@ -2835,7 +2830,6 @@ public abstract class PlayerImpl implements Player, Serializable {
game.informPlayers(getLogName() + " " + (event.getResult() == event.getChosen() ? "won" : "lost") + " the flip"
+ CardUtil.getSourceLogName(game, source));
}
event.setAppliedEffects(appliedEffects);
game.fireEvent(event.createFlippedEvent());
if (event.isWinnable()) {
return event.getResult() == event.getChosen();
@ -2853,85 +2847,334 @@ public abstract class PlayerImpl implements Player, Serializable {
return RandomUtil.nextBoolean();
}
private static final class RollDieResult {
// 706.2.
// After the roll, the number indicated on the top face of the die before any modifiers is
// the natural result. The instruction may include modifiers to the roll which add to or
// subtract from the natural result. Modifiers may also come from other sources. After
// considering all applicable modifiers, the final number is the result of the die roll.
private final int naturalResult;
private final int modifier;
private final PlanarDieRollResult planarResult;
RollDieResult(int naturalResult, int modifier, PlanarDieRollResult planarResult) {
this.naturalResult = naturalResult;
this.modifier = modifier;
this.planarResult = planarResult;
}
public int getResult() {
return this.naturalResult + this.modifier;
}
public PlanarDieRollResult getPlanarResult() {
return this.planarResult;
}
}
@Override
public int rollDice(Ability source, Game game, int numSides) {
return this.rollDice(source, game, null, numSides);
public int rollDieResult(int sides, Game game) {
return RandomUtil.nextInt(sides) + 1;
}
/**
* Roll single die. Support both die types: planar and numerical.
*
* @param outcome
* @param game
* @param source
* @param rollDieType
* @param sidesAmount
* @param chaosSidesAmount
* @param planarSidesAmount
* @param rollsAmount
* @return
*/
private Object rollDieInner(Outcome outcome, Game game, Ability source, RollDieType rollDieType,
int sidesAmount, int chaosSidesAmount, int planarSidesAmount, int rollsAmount) {
if (rollsAmount == 1) {
return rollDieInnerWithReplacement(game, source, rollDieType, sidesAmount, chaosSidesAmount, planarSidesAmount);
}
Set<Object> choices = new HashSet<>();
for (int j = 0; j < rollsAmount; j++) {
choices.add(rollDieInnerWithReplacement(game, source, rollDieType, sidesAmount, chaosSidesAmount, planarSidesAmount));
}
if (choices.size() == 1) {
return choices.stream().findFirst().orElse(0);
}
// AI hint - use max/min values
if (this.isComputer()) {
if (rollDieType == RollDieType.NUMERICAL) {
// numerical
if (outcome.isGood()) {
return choices.stream()
.map(Integer.class::cast)
.max(Comparator.naturalOrder())
.orElse(null);
} else {
return choices.stream()
.map(Integer.class::cast)
.min(Comparator.naturalOrder())
.orElse(null);
}
} else {
// planar
// priority: chaos -> planar -> blank
return choices.stream()
.map(PlanarDieRollResult.class::cast)
.max(Comparator.comparingInt(PlanarDieRollResult::getAIPriority))
.orElse(null);
}
}
Choice choice = new ChoiceImpl(true);
choice.setMessage("Choose which die roll result to keep (the rest will be ignored)");
choice.setChoices(choices.stream().sorted().map(Object::toString).collect(Collectors.toSet()));
this.choose(Outcome.Neutral, choice, game);
Object defaultChoice = choices.iterator().next();
return choices.stream()
.filter(o -> o.toString().equals(choice.getChoice()))
.findFirst()
.orElse(defaultChoice);
}
private Object rollDieInnerWithReplacement(Game game, Ability source, RollDieType rollDieType, int numSides, int numChaosSides, int numPlanarSides) {
switch (rollDieType) {
case NUMERICAL: {
int result = rollDieResult(numSides, game);
// Clam-I-Am workaround:
// If you roll a 3 on a six-sided die, you may reroll that die.
if (numSides == 6
&& result == 3
&& game.replaceEvent(GameEvent.getEvent(GameEvent.EventType.REPLACE_ROLLED_DIE, source.getControllerId(), source, source.getControllerId()))
&& chooseUse(Outcome.Neutral, "Re-roll the 3?", source, game)) {
result = rollDieResult(numSides, game);
}
return result;
}
case PLANAR: {
if (numChaosSides + numPlanarSides > numSides) {
numChaosSides = GameOptions.PLANECHASE_PLANAR_DIE_CHAOS_SIDES;
numPlanarSides = GameOptions.PLANECHASE_PLANAR_DIE_PLANAR_SIDES;
}
// for 9 sides:
// 1..2 - chaos
// 3..7 - blank
// 8..9 - planar
int result = this.rollDieResult(numSides, game);
PlanarDieRollResult roll;
if (result <= numChaosSides) {
roll = PlanarDieRollResult.CHAOS_ROLL;
} else if (result > numSides - numPlanarSides) {
roll = PlanarDieRollResult.PLANAR_ROLL;
} else {
roll = PlanarDieRollResult.BLANK_ROLL;
}
return roll;
}
default: {
throw new IllegalArgumentException("Unknown roll die type " + rollDieType);
}
}
}
/**
* @param outcome
* @param source
* @param game
* @param sidesAmount number of sides the dice has
* @param rollsAmount number of tries to roll the dice
* @param ignoreLowestAmount remove the lowest rolls from the results
* @return the number that the player rolled
*/
@Override
public List<Integer> rollDice(Outcome outcome, Ability source, Game game, int sidesAmount, int rollsAmount, int ignoreLowestAmount) {
return rollDiceInner(outcome, source, game, RollDieType.NUMERICAL, sidesAmount, 0, 0, rollsAmount, ignoreLowestAmount)
.stream()
.map(Integer.class::cast)
.collect(Collectors.toList());
}
/**
* Inner code to roll a dice. Support normal and planar types.
*
* @param outcome
* @param source
* @param game
* @param rollDieType die type to roll, e.g. planar or numerical
* @param sidesAmount sides per die
* @param chaosSidesAmount for planar die: chaos sides
* @param planarSidesAmount for planar die: planar sides
* @param rollsAmount rolls
* @param ignoreLowestAmount for numerical die: ignore multiple rolls with the lowest values
* @return
*/
private List<Object> rollDiceInner(Outcome outcome, Ability source, Game game, RollDieType rollDieType,
int sidesAmount, int chaosSidesAmount, int planarSidesAmount,
int rollsAmount, int ignoreLowestAmount) {
RollDiceEvent rollDiceEvent = new RollDiceEvent(source, rollDieType, sidesAmount, rollsAmount);
if (ignoreLowestAmount > 0) {
rollDiceEvent.incIgnoreLowestAmount(ignoreLowestAmount);
}
game.replaceEvent(rollDiceEvent);
// 706.6.
// In a Planechase game, rolling the planar die will cause any ability that triggers whenever a
// player rolls one or more dice to trigger. However, any effect that refers to a numerical
// result of a die roll, including ones that compare the results of that roll to other rolls
// or to a given number, ignores the rolling of the planar die. See rule 901, Planechase.
// ROLL MULTIPLE dies
// results amount can be less than a rolls amount (example: The Big Idea allows rolling 2x instead 1x)
List<Object> dieResults = new ArrayList<>();
List<RollDieResult> dieRolls = new ArrayList<>();
for (int i = 0; i < rollDiceEvent.getAmount(); i++) {
// ROLL SINGLE die
RollDieEvent rollDieEvent = new RollDieEvent(source, rollDiceEvent.getRollDieType(), rollDiceEvent.getSides());
game.replaceEvent(rollDieEvent);
Object rollResult;
// big idea logic for numerical rolls only
if (rollDieEvent.getRollDieType() == RollDieType.NUMERICAL && rollDieEvent.getBigIdeaRollsAmount() > 0) {
// rolls 2x + sum results
// The Big Idea: roll two six-sided dice and use the total of those results
int totalSum = 0;
for (int j = 0; j < rollDieEvent.getBigIdeaRollsAmount() + 1; j++) {
int singleResult = (Integer) rollDieInner(
outcome,
game,
source,
rollDieEvent.getRollDieType(),
rollDieEvent.getSides(),
chaosSidesAmount,
planarSidesAmount,
rollDieEvent.getRollsAmount());
totalSum += singleResult;
dieRolls.add(new RollDieResult(singleResult, rollDieEvent.getResultModifier(), null));
}
rollResult = totalSum;
} else {
// rolls 1x
switch (rollDieEvent.getRollDieType()) {
default:
case NUMERICAL: {
int naturalResult = (Integer) rollDieInner(
outcome,
game,
source,
rollDieEvent.getRollDieType(),
rollDieEvent.getSides(),
chaosSidesAmount,
planarSidesAmount,
rollDieEvent.getRollsAmount()
);
dieRolls.add(new RollDieResult(naturalResult, rollDieEvent.getResultModifier(), null));
rollResult = naturalResult;
break;
}
case PLANAR: {
PlanarDieRollResult planarResult = (PlanarDieRollResult) rollDieInner(
outcome,
game,
source,
rollDieEvent.getRollDieType(),
rollDieEvent.getSides(),
chaosSidesAmount,
planarSidesAmount,
rollDieEvent.getRollsAmount()
);
dieRolls.add(new RollDieResult(0, 0, planarResult));
rollResult = planarResult;
break;
}
}
}
dieResults.add(rollResult);
}
// ignore the lowest results
// planar dies: due to 706.6. planar die results must be fully ignored
//
// 706.5.
// If a player is instructed to roll two or more dice and ignore the lowest roll, the roll
// that yielded the lowest result is considered to have never happened. No abilities trigger
// because of the ignored roll, and no effects apply to that roll. If multiple results are tied
// for the lowest, the player chooses one of those rolls to be ignored.
if (rollDiceEvent.getRollDieType() == RollDieType.NUMERICAL && rollDiceEvent.getIgnoreLowestAmount() > 0) {
// find ignored values
List<Integer> ignoredResults = new ArrayList<>();
for (int i = 0; i < rollDiceEvent.getIgnoreLowestAmount(); i++) {
int min = dieResults.stream().map(Integer.class::cast).mapToInt(Integer::intValue).min().orElse(0);
dieResults.remove(Integer.valueOf(min));
ignoredResults.add(min);
}
// remove ignored rolls (they not exist anymore)
List<RollDieResult> newRolls = new ArrayList<>();
for (RollDieResult rollDieResult : dieRolls) {
if (ignoredResults.contains(rollDieResult.getResult())) {
ignoredResults.remove((Integer) rollDieResult.getResult());
} else {
newRolls.add(rollDieResult);
}
}
dieRolls.clear();
dieRolls.addAll(newRolls);
}
// raise affected roll events
for (RollDieResult result : dieRolls) {
game.fireEvent(new DieRolledEvent(source, rollDiceEvent.getRollDieType(), rollDiceEvent.getSides(), result.naturalResult, result.modifier, result.planarResult));
}
game.fireEvent(new DiceRolledEvent(rollDiceEvent.getSides(), dieResults, source));
String message;
switch (rollDiceEvent.getRollDieType()) {
default:
case NUMERICAL:
// [Roll a die] user rolled 2x d6 and got [1, 4] (source: xxx)
message = String.format("[Roll a die] %s rolled %s %s and got [%s]%s",
getLogName(),
(dieResults.size() > 1 ? dieResults.size() + "x" : "a"),
"d" + rollDiceEvent.getSides(),
dieResults.stream().map(Object::toString).collect(Collectors.joining(", ")),
CardUtil.getSourceLogName(game, source));
break;
case PLANAR:
// [Roll a planar die] user rolled CHAOS (source: xxx)
message = String.format("[Roll a planar die] %s rolled [%s]%s",
getLogName(),
dieResults.stream().map(Object::toString).collect(Collectors.joining(", ")),
CardUtil.getSourceLogName(game, source));
break;
}
game.informPlayers(message);
return dieResults;
}
/**
* @param source
* @param game
* @param appliedEffects
* @param numSides Number of sides the dice has
* @return the number that the player rolled
*/
@Override
public int rollDice(Ability source, Game game, List<UUID> appliedEffects, int numSides) {
int result = RandomUtil.nextInt(numSides) + 1;
if (!game.isSimulation()) {
game.informPlayers("[Roll a die] " + getLogName() + " rolled a "
+ result + " on a " + numSides + " sided die" + CardUtil.getSourceLogName(game, source));
}
GameEvent event = new GameEvent(GameEvent.EventType.ROLL_DICE, playerId, source, playerId, result, true);
event.setAppliedEffects(appliedEffects);
event.setAmount(result);
event.setData(numSides + "");
if (!game.replaceEvent(event)) {
GameEvent ge = new GameEvent(GameEvent.EventType.DICE_ROLLED, playerId, source, playerId, event.getAmount(), event.getFlag());
ge.setData(numSides + "");
game.fireEvent(ge);
}
return event.getAmount();
}
@Override
public PlanarDieRoll rollPlanarDie(Ability source, Game game) {
return this.rollPlanarDie(source, game, null);
}
@Override
public PlanarDieRoll rollPlanarDie(Ability source, Game game, List<UUID> appliedEffects) {
return rollPlanarDie(source, game, appliedEffects, 2, 2);
}
/**
* @param game
* @param appliedEffects
* @param numberChaosSides The number of chaos sides the planar die
* @param chaosSidesAmount The number of chaos sides the planar die
* currently has (normally 1 but can be 5)
* @param numberPlanarSides The number of chaos sides the planar die
* @param planarSidesAmount The number of chaos sides the planar die
* currently has (normally 1)
* @return the outcome that the player rolled. Either ChaosRoll, PlanarRoll
* or NilRoll
* or BlankRoll
*/
@Override
public PlanarDieRoll rollPlanarDie(Ability source, Game game, List<UUID> appliedEffects, int numberChaosSides, int numberPlanarSides) {
int result = RandomUtil.nextInt(9) + 1;
PlanarDieRoll roll = PlanarDieRoll.NIL_ROLL;
if (numberChaosSides + numberPlanarSides > 9) {
numberChaosSides = 2;
numberPlanarSides = 2;
}
if (result <= numberChaosSides) {
roll = PlanarDieRoll.CHAOS_ROLL;
} else if (result > 9 - numberPlanarSides) {
roll = PlanarDieRoll.PLANAR_ROLL;
}
if (!game.isSimulation()) {
game.informPlayers("[Roll the planar die] " + getLogName()
+ " rolled a " + roll + " on the planar die" + CardUtil.getSourceLogName(game, source));
}
GameEvent event = new GameEvent(GameEvent.EventType.ROLL_PLANAR_DIE,
playerId, source, playerId, result, true);
event.setAppliedEffects(appliedEffects);
event.setData(roll + "");
if (!game.replaceEvent(event)) {
GameEvent ge = new GameEvent(GameEvent.EventType.PLANAR_DIE_ROLLED,
playerId, source, playerId, event.getAmount(), event.getFlag());
ge.setData(roll + "");
game.fireEvent(ge);
}
return roll;
public PlanarDieRollResult rollPlanarDie(Outcome outcome, Ability source, Game game, int chaosSidesAmount, int planarSidesAmount) {
return rollDiceInner(outcome, source, game, RollDieType.PLANAR, GameOptions.PLANECHASE_PLANAR_DIE_TOTAL_SIDES, chaosSidesAmount, planarSidesAmount, 1, 0)
.stream()
.map(o -> (PlanarDieRollResult) o)
.findFirst()
.orElse(PlanarDieRollResult.BLANK_ROLL);
}
@Override
@ -3539,15 +3782,12 @@ public abstract class PlayerImpl implements Player, Serializable {
boolean canActivateAsHandZone = approvingObject != null
|| (fromZone == Zone.GRAVEYARD && canPlayCardsFromGraveyard());
boolean possibleToPlay = false;
boolean possibleToPlay = canActivateAsHandZone
&& ability.getZone().match(Zone.HAND)
&& (isPlaySpell || isPlayLand);
// spell/hand abilities (play from all zones)
// need permitingObject or canPlayCardsFromGraveyard
if (canActivateAsHandZone
&& ability.getZone().match(Zone.HAND)
&& (isPlaySpell || isPlayLand)) {
possibleToPlay = true;
}
// zone's abilities (play from specific zone)
// no need in permitingObject
@ -4275,7 +4515,7 @@ public abstract class PlayerImpl implements Player, Serializable {
}
break;
default:
throw new UnsupportedOperationException("to Zone" + toZone.toString() + " not supported yet");
throw new UnsupportedOperationException("to Zone" + toZone + " not supported yet");
}
return !successfulMovedCards.isEmpty();
}

View file

@ -1,7 +1,9 @@
package mage.watchers.common;
import mage.constants.RollDieType;
import mage.constants.WatcherScope;
import mage.game.Game;
import mage.game.events.DieRolledEvent;
import mage.game.events.GameEvent;
import mage.watchers.Watcher;
@ -25,9 +27,10 @@ public class PlanarRollWatcher extends Watcher {
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.PLANAR_DIE_ROLLED) {
UUID playerId = event.getPlayerId();
if (playerId != null) {
if (event.getType() == GameEvent.EventType.DIE_ROLLED) {
DieRolledEvent drEvent = (DieRolledEvent) event;
UUID playerId = drEvent.getPlayerId();
if (playerId != null && drEvent.getRollDieType() == RollDieType.PLANAR) {
Integer amount = numberTimesPlanarDieRolled.get(playerId);
if (amount == null) {
amount = 1;