Skip to content

Commit 07e1f54

Browse files
liamiak1claude
andcommitted
Let the AI cast Toxic Deluge and Flowstone Slide
Both carried AI:RemoveDeck:All because the AI could not cast them at all. Their X is announced as a cost and nothing ever chose it: PumpAll has an AI class, so the generic X handling in AiController is skipped, and Toxic Deluge's X lives in a PayLife<X> cost that path would not have seen anyway. With X unset both pump amounts read 0, so the sweep evaluated as -0/-0 and was always declined. Toxic Deluge was also missing IsCurse$ True, which meant PumpAllAi never reached its sweeper branch and instead asked whether to pump the AI's own creatures. PumpAllAi now picks X for a -X/-X sweep when the ability announces its own X, scoring candidates by the creatures each value destroys. Candidates are the toughnesses actually on the battlefield rather than every point up to the maximum, so the AI does not walk a 40 point life total one step at a time. Whether it survives the sweep is left to predictNextCombatsRemainingLife, which already models the blocks it will make, now told which attackers the sweep removes. Whether it wins outright is left to the attack planner, which already knows about fog, evasion and the rest - the two commits before this one gave both what they needed. Life spent is priced against how much of the total is going. A +X/-X hands survivors power until end of turn, so it counts against whoever still has a combat coming: on an opponent's turn theirs can swing back, on the AI's own turn theirs has expired before they untap. A mass pump lands on every opponent at once, so the evaluation weighs all of them instead of just the strongest. Two player games are unaffected. Triggers and sub-abilities are deliberately excluded, since X was already paid when the spell was cast - The Meathook Massacre would otherwise be refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ded7495 commit 07e1f54

3 files changed

Lines changed: 225 additions & 8 deletions

File tree

forge-ai/src/main/java/forge/ai/ability/PumpAllAi.java

Lines changed: 224 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package forge.ai.ability;
22

3+
import com.google.common.collect.Iterables;
4+
35
import forge.ai.*;
46
import forge.game.Game;
57
import forge.game.GameObject;
@@ -9,6 +11,7 @@
911
import forge.game.card.CardLists;
1012
import forge.game.combat.Combat;
1113
import forge.game.cost.Cost;
14+
import forge.game.cost.CostPayLife;
1215
import forge.game.phase.PhaseHandler;
1316
import forge.game.phase.PhaseType;
1417
import forge.game.player.Player;
@@ -17,10 +20,21 @@
1720

1821
import java.util.ArrayList;
1922
import java.util.Arrays;
23+
import java.util.HashMap;
2024
import java.util.List;
25+
import java.util.Map;
26+
import java.util.SortedSet;
27+
import java.util.TreeSet;
2128

2229
public class PumpAllAi extends PumpAiBase {
2330

31+
// What the AI charges itself for spending life, relative to how much of its total is going.
32+
// Spending half of a healthy life total costs about as much as losing one creature.
33+
private static final int LIFE_VALUE = 400;
34+
35+
// How many values of X we are willing to simulate a combat for before settling.
36+
private static final int SURVIVAL_CHECKS = 3;
37+
2438
/* (non-Javadoc)
2539
* @see forge.card.abilityfactory.SpellAiLogic#canPlayAI(forge.game.player.Player, java.util.Map, forge.card.spellability.SpellAbility)
2640
*/
@@ -62,16 +76,33 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa
6276
}
6377
}
6478

79+
final String valid = sa.getParamOrDefault("ValidCards", "");
80+
81+
CardCollection comp = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), valid, source.getController(), source, sa);
82+
// A mass pump lands on every opponent's board at once, so weigh all of them. In a two
83+
// player game this is the same list getStrongestOpponent() was giving.
84+
CardCollection human = CardLists.getValidCards(ai.getOpponents().getCardsIn(ZoneType.Battlefield), valid, source.getController(), source, sa);
85+
86+
// Nothing else announces X for a -X/-X sweep: PumpAll has an AI class, so the generic X
87+
// handling in AiController is skipped, and on cards like Toxic Deluge the X lives in a
88+
// non-mana cost (PayLife<X>) that path would not see either. Without this the amounts
89+
// below both read 0 and the sweep evaluates as -0/-0.
90+
if (sa.isCurse() && usesPaidX(sa)) {
91+
final SweepChoice sweep = chooseXForSweep(ai, sa, comp, human);
92+
if (sweep.x() <= 0) {
93+
return new AiAbilityDecision(0, AiPlayDecision.CantPlayAi);
94+
}
95+
// Clearing the way for a lethal swing is worth it whatever the card values below say.
96+
if (sweep.lethal()) {
97+
return new AiAbilityDecision(100, AiPlayDecision.WillPlay);
98+
}
99+
}
100+
65101
final int power = AbilityUtils.calculateAmount(source, sa.getParam("NumAtt"), sa);
66102
final int defense = AbilityUtils.calculateAmount(source, sa.getParam("NumDef"), sa);
67103
final List<String> keywords = sa.hasParam("KW") ? Arrays.asList(sa.getParam("KW").split(" & ")) : new ArrayList<>();
68104
final PhaseType phase = game.getPhaseHandler().getPhase();
69105

