package ai import ( "math" "math/rand/v2" "slices" "github.com/greyson/super-auto-pets-board-game/internal/game" ) // sellPetPenalty converts "persistent pet value destroyed by a sell" into a // score penalty (see the sell-candidate logic in shop.go). A pet's keepValue // is on the order of 1–5; multiplied by this weight and the future's share of // the blend, one junk-pet sell drops a candidate by roughly a tenth of a // win-probability point — enough to make keeping a body the default, while // still letting a strongly positive rollout justify a genuine reshape. const sellPetPenalty = 0.20 // winScore converts a simulated battle outcome to a utility for mySeat: // win 1, draw 0.5 (nobody gains ground), loss 0, plus a small margin term. // // The margin — your surviving pets minus the enemy's — breaks ties among // outcomes that share a verdict: a loss where you took most of the enemy down // beats a wipe, and a decisive win beats a squeaker. It is capped well under // 0.25 so it can never reorder win above draw above loss; it only decides // between moves the coarse win/draw/loss signal rates identically. That // gradient is what makes the bot play on sensibly when every option looks // hopeless — fielding its strongest force instead of picking at random. func winScore(res *game.BattleResult, mySeat int) float64 { var base float64 switch res.WinnerSeat { case mySeat: base = 1 case -1: base = 0.5 default: base = 0 } // Survivors is indexed by battle side, not by seat. mySide := res.Side(mySeat) margin := 0 for side, s := range res.Survivors { if side == mySide { margin += s } else { margin -= s } } return base + 0.1*math.Tanh(float64(margin)/3) } // winProb estimates the chance the arranged deck wins the upcoming battle by // simulating it against every sampled opponent arrangement, simsPer times // each (dice rerolled every time). All candidates in one decision share the // same opponent samples, so comparisons between them are paired and fair. func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int) float64 { if len(oppDecks) == 0 || simsPer <= 0 { return 0.5 } total, n := 0.0, 0 for _, opp := range oppDecks { for range simsPer { // The bot's own deck always takes scratch seat 0, so a rollout reads // the same however the real table happens to be seated. res := game.SimulateBattle(cx.v.Round, cx.simFirstSeat(), myDeck, opp, nil) total += winScore(res, 0) n++ } } return total / float64(n) } // simFirstSeat picks who acts first in a rollout, with the bot at seat 0. Two // players pass a priority token the bot can see, so it plans against the real // one; bigger tables flip a coin for each battle, which the bot can't know in // advance — it rolls too, and averages over both possibilities. func (cx *ctx) simFirstSeat() int { if len(cx.v.Players) == 2 { if cx.v.PrioritySeat == cx.me.Seat { return 0 } return 1 } return rand.IntN(2) } // keepValue ranks a single card's worth to the bot's future: what it loses // by selling or trading it away. Temporary cards (apples) are nearly free to // lose — they vanish after the next battle anyway. func keepValue(c game.Card) float64 { if c.Temporary { return 0.15 } if c.IsFood() { if c.Perk { return 1.6 } return 0.3 } val := float64(c.Power)*0.55 + float64(c.Tier)*0.8 if len(c.Effects) > 0 { val += 0.6 } return val } // deckValue is the future-facing worth of a deck: card quality plus suit // synergy (pairs and triples enable the Triple trade-in, the only path to // higher-tier cards than the current round offers). Temporary cards count // for nothing here — their value shows up in the battle rollouts instead. func deckValue(deck []game.Card, round, maxRounds int) float64 { total := 0.0 suits := map[game.Suit]int{} for _, c := range deck { if c.Temporary { continue } total += keepValue(c) if c.IsPet() { suits[c.Suit]++ } } if round < maxRounds { for _, k := range suits { switch { case k >= 3: total += 1.5 case k == 2: total += 0.6 } } } return total } // normFuture squashes an unbounded deck value into (0, 1) so it can be // blended with a win probability. func normFuture(val float64) float64 { return val / (val + 15) } // leadScore ranks pets for early battle positions: raw power fights longest, // faint effects want to actually faint (early), play effects fire on entry // wherever they are but are worth protecting slightly less. func leadScore(c game.Card) float64 { s := float64(c.Power) for _, e := range c.Effects { switch e.Trigger { case game.TriggerFaint: s += 1.5 case game.TriggerPlay: s += 0.8 case game.TriggerHurt: s += 0.5 case game.TriggerAfterAttack: // Wants to survive its clashes to keep triggering (Bulldog). s += 0.6 } } return s } // heuristicOrder arranges a deck the way a reasonable player might: pets // sorted by leadScore, apples front-loaded, perks spread across the // strongest pets, never a food trailing at the bottom. temp adds Gumbel // noise to every placement — 0 gives the deterministic "book" order, higher // values give increasingly scrambled-but-plausible alternatives (used to // model the range of orders an opponent might pick). func heuristicOrder(deck []game.Card, temp float64) []game.Card { var pets, apples, perks, otherFood []game.Card for _, c := range deck { switch { case c.IsPet(): pets = append(pets, c) case c.Perk: perks = append(perks, c) case c.Food == game.FoodApple: apples = append(apples, c) default: otherFood = append(otherFood, c) } } noisy := func(base float64) float64 { if temp <= 0 { return base } // Gumbel-perturbed scores turn a sort into a plausibility-weighted // random ranking. return base - temp*math.Log(-math.Log(rand.Float64())) } slices.SortStableFunc(pets, func(a, b game.Card) int { av, bv := noisy(leadScore(a)), noisy(leadScore(b)) switch { case av > bv: return -1 case av < bv: return 1 } return 0 }) if len(pets) == 0 { // No pets means an immediate loss; foods are wasted regardless. return append(append(append(apples, perks...), otherFood...), pets...) } // Assign foods to pet indices, then interleave. assign := make([][]game.Card, len(pets)) strongest := 0 for i, p := range pets { if p.Power > pets[strongest].Power { strongest = i } } for _, a := range apples { at := 0 if temp > 0 && rand.Float64() < 0.4 { at = rand.IntN(len(pets)) } assign[at] = append(assign[at], a) } // Perks one per pet, best pets first (a pet only keeps its last perk). perkOrder := []int{strongest} for i := range pets { if i != strongest { perkOrder = append(perkOrder, i) } } for i, p := range perks { at := perkOrder[min(i, len(perkOrder)-1)] if temp > 0 && rand.Float64() < 0.3 { at = rand.IntN(len(pets)) } assign[at] = append(assign[at], p) } for i, f := range otherFood { assign[i%len(pets)] = append(assign[i%len(pets)], f) } out := make([]game.Card, 0, len(deck)) for i, p := range pets { out = append(out, assign[i]...) out = append(out, p) } return out }