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

@ -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();
}