70-
final String valid = sa.getParamOrDefault("ValidCards", "");
71-
72-
CardCollection comp = CardLists.getValidCards(ai.getCardsIn(ZoneType.Battlefield), valid, source.getController(), source, sa);
73-
CardCollection human = CardLists.getValidCards(opp.getCardsIn(ZoneType.Battlefield), valid, source.getController(), source, sa);
74-
75106
if (sa.isCurse()) {
76107
if (defense < 0) { // try to destroy creatures
77108
// leaves all creatures that will be destroyed
@@ -143,6 +174,194 @@ protected AiAbilityDecision doTriggerNoCost(Player ai, SpellAbility sa, boolean
143174
return decision;
144175
}
145176

177+
/** The X the AI settled on for a sweep, and whether taking it simply wins this turn. */
178+
private record SweepChoice(int x, boolean lethal) {}
179+
180+
private static boolean usesPaidX(final SpellAbility sa) {
181+
if (!sa.getParamOrDefault("NumDef", "").endsWith("X") || !"Count$xPaid".equals(sa.getSVar("X"))) {
182+
return false;
183+
}
184+
// Only choose X where this ability is the thing announcing it. On triggers and sub-abilities
185+
// (The Meathook Massacre, Orcus) X was already paid when the spell was cast, so there is no
186+
// X left to find here and the AI would refuse an otherwise fine sweep.
187+
final Cost cost = sa.getPayCosts();
188+
return cost != null && cost.hasXInAnyCostPart();
189+
}
190+
191+
private static boolean pumpsPower(final SpellAbility sa) {
192+
return sa.getParamOrDefault("NumAtt", "").startsWith("+");
193+
}
194+
195+
private static boolean paysLifeForX(final SpellAbility sa) {
196+
final Cost cost = sa.getPayCosts();
197+
final CostPayLife part = cost == null ? null : cost.getCostPartByType(CostPayLife.class);
198+
return part != null && "X".equals(part.getAmount());
199+
}
200+
201+
/**
202+
* Picks how big a -X/-X sweep to pay for and records it on the ability, returning the chosen X.
203+
* Returns 0 when no value of X is worth casting at, so the caller can bail out.
204+
*/
205+
private SweepChoice chooseXForSweep(final Player ai, final SpellAbility sa, final CardCollection own, final CardCollection foes) {
206+
// Also sets X to its maximum, which is what the amounts would otherwise be read at.
207+
final int maxX = ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger());
208+
if (maxX <= 0) {
209+
return new SweepChoice(0, false);
210+
}
211+
212+
// Which creatures die only changes at the toughnesses actually on the battlefield, so try
213+
// those values instead of every point up to maxX - otherwise the AI walks its whole life
214+
// total one point at a time to reach the same answer. A superset is fine; diesToCurse stays
215+
// the single authority on what any given X actually kills.
216+
final SortedSet<Integer> candidates = new TreeSet<>();
217+
for (Card c : Iterables.concat(own, foes)) {
218+
addCandidate(candidates, c.getNetToughness(), maxX);
219+
addCandidate(candidates, ComputerUtilCombat.getDamageToKill(c, false), maxX);
220+
}
221+
222+
final boolean paysLife = paysLifeForX(sa);
223+
// Power the pump hands out lasts only until end of turn, so it matters to whoever still has
224+
// a combat coming: on an opponent's turn their survivors swing at us with it, on our own
225+
// turn ours use it and theirs has expired long before they untap.
226+
final boolean pumps = pumpsPower(sa);
227+
final boolean ourTurn = ai.getGame().getPhaseHandler().isPlayerTurn(ai);
228+
229+
// Rank the candidates on card value alone. Both checks below simulate a combat, so they
230+
// stay out of this loop and are asked only about the few values that could be chosen.
231+
final Map<Integer, Integer> scores = new HashMap<>();
232+
int cheapestLethal = 0;
233+
for (final int x : candidates) {
234+
int score = ComputerUtilCard.evaluateCreatureList(CardLists.filter(foes, c -> diesToCurse(c, -x)))
235+
- ComputerUtilCard.evaluateCreatureList(CardLists.filter(own, c -> diesToCurse(c, -x)));
236+
if (paysLife) {
237+
score -= LIFE_VALUE * x / Math.max(1, ai.getLife());
238+
}
239+
if (score > 0) {
240+
scores.put(x, score);
241+
}
242+
// Candidates ascend, so this keeps the cheapest X that could reach.
243+
if (cheapestLethal == 0 && couldReachLethal(ai, own, x, pumps && ourTurn ? x : 0)) {
244+
cheapestLethal = x;
245+
}
246+
}
247+
248+
// Mirrors DamageAllAi: a sweep that just wins beats any card-value comparison. Only the
249+
// cost has to be affordable here - if the swing is lethal there is no next combat to live
250+
// through, so what the survivors could have hit back with does not matter.
251+
if (cheapestLethal > 0 && canAfford(ai, sa, cheapestLethal, paysLife)
252+
&& winsThisTurn(ai, own, foes, cheapestLethal, pumps && ourTurn ? cheapestLethal : 0)) {
253+
sa.setXManaCostPaid(cheapestLethal);
254+
return new SweepChoice(cheapestLethal, true);
255+
}
256+
257+
// Otherwise take the most valuable sweep we can actually live through. Sorted by value,
258+
// then by size, so the cheapest X wins when a larger one kills nothing extra.
259+
final List<Integer> ranked = new ArrayList<>(scores.keySet());
260+
ranked.sort((a, b) -> {
261+
final int byValue = Integer.compare(scores.get(b), scores.get(a));
262+
return byValue != 0 ? byValue : Integer.compare(a, b);
263+
});
264+
int checked = 0;
265+
for (final int x : ranked) {
266+
if (checked++ >= SURVIVAL_CHECKS) {
267+
break;
268+
}
269+
if (survivesSweep(ai, sa, own, foes, x, paysLife, pumps && !ourTurn ? x : 0)) {
270+
sa.setXManaCostPaid(x);
271+
return new SweepChoice(x, false);
272+
}
273+
}
274+
return new SweepChoice(0, false);
275+
}
276+
277+
/**
278+
* Cheap necessary condition for winsThisTurn: no attack can deal more than every creature we
279+
* keep connecting unblocked, so anything short of that is not worth planning an attack for.
280+
*/
281+
private static boolean couldReachLethal(final Player ai, final CardCollection own, final int x, final int boost) {
282+
final CardCollection survivors = CardLists.filter(own, c -> !diesToCurse(c, -x));
283+
for (final Player opp : ai.getOpponents()) {
284+
if (ComputerUtilCombat.sumDamageIfUnblocked(survivors, opp) + boost * survivors.size() >= opp.getLife()) {
285+
return true;
286+
}
287+
}
288+
return false;
289+
}
290+
291+
private static void addCandidate(final SortedSet<Integer> candidates, final int x, final int maxX) {
292+
if (x > 0 && x <= maxX) {
293+
candidates.add(x);
294+
}
295+
}
296+
297+
/**
298+
* Whether the AI still stands up to the coming combats once it has paid for this sweep and both
299+
* boards have lost whatever it kills.
300+
*/
301+
/** Whether the cost can be paid at all, and paying it does not kill the AI outright. */
302+
private static boolean canAfford(final Player ai, final SpellAbility sa, final int x, final boolean paysLife) {
303+
if (!paysLife) {
304+
return true;
305+
}
306+
return ai.canPayLife(x, false, sa) && (ai.getLife() > x || ai.cantLoseForZeroOrLessLife());
307+
}
308+
309+
private static boolean survivesSweep(final Player ai, final SpellAbility sa, final CardCollection own,
310+
final CardCollection foes, final int x, final boolean paysLife, final int boost) {
311+
if (!canAfford(ai, sa, x, paysLife)) {
312+
return false;
313+
}
314+
final CardCollection ourDead = CardLists.filter(own, c -> diesToCurse(c, -x));
315+
final CardCollection theirDead = CardLists.filter(foes, c -> diesToCurse(c, -x));
316+
317+
// Cheap and pessimistic first: assume nothing gets blocked. Real blocks only ever reduce
318+
// that, so if we live through the worst case there is nothing to simulate.
319+
final CardCollection survivors = CardLists.filter(foes, c -> !diesToCurse(c, -x));
320+
final int worstCase = ComputerUtilCombat.sumDamageIfUnblocked(survivors, ai) + boost * survivors.size();
321+
if (ai.getLife() - (paysLife ? x : 0) > worstCase) {
322+
return true;
323+
}
324+
// The life goes before that combat, and a survivor's boost is charged per head, which the
325+
// simulation below has no way to model.
326+
final int lifeCost = (paysLife ? x : 0) + boost * survivors.size();
327+
return ComputerUtil.predictNextCombatsRemainingLife(ai, true, false, lifeCost, ourDead,
328+
ai.getOpponents(), theirDead) != Integer.MIN_VALUE;
329+
}
330+
331+
/**
332+
* Whether clearing the board at this X leaves the AI able to finish an opponent off this turn.
333+
* Asks the attack planner rather than guessing, so fog, evasion and the rest are accounted for.
334+
*/
335+
private static boolean winsThisTurn(final Player ai, final CardCollection own, final CardCollection foes,
336+
final int x, final int boost) {
337+
final PhaseHandler ph = ai.getGame().getPhaseHandler();
338+
if (!ph.isPlayerTurn(ai) || !ph.getPhase().isBefore(PhaseType.COMBAT_DECLARE_ATTACKERS)) {
339+
return false; // no attack step left to spend it on
340+
}
341+
342+
final AiAttackController atk = new AiAttackController(ai);
343+
for (final Card c : foes) {
344+
if (diesToCurse(c, -x)) {
345+
atk.removeBlocker(c);
346+
}
347+
}
348+
for (final Card c : own) {
349+
if (diesToCurse(c, -x)) {
350+
atk.removeAttacker(c);
351+
}
352+
}
353+
354+
final Combat planned = new Combat(ai);
355+
atk.declareAttackers(planned);
356+
for (final Player opp : ai.getOpponents()) {
357+
final int attacking = planned.getAttackersOf(opp).size();
358+
if (attacking > 0 && ComputerUtilCombat.lifeThatWouldRemain(opp, planned) - boost * attacking <= 0) {
359+
return true;
360+
}
361+
}
362+
return false;
363+
}
364+
146365
boolean pumpAgainstRemoval(Player ai, SpellAbility sa, List<Card> comp) {
147366
final List<GameObject> objects = ComputerUtil.predictThreatenedObjects(sa.getActivatingPlayer(), sa, true);
148367
for (final Card c : comp) {

forge-gui/res/cardsfolder/f/flowstone_slide.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@ ManaCost:X 2 R R
33
Types:Sorcery
44
A:SP$ PumpAll | ValidCards$ Creature | IsCurse$ True | NumAtt$ +X | NumDef$ -X | SpellDescription$ All creatures get +X/-X until end of turn.
55
SVar:X:Count$xPaid
6-
AI:RemoveDeck:All
76
Oracle:All creatures get +X/-X until end of turn.
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
Name:Toxic Deluge
22
ManaCost:2 B
33
Types:Sorcery
4-
A:SP$ PumpAll | Cost$ 2 B PayLife<X> | ValidCards$ Creature | NumAtt$ -X | NumDef$ -X | SpellDescription$ All creatures get -X/-X until end of turn.
4+
A:SP$ PumpAll | Cost$ 2 B PayLife<X> | ValidCards$ Creature | NumAtt$ -X | NumDef$ -X | IsCurse$ True | SpellDescription$ All creatures get -X/-X until end of turn.
55
SVar:X:Count$xPaid
6-
AI:RemoveDeck:All
76
Oracle:As an additional cost to cast this spell, pay X life.\nAll creatures get -X/-X until end of turn.

0 commit comments

Comments
 (0)