[FIN] Implement Edgar, King of Figaro, rework coin flips (#13672)

* add method for multiple coin flips

* [FIN] Implement Edgar, King of Figaro

* add extra note

* update coin flip logic

* add test
This commit is contained in:
Evan Kranzler 2025-05-27 21:56:23 -04:00 committed by GitHub
parent e1f4e9db59
commit 136988de29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 345 additions and 87 deletions

View file

@ -0,0 +1,121 @@
package mage.cards.e;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.EntersBattlefieldTriggeredAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.dynamicvalue.common.ArtifactYouControlCount;
import mage.abilities.effects.ReplacementEffectImpl;
import mage.abilities.effects.common.DrawCardSourceControllerEffect;
import mage.abilities.hint.common.ArtifactYouControlHint;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.*;
import mage.game.Game;
import mage.game.events.FlipCoinsEvent;
import mage.game.events.GameEvent;
import mage.watchers.Watcher;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* @author TheElk801
*/
public final class EdgarKingOfFigaro extends CardImpl {
public EdgarKingOfFigaro(UUID ownerId, CardSetInfo setInfo) {
super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{4}{U}{U}");
this.supertype.add(SuperType.LEGENDARY);
this.subtype.add(SubType.HUMAN);
this.subtype.add(SubType.ARTIFICER);
this.subtype.add(SubType.NOBLE);
this.power = new MageInt(4);
this.toughness = new MageInt(5);
// When Edgar enters, draw a card for each artifact you control.
this.addAbility(new EntersBattlefieldTriggeredAbility(
new DrawCardSourceControllerEffect(ArtifactYouControlCount.instance)
).addHint(ArtifactYouControlHint.instance));
// Two-Headed Coin -- The first time you flip one or more coins each turn, those coins come up heads and you win those flips.
this.addAbility(new SimpleStaticAbility(new EdgarKingOfFigaroEffect())
.withFlavorWord("Two-Headed Coin"), new EdgarKingOfFigaroWatcher());
}
private EdgarKingOfFigaro(final EdgarKingOfFigaro card) {
super(card);
}
@Override
public EdgarKingOfFigaro copy() {
return new EdgarKingOfFigaro(this);
}
}
class EdgarKingOfFigaroEffect extends ReplacementEffectImpl {
EdgarKingOfFigaroEffect() {
super(Duration.WhileOnBattlefield, Outcome.Benefit);
staticText = "the first time you flip one or more coins each turn, " +
"those coins come up heads and you win those flips";
}
private EdgarKingOfFigaroEffect(final EdgarKingOfFigaroEffect effect) {
super(effect);
}
@Override
public boolean replaceEvent(GameEvent event, Ability source, Game game) {
((FlipCoinsEvent) event).setHeadsAndWon(true);
return false;
}
@Override
public boolean checksEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.FLIP_COINS;
}
@Override
public boolean applies(GameEvent event, Ability source, Game game) {
return source.isControlledBy(event.getPlayerId())
&& !EdgarKingOfFigaroWatcher.checkPlayer(game, source);
}
@Override
public EdgarKingOfFigaroEffect copy() {
return new EdgarKingOfFigaroEffect(this);
}
}
class EdgarKingOfFigaroWatcher extends Watcher {
private final Set<UUID> set = new HashSet<>();
EdgarKingOfFigaroWatcher() {
super(WatcherScope.GAME);
}
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.COIN_FLIPPED) {
set.add(event.getPlayerId());
}
}
@Override
public void reset() {
super.reset();
set.clear();
}
static boolean checkPlayer(Game game, Ability source) {
return game
.getState()
.getWatcher(EdgarKingOfFigaroWatcher.class)
.set
.contains(source.getControllerId());
}
}

View file

