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

258 lines
8.4 KiB
Go

// Package ai implements a computer-controlled player.
//
// The bot is strictly information-hygienic: every decision is made from a
// game.View — the exact same state the server would send a human sitting in
// that seat — plus a Memory built purely from past public observations
// (battle lineups, the shared event log, and shop-row changes). The bot never
// touches the Game struct, so it cannot read the opponent's secret deck
// order, hidden trade picks, or upcoming shop cards even by accident.
//
// Decisions are made by generating candidate moves and scoring each one as a
// blend of two signals:
//
// - immediate: the estimated probability of winning the next battle,
// measured by Monte-Carlo rollouts (game.SimulateBattle) against sampled
// guesses of the opponent's deck and ordering;
// - future: a heuristic value of the resulting deck (power, tiers, suit
// synergy toward Triples) that only pays off in later rounds.
//
// The blend shifts toward "immediate" as the game nears its end and when the
// bot trails on trophies, which is what lets it deliberately take a weak
// round early to set up a stronger one later.
//
// Difficulty is a single level in [0, 1]: it sets the softmax temperature
// used to choose among scored candidates (a perfect bot always takes the top
// move; an easy bot often takes merely decent ones) and scales the rollout
// budget (an easy bot estimates win chances more noisily).
package ai
import (
"math"
"math/rand/v2"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// Action is one move the bot wants to make, mirroring the client protocol.
type Action struct {
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | sacrificeChoose
Row int // buy / buyAvocado
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
CardID string // revealChoose (Cockatoo) / sacrificeChoose (Water of Youth): the pet
}
// Bot is a computer player at a fixed difficulty level.
type Bot struct {
level float64
}
// New creates a bot with the given skill level in [0, 1].
func New(level float64) *Bot {
return &Bot{level: min(max(level, 0), 1)}
}
// Act computes the bot's next move from its view of the game, or nil when no
// input is owed. It does not modify the memory.
func (b *Bot) Act(v *game.View, mem *Memory) *Action {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return nil
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.PendingSacrifice != nil {
if v.PendingSacrifice.PlayerID == me.ID {
return b.decideSacrifice(v)
}
return nil
}
if v.PendingReveal != nil {
if v.PendingReveal.PlayerID == me.ID {
return b.decideReveal(v)
}
return nil
}
if v.Pending != nil {
if v.Pending.PlayerID == me.ID {
return b.decideTradeChoose(v, mem)
}
return nil
}
// During the shop, Ready means "passed": the turn keeps coming back
// (even with no coins — selling and trading are free) until the bot
// passes.
if v.Turn == v.YouSeat && !me.Ready {
return b.decideShop(v, mem)
}
case game.PhaseArrange:
if !me.Ready {
return b.decideArrange(v, mem)
}
case game.PhaseBattle:
if !me.Ready {
return &Action{Type: "ready"}
}
}
return nil
}
// decideReveal picks the highest-power eligible pet for Cockatoo's reveal, for
// the most apples.
func (b *Bot) decideReveal(v *game.View) *Action {
me := &v.Players[v.YouSeat]
best, bestPow := "", -1
for _, id := range v.PendingReveal.Options {
for _, c := range me.Deck {
if c.ID == id && c.Power > bestPow {
best, bestPow = id, c.Power
}
}
}
return &Action{Type: "revealChoose", CardID: best}
}
// decideSacrifice resolves Water of Youth (Unicorn pack): give up the
// lowest-value eligible pet to upgrade into a next-tier card.
func (b *Bot) decideSacrifice(v *game.View) *Action {
me := &v.Players[v.YouSeat]
worst, worstVal := "", math.Inf(1)
for _, id := range v.PendingSacrifice.Options {
for _, c := range me.Deck {
if c.ID == id {
if val := keepValue(c); val < worstVal {
worst, worstVal = id, val
}
}
}
}
if worst == "" && len(v.PendingSacrifice.Options) > 0 {
worst = v.PendingSacrifice.Options[0]
}
return &Action{Type: "sacrificeChoose", CardID: worst}
}
// Pending reports whether the seat owes the game an action right now — the
// server uses it to decide when to schedule a bot move.
func Pending(v *game.View) bool {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return false
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.PendingSacrifice != nil {
return v.PendingSacrifice.PlayerID == me.ID
}
if v.PendingReveal != nil {
return v.PendingReveal.PlayerID == me.ID
}
if v.Pending != nil {
return v.Pending.PlayerID == me.ID
}
return v.Turn == v.YouSeat && !me.Ready
case game.PhaseArrange:
return !me.Ready
case game.PhaseBattle:
return !me.Ready
}
return false
}
// candidate is one scored move option. Most candidates map to a single
// hypothetical deck; a trade maps to several (one per sampled reward card)
// whose scores are averaged.
type candidate struct {
act *Action
decks [][]game.Card
bias float64 // small nudge applied on top of the evaluated score
score float64
}
// pick chooses among candidates with a softmax over their scores. The
// difficulty level sets the temperature: near 0 the bot always takes the
// best move; higher temperatures make it increasingly willing to take
// second-best (or worse) options.
func (b *Bot) pick(cands []candidate) candidate {
if len(cands) == 1 {
return cands[0]
}
// Below competent play, the bot sometimes ignores its evaluation entirely
// and plays a random legal move. Softmax temperature alone can't make a bot
// a genuine pushover: it still samples among sensibly-generated candidates
// and, on this game's shop/dice variance, that wins ~1 game in 5 even
// against expert play. Real, occasional mistakes — buying the wrong pet,
// passing with coins in hand, a scrambled battle order — are what let a
// competent human beat "easy" almost every time. The rate is zero at and
// above competent level, so medium and hard never blunder.
if p := blunderProb(b.level); p > 0 && rand.Float64() < p {
return cands[rand.IntN(len(cands))]
}
temp := 0.02 + 0.30*(1-b.level)
best := math.Inf(-1)
for _, c := range cands {
best = max(best, c.score)
}
weights := make([]float64, len(cands))
total := 0.0
for i, c := range cands {
weights[i] = math.Exp((c.score - best) / temp)
total += weights[i]
}
r := rand.Float64() * total
for i, w := range weights {
r -= w
if r <= 0 {
return cands[i]
}
}
return cands[len(cands)-1]
}
// competentLevel is the skill (on the [0,1] level scale) at which the bot is
// meant to be an even match for a competent human: at or above it the bot
// never throws a game on purpose. Difficulties are calibrated around it —
// easy well below, medium at it, hard above.
const competentLevel = 0.6
// blunderProb is the chance pick discards its evaluation and plays a uniformly
// random legal move. It is zero at competent level and above, and ramps up
// steeply below, so only the easy tier makes real mistakes. blunderMax is the
// rate at level 0 (the weakest possible bot).
func blunderProb(level float64) float64 {
const blunderMax = 0.75
if level >= competentLevel {
return 0
}
return blunderMax * (competentLevel - level) / competentLevel
}
// budget returns the rollout counts for this difficulty: how many opponent
// deck/order guesses to test against, and how many dice-randomized battle
// simulations to run per guess. Fewer samples means noisier estimates, which
// is itself part of what makes an easy bot easy.
func (b *Bot) budget() (oppSamples, simsPer int) {
oppSamples = 6 + int(b.level*8) // 6 .. 14
simsPer = 1 + int(b.level*2) // 1 .. 3
return
}
// immediateWeight is how much of a move's score comes from the next battle
// versus long-term deck value. Later rounds shift weight toward "win now"
// (round 6 is worth double and there is no later); trailing on trophies
// pushes the same way, while a comfortable lead frees the bot to invest.
func immediateWeight(v *game.View) float64 {
w := 0.40
if v.MaxRounds > 1 {
w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1)
}
me := v.Players[v.YouSeat]
for _, p := range v.Players {
if p.Seat != v.YouSeat {
w += 0.08 * float64(p.Trophies-me.Trophies)
}
}
return min(max(w, 0.25), 1)
}