|
1 | 1 | package forge.ai.ability; |
2 | 2 |
|
| 3 | +import com.google.common.collect.Iterables; |
| 4 | + |
3 | 5 | import forge.ai.*; |
4 | 6 | import forge.game.Game; |
5 | 7 | import forge.game.GameObject; |
|
9 | 11 | import forge.game.card.CardLists; |
10 | 12 | import forge.game.combat.Combat; |
11 | 13 | import forge.game.cost.Cost; |
| 14 | +import forge.game.cost.CostPayLife; |
12 | 15 | import forge.game.phase.PhaseHandler; |
13 | 16 | import forge.game.phase.PhaseType; |
14 | 17 | import forge.game.player.Player; |
|
17 | 20 |
|
18 | 21 | import java.util.ArrayList; |
19 | 22 | import java.util.Arrays; |
| 23 | +import java.util.HashMap; |
20 | 24 | import java.util.List; |
| 25 | +import java.util.Map; |
| 26 | +import java.util.SortedSet; |
| 27 | +import java.util.TreeSet; |
21 | 28 |
|
22 | 29 | public class PumpAllAi extends PumpAiBase { |
23 | 30 |
|
| 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 | + |
24 | 38 | /* (non-Javadoc) |
25 | 39 | * @see forge.card.abilityfactory.SpellAiLogic#canPlayAI(forge.game.player.Player, java.util.Map, forge.card.spellability.SpellAbility) |
26 | 40 | */ |
@@ -62,16 +76,33 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa |
62 | 76 | } |
63 | 77 | } |
64 | 78 |
|
| 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 | + |
65 | 101 | final int power = AbilityUtils.calculateAmount(source, sa.getParam("NumAtt"), sa); |
66 | 102 | final int defense = AbilityUtils.calculateAmount(source, sa.getParam("NumDef"), sa); |
67 | 103 | final List<String> keywords = sa.hasParam("KW") ? Arrays.asList(sa.getParam("KW").split(" & ")) : new ArrayList<>(); |
68 | 104 | final PhaseType phase = game.getPhaseHandler().getPhase(); |
69 | 105 |
|
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 | | - |
75 | 106 | if (sa.isCurse()) { |
76 | 107 | if (defense < 0) { // try to destroy creatures |
77 | 108 | // leaves all creatures that will be destroyed |
@@ -143,6 +174,194 @@ protected AiAbilityDecision doTriggerNoCost(Player ai, SpellAbility sa, boolean |
143 | 174 | return decision; |
144 | 175 | } |
145 | 176 |
|
| 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 | + |
146 | 365 | boolean pumpAgainstRemoval(Player ai, SpellAbility sa, List<Card> comp) { |
147 | 366 | final List<GameObject> objects = ComputerUtil.predictThreatenedObjects(sa.getActivatingPlayer(), sa, true); |
148 | 367 | for (final Card c : comp) { |
|
0 commit comments