Add bot to play against.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user