231 lines
6.5 KiB
Go
231 lines
6.5 KiB
Go
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
|
||
}
|
||
margin := 0
|
||
for seat, s := range res.Survivors {
|
||
if seat == mySeat {
|
||
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 {
|
||
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
|
||
}
|