Skip to content
Draft
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
66 changes: 66 additions & 0 deletions forge-ai/src/main/java/forge/ai/AiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ public class AiController {
private boolean useLivingEnd;
private List<SpellAbility> skipped;
private volatile boolean timeoutReached;
private SpellAbility expectedPayingColorsSa;
private byte expectedPayingColors;
private int expectedConvergeX = -1;
private boolean solvingPayingColors;

public AiController(final Player computerPlayer, final Game game0) {
player = computerPlayer;
Expand Down Expand Up @@ -843,6 +847,64 @@ private AiPlayDecision canPlayAndPayFor(final SpellAbility sa) {
return decision;
}

/**
* Remember what a spell we have not paid for yet would be paid with. Answered back through
* PlayerController, so the prediction never has to be parked on the spell itself.
*/
public void setExpectedPayingColors(final SpellAbility sa, final byte colors) {
expectedPayingColorsSa = sa;
expectedPayingColors = colors;
expectedConvergeX = -1;
}

/**
* Converge counts the colors actually spent, which nothing has yet - so Count$Converge would
* report zero and the AI would size every converge effect as if it were empty. Worked out on
* the first read rather than up front, so a spell rejected by a cheap check never pays for a
* payment solve, and remembered by spell identity because it is only good for that one spell.
*/
public byte getExpectedPayingColors(final SpellAbility sa) {
if (sa == expectedPayingColorsSa) {
return expectedPayingColors;
}
final Card host = sa.getHostCard();
if (solvingPayingColors || host == null || !host.hasConverge()
|| !sa.getPayingMana().isEmpty()) {
return 0;
}
if (sa.costHasManaX()) {
// on these cards X is what buys the colors, so they are settled by the announcement
return 0;
}
solvingPayingColors = true;
try {
setExpectedPayingColors(sa, ComputerUtilMana.getConvergeColors(sa, player));
} finally {
solvingPayingColors = false;
}
return expectedPayingColors;
}

/**
* Same, for a spell whose X is what buys the colors, so the announcement is remembered with
* them - searching for it walks every X up to the affordable one and solves the payment at each.
*/
public void rememberConvergeX(final SpellAbility sa, final int x, final byte colors) {
setExpectedPayingColors(sa, colors);
expectedConvergeX = x;
}

/**
* Put back the X a previous search on this same spell settled on, if there was one.
*/
public boolean reapplyConvergeX(final SpellAbility sa) {
if (sa != expectedPayingColorsSa || expectedConvergeX < 0) {
return false;
}
sa.setXManaCostPaid(expectedConvergeX);
return true;
}

// This is for playing spells regularly (no Cascade/Ripple etc.)
private AiPlayDecision canPlayAndPayForFace(final SpellAbility sa) {
final Card host = sa.getHostCard();
Expand Down Expand Up @@ -1365,6 +1427,10 @@ public List<SpellAbility> chooseSpellAbilityToPlay() {
// Reset priority mana reservation that's meant to work for one spell only
memory.clearMemorySet(AiCardMemory.MemorySet.HELD_MANA_SOURCES_FOR_NEXT_SPELL);

// Same for what a spell was expected to be paid with, which is only good while we are
// deciding on that one spell
setExpectedPayingColors(null, (byte) 0);

if (usesFullSimulation()) {
return singleSpellAbilityList(simPicker.chooseSpellAbilityToPlay(null));
}
Expand Down
6 changes: 6 additions & 0 deletions forge-ai/src/main/java/forge/ai/ComputerUtilCost.java
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,12 @@ public static int setMaxXValue(SpellAbility sa, Player ai, final boolean effect)

int x = ObjectUtils.defaultIfNull(val, 0);
sa.setXManaCostPaid(x);
if (sa.isSpell() && sa.getHostCard() != null && sa.getHostCard().hasConverge()) {
// on a converge or sunburst card X only buys colors, so the useful announcement is the
// least X that reaches the most of them rather than all the mana the AI can find. The
// return value still reports what it could afford.
ComputerUtilMana.setXForBestConverge(sa, ai, x);
}
return x;
}

Expand Down
46 changes: 43 additions & 3 deletions forge-ai/src/main/java/forge/ai/ComputerUtilMana.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,56 @@ private static boolean payManaCost(final Cost cost, final SpellAbility sa, final
}

/**
* Return the number of colors used for payment for Converge
* Return the colors that would be used for payment, as a color mask.
*/
public static int getConvergeCount(final SpellAbility sa, final Player ai) {
public static byte getConvergeColors(final SpellAbility sa, final Player ai) {

@tool4ever tool4ever Aug 2, 2026

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.

don't mind a more generic solution but then you need to replace the specific variant above
otherwise we end up with duplicated logic that also wastes runtime
though the ones with X will probably make things tricky 🤔

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.

Agreed, and that's done as of the push after your review — getConvergeCount is now just ColorSet.fromMask(getConvergeColors(sa, ai)).countColors(), so there's one payment simulation rather than two. Same result either way: getSunburst() was already ColorSet.fromMask(sunburstMap).countColors() and getColorsPaid() returns that same mask.

The X ones are in that push too — X gets announced before the colours are measured, since on those cards X is what buys them.

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.

well the code duplication is gone but the method is still used and calculates again for no reason?

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.

You're right, and it goes further than that — the method can go entirely.

All four callers had the same shape: work out the amount with calculateAmount, then override it for Count$Converge. That override exists because calculateAmount returned zero before anything had been paid, which is the problem this branch set out to fix. With the prediction in place the engine's own expression gives the right number, so the override was running a full payment simulation to reach a value the line above it already held.

I checked rather than assumed, since it would be easy to see the right number coming from resolution instead of evaluation. Instrumenting the Count$Converge branch in AbilityUtils and tagging each call with its caller: 21 of them arrive via ChangeZoneAi.hiddenOriginCanPlayAI — the AI deciding whether to cast — with castSA set and the count correct.

PermanentAi's converge loop was already replaced by setXForBestConverge earlier on this branch, so removing these four leaves getConvergeCount with no callers and it goes too. That commit is +1/-20.

Verified on the paths I could reach. Painful Truths on the DrawAi path and Unified Front on TokenAi both get cast and sized correctly with the override gone — three cards and three life, four tokens. I added the first as a regression test. DamageAllAi I could not exercise: its checkApiLogic is never reached for Radiant Flames, the only DamageAll converge card in the pool. It is the same one-line pattern as the other two so I would not expect it to differ, but it is untested here rather than verified.

339 tests, 0 failures.

ManaCostBeingPaid cost = calculateManaCost(sa.getPayCosts(), sa, ai, true, 0, false);
if (payManaCost(cost, sa, ai, true, true, false) != null) {
return cost.getSunburst();
return cost.getColorsPaid();
}
return 0;
}

/**
* Announce X on a converge or sunburst card, where its only job is to buy colors: the least X
* that still reaches the most of them. Returns the colors the announced X buys.
*/
public static byte setXForBestConverge(final SpellAbility sa, final Player ai, final int maxX) {
final AiController aic = aiControllerOf(ai);
if (aic != null && aic.reapplyConvergeX(sa)) {
// the same spell is searched again on the way down to its API logic, and each step of
// the walk is a full payment solve - the answer from the first walk still holds
return aic.getExpectedPayingColors(sa);
}

int bestX = 0;
int bestCount = 0;
byte bestColors = 0;
for (int i = 0; i <= maxX; i++) {
sa.setXManaCostPaid(i);
byte colors = getConvergeColors(sa, ai);
int count = ColorSet.fromMask(colors).countColors();
if (count > bestCount) {
bestCount = count;
bestColors = colors;
bestX = i;
if (bestCount == MagicColor.WUBRG.length) {
break; // nothing above this can buy a sixth color
}
}
}
sa.setXManaCostPaid(bestX);
if (aic != null) {
aic.rememberConvergeX(sa, bestX, bestColors);
}
return bestColors;
}

private static AiController aiControllerOf(final Player ai) {
return ai != null && ai.getController() instanceof PlayerControllerAi controller
? controller.getAi() : null;
}

// Does not check if mana sources can be used right now, just checks for potential chance.
public static boolean hasEnoughManaSourcesToCast(final SpellAbility sa, final Player ai) {
if (ai == null || sa == null)
Expand Down
5 changes: 5 additions & 0 deletions forge-ai/src/main/java/forge/ai/PlayerControllerAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ public AiController getAi() {
return brains;
}

@Override
public byte getExpectedPayingColors(SpellAbility sa) {
return brains.getExpectedPayingColors(sa);
}

@Override
public boolean isAI() {
return true;
Expand Down
4 changes: 4 additions & 0 deletions forge-ai/src/main/java/forge/ai/ability/ChangeZoneAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,10 @@ public static Card chooseCardToHiddenOriginChangeZone(ZoneType destination, List
// Exiling or bouncing stuff
if (player.isOpponentOf(decider)) {
c = ComputerUtilCard.getBestAI(fetchList);
} else if (origin.contains(ZoneType.Library)) {
// searching your own library is a tutor - exile is where the card waits to be
// played, not somewhere to dump the worst thing you own
c = ComputerUtilCard.getBestAI(fetchList);
} else {
if (!sa.hasParam("Mandatory") && origin.contains(ZoneType.Battlefield) && sa.hasParam("ChangeNum")) {
// exclude tokens, they won't come back, and enchanted stuff, since auras will go away
Expand Down
3 changes: 0 additions & 3 deletions forge-ai/src/main/java/forge/ai/ability/DamageAllAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ protected AiAbilityDecision checkApiLogic(Player ai, SpellAbility sa) {
int x = -1;
final String damage = sa.getParam("NumDmg");
int dmg = AbilityUtils.calculateAmount(source, damage, sa);
if (damage.equals("X") && sa.getSVar(damage).equals("Count$Converge")) {
dmg = ComputerUtilMana.getConvergeCount(sa, ai);
}
if (damage.equals("X") && sa.getSVar(damage).equals("Count$xPaid")) {
x = ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger());
}
Expand Down
8 changes: 1 addition & 7 deletions forge-ai/src/main/java/forge/ai/ability/DrawAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,7 @@ private boolean canLoot(Player ai, SpellAbility sa) {

if (sa.hasParam("NumCards")) {
String numDrawStr = sa.getParam("NumCards");
if (numDrawStr.equals("X") && sa.getSVar(numDrawStr).equals("Count$Converge")) {
numDraw = ComputerUtilMana.getConvergeCount(sa, ai);
} else {
numDraw = AbilityUtils.calculateAmount(source, numDrawStr, sa);
}
numDraw = AbilityUtils.calculateAmount(source, numDrawStr, sa);
}
int numDiscard = 1;
if (sub.hasParam("NumCards")) {
Expand Down Expand Up @@ -274,8 +270,6 @@ private boolean targetAI(final Player ai, final SpellAbility sa, final boolean m
assumeSafeX = true;
}
xPaid = true;
} else if (sa.getSVar(num).equals("Count$Converge")) {
numCards = ComputerUtilMana.getConvergeCount(sa, ai);
}
}

Expand Down
14 changes: 1 addition & 13 deletions forge-ai/src/main/java/forge/ai/ability/PermanentAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,7 @@ protected AiAbilityDecision checkApiLogic(final Player ai, final SpellAbility sa
ManaCost mana = sa.getPayCosts().getTotalMana();
if (mana.countX() > 0) {
final int xPay = ComputerUtilCost.setMaxXValue(sa, ai, false);
if (source.hasConverge()) {
int nColors = -1;
for (int i = 0; i <= xPay; i++) {
sa.setXManaCostPaid(i);
int newColors = ComputerUtilMana.getConvergeCount(sa, ai);
if (newColors > nColors) {
nColors = newColors;
} else {
sa.setXManaCostPaid(i - 1);
break;
}
}
} else if (xPay <= 0) {
if (!source.hasConverge() && xPay <= 0) {
return new AiAbilityDecision(0, AiPlayDecision.CantAffordX);
}
} else if (mana.isZero()) {
Expand Down
12 changes: 9 additions & 3 deletions forge-ai/src/main/java/forge/ai/ability/TokenAi.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ protected boolean checkPhaseRestrictions(final Player ai, final SpellAbility sa,
}
}

// a converge token count reads the payment through its own SVar, so nothing below would
// announce X - and the count is read before that block, so it has to happen here
if (sa.costHasManaX() && source.hasConverge() && sa.getSVar("X").equals("Count$xPaid")
&& !"X".equals(sa.getParamOrDefault("TokenAmount", "1"))) {
// it announces the converge-best X itself; keeping the return value would put the
// largest affordable X back over it
ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger());
}

Card actualToken = spawnToken(ai, sa);

String tokenAmount = sa.getParamOrDefault("TokenAmount", "1");
Expand All @@ -84,9 +93,6 @@ protected boolean checkPhaseRestrictions(final Player ai, final SpellAbility sa,
// X-cost spells
if (tokenHasX) {
int x = AbilityUtils.calculateAmount(sa.getHostCard(), tokenAmount, sa);
if (source.getSVar("X").equals("Count$Converge")) {
x = ComputerUtilMana.getConvergeCount(sa, ai);
}
if (sa.getSVar("X").equals("Count$xPaid")) {
x = ComputerUtilCost.setMaxXValue(sa, ai, sa.isTrigger());
sa.getRootAbility().setXManaCostPaid(x);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,6 @@ public Map<String, Integer> getXManaCostPaidByColor() {
return xManaCostPaidByColor;
}

public final int getSunburst() {
return ColorSet.fromMask(sunburstMap).countColors();
}

public final byte getColorsPaid() {
return sunburstMap;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ public boolean addKeywordCost(SpellAbility sa, Cost cost, KeywordInterface keywo
return chooseNumberForKeywordCost(sa, cost, keyword, prompt, 1) == 1;
}

/**
* Colors this controller means to spend on a spell it has not paid for yet, as a color mask.
* Only asked while nothing has been spent. Zero - the answer for a human, who pays by tapping
* rather than by deciding up front - leaves the card reading its own payment as empty.
*/
public byte getExpectedPayingColors(SpellAbility sa) {
return 0;
}

public abstract int chooseNumber(SpellAbility sa, String title, int min, int max);
public abstract int chooseNumber(SpellAbility sa, String title, List<Integer> values, Player relatedPlayer);
public int chooseNumber(SpellAbility sa, String string, int min, int max, Map<String, Object> params) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,14 @@ public void run() {
}

public ColorSet getPayingColors() {
if (payingMana.isEmpty()) {
// nothing spent yet, so ask whoever is holding this spell what they mean to spend - it
// is the only answer available to a card that reads its own payment (Converge,
// ManaColorsPaid, ManaSpent) while its controller is still deciding whether to cast it
final Player activator = getActivatingPlayer();
return ColorSet.fromMask(activator == null ? 0
: activator.getController().getExpectedPayingColors(this));
}
byte colors = 0;
for (Mana m : payingMana) {
colors |= m.getColor();
Expand Down
Loading