[OTJ] Implement Plot mechanic (+8 cards) (#12017)

[OTJ] Implement Aloe Alchemist
[OTJ] Implement Aven Interrupter
[OTJ] Implement Longhorn Shapshooter
[OTJ] Implement Kellan Joins Up
[OTJ] Implement Make Your Own Luck
[OTJ] Implement Jace Reawakened
[OTJ] Implement Lilah, Undefeated Slickshot
[OTJ] Implement Doc Aurlock, Grizzled Genius
This commit is contained in:
Susucre 2024-03-31 17:06:55 +02:00 committed by GitHub
parent ed3d6e3078
commit 97ab8074b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1476 additions and 25 deletions

View file

@ -429,6 +429,7 @@ public abstract class AbilityImpl implements Ability {
case BESTOW:
case MORPH:
case DISGUISE:
case PLOT:
// from Snapcaster Mage:
// If you cast a spell from a graveyard using its flashback ability, you can't pay other alternative costs
// (such as that of Foil). (2018-12-07)
@ -521,7 +522,7 @@ public abstract class AbilityImpl implements Ability {
String message = controller.getLogName() + " announces a value of " + xValue + " (" + variableCost.getActionText() + ')'
+ CardUtil.getSourceLogName(game, this);
announceString.append(message);
setCostsTag("X",xValue);
setCostsTag("X", xValue);
}
}
return announceString.toString();
@ -626,7 +627,7 @@ public abstract class AbilityImpl implements Ability {
}
addManaCostsToPay(new ManaCostsImpl<>(manaString.toString()));
getManaCostsToPay().setX(xValue * xValueMultiplier, amountMana);
setCostsTag("X",xValue * xValueMultiplier);
setCostsTag("X", xValue * xValueMultiplier);
}
variableManaCost.setPaid();
}
@ -718,7 +719,8 @@ public abstract class AbilityImpl implements Ability {
public Map<String, Object> getCostsTagMap() {
return costsTagMap;
}
public void setCostsTag(String tag, Object value){
public void setCostsTag(String tag, Object value) {
if (costsTagMap == null) {
costsTagMap = new HashMap<>();
}

View file

@ -0,0 +1,44 @@
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.GameEvent;
/**
* @author Susucr
*/
public class BecomesPlottedSourceTriggeredAbility extends TriggeredAbilityImpl {
public BecomesPlottedSourceTriggeredAbility(Effect effect, boolean optional) {
super(Zone.EXILED, effect, optional);
setTriggerPhrase("When {this} becomes plotted, ");
}
public BecomesPlottedSourceTriggeredAbility(Effect effect) {
this(effect, false);
}
protected BecomesPlottedSourceTriggeredAbility(final BecomesPlottedSourceTriggeredAbility ability) {
super(ability);
}
@Override
public BecomesPlottedSourceTriggeredAbility copy() {
return new BecomesPlottedSourceTriggeredAbility(this);
}
@Override
public boolean checkEventType(GameEvent event, Game game) {
return event.getType() == GameEvent.EventType.BECOME_PLOTTED;
}
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (event.getTargetId().equals(this.getSourceId())) {
return true;
}
return false;
}
}

View file

@ -0,0 +1,48 @@
package mage.abilities.effects.common;
import mage.abilities.Ability;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.keyword.PlotAbility;
import mage.cards.Card;
import mage.constants.Outcome;
import mage.filter.FilterCard;
import mage.game.Game;
import mage.players.Player;
import mage.target.common.TargetCardInHand;
public class MayExileCardFromHandPlottedEffect extends OneShotEffect {
private final FilterCard filter;
public MayExileCardFromHandPlottedEffect(FilterCard filter) {
super(Outcome.PutCardInPlay);
this.filter = filter;
this.staticText = "you may exile a " + filter.getMessage() + " from your hand. If you do, it becomes plotted";
}
private MayExileCardFromHandPlottedEffect(final MayExileCardFromHandPlottedEffect effect) {
super(effect);
this.filter = effect.filter;
}
@Override
public MayExileCardFromHandPlottedEffect copy() {
return new MayExileCardFromHandPlottedEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Player player = game.getPlayer(source.getControllerId());
if (player == null) {
return false;
}
TargetCardInHand target = new TargetCardInHand(0, 1, filter);
if (player.chooseTarget(outcome, target, source, game)) {
Card card = game.getCard(target.getFirstTarget());
if (card != null) {
PlotAbility.doExileAndPlotCard(card, game, source);
}
}
return true;
}
}

View file

@ -332,17 +332,17 @@ public class ForetellAbility extends SpecialAction {
if (game.getState().getZone(mainCardId) != Zone.EXILED) {
return ActivationStatus.getFalse();
}
Integer foretoldTurn = (Integer) game.getState().getValue(mainCardId.toString() + "Foretell Turn Number");
UUID exileId = (UUID) game.getState().getValue(mainCardId.toString() + "foretellAbility");
// Card must be Foretold
if (game.getState().getValue(mainCardId.toString() + "Foretell Turn Number") == null
&& game.getState().getValue(mainCardId + "foretellAbility") == null) {
if (foretoldTurn == null || exileId == null) {
return ActivationStatus.getFalse();
}
// Can't be cast if the turn it was Foretold is the same
if ((int) game.getState().getValue(mainCardId.toString() + "Foretell Turn Number") == game.getTurnNum()) {
if (foretoldTurn == game.getTurnNum()) {
return ActivationStatus.getFalse();
}
// Check that the card is actually in the exile zone (ex: Oblivion Ring exiles it after it was Foretold, etc)
UUID exileId = (UUID) game.getState().getValue(mainCardId.toString() + "foretellAbility");
ExileZone exileZone = game.getState().getExile().getExileZone(exileId);
if (exileZone != null
&& exileZone.isEmpty()) {

View file

@ -1,26 +1,43 @@
package mage.abilities.keyword;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.SpecialAction;
import mage.abilities.SpellAbility;
import mage.abilities.costs.Cost;
import mage.abilities.costs.Costs;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.cards.Card;
import mage.constants.TimingRule;
import mage.constants.Zone;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.OneShotEffect;
import mage.cards.*;
import mage.constants.*;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.players.Player;
import mage.util.CardUtil;
import java.util.List;
import java.util.UUID;
/**
* TODO: Implement this
*
* @author TheElk801
* @author Susucr
*/
public class PlotAbility extends SpecialAction {
private final String rule;
public PlotAbility(String plotCost) {
super(Zone.HAND);
this.addCost(new ManaCostsImpl<>(plotCost));
this.addEffect(new PlotSourceExileEffect());
this.setTiming(TimingRule.SORCERY);
this.usesStack = false;
this.rule = "Plot " + plotCost;
}
private PlotAbility(final PlotAbility ability) {
super(ability);
this.rule = ability.rule;
}
@Override
@ -30,6 +47,258 @@ public class PlotAbility extends SpecialAction {
@Override
public String getRule() {
return "Plot";
return rule;
}
// TODO: handle [[Fblthp, Lost on the Range]] allowing player to plot from library.
@Override
public ActivationStatus canActivate(UUID playerId, Game game) {
// plot can only be activated from a hand
// TODO: change that for Fblthp.
if (game.getState().getZone(getSourceId()) != Zone.HAND) {
return ActivationStatus.getFalse();
}
// suspend uses card's timing restriction
Card card = game.getCard(getSourceId());
if (card == null) {
return ActivationStatus.getFalse();
}
if (!card.getSpellAbility().spellCanBeActivatedRegularlyNow(playerId, game)) {
return ActivationStatus.getFalse();
}
return super.canActivate(playerId, game);
}
static UUID getPlotExileId(UUID playerId, Game game) {
UUID exileId = (UUID) game.getState().getValue("PlotExileId" + playerId.toString());
if (exileId == null) {
exileId = UUID.randomUUID();
game.getState().setValue("PlotExileId" + playerId, exileId);
}
return exileId;
}
static String getPlotTurnKeyForCard(UUID cardId) {
return cardId.toString() + "|" + "Plotted Turn";
}
/**
* To be used in an OneShotEffect's apply.
* 'Plot' the provided card. The card is exiled in it's owner plot zone,
* and may be cast by that player without paying its mana cost at sorcery
* speed on a future turn.
*/
public static boolean doExileAndPlotCard(Card card, Game game, Ability source) {
if (card == null) {
return false;
}
Player owner = game.getPlayer(card.getOwnerId());
if (owner == null) {
return false;
}
UUID exileId = PlotAbility.getPlotExileId(owner.getId(), game);
String exileZoneName = "Plots of " + owner.getName();
Card mainCard = card.getMainCard();
if (mainCard.moveToExile(exileId, exileZoneName, source, game)) {
// Remember on which turn the card was last plotted.
game.getState().setValue(PlotAbility.getPlotTurnKeyForCard(mainCard.getId()), game.getTurnNum());
game.addEffect(new PlotAddSpellAbilityEffect(new MageObjectReference(mainCard, game)), source);
game.informPlayers(owner.getLogName() + " plots " + mainCard.getLogName());
game.fireEvent(GameEvent.getEvent(GameEvent.EventType.BECOME_PLOTTED, mainCard.getId(), source, owner.getId()));
}
return true;
}
}
/**
* Exile the source card in the plot exile zone of its owner
* and allow its owner to cast it at sorcery speed starting
* next turn.
*/
class PlotSourceExileEffect extends OneShotEffect {
PlotSourceExileEffect() {
super(Outcome.Benefit);
}
private PlotSourceExileEffect(final PlotSourceExileEffect effect) {
super(effect);
}
@Override
public PlotSourceExileEffect copy() {
return new PlotSourceExileEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
return PlotAbility.doExileAndPlotCard(game.getCard(source.getSourceId()), game, source);
}
}
class PlotAddSpellAbilityEffect extends ContinuousEffectImpl {
private final MageObjectReference mor;
PlotAddSpellAbilityEffect(MageObjectReference mor) {
super(Duration.EndOfGame, Layer.AbilityAddingRemovingEffects_6, SubLayer.NA, Outcome.AddAbility);
this.mor = mor;
staticText = "Plot card";
}
private PlotAddSpellAbilityEffect(final PlotAddSpellAbilityEffect effect) {
super(effect);
this.mor = effect.mor;
}
@Override
public PlotAddSpellAbilityEffect copy() {
return new PlotAddSpellAbilityEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Card card = mor.getCard(game);
if (card == null) {
discard();
return true;
}
Card mainCard = card.getMainCard();
UUID mainCardId = mainCard.getId();
Player player = game.getPlayer(card.getOwnerId());
if (game.getState().getZone(mainCardId) != Zone.EXILED || player == null) {
discard();
return true;
}
List<Card> faces = CardUtil.getCastableComponents(mainCard, null, source, player, game, null, false);
for (Card face : faces) {
// Add the spell ability to each castable face to have the proper name/paramaters.
PlotSpellAbility ability = new PlotSpellAbility(face.getName());
ability.setSourceId(face.getId());
ability.setControllerId(player.getId());
ability.setSpellAbilityType(face.getSpellAbility().getSpellAbilityType());
game.getState().addOtherAbility(face, ability);
}
return true;
}
}
/**
* This is inspired (after a little cleanup) by how {@link ForetellAbility} does it.
*/
class PlotSpellAbility extends SpellAbility {
private String faceCardName; // Same as with Foretell, we identify the proper face with its spell name.
private SpellAbility spellAbilityToResolve;
PlotSpellAbility(String faceCardName) {
super(null, faceCardName, Zone.EXILED, SpellAbilityType.BASE_ALTERNATE, SpellAbilityCastMode.PLOT);
this.setRuleVisible(false);
this.setAdditionalCostsRuleVisible(false);
this.faceCardName = faceCardName;
this.addCost(new ManaCostsImpl<>("{0}"));
}
private PlotSpellAbility(final PlotSpellAbility ability) {
super(ability);
this.faceCardName = ability.faceCardName;
this.spellAbilityToResolve = ability.spellAbilityToResolve;
}
@Override
public PlotSpellAbility copy() {
return new PlotSpellAbility(this);
}
@Override
public ActivationStatus canActivate(UUID playerId, Game game) {
if (super.canActivate(playerId, game).canActivate()) {
Card card = game.getCard(getSourceId());
if (card != null) {
Card mainCard = card.getMainCard();
UUID mainCardId = mainCard.getId();
// Card must be in the exile zone
if (game.getState().getZone(mainCardId) != Zone.EXILED) {
return ActivationStatus.getFalse();
}
Integer plottedTurn = (Integer) game.getState().getValue(PlotAbility.getPlotTurnKeyForCard(mainCardId));
// Card must have been plotted
if (plottedTurn == null) {
return ActivationStatus.getFalse();
}
// Can't be cast if the turn it was last Plotted is the same
if (plottedTurn == game.getTurnNum()) {
return ActivationStatus.getFalse();
}
// Only allow the cast at sorcery speed
if (!game.canPlaySorcery(playerId)) {
return ActivationStatus.getFalse();
}
// Check that the proper face can be cast.
// TODO: As with Foretell, this does not look very clean. Is the face card sometimes incorrect on calling canActivate?
if (mainCard instanceof CardWithHalves) {
if (((CardWithHalves) mainCard).getLeftHalfCard().getName().equals(faceCardName)) {
return ((CardWithHalves) mainCard).getLeftHalfCard().getSpellAbility().canActivate(playerId, game);
} else if (((CardWithHalves) mainCard).getRightHalfCard().getName().equals(faceCardName)) {
return ((CardWithHalves) mainCard).getRightHalfCard().getSpellAbility().canActivate(playerId, game);
}
} else if (card instanceof AdventureCard) {
if (card.getMainCard().getName().equals(faceCardName)) {
return card.getMainCard().getSpellAbility().canActivate(playerId, game);
} else if (((AdventureCard) card).getSpellCard().getName().equals(faceCardName)) {
return ((AdventureCard) card).getSpellCard().getSpellAbility().canActivate(playerId, game);
}
}
return card.getSpellAbility().canActivate(playerId, game);
}
}
return ActivationStatus.getFalse();
}
@Override
public SpellAbility getSpellAbilityToResolve(Game game) {
Card card = game.getCard(getSourceId());
if (card != null) {
if (spellAbilityToResolve == null) {
SpellAbility spellAbilityCopy = null;
// TODO: As with Foretell, this does not look very clean. Is the face card sometimes incorrect on calling getSpellAbilityToResolve?
if (card instanceof CardWithHalves) {
if (((CardWithHalves) card).getLeftHalfCard().getName().equals(faceCardName)) {
spellAbilityCopy = ((CardWithHalves) card).getLeftHalfCard().getSpellAbility().copy();
} else if (((CardWithHalves) card).getRightHalfCard().getName().equals(faceCardName)) {
spellAbilityCopy = ((CardWithHalves) card).getRightHalfCard().getSpellAbility().copy();
}
} else if (card instanceof AdventureCard) {
if (card.getMainCard().getName().equals(faceCardName)) {
spellAbilityCopy = card.getMainCard().getSpellAbility().copy();
} else if (((AdventureCard) card).getSpellCard().getName().equals(faceCardName)) {
spellAbilityCopy = ((AdventureCard) card).getSpellCard().getSpellAbility().copy();
}
} else {
spellAbilityCopy = card.getSpellAbility().copy();
}
if (spellAbilityCopy == null) {
return null;
}
spellAbilityCopy.setId(this.getId());
spellAbilityCopy.clearManaCosts();
spellAbilityCopy.clearManaCostsToPay();
spellAbilityCopy.addCost(this.getCosts().copy());
spellAbilityCopy.addCost(this.getManaCosts().copy());
spellAbilityCopy.setSpellAbilityCastMode(this.getSpellAbilityCastMode());
spellAbilityToResolve = spellAbilityCopy;
}
}
return spellAbilityToResolve;
}
@Override
public Costs<Cost> getCosts() {
if (spellAbilityToResolve == null) {
return super.getCosts();
}
return spellAbilityToResolve.getCosts();
}
}

View file

@ -21,7 +21,8 @@ public enum SpellAbilityCastMode {
DISGUISE("Disguise", false, true),
TRANSFORMED("Transformed", true),
DISTURB("Disturb", true),
MORE_THAN_MEETS_THE_EYE("More than Meets the Eye", true);
MORE_THAN_MEETS_THE_EYE("More than Meets the Eye", true),
PLOT("Plot");
private final String text;
@ -91,6 +92,7 @@ public enum SpellAbilityCastMode {
case MADNESS:
case FLASHBACK:
case DISTURB:
case PLOT:
case MORE_THAN_MEETS_THE_EYE:
// it changes only cost, so keep other characteristics
// TODO: research - why TRANSFORMED here - is it used in this.isTransformed code?!

View file

@ -623,6 +623,12 @@ public class GameEvent implements Serializable {
playerId controller of the creature mentoring
*/
MENTORED_CREATURE,
/* the card becomes plotted
targetId card that was plotted
sourceId of the plotting ability (may be the card itself or another one)
playerId owner of the plotted card (the one able to cast the card)
*/
BECOME_PLOTTED,
//custom events
CUSTOM_EVENT
}

View file

@ -1323,7 +1323,7 @@ public final class CardUtil {
* such as the adventure and main side of adventure spells or both sides of a fuse card.
*
* @param cardToCast
* @param filter A filter to determine if a card is eligible for casting.
* @param filter An optional filter to determine if a card is eligible for casting.
* @param source The ability or source responsible for the casting.
* @param player
* @param game
@ -1347,7 +1347,9 @@ public final class CardUtil {
if (!playLand || !player.canPlayLand() || !game.isActivePlayer(playerId)) {
cards.removeIf(card -> card.isLand(game));
}
cards.removeIf(card -> !filter.match(card, playerId, source, game));
if (filter != null) {
cards.removeIf(card -> !filter.match(card, playerId, source, game));
}
if (spellCastTracker != null) {
cards.removeIf(card -> !spellCastTracker.checkCard(card, game));
}