Files
super-auto-pets-board-game/internal/ai/eval.go
T

205 lines
5.3 KiB
Go

package ai
import (
"math"
"math/rand/v2"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// winScore converts a simulated battle outcome to a utility for mySeat:
// win 1, draw 0.5 (nobody gains ground), loss 0.
func winScore(res *game.BattleResult, mySeat int) float64 {
switch res.WinnerSeat {
case mySeat:
return 1
case -1:
return 0.5
default:
return 0
}
}
// 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 {
var res *game.BattleResult
if cx.me.Seat == 0 {
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil)
} else {
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil)
}
total += winScore(res, cx.me.Seat)
n++
}
}
return total / float64(n)
}
// 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
}