Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions forge-ai/src/main/java/forge/ai/AiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,10 @@ private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) {
return AiPlayDecision.AnotherTime;
}

if (ComputerUtilCard.shouldAvoidDrawPunisher(player, sa)) {
return AiPlayDecision.CurseEffects;
}

// this is the "heaviest" check, which also sets up targets, defines X, etc.
AiPlayDecision canPlay = canPlaySa(sa);

Expand Down Expand Up @@ -1274,6 +1278,10 @@ public boolean getBoolProperty(AiProps propName) {
}

public AiPlayDecision canPlayFromEffectAI(Spell spell, boolean mandatory, boolean withoutPayingManaCost) {
if (!mandatory && ComputerUtilCard.shouldAvoidDrawPunisher(player, spell)) {
return AiPlayDecision.CurseEffects;
}

if (spell instanceof SpellApiBased) {
boolean chance;
if (withoutPayingManaCost) {
Expand Down
201 changes: 201 additions & 0 deletions forge-ai/src/main/java/forge/ai/ComputerUtilCard.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
import forge.util.TextUtil;

public class ComputerUtilCard {
private static final int DANGEROUS_DRAW_COUNT = 3;

public static Card getMostExpensivePermanentAI(final CardCollectionView list, final SpellAbility spell, final boolean targeted) {
CardCollectionView all = list;
if (targeted) {
Expand Down Expand Up @@ -892,6 +894,205 @@ public static boolean canBeBlockedProfitably(final Player ai, Card attacker, boo
return ComputerUtilCombat.attackerWouldBeDestroyed(ai, attacker, combat);
}

public static boolean shouldAvoidDrawPunisher(final Player ai, final SpellAbility sa) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmn, I'll have to think on this:
we usually don't attempt guessing this sort of two level deep
(e.g. we have helpers for direct damage from etb/casting but this is more "I might draw from it" + "then I might lose life from drawing")

I'm not convinced that while at first this might look good for a few specific cases it doesn't end up causing just as much problems at other times, especially when it's hooked up this high in the chain 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. This heuristic is intentionally trying to catch cases that are one step beyond direct “this spell hurts me”: “this play causes me to draw a lot” plus “an opponent has an active draw punisher.” That is why it catches things like Wheel effects into Nekusar/Xyris, but also why it is riskier than the usual direct ETB/cast damage checks.

I think there are a few possible directions:

  1. Keep the current high-level hook. This catches the broadest set of cases, including future draw-engine permanents like Mindmoil, Teferi’s Puzzle Box, and Arjun. The downside is exactly what you pointed out: false positives are expensive because this blocks the play before normal AI evaluation can consider context.
  2. Move the heuristic into narrower ability logic, e.g. ChangeZoneAllAi / wheel-style effects only. This is much safer and easier to reason about, but it would no longer catch permanents like Mindmoil/Puzzle Box/Arjun being cast into Xyris/Nekusar/Kederekt.
  3. Split the heuristic into narrower cases: immediate wheel-style draw effects in the relevant ability AI, plus a separate permanent-spell check only when the card being cast has its own large self-draw trigger. That would still catch Mindmoil/Puzzle Box/Arjun, but avoid using this as a broad “before any spell” gate.

Happy to go with any of these options.

if (ai == null || sa == null || sa.isLandAbility()) {
return false;
}

final Card host = sa.getHostCard();
if (host == null || isOwnCommanderFromCommandZone(ai, host)) {
return false;
}

final int estimatedDraws = estimateSelfDrawsFromPlay(ai, sa);
if (estimatedDraws < DANGEROUS_DRAW_COUNT) {
return false;
}

for (final Player opponent : ai.getOpponents()) {
if (opponentHasDrawPunisher(ai, opponent, estimatedDraws)) {
return true;
}
}
return false;
}

private static int estimateSelfDrawsFromPlay(final Player ai, final SpellAbility sa) {
final int handSize = ai.getCardsIn(ZoneType.Hand).size();
int draws = estimateSelfDrawsFromAbility(ai, sa, handSize);

if (sa.isSpell()) {
for (final Card card : ai.getCardsIn(ZoneType.Battlefield)) {
draws = Math.max(draws, estimateSelfDrawsFromTriggers(ai, card, handSize, false));
}
}

final Card host = sa.getHostCard();
if (host != null) {
draws = Math.max(draws, estimateSelfDrawsFromTriggers(ai, host, handSize, true));
}
return draws;
}

private static int estimateSelfDrawsFromTriggers(final Player ai, final Card card,
final int fallbackDraws, final boolean includeDrawStepTriggers) {
int draws = 0;
for (final Trigger trigger : card.getTriggers()) {
if (trigger.getMode() == TriggerType.SpellCast && triggerMatchesControllerSpellCast(trigger)) {
draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), fallbackDraws));
} else if (includeDrawStepTriggers && trigger.getMode() == TriggerType.Phase
&& "Draw".equals(trigger.getParam("Phase"))
&& triggerMayAffectPlayer(trigger, ai)) {
draws = Math.max(draws, estimateSelfDrawsFromAbility(ai, trigger.ensureAbility(), fallbackDraws));
}
}
return draws;
}

private static boolean triggerMatchesControllerSpellCast(final Trigger trigger) {
final String activator = trigger.getParamOrDefault("ValidActivatingPlayer", "You");
return activator.contains("You") && !activator.contains("Opponent");
}

private static boolean triggerMayAffectPlayer(final Trigger trigger, final Player player) {
final String validPlayer = trigger.getParamOrDefault("ValidPlayer", "Player");
if (validPlayer.contains("Opponent")) {
return trigger.getHostCard().getController().isOpponentOf(player);
}
if (validPlayer.contains("You")) {
return trigger.getHostCard().getController().equals(player);
}
return validPlayer.contains("Player");
}

private static int estimateSelfDrawsFromAbility(final Player ai, final SpellAbility sa, final int fallbackDraws) {
int draws = 0;
boolean movedHand = false;
for (SpellAbility cur = sa; cur != null; cur = cur.getSubAbility()) {
if (cur.getApi() == ApiType.ChangeZoneAll && zoneParamIncludes(cur, "Origin", ZoneType.Hand)
&& (zoneParamIncludes(cur, "Destination", ZoneType.Library)
|| zoneParamIncludes(cur, "Destination", ZoneType.Graveyard)
|| zoneParamIncludes(cur, "Destination", ZoneType.Exile))) {
movedHand = true;
}

if (cur.getApi() == ApiType.Draw && abilityDrawsForPlayer(cur)) {
draws += estimateDrawAmount(ai, cur, movedHand ? fallbackDraws : 1);
}
}
return draws;
}

private static boolean zoneParamIncludes(final SpellAbility sa, final String param, final ZoneType zone) {
return sa.hasParam(param) && ZoneType.listValueOf(sa.getParam(param)).contains(zone);
}

private static boolean abilityDrawsForPlayer(final SpellAbility sa) {
final String defined = sa.getParamOrDefault("Defined", "You");
if (defined.contains("Player") || defined.contains("TriggeredPlayer") || defined.contains("TargetedAndYou")) {
return true;
}
if (defined.contains("Opponent")) {
return false;
}
return defined.contains("You") || defined.contains("Controller");
}

private static int estimateDrawAmount(final Player ai, final SpellAbility sa, final int fallbackDraws) {
final String numCards = sa.getParamOrDefault("NumCards", "1");
try {
return Integer.parseInt(numCards);
} catch (NumberFormatException ignored) {
if ("X".equals(numCards) && sa.getXManaCostPaid() != null) {
return sa.getXManaCostPaid();
}
if (numCards.startsWith("Rem") || numCards.contains("Remembered") || "X".equals(numCards)) {
return Math.max(1, fallbackDraws);
}
if (numCards.startsWith("Count$ValidHand")) {
return Math.max(1, ai.getCardsIn(ZoneType.Hand).size());
}
return 1;
}
}

private static boolean isOwnCommanderFromCommandZone(final Player ai, final Card host) {
return host.isCommander() && host.isInZone(ZoneType.Command)
&& (host.getOwner().equals(ai) || ai.getCommanders().contains(host));
}

private static boolean opponentHasDrawPunisher(final Player ai, final Player opponent, final int estimatedDraws) {
for (final Card card : opponent.getCardsIn(ZoneType.Battlefield)) {
if (cardHasDrawPunisher(ai, card, false, estimatedDraws)) {
return true;
}
}
for (final Card card : opponent.getCardsIn(ZoneType.Command)) {
if (cardHasDrawPunisher(ai, card, true, estimatedDraws)) {
return true;
}
}
return false;
}

private static boolean cardHasDrawPunisher(final Player ai, final Card card, final boolean commandZone, final int estimatedDraws) {
for (final Trigger trigger : card.getTriggers()) {
if (trigger.getMode() != TriggerType.Drawn || !drawTriggerCanAffectPlayer(trigger, ai)) {
continue;
}
if (commandZone && (trigger.getActiveZone() == null || !trigger.getActiveZone().contains(ZoneType.Command))) {
continue;
}
if (!commandZone && !trigger.requirementsCheck(card.getGame())) {
continue;
}
if (drawPunisherImpact(trigger.ensureAbility(), estimatedDraws) >= estimatedDraws) {
return true;
}
}
return false;
}

private static boolean drawTriggerCanAffectPlayer(final Trigger trigger, final Player ai) {
if (trigger.hasParam("ValidPlayer")) {
return triggerMayAffectPlayer(trigger, ai);
}

final String validCard = trigger.getParamOrDefault("ValidCard", "Card");
if (validCard.contains("Opp")) {
return trigger.getHostCard().getController().isOpponentOf(ai);
}
if (validCard.contains("You")) {
return trigger.getHostCard().getController().equals(ai);
}
return validCard.contains("Card");
}

private static int drawPunisherImpact(final SpellAbility sa, final int estimatedDraws) {
int impact = 0;
for (SpellAbility cur = sa; cur != null; cur = cur.getSubAbility()) {
if (cur.getApi() == ApiType.DealDamage) {
impact += estimateNumericParam(cur, "NumDmg", 1) * estimatedDraws;
} else if (cur.getApi() == ApiType.LoseLife) {
impact += estimateNumericParam(cur, "LifeAmount", 1) * estimatedDraws;
} else if (cur.getApi() == ApiType.Token) {
impact += estimatedDraws;
}
}
return impact;
}

private static int estimateNumericParam(final SpellAbility sa, final String param, final int fallback) {
if (!sa.hasParam(param)) {
return fallback;
}
try {
return Integer.parseInt(sa.getParam(param));
} catch (NumberFormatException ignored) {
return fallback;
}
}

public static boolean canBeKilledByRoyalAssassin(final Player ai, final Card card) {
boolean wasTapped = card.isTapped();
for (Player opp : ai.getOpponents()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ protected AiAbilityDecision canPlay(Player ai, SpellAbility sa) {

if ("LivingDeath".equals(aiLogic)) {
return SpecialCardAi.LivingDeath.consider(ai, sa);
} else if ("Timetwister".equals(aiLogic)) {
} else if ("Timetwister".equalsIgnoreCase(aiLogic)) {
return SpecialCardAi.Timetwister.consider(ai, sa);
} else if ("RetDiscardedThisTurn".equals(aiLogic)) {
boolean result = !ai.getDiscardedThisTurn().isEmpty() && ai.getGame().getPhaseHandler().is(PhaseType.END_OF_TURN);
Expand Down
Loading