Implement Prototype ability (#11249)

Prototype is a SpellAbilityType, for which alternate costs are permitted. It has a continuous effect that applies on the battlefield as well as a method to modify the spell on the stack. Permanents have an isPrototyped flag that copy effects can check explicitly (same brittle method as transformed permanents use; reworking copy effects to streamline them is a separate scope).

Many test cases have been added to confirm functionality (thanks to Zerris for additional test suggestions).

---------

Co-authored-by: Susucre <34709007+Susucre@users.noreply.github.com>
Co-authored-by: Evan Kranzler <theelk801@gmail.com>
Co-authored-by: xenohedron <xenohedron@users.noreply.github.com>
This commit is contained in:
ssk97 2023-10-09 18:06:19 -07:00 committed by GitHub
parent ac20483b73
commit 5e095afdb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 946 additions and 17 deletions

View file

@ -116,6 +116,8 @@ public interface MageObject extends MageItem, Serializable, Copyable<MageObject>
ManaCosts<ManaCost> getManaCost();
void setManaCost(ManaCosts<ManaCost> costs);
default List<String> getManaCostSymbols() {
List<String> symbols = new ArrayList<>();
for (ManaCost cost : getManaCost()) {

View file

@ -281,6 +281,11 @@ public abstract class MageObjectImpl implements MageObject {
return manaCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
this.manaCost = costs.copy();
}
@Override
public int getManaValue() {
if (manaCost != null) {

View file

@ -442,6 +442,9 @@ public abstract class AbilityImpl implements Ability {
// mandatory additional costs the spell has, such as that of Tormenting Voice. (2018-12-07)
canUseAdditionalCost = true;
break;
case PROTOTYPE:
// Notably, casting a spell as a prototype does not count as paying an alternative cost.
// https://magic.wizards.com/en/news/feature/comprehensive-rules-changes
case NORMAL:
canUseAlternativeCost = true;
canUseAdditionalCost = true;

View file

@ -322,7 +322,7 @@ public class SpellAbility extends ActivatedAbilityImpl {
}
if (spellCharacteristics != null) {
if (getSpellAbilityCastMode() != SpellAbilityCastMode.NORMAL) {
spellCharacteristics = getSpellAbilityCastMode().getTypeModifiedCardObjectCopy(spellCharacteristics, game);
spellCharacteristics = getSpellAbilityCastMode().getTypeModifiedCardObjectCopy(spellCharacteristics, this);
}
}
return spellCharacteristics;

View file

@ -146,6 +146,7 @@ public class CopyEffect extends ContinuousEffectImpl {
//permanent.setSecondCardFace(targetPermanent.getSecondCardFace());
permanent.setFlipCard(targetPermanent.isFlipCard());
permanent.setFlipCardName(targetPermanent.getFlipCardName());
permanent.setPrototyped(targetPermanent.isPrototyped());
}
CardUtil.copySetAndCardNumber(permanent, copyFromObject);

View file

@ -1,21 +1,48 @@
package mage.abilities.keyword;
import mage.MageObject;
import mage.ObjectColor;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.cards.Card;
import mage.constants.*;
import mage.game.Game;
import mage.game.permanent.Permanent;
/**
* @author TheElk801
* @author TheElk801, Susucr, notgreat
*/
public class PrototypeAbility extends SpellAbility {
private final int power;
private final int toughness;
private final String manaString;
private final String rule;
public PrototypeAbility(Card card, String manaString, int power, int toughness) {
super(new ManaCostsImpl<>(manaString), card.getName());
// TODO: implement this
this.setSpellAbilityCastMode(SpellAbilityCastMode.PROTOTYPE);
this.setTiming(TimingRule.SORCERY);
this.addSubAbility(new SimpleStaticAbility(
Zone.BATTLEFIELD, new PrototypeEffect(power, toughness, manaString)
).setRuleVisible(false));
this.rule = "Prototype " + manaString + " &mdash; " + power + "/" + toughness +
" <i>(You may cast this spell with different mana cost, color, and size. It keeps its abilities and types.)</i>";
setRuleAtTheTop(true);
this.power = power;
this.toughness = toughness;
this.manaString = manaString;
}
private PrototypeAbility(final PrototypeAbility ability) {
super(ability);
this.rule = ability.rule;
this.power = ability.power;
this.toughness = ability.toughness;
this.manaString = ability.manaString;
}
@Override
@ -25,6 +52,68 @@ public class PrototypeAbility extends SpellAbility {
@Override
public String getRule() {
return "Prototype";
return rule;
}
//based on TransformAbility
public Card prototypeCardSpell(Card original) {
Card newCard = original.copy();
newCard.setManaCost(new ManaCostsImpl<>(manaString));
newCard.getPower().setModifiedBaseValue(power);
newCard.getToughness().setModifiedBaseValue(toughness);
newCard.getColor().setColor(new ObjectColor(manaString));
return newCard;
}
public void prototypePermanent(MageObject targetObject, Game game) {
if (targetObject instanceof Permanent) {
((Permanent)targetObject).setPrototyped(true);
}
targetObject.getColor(game).setColor(new ObjectColor(manaString));
targetObject.setManaCost(new ManaCostsImpl<>(manaString));
targetObject.getPower().setModifiedBaseValue(power);
targetObject.getToughness().setModifiedBaseValue(toughness);
}
}
class PrototypeEffect extends ContinuousEffectImpl {
private final int power;
private final int toughness;
private final String manaString;
private final ObjectColor color;
PrototypeEffect(int power, int toughness, String manaString) {
super(Duration.EndOfGame, Layer.CopyEffects_1, SubLayer.CopyEffects_1a, Outcome.Benefit);
this.power = power;
this.toughness = toughness;
this.manaString = manaString;
this.color = new ObjectColor(manaString);
}
private PrototypeEffect(final PrototypeEffect effect) {
super(effect);
this.power = effect.power;
this.toughness = effect.toughness;
this.manaString = effect.manaString;
this.color = effect.color;
}
@Override
public PrototypeEffect copy() {
return new PrototypeEffect(this);
}
@Override
public boolean apply(Game game, Ability source) {
Permanent permanent = game.getPermanent(source.getSourceId());
if (permanent == null || !permanent.isPrototyped()) {
return false;
}
permanent.setManaCost(new ManaCostsImpl<>(manaString));
permanent.getColor(game).setColor(color);
permanent.getPower().setModifiedBaseValue(power);
permanent.getToughness().setModifiedBaseValue(toughness);
return true;
}
}

View file

@ -1,9 +1,10 @@
package mage.constants;
import mage.abilities.SpellAbility;
import mage.abilities.keyword.BestowAbility;
import mage.abilities.keyword.MorphAbility;
import mage.abilities.keyword.PrototypeAbility;
import mage.cards.Card;
import mage.game.Game;
import mage.abilities.keyword.MorphAbility;
import mage.game.stack.Spell;
/**
@ -14,6 +15,7 @@ public enum SpellAbilityCastMode {
MADNESS("Madness"),
FLASHBACK("Flashback"),
BESTOW("Bestow"),
PROTOTYPE("Prototype"),
MORPH("Morph"),
TRANSFORMED("Transformed", true),
DISTURB("Disturb", true),
@ -42,7 +44,7 @@ public enum SpellAbilityCastMode {
return text;
}
public Card getTypeModifiedCardObjectCopy(Card card, Game game) {
public Card getTypeModifiedCardObjectCopy(Card card, SpellAbility spellAbility) {
Card cardCopy = card.copy();
if (this.equals(BESTOW)) {
BestowAbility.becomeAura(cardCopy);
@ -53,6 +55,9 @@ public enum SpellAbilityCastMode {
cardCopy = tmp.copy();
}
}
if (this.equals(PROTOTYPE)) {
cardCopy = ((PrototypeAbility) spellAbility).prototypeCardSpell(cardCopy);
}
if (this.equals(MORPH)) {
if (cardCopy instanceof Spell) {
//Spell doesn't support setName, so make a copy of the card (we're blowing it away anyway)

View file

@ -133,6 +133,11 @@ public abstract class Designation extends MageObjectImpl {
return emptyCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public int getManaValue() {
return 0;

View file

@ -1978,6 +1978,14 @@ public abstract class GameImpl implements Game {
if (copyFromPermanent.isTransformed()) {
TransformAbility.transformPermanent(newBluePrint, newBluePrint.getSecondCardFace(), this, source);
}
if (copyFromPermanent.isPrototyped()) {
Abilities<Ability> abilities = copyFromPermanent.getAbilities();
for (Ability ability : abilities){
if (ability instanceof PrototypeAbility) {
((PrototypeAbility) ability).prototypePermanent(newBluePrint, this);
}
}
}
}
if (applier != null) {
applier.apply(this, newBluePrint, source, copyToPermanentId);

View file

@ -1,6 +1,7 @@
package mage.game;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.keyword.TransformAbility;
import mage.cards.*;
import mage.constants.Outcome;
@ -361,8 +362,16 @@ public final class ZonesHandler {
// put onto battlefield with possible counters
game.getPermanentsEntering().put(permanent.getId(), permanent);
card.checkForCountersToAdd(permanent, source, game);
permanent.setTapped(info instanceof ZoneChangeInfo.Battlefield
&& ((ZoneChangeInfo.Battlefield) info).tapped);
if (Zone.STACK == event.getFromZone()) {
Spell spell = game.getStack().getSpell(event.getTargetId());
if (spell != null) {
permanent.setPrototyped(spell.isPrototyped());
}
}
permanent.setFaceDown(info.faceDown, game);
if (info.faceDown) {

View file

@ -238,6 +238,11 @@ public class Commander extends CommandObjectImpl {
return sourceObject.getManaCost();
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public int getManaValue() {
return sourceObject.getManaValue();

View file

@ -255,6 +255,11 @@ public class Dungeon extends CommandObjectImpl {
return emptyCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public int getManaValue() {
return 0;

View file

@ -170,6 +170,11 @@ public abstract class Emblem extends CommandObjectImpl {
return emptyCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public int getManaValue() {
return 0;

View file

@ -195,6 +195,11 @@ public abstract class Plane extends CommandObjectImpl {
return emptyCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public int getManaValue() {
return 0;

View file

@ -75,6 +75,10 @@ public interface Permanent extends Card, Controllable {
void setRenowned(boolean value);
boolean isPrototyped();
void setPrototyped(boolean value);
int getClassLevel();
/**

View file

@ -109,6 +109,7 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
protected Map<String, String> info = new LinkedHashMap<>(); // additional info for permanent's rules
protected int createOrder;
protected boolean legendRuleApplies = true;
protected boolean prototyped;
private static final List<UUID> emptyList = Collections.unmodifiableList(new ArrayList<>());
@ -179,6 +180,7 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
this.morphed = permanent.morphed;
this.manifested = permanent.manifested;
this.createOrder = permanent.createOrder;
this.prototyped = permanent.prototyped;
}
@Override
@ -1616,6 +1618,11 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
return this.monstrous;
}
@Override
public boolean isPrototyped() {
return this.prototyped;
}
@Override
public void setMonstrous(boolean value) {
this.monstrous = value;
@ -1829,6 +1836,10 @@ public abstract class PermanentImpl extends CardImpl implements Permanent {
this.secondSideCard = card;
}
public void setPrototyped(boolean prototyped) {
this.prototyped = prototyped;
}
@Override
public boolean isRingBearer() {
return ringBearerFlag;

View file

@ -331,6 +331,11 @@ public abstract class TokenImpl extends MageObjectImpl implements Token {
allAddedTokens.add((PermanentToken) permanent);
}
// prototyped spell tokens make prototyped permanent tokens on resolution.
if (source instanceof SpellAbility && ((SpellAbility) source).getSpellAbilityCastMode() == SpellAbilityCastMode.PROTOTYPE) {
permanent.setPrototyped(true);
}
// if token was created (not a spell copy) handle auras coming into the battlefield
// that must determine what to enchant
// see #9583 for the root cause issue of why this convoluted searching is necessary
@ -345,6 +350,7 @@ public abstract class TokenImpl extends MageObjectImpl implements Token {
if (!(ability instanceof SpellAbility)) {
continue;
}
auraOutcome = ability.getEffects().getOutcome(ability);
for (Effect effect : ability.getEffects()) {
if (!(effect instanceof AttachEffect)) {

View file

@ -10,6 +10,7 @@ import mage.abilities.costs.mana.ManaCost;
import mage.abilities.costs.mana.ManaCosts;
import mage.abilities.keyword.BestowAbility;
import mage.abilities.keyword.MorphAbility;
import mage.abilities.keyword.PrototypeAbility;
import mage.abilities.keyword.TransformAbility;
import mage.cards.*;
import mage.constants.*;
@ -46,6 +47,7 @@ public class Spell extends StackObjectImpl implements Card {
private final List<SpellAbility> spellAbilities = new ArrayList<>();
private final Card card;
private ManaCosts<ManaCost> manaCost;
private final ObjectColor color;
private final ObjectColor frameColor;
private final FrameStyle frameStyle;
@ -62,6 +64,7 @@ public class Spell extends StackObjectImpl implements Card {
private boolean resolving = false;
private UUID commandedByPlayerId = null; // controller of the spell resolve, example: Word of Command
private String commandedByInfo; // info about spell commanded, e.g. source
private boolean prototyped;
private int startingLoyalty;
private int startingDefense;
@ -79,8 +82,13 @@ public class Spell extends StackObjectImpl implements Card {
// simulate another side as new card (another code part in continues effect from disturb ability)
affectedCard = TransformAbility.transformCardSpellStatic(card, card.getSecondCardFace(), game);
}
if (ability instanceof PrototypeAbility){
affectedCard = ((PrototypeAbility)ability).prototypeCardSpell(card);
this.prototyped = true;
}
this.card = affectedCard;
this.manaCost = this.card.getManaCost().copy();
this.color = affectedCard.getColor(null).copy();
this.frameColor = affectedCard.getFrameColor(null).copy();
this.frameStyle = affectedCard.getFrameStyle();
@ -109,6 +117,7 @@ public class Spell extends StackObjectImpl implements Card {
} else {
spellAbilities.add(ability);
}
this.controllerId = controllerId;
this.fromZone = fromZone;
this.countered = false;
@ -128,6 +137,7 @@ public class Spell extends StackObjectImpl implements Card {
this.card = spell.card.copy();
this.fromZone = spell.fromZone;
this.manaCost = spell.getManaCost().copy();
this.color = spell.color.copy();
this.frameColor = spell.color.copy();
this.frameStyle = spell.frameStyle;
@ -143,6 +153,7 @@ public class Spell extends StackObjectImpl implements Card {
this.currentActivatingManaAbilitiesStep = spell.currentActivatingManaAbilitiesStep;
this.targetChanged = spell.targetChanged;
this.prototyped = spell.prototyped;
this.startingLoyalty = spell.startingLoyalty;
this.startingDefense = spell.startingDefense;
}
@ -632,9 +643,12 @@ public class Spell extends StackObjectImpl implements Card {
@Override
public ManaCosts<ManaCost> getManaCost() {
return card.getManaCost();
return this.manaCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) { this.manaCost = costs.copy(); }
/**
* 202.3b When calculating the converted mana cost of an object with an {X}
* in its mana cost, X is treated as 0 while the object is not on the stack,
@ -652,7 +666,7 @@ public class Spell extends StackObjectImpl implements Card {
for (SpellAbility spellAbility : spellAbilities) {
cmc += spellAbility.getConvertedXManaCost(getCard());
}
cmc += getCard().getManaCost().manaValue();
cmc += this.manaCost.manaValue();
return cmc;
}
@ -789,6 +803,10 @@ public class Spell extends StackObjectImpl implements Card {
return false;
}
public boolean isPrototyped() {
return prototyped;
}
@Override
public Spell copy() {
return new Spell(this);

View file

@ -246,6 +246,11 @@ public class StackAbility extends StackObjectImpl implements Ability {
return emptyCost;
}
@Override
public void setManaCost(ManaCosts<ManaCost> costs) {
throw new UnsupportedOperationException("Unsupported operation");
}
@Override
public List<String> getManaCostSymbols() {
return super.getManaCostSymbols();

View file

@ -1,8 +1,10 @@
package mage.util.functions;
import mage.MageObject;
import mage.abilities.Abilities;
import mage.abilities.Ability;
import mage.abilities.keyword.MorphAbility;
import mage.abilities.keyword.PrototypeAbility;
import mage.cards.Card;
import mage.constants.CardType;
import mage.constants.SuperType;
@ -83,6 +85,14 @@ public class CopyTokenFunction {
copyToToken(target.getBackFace(), ((Card) sourceObj).getSecondCardFace(), game);
CardUtil.copySetAndCardNumber(target.getBackFace(), ((Card) sourceObj).getSecondCardFace());
}
if (((PermanentCard) source).isPrototyped()){
Abilities<Ability> abilities = source.getAbilities();
for (Ability ability : abilities){
if (ability instanceof PrototypeAbility) {
((PrototypeAbility) ability).prototypePermanent(target, game);
}
}
}
return;
}