update exile zone for CraftAbility to share between card sides

* updated and added test coverage for previously converted cards Altar of the Wretched and Eye of Ojer Taq
This commit is contained in:
jmlundeen 2025-12-04 09:36:26 -06:00
parent ae97f8944d
commit 99bb467bdc
5 changed files with 216 additions and 42 deletions

View file

@ -117,7 +117,7 @@ enum WretchedBonemassDynamicValue implements DynamicValue {
ExileZone exileZone = game
.getExile()
.getExileZone(CardUtil.getExileZoneId(
game, permanent.getId(), permanent.getZoneChangeCounter(game) - 2
game, permanent.getMainCard().getId(), permanent.getZoneChangeCounter(game) - 1
));
if (exileZone == null) {
return 0;
@ -167,7 +167,7 @@ class WretchedBonemassGainAbilityEffect extends ContinuousEffectImpl {
ExileZone exileZone = game
.getExile()
.getExileZone(CardUtil.getExileZoneId(
game, wretchedBonemass.getId(), wretchedBonemass.getZoneChangeCounter(game) - 2
game, wretchedBonemass.getMainCard().getId(), wretchedBonemass.getZoneChangeCounter(game) - 1
));
if (exileZone != null
&& !exileZone.isEmpty()) {

View file

@ -2,14 +2,15 @@ package mage.cards.e;
import mage.MageObject;
import mage.abilities.Ability;
import mage.abilities.SpellAbility;
import mage.abilities.common.SimpleActivatedAbility;
import mage.abilities.common.SimpleStaticAbility;
import mage.abilities.condition.Condition;
import mage.abilities.costs.AlternativeCostSourceAbility;
import mage.abilities.costs.common.TapSourceCost;
import mage.abilities.effects.ContinuousEffectImpl;
import mage.abilities.effects.EntersBattlefieldEffect;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.effects.common.TapSourceEffect;
import mage.abilities.effects.common.cost.CostModificationEffectImpl;
import mage.abilities.keyword.CraftAbility;
import mage.abilities.mana.AnyColorManaAbility;
import mage.cards.Card;
@ -27,6 +28,7 @@ import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.target.common.TargetCardInGraveyardBattlefieldOrStack;
import mage.util.CardUtil;
import mage.watchers.common.SpellsCastWatcher;
import java.util.*;
import java.util.stream.Collectors;
@ -149,7 +151,10 @@ class ChooseCardTypeEffect extends OneShotEffect {
if (permanent == null) {
return false;
}
ExileZone exileZone = game.getState().getExile().getExileZone(CardUtil.getExileZoneId(game, source, game.getState().getZoneChangeCounter(mageObject.getId()) - 1));
ExileZone exileZone = game.getState()
.getExile()
.getExileZone(CardUtil
.getExileZoneId(game, permanent.getMainCard().getId(), permanent.getMainCard().getZoneChangeCounter(game)));
if (exileZone == null) {
return false;
}
@ -232,63 +237,74 @@ class ApexObservatoryEffect extends OneShotEffect {
}
}
class ApexObservatoryCastWithoutManaEffect extends CostModificationEffectImpl {
class ApexObservatoryCastWithoutManaEffect extends ContinuousEffectImpl {
class ApexObservatoryCondition implements Condition {
private final int spellCastCount;
private ApexObservatoryCondition(int spellCastCount) {
this.spellCastCount = spellCastCount;
}
@Override
public boolean apply(Game game, Ability source) {
SpellsCastWatcher watcher = game.getState().getWatcher(SpellsCastWatcher.class);
if (watcher != null) {
return watcher.getSpellsCastThisTurn(playerId).size() == spellCastCount;
}
return false;
}
}
private final FilterCard filter;
private final String chosenCardType;
private final UUID playerId;
private boolean used = false;
private int spellCastCount;
private AlternativeCostSourceAbility alternativeCostSourceAbility;
ApexObservatoryCastWithoutManaEffect(String chosenCardType, UUID playerId) {
super(Duration.EndOfTurn, Outcome.Benefit, CostModificationType.SET_COST);
super(Duration.EndOfTurn, Layer.RulesEffects, SubLayer.NA, Outcome.PlayForFree);
this.chosenCardType = chosenCardType;
this.playerId = playerId;
this.filter = new FilterCard("spell of the chosen type");
filter.add(CardType.fromString(chosenCardType).getPredicate());
staticText = "The next spell you cast this turn of the chosen type can be cast without paying its mana cost";
}
@Override
public void init(Ability source, Game game) {
super.init(source, game);
SpellsCastWatcher watcher = game.getState().getWatcher(SpellsCastWatcher.class);
if (watcher != null) {
spellCastCount = watcher.getSpellsCastThisTurn(playerId).size();
Condition condition = new ApexObservatoryCondition(spellCastCount);
alternativeCostSourceAbility = new AlternativeCostSourceAbility(
null, condition, null, filter, true
);
}
}
private ApexObservatoryCastWithoutManaEffect(final ApexObservatoryCastWithoutManaEffect effect) {
super(effect);
this.chosenCardType = effect.chosenCardType;
this.playerId = effect.playerId;
this.used = effect.used;
this.spellCastCount = effect.spellCastCount;
this.filter = effect.filter;
this.alternativeCostSourceAbility = effect.alternativeCostSourceAbility;
}
@Override
public boolean apply(Game game, Ability source, Ability abilityToModify) {
public boolean apply(Game game, Ability source) {
Player controller = game.getPlayer(playerId);
if (controller != null) {
MageObject spell = abilityToModify.getSourceObject(game);
if (spell != null && !game.isSimulation()) {
String message = "Cast " + spell.getIdName() + " without paying its mana cost?";
if (controller.chooseUse(Outcome.Benefit, message, source, game)) {
abilityToModify.getManaCostsToPay().clear();
used = true;
}
}
if (controller == null) {
return false;
}
alternativeCostSourceAbility.setSourceId(source.getSourceId());
controller.getAlternativeSourceCosts().add(alternativeCostSourceAbility);
return true;
}
@Override
public boolean isInactive(Ability source, Game game) {
return used || super.isInactive(source, game);
}
@Override
public boolean applies(Ability ability, Ability source, Game game) {
if (used) {
return false;
}
if (!ability.isControlledBy(playerId)) {
return false;
}
if (!(ability instanceof SpellAbility)) {
return false;
}
MageObject object = game.getObject(ability.getSourceId());
return object != null && object.getCardType(game).stream()
.anyMatch(cardType -> cardType.toString().equals(chosenCardType));
}
@Override
public ApexObservatoryCastWithoutManaEffect copy() {
return new ApexObservatoryCastWithoutManaEffect(this);

View file

@ -0,0 +1,72 @@
package org.mage.test.cards.single.lcc;
import mage.abilities.Ability;
import mage.abilities.keyword.FlyingAbility;
import mage.abilities.keyword.LifelinkAbility;
import mage.abilities.keyword.VigilanceAbility;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
import java.util.ArrayList;
import java.util.List;
/**
*
* @author Jmlundeen
*/
public class AltarOfTheWretchedTest extends CardTestPlayerBase {
/*
Altar of the Wretched
{2}{B}
Artifact
When Altar of the Wretched enters the battlefield, you may sacrifice a nontoken creature. If you do, draw X cards, then mill X cards, where X is that creature's power.
Craft with one or more creatures {2}{B}{B}
{2}{B}: Return Altar of the Wretched from your graveyard to your hand.
Wretched Bonemass
Color Indicator: Black
Creature Skeleton Horror
Wretched Bonemasss power and toughness are each equal to the total power of the exiled cards used to craft it.
This creature has flying as long as an exiled card used to craft it has flying. The same is true for first strike, double strike, deathtouch, haste, hexproof, indestructible, lifelink, menace, protection, reach, trample, and vigilance.
0/0
*/
private static final String altarOfTheWretched = "Altar of the Wretched";
private static final String wretchedBoneMass = "Wretched Bonemass";
/*
Angel of Invention
{3}{W}{W}
Creature - Angel
Flying, vigilance, lifelink
Fabricate 2
Other creatures you control get +1/+1.
2/1
*/
private static final String angelOfInvention = "Angel of Invention";
@Test
public void testAltarOfTheWretched() {
addCard(Zone.BATTLEFIELD, playerA, altarOfTheWretched);
addCard(Zone.BATTLEFIELD, playerA, angelOfInvention);
addCard(Zone.BATTLEFIELD, playerA, "Swamp", 4);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Craft with one or more");
addTarget(playerA, angelOfInvention);
setStrictChooseMode(true);
setStopAt(1, PhaseStep.PRECOMBAT_MAIN);
execute();
assertPowerToughness(playerA, wretchedBoneMass, 2, 2);
assertExileCount(playerA, angelOfInvention, 1);
List<Ability> abilities = new ArrayList<>();
abilities.add(FlyingAbility.getInstance());
abilities.add(VigilanceAbility.getInstance());
abilities.add(LifelinkAbility.getInstance());
assertAbilities(playerA, wretchedBoneMass, abilities);
}
}

View file

@ -0,0 +1,86 @@
package org.mage.test.cards.single.lcc;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
*
* @author Jmlundeen
*/
public class EyeOfOjerTaqTest extends CardTestPlayerBase {
/*
Eye of Ojer Taq
{3}
Artifact
{T}: Add one mana of any color.
Craft with two that share a card type {6}
Apex Observatory
Artifact
Apex Observatory enters the battlefield tapped. As it enters, choose a card type shared among two exiled cards used to craft it.
{T}: The next spell you cast this turn of the chosen type can be cast without paying its mana cost.
*/
private static final String eyeOfOjerTaq = "Eye of Ojer Taq";
private static final String apexObservatory = "Apex Observatory";
/*
Lightning Bolt
{R}
Instant
Lightning Bolt deals 3 damage to any target.
*/
private static final String lightningBolt = "Lightning Bolt";
/*
Ponder
{U}
Sorcery
Look at the top three cards of your library, then put them back in any order. You may shuffle your library.
Draw a card.
*/
private static final String ponder = "Ponder";
/*
Shock
{R}
Instant
Shock deals 2 damage to any target.
*/
private static final String shock = "Shock";
@Test
public void testEyeOfOjerTaq() {
addCard(Zone.BATTLEFIELD, playerA, eyeOfOjerTaq);
addCard(Zone.BATTLEFIELD, playerA, "Forest", 6);
addCard(Zone.GRAVEYARD, playerA, lightningBolt);
addCard(Zone.GRAVEYARD, playerA, shock);
addCard(Zone.HAND, playerA, shock, 2);
addCard(Zone.HAND, playerA, ponder);
activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Craft with two that share a card type");
addTarget(playerA, lightningBolt + "^" + shock);
setChoice(playerA, "Instant");
activateAbility(3, PhaseStep.PRECOMBAT_MAIN, playerA, "{T}: The next spell");
waitStackResolved(3, PhaseStep.PRECOMBAT_MAIN, playerA);
castSpell(3, PhaseStep.PRECOMBAT_MAIN, playerA, shock, playerB); // should be able to cast for free
setChoice(playerA, "Cast without paying");
checkPlayableAbility("Can't cast second shock", 3, PhaseStep.PRECOMBAT_MAIN, playerA, "Cast Shock", false);
checkPlayableAbility("Can't cast ponder", 1, PhaseStep.POSTCOMBAT_MAIN, playerA, "Cast Ponder", false);
setStrictChooseMode(true);
setStopAt(3, PhaseStep.POSTCOMBAT_MAIN);
execute();
assertLife(playerB, 20 - 2); // took 2 damage from shock
assertExileCount(playerA, lightningBolt, 1);
assertExileCount(playerA, shock, 1);
assertGraveyardCount(playerA, shock, 1);
assertPermanentCount(playerA, apexObservatory, 1);
}
}

View file

@ -9,7 +9,6 @@ import mage.abilities.costs.common.ExileSourceCost;
import mage.abilities.costs.mana.ManaCostsImpl;
import mage.abilities.effects.OneShotEffect;
import mage.cards.Card;
import mage.cards.TransformingDoubleFacedCardHalf;
import mage.constants.*;
import mage.filter.FilterCard;
import mage.filter.FilterPermanent;
@ -122,7 +121,8 @@ class CraftCost extends CostImpl {
@Override
public boolean pay(Ability ability, Game game, Ability source, UUID controllerId, boolean noMana, Cost costToPay) {
Player player = game.getPlayer(source.getControllerId());
if (player == null) {
Card sourceCard = game.getCard(source.getSourceId());
if (player == null || sourceCard == null) {
paid = false;
return paid;
}
@ -143,7 +143,7 @@ class CraftCost extends CostImpl {
.collect(Collectors.toSet());
player.moveCardsToExile(
cards, source, game, true,
CardUtil.getExileZoneId(game, source),
CardUtil.getExileZoneId(game, sourceCard.getMainCard().getId(), sourceCard.getMainCard().getZoneChangeCounter(game)),
CardUtil.getSourceName(game, source)
);
paid = true;