Initial checkin for Waterbending (#9120)

This commit is contained in:
Chris H
2025-11-10 05:27:44 -05:00
committed by GitHub
parent 219e3d6182
commit c9a5fe9135
18 changed files with 186 additions and 47 deletions

View File

@@ -1345,9 +1345,7 @@ public class ComputerUtilMana {
}
}
if (!effect) {
CostAdjustment.adjust(manaCost, sa, null, test);
}
CostAdjustment.adjust(manaCost, sa, null, test, effect);
if ("NumTimes".equals(sa.getParam("Announce"))) { // e.g. the Adversary cycle
ManaCost mkCost = sa.getPayCosts().getTotalMana();
@@ -1773,15 +1771,18 @@ public class ComputerUtilMana {
/**
* Matches list of creatures to shards in mana cost for convoking.
*
* @param cost cost of convoked ability
* @param list creatures to be evaluated
* @param improvise
* @param artifacts
* @param creatures
* @return map between creatures and shards to convoke
*/
public static Map<Card, ManaCostShard> getConvokeOrImproviseFromList(final ManaCost cost, List<Card> list, boolean improvise) {
public static Map<Card, ManaCostShard> getConvokeOrImproviseFromList(final ManaCost cost, List<Card> list, boolean artifacts, boolean creatures) {
final Map<Card, ManaCostShard> convoke = new HashMap<>();
Card convoked = null;
if (!improvise) {
if (creatures && !artifacts) {
// Run for convoke but not improvise or waterbending
for (ManaCostShard toPay : cost) {
if (toPay.isSnow() || toPay.isColorless()) {
continue;

View File

@@ -1382,7 +1382,7 @@ public class PlayerControllerAi extends PlayerController {
}
@Override
public Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(SpellAbility sa, ManaCost manaCost, CardCollectionView untappedCards, boolean improvise) {
public Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(SpellAbility sa, ManaCost manaCost, CardCollectionView untappedCards, boolean artifacts, boolean creatures, Integer maxReduction) {
final Player ai = sa.getActivatingPlayer();
final PhaseHandler ph = ai.getGame().getPhaseHandler();
//Filter out mana sources that will interfere with payManaCost()
@@ -1390,9 +1390,10 @@ public class PlayerControllerAi extends PlayerController {
// Filter out creatures if AI hasn't attacked yet
if (ph.isPlayerTurn(ai) && ph.getPhase().isBefore(PhaseType.COMBAT_DECLARE_ATTACKERS)) {
if (improvise) {
if (!creatures) {
untapped = CardLists.filter(untapped, c -> !c.isCreature());
} else {
// TODO AI needs to learn how to use Convoke or Waterbend
return new HashMap<>();
}
}
@@ -1406,13 +1407,16 @@ public class PlayerControllerAi extends PlayerController {
if (!ai.getGame().getStack().isEmpty()) {
final List<GameObject> objects = ComputerUtil.predictThreatenedObjects(sa.getActivatingPlayer(), null);
for (Card c : blockers) {
if (objects.contains(c) && (!improvise || c.isArtifact())) {
if (objects.contains(c) && (creatures || c.isArtifact())) {
untapped.add(c);
}
if (maxReduction != null && untapped.size() >= maxReduction) {
break;
}
}
}
return ComputerUtilMana.getConvokeOrImproviseFromList(manaCost, untapped, improvise);
}
return ComputerUtilMana.getConvokeOrImproviseFromList(manaCost, untapped, artifacts, creatures);
}
@Override

View File

@@ -15,6 +15,7 @@ import forge.game.*;
import forge.game.ability.AbilityFactory.AbilityRecordType;
import forge.game.card.*;
import forge.game.cost.Cost;
import forge.game.cost.CostAdjustment;
import forge.game.cost.IndividualCostPaymentInstance;
import forge.game.keyword.Keyword;
import forge.game.keyword.KeywordInterface;
@@ -1527,6 +1528,7 @@ public class AbilityUtils {
else {
cost = new Cost(unlessCost, true);
}
cost = CostAdjustment.adjust(cost, sa, true);
return cost;
}

View File

@@ -188,6 +188,15 @@ public class Cost implements Serializable {
return this.isAbility;
}
public final String getMaxWaterbend() {
for (CostPart cp : this.costParts) {
if (cp instanceof CostPartMana) {
return ((CostPartMana) cp).getMaxWaterbend();
}
}
return null;
}
private Cost() {
}
@@ -564,6 +573,11 @@ public class Cost implements Serializable {
return new CostRevealChosen(splitStr[0], splitStr.length > 1 ? splitStr[1] : null);
}
if (parse.startsWith("Waterbend<")) {
final String[] splitStr = abCostParse(parse, 1);
return new CostWaterbend(splitStr[0]);
}
if (parse.equals("Forage")) {
return new CostForage();
}
@@ -973,6 +987,7 @@ public class Cost implements Serializable {
} else {
costParts.add(0, new CostPartMana(manaCost.toManaCost(), null));
}
getCostMana().setMaxWaterbend(mPart.getMaxWaterbend());
} else if (part instanceof CostPutCounter || (mergeAdditional && // below usually not desired because they're from different causes
(part instanceof CostDiscard || part instanceof CostDraw ||
part instanceof CostAddMana || part instanceof CostPayLife ||

View File

@@ -31,11 +31,13 @@ import org.apache.commons.lang3.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Predicate;
public class CostAdjustment {
public static Cost adjust(final Cost cost, final SpellAbility sa, boolean effect) {
if (sa.isTrigger() || cost == null || effect) {
sa.setMaxWaterbend(cost);
return cost;
}
@@ -99,6 +101,9 @@ public class CostAdjustment {
host.setState(CardStateName.Original, false);
host.setFaceDown(false);
}
sa.setMaxWaterbend(result);
return result;
}
@@ -171,22 +176,23 @@ public class CostAdjustment {
// If cardsToDelveOut is null, will immediately exile the delved cards and remember them on the host card.
// Otherwise, will return them in cardsToDelveOut and the caller is responsible for doing the above.
public static boolean adjust(ManaCostBeingPaid cost, final SpellAbility sa, CardCollection cardsToDelveOut, boolean test) {
if (sa.isTrigger() || sa.isReplacementAbility()) {
public static boolean adjust(ManaCostBeingPaid cost, final SpellAbility sa, CardCollection cardsToDelveOut, boolean test, boolean effect) {
if (effect) {
adjustCostByWaterbend(cost, sa, test);
}
if (effect || sa.isTrigger() || sa.isReplacementAbility()) {
return true;
}
final Game game = sa.getActivatingPlayer().getGame();
final Card originalCard = sa.getHostCard();
boolean isStateChangeToFaceDown = false;
if (sa.isSpell()) {
if (sa.isCastFaceDown() && !originalCard.isFaceDown()) {
boolean isStateChangeToFaceDown = false;
if (sa.isSpell() && sa.isCastFaceDown() && !originalCard.isFaceDown()) {
// Turn face down to apply cost modifiers correctly
originalCard.turnFaceDownNoUpdate();
isStateChangeToFaceDown = true;
}
}
CardCollection cardsOnBattlefield = new CardCollection(game.getCardsIn(ZoneType.Battlefield));
cardsOnBattlefield.addAll(game.getCardsIn(ZoneType.Stack));
@@ -278,17 +284,19 @@ public class CostAdjustment {
table.triggerChangesZoneAll(game, sa);
}
if (sa.getHostCard().hasKeyword(Keyword.CONVOKE)) {
adjustCostByConvokeOrImprovise(cost, sa, false, test);
adjustCostByConvokeOrImprovise(cost, sa, false, true, test);
}
if (sa.getHostCard().hasKeyword(Keyword.IMPROVISE)) {
adjustCostByConvokeOrImprovise(cost, sa, true, test);
adjustCostByConvokeOrImprovise(cost, sa, true, false, test);
}
} // isSpell
if (sa.hasParam("TapCreaturesForMana")) {
adjustCostByConvokeOrImprovise(cost, sa, false, test);
adjustCostByConvokeOrImprovise(cost, sa, false, true, test);
}
adjustCostByWaterbend(cost, sa, test);
// Reset card state (if changed)
if (isStateChangeToFaceDown) {
originalCard.setFaceDown(false);
@@ -299,6 +307,13 @@ public class CostAdjustment {
}
// GetSpellCostChange
private static void adjustCostByWaterbend(ManaCostBeingPaid cost, SpellAbility sa, boolean test) {
Integer maxWaterbend = sa.getMaxWaterbend();
if (maxWaterbend != null && maxWaterbend > 0) {
adjustCostByConvokeOrImprovise(cost, sa, true, true, test);
}
}
private static boolean adjustCostByAssist(ManaCostBeingPaid cost, final SpellAbility sa, boolean test) {
// 702.132a Assist is a static ability that modifies the rules of paying for the spell with assist (see rules 601.2g-h).
// If the total cost to cast a spell with assist includes a generic mana component, before you activate mana abilities while casting it, you may choose another player.
@@ -321,27 +336,33 @@ public class CostAdjustment {
return assistant.getController().helpPayForAssistSpell(cost, sa, genericLeft, requestedAmount);
}
private static void adjustCostByConvokeOrImprovise(ManaCostBeingPaid cost, final SpellAbility sa, boolean improvise, boolean test) {
if (!improvise) {
private static void adjustCostByConvokeOrImprovise(ManaCostBeingPaid cost, final SpellAbility sa, boolean artifacts, boolean creatures, boolean test) {
if (creatures && !artifacts) {
sa.clearTappedForConvoke();
}
final Player activator = sa.getActivatingPlayer();
CardCollectionView untappedCards = CardLists.filter(activator.getCardsIn(ZoneType.Battlefield),
CardPredicates.CAN_TAP);
if (improvise) {
Integer maxReduction = null;
if (artifacts && creatures) {
maxReduction = sa.getMaxWaterbend();
Predicate <Card> isArtifactOrCreature = card -> card.isArtifact() || card.isCreature();
untappedCards = CardLists.filter(untappedCards, isArtifactOrCreature);
} else if (artifacts) {
untappedCards = CardLists.filter(untappedCards, CardPredicates.ARTIFACTS);
} else {
untappedCards = CardLists.filter(untappedCards, CardPredicates.CREATURES);
}
Map<Card, ManaCostShard> convokedCards = activator.getController().chooseCardsForConvokeOrImprovise(sa,
cost.toManaCost(), untappedCards, improvise);
cost.toManaCost(), untappedCards, artifacts, creatures, maxReduction);
CardCollection tapped = new CardCollection();
for (final Entry<Card, ManaCostShard> conv : convokedCards.entrySet()) {
Card c = conv.getKey();
if (!improvise) {
if (creatures && !artifacts) {
sa.addTappedForConvoke(c);
}
cost.decreaseShard(conv.getValue(), 1);

View File

@@ -39,6 +39,8 @@ public class CostPartMana extends CostPart {
private boolean isEnchantedCreatureCost = false;
private boolean isCostPayAnyNumberOfTimes = false;
protected String maxWaterbend;
public int paymentOrder() { return shouldPayLast() ? 200 : 0; }
public boolean shouldPayLast() {
@@ -63,6 +65,13 @@ public class CostPartMana extends CostPart {
this.isEnchantedCreatureCost = enchantedCreatureCost;
}
public String getMaxWaterbend() {
return maxWaterbend;
}
public void setMaxWaterbend(String max) {
maxWaterbend = max;
}
/**
* Gets the mana.
*
@@ -101,7 +110,7 @@ public class CostPartMana extends CostPart {
public boolean isUndoable() { return true; }
@Override
public final String toString() {
public String toString() {
return cost.toString();
}

View File

@@ -0,0 +1,18 @@
package forge.game.cost;
import forge.card.mana.ManaCost;
import forge.card.mana.ManaCostParser;
public class CostWaterbend extends CostPartMana {
public CostWaterbend(final String mana) {
super(new ManaCost(new ManaCostParser(mana)), null);
maxWaterbend = mana;
}
@Override
public final String toString() {
return "Waterbend " + getMana().toString();
}
}

View File

@@ -202,7 +202,7 @@ public abstract class PlayerController {
public abstract CardCollection chooseCardsToDiscardToMaximumHandSize(int numDiscard);
public abstract CardCollectionView chooseCardsToDelve(int genericAmount, CardCollection grave);
public abstract Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(SpellAbility sa, ManaCost manaCost, CardCollectionView untappedCards, boolean improvise);
public abstract Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(SpellAbility sa, ManaCost manaCost, CardCollectionView untappedCards, boolean artifacts, boolean creatures, Integer maxReduction);
public abstract List<Card> chooseCardsForSplice(SpellAbility sa, List<Card> cards);
public abstract CardCollectionView chooseCardsToRevealFromHand(int min, int max, CardCollectionView valid);

View File

@@ -144,6 +144,7 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
private final Supplier<CardCollection> tappedForConvoke = Suppliers.memoize(CardCollection::new);
private Card sacrificedAsOffering;
private Card sacrificedAsEmerge;
private Integer maxWaterbend;
private AbilityManaPart manaPart;
@@ -2692,4 +2693,14 @@ public abstract class SpellAbility extends CardTraitBase implements ISpellAbilit
public void setName(String name) {
this.name = name;
}
public Integer getMaxWaterbend() {
return maxWaterbend;
}
public void setMaxWaterbend(Cost cost) {
if (cost == null || cost.getMaxWaterbend() == null) {
return;
}
maxWaterbend = AbilityUtils.calculateAmount(getHostCard(), cost.getMaxWaterbend(), this);
}
}

View File

@@ -428,6 +428,10 @@ public class MagicStack /* extends MyObservable */ implements Iterable<SpellAbil
game.getTriggerHandler().runTrigger(TriggerType.AbilityCast, runParams, true);
}
if (sp.getPayCosts() != null && sp.getMaxWaterbend() != null) {
activator.triggerElementalBend(TriggerType.Waterbend);
}
// Run Cycled triggers
if (sp.isCycling()) {
activator.addCycled(sp);

View File

@@ -690,7 +690,7 @@ public class PlayerControllerForTests extends PlayerController {
@Override
public Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(SpellAbility sa, ManaCost manaCost,
CardCollectionView untappedCards, boolean improvise) {
CardCollectionView untappedCards, boolean artifacts, boolean creatures, Integer maxReduction) {
// TODO: AI to choose a creature to tap would go here
// Probably along with deciding how many creatures to tap
return new HashMap<>();

View File

@@ -0,0 +1,8 @@
Name:Geyser Leaper
ManaCost:4 U
Types:Creature Human Warrior Ally
PT:4/3
K:Flying
A:AB$ Draw | Cost$ Waterbend<4> | NumCards$ 1 | SubAbility$ DBDiscard | SpellDescription$ Draw a card, then discard a card. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)
SVar:DBDiscard:DB$ Discard | Defined$ You | NumCards$ 1 | Mode$ TgtChoose
Oracle:Flying\nWaterbend 4: Draw a card, then discard a card.

View File

@@ -0,0 +1,9 @@
Name:Ruinous Waterbending
ManaCost:1 B B
Types:Sorcery Lesson
S:Mode$ OptionalCost | EffectZone$ All | ValidCard$ Card.Self | ValidSA$ Spell | Cost$ Waterbend<4> | Description$ As an additional cost to cast this spell, you may waterbend {4}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)
A:SP$ PumpAll | ValidCards$ Creature | NumAtt$ -2 | NumDef$ -2 | IsCurse$ True | SubAbility$ DBEffect | SpellDescription$ All creatures get -2/-2 until end of turn. If this spells additional cost was paid, whenever a creature dies this turn, you gain 1 life.
SVar:DBEffect:DB$ Effect | Triggers$ TrigDies | Condition$ OptionalCost | ConditionOptionalPaid$ True
SVar:TrigDies:Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Creature | Execute$ TrigGainLife | TriggerDescription$ Whenever a creature dies this turn, you gain 1 life.
SVar:TrigGainLife:DB$ GainLife | LifeAmount$ 1
Oracle:As an additional cost to cast this spell, you may waterbend {4}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)\nAll creatures get -2/-2 until end of turn. If this spells additional cost was paid, whenever a creature dies this turn, you gain 1 life.

View File

@@ -0,0 +1,10 @@
Name:Waterbender's Restoration
ManaCost:U U
Types:Instant Lesson
S:Mode$ RaiseCost | ValidCard$ Card.Self | Activator$ You | Type$ Spell | Cost$ Waterbend<X> | EffectZone$ All | Description$ As an additional cost to cast this spell, waterbend {X}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)
A:SP$ ChangeZone | ValidTgts$ Creature.YouCtrl | Announce$ X | TargetMin$ X | TargetMax$ X | Origin$ Battlefield | Destination$ Exile | TgtPrompt$ Select target creature you control | SubAbility$ DelTrig | RememberChanged$ True | SpellDescription$ Exile X target creatures you control. Return those cards to the battlefield under their owners control at the beginning of the next end step.
SVar:DelTrig:DB$ DelayedTrigger | Mode$ Phase | Phase$ End of Turn | Execute$ TrigReturn | RememberObjects$ RememberedLKI | TriggerDescription$ Return exiled permanent to the battlefield. | SubAbility$ DBCleanup
SVar:TrigReturn:DB$ ChangeZone | Origin$ Exile | Destination$ Battlefield | Defined$ DelayTriggerRememberedLKI
SVar:DBCleanup:DB$ Cleanup | ClearRemembered$ True
SVar:X:Count$xPaid
Oracle:As an additional cost to cast this spell, waterbend {X}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)\nExile X target creatures you control. Return those cards to the battlefield under their owners control at the beginning of the next end step.

View File

@@ -0,0 +1,7 @@
Name:Waterbending Lesson
ManaCost:3 U
Types:Sorcery Lesson
A:SP$ Draw | NumCards$ 3 | SubAbility$ DBDiscard | SpellDescription$ Draw three cards. Then discard a card unless you waterbend {2}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)
SVar:DBDiscard:DB$ Discard | Defined$ You | NumCards$ 1 | Mode$ TgtChoose | UnlessCost$ Waterbend<2> | UnlessPayer$ You
DeckHas:Ability$Discard
Oracle:Draw three cards. Then discard a card unless you waterbend {2}. (While paying a waterbend cost, you can tap your artifacts and creatures to help. Each one pays for {1}.)

View File

@@ -27,18 +27,33 @@ public final class InputSelectCardsForConvokeOrImprovise extends InputSelectMany
private final ManaCostBeingPaid remainingCost;
private final Player player;
private final CardCollectionView availableCards;
private final boolean improvise;
private final boolean artifacts;
private final boolean creatures;
private final Integer maxSelectable;
private final String cardType;
private final String description;
public InputSelectCardsForConvokeOrImprovise(final PlayerControllerHuman controller, final Player p, final ManaCost cost, final CardCollectionView untapped, boolean impr, final SpellAbility sa) {
public InputSelectCardsForConvokeOrImprovise(final PlayerControllerHuman controller, final Player p, final SpellAbility sa, final ManaCost cost, final CardCollectionView untapped, boolean artifacts, boolean creatures, Integer maxReduction) {
super(controller, 0, Math.min(cost.getCMC(), untapped.size()), sa);
remainingCost = new ManaCostBeingPaid(cost);
player = p;
availableCards = untapped;
improvise = impr;
cardType = impr ? "artifact" : "creature";
description = impr ? "Improvise" : "Convoke";
this.artifacts = artifacts;
this.creatures = creatures;
this.maxSelectable = maxReduction;
if (artifacts && creatures) {
cardType = "artifact or creature";
description = "Waterbend";
} else if (!artifacts && !creatures) {
throw new IllegalArgumentException("At least one of artifacts or creatures must be true");
} else if (creatures) {
cardType = "creature";
description = "Convoke";
} else {
cardType = "artifact";
description = "Improvise";
}
}
@Override
@@ -49,6 +64,10 @@ public final class InputSelectCardsForConvokeOrImprovise extends InputSelectMany
sb.append(sa.getStackDescription()).append("\n");
}
sb.append(TextUtil.concatNoSpace("Choose ", cardType, " to tap for ", description, ".\nRemaining mana cost is ", remainingCost.toString()));
if (maxSelectable != null) {
sb.append(". You may select up to ").append(chosenCards.size() - maxSelectable).append(" more ").append(cardType).append("(s).");
}
return sb.toString();
}
@@ -66,10 +85,17 @@ public final class InputSelectCardsForConvokeOrImprovise extends InputSelectMany
onSelectStateChanged(card, false);
}
else {
if (maxSelectable != null && chosenCards.size() >= maxSelectable) {
// Should show a different message if there's a max selectable
return false;
}
byte chosenColor;
if (improvise) {
if (artifacts) {
// Waterbend/Improvise can be paid with colorless mana from artifacts
chosenColor = ManaCostShard.COLORLESS.getColorMask();
} else {
// Convoke can pay color or generic mana cost from creatures
ColorSet colors = card.getColor();
if (colors.isMulticolor()) {
//if card is multicolor, strip out any colors which can't be paid towards remaining cost
@@ -107,10 +133,6 @@ public final class InputSelectCardsForConvokeOrImprovise extends InputSelectMany
return null;
}
@Override
protected void onPlayerSelected(final Player player, final ITriggerEvent triggerEvent) {
}
public Map<Card, ManaCostShard> getConvokeMap() {
if (hasCancelled()) {
return Maps.newHashMap();

View File

@@ -560,9 +560,7 @@ public class HumanPlay {
}
CardCollection cardsToDelve = new CardCollection();
if (!effect) {
CostAdjustment.adjust(toPay, ability, cardsToDelve, false);
}
CostAdjustment.adjust(toPay, ability, cardsToDelve, false, effect);
Card offering = null;
Card emerge = null;

View File

@@ -2332,9 +2332,9 @@ public class PlayerControllerHuman extends PlayerController implements IGameCont
@Override
public Map<Card, ManaCostShard> chooseCardsForConvokeOrImprovise(final SpellAbility sa, final ManaCost manaCost,
final CardCollectionView untappedCards, boolean improvise) {
final CardCollectionView untappedCards, boolean artifacts, boolean creatures, Integer maxReduction) {
final InputSelectCardsForConvokeOrImprovise inp = new InputSelectCardsForConvokeOrImprovise(this, player,
manaCost, untappedCards, improvise, sa);
sa, manaCost, untappedCards, artifacts, creatures, maxReduction);
inp.showAndWait();
return inp.getConvokeMap();
}