Files

244 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 15; 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
}