@ -10,10 +10,11 @@ import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.game.Controllable;
import mage.game.Game;
import mage.game.permanent.token.GoblinToken;
import mage.players.Player;
import java.util.Optional;
import java.util.UUID;
/**
@ -50,17 +51,16 @@ enum GoblinTraprunnerValue implements DynamicValue {
@Override
public int calculate(Game game, Ability sourceAbility, Effect effect) {
Player player = game.getPlayer(sourceAbility.getControllerId());
if (player == null) {
return 0;
}
int count = 0;
for (int i = 0; i < 3; i++) {
if (player.flipCoin(sourceAbility, game, true)) {
count++;
}
}
return count;
return Optional
.ofNullable(sourceAbility)
.map(Controllable::getControllerId)
.map(game::getPlayer)
.map(player -> player
.flipCoins(sourceAbility, game, 3, true)
.stream()
.mapToInt(x -> x ? 1 : 0)
.sum()
).orElse(0);
}
@Override

View file

@ -1,7 +1,5 @@
package mage.cards.r;
import java.util.UUID;
import mage.abilities.Ability;
import mage.abilities.LoyaltyAbility;
import mage.abilities.effects.Effect;
@ -12,20 +10,22 @@ import mage.abilities.effects.common.UntapTargetEffect;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.SubType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.constants.SuperType;
import mage.filter.FilterPermanent;
import mage.filter.predicate.other.AnotherTargetPredicate;
import mage.game.Controllable;
import mage.game.Game;
import mage.game.turn.TurnMod;
import mage.players.Player;
import mage.target.TargetPermanent;
import mage.target.common.TargetAnyTarget;
import mage.target.targetpointer.SecondTargetPointer;
import java.util.Optional;
import java.util.UUID;
/**
*
* @author LevelX2
*/
public final class RalZarek extends CardImpl {
@ -64,7 +64,6 @@ public final class RalZarek extends CardImpl {
// -7: Flip five coins. Take an extra turn after this one for each coin that comes up heads.
this.addAbility(new LoyaltyAbility(new RalZarekExtraTurnsEffect(), -7));
}
private RalZarek(final RalZarek card) {
@ -95,15 +94,19 @@ class RalZarekExtraTurnsEffect extends OneShotEffect {
@Override
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(source.getControllerId());
if (controller != null) {
for (int i = 0; i < 5; i++) {
if (controller.flipCoin(source, game, false)) {
game.getState().getTurnMods().add(new TurnMod(source.getControllerId()).withExtraTurn());
}
}
return true;
int amount = Optional
.ofNullable(source)
.map(Controllable::getControllerId)
.map(game::getPlayer)
.map(player -> player
.flipCoins(source, game, 5, true)
.stream()
.mapToInt(x -> x ? 1 : 0)
.sum()
).orElse(0);
for (int i = 0; i < amount; i++) {
game.getState().getTurnMods().add(new TurnMod(source.getControllerId()).withExtraTurn());
}
return false;
return true;
}
}

View file

@ -1,7 +1,6 @@
package mage.cards.t;
import java.util.UUID;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.AttacksTriggeredAbility;
@ -9,17 +8,19 @@ import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.continuous.GainAbilitySourceEffect;
import mage.abilities.keyword.DoubleStrikeAbility;
import mage.abilities.keyword.MenaceAbility;
import mage.constants.SubType;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.CardType;
import mage.constants.Duration;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.game.Game;
import mage.players.Player;
import java.util.List;
import java.util.UUID;
/**
*
* @author TheElk801
*/
public final class TwoHeadedGiant extends CardImpl {
@ -69,14 +70,11 @@ class TwoHeadedGiantEffect extends OneShotEffect {
if (player == null) {
return false;
}
boolean head1 = player.flipCoin(source, game, false);
boolean head2 = player.flipCoin(source, game, false);
if (head1 == head2) {
if (head1) {
game.addEffect(new GainAbilitySourceEffect(DoubleStrikeAbility.getInstance(), Duration.EndOfTurn), source);
} else {
game.addEffect(new GainAbilitySourceEffect(new MenaceAbility(), Duration.EndOfTurn), source);
}
List<Boolean> flips = player.flipCoins(source, game, 2, false);
if (flips.get(0) == flips.get(1)) {
game.addEffect(new GainAbilitySourceEffect(
flips.get(0) ? DoubleStrikeAbility.getInstance() : new MenaceAbility(), Duration.EndOfTurn
), source);
}
return true;
}

View file

@ -3,7 +3,6 @@ package mage.cards.y;
import mage.MageInt;
import mage.abilities.Ability;
import mage.abilities.common.AttacksTriggeredAbility;
import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.continuous.CastFromHandWithoutPayingManaCostEffect;
import mage.abilities.keyword.FlyingAbility;
@ -70,21 +69,16 @@ class YusriFortunesFlameEffect extends OneShotEffect {
return false;
}
int flips = player.getAmount(1, 5, "Choose a number between 1 and 5", source, game);
int wins = 0;
int losses = 0;
for (int i = 0; i < flips; i++) {
if (player.flipCoin(source, game, true)) {
wins++;
} else {
losses++;
}
}
int wins = player
.flipCoins(source, game, flips, true)
.stream()
.mapToInt(x -> x ? 1 : 0)
.sum();
int losses = flips - wins;
player.drawCards(wins, source, game);
player.damage(2 * losses, source.getSourceId(), source, game);
if (wins >= 5) {
ContinuousEffect effect = new CastFromHandWithoutPayingManaCostEffect();
effect.setDuration(Duration.EndOfTurn);
game.addEffect(effect, source);
game.addEffect(new CastFromHandWithoutPayingManaCostEffect().setDuration(Duration.EndOfTurn), source);
}
return true;
}

View file

@ -168,6 +168,8 @@ public final class FinalFantasy extends ExpansionSet {
cards.add(new SetCardInfo("Dragoon's Wyvern", 49, Rarity.COMMON, mage.cards.d.DragoonsWyvern.class));
cards.add(new SetCardInfo("Dreams of Laguna", 50, Rarity.COMMON, mage.cards.d.DreamsOfLaguna.class));
cards.add(new SetCardInfo("Dwarven Castle Guard", 18, Rarity.COMMON, mage.cards.d.DwarvenCastleGuard.class));
cards.add(new SetCardInfo("Edgar, King of Figaro", 436, Rarity.RARE, mage.cards.e.EdgarKingOfFigaro.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Edgar, King of Figaro", 51, Rarity.RARE, mage.cards.e.EdgarKingOfFigaro.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Eject", 52, Rarity.UNCOMMON, mage.cards.e.Eject.class));
cards.add(new SetCardInfo("Elixir", 256, Rarity.UNCOMMON, mage.cards.e.Elixir.class));
cards.add(new SetCardInfo("Emet-Selch, Unsundered", 218, Rarity.MYTHIC, mage.cards.e.EmetSelchUnsundered.class, NON_FULL_USE_VARIOUS));

View file

@ -0,0 +1,77 @@
package org.mage.test.cards.single.fin;
import mage.abilities.keyword.DoubleStrikeAbility;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author TheElk801
*/
public class EdgarKingOfFigaroTest extends CardTestPlayerBase {
private static final String edgar = "Edgar, King of Figaro";
private static final String traprunner = "Goblin Traprunner";
private static final String swindler = "Tavern Swindler";
@Test
public void testTraprunnerThenSwindler() {
addCard(Zone.BATTLEFIELD, playerA, edgar);
addCard(Zone.BATTLEFIELD, playerA, traprunner);
addCard(Zone.BATTLEFIELD, playerA, swindler);
attack(1, playerA, traprunner);
setFlipCoinResult(playerA, false);
activateAbility(1, PhaseStep.POSTCOMBAT_MAIN, playerA, "{T}");
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertPermanentCount(playerA, "Goblin Token", 3);
assertLife(playerA, 20 - 3);
assertLife(playerB, 20 - 4 - 1 - 1 - 1);
}
@Test
public void testSwindlerThenTraprunner() {
addCard(Zone.BATTLEFIELD, playerA, edgar);
addCard(Zone.BATTLEFIELD, playerA, traprunner);
addCard(Zone.BATTLEFIELD, playerA, swindler);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}");
setFlipCoinResult(playerA, false);
setFlipCoinResult(playerA, false);
setFlipCoinResult(playerA, false);
attack(1, playerA, traprunner);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertPermanentCount(playerA, "Goblin Token", 0);
assertLife(playerA, 20 - 3 + 6);
assertLife(playerB, 20 - 4);
}
private static final String giant = "Two-Headed Giant";
@Test
public void testTwoHeadedGiant() {
addCard(Zone.BATTLEFIELD, playerA, edgar);
addCard(Zone.BATTLEFIELD, playerA, giant);
attack(1, playerA, giant);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.END_TURN);
execute();
assertTapped(giant, true);
assertAbility(playerA, giant, DoubleStrikeAbility.getInstance(), true);
assertLife(playerB, 20 - 4 - 4);
}
}

View file

@ -3794,6 +3794,11 @@ public class TestPlayer implements Player {
return computerPlayer.flipCoin(source, game, true);
}
@Override
public List<Boolean> flipCoins(Ability source, Game game, int amount, boolean winnable) {
return computerPlayer.flipCoins(source, game, amount, winnable);
}
@Override
public boolean flipCoinResult(Game game) {
assertAliasSupportInChoices(false);

View file

@ -12,6 +12,7 @@ public class FlipCoinEvent extends GameEvent {
private boolean result;
private final boolean chosen;
private final boolean winnable;
private boolean autoWin = false;
private int flipCount = 1;
public FlipCoinEvent(UUID playerId, Ability source, boolean result, boolean chosen, boolean winnable) {
@ -53,6 +54,14 @@ public class FlipCoinEvent extends GameEvent {
this.flipCount = flipCount;
}
public void setAutoWin(boolean autoWin) {
this.autoWin = autoWin;
}
public boolean isAutoWin() {
return autoWin;
}
public CoinFlippedEvent createFlippedEvent() {
return new CoinFlippedEvent(playerId, sourceId, flipCount, result, chosen, winnable);
}

View file

@ -0,0 +1,25 @@
package mage.game.events;
import mage.abilities.Ability;
import java.util.UUID;
/**
* @author TheElk801
*/
public class FlipCoinsEvent extends GameEvent {
private boolean isHeadsAndWon = false;
public FlipCoinsEvent(UUID playerId, int amount, Ability source) {
super(EventType.FLIP_COINS, playerId, source, playerId, amount, false);
}
public void setHeadsAndWon(boolean headsAndWon) {
isHeadsAndWon = headsAndWon;
}
public boolean isHeadsAndWon() {
return isHeadsAndWon;
}
}

View file

@ -401,7 +401,7 @@ public class GameEvent implements Serializable {
SURVEIL, SURVEILED,
PROLIFERATE, PROLIFERATED,
FATESEALED,
FLIP_COIN, COIN_FLIPPED,
FLIP_COIN, FLIP_COINS, COIN_FLIPPED,
REPLACE_ROLLED_DIE, // for Clam-I-Am workaround only
ROLL_DIE, DIE_ROLLED,
ROLL_DICE, DICE_ROLLED,

View file

@ -533,6 +533,8 @@ public interface Player extends MageItem, Copyable<Player> {
boolean hasProtectionFrom(MageObject source, Game game);
List<Boolean> flipCoins(Ability source, Game game, int amount, boolean winnable);
boolean flipCoin(Ability source, Game game, boolean winnable);
boolean flipCoinResult(Game game);
@ -748,11 +750,12 @@ public interface Player extends MageItem, Copyable<Player> {
/**
* Set the value for X in spells and abilities
*
* @param isManaPay helper param for better AI logic
*/
int announceX(int min, int max, String message, Game game, Ability source, boolean isManaPay);
// TODO: rework to use pair's list of effect + ability instead string's map
// TODO: rework to use pair's list of effect + ability instead string's map
int chooseReplacementEffect(Map<String, String> effectsMap, Map<String, MageObject> objectsMap, Game game);
TriggeredAbility chooseTriggeredAbility(List<TriggeredAbility> abilities, Game game);
@ -764,7 +767,6 @@ public interface Player extends MageItem, Copyable<Player> {
void selectBlockers(Ability source, Game game, UUID defendingPlayerId);
/**
*
* @param source can be null for system actions like define damage
*/
int getAmount(int min, int max, String message, Ability source, Game game);

View file

@ -3054,44 +3054,66 @@ public abstract class PlayerImpl implements Player, Serializable {
*/
@Override
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);
game.informPlayers(getLogName() + " chose " + CardUtil.booleanToFlipName(chosen));
}
boolean result = this.flipCoinResult(game);
FlipCoinEvent event = new FlipCoinEvent(playerId, source, result, chosen, winnable);
game.replaceEvent(event);
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(event.getResult())
+ CardUtil.getSourceLogName(game, source));
if (event.getFlipCount() > 1) {
boolean canChooseHeads = event.getResult();
boolean canChooseTails = !event.getResult();
for (int i = 1; i < event.getFlipCount(); i++) {
boolean tempFlip = this.flipCoinResult(game);
canChooseHeads = canChooseHeads || tempFlip;
canChooseTails = canChooseTails || !tempFlip;
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(tempFlip));
return flipCoins(source, game, 1, winnable).get(0);
}
@Override
public List<Boolean> flipCoins(Ability source, Game game, int amount, boolean winnable) {
List<Boolean> results = new ArrayList<>();
FlipCoinsEvent flipsEvent = new FlipCoinsEvent(this.getId(), amount, source);
game.replaceEvent(flipsEvent);
for (int i = 0; i < flipsEvent.getAmount(); i++) {
if (flipsEvent.isHeadsAndWon()) {
if (winnable) {
game.informPlayers(getLogName() + " chose " + CardUtil.booleanToFlipName(true));
}
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(true) + CardUtil.getSourceLogName(game, source));
if (winnable) {
game.informPlayers(getLogName() + " won the flip" + CardUtil.getSourceLogName(game, source));
}
game.fireEvent(new FlipCoinEvent(playerId, source, true, true, winnable).createFlippedEvent());
results.add(true);
continue;
}
if (canChooseHeads && canChooseTails) {
event.setResult(chooseUse(Outcome.Benefit, "Choose which flip to keep",
(event.isWinnable() ? "(You called " + event.getChosenName() + ")" : null),
"Heads", "Tails", source, game
));
boolean chosen;
if (winnable) {
chosen = this.chooseUse(Outcome.Benefit, "Heads or tails?", "", "Heads", "Tails", source, game);
game.informPlayers(getLogName() + " chose " + CardUtil.booleanToFlipName(chosen));
} else {
event.setResult(canChooseHeads);
chosen = false;
}
game.informPlayers(getLogName() + " chose to keep " + CardUtil.booleanToFlipName(event.getResult()));
}
if (event.isWinnable()) {
game.informPlayers(getLogName() + " " + (event.getResult() == event.getChosen() ? "won" : "lost") + " the flip"
boolean result = this.flipCoinResult(game);
FlipCoinEvent event = new FlipCoinEvent(playerId, source, result, chosen, winnable);
game.replaceEvent(event);
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(event.getResult())
+ CardUtil.getSourceLogName(game, source));
if (event.getFlipCount() > 1) {
boolean canChooseHeads = event.getResult();
boolean canChooseTails = !event.getResult();
for (int j = 1; j < event.getFlipCount(); j++) {
boolean tempFlip = this.flipCoinResult(game);
canChooseHeads = canChooseHeads || tempFlip;
canChooseTails = canChooseTails || !tempFlip;
game.informPlayers(getLogName() + " flipped " + CardUtil.booleanToFlipName(tempFlip));
}
if (canChooseHeads && canChooseTails) {
event.setResult(chooseUse(Outcome.Benefit, "Choose which flip to keep",
(event.isWinnable() ? "(You called " + event.getChosenName() + ")" : null),
"Heads", "Tails", source, game
));
} else {
event.setResult(canChooseHeads);
}
game.informPlayers(getLogName() + " chose to keep " + CardUtil.booleanToFlipName(event.getResult()));
}
if (event.isWinnable()) {
game.informPlayers(getLogName() + " " + (event.getResult() == event.getChosen() ? "won" : "lost") + " the flip"
+ CardUtil.getSourceLogName(game, source));
}
game.fireEvent(event.createFlippedEvent());
results.add(event.isWinnable() ? event.getResult() == event.getChosen() : event.getResult());
}
game.fireEvent(event.createFlippedEvent());
if (event.isWinnable()) {
return event.getResult() == event.getChosen();
}
return event.getResult();
return results;
}
/**