diff --git a/forge-ai/src/main/java/forge/ai/AiController.java b/forge-ai/src/main/java/forge/ai/AiController.java index 7616d11c3b0c..8a09af2549c2 100644 --- a/forge-ai/src/main/java/forge/ai/AiController.java +++ b/forge-ai/src/main/java/forge/ai/AiController.java @@ -1346,6 +1346,7 @@ private List singleSpellAbilityList(SpellAbility sa) { public List chooseSpellAbilityToPlay() { AiCache.clear(); + ComputerUtilMana.beginAffordabilityCachePass(); // Reset cached predicted combat, as it may be stale. It will be // re-created if needed and used for any AI logic that needs it. predictedCombat = null; diff --git a/forge-ai/src/main/java/forge/ai/CastabilityProbe.java b/forge-ai/src/main/java/forge/ai/CastabilityProbe.java new file mode 100644 index 000000000000..8b311b2c2dc4 --- /dev/null +++ b/forge-ai/src/main/java/forge/ai/CastabilityProbe.java @@ -0,0 +1,404 @@ +package forge.ai; + +import com.google.common.collect.ListMultimap; +import forge.ai.AiCardMemory.MemorySet; +import forge.card.mana.ManaAtom; +import forge.card.mana.ManaCost; +import forge.card.mana.ManaCostShard; +import forge.game.card.Card; +import forge.game.cost.CostPartMana; +import forge.game.mana.ManaCostBeingPaid; +import forge.game.player.Player; +import forge.game.spellability.SpellAbility; +import forge.game.zone.ZoneType; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Castability-aware mana source selection: counts hand/command spells still castable after each + * candidate source is reserved. Can be disabled for performance on low-end devices. + */ +public final class CastabilityProbe { + /** Max candidates evaluated per castability probe on large boards. */ + static final int CANDIDATE_CAP = 5; + /** Soft CMC cap: skip castability dry-runs above this CMC when total mana budget is insufficient. */ + private static final int SOFT_CMC_CAP = 3; + + /** System property {@code forge.manaPayment.castabilityProbe} overrides preference when set. */ + private static final String SYS_PROP = "forge.manaPayment.castabilityProbe"; + + private static boolean defaultEnabled = true; + private static int dryRunCountForTests; + + private CastabilityProbe() { + } + + /** Whether castability probing is active (preference / system property). */ + public static boolean isEnabled() { + final String prop = System.getProperty(SYS_PROP); + if (prop != null) { + return Boolean.parseBoolean(prop); + } + return defaultEnabled; + } + + /** Set default from Forge preferences at startup (see {@link forge.model.FModel}). */ + public static void setDefaultEnabled(final boolean enabled) { + defaultEnabled = enabled; + } + + /** Test hook: enable probe and clear JVM override (see {@link forge.ai.controller.AutoPaymentTest}). */ + public static void enableForTests() { + System.clearProperty(SYS_PROP); + defaultEnabled = true; + } + + /** + * Castability-aware source comparison during payment. Enabled for human payment-prompt Auto preview + * and for AI production payment of hand/command spells only. + */ + static boolean shouldUse(final SpellAbility sa, final boolean test, + final ComputerUtilMana.ManaPaymentContext ctx) { + if (!isEnabled()) { + return false; + } + final Card host = sa.getHostCard(); + if (host == null) { + return false; + } + if (host.isInZone(ZoneType.Hand) || host.isInZone(ZoneType.Command)) { + return test ? ctx != null && ctx.paymentPromptPreview : true; + } + return false; + } + + /** Nested filter activations use castability only when the spell being paid is in hand/command. */ + static boolean shouldUseForNestedActivation(final SpellAbility sa, final boolean test, + final ComputerUtilMana.ManaPaymentContext ctx) { + final Card host = sa.getHostCard(); + if (host == null || (!host.isInZone(ZoneType.Hand) && !host.isInZone(ZoneType.Command))) { + return false; + } + return shouldUse(sa, test, ctx); + } + + @FunctionalInterface + interface ConsumedBuilder { + Set build(SpellAbility cand, SpellAbility sa, Player ai, ComputerUtilMana.ManaPaymentContext ctx); + } + + /** + * Among candidates, pick the source that leaves the most hand/command spells castable afterwards. + */ + static SpellAbility pickBest(final ManaCostBeingPaid cost, final List candidates, + final SpellAbility sa, final Player ai, final ManaCostShard toPay, final ConsumedBuilder consumedBuilder, + final boolean preferMultiForGeneric, final boolean test, final ComputerUtilMana.ManaPaymentContext ctx) { + SpellAbility best = null; + int bestCastable = -1; + int bestEfficiency = Integer.MAX_VALUE; + final boolean multicolorHand = ComputerUtilMana.handHasMulticolorManaSpells(ai, sa, ctx); + final ComputerUtilMana.ManaPaymentContext probeCtx = ctx.withFilterProbe(); + for (final SpellAbility cand : capCandidates(candidates)) { + final Set sacSnapshot = ComputerUtilMana.snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = ComputerUtilMana.snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + final Set consumed = consumedBuilder.build(cand, sa, ai, probeCtx); + if (consumed == null) { + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + continue; + } + final int castable = countCastableSpellsAfterPayment(ai, sa, consumed, probeCtx); + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + ComputerUtilMana.debugLog(test, " castability " + cand.getHostCard() + " -> " + castable + + " hand/command spells remain"); + final boolean preferCand = multicolorHand && ComputerUtilMana.isAnyMultiManaProducer(cand) + && preferMultiForGeneric; + final boolean preferBest = multicolorHand && best != null && ComputerUtilMana.isAnyMultiManaProducer(best) + && preferMultiForGeneric; + boolean takeCand = false; + if (castable > bestCastable) { + takeCand = true; + bestEfficiency = Integer.MAX_VALUE; + } else if (castable == bestCastable) { + if (preferCand && !preferBest) { + takeCand = true; + bestEfficiency = Integer.MAX_VALUE; + } else if (!preferCand && !preferBest) { + final ComputerUtilMana.PaymentImpact impact = ComputerUtilMana.evaluatePaymentImpact(cost, sa, ai, + toPay, cand, candidates, test, probeCtx); + final int candEfficiency = impact.efficiencyScore; + if (best == null) { + takeCand = true; + bestEfficiency = candEfficiency; + } else { + if (bestEfficiency == Integer.MAX_VALUE) { + bestEfficiency = ComputerUtilMana.evaluatePaymentImpact(cost, sa, ai, toPay, best, + candidates, test, probeCtx).efficiencyScore; + } + if (tieBreakPrefers(cand, best, candEfficiency, bestEfficiency, toPay, cost, ai, sa)) { + takeCand = true; + bestEfficiency = candEfficiency; + } + } + } + } + if (takeCand) { + bestCastable = castable; + best = cand; + } + } + return best; + } + + /** Record a colored-shard failure with zero candidates during a castability nested dry-run. */ + static void recordNoSourceColoredShardFailure(final ComputerUtilMana.ManaPaymentContext ctx, + final ManaCostShard toPay, final Collection saList) { + if (ctx == null || ctx.caches.castabilityProbe.availableManaAfterReservation < 0 + || toPay == null || toPay.isGeneric() || toPay.isPhyrexian() + || toPay == ManaCostShard.COLORLESS || toPay == ManaCostShard.X + || toPay == ManaCostShard.COLORED_X || saList == null || !saList.isEmpty()) { + return; + } + ctx.caches.castabilityProbe.lastFailedColoredShard = toPay; + ctx.caches.castabilityProbe.lastFailureWasNoSources = true; + } + + /** Test hook: reset nested castability dry-run counter. */ + public static void resetDryRunCountForTests() { + dryRunCountForTests = 0; + } + + /** Test hook: nested castability dry-runs since last reset. */ + public static int getDryRunCountForTests() { + return dryRunCountForTests; + } + + static int countCastableSpellsAfterPayment(final Player ai, final SpellAbility spellBeingPaid, + final Set consumed, final ComputerUtilMana.ManaPaymentContext ctx) { + final Set reserved = new HashSet<>(consumed); + final Set tapCost = AiCardMemory.getMemorySet(ai, MemorySet.PAYS_TAP_COST); + if (tapCost != null) { + reserved.addAll(tapCost); + } + final Set sacCost = AiCardMemory.getMemorySet(ai, MemorySet.PAYS_SAC_COST); + if (sacCost != null) { + reserved.addAll(sacCost); + } + final ComputerUtilMana.CastabilityProbeScratch probe = ctx.caches.castabilityProbe; + probe.resetForProbe(); + probe.unavailableColoredShards.addAll(computeUnavailableColoredShards(ai, reserved, ctx)); + probe.availableManaAfterReservation = computeAvailableManaAfterReservation(ai, reserved, ctx); + int count = countCastableSpellsInZone(ai, spellBeingPaid, reserved, ZoneType.Hand, ctx); + count += countCastableSpellsInZone(ai, spellBeingPaid, reserved, ZoneType.Command, ctx); + probe.resetForProbe(); + return count; + } + + private static List capCandidates(final List candidates) { + if (candidates.size() <= CANDIDATE_CAP) { + return candidates; + } + return candidates.subList(0, CANDIDATE_CAP); + } + + private static int countCastableSpellsInZone(final Player ai, final SpellAbility spellBeingPaid, + final Set consumed, final ZoneType zone, final ComputerUtilMana.ManaPaymentContext ctx) { + int count = 0; + final Card being = spellBeingPaid.getHostCard(); + final ComputerUtilMana.CastabilityProbeScratch probe = ctx.caches.castabilityProbe; + for (Card c : ai.getCardsIn(zone)) { + if (c == being) { + continue; + } + for (SpellAbility candSa : c.getSpellAbilities()) { + if (!candSa.isSpell() || candSa.getPayCosts() == null || !candSa.getPayCosts().hasManaCost()) { + continue; + } + candSa.setActivatingPlayer(ai); + if (isUncastableByTotalManaBudget(candSa, probe.availableManaAfterReservation)) { + continue; + } + final CostPartMana costMana = candSa.getPayCosts().getCostMana(); + if (costMana == null) { + continue; + } + final ManaCost mc = costMana.getMana(); + if (spellRequiresUnavailableColoredShard(mc, probe.unavailableColoredShards)) { + continue; + } + if (canPayManaCostExcluding(candSa, ai, consumed, ctx)) { + count++; + break; + } + } + } + return count; + } + + private static boolean spellRequiresUnavailableColoredShard(final ManaCost mc, + final Set unavailableColoredShards) { + if (mc == null || unavailableColoredShards == null || unavailableColoredShards.isEmpty()) { + return false; + } + final ManaCostBeingPaid probe = new ManaCostBeingPaid(mc); + for (final ManaCostShard shard : probe.getDistinctShards()) { + if (shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian() + || shard == ManaCostShard.X || shard == ManaCostShard.COLORED_X) { + continue; + } + if (unavailableColoredShards.contains(shard)) { + return true; + } + } + return false; + } + + private static boolean isUncastableByTotalManaBudget(final SpellAbility candSa, final int availableMana) { + if (availableMana < 0 || candSa == null || candSa.getPayCosts() == null + || !candSa.getPayCosts().hasManaCost()) { + return false; + } + final int cmc = candSa.getPayCosts().getCostMana().getMana().getCMC(); + return cmc > SOFT_CMC_CAP && cmc > availableMana; + } + + private static Set computeUnavailableColoredShards(final Player ai, final Set reserved, + final ComputerUtilMana.ManaPaymentContext ctx) { + final Set unavailable = new HashSet<>(); + final ListMultimap map = ComputerUtilMana.getOrBuildManaAbilityMap(ai, true, ctx); + for (final ManaCostShard shard : ManaCostShard.values()) { + if (shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian() + || shard == ManaCostShard.X || shard == ManaCostShard.COLORED_X) { + continue; + } + if (!hasAvailableProducerForShard(ai, map, shard, reserved)) { + unavailable.add(shard); + } + } + return unavailable; + } + + private static boolean hasAvailableProducerForShard(final Player ai, + final ListMultimap manaAbilityMap, final ManaCostShard shard, + final Set reserved) { + for (final byte color : ManaAtom.MANATYPES) { + if (!shard.canBePaidWithManaOfColor(color)) { + continue; + } + for (final SpellAbility candidate : manaAbilityMap.get((int) color)) { + if (isManaSourceAvailableAfterReservation(ai, candidate, reserved)) { + return true; + } + } + } + return false; + } + + private static boolean isManaSourceAvailableAfterReservation(final Player ai, final SpellAbility ma, + final Set reserved) { + if (ma == null || ai == null) { + return false; + } + final Card host = ma.getHostCard(); + if (host == null || reserved.contains(host)) { + return false; + } + if (AiCardMemory.isRememberedCard(ai, host, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL)) { + return false; + } + if (AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_SAC_COST)) { + return false; + } + if (ma.getPayCosts() != null && ma.getPayCosts().hasTapCost()) { + if (AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_TAP_COST) || host.isTapped()) { + return false; + } + } + ma.setActivatingPlayer(ai); + return ma.canPlay(); + } + + private static int computeAvailableManaAfterReservation(final Player ai, final Set reserved, + final ComputerUtilMana.ManaPaymentContext ctx) { + int available = ai.getManaPool().totalMana(); + final ListMultimap map = ComputerUtilMana.getOrBuildManaAbilityMap(ai, true, ctx); + final Set seenHosts = new HashSet<>(); + for (final SpellAbility ma : map.values()) { + final Card host = ma.getHostCard(); + if (host == null || seenHosts.contains(host)) { + continue; + } + if (!isManaSourceAvailableAfterReservation(ai, ma, reserved)) { + continue; + } + int maxForHost = 0; + for (final SpellAbility ma2 : host.getManaAbilities()) { + if (!isManaSourceAvailableAfterReservation(ai, ma2, reserved)) { + continue; + } + ma2.setActivatingPlayer(ai); + if (!ma2.canPlay()) { + continue; + } + maxForHost = Math.max(maxForHost, ma2.amountOfManaGenerated(true)); + } + seenHosts.add(host); + available += maxForHost; + } + return available; + } + + private static boolean canPayManaCostExcluding(final SpellAbility candSa, final Player ai, final Set consumed, + final ComputerUtilMana.ManaPaymentContext ctx) { + final ComputerUtilMana.CastabilityProbeScratch probe = ctx.caches.castabilityProbe; + probe.clearLastFailure(); + final List reserved = new ArrayList<>(); + for (Card c : consumed) { + if (!AiCardMemory.isRememberedCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL)) { + AiCardMemory.rememberCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + reserved.add(c); + } + } + final Set sacSnapshot = ComputerUtilMana.snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = ComputerUtilMana.snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + try { + dryRunCountForTests++; + final boolean result = ComputerUtilMana.payManaCostForCastabilityProbe(candSa.getPayCosts(), candSa, ai, + ctx); + if (!result && probe.lastFailureWasNoSources && probe.lastFailedColoredShard != null) { + probe.unavailableColoredShards.add(probe.lastFailedColoredShard); + } + return result; + } finally { + probe.clearLastFailure(); + for (Card c : reserved) { + AiCardMemory.forgetCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + } + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + ComputerUtilMana.restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + } + } + + private static boolean tieBreakPrefers(final SpellAbility cand, final SpellAbility best, + final int candEfficiency, final int bestEfficiency, final ManaCostShard toPay, + final ManaCostBeingPaid cost, final Player ai, final SpellAbility sa) { + if (candEfficiency < bestEfficiency) { + return true; + } + if (candEfficiency > bestEfficiency || best == null) { + return false; + } + if (toPay != ManaCostShard.GENERIC && toPay != ManaCostShard.X) { + return false; + } + final ComputerUtilMana.GenericColorPreference pref = ComputerUtilMana + .genericColorPreferenceForNestedActivation(ai, sa, cost); + return ComputerUtilMana.rankGenericManaSource(cand, pref) + < ComputerUtilMana.rankGenericManaSource(best, pref); + } +} diff --git a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java index ee4cc141c2d3..8216d2337f03 100644 --- a/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java +++ b/forge-ai/src/main/java/forge/ai/ComputerUtilMana.java @@ -7,6 +7,7 @@ import com.google.common.collect.Multimap; import forge.ai.AiCardMemory.MemorySet; import forge.ai.ability.AnimateAi; +import forge.card.CardType; import forge.card.ColorSet; import forge.card.MagicColor; import forge.card.mana.ManaAtom; @@ -33,10 +34,12 @@ import forge.game.replacement.ReplacementLayer; import forge.game.replacement.ReplacementType; import forge.game.spellability.AbilityManaPart; +import forge.game.spellability.AbilityStatic; import forge.game.spellability.AbilitySub; import forge.game.spellability.SpellAbility; import forge.game.staticability.StaticAbilityManaConvert; import forge.game.trigger.Trigger; +import forge.game.trigger.TriggerHandler; import forge.game.trigger.TriggerType; import forge.game.zone.Zone; import forge.game.zone.ZoneType; @@ -48,29 +51,615 @@ import java.util.stream.Collectors; public class ComputerUtilMana { - private final static boolean DEBUG_MANA_PAYMENT = false; + // Score penalty applied to a filter (mana ability with a mana activation cost) so plain lands + // are preferred for single colored shards. Overridden by a consolidation bonus when the filter covers + // two or more of the unpaid colored shards (see sortManaAbilities). + private final static int FILTER_SINGLE_SHARD_PENALTY = 8; + private final static int FILTER_CONSOLIDATION_BONUS = 20; + /** Per mana lost on net-negative any-mana filters ({2} -> one any pip). */ + private final static int NET_NEGATIVE_ANY_MANA_FILTER_PENALTY = 8; + // Sacrifice / one-shot mana (Lotus Petal) should lose to a consolidating signet when both can pay a colored pip. + private final static int DISPOSABLE_MANA_PENALTY = 30; + /** Outlets like Ashnod's Altar that sacrifice another permanent — below self-sac disposables. */ + private final static int EXTERNAL_SACRIFICE_MANA_PENALTY = 15; + /** Self-sac creature mana (Treva's Attendant) — just above external-sac outlets, below tokens/Petal. */ + private final static int SELF_SAC_CREATURE_MANA_PENALTY = 12; + /** Mana abilities that tap another creature (Springleaf Drum) — after reusables, before disposables. */ + private final static int CREATURE_TAP_MANA_PENALTY = 10; + /** Host cards with SVar AIManaReserve (Karakas, Library of Alexandria) — tap for mana only as last resort. */ + private final static int MANA_RESERVE_HOST_PENALTY = 45; + /** Index of colorless ({C}) pips in {@link AiDeckStatistics#maxPips} (WUBRGC order). */ + private final static int COLORLESS_PIP_INDEX = 5; + /** Prefer a mana source that grants a type-matching cast bonus (Yuna, Dragon Orbs, etc.). */ + private final static int CAST_BONUS_P1P1_WEIGHT = 35; + private final static int CAST_BONUS_DEFAULT_WEIGHT = 25; + private final static int CAST_BONUS_COPY_KEYWORD_WEIGHT = 30; + /** Light penalty when a cast-bonus source does not match the spell being paid. */ + private final static int CAST_BONUS_MISMATCH_PENALTY = 15; + + /** Per-outer-payment caches shared by nested feasibility probes. */ + static final class CastabilityProbeScratch { + final Set unavailableColoredShards = new HashSet<>(); + int availableManaAfterReservation = -1; + ManaCostShard lastFailedColoredShard; + boolean lastFailureWasNoSources; + + void resetForProbe() { + unavailableColoredShards.clear(); + availableManaAfterReservation = -1; + clearLastFailure(); + } + + void clearLastFailure() { + lastFailedColoredShard = null; + lastFailureWasNoSources = false; + } + } + + static final class ManaPaymentPlanCache { + ListMultimap manaAbilityMap; + Long manaAbilityMapKey; + final Map> playableManaCache = new HashMap<>(); + Long reusableTapLandKey; + Set reusableTapLandSet; + int handProbeSpellId = -1; + Boolean hasOtherHandOrCommandSpells; + Boolean handHasMulticolorManaSpells; + Boolean handHasGenericAndColoredCast; + final CastabilityProbeScratch castabilityProbe = new CastabilityProbeScratch(); + } + + /** + * Explicit payment-plan state passed through nested {@link #payManaCost} calls. + */ + static final class ManaPaymentContext { + final ManaPaymentPlanCache caches; + final int depth; + final boolean inFilterActivationProbe; + final boolean paymentPromptPreview; + final boolean tracePaymentPlan; + List testDepositedSurplus; + List planSteps; + + private ManaPaymentContext(final ManaPaymentPlanCache caches, final int depth, + final boolean inFilterActivationProbe, final boolean paymentPromptPreview, + final boolean tracePaymentPlan, final List testDepositedSurplus) { + this.caches = caches; + this.depth = depth; + this.inFilterActivationProbe = inFilterActivationProbe; + this.paymentPromptPreview = paymentPromptPreview; + this.tracePaymentPlan = tracePaymentPlan; + this.testDepositedSurplus = testDepositedSurplus; + } + + static ManaPaymentContext outer() { + return outer(null); + } + + static ManaPaymentContext outer(final List planSteps) { + return outer(planSteps, false, false); + } + + static ManaPaymentContext outerForPaymentPrompt() { + return outer(null, true, true); + } + + static ManaPaymentContext outerForPaymentPromptCommit() { + return outer(null, false, true); + } + + static ManaPaymentContext outer(final List planSteps, final boolean paymentPromptPreview, + final boolean tracePaymentPlan) { + final ManaPaymentContext ctx = new ManaPaymentContext(new ManaPaymentPlanCache(), 1, false, + paymentPromptPreview, tracePaymentPlan, null); + ctx.planSteps = planSteps; + return ctx; + } + + ManaPaymentContext nested() { + return new ManaPaymentContext(caches, depth + 1, inFilterActivationProbe, paymentPromptPreview, + tracePaymentPlan, testDepositedSurplus); + } + + ManaPaymentContext withFilterProbe() { + return new ManaPaymentContext(caches, depth, true, paymentPromptPreview, tracePaymentPlan, + testDepositedSurplus); + } + + ManaPaymentContext nestedWithFilterProbe() { + return new ManaPaymentContext(caches, depth + 1, true, paymentPromptPreview, tracePaymentPlan, + testDepositedSurplus); + } + + boolean isOutermost() { + return depth == 1; + } + + boolean shouldLogMain() { + return depth <= 1 && !inFilterActivationProbe; + } + } + + /** + * Full stranding / efficiency re-simulation runs for payment-prompt preview and production Auto-pay. + * AI feasibility dry-runs ({@link #canPayManaCost}) use sort order only to avoid M2 timeouts. + */ + private static boolean useFullPaymentProbes(final boolean test, final ManaPaymentContext ctx) { + if (!test) { + return true; + } + return ctx != null && ctx.paymentPromptPreview; + } + + /** Live spell cost + tap source while production Auto-pay runs (for TapsForMana combo choices). */ + private static final class ProductionPaymentState { + final ManaCostBeingPaid cost; + SpellAbility tapSource; + + ProductionPaymentState(final ManaCostBeingPaid cost) { + this.cost = cost; + } + } + + private static final ThreadLocal> activeProductionPayment = + ThreadLocal.withInitial(ArrayDeque::new); + + static void beginProductionPayment(final ManaCostBeingPaid cost) { + activeProductionPayment.get().push(new ProductionPaymentState(cost)); + } + + static void endProductionPayment() { + final Deque stack = activeProductionPayment.get(); + if (!stack.isEmpty()) { + stack.pop(); + } + if (stack.isEmpty()) { + activeProductionPayment.remove(); + } + } + + static void setProductionTapSource(final SpellAbility saPayment) { + final Deque stack = activeProductionPayment.get(); + if (!stack.isEmpty()) { + stack.peek().tapSource = saPayment; + } + } + + /** + * Cost-aware combo colors for {@code specifyManaCombo} during production payment. Trigger handlers + * copy the resolving SA and drop express choice, so choices must be derived from the active cost. + */ + static Map specifyComboFromActivePayment(final Player ai, final SpellAbility triggerSa, + final int manaAmount, final boolean different) { + final Deque stack = activeProductionPayment.get(); + if (stack.isEmpty()) { + return null; + } + final ProductionPaymentState state = stack.peek(); + if (state.cost == null || state.cost.isPaid()) { + return null; + } + final ManaCostBeingPaid probe = new ManaCostBeingPaid(state.cost); + if (state.tapSource != null) { + final String landMana = predictManaReplacement(state.tapSource, ai, ManaCostShard.GENERIC); + if (!StringUtils.isBlank(landMana)) { + payMultipleMana(probe, landMana.trim(), ai); + } + } + if (probe.isPaid()) { + return null; + } + final String choices = buildComboManaChoiceString(ai, triggerSa, probe, manaAmount, different); + if (StringUtils.isBlank(choices) || "0".equals(choices)) { + return null; + } + final Map result = new HashMap<>(); + for (final String color : choices.split(" ")) { + if (StringUtils.isBlank(color)) { + continue; + } + final byte atom = ManaAtom.fromName(color); + if (atom != 0) { + result.merge(atom, 1, Integer::sum); + } + } + return result.isEmpty() ? null : result; + } + + /** Extra fungible representatives kept per equivalence class for saExcludeList retries. */ + private static final int FUNGIBLE_CANDIDATE_BUFFER = 2; + + // Runtime-toggleable payment tracing, enabled with -Dforge.debugManaPayment=true. + // Trace filter (first match wins): + // -Dforge.debugManaPayment.trace=test|prod|all (default test) + // -Dforge.debugManaPayment.prodOnly=true (shorthand for trace=prod) + // -Dforge.debugManaPayment.testOnly=false (legacy shorthand for trace=all) + // Numbered payment plan (console only): -Dforge.debugManaPayment.plan=true + private static boolean debugManaPaymentPlan() { + return Boolean.getBoolean("forge.debugManaPayment.plan"); + } + + /** Console plans at payment prompt only: preview dry-run {@code [test]} and Auto commit {@code [prod]}. */ + private static boolean shouldTracePaymentPlan(final SpellAbility sa, final boolean test, + final ManaPaymentContext ctx) { + if (!debugManaPaymentPlan() || sa == null || ctx == null || !ctx.tracePaymentPlan || !ctx.isOutermost()) { + return false; + } + final Card host = sa.getHostCard(); + if (host == null || host.isInZone(ZoneType.Hand)) { + return false; + } + if (host.isInZone(ZoneType.Stack)) { + return true; + } + if (host.isInZone(ZoneType.Battlefield) && isBattlefieldAbilityPayment(sa)) { + return true; + } + return host.isInZone(ZoneType.Command) && isCommandZoneAbilityPayment(sa); + } + + /** True when paying mana to activate a non-spell ability (equip, crew, companion ST$, etc.). */ + private static boolean isAbilityManaPayment(final SpellAbility sa) { + if (sa.isSpell() || sa.isManaAbility()) { + return false; + } + final Cost payCosts = sa.getPayCosts(); + if (payCosts == null || !payCosts.hasManaCost()) { + return false; + } + // ST$ scripted abilities (e.g. Companion put-into-hand) are AbilityStatic, not AbilityActivated. + return sa.isActivatedAbility() || sa.isLandAbility() || sa instanceof AbilityStatic; + } + + /** True when paying mana to activate a non-mana ability on the battlefield (not a spell cast). */ + private static boolean isBattlefieldAbilityPayment(final SpellAbility sa) { + return isAbilityManaPayment(sa); + } + + /** True when paying mana to activate a non-spell ability from the command zone (e.g. Companion). */ + private static boolean isCommandZoneAbilityPayment(final SpellAbility sa) { + return isAbilityManaPayment(sa); + } + private enum ManaPaymentTraceMode { + TEST, PROD, ALL; + + static ManaPaymentTraceMode current() { + if (Boolean.getBoolean("forge.debugManaPayment.prodOnly")) { + return PROD; + } + final String trace = System.getProperty("forge.debugManaPayment.trace"); + if (trace != null) { + switch (trace.toLowerCase(Locale.ROOT)) { + case "prod": + return PROD; + case "all": + return ALL; + case "test": + return TEST; + default: + break; + } + } + if (!Boolean.parseBoolean(System.getProperty("forge.debugManaPayment.testOnly", "true"))) { + return ALL; + } + return TEST; + } + } + + private static boolean debugManaPayment(boolean test) { + if (!Boolean.getBoolean("forge.debugManaPayment")) { + return false; + } + switch (ManaPaymentTraceMode.current()) { + case PROD: + return !test; + case ALL: + return true; + case TEST: + default: + return test; + } + } + + static void debugLog(boolean test, String msg) { + if (debugManaPayment(test)) { + System.out.println("MANA_PAYMENT [" + (test ? "test" : "prod") + "] " + msg); + } + } + + private static void debugLogResult(final boolean test, final boolean success, final String msg, + final ManaPaymentContext ctx) { + final String line = success ? msg : "!! " + msg; + debugLogMain(test, line, ctx); + if (msg.contains("PAID (pool)") || msg.startsWith(" result: FAILED") || msg.startsWith(" reject ")) { + recordPlanStep(ctx, line); + } + } + + /** Payment trace for the outer spell only; nested feasibility probes stay silent. */ + private static void debugLogMain(final boolean test, final String msg, final ManaPaymentContext ctx) { + if (ctx == null || ctx.shouldLogMain()) { + debugLog(test, msg); + } + } + + /** Append a step to the numbered console plan (outer payment only). */ + private static void recordPlanStep(final ManaPaymentContext ctx, final String msg) { + if (ctx != null && ctx.planSteps != null && ctx.shouldLogMain()) { + ctx.planSteps.add(msg); + } + } + + private static void tracePlanStep(final boolean test, final String msg, final ManaPaymentContext ctx) { + if (debugManaPayment(test)) { + debugLogMain(test, msg, ctx); + } + recordPlanStep(ctx, msg); + } + + private static void printPaymentPlanToConsole(final boolean test, final String costLabel, + final SpellAbility sa, final List rawSteps, final boolean paid, + final ManaPaymentContext ctx) { + if (!shouldTracePaymentPlan(sa, test, ctx) || rawSteps == null || rawSteps.isEmpty()) { + return; + } + System.out.println("MANA_PAYMENT_PLAN [" + (test ? "test" : "prod") + "] " + costLabel + " for " + + manaPaymentSpellLabel(sa) + (paid ? "" : " (unpaid)")); + int step = 1; + for (final String line : formatPlanSteps(rawSteps)) { + System.out.println(" " + step++ + ". " + line); + } + } + + private static String formatManaProducedForLog(final SpellAbility saPayment, final Player ai, + final ManaCostShard toPay, final ManaCostBeingPaid cost) { + String manaProduced = predictManafromSpellAbility(saPayment, ai, toPay, cost); + if (isMultiManaComboAbility(saPayment)) { + manaProduced = capComboManaProduced(manaProduced, getComboManaAmount(saPayment)); + } + return manaProduced; + } + + private static void debugLogTap(final boolean test, final SpellAbility saPayment, final SpellAbility sa, + final String paidShards, final String manaProduced, final ManaPaymentContext ctx) { + tracePlanStep(test, " tap " + saPayment.getHostCard() + " -> " + manaProduced + + " (paying " + paidShards + " for " + manaPaymentSpellLabel(sa) + ")", ctx); + } + + /** + * Shards actually paid by one activation, diffed from the cost before/after applying its mana + * (a Signet paying {@code {G}} and the spell's generic {@code {1}} logs "{G}{1}", not just the + * shard the planner targeted). + */ + private static String formatShardsPaidDiff(final ManaCostBeingPaid before, final ManaCostBeingPaid after, + final ManaCostShard fallback) { + final StringBuilder paid = new StringBuilder(); + for (final ManaCostShard shard : before.getDistinctShards()) { + for (int i = before.getUnpaidShards(shard) - after.getUnpaidShards(shard); i > 0; i--) { + paid.append(shard); + } + } + return paid.length() == 0 ? fallback.toString() : paid.toString(); + } + + private static void debugLogNestedTap(final boolean test, final SpellAbility chosen, final Player ai, + final ManaCostShard toPay, final Card filterHost, final ManaPaymentContext ctx) { + tracePlanStep(test, " nested tap " + chosen.getHostCard() + " -> " + + predictManafromSpellAbility(chosen, ai, toPay) + + " for " + filterHost + " activation", ctx); + } + + private static String formatPlanStepForDisplay(final String raw) { + String s = raw.trim(); + if (s.startsWith("!! ")) { + s = s.substring(3).trim(); + } + if (s.startsWith("tap ")) { + s = "Tap " + s.substring(4); + } else if (s.startsWith("nested tap ")) { + s = "Tap " + s.substring(11); + final int forIdx = s.indexOf(" for "); + if (forIdx > 0 && s.endsWith(" activation")) { + s = s.substring(0, s.length() - " activation".length()) + .replace(" for ", " (to activate ") + ")"; + } + } else if (s.startsWith("bank via ")) { + final String rest = s.substring(9); + final int paren = rest.indexOf(" (paying "); + s = "Chain: " + (paren > 0 ? rest.substring(0, paren) : rest); + } else if (s.startsWith("consolidate via ")) { + final String rest = s.substring(16); + final int paren = rest.indexOf(" (paying "); + s = "Tap " + (paren > 0 ? rest.substring(0, paren) : rest) + " (consolidate)"; + } else if (s.startsWith("pool pays ")) { + final String rest = s.substring(10); + final int forIdx = rest.indexOf(" for "); + return "Pay " + (forIdx > 0 ? rest.substring(0, forIdx) : rest) + " from mana pool"; + } else if (s.equals("result: PAID (pool)")) { + return "Pay remainder from mana pool"; + } else if (s.startsWith("result: FAILED")) { + return "Cannot pay" + s.substring("result: FAILED".length()); + } else if (s.startsWith("reject ")) { + return "Skip " + s.substring(7); + } + final int paying = s.indexOf(" (paying "); + if (paying > 0) { + final int forSpell = s.indexOf(" for ", paying); + if (forSpell > 0 && s.endsWith(")")) { + final String shard = s.substring(paying + 9, forSpell); + s = s.substring(0, paying) + " (for " + shard + ")"; + } + } else { + final int bankFor = s.indexOf(" (bank for "); + if (bankFor > 0 && s.endsWith(")")) { + s = s.substring(0, bankFor) + " (bank)"; + } + } + return s.replace(" -> ", " → "); + } + + private static final ZoneType[] HAND_AND_COMMAND = { ZoneType.Hand, ZoneType.Command }; + + /** Spell name for payment traces; zone/ability kind tagged when not a hand spell. */ + private static String manaPaymentSpellLabel(final SpellAbility sa) { + final Card host = sa.getHostCard(); + final StringBuilder label = new StringBuilder(host.getName()); + if (host.isInZone(ZoneType.Command)) { + label.append(" [").append(commandZoneAbilityPaymentKind(sa)).append("]"); + } else if (host.isInZone(ZoneType.Stack)) { + label.append(" [stack]"); + } else if (host.isInZone(ZoneType.Battlefield)) { + label.append(" [").append(battlefieldAbilityPaymentKind(sa)).append("]"); + } + return label.toString(); + } + + private static String battlefieldAbilityPaymentKind(final SpellAbility sa) { + if (sa.isEquip()) { + return "equip"; + } + if (sa.isCrew()) { + return "crew"; + } + if (sa.isPwAbility()) { + return "loyalty"; + } + if (sa.isLandAbility()) { + return "land ability"; + } + if (sa.isActivatedAbility()) { + return "ability"; + } + return "battlefield"; + } + + private static String commandZoneAbilityPaymentKind(final SpellAbility sa) { + final String desc = sa.getDescription(); + if (desc != null && desc.contains("Companion")) { + return "companion"; + } + return "command"; + } + + static ListMultimap getOrBuildManaAbilityMap(final Player ai, + final boolean checkPlayable, final ManaPaymentContext ctx) { + if (ctx == null) { + return groupSourcesByManaColor(ai, checkPlayable, null); + } + final long fp = paymentPlanReservationFingerprint(ai); + final ListMultimap cached = ctx.caches.manaAbilityMap; + if (cached != null && ctx.caches.manaAbilityMapKey != null && ctx.caches.manaAbilityMapKey == fp) { + return cached; + } + final ListMultimap map = groupSourcesByManaColor(ai, checkPlayable, ctx); + ctx.caches.manaAbilityMap = map; + ctx.caches.manaAbilityMapKey = fp; + return map; + } + + /** Fingerprint reserved / tapped mana sources so nested activation dry-runs can be cached per plan. */ + private static long paymentPlanReservationFingerprint(final Player ai) { + return manaSourceReservationKey(ai, null); + } + + private static long manaSourceReservationKey(final Player ai, final Card filterHost) { + long key = filterHost == null ? 0 : filterHost.getId(); + key = key * 31 + fingerprintMemorySet(ai, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + key = key * 31 + fingerprintMemorySet(ai, MemorySet.PAYS_TAP_COST); + key = key * 31 + fingerprintMemorySet(ai, MemorySet.PAYS_SAC_COST); + return key; + } + + private static long fingerprintMemorySet(final Player ai, final MemorySet set) { + final Set cards = AiCardMemory.getMemorySet(ai, set); + if (cards == null || cards.isEmpty()) { + return 0; + } + long fp = 1; + for (final Card c : cards) { + fp = fp * 31 + c.getId(); + } + return fp; + } + + /** Per-priority cache for outermost {@link #canPayManaCost} dry-runs (see {@link #beginAffordabilityCachePass}). */ + private static Map affordabilityCache = null; + + /** + * Start caching {@link #canPayManaCost} results for one AI priority pass. Cleared/replaced on each call; + * pair with {@link AiController#chooseSpellAbilityToPlay()}. + */ + public static void beginAffordabilityCachePass() { + affordabilityCache = Maps.newHashMap(); + } + + public static void clearAffordabilityCachePass() { + affordabilityCache = null; + } + + private static long fingerprintManaPool(final ManaPool pool) { + if (pool == null) { + return 0; + } + long fp = pool.totalMana(); + for (final byte color : ManaAtom.MANATYPES) { + fp = fp * 31 + pool.getAmountOfColor(color); + } + return fp; + } + + private static String buildAffordabilityCacheKey(final SpellAbility sa, final Player ai, + final ManaCostBeingPaid cost, final int extraMana, final boolean checkPlayable, final boolean effect) { + return sa.getId() + "|" + cost + "|" + extraMana + "|" + (checkPlayable ? '1' : '0') + "|" + + (effect ? '1' : '0') + "|" + paymentPlanReservationFingerprint(ai) + "|" + + fingerprintManaPool(ai.getManaPool()); + } + + private static boolean canPayManaCostCached(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, + final int extraMana, final boolean checkPlayable, final boolean effect) { + if (affordabilityCache != null && sa != null && ai != null && cost != null) { + final String key = buildAffordabilityCacheKey(sa, ai, cost, extraMana, checkPlayable, effect); + final Boolean cached = affordabilityCache.get(key); + if (cached != null) { + return cached; + } + final boolean result = payManaCost(cost, sa, ai, true, checkPlayable, effect, null, + ManaPaymentContext.outer()); + affordabilityCache.put(key, result); + return result; + } + return payManaCost(cost, sa, ai, true, checkPlayable, effect, null, ManaPaymentContext.outer()); + } public static boolean canPayManaCost(ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean effect) { //check copy of cost so it doesn't modify the exist cost being paid cost = new ManaCostBeingPaid(cost); - return payManaCost(cost, sa, ai, true, true, effect) != null; + return canPayManaCostCached(cost, sa, ai, 0, true, effect); } public static boolean canPayManaCost(final SpellAbility sa, final Player ai, final int extraMana, final boolean effect) { return canPayManaCost(sa.getPayCosts(), sa, ai, extraMana, effect); } public static boolean canPayManaCost(final Cost cost, final SpellAbility sa, final Player ai, final int extraMana, final boolean effect) { - return payManaCost(cost, sa, ai, true, extraMana, true, effect); + final ManaCostBeingPaid manaCost = calculateManaCost(cost, sa, ai, true, extraMana, effect); + return canPayManaCostCached(manaCost, sa, ai, extraMana, true, effect); } public static boolean payManaCost(ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean effect) { - return payManaCost(cost, sa, ai, false, true, effect) != null; + return payManaCost(cost, sa, ai, false, true, effect, null, ManaPaymentContext.outer()); + } + + /** Production Auto-pay from the human payment prompt (emits {@code [prod]} plan when enabled). */ + public static boolean payManaCostFromPaymentPrompt(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final boolean effect) { + return payManaCost(cost, sa, ai, false, true, effect, null, ManaPaymentContext.outerForPaymentPromptCommit()); } public static boolean payManaCost(final Cost cost, final Player ai, final SpellAbility sa, final boolean effect) { - return payManaCost(cost, sa, ai, false, 0, true, effect); + return payManaCost(cost, sa, ai, false, 0, true, effect, ManaPaymentContext.outer()); } - private static boolean payManaCost(final Cost cost, final SpellAbility sa, final Player ai, final boolean test, final int extraMana, boolean checkPlayable, final boolean effect) { + private static boolean payManaCost(final Cost cost, final SpellAbility sa, final Player ai, final boolean test, final int extraMana, boolean checkPlayable, final boolean effect, final ManaPaymentContext ctx) { ManaCostBeingPaid manaCost = calculateManaCost(cost, sa, ai, test, extraMana, effect); - return payManaCost(manaCost, sa, ai, test, checkPlayable, effect) != null; + return payManaCost(manaCost, sa, ai, test, checkPlayable, effect, null, ctx); } /** @@ -78,7 +667,7 @@ private static boolean payManaCost(final Cost cost, final SpellAbility sa, final */ public static int getConvergeCount(final SpellAbility sa, final Player ai) { ManaCostBeingPaid cost = calculateManaCost(sa.getPayCosts(), sa, ai, true, 0, false); - if (payManaCost(cost, sa, ai, true, true, false) != null) { + if (payManaCost(cost, sa, ai, true, true, false, null, ManaPaymentContext.outer())) { return cost.getSunburst(); } return 0; @@ -89,25 +678,112 @@ public static boolean hasEnoughManaSourcesToCast(final SpellAbility sa, final Pl if (ai == null || sa == null) return false; sa.setActivatingPlayer(ai); - return payManaCost(sa.getPayCosts(), sa, ai, true, 0, false, false); + return payManaCost(sa.getPayCosts(), sa, ai, true, 0, false, false, ManaPaymentContext.outer()); } public static CardCollection getManaSourcesToPayCost(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean effect) { - final List payment = payManaCost(cost, sa, ai, true, true, effect); - if (payment == null) { + final CardCollection plan = new CardCollection(); + if (!payManaCost(cost, sa, ai, true, true, effect, plan, ManaPaymentContext.outer())) { + return null; + } + return plan; + } + + /** + * Dry-run for the human payment-prompt Auto preview. Uses castability-aware source selection so the + * highlighted cards match production auto-pay routing. + */ + public static CardCollection getManaSourcesToPayCostForPaymentPrompt(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final boolean effect) { + final CardCollection plan = new CardCollection(); + if (!payManaCost(cost, sa, ai, true, true, effect, plan, ManaPaymentContext.outerForPaymentPrompt())) { return null; } - return new CardCollection(payment.stream().map(Mana::getSourceCard).filter(Objects::nonNull)); + return plan; + } + + public static CardCollection getManaSourcesToPayCostForPaymentPrompt(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai) { + return getManaSourcesToPayCostForPaymentPrompt(cost, sa, ai, false); + } + + /** + * Dry-run the unified mana planner and return the cards it would tap plus formatted plan steps. + * When {@code -Dforge.debugManaPayment.plan=true} at the payment prompt, the numbered plan is printed for the + * preview dry-run ({@code [test]}) and Auto commit ({@code [prod]}). + */ + public static ManaPaymentPlan planManaPayment(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final boolean effect) { + final ManaCostBeingPaid costCopy = new ManaCostBeingPaid(cost); + final CardCollection sources = new CardCollection(); + final List rawSteps = shouldTracePaymentPlan(sa, true, ManaPaymentContext.outerForPaymentPrompt()) + ? new ArrayList<>() : null; + final boolean ok = payManaCost(costCopy, sa, ai, true, true, effect, sources, + ManaPaymentContext.outer(rawSteps, true, true)); + final boolean payable = ok && costCopy.isPaid(); + if (!payable) { + return new ManaPaymentPlan(null, formatPlanSteps(rawSteps), false); + } + return new ManaPaymentPlan(sources, formatPlanSteps(rawSteps), true); + } + + private static List formatPlanSteps(final List rawSteps) { + if (rawSteps == null || rawSteps.isEmpty()) { + return Collections.emptyList(); + } + final List formatted = new ArrayList<>(rawSteps.size()); + for (final String step : rawSteps) { + formatted.add(formatPlanStepForDisplay(step)); + } + return formatted; + } + + /** Cards Auto would tap and the ordered steps explaining why. */ + public static final class ManaPaymentPlan { + private final CardCollection sources; + private final List steps; + private final boolean payable; + + ManaPaymentPlan(final CardCollection sources, final List steps, final boolean payable) { + this.sources = sources; + this.steps = steps; + this.payable = payable; + } + + /** Host cards Auto would consume, or null if the cost cannot be paid. */ + public CardCollection getSources() { + return sources; + } + + /** Ordered human-readable steps (taps, chains, pool use). */ + public List getSteps() { + return steps; + } + + public boolean isPayable() { + return payable; + } } private static Integer scoreManaProducingCard(final Card card) { + return scoreManaProducingCard(card, null, card.getController()); + } + + private static Integer scoreManaProducingCard(final Card card, final SpellAbility spellBeingPaid, final Player ai) { int score = 0; + int maxManaProduced = 0; + boolean hasManaCostAbility = false; for (SpellAbility ability : card.getSpellAbilities()) { ability.setActivatingPlayer(card.getController()); if (ability.isManaAbility()) { score += ability.calculateScoreForManaAbility(); - // TODO check TriggersWhenSpent: decrease score depending on context + maxManaProduced = Math.max(maxManaProduced, ability.amountOfManaGenerated(true)); + if (ability.getPayCosts() != null && ability.getPayCosts().hasManaCost()) { + hasManaCostAbility = true; + } + score += castBonusPreferenceAdjustment(ability, spellBeingPaid, ai); + score += netNegativeAnyManaFilterLoss(ability) * NET_NEGATIVE_ANY_MANA_FILTER_PENALTY; } else if (!ability.isTrigger() && ability.isPossible()) { score += 13; //add 13 for any non-mana activated abilities @@ -115,305 +791,3926 @@ else if (!ability.isTrigger() && ability.isPossible()) { } if (card.isCreature()) { - // treat attacking and blocking as though they're non-mana abilities + // Rank mana sources by mana per tap, not card type alone. A creature that makes several + // mana is as efficient as several lands, so waive the combat "keep me back" penalty for the most + // efficient dorks (3+ mana) and halve it for 2-mana producers. + int combatPenalty = 0; if (CombatUtil.canAttack(card)) { - score += 13; + combatPenalty += 13; } if (CombatUtil.canBlock(card)) { - score += 13; + combatPenalty += 13; } - } - - return score; - } - - private static void sortManaAbilities(final ListMultimap sourcesForShards, final ListMultimap manaAbilityMap, final SpellAbility sa) { - final Map manaCardMap = Maps.newHashMap(); - final List orderedCards = Lists.newArrayList(); - - for (final ManaCostShard shard : sourcesForShards.keySet()) { - for (SpellAbility ability : sourcesForShards.get(shard)) { - final Card hostCard = ability.getHostCard(); - if (!manaCardMap.containsKey(hostCard)) { - // TODO +1 when reserved - manaCardMap.put(hostCard, scoreManaProducingCard(hostCard)); - orderedCards.add(hostCard); - } + if (maxManaProduced >= 3) { + combatPenalty = 0; + } else if (maxManaProduced == 2) { + combatPenalty /= 2; } + score += combatPenalty; } - // lower value means better choice - orderedCards.sort(Comparator.comparingInt(manaCardMap::get)); + // Deprioritize filters slightly by default so plain lands win the single-shard case. + // Multi-shard consolidation is handled separately in sortManaAbilities where the cost is known. + if (hasManaCostAbility) { + score += FILTER_SINGLE_SHARD_PENALTY; + } - if (DEBUG_MANA_PAYMENT) { - System.out.print("Ordered Cards: " + orderedCards.size()); - for (Card card : orderedCards) { - System.out.print(card.getName() + ", "); - } - System.out.println(); - } - - List colorsMostCommon; - if (sourcesForShards.keySet().stream().anyMatch(ManaCostShard::isGeneric)) { - // early tempo is more important so we only look at hand here - CardCollection hand = new CardCollection(sa.getActivatingPlayer().getCardsIn(ZoneType.Hand)); - hand.remove(sa.getHostCard()); - AiDeckStatistics stats = AiDeckStatistics.fromCards(hand); - Integer[] orderedColorsIdx = {0, 1, 2, 3, 4}; - // order common colors to the front, increases chance AI can play a second spell after - colorsMostCommon = Arrays.stream(orderedColorsIdx).sorted(Comparator.comparingInt(o -> stats.maxPips[(int) o]).reversed()) - .filter(idx -> stats.maxPips[idx] > 0) - .map(idx -> (int) MagicColor.WUBRG[idx]) - .collect(Collectors.toList()); - } else { - colorsMostCommon = null; + if (isDisposableManaCard(card)) { + score += DISPOSABLE_MANA_PENALTY; } - for (final ManaCostShard shard : sourcesForShards.keySet()) { - final List abilities = sourcesForShards.get(shard); - final List newAbilities = new ArrayList<>(abilities); + if (hasSelfSacrificeCreatureMana(card)) { + score += SELF_SAC_CREATURE_MANA_PENALTY; + } - if (DEBUG_MANA_PAYMENT) { - System.out.println("Unsorted Abilities: " + newAbilities); - } + if (hasCreatureTapManaAbility(card)) { + score += CREATURE_TAP_MANA_PENALTY; + } - newAbilities.sort((ability1, ability2) -> { - int preOrder = orderedCards.indexOf(ability1.getHostCard()) - orderedCards.indexOf(ability2.getHostCard()); + if (hasExternalSacrificeManaOutlet(card)) { + score += EXTERNAL_SACRIFICE_MANA_PENALTY; + } - if (preOrder != 0) { - // on identical score (most likely basics) try keep access to more colors longer - if (shard.isGeneric() && manaCardMap.get(ability1.getHostCard()).equals(manaCardMap.get(ability2.getHostCard()))) { - for (Integer col : colorsMostCommon) { - boolean fromCommonColorSource1 = manaAbilityMap.get(col).stream().anyMatch(ma -> ma.getHostCard().equals(ability1.getHostCard())); - boolean fromCommonColorSource2 = manaAbilityMap.get(col).stream().anyMatch(ma -> ma.getHostCard().equals(ability2.getHostCard())); - if (fromCommonColorSource1 && !fromCommonColorSource2) { - return 1; - } - if (!fromCommonColorSource1 && fromCommonColorSource2) { - return -1; - } - } - } + if (isManaReserveHost(card)) { + score += MANA_RESERVE_HOST_PENALTY; + } - // sources were previously sorted, so add their index to connect those values to some degree - // This has been disabled because it makes the AI more likely to sacrifice lands than use creatures for mana - // preOrder += abilities.indexOf(ability1) - abilities.indexOf(ability2); + return score; + } - return preOrder; - } + private static Cost payCostsOf(final SpellAbility sa) { + return sa == null ? null : sa.getPayCosts(); + } - // Mana abilities on the same card - String shardMana = shard.toShortString(); + private static boolean hasTapCost(final SpellAbility sa) { + final Cost c = payCostsOf(sa); + return c != null && c.hasTapCost(); + } - boolean payWithAb1 = ability1.getManaPart().mana(ability1).contains(shardMana); - boolean payWithAb2 = ability2.getManaPart().mana(ability2).contains(shardMana); + private static boolean hasManaCost(final SpellAbility sa) { + final Cost c = payCostsOf(sa); + return c != null && c.hasManaCost(); + } - if (payWithAb1 && !payWithAb2) { - return -1; - } else if (payWithAb2 && !payWithAb1) { - return 1; - } + /** True when the host card should only be tapped for mana when no better source exists. */ + private static boolean isManaReserveHost(final Card card) { + return card != null && card.hasSVar("AIManaReserve") + && "True".equalsIgnoreCase(card.getSVar("AIManaReserve")); + } - return ability1.compareTo(ability2); - }); - - if (DEBUG_MANA_PAYMENT) { - System.out.println("Sorted Abilities: " + newAbilities); + /** True for sacrifice or other one-shot mana abilities the AI should not spend before a reusable filter. */ + private static boolean isDisposableManaAbility(final SpellAbility ma) { + if (ma == null || !ma.isManaAbility()) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + if (payCosts != null) { + for (final CostPart part : payCosts.getCostParts()) { + if (part instanceof CostSacrifice) { + return true; + } + } + } + if (!ma.isUndoable()) { + // Variable-amount tap lands (Gaea's Cradle) are marked non-undoable but are reusable. + if (payCosts != null && payCosts.hasTapCost() && !payCosts.hasManaCost()) { + return false; } + return true; + } + return false; + } - sourcesForShards.replaceValues(shard, newAbilities); + private static boolean isDisposableManaCard(final Card card) { + for (final SpellAbility ma : card.getManaAbilities()) { + if (isDisposableManaAbility(ma)) { + return true; + } + } + return false; + } - // Sort the first N abilities so that the preferred shard is selected, e.g. Adamant - String manaPref = sa.getParamOrDefault("AIManaPref", ""); - if (manaPref.isEmpty() && sa.getHostCard() != null && sa.getHostCard().hasSVar("AIManaPref")) { - manaPref = sa.getHostCard().getSVar("AIManaPref"); + private static boolean hasExternalSacrificeManaOutlet(final Card card) { + for (final SpellAbility ma : card.getManaAbilities()) { + if (sacrificesOtherPermanentsForMana(ma)) { + return true; } + } + return false; + } - if (!manaPref.isEmpty()) { - final String[] prefShardInfo = manaPref.split(":"); - final String preferredShard = prefShardInfo[0]; - final int preferredShardAmount = prefShardInfo.length > 1 ? Integer.parseInt(prefShardInfo[1]) : 3; + /** Sac/one-shot source that produces 2+ mana per activation (Goldspan Treasure, etc.). */ + private static boolean isMultiManaDisposable(final SpellAbility ma) { + if (!isDisposableManaAbility(ma) || sacrificesOtherPermanentsForMana(ma)) { + return false; + } + return getManaProducedAmount(ma) >= 2; + } - if (!preferredShard.isEmpty()) { - final List prefSortedAbilities = new ArrayList<>(newAbilities); - final List otherSortedAbilities = new ArrayList<>(newAbilities); - - prefSortedAbilities.sort((ability1, ability2) -> { - if (ability1.getManaPart().mana(ability1).contains(preferredShard)) - return -1; - else if (ability2.getManaPart().mana(ability2).contains(preferredShard)) - return 1; - - return 0; - }); - otherSortedAbilities.sort((ability1, ability2) -> { - if (ability1.getManaPart().mana(ability1).contains(preferredShard)) - return 1; - else if (ability2.getManaPart().mana(ability2).contains(preferredShard)) - return -1; - - return 0; - }); - - final List finalAbilities = new ArrayList<>(); - for (int i = 0; i < preferredShardAmount && i < prefSortedAbilities.size(); i++) { - finalAbilities.add(prefSortedAbilities.get(i)); - } - for (SpellAbility ab : otherSortedAbilities) { - if (!finalAbilities.contains(ab)) - finalAbilities.add(ab); - } + /** True for outlets like Ashnod's Altar that sacrifice another permanent, not the mana source itself. */ + private static boolean sacrificesOtherPermanentsForMana(final SpellAbility ma) { + final Cost payCosts = ma.getPayCosts(); + if (payCosts == null) { + return false; + } + for (final CostPart part : payCosts.getCostParts()) { + if (part instanceof CostSacrifice && !part.payCostFromSource()) { + return true; + } + } + return false; + } - sourcesForShards.replaceValues(shard, finalAbilities); - } + /** Self-sac disposable on a creature (Treva's Attendant), not a token or artifact rock. */ + private static boolean isSelfSacrificeCreatureMana(final SpellAbility ma) { + return isDisposableManaAbility(ma) && !sacrificesOtherPermanentsForMana(ma) + && ma.getHostCard().isCreature(); + } + + private static boolean hasSelfSacrificeCreatureMana(final Card card) { + if (card == null) { + return false; + } + for (final SpellAbility ma : card.getManaAbilities()) { + if (isSelfSacrificeCreatureMana(ma)) { + return true; } } + return false; } - public static SpellAbility chooseManaAbility(ManaCostBeingPaid cost, SpellAbility sa, Player ai, ManaCostShard toPay, - Collection maList, boolean checkCosts) { - Card saHost = sa.getHostCard(); + /** + * Mana ability whose cost taps another creature via {@code tapXType} (Springleaf Drum, + * Survivors' Encampment). Excludes self-tap dorks ({@code Cost$ T} on the creature itself) + * and disposables, which have their own sacrifice tiers. + */ + private static boolean requiresTappingOtherCreatureForMana(final SpellAbility ma) { + if (ma == null || !ma.isManaAbility() || isDisposableManaAbility(ma)) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + if (payCosts == null) { + return false; + } + for (final CostPart part : payCosts.getCostParts()) { + if (part instanceof CostTapType && isCreatureTapType(part.getType())) { + return true; + } + } + return false; + } - // CastTotalManaSpent (AIPreference:ManaFrom$Type or AIManaPref$ Type) - String manaSourceType = ""; - if (saHost.hasSVar("AIPreference")) { - String condition = saHost.getSVar("AIPreference"); - if (condition.startsWith("ManaFrom")) { - manaSourceType = TextUtil.split(condition, '$')[1]; + /** True when a {@code tapXType} type string targets creatures (Creature, Elf, Spirit, ...). */ + private static boolean isCreatureTapType(final String type) { + if (type == null) { + return false; + } + for (final String option : type.split(";")) { + final String core = option.split("\\.", 2)[0].trim(); + if ("Creature".equals(core) || CardType.isACreatureType(core)) { + return true; } - } else if (sa.hasParam("AIManaPref")) { - manaSourceType = sa.getParam("AIManaPref"); } - if (manaSourceType != "") { - List filteredList = Lists.newArrayList(maList); - switch (manaSourceType) { - case "Snow": - filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().isSnow() - && ab2.getHostCard() != null && !ab2.getHostCard().isSnow() ? -1 : 1); - maList = filteredList; - break; - case "Treasure": - // Try to spend only one Treasure if possible - filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().getType().hasSubtype("Treasure") - && ab2.getHostCard() != null && !ab2.getHostCard().getType().hasSubtype("Treasure") ? -1 : 1); - SpellAbility first = filteredList.get(0); - if (first.getHostCard() != null && first.getHostCard().getType().hasSubtype("Treasure")) { - maList.remove(first); - List updatedList = Lists.newArrayList(); - updatedList.add(first); - updatedList.addAll(maList); - maList = updatedList; - } - break; - case "TreasureMax": - // Ok to spend as many Treasures as possible - filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().getType().hasSubtype("Treasure") - && ab2.getHostCard() != null && !ab2.getHostCard().getType().hasSubtype("Treasure") ? -1 : 1); - maList = filteredList; - break; - case "NotSameCard": - String hostName = sa.getHostCard().getName(); - maList = filteredList.stream() - .filter(saPay -> !saPay.getHostCard().getName().equals(hostName)) - .collect(Collectors.toList()); - break; - default: - break; + return false; + } + + private static boolean hasCreatureTapManaAbility(final Card card) { + if (card == null) { + return false; + } + for (final SpellAbility ma : card.getManaAbilities()) { + if (requiresTappingOtherCreatureForMana(ma)) { + return true; } } + return false; + } - for (final SpellAbility ma : maList) { - // this rarely seems like a good idea - if (ma.getHostCard() == saHost) { + /** + * Tap-cost mana source whose host won't untap during its controller's untap step (Mana Vault, + * Grim Monolith, stunned permanents). Tapping it strands the card for future turns, so it is less + * desirable than a normal reusable source even though it is not sacrificed. Disposable sources are + * handled by their own tiers. + */ + private static boolean doesNotUntapNormally(final SpellAbility ma) { + if (ma == null || !ma.isManaAbility() || isDisposableManaAbility(ma)) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + if (payCosts == null || !payCosts.hasTapCost()) { + return false; + } + final Card host = ma.getHostCard(); + return host != null && !host.canUntap(host.getController(), true); + } + + /** + * Disposable sacrifice tier for sorting. Lower = preferred. + * 0 = self-sac token/artifact (Treasure, Petal); 1 = self-sac creature; 2 = sacrifice-other outlet. + */ + private static int disposableSacrificeTier(final SpellAbility ma) { + if (!isDisposableManaAbility(ma)) { + return -1; + } + if (sacrificesOtherPermanentsForMana(ma)) { + return 2; + } + if (isSelfSacrificeCreatureMana(ma)) { + return 1; + } + return 0; + } + + /** + * Among disposable candidates only. Lower return = better. + * When {@code remaining >= 2}, prefer higher output; when {@code remaining == 1}, prefer tight fit. + */ + private static int compareDisposableByYield(final SpellAbility a, final SpellAbility b, final int remaining) { + final int prodA = getManaProducedAmount(a); + final int prodB = getManaProducedAmount(b); + if (remaining >= 2) { + return Integer.compare(prodB, prodA); + } + return Integer.compare(prodA, prodB); + } + + /** Self-sac tokens before self-sac creatures before sacrifice-other outlets (Ashnod's Altar). */ + private static int compareDisposableCandidates(final SpellAbility a, final SpellAbility b, final int remaining) { + final int tierA = disposableSacrificeTier(a); + final int tierB = disposableSacrificeTier(b); + if (tierA != tierB) { + return Integer.compare(tierA, tierB); + } + return compareDisposableByYield(a, b, remaining); + } + + /** Collect SpellCast triggers tied to a mana ability (TriggersWhenSpent or Effect subability). */ + private static List resolveCastBonusTriggers(final SpellAbility manaAb) { + if (manaAb == null || !manaAb.isManaAbility()) { + return Collections.emptyList(); + } + final Card host = manaAb.getHostCard(); + if (host == null) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(); + final AbilityManaPart mp = manaAb.getManaPart(); + if (mp != null && mp.getTriggersWhenSpent()) { + addParsedCastBonusTrigger(result, host, manaAb.getParam("TriggersWhenSpent"), manaAb.isIntrinsic(), true); + } + for (SpellAbility sub = manaAb.getSubAbility(); sub != null; sub = sub.getSubAbility()) { + if (sub.getApi() != ApiType.Effect) { continue; } + if (sub.hasParam("Triggers")) { + for (final String trigName : sub.getParam("Triggers").split(",")) { + addParsedCastBonusTrigger(result, host, trigName.trim(), manaAb.isIntrinsic(), false); + } + } + } + if (manaAb.hasParam("SubAbility")) { + collectCastBonusTriggersFromEffectSVar(result, host, manaAb.getParam("SubAbility"), manaAb.isIntrinsic()); + } + return result; + } - if (ma.getPayCosts().hasTapCost() && AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_TAP_COST)) { - continue; + private static void collectCastBonusTriggersFromEffectSVar(final List result, final Card host, + final String effectSVarName, final boolean intrinsic) { + if (host == null || StringUtils.isBlank(effectSVarName)) { + return; + } + final String effectDef = host.getSVar(effectSVarName); + if (StringUtils.isBlank(effectDef)) { + return; + } + for (final String part : TextUtil.split(effectDef, '|')) { + final String trimmed = part.trim(); + if (trimmed.startsWith("Triggers$")) { + addParsedCastBonusTrigger(result, host, trimmed.substring("Triggers$".length()).trim(), intrinsic, + false); } + } + } - int amount = ma.hasParam("Amount") ? AbilityUtils.calculateAmount(ma.getHostCard(), ma.getParam("Amount"), ma) : 1; - if (amount <= 0) { - // wrong gamestate for variable amount - continue; + private static void addParsedCastBonusTrigger(final List result, final Card host, + final String trigSVarName, final boolean intrinsic, final boolean fromTriggersWhenSpent) { + if (StringUtils.isBlank(trigSVarName)) { + return; + } + final String trigDef = host.getSVar(trigSVarName); + if (StringUtils.isBlank(trigDef)) { + return; + } + try { + final Trigger t = TriggerHandler.parseTrigger(trigDef, host, false, host); + if (isCastBonusTrigger(t, fromTriggersWhenSpent)) { + result.add(t); } + } catch (RuntimeException ex) { + // Unparseable trigger script — skip for AI planning. + } + } - if (sa.getApi() == ApiType.Animate) { - // For abilities like Genju of the Cedars, make sure that we're not activating the aura ability by tapping the enchanted card for mana - if (saHost.isAura() && "Enchanted".equals(sa.getParam("Defined")) - && ma.getHostCard() == saHost.getEnchantingCard() - && ma.getPayCosts().hasTapCost()) { - continue; - } + private static boolean isCastBonusTrigger(final Trigger t, final boolean fromTriggersWhenSpent) { + if (t == null || t.getMode() != TriggerType.SpellCast) { + return false; + } + if (fromTriggersWhenSpent) { + return true; + } + return "True".equalsIgnoreCase(t.getParam("OneOff")) + || "True".equalsIgnoreCase(t.getParam("ThisTurn")); + } - // If a manland was previously animated this turn, do not tap it to animate another manland - if (saHost.isLand() && ma.getHostCard().isLand() - && ai.getController().isAI() - && AnimateAi.isAnimatedThisTurn(ai, ma.getHostCard())) { - continue; + private static boolean spellMatchesCastBonusTrigger(final SpellAbility spellBeingPaid, final Trigger t, + final Player ai) { + if (spellBeingPaid == null || t == null || ai == null) { + return false; + } + final Card cast = spellBeingPaid.getHostCard(); + if (cast == null) { + return false; + } + spellBeingPaid.setActivatingPlayer(ai); + if (t.hasParam("ValidActivatingPlayer") && !t.matchesValidParam("ValidActivatingPlayer", ai)) { + return false; + } + if (!t.matchesValidParam("ValidCard", cast)) { + return false; + } + return !t.hasParam("ValidSA") || t.matchesValidParam("ValidSA", spellBeingPaid); + } + + private static int estimateCastBonusWeight(final SpellAbility manaAb, final Trigger t) { + int counterBonus = parseCounterBonusWeight(manaAb, t); + if (counterBonus > 0) { + return counterBonus; + } + final String execute = t.getParam("Execute"); + if (StringUtils.isNotBlank(execute)) { + final Card host = manaAb.getHostCard(); + if (host != null) { + final String executeDef = host.getSVar(execute); + if (executeDef != null && (executeDef.contains("Copy") || executeDef.contains("Scry") + || executeDef.contains("Animate") || executeDef.contains("Keyword"))) { + return CAST_BONUS_COPY_KEYWORD_WEIGHT; } - } else if (sa.getApi() == ApiType.Pump) { - if ((saHost.isInstant() || saHost.isSorcery()) - && ma.getHostCard().isCreature() - && ai.getController().isAI() - && ma.getPayCosts().hasTapCost() - && sa.getTargets().getTargetCards().contains(ma.getHostCard())) { - // do not activate pump instants/sorceries targeting creatures by tapping targeted - // creatures for mana (for example, Servant of the Conduit) - continue; + } + } + return CAST_BONUS_DEFAULT_WEIGHT; + } + + private static int parseCounterBonusWeight(final SpellAbility manaAb, final Trigger t) { + final Card host = manaAb.getHostCard(); + if (host == null) { + return 0; + } + int maxCounters = findPutCounterAmount(host, t.getParam("Execute"), 0, 4); + if (maxCounters <= 0) { + return 0; + } + return maxCounters * CAST_BONUS_P1P1_WEIGHT; + } + + private static int findPutCounterAmount(final Card host, final String sVarName, final int depth, + final int maxDepth) { + if (host == null || StringUtils.isBlank(sVarName) || depth > maxDepth) { + return 0; + } + final String def = host.getSVar(sVarName); + if (StringUtils.isBlank(def)) { + return 0; + } + int max = 0; + for (final String part : TextUtil.split(def, '|')) { + final String trimmed = part.trim(); + if (trimmed.startsWith("DB$ PutCounter")) { + int counterNum = 1; + for (final String token : TextUtil.split(trimmed, '|')) { + if (token.startsWith("CounterNum$")) { + try { + counterNum = Integer.parseInt(token.substring("CounterNum$".length())); + } catch (NumberFormatException ignored) { + counterNum = 1; + } + } } - } else if (sa.getApi() == ApiType.Attach - && "AvoidPayingWithAttachTarget".equals(saHost.getSVar("AIPaymentPreference"))) { - // For cards like Genju of the Cedars, make sure we're not attaching to the same land that will - // be tapped to pay its own cost if there's another untapped land like that available - if (ma.getHostCard().equals(sa.getTargetCard())) { - if (CardLists.count(ai.getCardsIn(ZoneType.Battlefield), CardPredicates.nameEquals(ma.getHostCard().getName()).and(CardPredicates.UNTAPPED)) > 1) { - continue; + max = Math.max(max, counterNum); + } + for (final String token : trimmed.split("\\s+")) { + if (token.startsWith("Execute$") || token.startsWith("ReplacementEffects$") + || token.startsWith("ReplaceWith$") || token.startsWith("SubAbility$")) { + final int idx = token.indexOf('$'); + if (idx >= 0 && idx + 1 < token.length()) { + max = Math.max(max, findPutCounterAmount(host, token.substring(idx + 1), depth + 1, maxDepth)); } } } + } + return max; + } - SpellAbility paymentChoice = ma; + /** Lower return = prefer this source. Zero = neutral; positive = deprioritize. */ + private static int castBonusPreferenceAdjustment(final SpellAbility manaAb, final SpellAbility spellBeingPaid, + final Player ai) { + if (manaAb == null || spellBeingPaid == null || ai == null) { + return 0; + } + final List triggers = resolveCastBonusTriggers(manaAb); + if (triggers.isEmpty()) { + return 0; + } + int bestMatch = 0; + for (final Trigger t : triggers) { + if (spellMatchesCastBonusTrigger(spellBeingPaid, t, ai)) { + bestMatch = Math.min(bestMatch, -estimateCastBonusWeight(manaAb, t)); + } + } + if (bestMatch < 0) { + return bestMatch; + } + return CAST_BONUS_MISMATCH_PENALTY; + } - // Exception: when paying generic mana with Cavern of Souls, prefer the colored mana producing ability - // to attempt to make the spell uncounterable when possible. - if (ComputerUtilAbility.getAbilitySourceName(ma).equals("Cavern of Souls") - && saHost.getType().hasCreatureType(ma.getHostCard().getChosenType())) { - if (toPay == ManaCostShard.COLORLESS && cost.getUnpaidShards().contains(ManaCostShard.GENERIC)) { - // Deprioritize Cavern of Souls, try to pay generic mana with it instead to use the NoCounter ability + /** + * Estimated value of available cast-type bonuses when choosing which spell to cast (simulation). + * Higher = more reason to cast a matching spell now. + */ + public static int estimateCastBonusForSpell(final SpellAbility spellBeingPaid, final Player ai) { + if (spellBeingPaid == null || ai == null) { + return 0; + } + int total = 0; + for (final Card c : ai.getCardsIn(ZoneType.Battlefield)) { + for (final SpellAbility ma : c.getManaAbilities()) { + ma.setActivatingPlayer(ai); + if (!ma.canPlay()) { continue; - } else if (toPay == ManaCostShard.GENERIC || toPay == ManaCostShard.X) { - for (SpellAbility ab : maList) { - if (ab.isManaAbility() && ab.getManaPart().isAnyMana() && ab.hasParam("AddsNoCounter")) { - if (!ab.getHostCard().isTapped()) { - paymentChoice = ab; - break; - } - } - } + } + if (ma.getPayCosts() != null && ma.getPayCosts().hasTapCost() + && (c.isTapped() || AiCardMemory.isRememberedCard(ai, c, MemorySet.PAYS_TAP_COST))) { + continue; + } + final int adj = castBonusPreferenceAdjustment(ma, spellBeingPaid, ai); + if (adj < 0) { + total += -adj; } } + } + return total; + } - if (!canPayShardWithSpellAbility(toPay, ai, paymentChoice, sa, cost, checkCosts, cost.getXManaCostPaidByColor())) { + private static int compareCastBonusPreference(final ManaAbilitySortContext ctx, + final SpellAbility ability1, final SpellAbility ability2) { + if (ctx.spellBeingPaid == null) { + return 0; + } + final int adj1 = castBonusPreferenceAdjustment(ability1, ctx.spellBeingPaid, ctx.ai); + final int adj2 = castBonusPreferenceAdjustment(ability2, ctx.spellBeingPaid, ctx.ai); + if (adj1 != adj2) { + return Integer.compare(adj1, adj2); + } + return 0; + } + + /** + * A source with no mana activation cost that may pay a filter's nested generic cost ({1}, etc.). + * Includes sacrifice rocks; {@link #sortFreeSourcesForNestedActivation} ranks them below tap sources. + */ + private static boolean isFreeManaSourceForNestedActivation(final SpellAbility ma, final Card filterHost) { + if (ma == null || ma.getHostCard() == filterHost) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + return payCosts == null || !payCosts.hasManaCost(); + } + + /** + * True when a free nested-activation source can actually be spent right now (not already consumed + * this payment, and untapped when the ability has a tap cost). + */ + private static boolean isCurrentlyAvailableForNestedActivation(final Player ai, final SpellAbility ma, + final Card filterHost) { + if (!isFreeManaSourceForNestedActivation(ma, filterHost)) { + return false; + } + final Card host = ma.getHostCard(); + if (AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_SAC_COST)) { + return false; + } + if (hasTapCost(ma)) { + if (AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_TAP_COST) || host.isTapped()) { + return false; + } + } + return true; + } + + /** True when this mana ability produces only colorless mana (e.g. Mind Stone, Wastes). */ + private static boolean producesOnlyColorless(final SpellAbility ma) { + final AbilityManaPart mp = ma.getManaPart(); + if (mp == null || mp.isAnyMana() || mp.isComboMana()) { + return false; + } + return "C".equals(mp.mana(ma).trim()); + } + + /** + * True for a host's free {@code {T}:{C}} when the same card also has a paid any-mana filter + * (Study Hall {@code {1},{T}:any} + {@code {T}:{C}}). + */ + private static boolean isFreeColorlessOnAnyManaFilterHost(final SpellAbility ma) { + if (!producesOnlyColorless(ma) || hasManaActivationCost(ma)) { + return false; + } + final Card host = ma.getHostCard(); + if (host == null) { + return false; + } + for (final SpellAbility other : host.getManaAbilities()) { + if (other != ma && isAnyManaConsolidatingFilter(other) && hasManaActivationCost(other)) { + return true; + } + } + return false; + } + + /** + * True when a free (no mana activation cost), reusable ability natively produces this colored shard + * (e.g. Plains for {W}, Forest for {G}). Excludes any-mana and one-shot sources. + */ + private static boolean producesShardDirectly(final SpellAbility ma, final ManaCostShard shard) { + if (ma == null || shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian()) { + return false; + } + if (isDisposableManaAbility(ma)) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + if (payCosts != null && payCosts.hasManaCost()) { + return false; + } + final AbilityManaPart mp = ma.getManaPart(); + if (mp == null || mp.isAnyMana() || mp.isComboMana()) { + return false; + } + return mp.mana(ma).contains(shard.toShortString()); + } + + /** Dedicated 1-mana producer for this colored shard (Forest for {G}, etc.). */ + private static boolean isSinglePipDirectColoredProducer(final SpellAbility ma, final ManaCostShard toPay) { + return producesShardDirectly(ma, toPay) && getManaProducedAmount(ma) == 1; + } + + /** Reusable direct colored source that produces 2+ mana per activation (Gaea's Cradle, etc.). */ + private static boolean isDirectColoredMultiProducer(final SpellAbility ma, final ManaCostShard toPay) { + return isMultiManaProducer(ma) && !isAnyMultiManaProducer(ma) && producesShardDirectly(ma, toPay); + } + + /** Dedicated colored producer (e.g. Plains, Mountain) without a mana activation cost. */ + private static boolean producesColoredManaWithoutFilterCost(final SpellAbility ma) { + if (ma == null || hasManaActivationCost(ma) || isDisposableManaAbility(ma)) { + return false; + } + final AbilityManaPart mp = ma.getManaPart(); + return mp != null && !mp.isAnyMana() && !mp.isComboMana() && !mp.mana(ma).isEmpty() + && !"C".equals(mp.mana(ma).trim()); + } + + /** True when a colored basic has already been tapped (e.g. Plains for {W} before paying generic {1}). */ + private static boolean coloredBasicTappedThisPayment(final Player ai) { + if (ai == null) { + return false; + } + final Set tapCost = AiCardMemory.getMemorySet(ai, MemorySet.PAYS_TAP_COST); + if (tapCost != null) { + for (final Card c : tapCost) { + if (isColoredBasicLand(c)) { + return true; + } + } + } + for (final Card c : ai.getCardsIn(ZoneType.Battlefield)) { + if (c.isTapped() && isColoredBasicLand(c)) { + return true; + } + } + return false; + } + + private static boolean isColoredBasicLand(final Card c) { + if (c == null || !c.isLand()) { + return false; + } + for (final SpellAbility ma : c.getManaAbilities()) { + if (producesColoredManaWithoutFilterCost(ma)) { + return true; + } + } + return false; + } + + /** True when hand/command still holds a spell whose mana cost uses two or more colors. */ + static boolean handHasMulticolorManaSpells(final Player ai, final SpellAbility spellBeingPaid, + final ManaPaymentContext ctx) { + if (ctx != null && spellBeingPaid.getHostCard() != null + && ctx.caches.handProbeSpellId == spellBeingPaid.getHostCard().getId() + && ctx.caches.handHasMulticolorManaSpells != null) { + return ctx.caches.handHasMulticolorManaSpells; + } + final boolean result = handHasMulticolorManaSpellsUncached(ai, spellBeingPaid); + if (ctx != null && spellBeingPaid.getHostCard() != null) { + ctx.caches.handProbeSpellId = spellBeingPaid.getHostCard().getId(); + ctx.caches.handHasMulticolorManaSpells = result; + } + return result; + } + + private static boolean handHasMulticolorManaSpellsUncached(final Player ai, final SpellAbility spellBeingPaid) { + final Card being = spellBeingPaid.getHostCard(); + for (final ZoneType zone : HAND_AND_COMMAND) { + for (Card c : ai.getCardsIn(zone)) { + if (c == being) { + continue; + } + for (SpellAbility candSa : c.getSpellAbilities()) { + if (!candSa.isSpell() || candSa.getPayCosts() == null || !candSa.getPayCosts().hasManaCost()) { + continue; + } + final CostPartMana costMana = candSa.getPayCosts().getCostMana(); + if (costMana == null) { + continue; + } + final ManaCost mc = costMana.getMana(); + if (ColorSet.fromManaCost(mc).countColors() >= 2) { + return true; + } + } + } + } + return false; + } + + /** + * True when hand/command still holds a cast with both generic and colored mana (e.g. Phelia {@code {1}{W}}), + * so a multi-color signet should stay available. + */ + private static boolean handHasGenericAndColoredCast(final Player ai, final SpellAbility spellBeingPaid, + final ManaPaymentContext ctx) { + if (ctx != null && spellBeingPaid.getHostCard() != null + && ctx.caches.handProbeSpellId == spellBeingPaid.getHostCard().getId() + && ctx.caches.handHasGenericAndColoredCast != null) { + return ctx.caches.handHasGenericAndColoredCast; + } + final boolean result = handHasGenericAndColoredCastUncached(ai, spellBeingPaid); + if (ctx != null && spellBeingPaid.getHostCard() != null) { + ctx.caches.handProbeSpellId = spellBeingPaid.getHostCard().getId(); + ctx.caches.handHasGenericAndColoredCast = result; + } + return result; + } + + private static boolean handHasGenericAndColoredCastUncached(final Player ai, final SpellAbility spellBeingPaid) { + final Card being = spellBeingPaid.getHostCard(); + for (final ZoneType zone : HAND_AND_COMMAND) { + for (Card c : ai.getCardsIn(zone)) { + if (c == being) { + continue; + } + final ManaCost mc = c.getManaCost(); + if (mc != null && !mc.isNoCost() && mc.getGenericCost() > 0 + && ColorSet.fromManaCost(mc).countColors() >= 1) { + return true; + } + } + } + return false; + } + + /** + * True for reusable, free sources that can pay this colored shard: dedicated lands, Arcane Signet + * (commander color identity), etc. Excludes one-shot mana and any-mana filters (Study Hall {@code {1},{T}:any}). + */ + private static boolean isReusableFreeManaForShard(final SpellAbility ma, final ManaCostShard shard) { + if (producesShardDirectly(ma, shard)) { + return true; + } + if (ma == null || shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian()) { + return false; + } + if (isDisposableManaAbility(ma) || isAnyManaConsolidatingFilter(ma)) { + return false; + } + final Cost payCosts = ma.getPayCosts(); + if (payCosts != null && payCosts.hasManaCost()) { + return false; + } + final AbilityManaPart mp = ma.getManaPart(); + if (mp == null) { + return false; + } + return mp.canProduce(shard.toShortString(), ma); + } + + /** + * How generic mana pips should be paid relative to colorless vs colored sources. + * Lower {@link #rankGenericManaSource} ranks are better. + */ + enum GenericColorPreference { + /** Colorless rocks carry generic; colored basics are acceptable fallback. */ + DEFAULT, + /** Every colored pip has a reusable producer — spend {C} on generic, keep colored basics. */ + PREFER_COLORLESS, + /** Hand still needs dedicated {C} pips (Eldrazi, etc.) — save rocks, spend colored on generic. */ + RESERVE_COLORLESS; + + boolean reservesColorless() { + return this == RESERVE_COLORLESS; + } + } + + private static GenericColorPreference genericColorPreference(final Player ai, final SpellAbility sa, + final ManaCostBeingPaid cost, final int coloredShardCount, + final ListMultimap sourcesForShards, + final ManaPaymentContext paymentCtx) { + if (shouldReserveColorlessMana(ai, sa)) { + return GenericColorPreference.RESERVE_COLORLESS; + } + if (coloredShardCount > 0 + && hasReusableFreeProducerForEveryColoredShard(cost, ai, paymentCtx) + && genericBucketHasColorlessSource(sourcesForShards)) { + return GenericColorPreference.PREFER_COLORLESS; + } + // Colored shards may already be paid; still tap {C} before another colored basic for generic. + if (genericBucketHasColorlessSource(sourcesForShards) && !hasUnpaidColoredShards(cost)) { + return GenericColorPreference.PREFER_COLORLESS; + } + return GenericColorPreference.DEFAULT; + } + + /** Preference for paying generic shards this iteration (colored-basic-already-tapped wins over reserve). */ + private static GenericColorPreference resolveGenericColorPreference(final Player ai, final SpellAbility sa) { + if (coloredBasicTappedThisPayment(ai)) { + return GenericColorPreference.PREFER_COLORLESS; + } + if (shouldReserveColorlessMana(ai, sa)) { + return GenericColorPreference.RESERVE_COLORLESS; + } + return GenericColorPreference.DEFAULT; + } + + private static int compareGenericCandidatesForPayment(final SpellAbility a, final SpellAbility b, + final GenericColorPreference pref, final int unpaidGeneric, final SpellAbility spellBeingPaid, + final Player ai) { + if (spellBeingPaid != null && ai != null) { + final int bonusCmp = castBonusPreferenceAdjustment(a, spellBeingPaid, ai) + - castBonusPreferenceAdjustment(b, spellBeingPaid, ai); + if (bonusCmp != 0) { + return bonusCmp; + } + } + if (unpaidGeneric >= 2) { + final boolean noUntap1 = doesNotUntapNormally(a); + final boolean noUntap2 = doesNotUntapNormally(b); + if (noUntap1 != noUntap2) { + return noUntap1 ? 1 : -1; + } + final boolean multi1 = isMultiManaProducer(a); + final boolean multi2 = isMultiManaProducer(b); + if (multi1 != multi2) { + return multi1 ? -1 : 1; + } + if (multi1) { + return Integer.compare(getManaProducedAmount(b), getManaProducedAmount(a)); + } + if (pref == GenericColorPreference.RESERVE_COLORLESS) { + final boolean rock1 = isFreeColorlessManaRock(a); + final boolean rock2 = isFreeColorlessManaRock(b); + if (rock1 != rock2) { + return rock1 ? -1 : 1; + } + } + } + final int rankCmp = rankGenericManaSource(a, pref) - rankGenericManaSource(b, pref); + if (rankCmp != 0) { + return rankCmp; + } + if (isDisposableManaAbility(a) && isDisposableManaAbility(b)) { + final int disposableCmp = compareDisposableCandidates(a, b, unpaidGeneric); + if (disposableCmp != 0) { + return disposableCmp; + } + } + final int filterCostCmp = compareAnyManaFilterActivationCost(a, b); + if (filterCostCmp != 0) { + return filterCostCmp; + } + final boolean land1 = a.getHostCard().isLand(); + final boolean land2 = b.getHostCard().isLand(); + if (land1 != land2) { + return land1 ? 1 : -1; + } + return 0; + } + + /** Untapped colorless rock with no mana activation cost (Reliquary Tower, Sol Ring, etc.). */ + private static boolean isFreeColorlessManaRock(final SpellAbility ma) { + return producesOnlyColorless(ma) && !hasManaActivationCost(ma) && !isDisposableManaAbility(ma); + } + + /** + * Preference rank for paying a generic mana pip. Lower is better. + */ + static int rankGenericManaSource(final SpellAbility ma, final GenericColorPreference pref) { + if (isDisposableManaAbility(ma)) { + if (sacrificesOtherPermanentsForMana(ma)) { + return 55; + } + if (isSelfSacrificeCreatureMana(ma)) { + return 54; + } + return isMultiManaDisposable(ma) ? 48 : 50; + } + if (requiresTappingOtherCreatureForMana(ma)) { + return 49; + } + if (doesNotUntapNormally(ma)) { + return 44; + } + if (isManaReserveHost(ma.getHostCard())) { + return 45; + } + final Cost payCosts = ma.getPayCosts(); + final boolean hasManaCost = payCosts != null && payCosts.hasManaCost(); + if (producesOnlyColorless(ma) && !hasManaCost) { + if (pref == GenericColorPreference.PREFER_COLORLESS) { + // Preserve Study Hall-style hosts for their paid any-mana filter; spend plain {C} lands first. + if (isFreeColorlessOnAnyManaFilterHost(ma)) { + return ma.getHostCard().isLand() ? 12 : 0; + } + return ma.getHostCard().isLand() ? 10 : 0; + } + if (pref == GenericColorPreference.RESERVE_COLORLESS) { + return 30; + } + return ma.getHostCard().isLand() ? 15 : 0; + } + if (hasManaCost) { + final int netLoss = netNegativeAnyManaFilterLoss(ma); + return netLoss > 0 ? 40 + netLoss : 40; + } + final AbilityManaPart mp = ma.getManaPart(); + if (mp != null && mp.isAnyMana()) { + return pref.reservesColorless() ? 10 : 20; + } + if (mp != null && mp.isComboMana() && !producesOnlyColorless(ma)) { + return pref.reservesColorless() ? 10 : 20; + } + if (pref == GenericColorPreference.PREFER_COLORLESS && producesColoredManaWithoutFilterCost(ma)) { + return 25; + } + return pref.reservesColorless() ? 0 : 10; + } + + /** + * True when other castable cards in hand or command zone need dedicated {@code {C}} pips, so colorless rocks + * should be saved for those costs rather than spent on generic mana. + */ + private static boolean shouldReserveColorlessMana(final Player ai, final SpellAbility sa) { + if (ai == null) { + return false; + } + final CardCollection remaining = new CardCollection(ai.getCardsIn(ZoneType.Hand)); + remaining.addAll(ai.getCardsIn(ZoneType.Command)); + remaining.remove(sa.getHostCard()); + return AiDeckStatistics.fromCards(remaining).maxPips[COLORLESS_PIP_INDEX] > 0; + } + + /** Reusable sources first; sacrifice / one-shot mana remains available as a fallback. */ + private static void sortFreeSourcesForNestedActivation(final List candidates, + final ManaCostShard toPay, final boolean reserveColorless) { + candidates.sort((a1, a2) -> { + if (toPay == ManaCostShard.GENERIC || toPay == ManaCostShard.X) { + final int colorlessCmp = compareColorlessPreference(a1, a2, reserveColorless); + if (colorlessCmp != 0) { + return colorlessCmp; + } + } + final int t1 = nestedActivationSourceTier(a1); + final int t2 = nestedActivationSourceTier(a2); + if (t1 != t2) { + return Integer.compare(t1, t2); + } + if (isDisposableManaAbility(a1) && isDisposableManaAbility(a2)) { + final int disposableCmp = compareDisposableCandidates(a1, a2, 1); + if (disposableCmp != 0) { + return disposableCmp; + } + } + return 0; + }); + } + + /** Reusable (0) before creature-tap sources like Springleaf Drum (1) before disposables (2). */ + private static int nestedActivationSourceTier(final SpellAbility ma) { + if (isDisposableManaAbility(ma)) { + return 2; + } + return requiresTappingOtherCreatureForMana(ma) ? 1 : 0; + } + + private static int compareColorlessPreference(final SpellAbility a1, final SpellAbility a2, + final boolean reserveColorless) { + final boolean c1 = producesOnlyColorless(a1); + final boolean c2 = producesOnlyColorless(a2); + if (c1 == c2) { + return 0; + } + return reserveColorless ? (c1 ? 1 : -1) : (c1 ? -1 : 1); + } + + private static int getComboManaAmount(final SpellAbility ability) { + if (ability == null) { + return 0; + } + final AbilityManaPart mp = ability.getManaPart(); + if (mp == null || !mp.isComboMana()) { + return 0; + } + return ability.hasParam("Amount") + ? AbilityUtils.calculateAmount(ability.getHostCard(), ability.getParam("Amount"), ability) : 1; + } + + private static int getManaProducedAmount(final SpellAbility ability) { + return ability == null ? 0 : ability.amountOfManaGenerated(true); + } + + /** Reusable source that produces exactly {@code remaining} mana with no filter activation cost. */ + private static boolean isTightGenericProducer(final SpellAbility ma, final int remaining) { + if (ma == null || remaining <= 0 || hasManaActivationCost(ma) || isDisposableManaAbility(ma)) { + return false; + } + return getManaProducedAmount(ma) == remaining; + } + + /** One activation adds two or more mana (Sol Ring, Gilded Lotus, etc.) without a mana activation cost. */ + private static boolean isMultiManaProducer(final SpellAbility ma) { + if (ma == null || hasManaActivationCost(ma) || isDisposableManaAbility(ma)) { + return false; + } + if (isMultiPipActivationFilter(ma) || isMultiManaComboAbility(ma)) { + return false; + } + return getManaProducedAmount(ma) >= 2; + } + + /** Gilded Lotus-style: any one color, two or more mana per activation. */ + static boolean isAnyMultiManaProducer(final SpellAbility ma) { + if (!isMultiManaProducer(ma)) { + return false; + } + final AbilityManaPart mp = ma.getManaPart(); + return mp != null && mp.isAnyMana(); + } + + private static boolean isConsolidatingCandidate(final SpellAbility ma) { + return isMultiPipActivationFilter(ma) || isMultiManaComboAbility(ma) || isAnyMultiManaProducer(ma); + } + + private static boolean hasAlternativeExcept(final List alternatives, final SpellAbility skip, + final java.util.function.Predicate predicate) { + for (final SpellAbility ma : alternatives) { + if (ma != skip && predicate.test(ma)) { + return true; + } + } + return false; + } + + private enum FilterKind { NONE, MULTI_SHARD, ANY_MANA, COMBO_MULTI } + + /** Precomputed mana-source characteristics for sort and efficiency scoring. */ + private static final class ManaSourceTraits { + final SpellAbility ability; + final FilterKind filterKind; + final int producedAmount; + final int genericRank; + private Boolean consolidates; + + private ManaSourceTraits(final SpellAbility ability, final FilterKind filterKind, final int producedAmount, + final int genericRank) { + this.ability = ability; + this.filterKind = filterKind; + this.producedAmount = producedAmount; + this.genericRank = genericRank; + } + + static ManaSourceTraits of(final SpellAbility ma, final ManaAbilitySortContext ctx) { + FilterKind kind = FilterKind.NONE; + if (isComboConsolidatingFilter(ma)) { + kind = FilterKind.COMBO_MULTI; + } else if (isMultiPipActivationFilter(ma)) { + kind = FilterKind.MULTI_SHARD; + } else if (isAnyManaConsolidatingFilter(ma)) { + kind = FilterKind.ANY_MANA; + } + return new ManaSourceTraits(ma, kind, getManaProducedAmount(ma), + rankGenericManaSource(ma, ctx.genericColorPref)); + } + + boolean tightFor(final int remaining) { + return isTightGenericProducer(ability, remaining); + } + + boolean directFor(final ManaCostShard shard) { + return producesShardDirectly(ability, shard); + } + + boolean consolidates(final ManaAbilitySortContext ctx) { + if (consolidates == null) { + consolidates = consolidatesFilter(ctx.ai, ability, ctx.manaAbilityMap); + } + return consolidates; + } + } + + /** Flags from a single scan of alternative mana sources when scoring efficiency. */ + private static final class AlternativeScanFlags { + boolean hasMultiShardAlt; + boolean hasMultiManaAlt; + boolean hasMultiManaDisposableAlt; + boolean hasSelfSacDisposableAlt; + boolean hasDirectColoredAlt; + boolean hasSinglePipDirectColoredAlt; + boolean hasTightGenericAlt; + boolean hasAnyMultiAlt; + boolean hasReusableNonCreatureTapAlt; + boolean hasReusableUntappingAlt; + boolean hasCheaperAnyManaFilterAlt; + + static AlternativeScanFlags scan(final List alternatives, final SpellAbility skip, + final ManaCostShard toPay, final int remaining) { + final AlternativeScanFlags flags = new AlternativeScanFlags(); + for (final SpellAbility ma : alternatives) { + if (ma == skip) { + continue; + } + if (isMultiPipActivationFilter(ma) || isComboConsolidatingFilter(ma)) { + flags.hasMultiShardAlt = true; + } + if (isMultiManaProducer(ma) && !isAnyMultiManaProducer(ma) + && getManaProducedAmount(ma) >= remaining) { + flags.hasMultiManaAlt = true; + } + if (isMultiManaDisposable(ma) && getManaProducedAmount(ma) >= remaining) { + flags.hasMultiManaDisposableAlt = true; + } + if (isDisposableManaAbility(ma) && !sacrificesOtherPermanentsForMana(ma)) { + flags.hasSelfSacDisposableAlt = true; + } + if (producesShardDirectly(ma, toPay)) { + flags.hasDirectColoredAlt = true; + if (isSinglePipDirectColoredProducer(ma, toPay)) { + flags.hasSinglePipDirectColoredAlt = true; + } + } + if (isTightGenericProducer(ma, remaining)) { + flags.hasTightGenericAlt = true; + } + if (isAnyMultiManaProducer(ma)) { + flags.hasAnyMultiAlt = true; + } + if (!isDisposableManaAbility(ma) && !requiresTappingOtherCreatureForMana(ma)) { + flags.hasReusableNonCreatureTapAlt = true; + } + if (!isDisposableManaAbility(ma) && !requiresTappingOtherCreatureForMana(ma) + && !doesNotUntapNormally(ma)) { + flags.hasReusableUntappingAlt = true; + } + if (isAnyManaConsolidatingFilter(ma) && isAnyManaConsolidatingFilter(skip) + && getFilterActivationCMC(ma) < getFilterActivationCMC(skip)) { + flags.hasCheaperAnyManaFilterAlt = true; + } + } + return flags; + } + } + + private static int countUnpaidPips(final ManaCostBeingPaid cost) { + int n = cost.getGenericManaAmount(); + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS && !shard.isPhyrexian()) { + n += cost.getUnpaidShards(shard); + } + } + return n; + } + + private static int remainingPipsForShard(final ManaCostBeingPaid cost, final ManaCostShard toPay) { + if (toPay == null) { + return 0; + } + if (toPay.isGeneric() || toPay == ManaCostShard.X) { + return cost.getGenericManaAmount(); + } + if (toPay == ManaCostShard.COLORLESS) { + return cost.getUnpaidShards(ManaCostShard.COLORLESS); + } + return cost.getUnpaidShards(toPay); + } + + /** + * True when one activation produces two or more combo mana (e.g. Cascade Bluffs, Starlight Cairn + * Metalcraft {@code {T}: Add three mana in any combination of colors}). + */ + private static boolean isMultiManaComboAbility(final SpellAbility ability) { + return getComboManaAmount(ability) >= 2; + } + + /** Signet-style or combo-land filter with a mana activation cost (e.g. Rakdos Signet, Cascade Bluffs). */ + private static boolean isManaActivationConsolidator(final SpellAbility ma) { + if (ma == null || ma.getPayCosts() == null || !ma.getPayCosts().hasManaCost()) { + return false; + } + return isNetPositiveConsolidator(ma) || isComboConsolidatingFilter(ma); + } + + /** True when {@code host} has a combo-land mana mode (e.g. Cascade Bluffs' {@code {U/R},{T}:...}). */ + private static boolean hostHasComboConsolidator(final Card host) { + if (host == null) { + return false; + } + for (final SpellAbility ma : host.getManaAbilities()) { + if (isComboConsolidatingFilter(ma)) { + return true; + } + } + return false; + } + + /** + * True when one activation of this combo filter produces two or more mana (e.g. Cascade Bluffs + * {@code {U/R},{T}: Add {U}{U}, {U}{R}, or {R}{R}}). + */ + private static boolean isComboConsolidatingFilter(final SpellAbility ability) { + if (!isMultiManaComboAbility(ability)) { + return false; + } + final Cost payCosts = ability.getPayCosts(); + return payCosts != null && payCosts.hasManaCost(); + } + + /** + * True when one activation of this filter produces multiple colored mana at once (e.g. Boros Signet + * {@code Produced$ R W}). {@code Produced$ Any} filters such as Study Hall add only one mana per + * activation and must not receive the multi-shard consolidation bonus. + */ + private static boolean isMultiShardConsolidatingFilter(final SpellAbility ability) { + if (ability.getPayCosts() == null || !ability.getPayCosts().hasManaCost()) { + return false; + } + final AbilityManaPart mp = ability.getManaPart(); + if (mp == null || mp.isAnyMana() || mp.isComboMana()) { + return false; + } + return mp.mana(ability).split(" ").length >= 2; + } + + /** + * Single-color filter that produces two or more mana per activation (e.g. Cabal Coffers + * {@code Amount$ X} with {@code Produced$ B}). + */ + private static boolean isVariableAmountConsolidatingFilter(final SpellAbility ability) { + if (!hasManaActivationCost(ability)) { + return false; + } + final AbilityManaPart mp = ability.getManaPart(); + if (mp == null || mp.isAnyMana() || mp.isComboMana()) { + return false; + } + if (mp.mana(ability).split(" ").length >= 2) { + return false; + } + return getManaProducedAmount(ability) >= 2; + } + + /** Multi-shard signets or variable-amount filters that apply multiple pips per activation. */ + private static boolean isMultiPipActivationFilter(final SpellAbility ability) { + return isMultiShardConsolidatingFilter(ability) || isVariableAmountConsolidatingFilter(ability); + } + + /** + * Any-mana filter with a mana activation cost (Study Hall), distinct from multi-mana signets that + * produce two colored mana per activation. + */ + private static boolean isAnyManaConsolidatingFilter(final SpellAbility ability) { + if (ability.getPayCosts() == null || !ability.getPayCosts().hasManaCost()) { + return false; + } + final AbilityManaPart mp = ability.getManaPart(); + return mp != null && mp.isAnyMana() && !isMultiShardConsolidatingFilter(ability) + && !isComboConsolidatingFilter(ability); + } + + /** Lower is better. Disposable sources are heavily penalized so signets beat Lotus Petal for colored pips. */ + private static int paymentEfficiencyScore(final SpellAbility chosen, final int consumedCount, + final ManaCostBeingPaid cost, final ManaCostShard toPay, final List alternatives, + final Player ai, final SpellAbility sa, final ManaPaymentContext ctx) { + int score = consumedCount; + final int castBonusAdj = castBonusPreferenceAdjustment(chosen, sa, ai); + final int remaining = remainingPipsForShard(cost, toPay); + final AlternativeScanFlags altFlags = AlternativeScanFlags.scan(alternatives, chosen, toPay, remaining); + if (isDisposableManaAbility(chosen) && castBonusAdj >= 0) { + if (altFlags.hasMultiShardAlt || !disposableIsReasonableForShard(chosen, cost, toPay, alternatives, ai, ctx)) { + score += 100; + } + } + if (sacrificesOtherPermanentsForMana(chosen) && altFlags.hasSelfSacDisposableAlt) { + score += 75; + } + if (requiresTappingOtherCreatureForMana(chosen) && castBonusAdj >= 0 + && altFlags.hasReusableNonCreatureTapAlt) { + score += 40; + } + if (doesNotUntapNormally(chosen) && castBonusAdj >= 0 && altFlags.hasReusableUntappingAlt) { + score += 60; + } + if (netNegativeAnyManaFilterLoss(chosen) > 0 && altFlags.hasCheaperAnyManaFilterAlt) { + score += 25 * netNegativeAnyManaFilterLoss(chosen); + } + if (isAnyManaConsolidatingFilter(chosen) && cost.getGenericManaAmount() > 0 && !toPay.isGeneric()) { + score += 50; + } + if (remaining >= 2 && getManaProducedAmount(chosen) < remaining && altFlags.hasMultiManaAlt + && !altFlags.hasSinglePipDirectColoredAlt) { + score += 50; + } + if (remaining >= 2 && isDisposableManaAbility(chosen) && getManaProducedAmount(chosen) < remaining + && altFlags.hasMultiManaDisposableAlt) { + score += 50; + } + if (isAnyMultiManaProducer(chosen) && getManaProducedAmount(chosen) > remaining + && altFlags.hasDirectColoredAlt) { + score += 25 * (getManaProducedAmount(chosen) - remaining); + } + if (isDirectColoredMultiProducer(chosen, toPay) && getManaProducedAmount(chosen) > remaining + && altFlags.hasSinglePipDirectColoredAlt) { + score += 25 * (getManaProducedAmount(chosen) - remaining); + } + if ((toPay.isGeneric() || toPay == ManaCostShard.X) + && getManaProducedAmount(chosen) > remaining && altFlags.hasTightGenericAlt) { + score += 25 * (getManaProducedAmount(chosen) - remaining); + } + if ((toPay.isGeneric() || toPay == ManaCostShard.X) && producesColoredManaWithoutFilterCost(chosen) + && handHasMulticolorManaSpells(ai, sa, ctx) && altFlags.hasAnyMultiAlt) { + score += 50; + } + if (castBonusAdj != 0) { + score += castBonusAdj; + } + return score; + } + + /** + * Disposables are a last resort. Sacrificing a Petal/Treasure is only reasonable when no reusable + * free producer exists for this shard and every any-mana filter alternative either cannot be + * activated without a disposable or would strand the rest of the spell cost. + */ + private static boolean disposableIsReasonableForShard(final SpellAbility disposable, + final ManaCostBeingPaid cost, final ManaCostShard toPay, final List alternatives, + final Player ai, final ManaPaymentContext ctx) { + if (!isDisposableManaAbility(disposable) || toPay.isGeneric()) { + return false; + } + if (cost.getGenericManaAmount() == 0) { + return hasReusableFreeProducerForEveryOtherColoredShard(cost, toPay, ai, ctx); + } + if (hasAlternativeExcept(alternatives, disposable, ma -> isReusableFreeManaForShard(ma, toPay))) { + return false; + } + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, true, ctx); + final List filterAlts = consolidatingFilterAlternatives(alternatives, disposable); + if (filterAlts.isEmpty()) { + return false; + } + return filterAlts.stream().noneMatch(ma -> consolidatorBeatsDisposable(ma, cost, ai, manaAbilityMap, ctx)); + } + + private static List consolidatingFilterAlternatives(final List alternatives, + final SpellAbility disposable) { + return alternatives.stream() + .filter(ma -> ma != disposable && (isAnyManaConsolidatingFilter(ma) + || isMultiPipActivationFilter(ma) || isComboConsolidatingFilter(ma))) + .collect(Collectors.toList()); + } + + private static int getFilterActivationCMC(final SpellAbility filter) { + if (filter.getPayCosts() == null || !filter.getPayCosts().hasManaCost()) { + return 0; + } + final CostPartMana costMana = filter.getPayCosts().getCostMana(); + return costMana == null ? 0 : costMana.getMana().getCMC(); + } + + /** Mana lost per activation on any-mana filters that produce less than their activation cost (Signpost {2}: any). */ + private static int netNegativeAnyManaFilterLoss(final SpellAbility ma) { + if (!isAnyManaConsolidatingFilter(ma) || isNetPositiveConsolidator(ma)) { + return 0; + } + return Math.max(0, getFilterActivationCMC(ma) - getManaProducedAmount(ma)); + } + + /** Among any-mana filters, prefer lower activation costs ({1} Study Hall over {2} Signpost). */ + private static int compareAnyManaFilterActivationCost(final SpellAbility a, final SpellAbility b) { + if (!isAnyManaConsolidatingFilter(a) || !isAnyManaConsolidatingFilter(b)) { + return 0; + } + final int c1 = getFilterActivationCMC(a); + final int c2 = getFilterActivationCMC(b); + if (c1 != c2) { + return Integer.compare(c1, c2); + } + return 0; + } + + /** Produced mana exceeds the filter's activation cost (e.g. Signet {1} -> {G}{W}). */ + private static boolean isNetPositiveConsolidator(final SpellAbility filter) { + return isConsolidatingCandidate(filter) && hasManaActivationCost(filter) + && getManaProducedAmount(filter) > getFilterActivationCMC(filter); + } + + /** + * Keep a multi-shard signet available when the spell being paid is generic-only but hand/command + * still needs a colored pip that only this consolidator can supply (see + * {@code genericCostPreservesSignetForHandSpell}). Mono-color hand spells with another producer + * for that color (e.g. Eternal Witness with a Forest while Sungrass Prairie is on board) do not + * reserve the consolidator. + */ + private static boolean shouldReserveConsolidator(final SpellAbility filter, final SpellAbility sa, + final ManaCostBeingPaid cost, final Player ai, final ManaPaymentContext ctx) { + if (!isMultiPipActivationFilter(filter) || cost.getGenericManaAmount() <= 0) { + return false; + } + int coloredUnpaid = countUnpaidPips(cost) - cost.getGenericManaAmount(); + if (coloredUnpaid > 0) { + return false; + } + return handHasSpellDependingOnConsolidator(filter, sa, ai, ctx); + } + + private static boolean handHasSpellDependingOnConsolidator(final SpellAbility filter, final SpellAbility sa, + final Player ai, final ManaPaymentContext ctx) { + final Card consolidatorHost = filter.getHostCard(); + final Set consolidatorColors = getColoredShardsProducedByConsolidator(filter); + if (consolidatorColors.isEmpty()) { + return false; + } + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, true, ctx); + final Card being = sa.getHostCard(); + for (final ZoneType zone : HAND_AND_COMMAND) { + for (Card c : ai.getCardsIn(zone)) { + if (c == being) { + continue; + } + if (spellDependsOnConsolidatorForColor(c, consolidatorColors, consolidatorHost, manaAbilityMap, ai)) { + return true; + } + } + } + return false; + } + + private static Set getColoredShardsProducedByConsolidator(final SpellAbility filter) { + final Set result = new HashSet<>(); + final AbilityManaPart mp = filter.getManaPart(); + if (mp == null) { + return result; + } + for (final String color : mp.mana(filter).split(" ")) { + if (color.isEmpty() || "C".equals(color)) { + continue; + } + result.add(ManaCostShard.valueOf(ManaAtom.fromName(color))); + } + return result; + } + + private static boolean spellDependsOnConsolidatorForColor(final Card handSpell, + final Set consolidatorColors, final Card consolidatorHost, + final ListMultimap manaAbilityMap, final Player ai) { + final ManaCost mc = handSpell.getManaCost(); + if (mc == null || mc.isNoCost()) { + return false; + } + for (final ManaCostShard needed : mc) { + if (needed.isGeneric() || needed == ManaCostShard.COLORLESS || needed.isPhyrexian()) { + continue; + } + if (!consolidatorProducesShard(needed, consolidatorColors)) { + continue; + } + if (!hasOtherReusableProducerForShard(manaAbilityMap, needed, consolidatorHost, ai)) { + // A disposable (Petal, Treasure) can still cover the hand spell's pip; use the + // consolidator on the spell being paid instead of hoarding it. + if (!hasDisposableProducerForShard(manaAbilityMap, needed, consolidatorHost, ai)) { + return true; + } + } + } + return false; + } + + private static boolean hasDisposableProducerForShard( + final ListMultimap manaAbilityMap, final ManaCostShard shard, + final Card excludeHost, final Player ai) { + for (final byte color : ManaAtom.MANATYPES) { + if (!shard.canBePaidWithManaOfColor(color)) { + continue; + } + for (final SpellAbility ma : manaAbilityMap.get((int) color)) { + if (ma.getHostCard() == excludeHost || !isDisposableManaAbility(ma)) { + continue; + } + if (AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_SAC_COST)) { + continue; + } + final AbilityManaPart mp = ma.getManaPart(); + if (mp != null && (mp.isAnyMana() || mp.canProduce(shard.toShortString(), ma))) { + return true; + } + } + } + return false; + } + + private static boolean consolidatorProducesShard(final ManaCostShard needed, + final Set consolidatorColors) { + for (final ManaCostShard produced : consolidatorColors) { + if (needed == produced) { + return true; + } + } + return false; + } + + private static boolean hasOtherReusableProducerForShard( + final ListMultimap manaAbilityMap, final ManaCostShard shard, + final Card excludeHost, final Player ai) { + for (final byte color : ManaAtom.MANATYPES) { + if (!shard.canBePaidWithManaOfColor(color)) { + continue; + } + for (final SpellAbility ma : manaAbilityMap.get((int) color)) { + if (ma.getHostCard() == excludeHost) { + continue; + } + if (!isReusableFreeManaForShard(ma, shard)) { + continue; + } + if (hasTapCost(ma) + && AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_TAP_COST)) { + continue; + } + if (AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_SAC_COST)) { + continue; + } + return true; + } + } + return false; + } + + private static ManaCostShard shardForConsolidatorProbe(final ManaCostBeingPaid cost) { + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS && !shard.isPhyrexian() + && cost.getUnpaidShards(shard) > 0) { + return shard; + } + } + return ManaCostShard.GENERIC; + } + + /** + * True when a net-positive consolidator can pay the entire remaining spell cost (including nested + * activation), e.g. Lotus Petal -> Signet {1} -> {G}{W} for {1}{W}. + */ + private static boolean consolidatorCoversSpellCost(final SpellAbility filter, final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final ManaPaymentContext ctx) { + if (!isNetPositiveConsolidator(filter) || shouldReserveConsolidator(filter, sa, cost, ai, ctx)) { + return false; + } + if (!canActivateFilter(ai, filter, getOrBuildManaAbilityMap(ai, true, ctx), false)) { + return false; + } + final Set consumed = collectCardsConsumedByPayment(filter, sa, ai, ctx); + if (consumed == null) { + return false; + } + final ManaCostShard probeShard = shardForConsolidatorProbe(cost); + refreshExpressChoice(cost, sa, ai, probeShard, filter); + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + applyChosenPaymentToCostProbe(probe, filter, ai, probeShard); + return probe.isPaid(); + } + + private static boolean consolidatorCoversRemainingCost(final Collection maList, + final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, + final ManaPaymentContext ctx) { + for (final SpellAbility other : maList) { + if (isDisposableManaAbility(other) || !isConsolidatingCandidate(other)) { + continue; + } + if (consolidatorCoversSpellCost(other, cost, sa, ai, ctx)) { + return true; + } + } + return false; + } + + /** Study Hall-style filter is preferable to sacrificing a disposable. */ + private static boolean anyManaFilterBeatsDisposable(final SpellAbility filter, + final ManaCostBeingPaid cost, final Player ai, + final ListMultimap manaAbilityMap, final ManaPaymentContext ctx) { + return canActivateFilter(ai, filter, manaAbilityMap, true) + && !filterActivationCompetesForSpellGeneric(filter, cost, ai, ctx); + } + + private static boolean consolidatorBeatsDisposable(final SpellAbility filter, final ManaCostBeingPaid cost, + final Player ai, final ListMultimap manaAbilityMap, + final ManaPaymentContext ctx) { + if (isAnyManaConsolidatingFilter(filter)) { + return anyManaFilterBeatsDisposable(filter, cost, ai, manaAbilityMap, ctx); + } + return canActivateFilter(ai, filter, manaAbilityMap, false); + } + + /** + * True when the filter's host also has a free colorless mana ability (Study Hall {@code {T}:{C}}) that + * can pay the spell's generic pips without the filter's paid activation. + */ + private static boolean hasFreeColorlessManaAbilityOnHost(final SpellAbility filter) { + final Card host = filter == null ? null : filter.getHostCard(); + if (host == null) { + return false; + } + for (final SpellAbility ma : host.getManaAbilities()) { + if (ma == filter || !producesOnlyColorless(ma) || hasManaActivationCost(ma)) { + continue; + } + return true; + } + return false; + } + + /** + * True when paying this filter's activation would consume reusable generic mana sources needed + * to pay the spell's own generic pips (e.g. one Plains cannot fund Study Hall's {@code {1}} and + * the spell's {@code {1}}). {@code {1}} accepts any mana; {@code {C}} sources count too. + * + * Also true when the host has a free {@code {C}} ability that should pay generic directly — using + * the filter's paid any-mana line for a colored pip taps the host and strands that path. + */ + private static boolean filterActivationCompetesForSpellGeneric(final SpellAbility filter, + final ManaCostBeingPaid cost, final Player ai, final ManaPaymentContext ctx) { + if (cost.getGenericManaAmount() <= 0 || ai == null) { + return false; + } + if (isAnyManaConsolidatingFilter(filter) && hasFreeColorlessManaAbilityOnHost(filter)) { + return true; + } + final CostPartMana costMana = filter.getPayCosts().getCostMana(); + if (costMana == null) { + return false; + } + final int activationGeneric = costMana.getMana().getGenericCost(); + if (activationGeneric <= 0) { + return false; + } + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, true, ctx); + final Card filterHost = filter.getHostCard(); + final int needed = cost.getGenericManaAmount() + activationGeneric; + int reusableGenericSources = 0; + for (final SpellAbility candidate : manaAbilityMap.get(ManaAtom.GENERIC)) { + if (isFreeReusableSourceForNestedActivation(candidate, filterHost)) { + reusableGenericSources++; + if (reusableGenericSources >= needed) { + return false; + } + } + } + return reusableGenericSources < needed; + } + + /** + * Keep Study Hall-style filters off single generic pips while a multi-mana consolidator (Sungrass + * Prairie) can still cover two or more remaining generic shards in one activation. + */ + private static boolean shouldDeferAnyManaFilterForMultiGeneric(final SpellAbility filter, + final ManaCostShard toPay, final ManaCostBeingPaid cost, final Collection maList, + final SpellAbility sa, final Player ai, final ManaPaymentContext ctx) { + if (!toPay.isGeneric() || cost.getGenericManaAmount() < 2) { + return false; + } + if (!isAnyManaConsolidatingFilter(filter) && !isFreeColorlessOnAnyManaFilterHost(filter)) { + return false; + } + for (final SpellAbility other : maList) { + if (other == filter || other.getHostCard() == filter.getHostCard()) { + continue; + } + if (isMultiPipActivationFilter(other) && isNetPositiveConsolidator(other) + && !shouldReserveConsolidator(other, sa, cost, ai, ctx)) { + return true; + } + } + return false; + } + + /** Free nested-activation source that is not a one-shot (Petal, Treasure, etc.). */ + private static boolean isFreeReusableSourceForNestedActivation(final SpellAbility ma, final Card filterHost) { + return isFreeManaSourceForNestedActivation(ma, filterHost) && !isDisposableManaAbility(ma); + } + + /** + * True when a filter's activation cost can be paid by free sources on the battlefield. + * {@code reusableOnly} excludes one-shot sources (Petal, Treasure) and, when true, only + * considers any-mana filters (Study Hall). + */ + private static boolean canActivateFilter(final Player ai, final SpellAbility filter, + final ListMultimap manaAbilityMap, final boolean reusableOnly) { + if (filter.getPayCosts() == null || !filter.getPayCosts().hasManaCost()) { + return false; + } + final AbilityManaPart mp = filter.getManaPart(); + if (mp == null) { + return false; + } + if (reusableOnly) { + if (!isAnyManaConsolidatingFilter(filter)) { + return false; + } + } else if (!isMultiPipActivationFilter(filter) && !isComboConsolidatingFilter(filter) + && !mp.isAnyMana()) { + return false; + } + final CostPartMana costMana = filter.getPayCosts().getCostMana(); + if (costMana == null) { + return false; + } + final Card filterHost = filter.getHostCard(); + final ManaCost activation = reusableOnly ? costMana.getManaCostFor(filter) : costMana.getMana(); + if (activation.getGenericCost() > 0 + && !hasActivatorInGenericBucket(ai, manaAbilityMap, filterHost, reusableOnly)) { + return false; + } + for (final ManaCostShard shard : activation) { + if (reusableOnly && (shard.isGeneric() || shard == ManaCostShard.COLORLESS)) { + continue; + } + if (!hasActivatorForShard(ai, manaAbilityMap, shard, filterHost, reusableOnly)) { + return false; + } + } + return true; + } + + /** {@code {1}} activation: any free tap in the generic bucket (all mana sources are indexed here). */ + private static boolean hasActivatorInGenericBucket(final Player ai, + final ListMultimap manaAbilityMap, final Card filterHost, + final boolean reusableOnly) { + for (final SpellAbility candidate : manaAbilityMap.get(ManaAtom.GENERIC)) { + if (reusableOnly) { + if (!isFreeReusableSourceForNestedActivation(candidate, filterHost)) { + continue; + } + } else if (!isFreeManaSourceForNestedActivation(candidate, filterHost)) { + continue; + } + if (isCurrentlyAvailableForNestedActivation(ai, candidate, filterHost)) { + return true; + } + } + return false; + } + + /** True when a free activator can pay this colored/hybrid shard. */ + private static boolean hasActivatorForShard(final Player ai, + final ListMultimap manaAbilityMap, final ManaCostShard shard, + final Card filterHost, final boolean reusableOnly) { + for (final byte color : ManaAtom.MANATYPES) { + if (!shard.canBePaidWithManaOfColor(color)) { + continue; + } + for (final SpellAbility candidate : manaAbilityMap.get((int) color)) { + if (reusableOnly) { + if (!isFreeReusableSourceForNestedActivation(candidate, filterHost)) { + continue; + } + } else if (!isFreeManaSourceForNestedActivation(candidate, filterHost)) { + continue; + } + if (isCurrentlyAvailableForNestedActivation(ai, candidate, filterHost)) { + return true; + } + } + } + return false; + } + + /** + * True when every unpaid colored shard of {@code cost} can be paid by a reusable free source on the + * battlefield. When {@code exclude} is non-null, that shard is skipped (e.g. the shard being paid now). + */ + private static boolean hasReusableFreeProducerForColoredShards(final ManaCostBeingPaid cost, + final ManaCostShard exclude, final Player ai, final ManaPaymentContext ctx) { + if (cost == null || ai == null) { + return false; + } + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, true, ctx); + boolean found = false; + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian()) { + continue; + } + if (exclude != null && shard == exclude) { + continue; + } + if (cost.getUnpaidShards(shard) <= 0) { + continue; + } + found = true; + if (!hasActivatorForShard(ai, manaAbilityMap, shard, null, true)) { + return false; + } + } + return found; + } + + private static boolean hasReusableFreeProducerForEveryOtherColoredShard(final ManaCostBeingPaid cost, + final ManaCostShard payingShard, final Player ai, final ManaPaymentContext ctx) { + return payingShard != null && !payingShard.isGeneric() + && hasReusableFreeProducerForColoredShards(cost, payingShard, ai, ctx); + } + + private static boolean hasReusableFreeProducerForEveryColoredShard(final ManaCostBeingPaid cost, + final Player ai, final ManaPaymentContext ctx) { + return hasReusableFreeProducerForColoredShards(cost, null, ai, ctx); + } + + private static boolean genericBucketHasColorlessSource( + final ListMultimap sourcesForShards) { + if (!sourcesForShards.containsKey(ManaCostShard.GENERIC)) { + return false; + } + for (final SpellAbility ma : sourcesForShards.get(ManaCostShard.GENERIC)) { + if (producesOnlyColorless(ma) && !hasManaActivationCost(ma)) { + return true; + } + } + return false; + } + + private static boolean consolidatesFilter(final Player ai, final SpellAbility ma, + final ListMultimap manaAbilityMap) { + return hasManaActivationCost(ma) && canActivateFilter(ai, ma, manaAbilityMap, false); + } + + private static final class ManaAbilitySortContext { + final Player ai; + final ListMultimap manaAbilityMap; + final ManaCostBeingPaid cost; + final int unpaidGeneric; + final int unpaidColoredShards; + final GenericColorPreference genericColorPref; + final Map manaCardMap; + final Map cardRank; + final Map> hostsByColor; + final List colorsMostCommon; + final SpellAbility spellBeingPaid; + private final Map consolidatesCache = new IdentityHashMap<>(); + private final Map traitsMap = new IdentityHashMap<>(); + + ManaAbilitySortContext(final Player ai, final ListMultimap manaAbilityMap, + final ManaCostBeingPaid cost, final int unpaidGeneric, final int unpaidColoredShards, + final GenericColorPreference genericColorPref, final Map manaCardMap, + final Map cardRank, final List colorsMostCommon, + final SpellAbility spellBeingPaid) { + this.ai = ai; + this.manaAbilityMap = manaAbilityMap; + this.cost = cost; + this.unpaidGeneric = unpaidGeneric; + this.unpaidColoredShards = unpaidColoredShards; + this.genericColorPref = genericColorPref; + this.manaCardMap = manaCardMap; + this.cardRank = cardRank; + this.colorsMostCommon = colorsMostCommon; + this.spellBeingPaid = spellBeingPaid; + this.hostsByColor = buildHostsByColor(manaAbilityMap); + } + + private static Map> buildHostsByColor( + final ListMultimap manaAbilityMap) { + final Map> result = new HashMap<>(); + for (final Integer colorKey : manaAbilityMap.keySet()) { + final Set hosts = new HashSet<>(); + for (final SpellAbility ma : manaAbilityMap.get(colorKey)) { + hosts.add(ma.getHostCard()); + } + result.put(colorKey, hosts); + } + return result; + } + + boolean consolidates(final SpellAbility ma) { + return consolidatesCache.computeIfAbsent(ma, k -> consolidatesFilter(ai, k, manaAbilityMap)); + } + + ManaSourceTraits traits(final SpellAbility ma) { + return traitsMap.computeIfAbsent(ma, k -> ManaSourceTraits.of(k, this)); + } + + int cardPreOrder(final SpellAbility a1, final SpellAbility a2) { + final Integer r1 = cardRank.get(a1.getHostCard()); + final Integer r2 = cardRank.get(a2.getHostCard()); + return (r1 == null ? Integer.MAX_VALUE : r1) - (r2 == null ? Integer.MAX_VALUE : r2); + } + } + + private static Map buildManaCardRankings(final Player ai, + final ListMultimap sourcesForShards, + final ListMultimap manaAbilityMap, final ManaCostBeingPaid cost, + final int unpaidGeneric, final List orderedCardsOut, final SpellAbility spellBeingPaid) { + final Map manaCardMap = Maps.newHashMap(); + final Map> coloredShardsCovered = Maps.newHashMap(); + final Map> comboShardsCovered = Maps.newHashMap(); + for (final ManaCostShard shard : sourcesForShards.keySet()) { + for (SpellAbility ability : sourcesForShards.get(shard)) { + final Card hostCard = ability.getHostCard(); + if (!manaCardMap.containsKey(hostCard)) { + manaCardMap.put(hostCard, scoreManaProducingCard(hostCard, spellBeingPaid, ai)); + orderedCardsOut.add(hostCard); + } + if (!shard.isGeneric()) { + if (isMultiPipActivationFilter(ability)) { + coloredShardsCovered.computeIfAbsent(hostCard, k -> new HashSet<>()).add(shard); + } else if (isMultiManaComboAbility(ability)) { + comboShardsCovered.computeIfAbsent(hostCard, k -> new HashSet<>()).add(shard); + } + } + } + } + for (Map.Entry> e : coloredShardsCovered.entrySet()) { + if (e.getValue().size() >= 2) { + manaCardMap.put(e.getKey(), manaCardMap.get(e.getKey()) - FILTER_CONSOLIDATION_BONUS); + } + } + for (final ManaCostShard shard : sourcesForShards.keySet()) { + if (shard.isGeneric() || cost.getUnpaidShards(shard) < 2) { + continue; + } + for (SpellAbility ability : sourcesForShards.get(shard)) { + if (isVariableAmountConsolidatingFilter(ability)) { + final Card hostCard = ability.getHostCard(); + if (manaCardMap.containsKey(hostCard)) { + manaCardMap.put(hostCard, manaCardMap.get(hostCard) - FILTER_CONSOLIDATION_BONUS); + } + break; + } + } + } + for (Map.Entry> e : comboShardsCovered.entrySet()) { + if (coloredShardsCovered.containsKey(e.getKey())) { + continue; + } + int coverablePips = 0; + for (final ManaCostShard s : e.getValue()) { + coverablePips += cost.getUnpaidShards(s); + } + if (coverablePips >= 2) { + manaCardMap.put(e.getKey(), manaCardMap.get(e.getKey()) - FILTER_CONSOLIDATION_BONUS); + } + } + if (unpaidGeneric >= 2 && sourcesForShards.containsKey(ManaCostShard.GENERIC)) { + final Set genericConsolidators = new HashSet<>(); + for (SpellAbility ability : sourcesForShards.get(ManaCostShard.GENERIC)) { + final boolean consolidates = (isMultiPipActivationFilter(ability) + && canActivateFilter(ai, ability, manaAbilityMap, false)) + || (isMultiManaComboAbility(ability) && !hasManaActivationCost(ability)); + if (consolidates && genericConsolidators.add(ability.getHostCard())) { + manaCardMap.put(ability.getHostCard(), + manaCardMap.get(ability.getHostCard()) - FILTER_CONSOLIDATION_BONUS); + } + } + } + return manaCardMap; + } + + private static List computeHandColorPreferences(final SpellAbility sa, + final boolean hasGenericShard) { + if (!hasGenericShard) { + return null; + } + final Player ap = sa.getActivatingPlayer(); + if (ap == null) { + return null; + } + CardCollection hand = new CardCollection(ap.getCardsIn(ZoneType.Hand)); + hand.remove(sa.getHostCard()); + AiDeckStatistics stats = AiDeckStatistics.fromCards(hand); + Integer[] orderedColorsIdx = {0, 1, 2, 3, 4}; + return Arrays.stream(orderedColorsIdx).sorted(Comparator.comparingInt(o -> stats.maxPips[(int) o]).reversed()) + .filter(idx -> stats.maxPips[idx] > 0) + .map(idx -> (int) MagicColor.WUBRG[idx]) + .collect(Collectors.toList()); + } + + private static int compareAnyManaFilterPreference(final ManaAbilitySortContext ctx, + final SpellAbility ability1, final SpellAbility ability2, final boolean rejectColorlessOpponent) { + final boolean ab1Any = isAnyManaConsolidatingFilter(ability1) && ctx.consolidates(ability1); + final boolean ab2Any = isAnyManaConsolidatingFilter(ability2) && ctx.consolidates(ability2); + if (rejectColorlessOpponent) { + if (ab1Any && !ab2Any && !producesOnlyColorless(ability2)) { + return -1; + } + if (ab2Any && !ab1Any && !producesOnlyColorless(ability1)) { + return 1; + } + } else { + if (ab1Any && !ab2Any) { + return -1; + } + if (ab2Any && !ab1Any) { + return 1; + } + } + return 0; + } + + private static int compareGenericShardAbilities(final ManaAbilitySortContext ctx, + final SpellAbility ability1, final SpellAbility ability2) { + if (ctx.unpaidGeneric == 1) { + final ManaSourceTraits t1 = ctx.traits(ability1); + final ManaSourceTraits t2 = ctx.traits(ability2); + final boolean tight1 = t1.tightFor(1); + final boolean tight2 = t2.tightFor(1); + if (tight1 != tight2) { + return tight1 ? -1 : 1; + } + if (!hasManaActivationCost(ability1) && !hasManaActivationCost(ability2)) { + if (t1.producedAmount != t2.producedAmount) { + return Integer.compare(t1.producedAmount, t2.producedAmount); + } + } + } + if (ctx.unpaidGeneric >= 2) { + final boolean ab1Multi = isMultiPipActivationFilter(ability1) && ctx.consolidates(ability1); + final boolean ab2Multi = isMultiPipActivationFilter(ability2) && ctx.consolidates(ability2); + if (ab1Multi != ab2Multi) { + return ab1Multi ? -1 : 1; + } + final boolean ab1Any = isAnyManaConsolidatingFilter(ability1) && ctx.consolidates(ability1); + final boolean ab2Any = isAnyManaConsolidatingFilter(ability2) && ctx.consolidates(ability2); + if (ab1Multi && ab2Any) { + return -1; + } + if (ab2Multi && ab1Any) { + return 1; + } + final int anyFilterCmp = compareAnyManaFilterPreference(ctx, ability1, ability2, true); + if (anyFilterCmp != 0) { + return anyFilterCmp; + } + final int prod1 = getManaProducedAmount(ability1); + final int prod2 = getManaProducedAmount(ability2); + if (!hasManaActivationCost(ability1) && !hasManaActivationCost(ability2)) { + final boolean noUntap1 = doesNotUntapNormally(ability1); + final boolean noUntap2 = doesNotUntapNormally(ability2); + if (noUntap1 != noUntap2) { + return noUntap1 ? 1 : -1; + } + final boolean multi1 = isMultiManaProducer(ability1); + final boolean multi2 = isMultiManaProducer(ability2); + if (multi1 != multi2) { + return multi1 ? -1 : 1; + } + if (multi1 && prod1 != prod2) { + return Integer.compare(prod2, prod1); + } + } + } + final int filterCostCmp = compareAnyManaFilterActivationCost(ability1, ability2); + if (filterCostCmp != 0) { + return filterCostCmp; + } + return rankGenericManaSource(ability1, effectiveGenericColorPreference(ctx)) + - rankGenericManaSource(ability2, effectiveGenericColorPreference(ctx)); + } + + /** Generic ranking may shift once colored pips are paid (prefer {C} over an extra basic). */ + private static GenericColorPreference effectiveGenericColorPreference(final ManaAbilitySortContext ctx) { + if (ctx.genericColorPref == GenericColorPreference.RESERVE_COLORLESS) { + return ctx.genericColorPref; + } + if (!hasUnpaidColoredShards(ctx.cost)) { + return GenericColorPreference.PREFER_COLORLESS; + } + return ctx.genericColorPref; + } + + private static int compareColoredShardAbilities(final ManaAbilitySortContext ctx, + final SpellAbility ability1, final SpellAbility ability2, final ManaCostShard shard) { + final boolean ab1Filter = hasManaActivationCost(ability1); + final boolean ab2Filter = hasManaActivationCost(ability2); + final boolean ab1Consolidates = ctx.consolidates(ability1); + final boolean ab2Consolidates = ctx.consolidates(ability2); + if (ab1Consolidates != ab2Consolidates && ctx.unpaidColoredShards >= 2 + && (isMultiPipActivationFilter(ability1) || isMultiPipActivationFilter(ability2) + || isComboConsolidatingFilter(ability1) || isComboConsolidatingFilter(ability2))) { + return ab1Consolidates ? -1 : 1; + } + if (ab1Filter != ab2Filter) { + if (ctx.unpaidGeneric == 0 && ctx.unpaidColoredShards >= 2) { + if (ab1Consolidates && isAnyManaConsolidatingFilter(ability1) && !ab2Filter) { + return -1; + } + if (ab2Consolidates && isAnyManaConsolidatingFilter(ability2) && !ab1Filter) { + return 1; + } + } + return ab1Filter ? 1 : -1; + } + if (ctx.cost.getUnpaidShards(shard) >= 2) { + final boolean direct1 = ctx.traits(ability1).directFor(shard); + final boolean direct2 = ctx.traits(ability2).directFor(shard); + final boolean anyMulti1 = isAnyMultiManaProducer(ability1); + final boolean anyMulti2 = isAnyMultiManaProducer(ability2); + if (anyMulti1 && direct2) { + return 1; + } + if (anyMulti2 && direct1) { + return -1; + } + final boolean directMulti1 = isDirectColoredMultiProducer(ability1, shard); + final boolean directMulti2 = isDirectColoredMultiProducer(ability2, shard); + final boolean singlePip1 = isSinglePipDirectColoredProducer(ability1, shard); + final boolean singlePip2 = isSinglePipDirectColoredProducer(ability2, shard); + if (directMulti1 && singlePip2) { + return 1; + } + if (directMulti2 && singlePip1) { + return -1; + } + } else if (ctx.cost.getUnpaidShards(shard) == 1) { + final boolean directMulti1 = isDirectColoredMultiProducer(ability1, shard); + final boolean directMulti2 = isDirectColoredMultiProducer(ability2, shard); + final boolean singlePip1 = isSinglePipDirectColoredProducer(ability1, shard); + final boolean singlePip2 = isSinglePipDirectColoredProducer(ability2, shard); + if (directMulti1 && singlePip2) { + return 1; + } + if (directMulti2 && singlePip1) { + return -1; + } + } + if (ab1Filter && ab2Filter) { + final int filterCostCmp = compareAnyManaFilterActivationCost(ability1, ability2); + if (filterCostCmp != 0) { + return filterCostCmp; + } + } + return 0; + } + + private static int compareGenericTiebreak(final ManaAbilitySortContext ctx, + final SpellAbility ability1, final SpellAbility ability2) { + if (!ctx.manaCardMap.get(ability1.getHostCard()).equals(ctx.manaCardMap.get(ability2.getHostCard()))) { + return 0; + } + final int colorlessCmp = compareColorlessPreference(ability1, ability2, ctx.genericColorPref.reservesColorless()); + if (colorlessCmp != 0) { + return colorlessCmp; + } + if (ctx.colorsMostCommon == null) { + return 0; + } + for (Integer col : ctx.colorsMostCommon) { + final Set hosts = ctx.hostsByColor.get(col); + if (hosts == null) { + continue; + } + final boolean fromCommonColorSource1 = hosts.contains(ability1.getHostCard()); + final boolean fromCommonColorSource2 = hosts.contains(ability2.getHostCard()); + if (fromCommonColorSource1 && !fromCommonColorSource2) { + return 1; + } + if (!fromCommonColorSource1 && fromCommonColorSource2) { + return -1; + } + } + return 0; + } + + private static int compareDifferentCards(final ManaAbilitySortContext ctx, final SpellAbility ability1, + final SpellAbility ability2, final ManaCostShard shard) { + if (shard.isGeneric()) { + final int genericCmp = compareGenericShardAbilities(ctx, ability1, ability2); + if (genericCmp != 0) { + return genericCmp; + } + } else if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS) { + final int coloredCmp = compareColoredShardAbilities(ctx, ability1, ability2, shard); + if (coloredCmp != 0) { + return coloredCmp; + } + } + if (shard.isGeneric()) { + final int tiebreak = compareGenericTiebreak(ctx, ability1, ability2); + if (tiebreak != 0) { + return tiebreak; + } + } + return ctx.cardPreOrder(ability1, ability2); + } + + private static int compareSameCard(final ManaAbilitySortContext ctx, final SpellAbility ability1, + final SpellAbility ability2, final ManaCostShard shard) { + final int unpaidForShard = ctx.cost.getUnpaidShards(shard); + if (unpaidForShard >= 2 || (shard.isGeneric() && ctx.unpaidGeneric >= 2)) { + final int combo1 = getComboManaAmount(ability1); + final int combo2 = getComboManaAmount(ability2); + if (combo1 >= 2 || combo2 >= 2) { + if (combo1 != combo2) { + return Integer.compare(combo2, combo1); + } + final int colorlessCmp = compareColorlessPreference(ability1, ability2, false); + if (colorlessCmp != 0) { + return colorlessCmp; + } + } + } + final boolean ab1HasManaCost = hasManaActivationCost(ability1); + final boolean ab2HasManaCost = hasManaActivationCost(ability2); + if (ab1HasManaCost != ab2HasManaCost) { + if (shard.isGeneric() && ctx.unpaidGeneric >= 2) { + final int anyFilterCmp = compareAnyManaFilterPreference(ctx, ability1, ability2, false); + if (anyFilterCmp != 0) { + return anyFilterCmp; + } + } + return ab1HasManaCost ? 1 : -1; + } + final String shardMana = shard.toShortString(); + final boolean payWithAb1 = ability1.getManaPart().mana(ability1).contains(shardMana); + final boolean payWithAb2 = ability2.getManaPart().mana(ability2).contains(shardMana); + if (payWithAb1 && !payWithAb2) { + return -1; + } + if (payWithAb2 && !payWithAb1) { + return 1; + } + return ability1.compareTo(ability2); + } + + private static int compareManaAbilities(final ManaAbilitySortContext ctx, final SpellAbility ability1, + final SpellAbility ability2, final ManaCostShard shard) { + final int castBonusCmp = compareCastBonusPreference(ctx, ability1, ability2); + if (castBonusCmp != 0) { + return castBonusCmp; + } + final int preOrder = ctx.cardPreOrder(ability1, ability2); + if (preOrder != 0) { + return compareDifferentCards(ctx, ability1, ability2, shard); + } + return compareSameCard(ctx, ability1, ability2, shard); + } + + private static List applyAIManaPrefReorder(final List abilities, + final String preferredShard, final int preferredShardAmount) { + final List preferred = new ArrayList<>(); + final List rest = new ArrayList<>(); + for (SpellAbility ab : abilities) { + if (preferred.size() < preferredShardAmount + && ab.getManaPart().mana(ab).contains(preferredShard)) { + preferred.add(ab); + } else { + rest.add(ab); + } + } + final List result = new ArrayList<>(preferred); + result.addAll(rest); + return result; + } + + private static void sortManaAbilities(final ListMultimap sourcesForShards, + final ListMultimap manaAbilityMap, final SpellAbility sa, + final ManaCostBeingPaid cost, final Player ai, final ManaPaymentContext paymentCtx) { + final int unpaidGeneric = cost.getGenericManaAmount(); + int coloredShardCount = 0; + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS && !shard.isPhyrexian()) { + coloredShardCount += cost.getUnpaidShards(shard); + } + } + final List orderedCards = Lists.newArrayList(); + final Map manaCardMap = buildManaCardRankings(ai, sourcesForShards, manaAbilityMap, cost, + unpaidGeneric, orderedCards, sa); + orderedCards.sort(Comparator.comparingInt(manaCardMap::get)); + final Map cardRank = new HashMap<>(); + for (int i = 0; i < orderedCards.size(); i++) { + cardRank.put(orderedCards.get(i), i); + } + + final boolean hasGenericShard = sourcesForShards.keySet().stream().anyMatch(ManaCostShard::isGeneric); + final List colorsMostCommon = computeHandColorPreferences(sa, hasGenericShard); + final GenericColorPreference genericColorPref = genericColorPreference(ai, sa, cost, coloredShardCount, + sourcesForShards, paymentCtx); + final ManaAbilitySortContext ctx = new ManaAbilitySortContext(ai, manaAbilityMap, cost, unpaidGeneric, + coloredShardCount, genericColorPref, manaCardMap, cardRank, colorsMostCommon, sa); + + for (final ManaCostShard shard : sourcesForShards.keySet()) { + final List newAbilities = new ArrayList<>(sourcesForShards.get(shard)); + newAbilities.sort((a1, a2) -> compareManaAbilities(ctx, a1, a2, shard)); + final List trimmed = trimFungibleManaCandidates(newAbilities, shard, cost, ai); + sourcesForShards.replaceValues(shard, trimmed); + + String manaPref = sa.getParamOrDefault("AIManaPref", ""); + if (manaPref.isEmpty() && sa.getHostCard() != null && sa.getHostCard().hasSVar("AIManaPref")) { + manaPref = sa.getHostCard().getSVar("AIManaPref"); + } + if (!manaPref.isEmpty()) { + final String[] prefShardInfo = manaPref.split(":"); + final String preferredShard = prefShardInfo[0]; + final int preferredShardAmount = prefShardInfo.length > 1 + ? Integer.parseInt(prefShardInfo[1]) : 3; + if (!preferredShard.isEmpty()) { + sourcesForShards.replaceValues(shard, + applyAIManaPrefReorder(trimmed, preferredShard, preferredShardAmount)); + } + } + } + } + + /** + * After sorting, keep only enough fungible representatives per equivalence class to pay the + * remaining shards (plus a small buffer for excluded retries). + *

+ * For generic/{@code X}, also keep copies for unpaid colored pips: the same basics may be + * spent on colored shards first, and sources trimmed out of the generic list (but still on + * the colored list) are unavailable once colored is paid — e.g. Cabal Coffers + Drain Life + * {@code {8}{1}{B}} stranding the final {@code {1}}. + */ + private static List trimFungibleManaCandidates(final List sorted, + final ManaCostShard shard, final ManaCostBeingPaid cost, final Player ai) { + if (sorted.size() <= 1) { + return sorted; + } + int cap = FUNGIBLE_CANDIDATE_BUFFER; + if (shard.isGeneric() || shard == ManaCostShard.X) { + cap += countUnpaidPips(cost); + } else { + cap += cost.getUnpaidShards(shard); + } + final Map classCounts = new HashMap<>(); + final List result = new ArrayList<>(); + for (final SpellAbility ma : sorted) { + final FungibleManaKey key = FungibleManaKey.of(ma, ai); + final int seen = classCounts.getOrDefault(key, 0); + if (seen < cap) { + classCounts.put(key, seen + 1); + result.add(ma); + } + } + return result; + } + + private static final class FungibleManaKey { + private final String cardName; + private final String abilitySignature; + private final boolean tapped; + private final boolean paysTap; + private final boolean paysSac; + private final boolean heldForNext; + private final String chosenType; + private final boolean snow; + + private FungibleManaKey(final String cardName, final String abilitySignature, final boolean tapped, + final boolean paysTap, final boolean paysSac, final boolean heldForNext, + final String chosenType, final boolean snow) { + this.cardName = cardName; + this.abilitySignature = abilitySignature; + this.tapped = tapped; + this.paysTap = paysTap; + this.paysSac = paysSac; + this.heldForNext = heldForNext; + this.chosenType = chosenType; + this.snow = snow; + } + + static FungibleManaKey of(final SpellAbility ma, final Player ai) { + final Card host = ma.getHostCard(); + return new FungibleManaKey(host.getName(), manaAbilitySignature(ma), + host.isTapped(), + AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_TAP_COST), + AiCardMemory.isRememberedCard(ai, host, MemorySet.PAYS_SAC_COST), + AiCardMemory.isRememberedCard(ai, host, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL), + host.getChosenType(), + host.isSnow()); + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof FungibleManaKey)) { + return false; + } + final FungibleManaKey other = (FungibleManaKey) o; + return tapped == other.tapped && paysTap == other.paysTap && paysSac == other.paysSac + && heldForNext == other.heldForNext && snow == other.snow + && Objects.equals(cardName, other.cardName) + && Objects.equals(chosenType, other.chosenType) + && Objects.equals(abilitySignature, other.abilitySignature); + } + + @Override + public int hashCode() { + return Objects.hash(cardName, abilitySignature, tapped, paysTap, paysSac, heldForNext, chosenType, snow); + } + } + + private static String manaAbilitySignature(final SpellAbility ma) { + final StringBuilder sb = new StringBuilder(); + sb.append(ma.getApi()); + final AbilityManaPart mp = ma.getManaPart(); + if (mp != null) { + sb.append('|').append(mp.getOrigProduced()); + } + sb.append('|').append(ma.getParamOrDefault("Produced", "")); + final Cost cost = ma.getPayCosts(); + if (cost != null && cost.hasManaCost() && cost.getCostMana() != null) { + sb.append('|').append(cost.getCostMana().getMana()); + } + return sb.toString(); + } + + private static List capProbeCandidates(final List candidates) { + if (candidates.size() <= CastabilityProbe.CANDIDATE_CAP) { + return candidates; + } + return candidates.subList(0, CastabilityProbe.CANDIDATE_CAP); + } + + public static SpellAbility chooseManaAbility(ManaCostBeingPaid cost, SpellAbility sa, Player ai, ManaCostShard toPay, + Collection maList, boolean checkCosts) { + final List valid = collectValidManaPaymentChoices(cost, sa, ai, toPay, maList, checkCosts, null); + return pickFirstReservedManaChoice(ai, sa, valid); + } + + /** + * Reservation checks ({@link ComputerUtilCost#checkForManaSacrificeCost}, + * {@link ComputerUtilCost#checkTapTypeCost}) have side effects and must run only for the ability + * actually chosen, not while enumerating every candidate (see AutoPaymentTest). + */ + private static boolean passesManaPaymentReservationChecks(final Player ai, final SpellAbility ma, + final SpellAbility sa) { + return ComputerUtilCost.checkForManaSacrificeCost(ai, ma.getPayCosts(), ma, ma.isTrigger()) + && ComputerUtilCost.checkTapTypeCost(ai, ma.getPayCosts(), ma.getHostCard(), sa, + AiCardMemory.getMemorySet(ai, MemorySet.PAYS_TAP_COST)); + } + + private static SpellAbility pickFirstReservedManaChoice(final Player ai, final SpellAbility sa, + final List candidates) { + for (final SpellAbility ma : candidates) { + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + if (passesManaPaymentReservationChecks(ai, ma, sa)) { + return ma; + } + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + } + return null; + } + + /** Match {@link #sortFreeSourcesForNestedActivation} / {@link #chooseManaAbility} generic ranking. */ + static GenericColorPreference genericColorPreferenceForNestedActivation(final Player ai, + final SpellAbility sa, final ManaCostBeingPaid cost) { + if (coloredBasicTappedThisPayment(ai)) { + return GenericColorPreference.PREFER_COLORLESS; + } + if (shouldReserveColorlessMana(ai, sa)) { + return GenericColorPreference.RESERVE_COLORLESS; + } + if (!hasUnpaidColoredShards(cost)) { + return GenericColorPreference.PREFER_COLORLESS; + } + return GenericColorPreference.DEFAULT; + } + + private static SpellAbility chooseManaAbilityForShard(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ManaCostShard toPay, Collection maList, final boolean checkCosts, + final boolean test, final ManaPaymentContext ctx) { + final List valid = collectValidManaPaymentChoices(cost, sa, ai, toPay, maList, checkCosts, ctx); + if (valid.isEmpty()) { + return null; + } + if (valid.size() == 1 || (ctx != null && ctx.inFilterActivationProbe) || !CastabilityProbe.shouldUse(sa, test, ctx) + || ((ctx == null || !ctx.paymentPromptPreview) + && valid.stream().noneMatch(ComputerUtilMana::isConsolidatingCandidate)) + || !hasOtherHandOrCommandSpells(ai, sa, ctx)) { + return preferSourceThatKeepsRestPayable(cost, sa, ai, toPay, valid, test, ctx); + } + if ((toPay.isGeneric() || toPay == ManaCostShard.X) + && (handHasMulticolorManaSpells(ai, sa, ctx) || handHasGenericAndColoredCast(ai, sa, ctx)) + && valid.stream().anyMatch(ComputerUtilMana::isAnyMultiManaProducer)) { + return preferSourceThatKeepsRestPayable(cost, sa, ai, toPay, valid, test, ctx); + } + + final boolean preferMultiForGeneric = toPay.isGeneric() || toPay == ManaCostShard.X; + final SpellAbility best = CastabilityProbe.pickBest(cost, valid, sa, ai, toPay, + ComputerUtilMana::collectCardsConsumedByPayment, preferMultiForGeneric, test, ctx); + return best != null ? refreshExpressChoice(cost, sa, ai, toPay, best) + : refreshExpressChoice(cost, sa, ai, toPay, valid.get(0)); + } + + /** + * Among equally-ranked candidates, avoid one that would strand the rest of THIS cost. Using a + * filter with a mana activation cost taps an extra source, which can leave later pips of the same + * payment unpayable (e.g. tapping Plains to activate Study Hall for {G} strands the {W} and {1} that also + * needed Plains). Only reorders when the top choice has such a cost and a cheaper alternative keeps the + * remaining cost payable; otherwise the existing order is preserved. + */ + /** Count reusable 1-mana producers for {@code toPay} among {@code valid} candidates. */ + private static int countSinglePipDirectColoredProducers(final List valid, + final ManaCostShard toPay) { + int count = 0; + for (final SpellAbility ma : valid) { + if (isSinglePipDirectColoredProducer(ma, toPay)) { + count++; + } + } + return count; + } + + /** + * When basics cannot cover all remaining colored pips but a direct multi-producer can (e.g. Cradle + * for 4 with only 2 Forests paying {G}{G}{G}), prefer one multi activation over piecing basics first. + */ + private static SpellAbility pickDirectColoredMultiWhenSinglePipsInsufficient(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final ManaCostShard toPay, final List valid, + final boolean test, final ManaPaymentContext ctx) { + if (toPay == null || toPay.isGeneric() || toPay == ManaCostShard.X || toPay == ManaCostShard.COLORLESS + || toPay.isPhyrexian()) { + return null; + } + final int remaining = remainingPipsForShard(cost, toPay); + if (remaining <= 1 || countSinglePipDirectColoredProducers(valid, toPay) >= remaining) { + return null; + } + SpellAbility bestMulti = null; + int bestAmount = 0; + for (final SpellAbility ma : valid) { + if (!isDirectColoredMultiProducer(ma, toPay)) { + continue; + } + final int prod = getManaProducedAmount(ma); + if (prod >= remaining && prod > bestAmount) { + bestAmount = prod; + bestMulti = ma; + } + } + if (bestMulti == null) { + return null; + } + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + if (!passesManaPaymentReservationChecks(ai, bestMulti, sa)) { + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + return null; + } + final PaymentImpact impact = evaluatePaymentImpact(cost, sa, ai, toPay, bestMulti, valid, test, ctx); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + if (impact.keepsRest) { + return refreshExpressChoice(cost, sa, ai, toPay, bestMulti); + } + return null; + } + + private static SpellAbility preferSourceThatKeepsRestPayable(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ManaCostShard toPay, final List valid, final boolean test, + final ManaPaymentContext ctx) { + // Nested feasibility probes follow sort order only; stranding / efficiency checks are for the + // outer spell payment (depth 1) so we don't re-simulate every land on every recursive call. + if ((ctx != null && ctx.inFilterActivationProbe) || (ctx != null && ctx.depth > 1)) { + final SpellAbility first = pickFirstReservedManaChoice(ai, sa, valid); + return first == null ? null : refreshExpressChoice(cost, sa, ai, toPay, first); + } + + final SpellAbility directMulti = pickDirectColoredMultiWhenSinglePipsInsufficient(cost, sa, ai, toPay, valid, + test, ctx); + if (directMulti != null) { + return directMulti; + } + + SpellAbility first = null; + SpellAbility best = null; + int bestEfficiency = Integer.MAX_VALUE; + + List ranked = valid; + if (toPay == ManaCostShard.GENERIC || toPay == ManaCostShard.X) { + ranked = Lists.newArrayList(valid); + final GenericColorPreference pref = resolveGenericColorPreference(ai, sa); + ranked.sort((a, b) -> compareGenericCandidatesForPayment(a, b, pref, cost.getGenericManaAmount(), sa, ai)); + } else if (toPay != null && !toPay.isPhyrexian() && toPay != ManaCostShard.COLORLESS) { + ranked = Lists.newArrayList(valid); + ranked.sort((a, b) -> castBonusPreferenceAdjustment(a, sa, ai) + - castBonusPreferenceAdjustment(b, sa, ai)); + } + + for (final SpellAbility cand : capProbeCandidates(ranked)) { + if (bestEfficiency <= 1 && best != null && producesShardDirectly(best, toPay)) { + break; + } + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + if (!passesManaPaymentReservationChecks(ai, cand, sa)) { + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + continue; + } + if (first == null) { + first = cand; + } + + final PaymentImpact impact = evaluatePaymentImpact(cost, sa, ai, toPay, cand, valid, test, ctx); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + + if (!impact.keepsRest) { + continue; + } + if (impact.efficiencyScore < bestEfficiency + || (impact.efficiencyScore == bestEfficiency && best != null + && ((toPay == ManaCostShard.GENERIC || toPay == ManaCostShard.X) + && compareGenericCandidatesForPayment(cand, best, + resolveGenericColorPreference(ai, sa), cost.getGenericManaAmount(), sa, ai) < 0) + || (toPay != null && !toPay.isGeneric() && toPay != ManaCostShard.X + && castBonusPreferenceAdjustment(cand, sa, ai) + < castBonusPreferenceAdjustment(best, sa, ai)))) { + bestEfficiency = impact.efficiencyScore; + best = cand; + } + } + if (best != null) { + return refreshExpressChoice(cost, sa, ai, toPay, best); + } + final SpellAbility consolidator = pickConsolidatorWhenDisposableWouldStrand(cost, sa, ai, toPay, valid, test, ctx); + if (consolidator != null) { + return refreshExpressChoice(cost, sa, ai, toPay, consolidator); + } + return first == null ? null : refreshExpressChoice(cost, sa, ai, toPay, first); + } + + /** + * When every candidate that passed {@code keepsRest} was skipped, avoid falling back to a disposable + * that strands a net-positive consolidator (e.g. Lotus Petal on {W} before Signet can fire). + */ + private static SpellAbility pickConsolidatorWhenDisposableWouldStrand(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final ManaCostShard toPay, + final List valid, final boolean test, final ManaPaymentContext ctx) { + if (valid.isEmpty() || toPay.isGeneric()) { + return null; + } + final List consolidators = new ArrayList<>(); + for (final SpellAbility cand : valid) { + if (!isConsolidatingCandidate(cand) || !isNetPositiveConsolidator(cand)) { + continue; + } + if (shouldReserveConsolidator(cand, sa, cost, ai, ctx)) { + continue; + } + consolidators.add(cand); + } + for (final SpellAbility cand : capProbeCandidates(consolidators)) { + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + if (!passesManaPaymentReservationChecks(ai, cand, sa)) { + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + continue; + } + final PaymentImpact impact = evaluatePaymentImpact(cost, sa, ai, toPay, cand, valid, test, ctx); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + if (impact.keepsRest) { + return cand; + } + } + return null; + } + + static final class PaymentImpact { + final boolean keepsRest; + final int consumedCount; + final int efficiencyScore; + + private PaymentImpact(final boolean keepsRest, final int consumedCount, final SpellAbility chosen, + final ManaCostBeingPaid cost, final ManaCostShard toPay, final List alternatives, + final Player ai, final SpellAbility sa, final ManaPaymentContext ctx) { + this.keepsRest = keepsRest; + this.consumedCount = consumedCount; + this.efficiencyScore = paymentEfficiencyScore(chosen, consumedCount, cost, toPay, alternatives, ai, sa, + ctx); + } + } + + private static int effectiveCardsConsumedForPayment(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ManaCostShard toPay, final SpellAbility chosen, final Set consumed) { + if (isMultiPipActivationFilter(chosen) || isMultiManaComboAbility(chosen)) { + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + applyChosenPaymentToCostProbe(probe, chosen, ai, toPay); + if (probe.isPaid()) { + return 1; + } + } else if (isMultiManaProducer(chosen) && !isAnyMultiManaProducer(chosen)) { + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + final int before = countUnpaidPips(probe); + refreshExpressChoice(cost, sa, ai, toPay, chosen); + payMultipleMana(probe, predictManafromSpellAbility(chosen, ai, toPay, cost), ai); + if (before - countUnpaidPips(probe) >= 2) { + return 1; + } + } else if (isMultiManaDisposable(chosen)) { + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + final int before = countUnpaidPips(probe); + refreshExpressChoice(cost, sa, ai, toPay, chosen); + payMultipleMana(probe, predictManafromSpellAbility(chosen, ai, toPay, cost), ai); + if (before - countUnpaidPips(probe) >= 2) { + return 1; + } + } + if (requiresTappingOtherCreatureForMana(chosen)) { + // The tapped creature is consumed too, but collectCardsConsumedByPayment only tracks + // nested taps for filters with mana activation costs. + return consumed.size() + 1; + } + return consumed.size(); + } + + /** Apply {@code chosen}'s mana production toward {@code remaining} for feasibility probes. */ + private static void applyChosenPaymentToCostProbe(final ManaCostBeingPaid remaining, final SpellAbility chosen, + final Player ai, final ManaCostShard toPay) { + if (isMultiPipActivationFilter(chosen)) { + payMultipleMana(remaining, predictManafromSpellAbility(chosen, ai, toPay, remaining), ai); + } else if (isMultiManaComboAbility(chosen)) { + setComboManaChoice(ai, chosen, remaining); + try { + payMultipleMana(remaining, + capComboManaProduced(predictManafromSpellAbility(chosen, ai, toPay, remaining), + getComboManaAmount(chosen)), + ai); + } finally { + chosen.getManaPart().clearExpressChoice(); + } + } else if (!hasManaActivationCost(chosen)) { + final String manaProduced = predictManafromSpellAbility(chosen, ai, toPay, remaining); + if (shouldApplyProducedManaToShardOnly(chosen, remaining, toPay, ai)) { + payProducedManaTowardShard(remaining, manaProduced, toPay, ai); + } else { + payMultipleMana(remaining, manaProduced, ai); + } + } else { + remaining.decreaseShard(toPay, 1); + } + } + + /** + * Single pass: cards consumed by paying with {@code chosen}, and whether the rest of {@code cost} + * remains payable afterwards. + */ + static PaymentImpact evaluatePaymentImpact(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ManaCostShard toPay, final SpellAbility chosen, + final List alternatives, final boolean test, final ManaPaymentContext ctx) { + refreshExpressChoice(cost, sa, ai, toPay, chosen); + final Set consumed = collectCardsConsumedByPayment(chosen, sa, ai, ctx); + if (consumed == null) { + return new PaymentImpact(false, Integer.MAX_VALUE, chosen, cost, toPay, alternatives, ai, sa, ctx); + } + final int consumedCount = effectiveCardsConsumedForPayment(cost, sa, ai, toPay, chosen, consumed); + if (!hasRemainingCostAfterShard(cost, toPay)) { + return new PaymentImpact(true, consumedCount, chosen, cost, toPay, alternatives, ai, sa, ctx); + } + return new PaymentImpact( + keepsRemainingCostPayableWithConsumed(cost, sa, ai, toPay, chosen, consumed, test, ctx), + consumedCount, chosen, cost, toPay, alternatives, ai, sa, ctx); + } + + private static SpellAbility refreshExpressChoice(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ManaCostShard toPay, final SpellAbility chosen) { + final AbilityManaPart mp = chosen.getManaPart(); + if (mp != null && (mp.isAnyMana() || mp.isComboMana() || chosen.getApi() == ApiType.ManaReflected)) { + canPayShardWithSpellAbility(toPay, ai, chosen, sa, cost, true, cost.getXManaCostPaidByColor()); + } + return chosen; + } + + /** True when the ability has a mana activation cost of its own (filter), so using it taps an extra source. */ + private static boolean hasManaActivationCost(final SpellAbility ma) { + return hasManaCost(ma); + } + + /** True when the cost still has unpaid shards other than a single copy of the one about to be paid. */ + private static boolean hasRemainingCostAfterShard(final ManaCostBeingPaid cost, final ManaCostShard toPay) { + ManaCostShard first = null; + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (first == null) { + first = shard; + } else { + return true; + } + } + return cost.getUnpaidShards(toPay) > 1; + } + + /** + * Simulate paying {@code toPay} with {@code chosen} (reserving the cards it and any nested activation + * consume) and check the remaining shards of {@code cost} are still payable from what's left. + */ + private static boolean keepsRemainingCostPayableWithConsumed(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final ManaCostShard toPay, final SpellAbility chosen, + final Set consumed, final boolean test, final ManaPaymentContext ctx) { + if ((ctx != null && ctx.inFilterActivationProbe) || !useFullPaymentProbes(test, ctx)) { + return true; + } + final ManaCostBeingPaid remaining = new ManaCostBeingPaid(cost); + applyChosenPaymentToCostProbe(remaining, chosen, ai, toPay); + if (remaining.isPaid()) { + return true; + } + final List reserved = new ArrayList<>(); + for (Card c : consumed) { + if (!AiCardMemory.isRememberedCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL)) { + AiCardMemory.rememberCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + reserved.add(c); + } + } + final Set tapMemorySnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + final Set sacMemorySnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + try { + return payManaCost(remaining, sa, ai, true, true, false, null, ctx.nestedWithFilterProbe()); + } finally { + for (Card c : reserved) { + AiCardMemory.forgetCard(ai, c, MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL); + } + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapMemorySnapshot); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacMemorySnapshot); + } + } + + /** Copy the current contents of an AI card memory set (or null if unavailable). */ + static Set snapshotMemory(final Player ai, final MemorySet set) { + final Set live = AiCardMemory.getMemorySet(ai, set); + return live == null ? null : new HashSet<>(live); + } + + /** Reset an AI card memory set back to a previously captured snapshot. */ + static void restoreMemory(final Player ai, final MemorySet set, final Set snapshot) { + if (snapshot == null) { + return; + } + AiCardMemory.clearMemorySet(ai, set); + for (final Card c : snapshot) { + AiCardMemory.rememberCard(ai, c, set); + } + } + + /** True when hand or command zone contains another spell besides the one being paid for. */ + private static boolean hasOtherHandOrCommandSpells(final Player ai, final SpellAbility sa, + final ManaPaymentContext ctx) { + if (ctx != null && sa.getHostCard() != null + && ctx.caches.handProbeSpellId == sa.getHostCard().getId() + && ctx.caches.hasOtherHandOrCommandSpells != null) { + return ctx.caches.hasOtherHandOrCommandSpells; + } + final boolean result = hasOtherHandOrCommandSpellsUncached(ai, sa); + if (ctx != null && sa.getHostCard() != null) { + ctx.caches.handProbeSpellId = sa.getHostCard().getId(); + ctx.caches.hasOtherHandOrCommandSpells = result; + } + return result; + } + + private static boolean hasOtherHandOrCommandSpellsUncached(final Player ai, final SpellAbility sa) { + final Card host = sa.getHostCard(); + for (final ZoneType zone : HAND_AND_COMMAND) { + for (Card c : ai.getCardsIn(zone)) { + if (c == host) { + continue; + } + for (SpellAbility candSa : c.getSpellAbilities()) { + if (candSa.isSpell() && candSa.getPayCosts() != null && candSa.getPayCosts().hasManaCost()) { + return true; + } + } + } + } + return false; + } + + /** Cards tapped (including nested activation costs) if this mana ability is chosen. */ + private static Set collectCardsConsumedByPayment(final SpellAbility saPayment, final SpellAbility sa, + final Player ai, final ManaPaymentContext ctx) { + final Set consumed = new HashSet<>(); + consumed.add(saPayment.getHostCard()); + if (saPayment.getPayCosts() != null && saPayment.getPayCosts().hasManaCost()) { + final ManaPaymentContext predictCtx = ctx == null ? null : ctx.withFilterProbe(); + final CardCollection nested = predictNestedActivationTaps(saPayment, sa, ai, predictCtx); + if (nested == null) { + return null; + } + consumed.addAll(nested); + } + return consumed; + } + + /** Dry-run nested generic activation cost payment; returns tapped cards or null if unpayable. */ + private static CardCollection predictNestedActivationTaps(final SpellAbility filterAb, + final SpellAbility sa, final Player ai, final ManaPaymentContext ctx) { + if (ctx == null) { + final CardCollection taps = new CardCollection(); + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + final List probePoolRemovals = new ArrayList<>(); + final List probeDeposited = new ArrayList<>(); + final ManaPaymentContext probeCtx = ManaPaymentContext.outer(); + try { + if (!payNestedActivationCost(filterAb, sa, ai, ArrayListMultimap.create(), probePoolRemovals, + probeDeposited, true, false, taps, probeCtx)) { + return null; + } + return taps; + } finally { + cleanupTestManaPayment(ai, probePoolRemovals, probeDeposited); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + } + } + final CardCollection taps = new CardCollection(); + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + final List probePoolRemovals = new ArrayList<>(); + final List probeDeposited = new ArrayList<>(); + try { + if (!payNestedActivationCost(filterAb, sa, ai, ArrayListMultimap.create(), probePoolRemovals, probeDeposited, + true, false, taps, ctx)) { + return null; + } + return taps; + } finally { + cleanupTestManaPayment(ai, probePoolRemovals, probeDeposited); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + } + } + + private static List collectValidManaPaymentChoices(final ManaCostBeingPaid cost, + final SpellAbility sa, final Player ai, final ManaCostShard toPay, + Collection maList, final boolean checkCosts, final ManaPaymentContext ctx) { + Card saHost = sa.getHostCard(); + + // When paying the activation cost of a filter (the ability being paid for is itself a mana + // ability with a mana cost), only free sources may pay it — never another filter. Prevents filter chains. + if (sa.isManaAbility() && sa.getPayCosts() != null && sa.getPayCosts().hasManaCost()) { + final List freeOnly = new ArrayList<>(); + for (SpellAbility ma : maList) { + if (ma.getPayCosts() == null || !ma.getPayCosts().hasManaCost()) { + freeOnly.add(ma); + } + } + maList = freeOnly; + } + + // CastTotalManaSpent (AIPreference:ManaFrom$Type or AIManaPref$ Type) + String manaSourceType = ""; + if (saHost.hasSVar("AIPreference")) { + String condition = saHost.getSVar("AIPreference"); + if (condition.startsWith("ManaFrom")) { + manaSourceType = TextUtil.split(condition, '$')[1]; + } + } else if (sa.hasParam("AIManaPref")) { + manaSourceType = sa.getParam("AIManaPref"); + } + if (!manaSourceType.isEmpty()) { + List filteredList = Lists.newArrayList(maList); + switch (manaSourceType) { + case "Snow": + filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().isSnow() + && ab2.getHostCard() != null && !ab2.getHostCard().isSnow() ? -1 : 1); + maList = filteredList; + break; + case "Treasure": + // Try to spend only one Treasure if possible + filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().getType().hasSubtype("Treasure") + && ab2.getHostCard() != null && !ab2.getHostCard().getType().hasSubtype("Treasure") ? -1 : 1); + SpellAbility first = filteredList.get(0); + if (first.getHostCard() != null && first.getHostCard().getType().hasSubtype("Treasure")) { + maList.remove(first); + List updatedList = Lists.newArrayList(); + updatedList.add(first); + updatedList.addAll(maList); + maList = updatedList; + } + break; + case "TreasureMax": + // Ok to spend as many Treasures as possible + filteredList.sort((ab1, ab2) -> ab1.getHostCard() != null && ab1.getHostCard().getType().hasSubtype("Treasure") + && ab2.getHostCard() != null && !ab2.getHostCard().getType().hasSubtype("Treasure") ? -1 : 1); + maList = filteredList; + break; + case "NotSameCard": + String hostName = sa.getHostCard().getName(); + maList = filteredList.stream() + .filter(saPay -> !saPay.getHostCard().getName().equals(hostName)) + .collect(Collectors.toList()); + break; + default: + break; + } + } + + final List valid = new ArrayList<>(); + Map untappedCountByName = null; + if (sa.getApi() == ApiType.Attach + && "AvoidPayingWithAttachTarget".equals(saHost.getSVar("AIPaymentPreference")) + && sa.getTargetCard() != null) { + untappedCountByName = new HashMap<>(); + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + if (c.isUntapped()) { + untappedCountByName.merge(c.getName(), 1, Integer::sum); + } + } + } + for (final SpellAbility ma : maList) { + // this rarely seems like a good idea + if (ma.getHostCard() == saHost) { + continue; + } + + if (hasTapCost(ma) && AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_TAP_COST)) { + continue; + } + if (AiCardMemory.isRememberedCard(ai, ma.getHostCard(), MemorySet.PAYS_SAC_COST)) { + continue; + } + + int amount = ma.hasParam("Amount") ? AbilityUtils.calculateAmount(ma.getHostCard(), ma.getParam("Amount"), ma) : 1; + if (amount <= 0) { + // wrong gamestate for variable amount + continue; + } + + if (sa.getApi() == ApiType.Animate) { + // For abilities like Genju of the Cedars, make sure that we're not activating the aura ability by tapping the enchanted card for mana + if (saHost.isAura() && "Enchanted".equals(sa.getParam("Defined")) + && ma.getHostCard() == saHost.getEnchantingCard() + && hasTapCost(ma)) { + continue; + } + + // If a manland was previously animated this turn, do not tap it to animate another manland + if (saHost.isLand() && ma.getHostCard().isLand() + && ai.getController().isAI() + && AnimateAi.isAnimatedThisTurn(ai, ma.getHostCard())) { + continue; + } + } else if (sa.getApi() == ApiType.Pump) { + if ((saHost.isInstant() || saHost.isSorcery()) + && ma.getHostCard().isCreature() + && ai.getController().isAI() + && hasTapCost(ma) + && sa.getTargets().getTargetCards().contains(ma.getHostCard())) { + // do not activate pump instants/sorceries targeting creatures by tapping targeted + // creatures for mana (for example, Servant of the Conduit) + continue; + } + } else if (sa.getApi() == ApiType.Attach + && "AvoidPayingWithAttachTarget".equals(saHost.getSVar("AIPaymentPreference"))) { + // For cards like Genju of the Cedars, make sure we're not attaching to the same land that will + // be tapped to pay its own cost if there's another untapped land like that available + if (ma.getHostCard().equals(sa.getTargetCard())) { + final Integer untappedSameName = untappedCountByName == null ? null + : untappedCountByName.get(ma.getHostCard().getName()); + if (untappedSameName != null && untappedSameName > 1) { + continue; + } + } + } + + SpellAbility paymentChoice = ma; + + // Exception: when paying generic mana with Cavern of Souls, prefer the colored mana producing ability + // to attempt to make the spell uncounterable when possible. + if (ComputerUtilAbility.getAbilitySourceName(ma).equals("Cavern of Souls") + && saHost.getType().hasCreatureType(ma.getHostCard().getChosenType())) { + if (toPay == ManaCostShard.COLORLESS && cost.getUnpaidShards().contains(ManaCostShard.GENERIC)) { + // Deprioritize Cavern of Souls, try to pay generic mana with it instead to use the NoCounter ability + continue; + } else if (toPay == ManaCostShard.GENERIC || toPay == ManaCostShard.X) { + for (SpellAbility ab : maList) { + if (ab.isManaAbility() && ab.getManaPart().isAnyMana() && ab.hasParam("AddsNoCounter")) { + if (!ab.getHostCard().isTapped()) { + paymentChoice = ab; + break; + } + } + } + } + } + + if (!canPayShardWithSpellAbility(toPay, ai, paymentChoice, sa, cost, checkCosts, cost.getXManaCostPaidByColor())) { + continue; + } + + // Skip useless 1:1 filters (e.g. Initiates of the Ebon Hand: {1} -> {B}) when a direct, + // free source for the same color is available. No net mana profit means the filter is wasteful. + if (isUselessFilter(paymentChoice, toPay, maList, ai, cost, ctx)) { + continue; + } + if (isWastefulSacLandForGeneric(paymentChoice, toPay, ai, ctx)) { + continue; + } + if (isDisposableManaAbility(paymentChoice) && !toPay.isGeneric() + && consolidatorCoversRemainingCost(maList, cost, sa, ai, ctx)) { + continue; + } + if (shouldReserveConsolidator(paymentChoice, sa, cost, ai, ctx)) { + continue; + } + if (isManaActivationConsolidator(paymentChoice) && poolOrDepositedCanPayShard(ai, cost, toPay, ctx)) { + continue; + } + if (shouldDeferAnyManaFilterForMultiGeneric(paymentChoice, toPay, cost, maList, sa, ai, ctx)) { + continue; + } + + valid.add(paymentChoice); + } + return valid; + } + + /** + * Skip useless filters when a direct source in the candidate pool can pay {@code toPay} without + * routing through the filter. Covers 1:1 filters (Initiates {1} -> {B}) and any-mana filters + * (Study Hall) when activation would burn a disposable that could pay the pip directly. + */ + private static boolean isUselessFilter(final SpellAbility ma, final ManaCostShard toPay, + final Collection maList, final Player ai, final ManaCostBeingPaid cost, + final ManaPaymentContext ctx) { + final Cost payCosts = ma.getPayCosts(); + if (payCosts == null || !payCosts.hasManaCost()) { + return false; + } + final AbilityManaPart mp = ma.getManaPart(); + if (mp == null) { + return false; + } + if (mp.isComboMana() || mp.mana(ma).split(" ").length > 1) { + return false; + } + if (mp.isAnyMana()) { + return isUselessAnyManaFilter(ma, maList, ai, cost, toPay, ctx); + } + final CostPartMana costMana = payCosts.getCostMana(); + final int activationCMC = costMana == null ? 0 : costMana.getMana().getCMC(); + // net mana profit means it's worth using (e.g. mana rocks that add more than they cost) + if (ma.amountOfManaGenerated(true) > activationCMC) { + return false; + } + // only skip when a free source in the same candidate pool can pay this shard directly + for (SpellAbility other : maList) { + if (other == ma || other.getHostCard() == ma.getHostCard()) { + continue; + } + final Cost otherCost = other.getPayCosts(); + if (otherCost != null && !otherCost.hasManaCost()) { + return true; + } + } + return false; + } + + /** + * Any-mana filters (Study Hall) are wasteful for a single pip when a dedicated reusable producer + * exists, or when a disposable is the only way to pay and the filter's activation cannot be covered + * by reusable sources alone. Otherwise keep the filter — a reusable activation line preserves the + * disposable ({@code {1}} accepts any mana; hybrid entry costs need matching reusable taps). + */ + private static boolean isUselessAnyManaFilter(final SpellAbility filter, final Collection maList, + final Player ai, final ManaCostBeingPaid cost, final ManaCostShard toPay, + final ManaPaymentContext ctx) { + final Card filterHost = filter.getHostCard(); + if (maList.stream().anyMatch(other -> other != filter && other.getHostCard() != filterHost + && isReusableFreeManaForShard(other, toPay))) { + return true; + } + if (!toPay.isGeneric() && cost != null && cost.getGenericManaAmount() == 0 && ai != null + && maList.stream().anyMatch(other -> other != filter && other.getHostCard() != filterHost + && isDisposableManaAbility(other)) + && hasReusableFreeProducerForEveryOtherColoredShard(cost, toPay, ai, ctx)) { + return true; + } + final ListMultimap manaAbilityMap = ai != null + ? getOrBuildManaAbilityMap(ai, true, ctx) : null; + if (cost != null && cost.getGenericManaAmount() > 0 && !toPay.isGeneric() && manaAbilityMap != null) { + if (anyManaFilterBeatsDisposable(filter, cost, ai, manaAbilityMap, ctx)) { + return false; + } + for (SpellAbility other : maList) { + if (other == filter || other.getHostCard() == filterHost) { + continue; + } + if (isDisposableManaAbility(other)) { + return true; + } + } + } + if (toPay.isGeneric() || toPay == ManaCostShard.X) { + for (SpellAbility other : maList) { + if (other == filter || other.getHostCard() == filterHost) { + continue; + } + final Cost otherCost = other.getPayCosts(); + if (otherCost == null || !otherCost.hasManaCost()) { + return true; + } + } + } + if (manaAbilityMap != null && canActivateFilter(ai, filter, manaAbilityMap, true)) { + return false; + } + for (SpellAbility other : maList) { + if (other == filter || other.getHostCard() == filterHost) { + continue; + } + final Cost otherCost = other.getPayCosts(); + if (otherCost != null && !otherCost.hasManaCost() && isDisposableManaAbility(other)) { + return true; + } + } + return false; + } + + /** Skip sacrificing a land for generic when another land can still tap for mana. */ + private static boolean isWastefulSacLandForGeneric(final SpellAbility ma, final ManaCostShard toPay, + final Player ai, final ManaPaymentContext ctx) { + if (!toPay.isGeneric() && toPay != ManaCostShard.X) { + return false; + } + if (!isDisposableManaAbility(ma) || !ma.getHostCard().isLand() || ai == null) { + return false; + } + if (hasReusableTapLandOnBattlefield(ai, ma.getHostCard(), ctx)) { + return true; + } + return false; + } + + private static Set findReusableTapLandSources(final Player ai, final Card exclude, + final ManaPaymentContext ctx) { + final Set sources = new HashSet<>(); + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + if (isDisposableManaCard(c) || c == exclude) { + continue; + } + for (SpellAbility other : getAIPlayableMana(c, ctx)) { + if (other.getPayCosts() != null && other.getPayCosts().hasTapCost() + && isFreeManaSourceForNestedActivation(other, exclude)) { + sources.add(c); + break; + } + } + } + return sources; + } + + private static boolean hasReusableTapLandOnBattlefield(final Player ai, final Card exclude, + final ManaPaymentContext ctx) { + final Set sources; + if (ctx == null) { + sources = findReusableTapLandSources(ai, exclude, null); + } else { + final long fp = paymentPlanReservationFingerprint(ai); + final Long cachedKey = ctx.caches.reusableTapLandKey; + Set cached = ctx.caches.reusableTapLandSet; + if (cached == null || cachedKey == null || cachedKey != fp) { + cached = findReusableTapLandSources(ai, exclude, ctx); + ctx.caches.reusableTapLandSet = cached; + ctx.caches.reusableTapLandKey = fp; + } + sources = cached; + } + for (Card c : sources) { + if (c == exclude) { + continue; + } + if (!AiCardMemory.isRememberedCard(ai, c, MemorySet.PAYS_TAP_COST)) { + return true; + } + } + return false; + } + + /** Record tap/sacrifice reservation during test-mode planning so it matches production auto-pay. */ + private static void rememberManaSourceConsumed(final Player ai, final SpellAbility ma) { + if (hasTapCost(ma)) { + AiCardMemory.rememberCard(ai, ma.getHostCard(), MemorySet.PAYS_TAP_COST); + } + if (isDisposableManaAbility(ma)) { + AiCardMemory.rememberCard(ai, ma.getHostCard(), MemorySet.PAYS_SAC_COST); + } + } + + /** + * True when an untapped reusable source can still pay a filter's nested activation cost + * (excludes disposables and sources already reserved for this payment). + */ + private static boolean hasAvailableReusableActivatorForNestedCost(final Player ai, final Card filterHost, + final ManaPaymentContext ctx) { + if (ai == null) { + return false; + } + if (ctx != null) { + for (final SpellAbility ma : getOrBuildManaAbilityMap(ai, true, ctx).values()) { + if (isCurrentlyAvailableForNestedActivation(ai, ma, filterHost) + && !isDisposableManaAbility(ma)) { + return true; + } + } + return false; + } + for (Card c : ai.getCardsIn(ZoneType.Battlefield)) { + for (SpellAbility ma : getAIPlayableMana(c)) { + if (!isCurrentlyAvailableForNestedActivation(ai, ma, filterHost) + || isDisposableManaAbility(ma)) { + continue; + } + return true; + } + } + return false; + } + + /** + * Simulate paying a mana ability's generic activation cost (e.g. a signet's {1}) during test-mode planning. + * Only free sources (no mana activation cost of their own) may pay it, which blocks filter-for-filter + * chains. Consumed sources are removed from the shared candidate pool so they can't be reused. + * + * @return true if the activation cost was fully paid from free sources. + */ + private static boolean simulateNestedActivationCost(final SpellAbility filterAb, + final SpellAbility sa, final Player ai, final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, final CardCollection outTapped, + final ManaPaymentContext ctx) { + return payNestedActivationCost(filterAb, sa, ai, sourcesForShards, manaSpentToPay, testDepositedSurplus, + true, false, outTapped, ctx); + } + + /** + * Production counterpart to {@link #simulateNestedActivationCost}: physically taps the same sources the + * planner chose so Auto-pay matches simulation / feasibility checks. + */ + private static boolean executeNestedActivationCost(final SpellAbility filterAb, + final SpellAbility sa, final Player ai, final ListMultimap sourcesForShards, + final List manaSpentToPay, final boolean effect, final ManaPaymentContext ctx) { + return payNestedActivationCost(filterAb, sa, ai, sourcesForShards, manaSpentToPay, null, false, effect, null, ctx); + } + + private static boolean payNestedActivationCost(final SpellAbility filterAb, + final SpellAbility sa, final Player ai, final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, final boolean test, + final boolean effect, final CardCollection outTapped, final ManaPaymentContext ctx) { + if (filterAb.getPayCosts() == null) { + return true; + } + final CostPartMana costMana = filterAb.getPayCosts().getCostMana(); + if (costMana == null) { + return true; + } + final ManaCost activationMana = costMana.getManaCostFor(filterAb); + if (activationMana.isNoCost() || activationMana.getCMC() == 0) { + return true; + } + + final ManaCostBeingPaid nestedCost = new ManaCostBeingPaid(activationMana); + final Card filterHost = filterAb.getHostCard(); + + // The outer sourcesForShards only lists shards from the spell being cast (e.g. {W}{W} has no + // GENERIC bucket), so build a dedicated map for paying this activation cost ({1}, etc.). + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, true, ctx); + final ListMultimap nestedSourcesForShards = + groupAndOrderToPayShards(ai, manaAbilityMap, nestedCost); + sortManaAbilities(nestedSourcesForShards, manaAbilityMap, sa, nestedCost, ai, ctx); + + // First spend any floating mana in the pool towards the activation cost. + final ManaPool pool = ai.getManaPool(); + pool.payManaCostFromPool(nestedCost, filterAb, test, manaSpentToPay); + if (test) { + spendTestDepositedManaTowardCost(filterAb, nestedCost, pool, testDepositedSurplus, manaSpentToPay); + } + + while (!nestedCost.isPaid()) { + if (test) { + spendTestDepositedManaTowardCost(filterAb, nestedCost, pool, testDepositedSurplus, manaSpentToPay); + if (nestedCost.isPaid()) { + break; + } + } + final String costBefore = nestedCost.toString(); + final ManaCostShard toPay = getNextShardToPay(nestedCost, nestedSourcesForShards); + if (toPay == null) { + return false; + } + final Collection saList = nestedSourcesForShards.get(toPay); + if (saList == null || saList.isEmpty()) { + return false; + } + + // Only free sources may pay a nested activation cost (no other signets/filters), + // and the filter can never tap itself to pay its own cost. + final boolean reusableActivatorAvailable = hasAvailableReusableActivatorForNestedCost(ai, filterHost, ctx); + final List freeCandidates = new ArrayList<>(); + for (SpellAbility ma : saList) { + if (!isFreeManaSourceForNestedActivation(ma, filterHost)) { + continue; + } + // Combo lands (Cascade Bluffs) bank mana; don't spend them paying another filter's activation. + if (hostHasComboConsolidator(ma.getHostCard()) && isManaActivationConsolidator(filterAb)) { + continue; + } + if (!isCurrentlyAvailableForNestedActivation(ai, ma, filterHost)) { + continue; + } + if (reusableActivatorAvailable && isDisposableManaAbility(ma)) { + continue; + } + freeCandidates.add(ma); + } + if (freeCandidates.isEmpty()) { + pool.payManaCostFromPool(nestedCost, filterAb, test, manaSpentToPay); + if (test) { + spendTestDepositedManaTowardCost(filterAb, nestedCost, pool, testDepositedSurplus, manaSpentToPay); + } + if (nestedCost.isPaid()) { + continue; + } + return false; + } + sortFreeSourcesForNestedActivation(freeCandidates, toPay, + shouldReserveColorlessMana(ai, sa)); + + final SpellAbility chosen = chooseSourceForFilterActivation(sa, ai, filterAb, toPay, freeCandidates, + nestedCost, nestedSourcesForShards, test, ctx); + if (chosen == null) { + return false; + } + + if (outTapped != null) { + outTapped.add(chosen.getHostCard()); + } + + if (test) { + final String manaProduced = predictManafromSpellAbility(chosen, ai, toPay, nestedCost); + debugLogNestedTap(true, chosen, ai, toPay, filterHost, ctx); + final String unused = payMultipleMana(nestedCost, manaProduced, ai); + depositNestedManaSurplus(unused, chosen.getHostCard(), ai, testDepositedSurplus); + rememberManaSourceConsumed(ai, chosen); + } else { + debugLogNestedTap(false, chosen, ai, toPay, filterHost, ctx); + if (!executeFreeManaSource(chosen, filterAb, ai, nestedCost, effect)) { + return false; + } + } + nestedSourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(chosen.getHostCard())); + sourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(chosen.getHostCard())); + if (costBefore.equals(nestedCost.toString())) { + return false; + } + } + return true; + } + + /** Pay tap/sac/etc. on a mana source without re-entering generic mana payment (already handled by nested planner). */ + private static boolean payNonManaAbilityCosts(final SpellAbility ma, final Player ai, final boolean effect) { + final Cost adjusted = CostAdjustment.adjust(ma.getPayCosts(), ma, effect); + if (adjusted == null) { + return true; + } + for (final CostPart part : adjusted.getCostParts()) { + if (part instanceof CostPartMana) { + continue; + } + final PaymentDecision pd = part.accept(new AiCostDecision(ai, ma, effect, true)); + if (pd == null || !part.payAsDecided(ai, pd, ma, effect)) { + return false; + } + } + return true; + } + + /** Physically activate a free mana source and apply its mana toward {@code costToPay}. */ + private static boolean executeFreeManaSource(final SpellAbility ma, final SpellAbility saPaidFor, + final Player ai, final ManaCostBeingPaid costToPay, final boolean effect) { + ma.setActivatingPlayer(ai); + if (!ComputerUtilCost.checkForManaSacrificeCost(ai, ma.getPayCosts(), ma, ma.isTrigger())) { + return false; + } + if (!ComputerUtilCost.checkTapTypeCost(ai, ma.getPayCosts(), ma.getHostCard(), saPaidFor, AiCardMemory.getMemorySet(ai, MemorySet.PAYS_TAP_COST))) { + return false; + } + if (!payNonManaAbilityCosts(ma, ai, effect)) { + return false; + } + ai.getGame().getStack().addAndUnfreeze(ma); + ai.getManaPool().payManaFromAbility(saPaidFor, costToPay, ma); + return true; + } + + /** + * Activate a filter after {@link #executeNestedActivationCost} paid its generic activation cost. + * Skips {@link ComputerUtilCost#checkTapTypeCost} on the filter host because the outer payment loop + * already reserved it in {@link MemorySet#PAYS_TAP_COST} before calling here. + */ + private static boolean executeFilterManaSource(final SpellAbility filterAb, final SpellAbility sa, + final Player ai, final ManaCostBeingPaid cost, final ManaCostShard toPay, + final boolean effect, final ManaPool manapool) { + filterAb.setActivatingPlayer(ai); + if (!ComputerUtilCost.checkForManaSacrificeCost(ai, filterAb.getPayCosts(), filterAb, filterAb.isTrigger())) { + return false; + } + refreshExpressChoice(cost, sa, ai, toPay, filterAb); + if (!payNonManaAbilityCosts(filterAb, ai, effect)) { + return false; + } + ai.getGame().getStack().addAndUnfreeze(filterAb); + if (shouldApplyProducedManaToShardOnly(filterAb, cost, toPay, ai)) { + return payFilterManaTowardShardOnly(sa, cost, toPay, filterAb, manapool); + } + manapool.payManaFromAbility(sa, cost, filterAb); + return true; + } + + /** Apply filter mana toward one colored shard; leave other colors in the pool for chaining. */ + private static boolean payFilterManaTowardShardOnly(final SpellAbility sa, final ManaCostBeingPaid cost, + final ManaCostShard toPay, final SpellAbility filterAb, final ManaPool manapool) { + sa.getPayingManaAbilities().add(filterAb); + for (final AbilityManaPart mp : filterAb.getAllManaParts()) { + for (final Mana mana : mp.getLastManaProduced()) { + if (!sa.allowsPayingWithShard(mp.getSourceCard(), mana.getColor())) { + continue; + } + if (cost.getUnpaidShards(toPay) > 0 + && manapool.canPayForShardWithColor(toPay, mana.getColor()) + && manapool.tryPayCostWithMana(sa, cost, mana, false)) { + sa.getPayingMana().add(mana); + return true; + } + } + } + return false; + } + + /** + * Bank mana through a Signet (or similar) then a combo land (Cascade Bluffs), then pay the spell + * from the pool. Covers Signet {@code {B}{R}} -> Bluffs {@code {R}{R}} -> {@code {1}{R}{R}}. + */ + private static boolean tryPayViaManaBankingChain(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, final boolean test, + final boolean effect, final ManaPool manapool, final CardCollection planOut, + final List paymentList, final ManaPaymentContext ctx) { + if (cost.isPaid() || (ctx != null && ctx.inFilterActivationProbe) || countUnpaidPips(cost) < 3) { + return false; + } + final List combos = new ArrayList<>(); + final List signets = new ArrayList<>(); + for (final Card c : ai.getCardsIn(ZoneType.Battlefield)) { + for (final SpellAbility ma : c.getManaAbilities()) { + if (ma.getHostCard() != c) { + continue; + } + if (hasTapCost(ma) + && AiCardMemory.isRememberedCard(ai, c, MemorySet.PAYS_TAP_COST)) { + continue; + } + if (isComboConsolidatingFilter(ma)) { + combos.add(ma); + } else if (isNetPositiveConsolidator(ma)) { + signets.add(ma); + } + } + } + SpellAbility combo = null; + SpellAbility signet = null; + outer: + for (final SpellAbility ma : combos) { + for (final SpellAbility signetMa : signets) { + if (canPayViaSignetThenComboBanking(cost, sa, ai, signetMa, ma, testDepositedSurplus)) { + combo = ma; + signet = signetMa; + break outer; + } + } + } + if (combo == null || signet == null) { + return false; + } + tracePlanStep(test, " bank via " + signet.getHostCard() + " -> " + combo.getHostCard() + + " (paying " + cost + " for " + manaPaymentSpellLabel(sa) + ")", ctx); + return executeSignetThenComboBanking(cost, sa, ai, signet, combo, sourcesForShards, manaSpentToPay, + testDepositedSurplus, test, effect, manapool, planOut, paymentList, ctx); + } + + private static boolean canPayViaSignetThenComboBanking(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final SpellAbility signet, final SpellAbility combo, + final List testDepositedSurplus) { + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + final List poolSnapshot = snapshotPool(ai.getManaPool()); + final List surplus = new ArrayList<>(); + try { + if (!simulateAndBankConsolidator(signet, sa, ai, cost, surplus)) { + return false; + } + if (!simulateAndBankConsolidator(combo, sa, ai, cost, surplus)) { + return false; + } + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + final ManaPool pool = ai.getManaPool(); + final List probeSpent = new ArrayList<>(); + spendTestDepositedManaTowardCost(sa, probe, pool, surplus, probeSpent); + pool.payManaCostFromPool(probe, sa, true, probeSpent); + while (!probe.isPaid() && !pool.isEmpty()) { + boolean found = false; + for (final byte color : ManaAtom.MANATYPES) { + if (pool.tryPayCostWithColor(color, sa, probe, probeSpent)) { + found = true; + break; + } + } + if (!found) { + break; + } + } + return probe.isPaid(); + } finally { + restorePool(ai, poolSnapshot); + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + } + } + + private static boolean executeSignetThenComboBanking(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final SpellAbility signet, final SpellAbility combo, + final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, + final boolean test, final boolean effect, final ManaPool manapool, + final CardCollection planOut, final List paymentList, final ManaPaymentContext ctx) { + if (!activateAndBankConsolidator(signet, sa, ai, cost, sourcesForShards, manaSpentToPay, + testDepositedSurplus, test, effect, planOut, paymentList, ctx)) { + return false; + } + if (!activateAndBankConsolidator(combo, sa, ai, cost, sourcesForShards, manaSpentToPay, + testDepositedSurplus, test, effect, planOut, paymentList, ctx)) { + return false; + } + if (test) { + spendTestDepositedManaTowardCost(sa, cost, manapool, testDepositedSurplus, manaSpentToPay); + } + final boolean hadUnpaid = !cost.isPaid(); + manapool.payManaCostFromPool(cost, sa, test, manaSpentToPay); + while (!cost.isPaid() && !manapool.isEmpty()) { + boolean found = false; + for (final byte color : ManaAtom.MANATYPES) { + if (manapool.tryPayCostWithColor(color, sa, cost, manaSpentToPay)) { + found = true; + break; + } + } + if (!found) { + break; + } + } + if (hadUnpaid && cost.isPaid()) { + recordPlanStep(ctx, " result: PAID (pool)"); + } + return cost.isPaid(); + } + + private static boolean activateAndBankConsolidator(final SpellAbility filterAb, final SpellAbility sa, + final Player ai, final ManaCostBeingPaid spellCost, + final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, + final boolean test, final boolean effect, final CardCollection planOut, + final List paymentList, final ManaPaymentContext ctx) { + paymentList.add(filterAb); + if (hasTapCost(filterAb)) { + AiCardMemory.rememberCard(ai, filterAb.getHostCard(), MemorySet.PAYS_TAP_COST); + } + if (hasManaCost(filterAb)) { + if (test) { + final CardCollection nestedTaps = planOut == null ? null : new CardCollection(); + if (!simulateNestedActivationCost(filterAb, sa, ai, + sourcesForShards == null ? ArrayListMultimap.create() : sourcesForShards, + manaSpentToPay, testDepositedSurplus, nestedTaps, ctx)) { + paymentList.remove(filterAb); + if (hasTapCost(filterAb)) { + AiCardMemory.forgetCard(ai, filterAb.getHostCard(), MemorySet.PAYS_TAP_COST); + } + return false; + } + if (planOut != null && nestedTaps != null && !nestedTaps.isEmpty()) { + planOut.addAll(nestedTaps); + } + } else if (!executeNestedActivationCost(filterAb, sa, ai, sourcesForShards, manaSpentToPay, effect, ctx)) { + paymentList.remove(filterAb); + if (hasTapCost(filterAb)) { + AiCardMemory.forgetCard(ai, filterAb.getHostCard(), MemorySet.PAYS_TAP_COST); + } + return false; + } + } + if (!bankManaAfterActivation(filterAb, sa, ai, spellCost, testDepositedSurplus, test, effect)) { + paymentList.remove(filterAb); + if (hasTapCost(filterAb)) { + AiCardMemory.forgetCard(ai, filterAb.getHostCard(), MemorySet.PAYS_TAP_COST); + } + return false; + } + tracePlanStep(test, " tap " + filterAb.getHostCard() + " -> " + + formatManaProducedForLog(filterAb, ai, ManaCostShard.GENERIC, spellCost) + + " (bank for " + manaPaymentSpellLabel(sa) + ")", ctx); + if (planOut != null) { + planOut.add(filterAb.getHostCard()); + } + sourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(filterAb.getHostCard())); + if (test) { + rememberManaSourceConsumed(ai, filterAb); + } + return true; + } + + private static boolean simulateAndBankConsolidator(final SpellAbility filterAb, final SpellAbility sa, + final Player ai, final ManaCostBeingPaid spellCost, final List surplus) { + if (filterAb.getPayCosts() != null && filterAb.getPayCosts().hasManaCost()) { + if (!simulateNestedActivationCost(filterAb, sa, ai, ArrayListMultimap.create(), null, surplus, null, + ManaPaymentContext.outer())) { + return false; + } + } + if (isMultiManaComboAbility(filterAb)) { + setComboManaChoice(ai, filterAb, spellCost); + } + String produced = predictManafromSpellAbility(filterAb, ai, ManaCostShard.GENERIC); + if (isMultiManaComboAbility(filterAb)) { + produced = capComboManaProduced(produced, getComboManaAmount(filterAb)); + } + depositNestedManaSurplus(produced, filterAb.getHostCard(), ai, surplus); + return true; + } + + private static boolean bankManaAfterActivation(final SpellAbility filterAb, final SpellAbility sa, + final Player ai, final ManaCostBeingPaid spellCost, final List testDepositedSurplus, + final boolean test, final boolean effect) { + if (isMultiManaComboAbility(filterAb)) { + setComboManaChoice(ai, filterAb, spellCost); + } + if (test) { + String produced = predictManafromSpellAbility(filterAb, ai, ManaCostShard.GENERIC); + if (isMultiManaComboAbility(filterAb)) { + produced = capComboManaProduced(produced, getComboManaAmount(filterAb)); + } + depositNestedManaSurplus(produced, filterAb.getHostCard(), ai, testDepositedSurplus); + return true; + } + filterAb.setActivatingPlayer(ai); + if (!ComputerUtilCost.checkForManaSacrificeCost(ai, filterAb.getPayCosts(), filterAb, filterAb.isTrigger())) { + return false; + } + if (!payNonManaAbilityCosts(filterAb, ai, effect)) { + return false; + } + ai.getGame().getStack().addAndUnfreeze(filterAb); + return true; + } + + private static boolean poolOrDepositedCanPayShard(final Player ai, final ManaCostBeingPaid cost, + final ManaCostShard toPay, final ManaPaymentContext ctx) { + if (toPay == null || toPay.isGeneric() || toPay == ManaCostShard.COLORLESS || toPay == ManaCostShard.X + || cost.getUnpaidShards(toPay) <= 0) { + return false; + } + final ManaPool pool = ai.getManaPool(); + for (final Mana m : pool) { + if (pool.canPayForShardWithColor(toPay, m.getColor())) { + return true; + } + } + final List deposited = ctx != null ? ctx.testDepositedSurplus : null; + if (deposited != null) { + for (final Mana m : deposited) { + if (pool.canPayForShardWithColor(toPay, m.getColor())) { + return true; + } + } + } + return false; + } + + private static boolean tryApplyConsolidatingFilter(final ManaCostBeingPaid cost, final SpellAbility sa, + final Player ai, final ListMultimap sourcesForShards, + final List manaSpentToPay, final List testDepositedSurplus, final boolean test, + final boolean effect, final ManaPool manapool, final CardCollection planOut, + final List paymentList, final ManaPaymentContext ctx) { + if (countUnpaidPips(cost) < 2 || sourcesForShards == null) { + return false; + } + final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + SpellAbility best = null; + int bestConsumed = Integer.MAX_VALUE; + ManaCostShard bestShard = null; + for (final SpellAbility ma : sourcesForShards.values()) { + if (!seen.add(ma) || !isNetPositiveConsolidator(ma)) { continue; } - - // these should come last since they reserve the paying cards - // (this means if a mana ability has both parts it doesn't currently undo reservations if the second part fails) - if (!ComputerUtilCost.checkForManaSacrificeCost(ai, ma.getPayCosts(), ma, ma.isTrigger())) { + if (!consolidatorCoversSpellCost(ma, cost, sa, ai, ctx)) { continue; } - if (!ComputerUtilCost.checkTapTypeCost(ai, ma.getPayCosts(), ma.getHostCard(), sa, AiCardMemory.getMemorySet(ai, MemorySet.PAYS_TAP_COST))) { + final Set consumed = collectCardsConsumedByPayment(ma, sa, ai, ctx); + if (consumed == null) { continue; } + final ManaCostShard shard = shardForConsolidatorProbe(cost); + final int cardsConsumed = effectiveCardsConsumedForPayment(cost, sa, ai, shard, ma, consumed); + if (cardsConsumed < bestConsumed) { + bestConsumed = cardsConsumed; + best = ma; + bestShard = shard; + } + } + if (best == null || bestShard == null) { + return false; + } + final Set sacSnapshot = snapshotMemory(ai, MemorySet.PAYS_SAC_COST); + final Set tapSnapshot = snapshotMemory(ai, MemorySet.PAYS_TAP_COST); + if (!passesManaPaymentReservationChecks(ai, best, sa)) { + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); + return false; + } + restoreMemory(ai, MemorySet.PAYS_SAC_COST, sacSnapshot); + restoreMemory(ai, MemorySet.PAYS_TAP_COST, tapSnapshot); - return paymentChoice; + paymentList.add(best); + if (hasTapCost(best)) { + AiCardMemory.rememberCard(ai, best.getHostCard(), MemorySet.PAYS_TAP_COST); } - return null; + // Verbose trace only; the plan gets the "tap ..." step from applyChosenManaPayment below. + debugLogMain(test, " consolidate via " + best.getHostCard() + " (paying " + cost + " for " + + manaPaymentSpellLabel(sa) + ")", ctx); + if (!applyChosenManaPayment(best, sa, ai, cost, bestShard, sourcesForShards, manaSpentToPay, + testDepositedSurplus, test, effect, manapool, planOut, ctx)) { + paymentList.remove(best); + if (hasTapCost(best)) { + AiCardMemory.forgetCard(ai, best.getHostCard(), MemorySet.PAYS_TAP_COST); + } + return false; + } + if (planOut != null) { + planOut.add(best.getHostCard()); + } + if (test) { + rememberManaSourceConsumed(ai, best); + } + return true; + } + + /** + * Apply a chosen mana source to {@code cost}. Test mode simulates; production executes the same plan + * (including nested filter activation costs) so Auto-pay matches feasibility / simulation output. + */ + private static boolean applyChosenManaPayment(final SpellAbility saPayment, final SpellAbility sa, + final Player ai, final ManaCostBeingPaid cost, final ManaCostShard toPay, + final ListMultimap sourcesForShards, final List manaSpentToPay, + final List testDepositedSurplus, final boolean test, final boolean effect, + final ManaPool manapool, final CardCollection outTapped, final ManaPaymentContext ctx) { + if (test) { + if (saPayment.getPayCosts() != null && saPayment.getPayCosts().hasManaCost()) { + if (!simulateNestedActivationCost(saPayment, sa, ai, sourcesForShards, manaSpentToPay, + testDepositedSurplus, outTapped, ctx)) { + return false; + } + } + if (isMultiManaComboAbility(saPayment)) { + setComboManaChoice(ai, saPayment, cost); + } + final String manaProduced = formatManaProducedForLog(saPayment, ai, toPay, cost); + final ManaCostBeingPaid costBefore = new ManaCostBeingPaid(cost); + final String unused = shouldApplyProducedManaToShardOnly(saPayment, cost, toPay, ai) + ? payProducedManaTowardShard(cost, manaProduced, toPay, ai) + : payMultipleMana(cost, manaProduced, ai); + debugLogTap(true, saPayment, sa, formatShardsPaidDiff(costBefore, cost, toPay), manaProduced, ctx); + depositNestedManaSurplus(unused, saPayment.getHostCard(), ai, testDepositedSurplus); + } else if (saPayment.getPayCosts() != null && saPayment.getPayCosts().hasManaCost()) { + if (!executeNestedActivationCost(saPayment, sa, ai, sourcesForShards, manaSpentToPay, effect, ctx)) { + return false; + } + final String manaProduced = formatManaProducedForLog(saPayment, ai, toPay, cost); + final ManaCostBeingPaid costBefore = new ManaCostBeingPaid(cost); + if (!executeFilterManaSource(saPayment, sa, ai, cost, toPay, effect, manapool)) { + return false; + } + debugLogTap(false, saPayment, sa, formatShardsPaidDiff(costBefore, cost, toPay), manaProduced, ctx); + } else { + if (isMultiManaComboAbility(saPayment)) { + setComboManaChoice(ai, saPayment, cost); + } + if (!test) { + setProductionTapSource(saPayment); + } + final CostPayment pay = new CostPayment(saPayment.getPayCosts(), saPayment); + if (!pay.payComputerCosts(new AiCostDecision(ai, saPayment, effect, true))) { + return false; + } + final String manaProduced = formatManaProducedForLog(saPayment, ai, toPay, cost); + final ManaCostBeingPaid costBefore = new ManaCostBeingPaid(cost); + ai.getGame().getStack().addAndUnfreeze(saPayment); + manapool.payManaFromAbility(sa, cost, saPayment); + debugLogTap(false, saPayment, sa, formatShardsPaidDiff(costBefore, cost, toPay), manaProduced, ctx); + } + sourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(saPayment.getHostCard())); + return true; + } + + /** + * Choose which free source pays a filter's generic activation cost, preferring the source that keeps + * the most castable spells in hand and command zone afterwards (castability-aware). + * Falls back to {@link #chooseManaAbility}. + */ + private static SpellAbility chooseSourceForFilterActivation(final SpellAbility sa, final Player ai, + final SpellAbility filterAb, final ManaCostShard toPay, final List candidates, + final ManaCostBeingPaid nestedCost, final ListMultimap sourcesForShards, + final boolean test, final ManaPaymentContext ctx) { + if (candidates.size() == 1) { + // Sole free source for a nested activation; reservation runs when the payment is applied. + return candidates.get(0); + } + if (candidates.stream().allMatch(ComputerUtilMana::isDisposableManaAbility)) { + final List sorted = Lists.newArrayList(candidates); + sortFreeSourcesForNestedActivation(sorted, toPay, shouldReserveColorlessMana(ai, sa)); + return sorted.get(0); + } + if (ctx == null || ctx.inFilterActivationProbe + || !CastabilityProbe.shouldUseForNestedActivation(sa, test, ctx)) { + return chooseManaAbility(nestedCost, sa, ai, toPay, candidates, true); + } + + final SpellAbility best = CastabilityProbe.pickBest(nestedCost, candidates, sa, ai, toPay, + (cand, spell, player, probeCtx) -> { + final Set consumed = new HashSet<>(); + consumed.add(cand.getHostCard()); + consumed.add(filterAb.getHostCard()); + return consumed; + }, false, test, ctx); + if (best != null) { + debugLog(test, " candidate for " + filterAb.getHostCard() + " {1}: " + best.getHostCard() + + " chosen by castability probe"); + return best; + } + // fall back to the standard chooser if the heuristic couldn't decide + return chooseManaAbility(nestedCost, sa, ai, toPay, candidates, true); + } + + /** Nested castability dry-run from {@link CastabilityProbe}. */ + static boolean payManaCostForCastabilityProbe(final forge.game.cost.Cost cost, final SpellAbility sa, + final Player ai, final ComputerUtilMana.ManaPaymentContext ctx) { + return payManaCost(cost, sa, ai, true, 0, true, false, ctx.nestedWithFilterProbe()); + } + + /** Test hook: reset castability nested dry-run counter. */ + public static void resetCastabilityProbeDryRunCountForTests() { + CastabilityProbe.resetDryRunCountForTests(); + } + + /** Test hook: nested castability dry-runs since last reset. */ + public static int getCastabilityProbeDryRunCountForTests() { + return CastabilityProbe.getDryRunCountForTests(); } public static String predictManaReplacement(SpellAbility saPayment, Player ai, ManaCostShard toPay) { Card hostCard = saPayment.getHostCard(); + if (hostCard == null) { + return ""; + } Game game = hostCard.getGame(); String manaProduced = toPay.isSnow() && hostCard.isSnow() ? "S" : GameActionUtil.generatedTotalMana(saPayment); @@ -525,7 +4822,165 @@ public static String predictManaReplacement(SpellAbility saPayment, Player ai, M return manaProduced; } + /** + * Predict bonus mana from a {@link TriggerType#TapsForMana} {@code DB$ Mana} subability. + * {@code landManaAlready} is what the tapped source produced before triggers (so combo/any + * choices target only the remaining unpaid cost). + */ + private static String predictTriggerManaProduction(final SpellAbility trSA, final Player ai, + final ManaCostShard toPay, final ManaCostBeingPaid costHint, final String landManaAlready) { + final AbilityManaPart mp = trSA.getManaPart(); + if (mp == null) { + return ""; + } + trSA.setActivatingPlayer(ai); + final int pAmount = AbilityUtils.calculateAmount(trSA.getHostCard(), + trSA.getParamOrDefault("Amount", "1"), trSA); + final String producedParam = trSA.getParam("Produced"); + if (producedParam == null) { + return ""; + } + if ("Chosen".equals(producedParam)) { + final String chosen = MagicColor.toShortString(trSA.getHostCard().getChosenColor()); + return StringUtils.repeat(chosen, " ", pAmount); + } + if (mp.isComboMana()) { + return predictComboTriggerMana(trSA, ai, toPay, costHint, landManaAlready, pAmount); + } + if (mp.isAnyMana()) { + return predictAnyTriggerMana(ai, toPay, costHint, landManaAlready, pAmount); + } + return StringUtils.repeat(producedParam, " ", pAmount); + } + + private static String predictComboTriggerMana(final SpellAbility trSA, final Player ai, + final ManaCostShard toPay, final ManaCostBeingPaid costHint, final String landManaAlready, + final int pAmount) { + if (costHint != null) { + final ManaCostBeingPaid probe = new ManaCostBeingPaid(costHint); + if (!StringUtils.isBlank(landManaAlready)) { + payMultipleMana(probe, landManaAlready.trim(), ai); + } + if (!probe.isPaid()) { + final String choice = buildComboManaChoiceString(ai, trSA, probe, pAmount, + requiresDifferentComboColors(trSA.getManaPart())); + if (!StringUtils.isBlank(choice) && !"0".equals(choice)) { + return choice; + } + } + } + return predictComboTriggerManaForRegistration(trSA, toPay, pAmount); + } + + /** When no cost context exists, expose every combo color so shard buckets stay accurate. */ + private static String predictComboTriggerManaForRegistration(final SpellAbility trSA, + final ManaCostShard toPay, final int pAmount) { + final AbilityManaPart mp = trSA.getManaPart(); + final String comboColors = mp.getComboColors(trSA); + if (StringUtils.isBlank(comboColors)) { + return ""; + } + if (toPay != null && !toPay.isGeneric() && toPay != ManaCostShard.COLORLESS && !toPay.isPhyrexian()) { + final String shardColor = toPay.toShortString(); + if (comboColors.contains(shardColor)) { + return StringUtils.repeat(shardColor, " ", pAmount); + } + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < pAmount; i++) { + for (final String color : comboColors.split(" ")) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(color); + } + } + return sb.toString(); + } + + private static String predictAnyTriggerMana(final Player ai, final ManaCostShard toPay, + final ManaCostBeingPaid costHint, final String landManaAlready, final int pAmount) { + ManaCostBeingPaid probe = null; + if (costHint != null) { + probe = new ManaCostBeingPaid(costHint); + if (!StringUtils.isBlank(landManaAlready)) { + payMultipleMana(probe, landManaAlready.trim(), ai); + } + } + final String color = MagicColor.toShortString(pickColorForAnyMana(ai, null, toPay, probe, null)); + return StringUtils.repeat(color, " ", pAmount); + } + + /** + * Pick a color for {@code Produced$ Any} (shard payment, trigger bonus, or dry-run probe). + * Returns {@code 0} when no color can pay {@code toPay}. + */ + private static byte pickColorForAnyMana(final Player ai, final SpellAbility saPaidFor, + final ManaCostShard toPay, final ManaCostBeingPaid remainingCost, final Card sourceCard) { + if (toPay != null && toPay.isOr2Generic()) { + final byte c = toPay.getColorMask(); + if (canUseAnyManaColorForShard(ai, saPaidFor, sourceCard, toPay, c)) { + return c; + } + return 0; + } + if (remainingCost != null) { + for (final ManaCostShard shard : remainingCost.getDistinctShards()) { + if (shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard.isPhyrexian()) { + continue; + } + if (remainingCost.getUnpaidShards(shard) > 0) { + final byte c = ManaAtom.fromName(shard.toShortString()); + final ManaCostShard probeShard = toPay != null ? toPay : shard; + if (canUseAnyManaColorForShard(ai, saPaidFor, sourceCard, probeShard, c)) { + return c; + } + } + } + } + if (toPay != null && !toPay.isGeneric() && toPay != ManaCostShard.COLORLESS && !toPay.isPhyrexian()) { + final byte c = toPay.getColorMask(); + if (canUseAnyManaColorForShard(ai, saPaidFor, sourceCard, toPay, c)) { + return c; + } + } + for (final byte c : MagicColor.WUBRG) { + if (toPay != null && canUseAnyManaColorForShard(ai, saPaidFor, sourceCard, toPay, c)) { + return c; + } + if (toPay == null && (sourceCard == null || saPaidFor == null + || saPaidFor.allowsPayingWithShard(sourceCard, c))) { + return c; + } + } + final String prominent = ComputerUtilCard.getMostProminentColor(ai.getCardsIn(ZoneType.Hand)); + if (!StringUtils.isBlank(prominent)) { + return MagicColor.fromName(MagicColor.toShortString(prominent)); + } + return MagicColor.WHITE; + } + + private static boolean canUseAnyManaColorForShard(final Player ai, final SpellAbility saPaidFor, + final Card sourceCard, final ManaCostShard shard, final byte color) { + if (sourceCard != null && saPaidFor != null + && !saPaidFor.allowsPayingWithShard(sourceCard, color)) { + return false; + } + return shard == null || shard.isGeneric() || shard == ManaCostShard.COLORLESS + || ai.getManaPool().canPayForShardWithColor(shard, color); + } + + private static String pickAnyManaColorForTrigger(final Player ai, final ManaCostShard toPay, + final ManaCostBeingPaid remainingCost) { + return MagicColor.toShortString(pickColorForAnyMana(ai, null, toPay, remainingCost, null)); + } + public static String predictManafromSpellAbility(SpellAbility saPayment, Player ai, ManaCostShard toPay) { + return predictManafromSpellAbility(saPayment, ai, toPay, null); + } + + public static String predictManafromSpellAbility(SpellAbility saPayment, Player ai, ManaCostShard toPay, + final ManaCostBeingPaid costHint) { Card hostCard = saPayment.getHostCard(); StringBuilder manaProduced = new StringBuilder(predictManaReplacement(saPayment, ai, toPay)); @@ -546,12 +5001,10 @@ public static String predictManafromSpellAbility(SpellAbility saPayment, Player continue; } if (ApiType.Mana.equals(trSA.getApi())) { - int pAmount = AbilityUtils.calculateAmount(trSA.getHostCard(), trSA.getParamOrDefault("Amount", "1"), trSA); - String produced = trSA.getParam("Produced"); - if (produced.equals("Chosen")) { - produced = MagicColor.toShortString(trSA.getHostCard().getChosenColor()); + final String bonus = predictTriggerManaProduction(trSA, ai, toPay, costHint, originalProduced); + if (!bonus.isEmpty()) { + manaProduced.append(' ').append(bonus); } - manaProduced.append(" ").append(StringUtils.repeat(produced, " ", pAmount)); } else if (ApiType.ManaReflected.equals(trSA.getApi())) { final String colorOrType = trSA.getParamOrDefault("ColorOrType", "Color"); // currently Color or Type, Type is colors + colorless @@ -592,20 +5045,61 @@ public static String predictManafromSpellAbility(SpellAbility saPayment, Player return manaProduced.toString(); } - // returns null if unpayable - private static List payManaCost(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean test, boolean checkPlayable, boolean effect) { + /** + * Dry-run the unified mana planner and return the host cards it would consume (including nested + * activators). Uses the same path as {@link #canPayManaCost} and production {@link #payManaCost}. + */ + public static CardCollection getManaSourcesToPayCost(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai) { + return getManaSourcesToPayCost(cost, sa, ai, false); + } + + /** @return cards Auto would tap to pay, or null if the cost can't be paid */ + public static CardCollection getManaSourcesToPayCostIfAble(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai) { + final ManaCostBeingPaid costCopy = new ManaCostBeingPaid(cost); + final CardCollection plan = getManaSourcesToPayCost(costCopy, sa, ai, false); + return costCopy.isPaid() ? plan : null; + } + + private static boolean payManaCost(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean test, boolean checkPlayable, boolean effect, final CardCollection planOut, final ManaPaymentContext ctx) { + if (ai == null || sa == null || cost == null || ctx == null) { + return false; + } + final boolean outermost = ctx.isOutermost(); + List manaSpentToPay = null; + List testDepositedSurplus = null; + List paymentList = null; + List poolSnapshotAtStart = null; + final ManaPool manapool = ai.getManaPool(); + final String planCostLabel = cost.toString(); + try { if ((sa.isOffering() && sa.getSacrificedAsOffering() == null) || (sa.isEmerge() && sa.getSacrificedAsEmerge() == null)) { // nothing was chosen - return null; + return false; } - AiCardMemory.clearMemorySet(ai, MemorySet.PAYS_TAP_COST); - AiCardMemory.clearMemorySet(ai, MemorySet.PAYS_SAC_COST); + if (outermost) { + AiCardMemory.clearMemorySet(ai, MemorySet.PAYS_TAP_COST); + AiCardMemory.clearMemorySet(ai, MemorySet.PAYS_SAC_COST); + if (shouldTracePaymentPlan(sa, test, ctx) && ctx.planSteps == null) { + ctx.planSteps = new ArrayList<>(); + } + if (!test) { + beginProductionPayment(cost); + } + } adjustManaCostToAvoidNegEffects(cost, sa.getHostCard(), ai); - List manaSpentToPay = test ? new ArrayList<>() : sa.getPayingMana(); - List paymentList = Lists.newArrayList(); - final ManaPool manapool = ai.getManaPool(); + debugLogMain(test, "paying " + cost + " for " + manaPaymentSpellLabel(sa), ctx); + + manaSpentToPay = test ? new ArrayList<>() : sa.getPayingMana(); + if (test) { + testDepositedSurplus = ctx.testDepositedSurplus != null ? ctx.testDepositedSurplus : new ArrayList<>(); + ctx.testDepositedSurplus = testDepositedSurplus; + } + paymentList = Lists.newArrayList(); + if (test && outermost) { + poolSnapshotAtStart = snapshotPool(manapool); + } // Apply color/type conversion matrix if necessary (already done via autopay) if (ai.getControllingPlayer() == null) { @@ -627,14 +5121,15 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA // not worth checking if it makes sense to not spend floating first if (manapool.payManaCostFromPool(cost, sa, test, manaSpentToPay)) { CostPayment.handleOfferings(sa, test, cost.isPaid()); + debugLogResult(test, true, " result: PAID (pool)", ctx); // paid all from floating mana - return manaSpentToPay; + return true; } int phyLifeToPay = 2; boolean purePhyrexian = cost.containsOnlyPhyrexianMana(); boolean hasConverge = sa.getHostCard().hasConverge(); - ListMultimap sourcesForShards = getSourcesForShards(cost, sa, ai, test, checkPlayable, hasConverge); + ListMultimap sourcesForShards = getSourcesForShards(cost, sa, ai, test, checkPlayable, hasConverge, ctx); int testEnergyPool = ai.getCounters(CounterEnumType.ENERGY); ManaCostShard toPay = null; @@ -642,10 +5137,27 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA // Loop over mana needed while (!cost.isPaid()) { + if (test) { + final ManaCostBeingPaid beforeSurplus = new ManaCostBeingPaid(cost); + spendTestDepositedManaTowardCost(sa, cost, manapool, testDepositedSurplus, manaSpentToPay); + if (beforeSurplus.getUnpaidShards().size() != cost.getUnpaidShards().size()) { + tracePlanStep(true, " pool pays " + formatShardsPaidDiff(beforeSurplus, cost, ManaCostShard.GENERIC) + + " for " + manaPaymentSpellLabel(sa), ctx); + } + if (cost.isPaid()) { + break; + } + } while (!cost.isPaid() && !manapool.isEmpty()) { + if (hasUnpaidColoredShards(cost) && hasUntappedConsolidatorWithManaActivationCost(ai) + && !poolCanPayAnyUnpaidColoredShard(cost, sa, manapool)) { + break; + } boolean found = false; for (byte color : ManaAtom.MANATYPES) { if (manapool.tryPayCostWithColor(color, sa, cost, manaSpentToPay)) { + tracePlanStep(test, " pool pays " + MagicColor.toShortString(color) + + " for " + manaPaymentSpellLabel(sa), ctx); found = true; break; } @@ -663,7 +5175,20 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA break; } + if (tryPayViaManaBankingChain(cost, sa, ai, sourcesForShards, manaSpentToPay, testDepositedSurplus, + test, effect, manapool, planOut, paymentList, ctx)) { + continue; + } + + if (tryApplyConsolidatingFilter(cost, sa, ai, sourcesForShards, manaSpentToPay, testDepositedSurplus, + test, effect, manapool, planOut, paymentList, ctx)) { + continue; + } + toPay = getNextShardToPay(cost, sourcesForShards); + if (toPay == null) { + break; + } Collection saList = null; if (hasConverge && @@ -691,8 +5216,18 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA } saList.removeAll(saExcludeList); + if (toPay != null && toPay.isGeneric() && !saList.isEmpty()) { + final GenericColorPreference pref = resolveGenericColorPreference(ai, sa); + final List sorted = Lists.newArrayList(saList); + final int unpaidGeneric = cost.getGenericManaAmount(); + sorted.sort((a, b) -> compareGenericCandidatesForPayment(a, b, pref, unpaidGeneric, sa, ai)); + saList = sorted; + } + debugLogMain(test, " shard " + toPay + " candidates: " + saList, ctx); - SpellAbility saPayment = saList.isEmpty() ? null : chooseManaAbility(cost, sa, ai, toPay, saList, checkPlayable || !test); + SpellAbility saPayment = saList.isEmpty() ? null + : chooseManaAbilityForShard(cost, sa, ai, toPay, saList, checkPlayable || !test, test, ctx); + debugLogMain(test, " chosen for " + toPay + ": " + (saPayment == null ? "(none)" : saPayment.getHostCard()), ctx); if (saPayment != null && ComputerUtilCost.isSacrificeSelfCost(saPayment.getPayCosts()) && sa.isTargeting(saPayment.getHostCard())) { // not a good idea to sac a card that you're targeting with the SA you're paying for @@ -707,6 +5242,9 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA } if (saPayment == null) { + if (test && ctx != null && ctx.inFilterActivationProbe) { + CastabilityProbe.recordNoSourceColoredShardFailure(ctx, toPay, saList); + } boolean lifeInsteadOfBlack = toPay.isBlack() && ai.hasKeyword("PayLifeInsteadOf:B"); if ((!toPay.isPhyrexian() && !lifeInsteadOfBlack) || !ai.canPayLife(phyLifeToPay, false, sa) || (ai.getLife() <= phyLifeToPay && !ai.cantLoseForZeroOrLessLife())) { @@ -744,13 +5282,14 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA } paymentList.add(saPayment); - if (saPayment.getPayCosts().hasTapCost()) { + if (hasTapCost(saPayment)) { AiCardMemory.rememberCard(ai, saPayment.getHostCard(), MemorySet.PAYS_TAP_COST); } if (test) { // Check energy when testing - CostPayEnergy energyCost = saPayment.getPayCosts().getCostEnergy(); + final Cost payCosts = payCostsOf(saPayment); + CostPayEnergy energyCost = payCosts != null ? payCosts.getCostEnergy() : null; if (energyCost != null) { testEnergyPool -= Integer.parseInt(energyCost.getAmount()); if (testEnergyPool < 0) { @@ -758,28 +5297,36 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA break; } } + } - String manaProduced = predictManafromSpellAbility(saPayment, ai, toPay); - payMultipleMana(cost, manaProduced, ai); - - // remove to prevent re-usage since resources don't get consumed - sourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(saPayment.getHostCard())); - } else { - final CostPayment pay = new CostPayment(saPayment.getPayCosts(), saPayment); - if (!pay.payComputerCosts(new AiCostDecision(ai, saPayment, effect, true))) { + if (!applyChosenManaPayment(saPayment, sa, ai, cost, toPay, sourcesForShards, manaSpentToPay, + testDepositedSurplus, test, effect, manapool, planOut, ctx)) { + if (hasManaCost(saPayment)) { + debugLogResult(test, false, " reject " + saPayment.getHostCard() + + " (nested activation cost unpayable)", ctx); + } + saExcludeList.add(saPayment); + paymentList.remove(saPayment); + if (hasTapCost(saPayment)) { + AiCardMemory.forgetCard(ai, saPayment.getHostCard(), MemorySet.PAYS_TAP_COST); + } + if (!test) { saList.remove(saPayment); - continue; } + continue; + } - ai.getGame().getStack().addAndUnfreeze(saPayment); - // subtract mana from mana pool - manapool.payManaFromAbility(sa, cost, saPayment); + if (planOut != null) { + planOut.add(saPayment.getHostCard()); + } + rememberManaSourceConsumed(ai, saPayment); - // need to consider if another use is now prevented - if (!cost.isPaid() && saPayment.isActivatedAbility() && !saPayment.getRestrictions().canPlay(saPayment.getHostCard(), saPayment)) { - sourcesForShards.values().removeIf(s -> s == saPayment); - } + if (!cost.isPaid() && saPayment.isActivatedAbility() + && !saPayment.getRestrictions().canPlay(saPayment.getHostCard(), saPayment)) { + sourcesForShards.values().removeIf(s -> s == saPayment); + } + if (!test) { if (hasConverge) { // hack to prevent converge re-using sources sourcesForShards.values().removeIf(CardTraitPredicates.isHostCard(saPayment.getHostCard())); @@ -789,35 +5336,55 @@ private static List payManaCost(final ManaCostBeingPaid cost, final SpellA CostPayment.handleOfferings(sa, test, cost.isPaid()); -// if (DEBUG_MANA_PAYMENT) { -// System.err.printf("%s > [%s] payment has %s (%s +%d) for (%s) %s:%n\t%s%n%n", -// FThreads.debugGetCurrThreadId(), test ? "test" : "PROD", cost.isPaid() ? "*PAID*" : "failed", originalCost, -// extraMana, sa.getHostCard(), sa.toUnsuppressedString(), StringUtils.join(paymentPlan, "\n\t")); -// } - // The cost is still unpaid, so refund the mana and report if (!cost.isPaid()) { - manapool.refundMana(manaSpentToPay); - if (test) { - resetPayment(paymentList); - } else { - System.out.println("ComputerUtilMana: payManaCost() cost was not paid for " + sa + " (" + sa.getHostCard().getName() + "). Didn't find what to pay for " + toPay); + debugLogResult(test, false, " result: FAILED (unpaid " + toPay + ") for " + + manaPaymentSpellLabel(sa), ctx); + if (!test) { + manapool.refundMana(manaSpentToPay); sa.setSkip(true); } - return null; + return false; } - if (test) { - manapool.refundMana(manaSpentToPay); - resetPayment(paymentList); - } + debugLogResult(test, true, " result: PAID", ctx); - return manaSpentToPay; + return true; + } finally { + if (outermost && !test) { + endProductionPayment(); + } + if (outermost && ctx.planSteps != null && !ctx.planSteps.isEmpty()) { + printPaymentPlanToConsole(test, planCostLabel, sa, ctx.planSteps, cost.isPaid(), ctx); + } + if (test) { + cleanupTestManaPayment(ai, manaSpentToPay, testDepositedSurplus); + if (outermost) { + ctx.testDepositedSurplus = null; + } + if (paymentList != null) { + resetPayment(ai, paymentList); + } + if (outermost && poolSnapshotAtStart != null) { + restorePool(ai, poolSnapshotAtStart); + } + } + } } - private static void resetPayment(List payments) { + private static void resetPayment(final Player ai, final List payments) { for (SpellAbility sa : payments) { sa.getManaPart().clearExpressChoice(); + if (ai == null) { + continue; + } + final Card host = sa.getHostCard(); + if (sa.getPayCosts() != null && sa.getPayCosts().hasTapCost()) { + AiCardMemory.forgetCard(ai, host, MemorySet.PAYS_TAP_COST); + } + if (ComputerUtilCost.isSacrificeSelfCost(sa.getPayCosts())) { + AiCardMemory.forgetCard(ai, host, MemorySet.PAYS_SAC_COST); + } } } @@ -826,16 +5393,15 @@ private static void resetPayment(List payments) { */ private static ListMultimap getSourcesForShards(final ManaCostBeingPaid cost, final SpellAbility sa, final Player ai, final boolean test, final boolean checkPlayable, - final boolean hasConverge) { + final boolean hasConverge, final ManaPaymentContext ctx) { // arrange all mana abilities by color produced. - final ListMultimap manaAbilityMap = groupSourcesByManaColor(ai, checkPlayable); + final ListMultimap manaAbilityMap = getOrBuildManaAbilityMap(ai, checkPlayable, ctx); + debugLogMain(test, " source colors: " + manaAbilityMap, ctx); if (manaAbilityMap.isEmpty()) { // no mana abilities, bailing out + debugLogMain(test, " no playable mana abilities found", ctx); return null; } - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: manaAbilityMap = " + manaAbilityMap); - } // select which abilities may be used for each shard ListMultimap sourcesForShards = groupAndOrderToPayShards(ai, manaAbilityMap, cost); @@ -855,74 +5421,149 @@ private static ListMultimap getSourcesForShards(fin } } - sortManaAbilities(sourcesForShards, manaAbilityMap, sa); - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: sourcesForShards = " + sourcesForShards); - } + sortManaAbilities(sourcesForShards, manaAbilityMap, sa, cost, ai, ctx); + debugLogMain(test, " sources by shard: " + sourcesForShards, ctx); return sourcesForShards; } + private static String capComboManaProduced(final String manaProduced, final int maxMana) { + if (manaProduced == null || manaProduced.isEmpty() || maxMana <= 0) { + return manaProduced; + } + final String[] parts = TextUtil.split(manaProduced, ' '); + if (parts.length <= maxMana) { + return manaProduced; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < maxMana; i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(parts[i]); + } + return sb.toString(); + } + + private static boolean requiresDifferentComboColors(final AbilityManaPart mp) { + return mp != null && mp.getOrigProduced().contains("Different"); + } + private static void setComboManaChoice(final Player ai, final SpellAbility manaAb, final ManaCostBeingPaid cost) { - final StringBuilder choiceString = new StringBuilder(); final AbilityManaPart comboMana = manaAb.getManaPart(); + final int amount = manaAb.hasParam("Amount") ? AbilityUtils.calculateAmount(manaAb.getHostCard(), manaAb.getParam("Amount"), manaAb) : 1; + final String expressHint = comboMana.getExpressChoice(); + comboMana.clearExpressChoice(); + final String preferredColor = expressHint != null && !expressHint.isEmpty() && !expressHint.contains(" ") + ? expressHint : ""; + final String choices = buildComboManaChoiceString(ai, manaAb, cost, amount, + requiresDifferentComboColors(comboMana), preferredColor); + comboMana.setExpressChoice(choices); + } + + private static String buildComboManaChoiceString(final Player ai, final SpellAbility manaAb, + final ManaCostBeingPaid cost, final int amount, final boolean different) { + return buildComboManaChoiceString(ai, manaAb, cost, amount, different, ""); + } - int amount = manaAb.hasParam("Amount") ? AbilityUtils.calculateAmount(manaAb.getHostCard(), manaAb.getParam("Amount"), manaAb) : 1; + private static String buildComboManaChoiceString(final Player ai, final SpellAbility manaAb, + final ManaCostBeingPaid cost, final int amount, final boolean different, final String preferredColor) { + final StringBuilder choiceString = new StringBuilder(); + final AbilityManaPart comboMana = manaAb.getManaPart(); final ManaCostBeingPaid testCost = new ManaCostBeingPaid(cost); final String[] comboColors = comboMana.getComboColors(manaAb).split(" "); + ColorSet remainingOptions = ColorSet.fromNames(comboColors); + for (int nMana = 1; nMana <= amount; nMana++) { String choice = ""; - // Use expressChoice first - if (!comboMana.getExpressChoice().isEmpty()) { - choice = comboMana.getExpressChoice(); - comboMana.clearExpressChoice(); - byte colorMask = ManaAtom.fromName(choice); - if (manaAb.canProduce(choice) && satisfiesColorChoice(comboMana, choiceString, choice) && testCost.isAnyPartPayableWith(colorMask, ai.getManaPool())) { - choiceString.append(choice); - payMultipleMana(testCost, choice, ai); - continue; - } + if (nMana == 1 && !preferredColor.isEmpty() + && manaAb.canProduce(preferredColor) + && satisfiesColorChoice(comboMana, choiceString, preferredColor) + && colorAllowedForComboPick(preferredColor, different, remainingOptions) + && testCost.isAnyPartPayableWith(ManaAtom.fromName(preferredColor), ai.getManaPool())) { + choice = preferredColor; } - // check colors needed for cost - if (!testCost.isPaid()) { - // Loop over combo colors + if (choice.isEmpty() && !testCost.isPaid()) { for (String color : comboColors) { - if (satisfiesColorChoice(comboMana, choiceString, choice) && testCost.needsColor(ManaAtom.fromName(color), ai.getManaPool())) { - payMultipleMana(testCost, color, ai); - if (nMana != 1) { - choiceString.append(" "); - } - choiceString.append(color); + if (!colorAllowedForComboPick(color, different, remainingOptions)) { + continue; + } + if (satisfiesColorChoice(comboMana, choiceString, color) + && testCost.needsColor(ManaAtom.fromName(color), ai.getManaPool())) { choice = color; break; } } - if (!choice.isEmpty()) { - continue; - } } - // check if combo mana can produce most common color in hand - String commonColor = ComputerUtilCard.getMostProminentColor(ai.getCardsIn(ZoneType.Hand)); - if (!commonColor.isEmpty() && satisfiesColorChoice(comboMana, choiceString, MagicColor.toShortString(commonColor)) && comboMana.getComboColors(manaAb).contains(MagicColor.toShortString(commonColor))) { - choice = MagicColor.toShortString(commonColor); - } else { - // default to first available color - for (String c : comboColors) { - if (satisfiesColorChoice(comboMana, choiceString, c)) { - choice = c; - break; + if (choice.isEmpty()) { + String commonColor = ComputerUtilCard.getMostProminentColor(ai.getCardsIn(ZoneType.Hand)); + if (!commonColor.isEmpty() + && satisfiesColorChoice(comboMana, choiceString, MagicColor.toShortString(commonColor)) + && colorAllowedForComboPick(MagicColor.toShortString(commonColor), different, remainingOptions) + && comboMana.getComboColors(manaAb).contains(MagicColor.toShortString(commonColor))) { + choice = MagicColor.toShortString(commonColor); + } else { + for (String c : comboColors) { + if (!colorAllowedForComboPick(c, different, remainingOptions)) { + continue; + } + if (satisfiesColorChoice(comboMana, choiceString, c)) { + choice = c; + break; + } } } } - if (nMana != 1) { - choiceString.append(" "); + if (choice.isEmpty()) { + break; + } + payMultipleMana(testCost, choice, ai); + if (choiceString.length() > 0) { + choiceString.append(' '); } choiceString.append(choice); + if (different) { + remainingOptions = ColorSet.fromMask(remainingOptions.getColor() - ManaAtom.fromName(choice)); + } } - if (choiceString.toString().isEmpty()) { - choiceString.append("0"); + + return choiceString.length() == 0 ? "0" : choiceString.toString(); + } + + private static boolean colorAllowedForComboPick(final String color, final boolean different, + final ColorSet remainingOptions) { + if (!different) { + return true; } + return (remainingOptions.getColor() & ManaAtom.fromName(color)) != 0; + } + + @FunctionalInterface + private interface ActiveManaLinkVisitor { + /** @return {@code true} to stop iterating */ + boolean visit(SpellAbility root, SpellAbility tail, AbilityManaPart mp); + } - comboMana.setExpressChoice(choiceString.toString()); + /** + * Walk root → subAbility mana links whose conditions are met. Optionally skips links that fail + * {@link AbilityManaPart#meetsManaRestrictions(SpellAbility)} when paying a specific spell. + */ + private static boolean forEachActiveManaLink(final SpellAbility root, final Player ai, + final SpellAbility saPaidFor, final boolean checkManaRestrictions, + final ActiveManaLinkVisitor visitor) { + root.setActivatingPlayer(ai); + for (SpellAbility tail = root; tail != null; tail = tail.getSubAbility()) { + final AbilityManaPart mp = tail.getManaPart(); + if (mp == null || !tail.metConditions()) { + continue; + } + if (checkManaRestrictions && saPaidFor != null && !mp.meetsManaRestrictions(saPaidFor)) { + continue; + } + if (visitor.visit(root, tail, mp)) { + return true; + } + } + return false; } private static boolean satisfiesColorChoice(AbilityManaPart abMana, StringBuilder choices, String choice) { @@ -940,24 +5581,36 @@ private static boolean canPayShardWithSpellAbility(ManaCostShard toPay, Player a return false; } - AbilityManaPart m = ma.getManaPart(); - if (!m.meetsManaRestrictions(sa)) { - return false; - } - if (checkCosts) { - // Check if AI can still play this mana ability ma.setActivatingPlayer(ai); - if (!CostPayment.canPayAdditionalCosts(ma.getPayCosts(), ma, false)) { + if (ma.getPayCosts() != null && ma.getPayCosts().hasManaCost() && hasOnlyGenericManaCost(ma.getPayCosts())) { + if (ma.getRestrictions() != null && ma.getRestrictions().isInstantSpeed()) { + return false; + } + } else if (!CostPayment.canPayAdditionalCosts(ma.getPayCosts(), ma, false)) { return false; - } - if (ma.getRestrictions() != null && ma.getRestrictions().isInstantSpeed()) { + } else if (ma.getRestrictions() != null && ma.getRestrictions().isInstantSpeed()) { return false; } } + ma.setActivatingPlayer(ai); + if (forEachActiveManaLink(ma, ai, sa, true, (root, tail, m) -> canPayShardWithActiveManaPart(toPay, ai, + root, tail, sa, cost, xManaCostPaidByColor, m))) { + return true; + } + + return canPayShardViaTapsForManaBonus(toPay, ai, ma, sa, cost); + } + + /** Match a shard against one conditional mana link (root or subAbility, e.g. Gemstone Caverns luck mode). */ + private static boolean canPayShardWithActiveManaPart(final ManaCostShard toPay, final Player ai, + final SpellAbility ma, final SpellAbility tail, final SpellAbility sa, final ManaCostBeingPaid cost, + final Map xManaCostPaidByColor, final AbilityManaPart m) { + final Card sourceCard = ma.getHostCard(); + if (m.isComboMana()) { - for (String s : m.getComboColors(ma).split(" ")) { + for (String s : m.getComboColors(tail).split(" ")) { if (toPay == ManaCostShard.COLORED_X && !ManaCostBeingPaid.canColoredXShardBePaidByColor(s, xManaCostPaidByColor)) { continue; } @@ -967,45 +5620,85 @@ private static boolean canPayShardWithSpellAbility(ManaCostShard toPay, Player a } if (ai.getManaPool().canPayForShardWithColor(toPay, ManaAtom.fromName(s))) { - // usually we'll want to produce color that matches the shard - ColorSet shared = ColorSet.fromMask(toPay.getColorMask()).getSharedColors(ColorSet.fromNames(m.getComboColors(ma).split(" "))); - // but other effects might still lead to a more permissive payment + final ColorSet shared = ColorSet.fromMask(toPay.getColorMask()) + .getSharedColors(ColorSet.fromNames(m.getComboColors(tail).split(" "))); if (!shared.isColorless()) { m.setExpressChoice(shared.iterator().next().getShortName()); } - setComboManaChoice(ai, ma, cost); + setComboManaChoice(ai, tail, cost); return true; } } return false; } - if (ma.getApi() == ApiType.ManaReflected) { - Set reflected = CardUtil.getReflectableManaColors(ma); + if (tail.getApi() == ApiType.ManaReflected) { + final Set reflected = CardUtil.getReflectableManaColors(tail); for (byte c : MagicColor.WUBRGC) { if (toPay == ManaCostShard.COLORED_X && !ManaCostBeingPaid.canColoredXShardBePaidByColor(MagicColor.toShortString(c), xManaCostPaidByColor)) { continue; } - if (!sa.allowsPayingWithShard(sourceCard, c)) { + if (!sa.allowsPayingWithShard(sourceCard, c)) { + continue; + } + + if (ai.getManaPool().canPayForShardWithColor(toPay, c) && reflected.contains(MagicColor.toLongString(c))) { + m.setExpressChoice(MagicColor.toShortString(c)); + return true; + } + } + return false; + } + + if (m.isAnyMana()) { + final byte colorChoice = pickColorForAnyMana(ai, sa, toPay, cost, sourceCard); + if (colorChoice == 0) { + return false; + } + m.setExpressChoice(MagicColor.toShortString(colorChoice)); + return true; + } + + final String[] producedColors = m.mana(tail).split(" "); + final boolean multiColorProducer = producedColors.length > 1; + if (multiColorProducer) { + String payColor = null; + for (String s : producedColors) { + final byte c = MagicColor.fromName(s); + if (c == 0 || !sa.allowsPayingWithShard(sourceCard, c)) { continue; } - - if (ai.getManaPool().canPayForShardWithColor(toPay, c) && reflected.contains(MagicColor.toLongString(c))) { - m.setExpressChoice(MagicColor.toShortString(c)); - return true; + if (toPay == ManaCostShard.COLORED_X) { + if (ManaCostBeingPaid.canColoredXShardBePaidByColor(s, xManaCostPaidByColor)) { + payColor = s; + break; + } + } else if (ai.getManaPool().canPayForShardWithColor(toPay, c)) { + payColor = s; + break; } } + if (payColor == null) { + return false; + } + if (!toPay.isGeneric() && toPay != ManaCostShard.COLORED_X) { + m.setExpressChoice(payColor); + } + return true; + } + + if (producedColors.length == 0 || producedColors[0].isEmpty()) { return false; } - if (!sa.allowsPayingWithShard(sourceCard, MagicColor.fromName(m.getOrigProduced()))) { + if (!sa.allowsPayingWithShard(sourceCard, MagicColor.fromName(producedColors[0]))) { return false; } if (toPay == ManaCostShard.COLORED_X) { - for (String s : m.mana(ma).split(" ")) { + for (String s : producedColors) { if (ManaCostBeingPaid.canColoredXShardBePaidByColor(s, xManaCostPaidByColor)) { return true; } @@ -1013,22 +5706,44 @@ private static boolean canPayShardWithSpellAbility(ManaCostShard toPay, Player a return false; } - if (m.isAnyMana()) { - byte colorChoice = 0; - if (toPay.isOr2Generic()) { - colorChoice = toPay.getColorMask(); - } else { - for (byte c : MagicColor.WUBRG) { - if (ai.getManaPool().canPayForShardWithColor(toPay, c)) { - colorChoice = c; - break; - } - } + final byte producedColor = MagicColor.fromName(producedColors[0]); + return ai.getManaPool().canPayForShardWithColor(toPay, producedColor); + } + + /** When static card text is one color but TapsForMana adds others (Sprawl, Mana Flare). */ + private static boolean canPayShardViaTapsForManaBonus(final ManaCostShard toPay, final Player ai, + final SpellAbility ma, final SpellAbility sa, final ManaCostBeingPaid cost) { + if (hasManaActivationCost(ma)) { + return false; + } + final AbilityManaPart rootMana = ma.getManaPart(); + if (rootMana == null || !ma.metConditions()) { + return false; + } + final String rootManaProduced = rootMana.mana(ma); + if (StringUtils.isBlank(rootManaProduced)) { + return false; + } + final byte producedColor = MagicColor.fromName(rootManaProduced.split(" ")[0]); + ma.setActivatingPlayer(ai); + final String predicted = predictManafromSpellAbility(ma, ai, toPay, cost); + if (StringUtils.isBlank(predicted)) { + return false; + } + final Card sourceCard = ma.getHostCard(); + for (final String s : TextUtil.split(predicted.trim(), ' ')) { + if (StringUtils.isNumeric(s) || "Any".equals(s)) { + continue; + } + final byte c = MagicColor.fromName(MagicColor.toShortString(s)); + if (c == 0 || c == producedColor || !sa.allowsPayingWithShard(sourceCard, c)) { + continue; + } + if (ai.getManaPool().canPayForShardWithColor(toPay, c)) { + return true; } - m.setExpressChoice(MagicColor.toShortString(colorChoice)); } - - return true; + return false; } // returns true if sourceCard is reserved as a mana source for payment @@ -1118,6 +5833,213 @@ private static void adjustManaCostToAvoidNegEffects(ManaCostBeingPaid cost, fina * a {@link java.lang.String} object. * @return a boolean. */ + private static List snapshotPool(final ManaPool pool) { + return Lists.newArrayList(pool); + } + + /** Restore the mana pool to a pre-simulation snapshot after test-mode planning. */ + private static void restorePool(final Player ai, final List snapshot) { + final ManaPool pool = ai.getManaPool(); + final List current = Lists.newArrayList(pool); + if (!current.isEmpty()) { + pool.removeMana(current); + } + if (!snapshot.isEmpty()) { + pool.addMana(snapshot); + } + } + + /** Apply simulated surplus mana (never placed in the real pool) toward {@code cost}. */ + private static void spendTestDepositedManaTowardCost(final SpellAbility sa, + final ManaCostBeingPaid cost, final ManaPool manapool, final List testDepositedSurplus, + final List manaSpentToPay) { + if (testDepositedSurplus == null || testDepositedSurplus.isEmpty()) { + return; + } + while (!cost.isPaid() && !testDepositedSurplus.isEmpty()) { + boolean progress = false; + final Iterator it = testDepositedSurplus.iterator(); + while (it.hasNext() && !cost.isPaid()) { + final Mana m = it.next(); + if (!cost.isNeeded(m, manapool)) { + continue; + } + if (hasUnpaidColoredShards(cost) && !canDepositedManaPayUnpaidColoredShard(cost, m, manapool)) { + continue; + } + if (!cost.payMana(m, manapool)) { + continue; + } + if (manaSpentToPay != null) { + manaSpentToPay.add(m); + } + it.remove(); + progress = true; + } + if (!progress) { + break; + } + } + } + + private static boolean hasUnpaidColoredShards(final ManaCostBeingPaid cost) { + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS && !shard.isPhyrexian() + && cost.getUnpaidShards(shard) > 0) { + return true; + } + } + return false; + } + + /** True when {@code mana} can satisfy an unpaid colored pip, not only generic/{@code {X}}. */ + private static boolean canDepositedManaPayUnpaidColoredShard(final ManaCostBeingPaid cost, final Mana mana, + final ManaPool pool) { + return colorCanPayUnpaidColoredShard(cost, pool, mana.getColor()); + } + + private static boolean colorCanPayUnpaidColoredShard(final ManaCostBeingPaid cost, final ManaPool pool, + final byte manaColor) { + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (!shard.isGeneric() && shard != ManaCostShard.COLORLESS && !shard.isPhyrexian() + && cost.getUnpaidShards(shard) > 0 + && pool.canPayForShardWithColor(shard, manaColor)) { + return true; + } + } + return false; + } + + private static void cleanupTestManaPayment(final Player ai, final List manaSpentToPay, + final List testDepositedSurplus) { + final ManaPool pool = ai.getManaPool(); + final Set deposited = testDepositedSurplus == null || testDepositedSurplus.isEmpty() + ? Collections.emptySet() : new HashSet<>(testDepositedSurplus); + if (testDepositedSurplus != null) { + testDepositedSurplus.clear(); + } + if (manaSpentToPay != null && !manaSpentToPay.isEmpty()) { + final List preExistingPoolMana = new ArrayList<>(); + for (final Mana m : manaSpentToPay) { + if (!deposited.contains(m)) { + preExistingPoolMana.add(m); + } + } + manaSpentToPay.clear(); + pool.refundMana(preExistingPoolMana); + } + } + + private static void depositNestedManaSurplus(final String unusedMana, final Card sourceCard, + final Player ai, final List testDepositedSurplus) { + if (unusedMana == null || sourceCard == null || testDepositedSurplus == null) { + return; + } + for (final String manaPart : TextUtil.split(unusedMana, ' ')) { + if (StringUtils.isNumeric(manaPart)) { + for (int i = Integer.parseInt(manaPart); i > 0; i--) { + testDepositedSurplus.add(new Mana((byte) ManaAtom.COLORLESS, sourceCard, null, ai)); + } + } else { + testDepositedSurplus.add(new Mana(ManaAtom.fromName(MagicColor.toShortString(manaPart)), sourceCard, null, ai)); + } + } + } + + /** + * When a multi-mana source pays one colored shard of the spell (e.g. Signet {@code {B}{R}} for {@code {R}}), + * only apply mana toward that shard so surplus colors stay in the pool for chaining another Signet. + */ + private static boolean shouldApplyProducedManaToShardOnly(final SpellAbility saPayment, + final ManaCostBeingPaid cost, final ManaCostShard toPay, final Player ai) { + if (toPay.isGeneric() || toPay == ManaCostShard.X || toPay == ManaCostShard.COLORLESS + || cost.getUnpaidShards(toPay) <= 0 || getManaProducedAmount(saPayment) <= 1) { + return false; + } + final ManaCostBeingPaid probe = new ManaCostBeingPaid(cost); + probe.decreaseShard(toPay, 1); + if (probe.isPaid()) { + return false; + } + return hasAvailableConsolidatorNeedingSurplusMana(saPayment, ai); + } + + /** Another untapped signet/combo land may still need floating mana for its activation cost. */ + private static boolean hasAvailableConsolidatorNeedingSurplusMana(final SpellAbility used, final Player ai) { + final Card usedHost = used.getHostCard(); + for (final Card c : ai.getCardsIn(ZoneType.Battlefield)) { + if (c == usedHost) { + continue; + } + for (final SpellAbility ma : c.getManaAbilities()) { + if (ma.getHostCard() != c || !isManaActivationConsolidator(ma)) { + continue; + } + if (hasTapCost(ma) + && AiCardMemory.isRememberedCard(ai, c, MemorySet.PAYS_TAP_COST)) { + continue; + } + return true; + } + } + return false; + } + + private static boolean hasUntappedConsolidatorWithManaActivationCost(final Player ai) { + for (final Card c : ai.getCardsIn(ZoneType.Battlefield)) { + for (final SpellAbility ma : c.getManaAbilities()) { + if (ma.getHostCard() != c || !isManaActivationConsolidator(ma)) { + continue; + } + if (!(hasTapCost(ma) + && AiCardMemory.isRememberedCard(ai, c, MemorySet.PAYS_TAP_COST))) { + return true; + } + } + } + return false; + } + + /** True when floating mana can satisfy at least one unpaid colored pip of {@code cost}. */ + private static boolean poolCanPayAnyUnpaidColoredShard(final ManaCostBeingPaid cost, final SpellAbility sa, + final ManaPool manapool) { + for (final ManaCostShard shard : cost.getDistinctShards()) { + if (shard.isGeneric() || shard == ManaCostShard.COLORLESS || shard == ManaCostShard.X + || cost.getUnpaidShards(shard) <= 0) { + continue; + } + for (final Mana m : manapool) { + if (manapool.canPayForShardWithColor(shard, m.getColor()) + && sa.allowsPayingWithShard(m.getSourceCard(), m.getColor())) { + return true; + } + } + } + return false; + } + + private static String payProducedManaTowardShard(final ManaCostBeingPaid cost, final String mana, + final ManaCostShard toPay, final Player ai) { + final List unused = new ArrayList<>(); + for (final String manaPart : TextUtil.split(mana, ' ')) { + if (cost.getUnpaidShards(toPay) <= 0) { + unused.add(manaPart); + continue; + } + if (StringUtils.isNumeric(manaPart)) { + unused.add(manaPart); + continue; + } + final String color = MagicColor.toShortString(manaPart); + if (ai.getManaPool().canPayForShardWithColor(toPay, ManaAtom.fromName(color)) + && cost.ai_payMana(color, ai.getManaPool())) { + continue; + } + unused.add(manaPart); + } + return unused.isEmpty() ? null : StringUtils.join(unused, ' '); + } + private static String payMultipleMana(ManaCostBeingPaid testCost, String mana, final Player p) { List unused = new ArrayList<>(4); for (String manaPart : TextUtil.split(mana, ' ')) { @@ -1129,6 +6051,16 @@ private static String payMultipleMana(ManaCostBeingPaid testCost, String mana, f break; } } + } else if ("Any".equals(manaPart)) { + final byte anyColor = pickColorForAnyMana(p, null, null, testCost, null); + if (anyColor == 0) { + unused.add("Any"); + } else { + final String color = MagicColor.toShortString(anyColor); + if (!testCost.ai_payMana(color, p.getManaPool())) { + unused.add(color); + } + } } else { String color = MagicColor.toShortString(manaPart); boolean wasNeeded = testCost.ai_payMana(color, p.getManaPool()); @@ -1148,27 +6080,27 @@ private static String payMultipleMana(ManaCostBeingPaid testCost, String mana, f private static ListMultimap groupAndOrderToPayShards(final Player ai, final ListMultimap manaAbilityMap, final ManaCostBeingPaid cost) { ListMultimap res = ArrayListMultimap.create(); + final Map> seenPerShard = new HashMap<>(); if ((cost.getGenericManaAmount() > 0 || cost.hasAnyKind(ManaAtom.OR_2_GENERIC)) && manaAbilityMap.containsKey(ManaAtom.GENERIC)) { - res.putAll(ManaCostShard.GENERIC, manaAbilityMap.get(ManaAtom.GENERIC)); + putShardAbilities(res, seenPerShard, ManaCostShard.GENERIC, manaAbilityMap.get(ManaAtom.GENERIC)); } // loop over cost parts for (ManaCostShard shard : cost.getDistinctShards()) { - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: shard = " + shard); - } if (shard == ManaCostShard.S) { - res.putAll(shard, manaAbilityMap.get(ManaAtom.IS_SNOW)); + putShardAbilities(res, seenPerShard, shard, manaAbilityMap.get(ManaAtom.IS_SNOW)); continue; } if (shard.isOr2Generic()) { Integer colorKey = (int) shard.getColorMask(); - if (manaAbilityMap.containsKey(colorKey)) - res.putAll(shard, manaAbilityMap.get(colorKey)); - if (manaAbilityMap.containsKey(ManaAtom.GENERIC)) - res.putAll(shard, manaAbilityMap.get(ManaAtom.GENERIC)); + if (manaAbilityMap.containsKey(colorKey)) { + putShardAbilities(res, seenPerShard, shard, manaAbilityMap.get(colorKey)); + } + if (manaAbilityMap.containsKey(ManaAtom.GENERIC)) { + putShardAbilities(res, seenPerShard, shard, manaAbilityMap.get(ManaAtom.GENERIC)); + } continue; } @@ -1179,11 +6111,7 @@ private static ListMultimap groupAndOrderToPayShard for (Integer colorint : manaAbilityMap.keySet()) { // apply mana color change matrix here if (ai.getManaPool().canPayForShardWithColor(shard, colorint.byteValue())) { - for (SpellAbility sa : manaAbilityMap.get(colorint)) { - if (!res.get(shard).contains(sa)) { - res.put(shard, sa); - } - } + putShardAbilities(res, seenPerShard, shard, manaAbilityMap.get(colorint)); } } } @@ -1191,6 +6119,17 @@ private static ListMultimap groupAndOrderToPayShard return res; } + private static void putShardAbilities(final ListMultimap res, + final Map> seenPerShard, final ManaCostShard shard, + final List abilities) { + Set seen = seenPerShard.computeIfAbsent(shard, k -> new HashSet<>()); + for (SpellAbility sa : abilities) { + if (seen.add(sa)) { + res.put(shard, sa); + } + } + } + /** * Calculate the ManaCost for the given SpellAbility. * @param sa The SpellAbility to calculate for. @@ -1200,6 +6139,9 @@ private static ListMultimap groupAndOrderToPayShard */ public static ManaCostBeingPaid calculateManaCost(final Cost cost, final SpellAbility sa, final Player payer, final boolean test, final int extraMana, final boolean effect) { Card host = sa.getHostCard(); + if (host == null) { + return new ManaCostBeingPaid(ManaCost.NO_COST); + } Zone castFromBackup = null; if (test && sa.isSpell() && !host.isInZone(ZoneType.Stack)) { castFromBackup = host.getCastFrom(); @@ -1233,7 +6175,8 @@ public static ManaCostBeingPaid calculateManaCost(final Cost cost, final SpellAb manaToAdd = AbilityUtils.calculateAmount(host, sa.getParamOrDefault("XAlternative", "X"), sa) * xCounter; } - if (manaToAdd < 1 && payCosts != null && payCosts.getCostMana().getXMin() > 0) { + final CostPartMana xCostMana = payCosts != null ? payCosts.getCostMana() : null; + if (manaToAdd < 1 && xCostMana != null && xCostMana.getXMin() > 0) { // AI cannot really handle X costs properly but this keeps AI from violating rules manaToAdd = 1; } @@ -1259,14 +6202,17 @@ public static ManaCostBeingPaid calculateManaCost(final Cost cost, final SpellAb CostAdjustment.adjust(manaCost, sa, payer, null, test, effect); if ("NumTimes".equals(sa.getParam("Announce"))) { // e.g. the Adversary cycle - ManaCost mkCost = sa.getPayCosts().getTotalMana(); - ManaCost mCost = ManaCost.ZERO; - for (int i = 0; i < 10; i++) { - mCost = ManaCost.combine(mCost, mkCost); - ManaCostBeingPaid mcbp = new ManaCostBeingPaid(mCost); - if (!canPayManaCost(mcbp, sa, sa.getActivatingPlayer(), true)) { - host.setSVar("NumTimes", "Number$" + i); - break; + final Cost announcePayCosts = sa.getPayCosts(); + if (announcePayCosts != null) { + ManaCost mkCost = announcePayCosts.getTotalMana(); + ManaCost mCost = ManaCost.ZERO; + for (int i = 0; i < 10; i++) { + mCost = ManaCost.combine(mCost, mkCost); + ManaCostBeingPaid mcbp = new ManaCostBeingPaid(mCost); + if (!canPayManaCost(mcbp, sa, sa.getActivatingPlayer(), true)) { + host.setSVar("NumTimes", "Number$" + i); + break; + } } } } @@ -1329,9 +6275,14 @@ public static int getAvailableManaEstimate(final Player p, final boolean checkPl } public static CardCollection getAvailableManaSources(final Player ai, final boolean checkPlayable) { + return getAvailableManaSources(ai, checkPlayable, null); + } + + private static CardCollection getAvailableManaSources(final Player ai, final boolean checkPlayable, + final ManaPaymentContext ctx) { final CardCollectionView list = CardCollection.combine(ai.getCardsIn(ZoneType.Battlefield), ai.getCardsIn(ZoneType.Hand)); final List manaSources = CardLists.filter(list, c -> { - for (final SpellAbility am : getAIPlayableMana(c)) { + for (final SpellAbility am : getAIPlayableMana(c, ctx)) { am.setActivatingPlayer(ai); if (!checkPlayable || (am.canPlay() && am.checkRestrictions(ai))) { return true; @@ -1388,16 +6339,33 @@ public static CardCollection getAvailableManaSources(final Player ai, final bool } } + // High-efficiency mana creatures (2+ mana per tap, e.g. Fyndhorn Elder, Fanatic of + // Rhonas with ferocious active) are as valuable as multi-mana lands and should compete with + // them rather than always tapping last. One-mana dorks (Llanowar Elves, Birds) stay behind + // lands in otherManaSources. + final List manaAbilities = getAIPlayableMana(card, ctx); + int maxCreatureMana = 0; if (card.isCreature() || card.isEnchanted()) { - otherManaSources.add(card); - continue; // don't use creatures before other permanents + if (card.isCreature()) { + for (final SpellAbility m : manaAbilities) { + m.setActivatingPlayer(ai); + if (checkPlayable && !m.canPlay()) { + continue; + } + maxCreatureMana = Math.max(maxCreatureMana, m.amountOfManaGenerated(true)); + } + } + if (maxCreatureMana < 2) { + otherManaSources.add(card); + continue; // don't use weak creatures before other permanents + } + // else fall through to normal bucketing by mana output tier } int usableManaAbilities = 0; boolean needsLimitedResources = false; boolean unpreferredCost = false; boolean producesAnyColor = false; - final List manaAbilities = getAIPlayableMana(card); for (final SpellAbility m : manaAbilities) { if (m.getManaPart().isAnyMana()) { @@ -1440,20 +6408,25 @@ public static CardCollection getAvailableManaSources(final Player ai, final bool otherManaSources.add(card); } else if (producesAnyColor) { anyColorManaSources.add(card); - } else if (usableManaAbilities == 1) { - if (manaAbilities.get(0).getManaPart().mana(manaAbilities.get(0)).equals("C")) { - colorlessManaSources.add(card); - } else { - oneManaSources.add(card); - } - } else if (usableManaAbilities == 2) { - twoManaSources.add(card); - } else if (usableManaAbilities == 3) { - threeManaSources.add(card); - } else if (usableManaAbilities == 4) { - fourManaSources.add(card); } else { - fiveManaSources.add(card); + // For high-efficiency creatures a single ability can make several mana; bucket them by mana + // output rather than ability count so they sort alongside comparable multi-mana lands. + int tier = Math.max(usableManaAbilities, maxCreatureMana); + if (tier == 1) { + if (manaAbilities.get(0).getManaPart().mana(manaAbilities.get(0)).equals("C")) { + colorlessManaSources.add(card); + } else { + oneManaSources.add(card); + } + } else if (tier == 2) { + twoManaSources.add(card); + } else if (tier == 3) { + threeManaSources.add(card); + } else if (tier == 4) { + fourManaSources.add(card); + } else { + fiveManaSources.add(card); + } } } sortedManaSources.addAll(sortedManaSources.size(), colorlessManaSources); @@ -1471,25 +6444,16 @@ public static CardCollection getAvailableManaSources(final Player ai, final bool ComputerUtilCard.sortByEvaluateCreature(useLastManaSources); Collections.reverse(useLastManaSources); sortedManaSources.addAll(sortedManaSources.size(), useLastManaSources); - - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: sortedManaSources = " + sortedManaSources); - } return sortedManaSources; } - private static ListMultimap groupSourcesByManaColor(final Player ai, boolean checkPlayable) { + private static ListMultimap groupSourcesByManaColor(final Player ai, boolean checkPlayable, + final ManaPaymentContext ctx) { final ListMultimap manaMap = ArrayListMultimap.create(); final Game game = ai.getGame(); - for (final Card sourceCard : getAvailableManaSources(ai, checkPlayable)) { - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: groupSourcesByManaColor sourceCard = " + sourceCard); - } - for (final SpellAbility m : getAIPlayableMana(sourceCard)) { - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: groupSourcesByManaColor m = " + m); - } + for (final Card sourceCard : getAvailableManaSources(ai, checkPlayable, ctx)) { + for (final SpellAbility m : getAIPlayableMana(sourceCard, ctx)) { m.setActivatingPlayer(ai); if (checkPlayable && !m.canPlay()) { continue; @@ -1510,82 +6474,107 @@ private static ListMultimap groupSourcesByManaColor(final manaMap.put(ManaAtom.GENERIC, m); - SpellAbility tail = m; - while (tail != null) { - AbilityManaPart mp = tail.getManaPart(); - if (mp != null && tail.metConditions()) { - // TODO Replacement Check currently doesn't work for reflected colors - - // setup produce mana replacement effects - String origin = mp.getOrigProduced(); - final Map repParams = AbilityKey.mapFromAffected(sourceCard); - repParams.put(AbilityKey.Mana, origin); - repParams.put(AbilityKey.Activator, ai); - repParams.put(AbilityKey.AbilityMana, m); // RootAbility - - List reList = game.getReplacementHandler().getReplacementList(ReplacementType.ProduceMana, repParams, ReplacementLayer.Other); - - if (reList.isEmpty()) { - Set reflectedColors = CardUtil.getReflectableManaColors(m); - // find possible colors - for (MagicColor.Color color : MagicColor.Color.values()) { - if (mp.canProduce(color.getShortName(), tail) || reflectedColors.contains(color.getName())) { - manaMap.put((int) ManaAtom.fromName(color.getName()), m); - } - } - } else { - // try to guess the color the mana gets replaced to - for (ReplacementEffect re : reList) { - SpellAbility o = re.getOverridingAbility(); - String replaced = origin; - if (o == null || o.getApi() != ApiType.ReplaceMana) { - continue; - } - if (o.hasParam("ReplaceMana")) { - replaced = o.getParam("ReplaceMana"); - } else if (o.hasParam("ReplaceType")) { - String color = o.getParam("ReplaceType"); - for (byte c : MagicColor.WUBRGC) { - String s = MagicColor.toShortString(c); - replaced = replaced.replace(s, color); - } - } else if (o.hasParam("ReplaceColor")) { - String color = o.getParam("ReplaceColor"); - if (o.hasParam("ReplaceOnly")) { - replaced = replaced.replace(o.getParam("ReplaceOnly"), color); - } else { - for (byte c : MagicColor.WUBRG) { - String s = MagicColor.toShortString(c); - replaced = replaced.replace(s, color); - } - } - } + forEachActiveManaLink(m, ai, null, false, + (root, tail, mp) -> { + registerColorsForActiveManaLink(root, tail, mp, sourceCard, ai, game, manaMap); + return false; + }); - for (byte color : MagicColor.WUBRG) { - if ("Any".equals(replaced) || replaced.contains(MagicColor.toShortString(color))) { - manaMap.put((int)color, m); - } - } + if (m.getHostCard().isSnow()) { + manaMap.put(ManaAtom.IS_SNOW, m); + } + registerTapsForManaBonusColors(m, ai, manaMap); + } // end of mana abilities loop + } // end of mana sources loop - if (replaced.contains("C")) { - manaMap.put(ManaAtom.COLORLESS, m); - } - } + return manaMap; + } + + /** Register shard buckets for one active mana link (root or conditional subAbility). */ + private static void registerColorsForActiveManaLink(final SpellAbility root, final SpellAbility tail, + final AbilityManaPart mp, final Card sourceCard, final Player ai, final Game game, + final ListMultimap manaMap) { + // TODO Replacement Check currently doesn't work for reflected colors + String origin = mp.getOrigProduced(); + final Map repParams = AbilityKey.mapFromAffected(sourceCard); + repParams.put(AbilityKey.Mana, origin); + repParams.put(AbilityKey.Activator, ai); + repParams.put(AbilityKey.AbilityMana, root); + + List reList = game.getReplacementHandler().getReplacementList(ReplacementType.ProduceMana, + repParams, ReplacementLayer.Other); + + if (reList.isEmpty()) { + Set reflectedColors = CardUtil.getReflectableManaColors(root); + for (MagicColor.Color color : MagicColor.Color.values()) { + if (mp.canProduce(color.getShortName(), tail) || reflectedColors.contains(color.getName())) { + manaMap.put((int) ManaAtom.fromName(color.getName()), root); + } + } + } else { + for (ReplacementEffect re : reList) { + SpellAbility o = re.getOverridingAbility(); + String replaced = origin; + if (o == null || o.getApi() != ApiType.ReplaceMana) { + continue; + } + if (o.hasParam("ReplaceMana")) { + replaced = o.getParam("ReplaceMana"); + } else if (o.hasParam("ReplaceType")) { + String color = o.getParam("ReplaceType"); + for (byte c : MagicColor.WUBRGC) { + String s = MagicColor.toShortString(c); + replaced = replaced.replace(s, color); + } + } else if (o.hasParam("ReplaceColor")) { + String color = o.getParam("ReplaceColor"); + if (o.hasParam("ReplaceOnly")) { + replaced = replaced.replace(o.getParam("ReplaceOnly"), color); + } else { + for (byte c : MagicColor.WUBRG) { + String s = MagicColor.toShortString(c); + replaced = replaced.replace(s, color); } } - tail = tail.getSubAbility(); } - if (m.getHostCard().isSnow()) { - manaMap.put(ManaAtom.IS_SNOW, m); + for (byte color : MagicColor.WUBRG) { + if ("Any".equals(replaced) || replaced.contains(MagicColor.toShortString(color))) { + manaMap.put((int) color, root); + } } - if (DEBUG_MANA_PAYMENT) { - System.out.println("DEBUG_MANA_PAYMENT: groupSourcesByManaColor manaMap = " + manaMap); + + if (replaced.contains("C")) { + manaMap.put(ManaAtom.COLORLESS, root); } - } // end of mana abilities loop - } // end of mana sources loop + } + } + } - return manaMap; + /** + * Register colors a source can produce when tapped, including {@link TriggerType#TapsForMana} bonuses + * (Utopia Sprawl, Mana Flare, etc.) so shard buckets and feasibility probes match tap-time output. + */ + private static void registerTapsForManaBonusColors(final SpellAbility ma, final Player ai, + final ListMultimap manaMap) { + if (ma == null || hasManaActivationCost(ma) || isDisposableManaAbility(ma)) { + return; + } + ma.setActivatingPlayer(ai); + final String produced = predictManafromSpellAbility(ma, ai, ManaCostShard.GENERIC); + if (StringUtils.isBlank(produced)) { + return; + } + for (final String manaPart : TextUtil.split(produced.trim(), ' ')) { + if (StringUtils.isNumeric(manaPart)) { + manaMap.put(ManaAtom.GENERIC, ma); + } else { + final byte atom = ManaAtom.fromName(MagicColor.toShortString(manaPart)); + if (atom != 0) { + manaMap.put((int) atom, ma); + } + } + } } /** @@ -1610,12 +6599,20 @@ public static int determineLeftoverMana(final SpellAbility sa, final Player play sa.setXManaCostPaid(max); max = Math.min(max, AbilityUtils.calculateAmount(sa.getHostCard(), sa.getParam("AIXMax"), sa)); } - for (int i = 1; i <= max; i++) { - if (!canPayManaCost(sa.getRootAbility(), player, i, effect)) { - return i - 1; + if (canPayManaCost(sa.getRootAbility(), player, max, effect)) { + return max; + } + int lo = 0; + int hi = max; + while (lo < hi) { + final int mid = (lo + hi + 1) / 2; + if (canPayManaCost(sa.getRootAbility(), player, mid, effect)) { + lo = mid; + } else { + hi = mid - 1; } } - return max; + return lo; } /** @@ -1633,17 +6630,35 @@ public static int determineLeftoverMana(final SpellAbility sa, final Player play * @since 1.5.59 */ public static int determineLeftoverMana(final SpellAbility sa, final Player player, final String shardColor, final boolean effect) { - ManaCost origCost = sa.getRootAbility().getPayCosts().getTotalMana(); - - String shardSurplus = shardColor; - for (int i = 1; i < 100; i++) { - ManaCost extra = new ManaCost(shardSurplus); - if (!canPayManaCost(new ManaCostBeingPaid(ManaCost.combine(origCost, extra)), sa, player, effect)) { - return i - 1; + final ManaCost origCost = sa.getRootAbility().getPayCosts().getTotalMana(); + final int max = 99; + if (canPayWithShardSurplus(origCost, sa, player, shardColor, max, effect)) { + return max; + } + int lo = 0; + int hi = max; + while (lo < hi) { + final int mid = (lo + hi + 1) / 2; + if (canPayWithShardSurplus(origCost, sa, player, shardColor, mid, effect)) { + lo = mid; + } else { + hi = mid - 1; } - shardSurplus += " " + shardColor; } - return 99; + return lo; + } + + private static boolean canPayWithShardSurplus(final ManaCost origCost, final SpellAbility sa, + final Player player, final String shardColor, final int count, final boolean effect) { + if (count <= 0) { + return canPayManaCost(new ManaCostBeingPaid(origCost), sa, player, effect); + } + final StringBuilder shardSurplus = new StringBuilder(shardColor); + for (int i = 1; i < count; i++) { + shardSurplus.append(' ').append(shardColor); + } + final ManaCost extra = new ManaCost(shardSurplus.toString()); + return canPayManaCost(new ManaCostBeingPaid(ManaCost.combine(origCost, extra)), sa, player, effect); } // Returns basic mana abilities plus "reflected mana" abilities @@ -1655,12 +6670,36 @@ public static int determineLeftoverMana(final SpellAbility sa, final Player play * @return a {@link java.util.List} object. */ public static List getAIPlayableMana(Card c) { + return buildAIPlayableMana(c); + } + + private static List getAIPlayableMana(final Card c, final ManaPaymentContext ctx) { + if (ctx == null) { + return buildAIPlayableMana(c); + } + final Map> cache = ctx.caches.playableManaCache; + final List cached = cache.get(c); + if (cached != null) { + return cached; + } + final List res = buildAIPlayableMana(c); + cache.put(c, res); + return res; + } + + private static List buildAIPlayableMana(final Card c) { final List res = new ArrayList<>(); + final Set seen = new HashSet<>(); for (final SpellAbility a : c.getManaAbilities()) { - // if a mana ability has a mana cost the AI will miscalculate // if there is a parent ability the AI can't use it + if (a.getApi() != ApiType.Mana && a.getApi() != ApiType.ManaReflected) { + continue; + } + final Cost cost = a.getPayCosts(); - if (cost.hasManaCost() || (a.getApi() != ApiType.Mana && a.getApi() != ApiType.ManaReflected)) { + // Generic ({1}) and hybrid-only ({U/R}) activation costs are supported via nested payment + // planning. Single-colored, X, and phyrexian activation costs are still excluded. + if (cost != null && cost.hasManaCost() && !hasPlannableManaActivationCost(cost)) { continue; } @@ -1668,17 +6707,54 @@ public static List getAIPlayableMana(Card c) { continue; } - if (!res.contains(a)) { - if (cost.isReusuableResource()) { - res.add(0, a); - } else { - res.add(res.size(), a); - } + if (!seen.add(a)) { + continue; + } + if (cost == null || cost.isReusuableResource()) { + res.add(0, a); + } else { + res.add(a); } } return res; } + // True when the ability's mana cost is only generic mana (e.g. {1}, {2}) with no colored, hybrid, or X pips. + private static boolean hasOnlyGenericManaCost(final Cost cost) { + final CostPartMana manaCost = cost.getCostMana(); + if (manaCost == null) { + return true; + } + final ManaCost mc = manaCost.getMana(); + return mc.getColorProfile() == 0 && mc.countX() == 0; + } + + /** + * True when nested payment planning can pay this ability's mana activation cost: generic-only + * (signets, Skycloud Expanse) or hybrid-only (Cascade Bluffs, Flooded Grove). + */ + private static boolean hasPlannableManaActivationCost(final Cost cost) { + if (hasOnlyGenericManaCost(cost)) { + return true; + } + final CostPartMana costMana = cost.getCostMana(); + if (costMana == null) { + return true; + } + final ManaCost mc = costMana.getMana(); + if (mc.countX() > 0 || mc.getGenericCost() > 0) { + return false; + } + boolean hasHybridShard = false; + for (final ManaCostShard shard : mc) { + hasHybridShard = true; + if (shard.isPhyrexian() || shard.isOr2Generic() || shard.isMonoColor() || !shard.isMultiColor()) { + return false; + } + } + return hasHybridShard; + } + /** * Matches list of creatures to shards in mana cost for convoking. * diff --git a/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java b/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java index 7e6ea3063c98..a9b0af8063ef 100644 --- a/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java +++ b/forge-ai/src/main/java/forge/ai/PlayerControllerAi.java @@ -231,6 +231,13 @@ public Map divideShield(Card effectSource, Map specifyManaCombo(SpellAbility sa, ColorSet colorSet, int manaAmount, boolean different) { + if (sa.getApi() == ApiType.Mana) { + final Map fromPayment = ComputerUtilMana.specifyComboFromActivePayment(player, sa, + manaAmount, different); + if (fromPayment != null) { + return fromPayment; + } + } Map result = new HashMap<>(); for (int i = 0; i < manaAmount; ++i) { Byte chosen = chooseColor("", sa, colorSet); diff --git a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java index 23d7a118894f..b428f17a71a8 100644 --- a/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java +++ b/forge-ai/src/main/java/forge/ai/simulation/SpellAbilityPicker.java @@ -359,6 +359,10 @@ public Score evaluateSa(final SimulationController controller, PhaseType phase, simulator.setInterceptor(choicesIterator); // I feel like something here is making a wrong assumption about what the target is lastScore = simulator.simulateSpellAbility(sa); + final int castBonus = ComputerUtilMana.estimateCastBonusForSpell(sa, player); + if (castBonus != 0) { + lastScore = new Score(lastScore.value + castBonus, lastScore.summonSickValue + castBonus); + } numSimulations++; if (lastScore.value > bestScore.value) { bestScore = lastScore; diff --git a/forge-gui-desktop/src/test/java/forge/ai/AITest.java b/forge-gui-desktop/src/test/java/forge/ai/AITest.java index cbe03adc5836..3ab2ec8ad7c2 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/AITest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/AITest.java @@ -42,8 +42,10 @@ public void initializeModel() { FModel.initialize(null, preferences -> { preferences.setPref(FPref.LOAD_CARD_SCRIPTS_LAZILY, false); preferences.setPref(FPref.UI_LANGUAGE, "en-US"); + preferences.setPref(FPref.MANA_PAYMENT_CASTABILITY_PROBE, true); return null; }); + CastabilityProbe.enableForTests(); initialized = true; } diff --git a/forge-gui-desktop/src/test/java/forge/ai/controller/AutoPaymentTest.java b/forge-gui-desktop/src/test/java/forge/ai/controller/AutoPaymentTest.java index 0f4e15976150..f45d0c318834 100644 --- a/forge-gui-desktop/src/test/java/forge/ai/controller/AutoPaymentTest.java +++ b/forge-gui-desktop/src/test/java/forge/ai/controller/AutoPaymentTest.java @@ -1,22 +1,97 @@ package forge.ai.controller; +import forge.ai.ComputerUtilMana; +import forge.ai.CastabilityProbe; +import forge.ai.PlayerControllerAi; import forge.ai.simulation.GameSimulator; import forge.ai.simulation.Plan; import forge.ai.simulation.SimulationTest; import forge.ai.simulation.SpellAbilityPicker; +import forge.card.mana.ManaCost; +import forge.card.mana.ManaCostParser; import forge.game.Game; +import forge.card.CardStateName; import forge.game.card.Card; +import forge.game.card.CardCollection; +import forge.game.keyword.Keyword; +import forge.game.keyword.KeywordInterface; +import forge.game.mana.ManaCostBeingPaid; import forge.game.phase.PhaseType; import forge.game.player.Player; import forge.game.spellability.SpellAbility; import forge.game.zone.ZoneType; import org.testng.AssertJUnit; +import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import java.util.List; +import com.google.common.collect.Lists; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; public class AutoPaymentTest extends SimulationTest { + // Tests are written with the CastabilityProbe enabled. + @BeforeMethod(alwaysRun = true) + public void enableCastabilityProbeForPaymentTests() { + CastabilityProbe.enableForTests(); + } + + private static ManaCostBeingPaid cost(String s) { + return new ManaCostBeingPaid(new ManaCost(new ManaCostParser(s))); + } + + private boolean canAutoPay(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa) { + final boolean[] result = new boolean[1]; + p.runWithController(() -> result[0] = ComputerUtilMana.canPayManaCost(mc, sa, p, false), + new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + return result[0]; + } + + private boolean prodAutoPay(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa) { + final boolean[] result = new boolean[1]; + p.runWithController(() -> result[0] = ComputerUtilMana.payManaCost(mc, sa, p, false), + new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + return result[0]; + } + + private void assertProductionPayment(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa) { + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + AssertJUnit.assertTrue(prodAutoPay(game, p, mc, sa)); + } + + private int countTapped(Game game, String name) { + int i = 0; + for (Card c : game.getCardsIn(ZoneType.Battlefield)) { + if (c.getName().equals(name) && c.isTapped()) { + i++; + } + } + return i; + } + + private int countOnBattlefield(Game game, String name) { + int i = 0; + for (Card c : game.getCardsIn(ZoneType.Battlefield)) { + if (c.getName().equals(name)) { + i++; + } + } + return i; + } + + /** Find a spell card after simulation (hand, stack, graveyard, or battlefield). */ + private Card findSpellCard(Game game, String name) { + for (ZoneType zone : new ZoneType[] { ZoneType.Hand, ZoneType.Stack, ZoneType.Graveyard, ZoneType.Battlefield }) { + for (Card c : game.getCardsIn(zone)) { + if (c.getName().equals(name)) { + return c; + } + } + } + return null; + } @Test public void dontPayWithAshnodsAltar() { @@ -52,6 +127,109 @@ public void dontPayWithAshnodsAltar() { AssertJUnit.assertNotNull(elfCopy); } + @Test + public void trevasAttendantBeforeAshnodsAltar() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Treva's Attendant", p); + addCard("Ashnod's Altar", p); + Card elf = addCard("Llanowar Elves", p); + elf.setSickness(false); + Card spell = addCardToZone("Mind Stone", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("2"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals(0, countOnBattlefield(game, "Treva's Attendant")); + AssertJUnit.assertEquals(1, countOnBattlefield(game, "Llanowar Elves")); + AssertJUnit.assertEquals(1, countOnBattlefield(game, "Ashnod's Altar")); + } + + // Springleaf Drum taps another creature, so a plain land should be preferred for a colored pip. + @Test + public void springleafDrumAfterForestBeforeTreasure() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Springleaf Drum", p); + addToken("c_a_treasure_sac", p); + Card elf = addCard("Llanowar Elves", p); + elf.setSickness(false); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("G"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals("Forest should pay {G}", 1, countTapped(game, "Forest")); + AssertJUnit.assertEquals("Springleaf Drum should stay untapped", 0, countTapped(game, "Springleaf Drum")); + AssertJUnit.assertEquals("Treasure should stay unused", 1, countOnBattlefield(game, "Treasure Token")); + } + + // Without a land, Springleaf Drum (reusable but taps a creature) should still beat sacrificing a Treasure. + @Test + public void springleafDrumBeforeTreasureWhenNoLand() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Springleaf Drum", p); + addToken("c_a_treasure_sac", p); + Card bear = addCard("Bear Cub", p); + bear.setSickness(false); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("G"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals("Springleaf Drum should pay {G}", 1, countTapped(game, "Springleaf Drum")); + AssertJUnit.assertEquals("Treasure should stay on the battlefield", 1, countOnBattlefield(game, "Treasure Token")); + } + + @Test + public void yunaGrandSummonPaysCreatureWithYuna() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card yuna = addCard("Yuna, Grand Summoner", p); + yuna.setSickness(false); + addCards("Forest", 4, p); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("1 G"), sa); + AssertJUnit.assertEquals(1, countTapped(game, "Yuna, Grand Summoner")); + AssertJUnit.assertEquals("A single Forest could pay alone; Yuna should be preferred", + 1, countTapped(game, "Forest")); + AssertJUnit.assertEquals(4, countOnBattlefield(game, "Forest")); + } + + @Test + public void jadeOrbPrefersOrbForDragon() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Jade Orb of Dragonkind", p); + addCard("Forest", p); + addCards("Mountain", 4, p); + Card spell = addCardToZone("Swift Warkite", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + CardCollection sources = predictedManaSources(game, p, cost("4 R G"), sa); + AssertJUnit.assertTrue("Jade Orb should pay the {G} for a Dragon", + sources.anyMatch(c -> "Jade Orb of Dragonkind".equals(c.getName()))); + } + @Test public void payWithTreasuresOverPhyrexianAltar() { Game game = initAndCreateGame(); @@ -143,4 +321,2528 @@ public void testKeepColorsOpen() { Plan plan = picker.getPlan(); AssertJUnit.assertEquals(2, plan.getDecisions().size()); } + + // {R}{W} with Mountain + Plains + Signet should consolidate onto one basic + Signet (rule A): + // exactly one basic tapped, Signet used, one basic left untapped. + @Test + public void consolidatesTwoColoredShardsOntoSignet() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Plains", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("R W"), sa); + + AssertJUnit.assertEquals("Signet should be tapped", 1, countTapped(game, "Boros Signet")); + int tappedBasics = countTapped(game, "Mountain") + countTapped(game, "Plains"); + AssertJUnit.assertEquals("Only one basic should pay the Signet's {1}", 1, tappedBasics); + } + + // {R} alone with Mountain + Signet should tap the Mountain, not the Signet (single-shard penalty). + @Test + public void singleShardPrefersBasicOverSignet() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Shock", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("R"), sa); + + AssertJUnit.assertEquals("Mountain should be tapped for R", 1, countTapped(game, "Mountain")); + AssertJUnit.assertEquals("Signet should be untapped", 0, countTapped(game, "Boros Signet")); + } + + // {R}{W} with two Signets + one Plains should use one Signet + Plains, not both Signets (anti-chain rule D). + @Test + public void doesNotChainTwoSignets() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Boros Signet", p); + addCard("Boros Signet", p); + addCard("Plains", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("R W"), sa); + + AssertJUnit.assertEquals("Exactly one Signet should be used", 1, countTapped(game, "Boros Signet")); + AssertJUnit.assertEquals("Plains pays the Signet's {1}", 1, countTapped(game, "Plains")); + } + + // {1}{R}{R} with Plains + two Rakdos Signets (3 mana total): Plains pays the first Signet's {1}, + // the Signet's {B} pays the second Signet's {1}, and both Signets' {R} plus a {B} pay Aisha. + @Test + public void chainedSignetsPayTripleRedSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Rakdos Signet", p); + addCard("Rakdos Signet", p); + Card spell = addCardToZone("Aisha of Sparks and Smoke", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertEquals(3, spell.getManaCost().getCMC()); + ManaCostBeingPaid mc = new ManaCostBeingPaid(spell.getManaCost()); + + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, new ManaCostBeingPaid(spell.getManaCost()), sa); + AssertJUnit.assertTrue("Plains should pay the first Signet's {1}", + sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertEquals("Both Rakdos Signets should be used", 2, + sources.stream().filter(c -> "Rakdos Signet".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should chain Signets for a 3 CMC spell", + prodAutoPay(game, p, new ManaCostBeingPaid(spell.getManaCost()), sa)); + AssertJUnit.assertEquals("Plains should be tapped", 1, countTapped(game, "Plains")); + AssertJUnit.assertEquals("Both Signets should be tapped", 2, countTapped(game, "Rakdos Signet")); + } + + // {1}{R}{R} with Plains + three Rakdos Signets: only two Signets are needed; the third must stay untapped. + @Test + public void chainedSignetsIgnoreExtraSignet() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCards("Rakdos Signet", 3, p); + Card spell = addCardToZone("Aisha of Sparks and Smoke", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = new ManaCostBeingPaid(spell.getManaCost()); + + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + AssertJUnit.assertTrue(prodAutoPay(game, p, new ManaCostBeingPaid(spell.getManaCost()), sa)); + AssertJUnit.assertEquals("Only two Signets should be tapped", 2, countTapped(game, "Rakdos Signet")); + } + + // {1}{R}{R} with Plains + Rakdos Signet + Cascade Bluffs: Plains -> Signet {B}{R}, pool {R} pays + // Bluffs' {U/R} for {R}{R}, then {R}{R}{B} pays Aisha. + @Test + public void signetAndCascadeBluffsPayTripleRedSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Rakdos Signet", p); + addCard("Cascade Bluffs", p); + Card spell = addCardToZone("Aisha of Sparks and Smoke", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = new ManaCostBeingPaid(spell.getManaCost()); + + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, new ManaCostBeingPaid(spell.getManaCost()), sa); + AssertJUnit.assertTrue("Plains should pay the Signet's {1}", + sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertTrue("Rakdos Signet should be used", + sources.anyMatch(c -> "Rakdos Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Cascade Bluffs should be used", + sources.anyMatch(c -> "Cascade Bluffs".equals(c.getName()))); + AssertJUnit.assertFalse("A second Signet is not required", + sources.stream().filter(c -> "Rakdos Signet".equals(c.getName())).count() > 1); + + AssertJUnit.assertTrue(prodAutoPay(game, p, new ManaCostBeingPaid(spell.getManaCost()), sa)); + AssertJUnit.assertEquals("Plains should be tapped", 1, countTapped(game, "Plains")); + AssertJUnit.assertEquals("Signet should be tapped", 1, countTapped(game, "Rakdos Signet")); + AssertJUnit.assertEquals("Cascade Bluffs should be tapped", 1, countTapped(game, "Cascade Bluffs")); + } + + // {B} with Swamp + Initiates: tap the Swamp, never the useless 1:1 filter (rule H). + @Test + public void skipsUselessFilterWhenDirectSourceExists() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Swamp", p); + Card initiates = addCard("Initiates of the Ebon Hand", p); + initiates.setSickness(false); + Card spell = addCardToZone("Duress", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("B"), sa); + + AssertJUnit.assertEquals("Swamp should be tapped for B", 1, countTapped(game, "Swamp")); + AssertJUnit.assertEquals("Initiates should not be used", 0, countTapped(game, "Initiates of the Ebon Hand")); + } + + // {G} with Forest + Signpost Scarecrow ({2}: any): tap the Forest, not the expensive filter. + @Test + public void skipsSignpostScarecrowWhenDirectSourceExists() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Signpost Scarecrow", p); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("G"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals("Forest should be tapped for {G}", 1, countTapped(game, "Forest")); + AssertJUnit.assertEquals("Signpost Scarecrow should not be used", 0, countTapped(game, "Signpost Scarecrow")); + } + + // {G} with Forest + Prismite ({2}: any): same as Signpost — direct basic wins. + @Test + public void skipsPrismiteWhenDirectSourceExists() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Prismite", p); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("G"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals("Forest should be tapped for {G}", 1, countTapped(game, "Forest")); + AssertJUnit.assertEquals("Prismite should not be used", 0, countTapped(game, "Prismite")); + } + + // {R} with Study Hall ({1}: any) + Signpost ({2}: any) + one Plains: prefer the cheaper filter. + @Test + public void prefersStudyHallOverSignpostForOffColor() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Study Hall", p); + addCard("Signpost Scarecrow", p); + addCard("Plains", p); + Card spell = addCardToZone("Shock", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("R"), spell.getFirstSpellAbility()); + AssertJUnit.assertEquals("Study Hall should pay {R}", 1, countTapped(game, "Study Hall")); + AssertJUnit.assertEquals("Signpost Scarecrow should stay unused", 0, countTapped(game, "Signpost Scarecrow")); + } + + // {W} with Plains + Karakas: tap Plains; keep Karakas for its bounce ability. + @Test + public void karakasReservedWhenPlainsAvailable() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Karakas", p); + Card spell = addCardToZone("Healing Salve", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("W"), sa); + + AssertJUnit.assertEquals("Plains should be tapped for W", 1, countTapped(game, "Plains")); + AssertJUnit.assertEquals("Karakas should stay untapped", 0, countTapped(game, "Karakas")); + } + + // {W} with only Karakas: still payable — reserve is soft depriorization, not a hard block. + @Test + public void karakasTappedWhenOnlyWhiteSource() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Karakas", p); + Card spell = addCardToZone("Healing Salve", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("W"), sa); + + AssertJUnit.assertEquals("Karakas should be tapped when it is the only source", 1, countTapped(game, "Karakas")); + } + + // Generic {1} with Mind Stone + Library of Alexandria: spend the rock, not the draw land. + @Test + public void libraryReservedWhenColorlessRockAvailable() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Library of Alexandria", p); + Card spell = addCardToZone("Expedition Map", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + CardCollection sources = predictedManaSources(game, p, cost("1"), sa); + AssertJUnit.assertTrue("Mind Stone should pay generic {1}", sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + AssertJUnit.assertFalse("Library should not pay generic {1}", + sources.anyMatch(c -> "Library of Alexandria".equals(c.getName()))); + + assertProductionPayment(game, p, cost("1"), sa); + AssertJUnit.assertEquals("Mind Stone should be tapped", 1, countTapped(game, "Mind Stone")); + AssertJUnit.assertEquals("Library should stay untapped", 0, countTapped(game, "Library of Alexandria")); + } + + // {2} with Goldspan Treasure (2 mana) + Lotus Petal (1): sacrifice the Treasure, keep the Petal. + @Test + public void goldspanTreasurePaysDoubleGenericOverSingleManaDisposable() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Goldspan Dragon", p); + addToken("c_a_treasure_sac", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Darksteel Pendant", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("2"), sa); + + AssertJUnit.assertEquals("Lotus Petal should stay on the battlefield", 1, countOnBattlefield(game, "Lotus Petal")); + AssertJUnit.assertEquals("Treasure should be sacrificed for {2}", 0, countOnBattlefield(game, "Treasure Token")); + } + + // {R}{R} with Goldspan + one Treasure: one activation pays both red pips. + @Test + public void goldspanTreasurePaysDoubleRedInOneActivation() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Goldspan Dragon", p); + addToken("c_a_treasure_sac", p); + Card spell = addCardToZone("Doublecast", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("R R"), sa); + + AssertJUnit.assertEquals("Only one Treasure should be sacrificed", 0, countOnBattlefield(game, "Treasure Token")); + } + + // Reusable basics still beat Goldspan Treasures for a single colored pip. + @Test + public void reusableStillBeatsGoldspanTreasure() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Goldspan Dragon", p); + addToken("c_a_treasure_sac", p); + addCard("Forest", p); + Card spell = addCardToZone("Grizzly Bears", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("G"), sa); + + AssertJUnit.assertEquals("Forest should be tapped for {G}", 1, countTapped(game, "Forest")); + AssertJUnit.assertEquals("Treasure should stay unused", 1, countOnBattlefield(game, "Treasure Token")); + } + + // Signet {1} nested activation: Lotus Petal beats Goldspan Treasure (don't waste 2-mana disposable). + @Test + public void petalPreferredOverGoldspanTreasureForSignetActivation() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Goldspan Dragon", p); + addToken("c_a_treasure_sac", p); + addCard("Lotus Petal", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("R W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Lotus Petal should activate the Signet", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertFalse("Goldspan Treasure should not pay {R}{W} directly", + sources.size() == 1 && sources.anyMatch(c -> "Treasure Token".equals(c.getName()))); + + assertProductionPayment(game, p, mc, sa); + AssertJUnit.assertEquals("Treasure should stay unused", 1, countOnBattlefield(game, "Treasure Token")); + } + + // {R}{W} with only Plains + Signet (no Mountain) is still payable: Plains -> Signet {1}, Signet -> {R}{W}. + @Test + public void filterOnlyPathIsFeasible() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertTrue(canAutoPay(game, p, cost("R W"), spell.getFirstSpellAbility())); + } + + // {1}{R}{W} with only 1 Plains + Signet is not enough mana; Auto must report infeasible. + @Test + public void insufficientManaIsInfeasible() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertFalse(canAutoPay(game, p, cost("1 R W"), spell.getFirstSpellAbility())); + } + + // Skycloud Expanse ({1}{T}: Add {W}{U}) with a Wastes for its {1} can pay {W}{U}. + @Test + public void filterLandOnlyBaseIsFeasible() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Wastes", p); + addCard("Skycloud Expanse", p); + Card spell = addCardToZone("Azorius Charm", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W U"), spell.getFirstSpellAbility())); + } + + // Selesnya Signet should cover {G}{W} (with a reusable source for its {1}) instead of sacrificing Lotus Petal. + @Test + public void signetConsolidatesColoredShardsOverLotusPetal() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Wastes", p); + addCard("Selesnya Signet", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Arcus Acolyte", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + GameSimulator sim = createSimulator(game, p); + int score = sim.simulateSpellAbility(spell.getFirstSpellAbility()).value; + AssertJUnit.assertTrue(score > 0); + Game simGame = sim.getSimulatedGameState(); + + AssertJUnit.assertNotNull(findSpellCard(simGame, "Arcus Acolyte")); + AssertJUnit.assertNotNull("Lotus Petal should not be sacrificed", findSpellCard(simGame, "Lotus Petal")); + AssertJUnit.assertEquals("Signet should be tapped", 1, countTapped(simGame, "Selesnya Signet")); + } + + // {W}{G}: Selesnya Signet + Mox Emerald for its {1} beats sacrificing Lotus Petal. + @Test + public void signetConsolidatesOverPetalWithMoxEmerald() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mox Emerald", p); + addCard("Selesnya Signet", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Arcus Acolyte", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W G"), spell.getFirstSpellAbility())); + } + + // Study Hall ({1}: any) can pay the second {W} when Plains pays the first. + @Test + public void filterLandPaysSecondColoredShardAfterBasic() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Plains", p); + addCard("Study Hall", p); + Card spell = addCardToZone("Flowering of the White Tree", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W W"), spell.getFirstSpellAbility())); + } + + // Signet {1} should tap Mountain, not Plains, when a {W} commander in command zone still needs white mana. + @Test + public void filterActivationPreservesCommandZoneCastability() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Plains", p); + addCard("Boros Signet", p); + addCardToZone("Yoshimaru, Ever Faithful", p, ZoneType.Command); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("R W"), sa); + + AssertJUnit.assertEquals("Signet should be tapped", 1, countTapped(game, "Boros Signet")); + AssertJUnit.assertEquals("Mountain should pay the Signet's {1}", 1, countTapped(game, "Mountain")); + AssertJUnit.assertEquals("Plains should stay untapped for the command-zone spell", 0, countTapped(game, "Plains")); + } + + // Castability probe: no red sources — skip dry-runs for hand spells that need {R}. + @Test + public void castabilityProbeSkipsRedDependentsWhenNoRed() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Forest", p); + addCard("Llanowar Elves", p); + addCard("Study Hall", p); + addCardToZone("Shock", p, ZoneType.Hand); + addCardToZone("Shock", p, ZoneType.Hand); + addCardToZone("Shock", p, ZoneType.Hand); + Card spell = addCardToZone("Healing Salve", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W"), sa)); + final int dryRuns = castabilityProbeDryRunsForPaymentPrompt(game, p, cost("W"), sa); + AssertJUnit.assertEquals("Red spells should be pruned without nested dry-runs", 0, dryRuns); + assertProductionPayment(game, p, cost("W"), sa); + } + + // Castability probe: one Mountain — both {R} spells still get full dry-runs (no quantity over-prune). + @Test + public void castabilityProbeDoesNotSkipWhenOneRedRemains() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Plains", p); + addCard("Boros Signet", p); + addCardToZone("Shock", p, ZoneType.Hand); + addCardToZone("Shock", p, ZoneType.Hand); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("R W"), sa)); + final int dryRuns = castabilityProbeDryRunsForPaymentPrompt(game, p, cost("R W"), sa); + AssertJUnit.assertTrue("Both red spells should still be probed when {R} remains available", dryRuns >= 2); + assertProductionPayment(game, p, cost("R W"), sa); + } + + // Soft CMC cap: skip dry-run for 5-drop when total mana < CMC; still probe low-CMC spells. + @Test + public void castabilityProbeSoftCmcCapSkipsWhenTotalManaInsufficient() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Plains", p); + addCard("Boros Signet", p); + addCardToZone("Air Elemental", p, ZoneType.Hand); + addCardToZone("Divine Favor", p, ZoneType.Hand); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("R W"), sa)); + final int dryRuns = castabilityProbeDryRunsForPaymentPrompt(game, p, cost("R W"), sa); + AssertJUnit.assertTrue("High-CMC spell should be skipped; low-CMC spell still probed", dryRuns >= 1); + assertProductionPayment(game, p, cost("R W"), sa); + } + + // Canopy Vista produces {W} directly; Study Hall should not be used for a lone {W} pip. + @Test + public void studyHallDoesNotBeatDualLandForSingleWhite() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Study Hall", p); + addCard("Canopy Vista", p); + addCard("Snow-Covered Forest", p); + Card spell = addCardToZone("Healing Salve", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("W"), sa); + AssertJUnit.assertTrue("Canopy Vista should pay {W} directly", sources.anyMatch(c -> "Canopy Vista".equals(c.getName()))); + AssertJUnit.assertFalse("Study Hall should not be used for a single {W}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + } + + private CardCollection predictedManaSources(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa) { + final CardCollection[] sources = new CardCollection[1]; + p.runWithController(() -> sources[0] = ComputerUtilMana.getManaSourcesToPayCostForPaymentPrompt(mc, sa, p, false), + new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + return sources[0]; + } + + /** Runs payment-prompt preview and returns castability nested dry-run count (see {@link ComputerUtilMana}). */ + private int castabilityProbeDryRunsForPaymentPrompt(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa) { + final int[] count = new int[1]; + p.runWithController(() -> { + ComputerUtilMana.resetCastabilityProbeDryRunCountForTests(); + ComputerUtilMana.getManaSourcesToPayCostForPaymentPrompt(mc, sa, p, false); + count[0] = ComputerUtilMana.getCastabilityProbeDryRunCountForTests(); + }, new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + return count[0]; + } + + private SpellAbility findEquipAbility(final Card equipment) { + for (KeywordInterface kw : equipment.getKeywords(Keyword.EQUIP)) { + for (SpellAbility sa : kw.getAbilities()) { + if (sa.isEquip()) { + return sa; + } + } + } + return null; + } + + private String capturePaymentPlan(Game game, Player p, ManaCostBeingPaid mc, SpellAbility sa, final boolean test) { + final String prevPlan = System.getProperty("forge.debugManaPayment.plan"); + final PrintStream prevOut = System.out; + final ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try { + System.setProperty("forge.debugManaPayment.plan", "true"); + System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8)); + if (test) { + final CardCollection[] sources = new CardCollection[1]; + p.runWithController(() -> sources[0] = ComputerUtilMana.getManaSourcesToPayCostForPaymentPrompt( + new ManaCostBeingPaid(mc), sa, p, false), + new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + AssertJUnit.assertNotNull(sources[0]); + } else { + final boolean[] result = new boolean[1]; + p.runWithController(() -> result[0] = ComputerUtilMana.payManaCostFromPaymentPrompt( + new ManaCostBeingPaid(mc), sa, p, false), + new PlayerControllerAi(game, p, p.getOriginalLobbyPlayer())); + AssertJUnit.assertTrue(result[0]); + } + return captured.toString(StandardCharsets.UTF_8); + } finally { + System.setOut(prevOut); + if (prevPlan == null) { + System.clearProperty("forge.debugManaPayment.plan"); + } else { + System.setProperty("forge.debugManaPayment.plan", prevPlan); + } + } + } + + private List extractPaymentPlanSteps(final String log, final boolean test) { + final String marker = "MANA_PAYMENT_PLAN [" + (test ? "test" : "prod") + "]"; + final int start = log.indexOf(marker); + if (start < 0) { + return List.of(); + } + final List steps = Lists.newArrayList(); + final String[] lines = log.substring(start).split("\\R"); + for (int i = 1; i < lines.length; i++) { + final String line = lines[i].trim(); + if (line.isEmpty()) { + break; + } + if (!line.matches("\\d+\\. .*")) { + break; + } + steps.add(line.replaceFirst("^\\d+\\. ", "")); + } + return steps; + } + + /** Cards added directly to a zone skip ETB; register triggers for TapsForMana auras/enchantments. */ + private void registerBattlefieldTriggers(Game game, Card... cards) { + for (final Card c : cards) { + if (c != null) { + game.getTriggerHandler().registerActiveTrigger(c, false); + } + } + } + + // {G} with Plains + Study Hall + Lotus Petal should tap Plains for Study Hall's {1}, not sacrifice Petal. + @Test + public void studyHallBeatsLotusPetalForSingleGreen() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Hardened Scales", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("G"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G"), sa); + AssertJUnit.assertTrue("Study Hall should produce {G}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertTrue("Plains should pay Study Hall's {1}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + } + + // {W} with tapped Plains: Forest pays Study Hall's {1}, not Lotus Petal. + @Test + public void studyHallBeatsLotusPetalWhenPlainsTapped() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card plains = addCard("Plains", p); + plains.setTapped(true); + Card prairie = addCard("Sungrass Prairie", p); + prairie.setTapped(true); + Card petal2 = addCard("Lotus Petal", p); + petal2.setTapped(true); + addCard("Forest", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + addCardToZone("Healing Salve", p, ZoneType.Hand); + Card spell = addSpellOnStack(game, "Speaker of the Heavens", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Study Hall should produce {W}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertTrue("Forest should pay Study Hall's {1}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W} on stack: Mind Stone activates Sungrass Prairie; Study Hall filter stays for later. + @Test + public void shelteredByGhostsUsesMindStoneNotStudyHallForPrairie() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Study Hall", p); + addCard("Sungrass Prairie", p); + Card spell = addSpellOnStack(game, "Sheltered by Ghosts", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Sungrass Prairie should pay {1}{W}", + sources.anyMatch(c -> "Sungrass Prairie".equals(c.getName()))); + AssertJUnit.assertTrue("Mind Stone should activate Sungrass Prairie", + sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + AssertJUnit.assertFalse("Study Hall should not be tapped for Prairie's {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + + final String testLog = capturePaymentPlan(game, p, mc, sa, true); + final String prodLog = capturePaymentPlan(game, p, mc, sa, false); + final List testSteps = extractPaymentPlanSteps(testLog, true); + final List prodSteps = extractPaymentPlanSteps(prodLog, false); + AssertJUnit.assertFalse("Test plan should not be empty", testSteps.isEmpty()); + AssertJUnit.assertFalse("Prod plan should not be empty", prodSteps.isEmpty()); + AssertJUnit.assertEquals("Preview and production plans should match", prodSteps, testSteps); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W} on stack: preview should match prod (Haven for generic, not off-color Forest). + @Test + public void ainokBondKinStackPreviewMatchesProduction() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card lair = addCard("Valgavoth's Lair", p); + lair.setChosenColors(Lists.newArrayList("white")); + addCard("Snow-Covered Forest", p); + Card haven = addCard("Strength of the Harvest", p); + haven.setState(CardStateName.Backside, false, true); + haven.setTapped(false); + addCardToZone("Healing Salve", p, ZoneType.Hand); + Card spell = addSpellOnStack(game, "Ainok Bond-Kin", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Valgavoth's Lair should pay {W}", + sources.anyMatch(c -> "Valgavoth's Lair".equals(c.getName()))); + AssertJUnit.assertTrue("Haven of the Harvest should pay generic {1}", + sources.anyMatch(c -> "Haven of the Harvest".equals(c.getName()))); + AssertJUnit.assertFalse("Forest should not pay generic {1}", + sources.anyMatch(c -> "Snow-Covered Forest".equals(c.getName()))); + + final String testLog = capturePaymentPlan(game, p, mc, sa, true); + final String prodLog = capturePaymentPlan(game, p, mc, sa, false); + AssertJUnit.assertEquals("Preview and production plans should match", + extractPaymentPlanSteps(prodLog, false), extractPaymentPlanSteps(testLog, true)); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Generic {1} should tap colorless mana, not an any-mana signet, when {W}{W} also need paying. + @Test + public void genericShardPrefersColorlessOverSignet() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Plains", p); + addCard("Plains", p); + addCard("Arcane Signet", p); + Card spell = addCardToZone("Akroma's Vengeance", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("1 W W"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("1 W W"), sa); + AssertJUnit.assertTrue("Mind Stone should pay generic {1}", sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + AssertJUnit.assertFalse("Arcane Signet should not pay generic {1}", sources.anyMatch(c -> "Arcane Signet".equals(c.getName()))); + } + + // Lone generic {1} should still prefer a colorless rock over a colored basic. + @Test + public void genericShardPrefersColorlessWithoutColorlessDemand() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Plains", p); + Card spell = addCardToZone("Expedition Map", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + CardCollection sources = predictedManaSources(game, p, cost("1"), sa); + AssertJUnit.assertTrue("Mind Stone should pay generic {1}", sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + AssertJUnit.assertFalse("Plains should not pay generic {1}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + } + + // {1}{W}: Plains pays {W}, colorless source pays generic {1}; Forest stays untapped for future {G}. + @Test + public void colorlessGenericPreservesColoredBasicsForColoredPips() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Forest", p); + addCard("Reliquary Tower", p); + Card spell = addCardToZone("Sheltered by Ghosts", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Plains should pay {W}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertTrue("Colorless source should pay generic {1}", + sources.anyMatch(c -> "Reliquary Tower".equals(c.getName()))); + AssertJUnit.assertFalse("Forest should not pay generic {1}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Eldrazi and other {C}-heavy hands should spend colored mana on generic pips and keep rocks for {C}. + @Test + public void reservesColorlessWhenHandNeedsColorlessPips() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Plains", p); + addCardToZone("Thought-Knot Seer", p, ZoneType.Hand); + Card spell = addCardToZone("Expedition Map", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + CardCollection sources = predictedManaSources(game, p, cost("1"), sa); + AssertJUnit.assertTrue("Plains should pay generic {1}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertFalse("Mind Stone should be reserved for {C}", sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + } + + // Signet {1} should prefer colorless mana (Mind Stone) over a colored basic when both can pay generic. + @Test + public void nestedActivationPrefersColorlessOverColoredBasic() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mind Stone", p); + addCard("Mountain", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("R W"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("R W"), sa); + AssertJUnit.assertTrue("Mind Stone should pay the Signet's {1}", sources.anyMatch(c -> "Mind Stone".equals(c.getName()))); + AssertJUnit.assertFalse("Mountain should not pay generic {1}", sources.anyMatch(c -> "Mountain".equals(c.getName()))); + AssertJUnit.assertTrue("Signet should produce both colors", sources.anyMatch(c -> "Boros Signet".equals(c.getName()))); + } + + // {2} with Sungrass Prairie + Study Hall should tap both (Prairie pays both generic pips), not also a basic. + @Test + public void multiManaFilterLandConsolidatesGenericCost() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Study Hall", p); + addCard("Sungrass Prairie", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Shadowspear", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2"), sa); + AssertJUnit.assertTrue("Sungrass Prairie should pay {2}", + sources.anyMatch(c -> "Sungrass Prairie".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay Sungrass Prairie's {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertFalse("Forest should not be tapped for {2}", + sources.anyMatch(c -> "Snow-Covered Forest".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed for {2}", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("2"), sa)); + } + + // {2} with Sungrass Prairie + Study Hall should still consolidate when Eternal Witness ({1}{G}{G}) + // is in hand — Forest covers {G}, so Prairie must not be reserved for Witness. + @Test + public void sungrassPrairieConsolidatesDespiteMonoColorHandSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Study Hall", p); + addCard("Sungrass Prairie", p); + addCard("Lotus Petal", p); + addCardToZone("Eternal Witness", p, ZoneType.Hand); + Card spell = addCardToZone("Shadowspear", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2"), sa); + AssertJUnit.assertTrue("Sungrass Prairie should pay {2}", + sources.anyMatch(c -> "Sungrass Prairie".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay Sungrass Prairie's {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed for {2}", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("2"), sa)); + } + + // Same as above without a Forest: Witness's {G} can come from Lotus Petal, so Prairie still pays {2}. + @Test + public void sungrassPrairieConsolidatesWhenHandGreenCoveredByPetal() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Study Hall", p); + addCard("Sungrass Prairie", p); + addCard("Lotus Petal", p); + addCardToZone("Eternal Witness", p, ZoneType.Hand); + Card spell = addCardToZone("Shadowspear", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2"), sa); + AssertJUnit.assertTrue("Sungrass Prairie should pay {2}", + sources.anyMatch(c -> "Sungrass Prairie".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed for {2}", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + } + + // Casting {2} on stack: one colorless rock + one colored basic, not two colored basics; test == prod. + @Test + public void doubleGenericUsesColorlessRockNotSecondColoredBasic() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Reliquary Tower", p); + addCards("Snow-Covered Forest", 1, p); + addCardToZone("Thought-Knot Seer", p, ZoneType.Hand); + Card spell = addSpellOnStack(game, "Fellwar Stone", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("2"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Reliquary Tower should pay generic {1}", + sources.anyMatch(c -> "Reliquary Tower".equals(c.getName()))); + AssertJUnit.assertEquals("Should tap exactly two sources for {2}", 2, sources.size()); + AssertJUnit.assertFalse("Should not tap two colored basics for {2}", + sources.stream().filter(c -> c.getName().contains("Forest")).count() >= 2); + + AssertJUnit.assertTrue("Production auto-pay should match preview", prodAutoPay(game, p, mc, sa)); + } + + // {2} Mind Stone on stack: Snow-Covered Forest + Study Hall {T}:{C}, not two colored basics; test == prod. + @Test + public void stackMindStoneUsesStudyHallNotSecondForestForGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Forest", p); + addCard("Study Hall", p); + Card spell = addSpellOnStack(game, "Mind Stone", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("2"); + AssertJUnit.assertTrue(canAutoPay(game, p, new ManaCostBeingPaid(mc), sa)); + + CardCollection sources = predictedManaSources(game, p, new ManaCostBeingPaid(mc), sa); + AssertJUnit.assertTrue("Study Hall should pay generic {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertEquals("Should tap exactly two sources for {2}", 2, sources.size()); + AssertJUnit.assertFalse("Should not tap two colored basics for {2}", + sources.stream().filter(c -> c.getName().contains("Forest")).count() >= 2); + + AssertJUnit.assertTrue("Production auto-pay should match preview", prodAutoPay(game, p, new ManaCostBeingPaid(mc), sa)); + AssertJUnit.assertEquals("Study Hall should be tapped in production", 1, countTapped(game, "Study Hall")); + AssertJUnit.assertEquals("Only one Forest should be tapped in production", 1, + countTapped(game, "Snow-Covered Forest") + countTapped(game, "Forest")); + } + + // Equip {1}: payment-prompt preview and Auto commit both emit MANA_PAYMENT_PLAN tagged [equip]. + @Test + public void equipAbilityEmitsPaymentPromptPlans() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + Card bear = addCard("Runeclaw Bear", p); + bear.setSickness(false); + Card equipment = addCard("Bonesplitter", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility equipSa = findEquipAbility(equipment); + AssertJUnit.assertNotNull(equipSa); + equipSa.setActivatingPlayer(p); + equipSa.getTargets().add(bear); + + ManaCostBeingPaid mc = new ManaCostBeingPaid(equipSa.getPayCosts().getCostMana().getMana()); + + final String testLog = capturePaymentPlan(game, p, mc, equipSa, true); + AssertJUnit.assertTrue("Test plan should be emitted for equip", testLog.contains("MANA_PAYMENT_PLAN [test]")); + AssertJUnit.assertTrue("Test plan should tag equip abilities", testLog.contains("Bonesplitter [equip]")); + + final String prodLog = capturePaymentPlan(game, p, mc, equipSa, false); + AssertJUnit.assertTrue("Prod plan should be emitted for equip", prodLog.contains("MANA_PAYMENT_PLAN [prod]")); + AssertJUnit.assertTrue("Prod plan should tag equip abilities", prodLog.contains("Bonesplitter [equip]")); + + final List testSteps = extractPaymentPlanSteps(testLog, true); + final List prodSteps = extractPaymentPlanSteps(prodLog, false); + AssertJUnit.assertFalse("Test plan should list tap steps", testSteps.isEmpty()); + AssertJUnit.assertEquals("Prod plan steps should match test", testSteps, prodSteps); + } + + // Companion {3}: ST$ put-into-hand is AbilityStatic; preview and Auto commit emit [companion] plans. + @Test + public void companionAbilityEmitsPaymentPromptPlans() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Island", p); + addCard("Swamp", p); + Card companion = addCardToZone("Grizzly Bears", p, ZoneType.Command); + p.getZone(ZoneType.Command).add(Player.createCompanionEffect(companion)); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility companionSa = null; + for (final SpellAbility sa : companion.getNonManaAbilities()) { + final String desc = sa.getDescription(); + if (desc != null && desc.contains("Companion") + && sa.getPayCosts() != null && sa.getPayCosts().hasManaCost()) { + companionSa = sa; + break; + } + } + AssertJUnit.assertNotNull("Companion put-into-hand ability should be granted", companionSa); + companionSa.setActivatingPlayer(p); + + final ManaCostBeingPaid mc = new ManaCostBeingPaid(companionSa.getPayCosts().getCostMana().getMana()); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, companionSa)); + + final String testLog = capturePaymentPlan(game, p, mc, companionSa, true); + AssertJUnit.assertTrue("Test plan should be emitted for companion", testLog.contains("MANA_PAYMENT_PLAN [test]")); + AssertJUnit.assertTrue("Test plan should tag companion abilities", testLog.contains("Grizzly Bears [companion]")); + + final String prodLog = capturePaymentPlan(game, p, mc, companionSa, false); + AssertJUnit.assertTrue("Prod plan should be emitted for companion", prodLog.contains("MANA_PAYMENT_PLAN [prod]")); + AssertJUnit.assertTrue("Prod plan should tag companion abilities", prodLog.contains("Grizzly Bears [companion]")); + + final List testSteps = extractPaymentPlanSteps(testLog, true); + final List prodSteps = extractPaymentPlanSteps(prodLog, false); + AssertJUnit.assertFalse("Test plan should list tap steps", testSteps.isEmpty()); + AssertJUnit.assertEquals("Prod plan steps should match test", testSteps, prodSteps); + } + + // {1}{W}{G} on stack: Plains {W}, Forest {G}, Reliquary Tower {C} for generic — not Sol Ring. + @Test + public void stackCalixUsesReliquaryTowerNotSolRingForGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Forest", p); + addCard("Reliquary Tower", p); + addCard("Sol Ring", p); + Card spell = addSpellOnStack(game, "Calix, Guided by Fate", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W G"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Plains should pay {W}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertTrue("Forest should pay {G}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertTrue("Reliquary Tower should pay generic {1}", + sources.anyMatch(c -> "Reliquary Tower".equals(c.getName()))); + AssertJUnit.assertFalse("Sol Ring should not pay generic {1} when Reliquary Tower can", + sources.anyMatch(c -> "Sol Ring".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {2}{G} should tap Sol Ring once for {2}, not Reliquary Tower + Sol Ring. + @Test + public void solRingPaysDoubleGenericWithColoredPip() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Reliquary Tower", p); + addCard("Sol Ring", p); + Card spell = addCardToZone("Chomping Changeling", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2 G"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2 G"), sa); + AssertJUnit.assertTrue("Forest should pay {G}", + sources.anyMatch(c -> c.getName().contains("Forest"))); + AssertJUnit.assertTrue("Sol Ring should pay {2}", sources.anyMatch(c -> "Sol Ring".equals(c.getName()))); + AssertJUnit.assertFalse("Reliquary Tower should not pay generic when Sol Ring covers {2}", + sources.anyMatch(c -> "Reliquary Tower".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("2 G"), sa)); + } + + // {2}{G}{G} should tap Sol Ring once for {2}, not Thought Vessel + Sol Ring. + @Test + public void solRingPaysDoubleGenericWithoutExtraRock() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Forest", p); + addCard("Sol Ring", p); + addCard("Thought Vessel", p); + Card spell = addCardToZone("Ouroboroid", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2 G G"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2 G G"), sa); + AssertJUnit.assertTrue("Sol Ring should pay {2}", sources.anyMatch(c -> "Sol Ring".equals(c.getName()))); + AssertJUnit.assertFalse("Thought Vessel should not pay generic", + sources.anyMatch(c -> "Thought Vessel".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("2 G G"), sa)); + } + + // {G}{G} with two Forests should not waste Gilded Lotus's third mana. + @Test + public void forestsPayDoubleGreenWithoutLotus() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Gilded Lotus", p); + addCards("Forest", 2, p); + Card spell = addCardToZone("Slith Predator", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("G G"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G G"), sa); + AssertJUnit.assertFalse("Gilded Lotus should not be tapped", + sources.anyMatch(c -> "Gilded Lotus".equals(c.getName()))); + AssertJUnit.assertEquals("Both Forests should pay {G}{G}", 2, + sources.stream().filter(c -> "Forest".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("G G"), sa)); + } + + // {G}{G} with Gaea's Cradle (4 creatures → 4G) + two Forests should not waste Cradle's extra mana. + @Test + public void forestsPayDoubleGreenWithoutGaesCradle() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Gaea's Cradle", p); + addCards("Forest", 2, p); + addCards("Bear Cub", 4, p); + Card spell = addCardToZone("Slith Predator", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("G G"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G G"), sa); + AssertJUnit.assertFalse("Gaea's Cradle should not be tapped", + sources.anyMatch(c -> "Gaea's Cradle".equals(c.getName()))); + AssertJUnit.assertEquals("Both Forests should pay {G}{G}", 2, + sources.stream().filter(c -> "Forest".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("G G"), sa)); + } + + // Cradle (4 creatures → 4G) + 2 Forests: {G}{G}{G} needs Cradle; don't also tap both Forests. + @Test + public void gaesCradleUsedAloneWhenForestsCannotCoverCost() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Gaea's Cradle", p); + addCards("Forest", 2, p); + addCards("Bear Cub", 4, p); + Card spell = addCardToZone("Slith Predator", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("G G G"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Gaea's Cradle should pay when two Forests cannot cover {G}{G}{G}", + sources.anyMatch(c -> "Gaea's Cradle".equals(c.getName()))); + AssertJUnit.assertEquals("Forests should stay untapped when Cradle covers the cost", + 0, sources.stream().filter(c -> "Forest".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, mc, sa)); + } + + // {G}{G}{G}{G} with Cradle matching creature count should use Cradle instead of stranding basics. + @Test + public void gaesCradleUsedWhenCostNeedsFullOutput() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Gaea's Cradle", p); + addCards("Forest", 4, p); + addCards("Bear Cub", 4, p); + Card spell = addCardToZone("Slith Predator", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("G G G G"), sa); + + CardCollection sources = predictedManaSources(game, p, cost("G G G G"), sa); + AssertJUnit.assertTrue("Gaea's Cradle should pay when the cost needs all four green", + sources.anyMatch(c -> "Gaea's Cradle".equals(c.getName()))); + } + + // {2}{R} with M/P/F + Lotus should tap Lotus when Lightning Helix ({R}{W}) is still in hand. + @Test + public void gildedLotusUsedWhenHandNeedsMultipleColors() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Mountain", p); + addCard("Plains", p); + addCard("Forest", p); + addCard("Gilded Lotus", p); + addCardToZone("Lightning Helix", p, ZoneType.Hand); + Card spell = addCardToZone("Homing Sliver", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2 R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2 R"), sa); + AssertJUnit.assertTrue("Gilded Lotus should pay part of {2}{R}", + sources.anyMatch(c -> "Gilded Lotus".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("2 R"), sa)); + } + + // {2} for Thought Vessel should not spend Selesnya Signet when Phelia ({1}{W}) is still in hand. + @Test + public void genericCostPreservesSignetForHandSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Wastes", p); + addCards("Forest", 2, p); + addCard("Selesnya Signet", p); + addCardToZone("Phelia, Exuberant Shepherd", p, ZoneType.Hand); + Card spell = addCardToZone("Thought Vessel", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("2"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("2"), sa); + AssertJUnit.assertFalse("Signet should stay available for Phelia", + sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + } + + // Lotus Petal should be reserved to activate the Signet; Signet then produces {G}{W} to pay both + // Luminarch Aspirant's {W} and generic {1}. Spending Petal directly on {W} strands the Signet. + @Test + public void lotusPetalCanActivateSignetForOneWhiteCost() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Lotus Petal", p); + addCard("Selesnya Signet", p); + Card spell = addCardToZone("Luminarch Aspirant", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Signet should pay", sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not pay {W} directly", + sources.size() == 1 && sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + /** Place a spell on the game stack for payment tests (player zones have no Stack). */ + private Card addSpellOnStack(Game game, String name, Player p) { + Card spell = createCard(name, p); + spell.setGameTimestamp(game.getNextTimestamp()); + SpellAbility sa = spell.getFirstSpellAbility(); + sa.setActivatingPlayer(p); + game.getStack().addAndUnfreeze(sa); + return spell; + } + + // Same as above when the spell is already on the stack (Luminarch Aspirant is {1}{W}). + @Test + public void lotusPetalActivatesSignetForOneWhiteCostOnStack() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Lotus Petal", p); + addCard("Selesnya Signet", p); + Card spell = addSpellOnStack(game, "Luminarch Aspirant", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Signet should pay", sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Lotus Petal should activate the Signet", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Jacked Rabbit is {X}{1}{W}; X=0 matches the Petal + Signet routing case on stack. + @Test + public void lotusPetalActivatesSignetForJackedRabbitOnStack() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Lotus Petal", p); + addCard("Selesnya Signet", p); + Card spell = addSpellOnStack(game, "Jacked Rabbit", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Signet should pay", sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Lotus Petal should activate the Signet", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W} on stack: preview and production both tap the on-color dual for generic (no castability probe). + @Test + public void stackPaymentUsesOffColorRockForGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + Player opp = game.getPlayers().get(0); + + addCard("Swamp", opp); + addCard("Snow-Covered Plains", p); + addCard("Razorverge Thicket", p); + addCard("Fellwar Stone", p); + addCardToZone("Healing Salve", p, ZoneType.Hand); + Card spell = addSpellOnStack(game, "Luminarch Aspirant", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Plains should pay {W}", + sources.anyMatch(c -> c.getName().contains("Plains"))); + AssertJUnit.assertTrue("Razorverge Thicket should pay generic {1}", + sources.anyMatch(c -> "Razorverge Thicket".equals(c.getName()))); + + final String testLog = capturePaymentPlan(game, p, mc, sa, true); + final String prodLog = capturePaymentPlan(game, p, mc, sa, false); + AssertJUnit.assertEquals("Preview and production plans should match", + extractPaymentPlanSteps(prodLog, false), extractPaymentPlanSteps(testLog, true)); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Tapped land must not block Petal from activating Signet when the land cannot actually pay {1}. + @Test + public void lotusPetalActivatesSignetWhenOnlyLandIsTapped() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card plains = addCard("Plains", p); + plains.setTapped(true); + addCard("Lotus Petal", p); + addCard("Selesnya Signet", p); + Card spell = addSpellOnStack(game, "Jacked Rabbit", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Signet should pay", sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Lotus Petal should activate the Signet", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {2} with imprinted Chrome Mox {G} + Study Hall {T}:{C} should not sacrifice Lotus Petal. + @Test + public void chromeMoxAndStudyHallPayDoubleGenericWithoutPetal() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card imprint = addCardToZone("Llanowar Elves", p, ZoneType.Exile); + Card mox = addCard("Chrome Mox", p); + mox.addImprintedCard(imprint); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addSpellOnStack(game, "Selesnya Signet", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("2"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Chrome Mox should pay", sources.anyMatch(c -> "Chrome Mox".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {5}{W}{W}: Signet + reusable activator should cover a {W} pip instead of sacrificing Lotus Petal. + @Test + public void signetBeatsLotusPetalForWhiteWithLargeGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Reliquary Tower", p); + addCard("Selesnya Signet", p); + addCard("Lotus Petal", p); + addCards("Plains", 6, p); + Card spell = addCardToZone("Elesh Norn, Grand Cenobite", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("5 W W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Selesnya Signet should help pay white", + sources.anyMatch(c -> "Selesnya Signet".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should be preserved", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + } + + // {1}{W}{G} with Plains + Lotus Petal + Study Hall must be payable: Plains -> {W}, Lotus Petal -> {G}, + // Study Hall {C} -> generic {1}. Using Study Hall (and its nested Plains activation) for {G} strands {1}. + @Test + public void filterActivationDoesNotStrandGenericPip() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Lotus Petal", p); + addCard("Study Hall", p); + Card spell = addCardToZone("Calix, Guided by Fate", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Plains should pay {W}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertTrue("Lotus Petal should pay {G}", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay generic {1}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Two Plains + Study Hall + Petal on stack: Petal {G}, one Plains {W}, Study Hall {C} for {1}. + @Test + public void calixOnStackDoesNotBurnStudyHallForGreenWithTwoPlains() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Snow-Covered Plains", p); + addCard("Lotus Petal", p); + addCard("Study Hall", p); + Card spell = addSpellOnStack(game, "Calix, Guided by Fate", p); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Lotus Petal should pay {G}", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay generic {1}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertEquals("Only one Plains should be tapped", 1, + sources.stream().filter(c -> c.getName().contains("Plains")).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W}{G} with dedicated sources for each pip: Plains {W}, Forest {G}, Study Hall {T}:{C} for + // generic {1}. Lotus Petal must not be sacrificed when reusables cover every shard. + @Test + public void dedicatedSourcesBeatLotusPetalForMulticolorWithGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Forest", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Calix, Guided by Fate", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Plains should pay {W}", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertTrue("Forest should pay {G}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay generic {1}", sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W}{G} with Arcane Signet (GW commander), Forest, Study Hall, Reliquary Tower, and Lotus Petal: + // Signet -> {W}, Forest -> {G}, colorless land -> {1}; Petal unused. + @Test + public void arcaneSignetBeatsLotusPetalForMulticolorWithGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Arcane Signet", p); + addCard("Study Hall", p); + addCard("Reliquary Tower", p); + addCard("Lotus Petal", p); + Card commander = addCardToZone("Calix, Guided by Fate", p, ZoneType.Command); + p.addCommander(commander); + Card spell = addCardToZone("Knight of Autumn", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Arcane Signet should pay {W}", sources.anyMatch(c -> "Arcane Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Forest should pay {G}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertTrue("A colorless source should pay generic {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()) || "Reliquary Tower".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W}{G} with Arcane Signet, Forest, Study Hall ({T}:{C} is free), and Basalt Monolith: + // Signet -> {W}, Forest -> {G}, Study Hall's free {C} -> generic {1}; not Basalt's {C}{C}{C}. + @Test + public void studyHallFreeColorlessBeatsBasaltForSingleGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Forest", p); + addCard("Arcane Signet", p); + addCard("Study Hall", p); + addCard("Basalt Monolith", p); + Card commander = addCardToZone("Calix, Guided by Fate", p, ZoneType.Command); + p.addCommander(commander); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = commander.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Arcane Signet should pay {W}", sources.anyMatch(c -> "Arcane Signet".equals(c.getName()))); + AssertJUnit.assertTrue("Forest should pay {G}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertTrue("Study Hall should pay generic {1} via free {C}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + AssertJUnit.assertFalse("Basalt Monolith should not pay a lone generic {1}", + sources.anyMatch(c -> "Basalt Monolith".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{G} with Plains + Study Hall + Lotus Petal (no Forest): sacrifice Petal for {G}, tap Plains + // for generic {1}. Petal only wins because Study Hall's filter is a 2:1 trade that strands {1}; + // with a Forest available the disposable would stay unused. + @Test + public void disposablePaysColoredWhenGenericAlsoUnpaid() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Michelangelo, Weirdness to 11", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Lotus Petal should pay {G}", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertTrue("Plains should pay generic {1} directly", sources.anyMatch(c -> "Plains".equals(c.getName()))); + AssertJUnit.assertFalse("Study Hall should be saved when Petal pays {G}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // {1}{W}{G} with two Plains, Study Hall, Reliquary Tower, and Lotus Petal: Petal -> {G}, one Plains + // -> {W}, Reliquary Tower {C} -> generic {1}; Study Hall stays untapped for its filter ability. + @Test + public void studyHallFilterWithReliquaryTowerBeatsLotusPetal() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCards("Plains", 2, p); + addCard("Study Hall", p); + addCard("Reliquary Tower", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Calix, Guided by Fate", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G W"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Lotus Petal should pay {G}", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertTrue("Reliquary Tower should pay generic {1}", + sources.anyMatch(c -> "Reliquary Tower".equals(c.getName()))); + AssertJUnit.assertEquals("Only one Plains should be tapped", 1, + sources.stream().filter(c -> c.getName().contains("Plains")).count()); + AssertJUnit.assertFalse("Study Hall should stay untapped when Reliquary Tower can pay generic {1}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // --- TapsForMana bonuses and conditional mana links (Sprawl, Festival, Flare, Gemstone Caverns) --- + + // Forest + Utopia Sprawl (chosen blue): one tap produces {G}{U} via TapsForMana trigger simulation. + @Test + public void utopiaSprawlPaysGreenAndChosenColorFromOneForestTap() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card forest = addCard("Forest", p); + Card sprawl = addCard("Utopia Sprawl", p); + sprawl.setChosenColors(Lists.newArrayList("blue")); + sprawl.attachToEntity(forest, null); + registerBattlefieldTriggers(game, sprawl); + Card spell = addCardToZone("Growth Spiral", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("G U"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G U"), sa); + AssertJUnit.assertEquals("Only the Sprawl'd Forest should be tapped", 1, + sources.stream().filter(c -> "Forest".equals(c.getName())).count()); + AssertJUnit.assertFalse("Utopia Sprawl is not a mana source host", + sources.anyMatch(c -> "Utopia Sprawl".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, cost("G U"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Forest")); + } + + // Forest + Market Festival: one tap produces {G} plus two any ({G}{U}{R} from a single source). + @Test + public void marketFestivalProducesThreeManaFromOneForestTap() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card forest = addCard("Forest", p); + Card festival = addCard("Market Festival", p); + festival.attachToEntity(forest, null); + registerBattlefieldTriggers(game, festival); + Card spell = addCardToZone("Temur Charm", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue("One Festival'd Forest should pay {G}{U}{R}", + canAutoPay(game, p, cost("G U R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G U R"), sa); + AssertJUnit.assertEquals("Only the Festival'd Forest should be tapped", 1, + sources.stream().filter(c -> "Forest".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("G U R"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Forest")); + } + + // One Festival'd Forest tap yields at most three mana ({G} plus two any); four colored pips is infeasible. + @Test + public void marketFestivalCannotPayFourColorsFromOneForest() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card forest = addCard("Forest", p); + Card festival = addCard("Market Festival", p); + festival.attachToEntity(forest, null); + registerBattlefieldTriggers(game, festival); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertFalse("One Forest tap cannot pay {G}{U}{R}{W}", + canAutoPay(game, p, cost("G U R W"), sa)); + } + + // Gemstone Caverns with a luck counter: tap adds one mana of any color. + @Test + public void gemstoneCavernsWithLuckCounterPaysColoredMana() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card caverns = addCard("Gemstone Caverns", p); + caverns.addCounterInternal(forge.game.card.CounterEnumType.LUCK, 1, p, false, null, null); + Card spell = addCardToZone("Lightning Bolt", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue("Luck-counter Gemstone Caverns should pay {R}", + canAutoPay(game, p, cost("R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("R"), sa); + AssertJUnit.assertTrue("Gemstone Caverns should be the mana source", + sources.anyMatch(c -> "Gemstone Caverns".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("R"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Gemstone Caverns")); + } + + // Gemstone Caverns without a luck counter: tap adds {C} only. + @Test + public void gemstoneCavernsWithoutLuckCounterPaysColorlessOnly() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Gemstone Caverns", p); + Card spell = addCardToZone("Expedition Map", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("1"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("1"), sa); + AssertJUnit.assertTrue(sources.anyMatch(c -> "Gemstone Caverns".equals(c.getName()))); + + AssertJUnit.assertTrue(prodAutoPay(game, p, cost("1"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Gemstone Caverns")); + } + + // Mana Flare doubles land output: one Plains pays {W}{W}. + @Test + public void manaFlareDoublesLandOutputForDoubleWhite() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card manaFlare = addCard("Mana Flare", p); + addCards("Plains", 2, p); + registerBattlefieldTriggers(game, manaFlare); + Card spell = addCardToZone("Raise the Alarm", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("W W"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("W W"), sa); + AssertJUnit.assertEquals("Only one Plains should be tapped", 1, + sources.stream().filter(c -> "Plains".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, cost("W W"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Plains")); + } + + // Sprawl'd Forest covers {G}{U}; Lotus Petal should stay unused. + @Test + public void utopiaSprawlForestBeatsLotusPetalForGreenAndBlue() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + Card forest = addCard("Forest", p); + Card sprawl = addCard("Utopia Sprawl", p); + sprawl.setChosenColors(Lists.newArrayList("blue")); + sprawl.attachToEntity(forest, null); + registerBattlefieldTriggers(game, sprawl); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Growth Spiral", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("G U"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("G U"), sa); + AssertJUnit.assertTrue("Forest should pay both pips", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, cost("G U"), sa)); + } + + // {1}{G} with Forest available: tap Forest for {G}, keep Lotus Petal unused (disposable is last resort). + @Test + public void forestBeatsLotusPetalWhenReusableSourceExists() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Plains", p); + addCard("Forest", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Michelangelo, Weirdness to 11", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("1 G"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Forest should pay {G}", sources.anyMatch(c -> "Forest".equals(c.getName()))); + AssertJUnit.assertFalse("Lotus Petal should not be sacrificed", + sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // --- Combo AnyDifferent ({T}: two mana of different colors) --- + + // Firemind Vessel: one tap adds two different colors ({U}{R} from a single source). + @Test + public void firemindVesselPaysTwoDifferentColorsFromOneTap() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Firemind Vessel", p); + Card spell = addCardToZone("Goblin Electromancer", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("U R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("U R"), sa); + AssertJUnit.assertEquals("Only Firemind Vessel should be tapped", 1, + sources.stream().filter(c -> "Firemind Vessel".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("U R"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Firemind Vessel")); + } + + // One Firemind tap cannot produce two blue pips ({U}{U}). + @Test + public void firemindVesselCannotPayTwoSameColorsFromOneTap() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Firemind Vessel", p); + Card spell = addCardToZone("Counterspell", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertFalse("Firemind Vessel cannot pay {U}{U}", + canAutoPay(game, p, cost("U U"), sa)); + } + + // Guild Globe: {2}{T}, Sac — two different colors; Wastes pay the {2} activation cost. + @Test + public void guildGlobeSacPaysTwoDifferentColorsWithGenericCost() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCards("Wastes", 2, p); + addCard("Guild Globe", p); + Card spell = addCardToZone("Terminate", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("B R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("B R"), sa); + AssertJUnit.assertTrue("Guild Globe should be a mana source", + sources.anyMatch(c -> "Guild Globe".equals(c.getName()))); + AssertJUnit.assertEquals("Both Wastes should be tapped for Globe's {2}", 2, + sources.stream().filter(c -> "Wastes".equals(c.getName())).count()); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("B R"), sa)); + AssertJUnit.assertEquals("Guild Globe should be sacrificed", 0, + countOnBattlefield(game, "Guild Globe")); + AssertJUnit.assertEquals(2, countTapped(game, "Wastes")); + } + + // --- ManaReflected (Corrupted Grafstone: color from a card in your graveyard) --- + + // Lightning Bolt in the graveyard enables Grafstone to pay {R}. + @Test + public void corruptedGrafstonePaysColorFromGraveyard() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Corrupted Grafstone", p); + addCardToZone("Lightning Bolt", p, ZoneType.Graveyard); + Card spell = addCardToZone("Shock", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("R"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("R"), sa); + AssertJUnit.assertTrue("Corrupted Grafstone should be the mana source", + sources.anyMatch(c -> "Corrupted Grafstone".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("R"), sa)); + AssertJUnit.assertEquals(1, countTapped(game, "Corrupted Grafstone")); + } + + // With no graveyard colors matching the spell, Grafstone cannot pay {R}. + @Test + public void corruptedGrafstoneCannotPayWithoutMatchingGraveyardColor() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Corrupted Grafstone", p); + addCardToZone("Counterspell", p, ZoneType.Graveyard); + Card spell = addCardToZone("Shock", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertFalse("Grafstone cannot add {R} when the graveyard has only blue", + canAutoPay(game, p, cost("R"), sa)); + } + + // --- Variable-amount filters (Cabal Coffers, Crypt of Agadeem) --- + + // Six Swamps + Coffers: pay {2} to activate → 6{B} from Coffers + 4 remaining Swamps = 10{B}, + // enough for Drain Life with X=8 ({8}{1}{B}). + @Test + public void cabalCoffersPaysMultipleBlackFromSwamps() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Cabal Coffers", p); + addCards("Swamp", 6, p); + Card spell = addCardToZone("Drain Life", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("X 1 B"); + mc.setXManaCostPaid(8, "B"); + + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Cabal Coffers should be among mana sources", + sources.anyMatch(c -> "Cabal Coffers".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, mc, sa)); + } + + // Coffers alone (X=0) cannot pay a large Drain Life. + @Test + public void cabalCoffersInfeasibleWithoutSwamps() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Cabal Coffers", p); + Card spell = addCardToZone("Drain Life", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("X 1 B"); + mc.setXManaCostPaid(8, "B"); + + AssertJUnit.assertFalse("Coffers with no Swamps cannot pay {8}{1}{B}", + canAutoPay(game, p, mc, sa)); + } + + // Crypt of Agadeem adds {B} per black creature in the graveyard. + @Test + public void cryptOfAgadeemPaysBlackFromGraveyardCreatures() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Crypt of Agadeem", p); + addCards("Swamp", 2, p); + addCardToZone("Gravecrawler", p, ZoneType.Graveyard); + addCardToZone("Typhoid Rats", p, ZoneType.Graveyard); + Card spell = addCardToZone("Distress", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + // Crypt enters tapped; untap so the {2}{T} variable-amount ability can be used. + for (Card c : game.getCardsIn(ZoneType.Battlefield)) { + if ("Crypt of Agadeem".equals(c.getName())) { + c.setTapped(false); + } + } + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("B B"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("B B"), sa); + AssertJUnit.assertTrue("Crypt of Agadeem should be among mana sources", + sources.anyMatch(c -> "Crypt of Agadeem".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("B B"), sa)); + } + + // --- Non-untapping sources (Mana Vault) deprioritized for generic mana --- + + // Mana Vault ({T}: {C}{C}{C}, doesn't untap) shouldn't be preferred for the {2} of {2}{U}{R} + // when basics can pay it: tapping Mana Vault strands it and wastes a mana. + @Test + public void manaVaultDeprioritizedForGenericWhenLandsAvailable() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Island", p); + addCard("Mountain", p); + addCards("Wastes", 2, p); + addCard("Mana Vault", p); + Card spell = addCardToZone("Jhoira, Weatherlight Captain", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("2 U R"), sa); + + AssertJUnit.assertEquals("Mana Vault should stay untapped when lands can pay the generic", + 0, countTapped(game, "Mana Vault")); + } + + // With no other sources for the generic, Mana Vault is still used (feasibility unaffected). + @Test + public void manaVaultStillUsedWhenNoOtherGenericSource() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Island", p); + addCard("Mountain", p); + addCard("Mana Vault", p); + Card spell = addCardToZone("Jhoira, Weatherlight Captain", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("2 U R"), sa); + + AssertJUnit.assertEquals("Mana Vault pays the generic when nothing else can", + 1, countTapped(game, "Mana Vault")); + } + + // Cascade Bluffs ({U/R}{T}: Add {U}{U}, {U}{R}, or {R}{R}) with an Island paying its hybrid + // activation cost can pay {U}{R} on its own. + @Test + public void comboFilterLandOnlyBaseIsFeasible() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Island", p); + addCard("Cascade Bluffs", p); + Card spell = addCardToZone("Goblin Electromancer", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("U R"), sa)); + AssertJUnit.assertTrue("Production auto-pay should match feasibility", + prodAutoPay(game, p, cost("U R"), sa)); + } + + // {U}{R} with Island + Mountain + Cascade Bluffs should consolidate onto one basic + Bluffs, + // like the Boros Signet consolidation, instead of tapping both basics. + @Test + public void comboFilterConsolidatesTwoColoredShards() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Island", p); + addCard("Mountain", p); + addCard("Cascade Bluffs", p); + Card spell = addCardToZone("Goblin Electromancer", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + GameSimulator sim = createSimulator(game, p); + int score = sim.simulateSpellAbility(spell.getFirstSpellAbility()).value; + AssertJUnit.assertTrue(score > 0); + Game simGame = sim.getSimulatedGameState(); + + AssertJUnit.assertNotNull(findSpellCard(simGame, "Goblin Electromancer")); + AssertJUnit.assertEquals("Cascade Bluffs should be tapped", 1, countTapped(simGame, "Cascade Bluffs")); + int tappedBasics = countTapped(simGame, "Island") + countTapped(simGame, "Mountain"); + AssertJUnit.assertEquals("Only one basic should pay the Bluffs' {U/R}", 1, tappedBasics); + } + + // Bluffs' hybrid {U/R} can be paid by either basic; with Shock ({R}) still in hand, the Island + // should be tapped so the Mountain stays available. + @Test + public void comboFilterActivationPreservesHandCastability() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Island", p); + addCard("Mountain", p); + addCard("Cascade Bluffs", p); + addCardToZone("Shock", p, ZoneType.Hand); + Card spell = addCardToZone("Goblin Electromancer", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + GameSimulator sim = createSimulator(game, p); + int score = sim.simulateSpellAbility(spell.getFirstSpellAbility()).value; + AssertJUnit.assertTrue(score > 0); + Game simGame = sim.getSimulatedGameState(); + + AssertJUnit.assertNotNull(findSpellCard(simGame, "Goblin Electromancer")); + AssertJUnit.assertEquals("Cascade Bluffs should be tapped", 1, countTapped(simGame, "Cascade Bluffs")); + AssertJUnit.assertEquals("Island should pay the Bluffs' {U/R}", 1, countTapped(simGame, "Island")); + AssertJUnit.assertEquals("Mountain should stay untapped for Shock", 0, countTapped(simGame, "Mountain")); + } + + // {B} with only Study Hall + Lotus Petal should sacrifice the Petal directly, not route it through Study Hall. + @Test + public void studyHallDoesNotRouteDisposableThroughFilter() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Duress", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertTrue(canAutoPay(game, p, cost("B"), sa)); + + CardCollection sources = predictedManaSources(game, p, cost("B"), sa); + AssertJUnit.assertTrue("Lotus Petal should pay {B} directly", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertFalse("Study Hall should not be used when only a Petal can activate it", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + } + + // {W}{G} with Forest + Study Hall + Lotus Petal: Forest pays {G}, Petal pays {W}. Study Hall must not + // burn Forest on its {1} activation when the disposable can cover the other colored pip. + @Test + public void forestAndPetalBeatStudyHallForPureMulticolor() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Snow-Covered Forest", p); + addCard("Study Hall", p); + addCard("Lotus Petal", p); + Card spell = addCardToZone("Arcus Acolyte", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + ManaCostBeingPaid mc = cost("W G"); + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + + CardCollection sources = predictedManaSources(game, p, mc, sa); + AssertJUnit.assertTrue("Forest should pay {G}", + sources.anyMatch(c -> "Snow-Covered Forest".equals(c.getName()))); + AssertJUnit.assertTrue("Lotus Petal should pay {W}", sources.anyMatch(c -> "Lotus Petal".equals(c.getName()))); + AssertJUnit.assertFalse("Study Hall should not burn Forest for {W}", + sources.anyMatch(c -> "Study Hall".equals(c.getName()))); + + AssertJUnit.assertTrue("Production auto-pay should match feasibility", prodAutoPay(game, p, mc, sa)); + } + + // Only Sol Ring + Boros Signet (3 mana total): Ring {C}{C} pays Signet's {1} and leaves {C} in the pool + // for the spell's generic; Signet pays {R}{W}. Casts a real 3 CMC spell ({1}{R}{W}). + @Test + public void nestedManaSurplusPaysOuterGeneric() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("Sol Ring", p); + addCard("Boros Signet", p); + Card spell = addCardToZone("Goblin Trenches", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertEquals(3, spell.getManaCost().getCMC()); + ManaCostBeingPaid mc = new ManaCostBeingPaid(spell.getManaCost()); + + AssertJUnit.assertTrue(canAutoPay(game, p, mc, sa)); + AssertJUnit.assertTrue("Only Ring + Signet should pay a 3 CMC spell via surplus {C}", + prodAutoPay(game, p, mc, sa)); + } + + // TODO: Move the CantCast tests to another test suite. + // Powerstone mana ({C}{C}, can't cast nonartifact spells) may pay artifact spells. + @Test + public void powerstoneManaCanPayArtifactSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("The Mightstone and Weakstone", p); + Card spell = addCardToZone("Arcane Signet", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("2"), sa); + AssertJUnit.assertEquals(1, countTapped(game, "The Mightstone and Weakstone")); + } + + // Powerstone mana must not pay nonartifact spells. + @Test + public void powerstoneManaCannotPayNonArtifactSpell() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCard("The Mightstone and Weakstone", p); + Card spell = addCardToZone("Lightning Bolt", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + AssertJUnit.assertFalse(canAutoPay(game, p, cost("R"), sa)); + } + + // Many duplicate basics: payment planning must still succeed on large boards. + @Test + public void manyDuplicateBasicsPayMulticolor() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCards("Plains", 7, p); + addCards("Island", 7, p); + addCards("Swamp", 6, p); + Card spell = addCardToZone("Dromar's Charm", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + assertProductionPayment(game, p, cost("W U B"), sa); + } + + // Many identical signets: consolidation and nested activation must still work. + @Test + public void manyDuplicateSignetsPayMulticolor() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + addCards("Boros Signet", 10, p); + addCard("Mountain", p); + addCard("Plains", p); + Card spell = addCardToZone("Lightning Helix", p, ZoneType.Hand); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + SpellAbility sa = spell.getFirstSpellAbility(); + CardCollection sources = predictedManaSources(game, p, cost("R W"), sa); + AssertJUnit.assertTrue("A signet should pay", sources.anyMatch(c -> "Boros Signet".equals(c.getName()))); + AssertJUnit.assertTrue("At least one land should be tapped for signet activation", + sources.stream().filter(c -> c.getName().contains("Plains") || c.getName().contains("Mountain")).count() >= 1); + + assertProductionPayment(game, p, cost("R W"), sa); + AssertJUnit.assertTrue("At least one land should be tapped for signet activation", + countTapped(game, "Mountain") + countTapped(game, "Plains") >= 1); + } + + // Genju animate: with duplicate Forests, do not tap the enchanted Forest for {2}. + @Test + public void genjuAnimateAvoidsEnchantedForestWhenDuplicateExists() { + Game game = initAndCreateGame(); + Player p = game.getPlayers().get(1); + + List forests = addCards("Forest", 20, p); + Card enchantedForest = forests.get(0); + Card genju = addCard("Genju of the Cedars", p); + genju.attachToEntity(enchantedForest, null); + + SpellAbility animate = null; + for (SpellAbility ab : genju.getSpellAbilities()) { + if (ab.isActivatedAbility() && ab.getPayCosts() != null && ab.getPayCosts().hasManaCost()) { + animate = ab; + break; + } + } + AssertJUnit.assertNotNull(animate); + + game.getPhaseHandler().devModeSet(PhaseType.MAIN1, p); + game.getAction().checkStateEffects(true); + + assertProductionPayment(game, p, cost("2"), animate); + AssertJUnit.assertFalse("Enchanted Forest should not be tapped for {2}", + enchantedForest.isTapped()); + AssertJUnit.assertTrue("Another Forest should be tapped", countTapped(game, "Forest") >= 1); + } } diff --git a/forge-gui-mobile/src/forge/adventure/scene/SettingsScene.java b/forge-gui-mobile/src/forge/adventure/scene/SettingsScene.java index ed3e7f5b9d1d..36eb541839e0 100644 --- a/forge-gui-mobile/src/forge/adventure/scene/SettingsScene.java +++ b/forge-gui-mobile/src/forge/adventure/scene/SettingsScene.java @@ -11,6 +11,7 @@ import forge.adventure.data.RewardData; import forge.adventure.util.Config; import forge.adventure.util.Controls; +import forge.ai.CastabilityProbe; import forge.assets.ImageCache; import forge.gui.GuiBase; import forge.localinstance.properties.ForgeConstants; @@ -307,6 +308,9 @@ public void changed(ChangeEvent event, Actor actor) { addSettingSlider(Forge.getLocalizer().getMessage("cbAdjustMusicVolume"), ForgePreferences.FPref.UI_VOL_MUSIC, 0, 100); addSettingSlider(Forge.getLocalizer().getMessage("cbAdjustSoundsVolume"), ForgePreferences.FPref.UI_VOL_SOUNDS, 0, 100); addCheckBox(Forge.getLocalizer().getMessage("cbShowAutoTapPreview"), ForgePreferences.FPref.UI_SHOW_AUTOTAP_PREVIEW); + addCheckBox(Forge.getLocalizer().getMessage("cbManaCastabilityProbe"), ForgePreferences.FPref.MANA_PAYMENT_CASTABILITY_PROBE, + () -> CastabilityProbe.setDefaultEnabled( + FModel.getPreferences().getPrefBoolean(ForgePreferences.FPref.MANA_PAYMENT_CASTABILITY_PROBE))); addCheckBox(Forge.getLocalizer().getMessage("lblManaCost"), ForgePreferences.FPref.UI_OVERLAY_CARD_MANA_COST); addCheckBox(Forge.getLocalizer().getMessage("lblPerpetualManaCost"), ForgePreferences.FPref.UI_OVERLAY_CARD_PERPETUAL_MANA_COST); addCheckBox(Forge.getLocalizer().getMessage("lblPowerOrToughness"), ForgePreferences.FPref.UI_OVERLAY_CARD_POWER); diff --git a/forge-gui/res/cardsfolder/b/blast_zone.txt b/forge-gui/res/cardsfolder/b/blast_zone.txt index d5f7d0013d4a..8e4d0bf6f51e 100644 --- a/forge-gui/res/cardsfolder/b/blast_zone.txt +++ b/forge-gui/res/cardsfolder/b/blast_zone.txt @@ -8,5 +8,6 @@ SVar:X:Count$xPaid A:AB$ DestroyAll | Cost$ 3 T Sac<1/CARDNAME> | ValidCards$ Permanent.nonLand+cmcEQY | SpellDescription$ Destroy each nonland permanent with mana value equal to the number of charge counters on CARDNAME. SVar:Y:Count$CardCounters.CHARGE AI:RemoveDeck:All +SVar:AIManaReserve:True DeckHas:Ability$Counters Oracle:Blast Zone enters with a charge counter on it.\n{T}: Add {C}.\n{X}{X}, {T}: Put X charge counters on Blast Zone.\n{3}, {T}, Sacrifice Blast Zone: Destroy each nonland permanent with mana value equal to the number of charge counters on Blast Zone. diff --git a/forge-gui/res/cardsfolder/c/celestial_colonnade.txt b/forge-gui/res/cardsfolder/c/celestial_colonnade.txt index 18f9bcef4236..812cdf26bab7 100644 --- a/forge-gui/res/cardsfolder/c/celestial_colonnade.txt +++ b/forge-gui/res/cardsfolder/c/celestial_colonnade.txt @@ -5,4 +5,5 @@ R:Event$ Moved | ValidCard$ Card.Self | Destination$ Battlefield | ReplacementRe SVar:ETBTapped:DB$ Tap | Defined$ Self | ETB$ True A:AB$ Mana | Cost$ T | Produced$ Combo W U | SpellDescription$ Add {W} or {U}. A:AB$ Animate | Cost$ 3 W U | Defined$ Self | Power$ 4 | Toughness$ 4 | Types$ Creature,Elemental | Colors$ White,Blue | OverwriteColors$ True | Keywords$ Flying & Vigilance | SpellDescription$ Until end of turn, this land becomes a 4/4 white and blue Elemental creature with flying and vigilance. It's still a land. +SVar:AIManaReserve:True Oracle:This land enters tapped.\n{T}: Add {W} or {U}.\n{3}{W}{U}: Until end of turn, this land becomes a 4/4 white and blue Elemental creature with flying and vigilance. It's still a land. diff --git a/forge-gui/res/cardsfolder/c/creeping_tar_pit.txt b/forge-gui/res/cardsfolder/c/creeping_tar_pit.txt index fe188bee9033..b088f7fc44ae 100644 --- a/forge-gui/res/cardsfolder/c/creeping_tar_pit.txt +++ b/forge-gui/res/cardsfolder/c/creeping_tar_pit.txt @@ -8,4 +8,5 @@ A:AB$ Animate | Cost$ 1 U B | Defined$ Self | Power$ 3 | Toughness$ 2 | Types$ C SVar:DBUnblockable:DB$ Effect | ExileOnMoved$ Battlefield | RememberObjects$ Self | StaticAbilities$ Unblockable SVar:Unblockable:Mode$ CantBlockBy | ValidAttacker$ Card.IsRemembered | Description$ EFFECTSOURCE can't be blocked this turn. DeckHas:Type$Elemental +SVar:AIManaReserve:True Oracle:This land enters tapped.\n{T}: Add {U} or {B}.\n{1}{U}{B}: Until end of turn, this land becomes a 3/2 blue and black Elemental creature. It's still a land. It can't be blocked this turn. diff --git a/forge-gui/res/cardsfolder/i/inkmoth_nexus.txt b/forge-gui/res/cardsfolder/i/inkmoth_nexus.txt index 924bf8102f2c..0a1ea038b366 100644 --- a/forge-gui/res/cardsfolder/i/inkmoth_nexus.txt +++ b/forge-gui/res/cardsfolder/i/inkmoth_nexus.txt @@ -3,4 +3,5 @@ ManaCost:no cost Types:Land A:AB$ Mana | Cost$ T | Produced$ C | SpellDescription$ Add {C}. A:AB$ Animate | Cost$ 1 | Defined$ Self | Power$ 1 | Toughness$ 1 | Types$ Creature,Artifact,Phyrexian,Blinkmoth | RemoveCreatureTypes$ True | Keywords$ Flying & Infect | SpellDescription$ CARDNAME becomes a 1/1 Phyrexian Blinkmoth artifact creature with flying and infect until end of turn. It's still a land. (It deals damage to creatures in the form of -1/-1 counters and to players in the form of poison counters.) +SVar:AIManaReserve:True Oracle:{T}: Add {C}.\n{1}: Inkmoth Nexus becomes a 1/1 Phyrexian Blinkmoth artifact creature with flying and infect until end of turn. It's still a land. (It deals damage to creatures in the form of -1/-1 counters and to players in the form of poison counters.) diff --git a/forge-gui/res/cardsfolder/k/karakas.txt b/forge-gui/res/cardsfolder/k/karakas.txt index cacd42529270..5be7f0762008 100644 --- a/forge-gui/res/cardsfolder/k/karakas.txt +++ b/forge-gui/res/cardsfolder/k/karakas.txt @@ -4,4 +4,5 @@ Types:Legendary Land A:AB$ Mana | Cost$ T | Produced$ W | SpellDescription$ Add {W}. A:AB$ ChangeZone | Cost$ T | ValidTgts$ Creature.Legendary | TgtPrompt$ Select target legendary creature | Origin$ Battlefield | Destination$ Hand | SpellDescription$ Return target legendary creature to its owner's hand. DeckHints:Type$Legendary +SVar:AIManaReserve:True Oracle:{T}: Add {W}.\n{T}: Return target legendary creature to its owner's hand. diff --git a/forge-gui/res/cardsfolder/l/library_of_alexandria.txt b/forge-gui/res/cardsfolder/l/library_of_alexandria.txt index f0d979d559f3..07937a86d96c 100644 --- a/forge-gui/res/cardsfolder/l/library_of_alexandria.txt +++ b/forge-gui/res/cardsfolder/l/library_of_alexandria.txt @@ -3,4 +3,5 @@ ManaCost:no cost Types:Land A:AB$ Mana | Cost$ T | Produced$ C | SpellDescription$ Add {C}. A:AB$ Draw | Cost$ T | PresentZone$ Hand | IsPresent$ Card.YouOwn | PresentCompare$ EQ7 | SpellDescription$ Draw a card. Activate only if you have exactly seven cards in hand. +SVar:AIManaReserve:True Oracle:{T}: Add {C}.\n{T}: Draw a card. Activate only if you have exactly seven cards in hand. diff --git a/forge-gui/res/cardsfolder/s/shifting_woodland.txt b/forge-gui/res/cardsfolder/s/shifting_woodland.txt index a1b31ef74665..2f55c50abda4 100644 --- a/forge-gui/res/cardsfolder/s/shifting_woodland.txt +++ b/forge-gui/res/cardsfolder/s/shifting_woodland.txt @@ -5,4 +5,5 @@ R:Event$ Moved | ValidCard$ Card.Self | Destination$ Battlefield | ReplaceWith$ SVar:LandTapped:DB$ Tap | Defined$ Self | ETB$ True | ConditionPresent$ Forest.YouCtrl | ConditionCompare$ EQ0 A:AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}. A:AB$ Clone | Cost$ 2 G G | TgtZone$ Graveyard | TgtPrompt$ Select target permanent card in your graveyard | ValidTgts$ Permanent.YouOwn | Duration$ UntilEndOfTurn | Activation$ Delirium | PrecostDesc$ Delirium — | SpellDescription$ CARDNAME becomes a copy of target permanent card in your graveyard until end of turn. Activate only if there are four or more card types among cards in your graveyard. +SVar:AIManaReserve:True Oracle:Shifting Woodland enters tapped unless you control a Forest.\n{T}: Add {G}.\nDelirium — {2}{G}{G}: Shifting Woodland becomes a copy of target permanent card in your graveyard until end of turn. Activate only if there are four or more card types among cards in your graveyard. diff --git a/forge-gui/res/languages/en-US.properties b/forge-gui/res/languages/en-US.properties index da48d102b293..dfc1ce9f4a64 100644 --- a/forge-gui/res/languages/en-US.properties +++ b/forge-gui/res/languages/en-US.properties @@ -110,6 +110,8 @@ cbScaleLarger=Scale Image Larger cbRenderBlackCardBorders=Render Black Card Borders cbShowActionableHighlights=Highlight Actionable Cards cbShowAutoTapPreview=Highlight Auto-tap Cards +cbManaCastabilityProbe=Castability-aware Auto-pay +nlManaCastabilityProbe=When paying mana, consider which hand spells stay castable. Disable for faster Auto-pay on slow devices. cbShowLinkedExileCards=Show Linked Exile Cards lblActionableHighlightColor=Highlight Color (hex) cbLargeCardViewers=Use Large Card Viewers diff --git a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java index 7350155fab62..1834815455b4 100644 --- a/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java +++ b/forge-gui/src/main/java/forge/gamemodes/match/input/InputPayMana.java @@ -377,7 +377,7 @@ protected void onOk() { if (supportAutoPay() && !locked) { //prevent AI taking over from double-clicking Auto locked = true; //use AI utility to automatically pay mana cost if possible - final Runnable proc = () -> ComputerUtilMana.payManaCost(manaCost, saPaidFor, player, effect); + final Runnable proc = () -> ComputerUtilMana.payManaCostFromPaymentPrompt(manaCost, saPaidFor, player, effect); //must run in game thread as certain payment actions can only be automated there game.getAction().invoke(() -> { runAsAi(proc); @@ -443,7 +443,7 @@ private void ensureAutoPayManaSources() { Evaluator proc = new Evaluator<>() { @Override public CardCollection evaluate() { - return ComputerUtilMana.getManaSourcesToPayCost(costCopy, saPaidFor, player, effect); + return ComputerUtilMana.getManaSourcesToPayCostForPaymentPrompt(costCopy, saPaidFor, player, effect); } }; runAsAi(proc); diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java index 538865f7772b..457b8d15a683 100644 --- a/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java +++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgePreferences.java @@ -203,6 +203,8 @@ public enum FPref implements PreferencesStore.IPref { ENFORCE_DECK_LEGALITY ("true"), PERFORMANCE_MODE ("false"), + /** When false, mana Auto-pay skips hand/command castability dry-runs (faster on low-end devices). */ + MANA_PAYMENT_CASTABILITY_PROBE ("true"), FILTERED_HANDS ("false"), MULLIGAN_RULE(MulliganDefs.getDefaultRule().name()), diff --git a/forge-gui/src/main/java/forge/model/FModel.java b/forge-gui/src/main/java/forge/model/FModel.java index 922d78e12047..9bbcd21e9ebf 100644 --- a/forge-gui/src/main/java/forge/model/FModel.java +++ b/forge-gui/src/main/java/forge/model/FModel.java @@ -23,6 +23,7 @@ import forge.*; import forge.CardStorageReader.ProgressObserver; import forge.ai.AiProfileUtil; +import forge.ai.CastabilityProbe; import forge.card.CardRulesPredicates; import forge.card.CardType; import forge.deck.CardArchetypeLDAGenerator; @@ -243,6 +244,7 @@ public void report(final int current, final int total) { } Spell.setPerformanceMode(getPreferences().getPrefBoolean(FPref.PERFORMANCE_MODE)); + CastabilityProbe.setDefaultEnabled(getPreferences().getPrefBoolean(FPref.MANA_PAYMENT_CASTABILITY_PROBE)); if (progressBar != null) { FThreads.invokeInEdtLater(() -> progressBar.setDescription(Localizer.getInstance().getMessage("splash.loading.decks")));