Clash adjustments (#10616)

* adjust clash effect

* Make clash not a singleton

* Add unit test for Clash effect

* fix test (skip init shuffling)

* Fix CLASHED event flag logic and add to unit test

* Additional test and comments

* comments in GameEvent

* param name typo
This commit is contained in:
xenohedron 2023-07-14 23:15:01 -04:00 committed by GitHub
parent 0d4c73b385
commit ee29c38413
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 187 additions and 67 deletions

View file

@ -66,7 +66,7 @@ class BrokenAmbitionsEffect extends OneShotEffect {
if (player == null) {
return false;
}
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
player.millCards(4, source, game);
}
return true;

View file

@ -80,7 +80,7 @@ class CaptivatingGlanceEffect extends OneShotEffect {
if (controller != null
&& captivatingGlance != null) {
Permanent enchantedCreature = game.getPermanent(captivatingGlance.getAttachedTo());
clashResult = ClashEffect.getInstance().apply(game, source);
clashResult = new ClashEffect().apply(game, source);
if (enchantedCreature != null) {
if (clashResult) {
ContinuousEffect effect = new GainControlTargetEffect(Duration.Custom, controller.getId());

View file

@ -69,7 +69,7 @@ class FistfulOfForceEffect extends OneShotEffect {
ContinuousEffect effect = new BoostTargetEffect(2,2,Duration.EndOfTurn);
effect.setTargetPointer(new FixedTarget(creature.getId(), game));
game.addEffect(effect, source);
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
game.addEffect(effect.copy(), source);
effect = new GainAbilityTargetEffect(TrampleAbility.getInstance(), Duration.EndOfTurn);
effect.setTargetPointer(new FixedTarget(creature.getId(), game));

View file

@ -66,7 +66,7 @@ class GiltLeafAmbushCreateTokenEffect extends OneShotEffect {
if (controller != null) {
CreateTokenEffect effect = new CreateTokenEffect(new ElfWarriorToken(), 2);
effect.apply(game, source);
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
for (UUID tokenId : effect.getLastAddedTokenIds()) {
Permanent token = game.getPermanent(tokenId);
if (token != null) {

View file

@ -59,7 +59,7 @@ class HoardersGreedEffect extends OneShotEffect {
do {
controller.loseLife(2, game, source, false);
controller.drawCards(2, source, game);
} while (controller.canRespond() && ClashEffect.getInstance().apply(game, source));
} while (controller.canRespond() && new ClashEffect().apply(game, source));
return true;
}
return false;

View file

@ -60,7 +60,7 @@ class LashOutEffect extends OneShotEffect {
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
if (controller != null && creature != null) {
creature.damage(3, source.getSourceId(), source, game, false, true);
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
Player creaturesController = game.getPlayer(creature.getControllerId());
if (creaturesController != null) {
creaturesController.damage(3, source.getSourceId(), source, game);

View file

@ -59,7 +59,7 @@ class PullingTeethEffect extends OneShotEffect {
Player controller = game.getPlayer(source.getControllerId());
if (controller != null) {
int cardsToDiscard;
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
cardsToDiscard = 2;
} else {
cardsToDiscard = 1;

View file

@ -65,7 +65,7 @@ class ScatteringStrokeEffect extends OneShotEffect {
Player controller = game.getPlayer(source.getControllerId());
if (controller != null && spell != null) {
game.getStack().counter(spell.getId(), source, game);
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
Effect effect = new AddManaToManaPoolSourceControllerEffect(new Mana(0, 0, 0, 0, 0, 0, 0, spell.getManaValue()));
AtTheBeginOfMainPhaseDelayedTriggeredAbility delayedAbility
= new AtTheBeginOfMainPhaseDelayedTriggeredAbility(effect, true, TargetController.YOU, AtTheBeginOfMainPhaseDelayedTriggeredAbility.PhaseSelection.NEXT_MAIN);

View file

@ -62,7 +62,7 @@ class WeedStrangleEffect extends OneShotEffect {
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
if (controller != null && creature != null) {
creature.destroy(source, game, false);
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
controller.gainLife(creature.getToughness().getValue(), game, source);
}
return true;

View file

@ -61,7 +61,7 @@ class WhirlpoolWhelmEffect extends OneShotEffect {
Permanent creature = game.getPermanent(getTargetPointer().getFirst(game, source));
if (controller != null) {
boolean topOfLibrary = false;
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
topOfLibrary = controller.chooseUse(outcome, "Put " + creature.getLogName() + " to top of libraray instead?", source, game);
}
if (topOfLibrary) {

View file

@ -0,0 +1,135 @@
package org.mage.test.cards.abilities.keywords;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author xenohedron
*/
public class ClashEffectTest extends CardTestPlayerBase {
/**
* 701.23. Clash
* 701.23a. To clash, a player reveals the top card of their library.
* That player may then put that card on the bottom of their library.
* 701.23b. "Clash with an opponent" means "Choose an opponent. You and that opponent each clash."
* 701.23c. Each clashing player reveals the top card of their library at the same time.
* Then those players decide in APNAP order (see rule 101.4) where to put those cards, then those cards move at the same time.
* 701.23d. A player wins a clash if that player revealed a card with a higher mana value than all other cards revealed in that clash.
*/
private static final String rascal = "Paperfin Rascal"; // 2/2 for 2U, ETB clash, +1/+1 counter if won (so 3/3)
private void prepareClashing() {
setStrictChooseMode(true);
addCard(Zone.HAND, playerA, rascal);
addCard(Zone.BATTLEFIELD, playerA, "Island", 3);
addCard(Zone.BATTLEFIELD, playerB, "Sylvan Echoes" , 1); // Whenever you clash and win, you may draw a card.
// Default: both players have only lands with mana value 0
// Add card with greater mana value to one player's library so that they win the clash
removeAllCardsFromLibrary(playerA);
removeAllCardsFromLibrary(playerB);
addCard(Zone.LIBRARY, playerA, "Island", 4);
addCard(Zone.LIBRARY, playerB, "Island", 4);
skipInitShuffling();
}
private void performClashing() {
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, rascal);
setChoice(playerA, "PlayerB"); // choose an opponent to clash with
setChoice(playerA, false); // put revealed card to bottom of library
setChoice(playerB, true); // put revealed card to top of library
}
@Test
public void testClashYouWin() {
prepareClashing();
addCard(Zone.LIBRARY, playerA, "Divination", 1);
performClashing();
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPowerToughness(playerA, rascal, 3, 3);
}
@Test
public void testClashOppWin() {
prepareClashing();
addCard(Zone.LIBRARY, playerB, "Divination", 1);
performClashing();
setChoice(playerB, true); // Draw a card from Sylvan Echoes
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPowerToughness(playerA, rascal, 2, 2);
assertHandCount(playerB, "Divination", 1);
}
@Test
public void testClashNoWin() {
prepareClashing();
performClashing();
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertPowerToughness(playerA, rascal, 2, 2);
}
@Test
public void testHoardersGreed() {
setStrictChooseMode(true);
removeAllCardsFromLibrary(playerA);
removeAllCardsFromLibrary(playerB);
skipInitShuffling();
addCard(Zone.LIBRARY, playerB, "Island", 5);
addCard(Zone.LIBRARY, playerA, "Island", 5);
addCard(Zone.LIBRARY, playerA, "Wastes", 1);
addCard(Zone.LIBRARY, playerA, "Razorfield Thresher", 1);
addCard(Zone.LIBRARY, playerA, "Phyrexian Hulk", 1);
addCard(Zone.LIBRARY, playerA, "Stone Golem", 1);
addCard(Zone.LIBRARY, playerA, "Gilded Sentinel", 1);
addCard(Zone.LIBRARY, playerA, "Stonework Puma", 1);
addCard(Zone.LIBRARY, playerA, "Field Creeper", 1);
addCard(Zone.LIBRARY, playerA, "Metallic Sliver", 1);
addCard(Zone.LIBRARY, playerA, "Memnite", 1);
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 4);
addCard(Zone.HAND, playerA, "Hoarder's Greed", 1);
castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Hoarder's Greed");
// Draw two cards (Memnite and Metallic Sliver), lose 2 life, clash:
setChoice(playerA, "PlayerB");
setChoice(playerA, false); // Field Creeper to bottom of library
setChoice(playerB, true);
// Clash won. Draw two cards (Stonework Puma and Gilded Sentinel), lose 2 life, clash:
setChoice(playerA, "PlayerB");
setChoice(playerA, false); // Stone Golem to bottom of library
setChoice(playerB, true);
// Clash won. Draw two cards (Phyrexian Hulk and Razorfield Thresher), lose 2 life, clash:
setChoice(playerA, "PlayerB");
setChoice(playerA, true); // Wastes to top of library
setChoice(playerB, true);
// No winner in this clash.
setStopAt(1, PhaseStep.BEGIN_COMBAT);
execute();
assertLife(playerA, 20 - (2 * 3));
assertHandCount(playerA, "Memnite", 1);
assertHandCount(playerA, "Metallic Sliver", 1);
assertHandCount(playerA, "Field Creeper", 0);
assertHandCount(playerA, "Stonework Puma", 1);
assertHandCount(playerA, "Gilded Sentinel", 1);
assertHandCount(playerA, "Stone Golem", 0);
assertHandCount(playerA, "Phyrexian Hulk", 1);
assertHandCount(playerA, "Razorfield Thresher", 1);
}
}

View file

@ -2,8 +2,6 @@ package mage.abilities.effects.common;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.MageSingleton;
import mage.abilities.effects.Effect;
import mage.abilities.effects.OneShotEffect;
import mage.cards.Card;
import mage.cards.Cards;
@ -13,11 +11,10 @@ import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.players.PlayerList;
import mage.target.Target;
import mage.target.common.TargetOpponent;
import java.io.ObjectStreamException;
import java.util.UUID;
/**
* 1. The controller of the spell or ability chooses an opponent. (This doesn't
@ -54,24 +51,14 @@ import java.io.ObjectStreamException;
*
* @author LevelX2
*/
public class ClashEffect extends OneShotEffect implements MageSingleton {
public class ClashEffect extends OneShotEffect {
private static final ClashEffect instance = new ClashEffect();
private Object readResolve() throws ObjectStreamException {
return instance;
}
private ClashEffect() {
public ClashEffect() {
super(Outcome.Benefit);
this.staticText = "Clash with an opponent";
}
public static ClashEffect getInstance() {
return instance;
}
public ClashEffect(final ClashEffect effect) {
protected ClashEffect(final ClashEffect effect) {
super(effect);
}
@ -94,7 +81,6 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
// choose opponent
Target target = new TargetOpponent(true);
target.setTargetName("an opponent to clash with");
target.setNotTarget(true);
if (!controller.choose(Outcome.Benefit, target, source, game)) {
return false;
}
@ -133,32 +119,26 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
message.append(" no card");
}
message.append(" - ");
if (!game.isSimulation()) {
if (cmcController > cmcOpponent) {
message.append(controller.getLogName()).append(" won the clash");
game.informPlayer(controller, "You won the clash!");
} else if (cmcController < cmcOpponent) {
message.append(opponent.getLogName()).append(" won the clash");
game.informPlayer(controller, opponent.getName() + " won the clash!");
} else {
message.append(" no winner ");
}
game.informPlayers(message.toString());
if (cmcController > cmcOpponent) {
message.append(controller.getLogName()).append(" won the clash");
} else if (cmcController < cmcOpponent) {
message.append(opponent.getLogName()).append(" won the clash");
} else {
message.append(" no winner ");
}
// decide to put the cards on top or on the buttom of library in turn order beginning with the active player in turn order
PlayerList playerList = game.getPlayerList().copy();
playerList.setCurrent(game.getActivePlayerId());
Player nextPlayer;
do {
Player current = playerList.getCurrent(game);
if (cardController != null && current.getId().equals(controller.getId())) {
topController = current.chooseUse(Outcome.Detriment, "Put " + cardController.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
game.informPlayers(message.toString());
// decide to put the cards on top or on the bottom of library in turn order beginning with the active player in turn order
for (UUID playerId : game.getState().getPlayersInRange(source.getControllerId(), game)) {
Player player = game.getPlayer(playerId);
if (player == null) {
continue;
}
if (cardOpponent != null && current.getId().equals(opponent.getId())) {
topOpponent = current.chooseUse(Outcome.Detriment, "Put " + cardOpponent.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
if (cardController != null && player.getId().equals(controller.getId())) {
topController = player.chooseUse(Outcome.Detriment, "Put " + cardController.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
} else if (cardOpponent != null && player.getId().equals(opponent.getId())) {
topOpponent = player.chooseUse(Outcome.Detriment, "Put " + cardOpponent.getLogName() + " back on top of your library? (otherwise it goes to bottom)", source, game);
}
nextPlayer = playerList.getNext(game, false);
} while (nextPlayer != null && !nextPlayer.getId().equals(game.getActivePlayerId()));
}
// put the cards back to library
if (cardController != null) {
controller.moveCardToLibraryWithInfo(cardController, source, game, Zone.LIBRARY, topController, true);
@ -166,14 +146,14 @@ public class ClashEffect extends OneShotEffect implements MageSingleton {
if (cardOpponent != null) {
opponent.moveCardToLibraryWithInfo(cardOpponent, source, game, Zone.LIBRARY, topOpponent, true);
}
// fire CLASHED event with info about who won
game.fireEvent(new GameEvent(
GameEvent.EventType.CLASHED, controller.getId(), source,
opponent.getId(), 0, cmcController > cmcOpponent
));
// fire CLASHED events with info about winner (flag is true if playerId won; other player is targetId)
game.fireEvent(new GameEvent(
GameEvent.EventType.CLASHED, opponent.getId(), source,
controller.getId(), 0, cmcOpponent > cmcController
controller.getId(), 0, cmcController > cmcOpponent
));
game.fireEvent(new GameEvent(
GameEvent.EventType.CLASHED, controller.getId(), source,
opponent.getId(), 0, cmcOpponent > cmcController
));
// set opponent to DoIfClashWonEffect

View file

@ -54,7 +54,7 @@ public class DoIfClashWonEffect extends OneShotEffect {
}
if (chooseUseText == null || player.chooseUse(executingEffect.getOutcome(), message, source, game)) {
if (ClashEffect.getInstance().apply(game, source)) {
if (new ClashEffect().apply(game, source)) {
if (setTargetPointerToClashedOpponent) {
Object opponent = getValue("clashOpponent");
if (opponent instanceof Player) {

View file

@ -99,6 +99,11 @@ public class GameEvent implements Serializable {
DISCARDED_CARD,
DISCARDED_CARDS,
CYCLE_CARD, CYCLED_CARD, CYCLE_DRAW,
/* CLASHED (one event fired for each player involved)
playerId the id of the clashing player
flag true = playerId won the clash
targetId the id of the other player in the clash
*/
CLASH, CLASHED,
DAMAGE_PLAYER,
MILL_CARDS,
@ -107,9 +112,9 @@ public class GameEvent implements Serializable {
/* DAMAGED_PLAYER
targetId the id of the damaged player
sourceId sourceId of the ability which caused the damage
playerId the id of the damged player
playerId the id of the damaged player
amount amount of damage
flag true = comabat damage - other damage = false
flag true = combat damage - other damage = false
*/
DAMAGED_PLAYER,
@ -121,7 +126,7 @@ public class GameEvent implements Serializable {
/* DAMAGE_CAUSES_LIFE_LOSS,
targetId the id of the damaged player
sourceId sourceId of the ability which caused the damage, can be null for default events like combat
playerId the id of the damged player
playerId the id of the damaged player
amount amount of damage
flag is it combat damage
*/
@ -134,7 +139,7 @@ public class GameEvent implements Serializable {
sourceId sourceId of the ability which caused the lose
playerId the id of the player loosing life
amount amount of life loss
flag true = from comabat damage - other from non combat damage
flag true = from combat damage - other from non combat damage
*/
PLAY_LAND, LAND_PLAYED,
CREATURE_CHAMPIONED,
@ -333,7 +338,7 @@ public class GameEvent implements Serializable {
TAP,
/* TAPPED,
targetId tapped permanent
sourceId id of the abilitity's source (can be null for standard tap actions like combat)
sourceId id of the ability's source (can be null for standard tap actions like combat)
playerId controller of the tapped permanent
amount not used for this event
flag is it tapped for combat
@ -441,7 +446,7 @@ public class GameEvent implements Serializable {
/* LOST_CONTROL
targetId id of the creature that lost control
sourceId null
playerId player that controlles the creature before
playerId player that controls the creature before
amount not used for this event
flag not used for this event
*/

View file

@ -567,11 +567,11 @@ public interface Player extends MageItem, Copyable<Player> {
void revealCards(Ability source, Cards cards, Game game);
void revealCards(String titelSuffix, Cards cards, Game game);
void revealCards(String titleSuffix, Cards cards, Game game);
void revealCards(Ability source, String titelSuffix, Cards cards, Game game);
void revealCards(Ability source, String titleSuffix, Cards cards, Game game);
void revealCards(String titelSuffix, Cards cards, Game game, boolean postToLog);
void revealCards(String titleSuffix, Cards cards, Game game, boolean postToLog);
/**
* Adds the cards to the reveal window and adds the source object's id name