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 11 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?
There was a problem hiding this comment.
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.
29244af to
e1cbad2
Compare
|
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 |
|
my recompute was still meant for something else but I'll try to explain if I can also figure out good prediction storing to revisit it I think for now I'll just cherry pick the fix in PermanentAi while keeping the early loop exit around |
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>
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>
The four AI callers all had the same shape: work out the amount with calculateAmount, then override it for Count$Converge. That override existed 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 already gives the right number, so the override is computing a full payment simulation to arrive at a value the line above it already holds. Checked rather than assumed: instrumenting the Count$Converge branch in AbilityUtils and tagging each call with its caller shows 21 of them arriving via ChangeZoneAi.hiddenOriginCanPlayAI - the AI deciding whether to cast, not resolution - with castSA set and the count correct. So calculateAmount is answering properly while the AI is still choosing. PermanentAi's converge loop was already replaced by setXForBestConverge earlier on this branch, so removing these four leaves getConvergeCount with no callers at all and it goes too. 338 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Painful Truths is a Count$Converge draw spell the AI will actually cast, so it exercises the path DrawAi's override used to serve. Five lands, a 2B spell, so the best it can reach is three colours - it draws three and pays three life. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweep the Skies announces X from one SVar and reads Count$Converge from another, so neither the per-API override nor PermanentAi's loop sizes it. It gets one thopter instead of five without the prediction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same spell reaches setMaxXValue twice on the way to a decision - once from the prediction, once from its own API logic - and each walk solves the payment at every X. Chamber Sentry with nine lands spent 20 solves to answer the same question twice; it now spends 6. The walk also ran to the affordable maximum after the answer could no longer improve, so it stops once all five colors are bought. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9311877 to
55ebb1c
Compare
|
Rebased — #11515 is in, so the I re-ran the tests against plain master to see what the rest is still worth: Chamber Sentry and On prediction storing: I had a go while this was waiting. It sits on No rush from me. If you would rather land your own version of the storing, say so and I will drop |
Rebased onto master. #11515 took the
PermanentAipiece, so that is out of this branch — whatremains is the prediction and the paths the loop cannot reach.
Count$Convergereads 0 while deciding. Nothing has been spent yet andgetCastSA()is null, soBring to Light searched for a 0-drop and never cast.
getPayingColors()now asks the activatingplayer's controller what it expects to spend:
PlayerControlleranswers 0, so humans are unchanged,and
PlayerControllerAiforwards to whatAiControllerworked out. Nothing is parked on the spell.Storing it. The prediction lives on
AiController, keyed by spell identity, reset at prioritynext to
AiCache.clear()— the same species of state asHELD_MANA_SOURCES_FOR_NEXT_SPELLfourlines up.
canPlayAndPayForsnapshots and restores it, so a nested evaluation cannot lose the outerspell's answer. Keeping the announced X alongside the colours also settles the recompute you
flagged: the same spell reaches
setMaxXValueonce from the prediction and again from its own APIlogic, and each walk solves the payment at every X. Chamber Sentry off five lands, 12 payment solves
→ 6; off nine lands, with a five-colour early exit, 20 → 6.
Sweep the Skies is the case the loop does not reach.
TokenAmount$ YreadsCount$Convergeoffa different SVar than X announces, so neither the per-API override nor
PermanentAiever sized it —one thopter on master, five here.
The four
Count$Convergeoverrides are gone. Each worked the amount out withcalculateAmountand then overrode it, because
calculateAmountreturned zero before anything had been paid. Withthe prediction the engine's own expression is right, and
getConvergeCounthas no callers left.Then it tutored for the worst card.
chooseCardToHiddenOriginChangeZonetreatsDestination$ Exileas dumping junk, so on your own library it tookgetWorstAI— Bring to Lightpicked a Runeclaw Bear over a Serra Angel. 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.
355 tests, 0 failures.
🤖 Implemented with the assistance of Claude Code (Opus 5).