Let the AI count converge before it decides whether to cast - #11486
Let the AI count converge before it decides whether to cast#11486liamiak wants to merge 7 commits into
Conversation
| final byte colors = ComputerUtilMana.getConvergeColors(sa, player); | ||
| for (byte color : MagicColor.WUBRG) { | ||
| if ((colors & color) != 0) { | ||
| sa.getPayingMana().add(new Mana(color, host, null, player)); |
There was a problem hiding this comment.
don't like having to create extra fake Mana
what are the alternatives?
There was a problem hiding this comment.
You're right — I missed this one on the first pass. Working it now, expect another push.
There was a problem hiding this comment.
Pushed. Went with predicting the colours rather than the payment: a byte predictedPayingColors on SpellAbility that getPayingColors() ORs in, set and cleared on the same lines that already borrow and return castSA.
payingMana is a multiset with provenance and the prediction is only a set of colours, so the Mana objects were right about colour and invented the rest — one entry per colour, each sourced from the spell itself, which is what Count$Adamant, Count$TotalManaSpent and Count$EachSpentToCast read.
On the redundancy point: getSunburst() had no callers left once getConvergeCount was rewritten — it was ColorSet.fromMask(sunburstMap).countColors() over the same field getColorsPaid() returns — so I removed it. Happy to put it back if you'd rather keep the accessor.
To be straight about the scope: the mask is only set for hasConverge() cards, and none of the 31 in the pool use ManaColorsPaid or ManaSpent, so Count$Converge is the only reader that can observe a non-zero value today. Outside the AI's own evaluation getPayingColors() is unchanged.
There was a problem hiding this comment.
Mhn, I'll think about it though a lot of work has previously gone into removing AI only stuff from the main rules engines...
| /** | ||
| * Return the colors that would be used for payment, as a color mask. | ||
| */ | ||
| public static byte getConvergeColors(final SpellAbility sa, final Player ai) { |
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
well the code duplication is gone but the method is still used and calculates again for no reason?
On a converge or sunburst card X has one job: buy colours. Nothing announced it usefully, so Chamber Sentry was never cast at all (at X=0 it is a free 0/0 that dies), Skyrider Elf arrived as a 2/2 instead of a 5/5, and Sweep the Skies made one thopter instead of five. PermanentAi measured its converge baseline at the maximum X that setMaxXValue leaves behind, so the first step of its walk always compared worse and X collapsed to 0. Rather than fix that loop in place it is deleted: setMaxXValue is where every X announcement already goes, so the choice belongs there. It still returns the largest affordable X, so the xPay <= 0 checks at its 66 call sites are unchanged - only the announced value differs, and only for a spell, since converge counts colours spent casting. getConvergeCount now derives from getConvergeColors instead of running its own copy of the payment simulation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Count$Converge reads the colours actually spent, which nothing has while the AI is still deciding, so every converge effect was evaluated as if it were empty. On Bring to Light that means ChangeType$ Creature.cmcLEX with X=0 - it searches for a 0-drop, finds nothing, and never casts. canPlayAndPayFor already lends the card its castSA for this same reason, so lend it the payment it is about to make too, announcing X first because that is what buys the colours being measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chooseCardToHiddenOriginChangeZone treats Destination$ Exile as "exiling or bouncing stuff", so when the library being searched is the AI's own it picks getWorstAI. That is right for exile-as-removal, but a tutor exiles as a staging step before a DB$ Play casts the card - Bring to Light deliberately searched out the worst creature it could legally find. Nine cards are Library -> Exile with a Play sub-ability and eight of them are unflagged, so this is live: Beseech the Mirror, Emergent Ultimatum, Evolving Door, Jace Architect of Thought, Kasmina Enigma Sage, Portent of Calamity, The Heron Moon, Djinn of Wishes. Searching an opponent's library still takes their best card - that branch is untouched. Unflags Bring to Light. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of the three fails without its own commit: Chamber Sentry is not cast, Bring to Light is not cast, and Bring to Light takes a Runeclaw Bear over the Serra Angel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29244af to
e1cbad2
Compare
The prediction was stored as Mana objects pushed onto payingMana, which is a multiset with provenance - so it was honest about colour and invented the other two dimensions: one entry per colour, each sourced from the spell itself. Count$Adamant, Count$TotalManaSpent and Count$EachSpentToCast all read those. Store a colour mask instead, ORed in by getPayingColors. That narrows the reach to the four callers with colour-set semantics - Converge, ManaColorsPaid, and the two ManaSpent/ManaNotSpent checks - each of which reads zero today for a spell the AI has not cast yet. Also drop ManaCostBeingPaid.getSunburst, whose only caller was the getConvergeCount body replaced in the previous commit. It was ColorSet.fromMask(sunburstMap).countColors() over the same field getColorsPaid returns, now inlined at the one site that wanted the count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Announcing X on a converge card runs a payment simulation per candidate X; predictConvergePayment then ran one more to read back the colours that X had just bought. setXForBestConverge now returns the winning mask and setMaxXValue records it, so the extra pass is gone - measured 13 -> 12 simulations casting Chamber Sentry off five lands. Recording it there means the ~60 setMaxXValue callers can set the prediction, not just canPlayAndPayFor, which is the only place that clears it. So getPayingColors now consults the prediction only while nothing has been spent; any real payment wins outright. That also covers the simulation AI, which announces X through SpellAbilityChoicesIterator and never goes through canPlayAndPayFor at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An alternative to the two commits before it, for the "AI only stuff in the rules engine" concern - these are not meant to both land, so drop whichever you do not want. Nothing is parked on the spell. getPayingColors asks the activating player's controller what it means to spend while nothing has been spent; PlayerController answers zero, so a human is unchanged, and PlayerControllerAi forwards to what AiController worked out. One seam still serves Converge, ManaColorsPaid and both ManaSpent checks, and there is no mask left to go stale. Scoped twice: canPlayAndPayFor hands back whatever the outer spell was expecting, so a nested evaluation cannot lose the outer prediction, and chooseSpellAbilityToPlay resets it next to AiCache and predictedCombat. HELD_MANA_SOURCES_FOR_NEXT_SPELL four lines above is the same species of state - good for one spell, kept AI side, reset at priority. It costs more than the field it replaces, and the prediction becomes controller-scoped rather than spell-scoped, so a runWithController swap loses it. Both cases degrade to zero, which is the answer before any of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed two commits.
It parks nothing on the spell. Scoped twice — The honest trade: the field version is +17/−21 across three files, this is +60/−17 across six, and the prediction becomes controller-scoped rather than spell-scoped, so a |
Supersedes #11485 — the two were halves of one change, and the duplicated payment simulation you
flagged could only be resolved in one place.
Converge and sunburst count the colours actually spent, which nothing has while the AI is still
deciding. Two things followed from that.
X was never announced to buy them.
PermanentAimeasured its converge baseline at the maximum XsetMaxXValueleaves behind, so its walk always compared worse and X collapsed to 0. That loop isdeleted —
setMaxXValueis where every X announcement already goes. It still returns the largestaffordable X, so the
xPay <= 0checks at its 66 call sites are unchanged, and it only acts on aspell, since converge counts colours spent casting. Sweep the Skies is then fixed without touching
TokenAi.Count$Convergeread 0 while deciding.getCastSA()is null before the spell is cast, so Bringto Light searched for a 0-drop and never cast.
canPlayAndPayForalready lends the card itscastSAfor this reason; it now lends the payment too.getConvergeCountderives fromgetConvergeColors, so there is one simulation rather than two.Then it tutored for the worst card.
chooseCardToHiddenOriginChangeZonetreatsDestination$ Exileas dumping junk, so on your own library it takesgetWorstAI— Bring to Lightpicked a Runeclaw Bear over a Serra Angel 18/18. Exile is a staging step before
DB$ Play. Eightunflagged cards share this. Scoped by calling the chooser across every destination × origin ×
own-or-opponent: one cell of thirty moves.
Engineered Explosives stays flagged. Its counters select which mana value the wipe hits, so most
colours is usually the worst live choice — that needs board-aware selection, separately.
Each commit fails its own assertion without it. 338 tests, 0 failures.
🤖 Implemented with the assistance of Claude Code (Opus 5).