// 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) } // How far behind the field the bot is. The yardstick is whoever is leading, // not the sum of everyone — at a six-player table the title is a race // against the front-runner, and summing would swamp the round term. me := v.PlayerView(v.YouSeat) best := 0 for _, p := range v.Players { if p.Seat != v.YouSeat { best = max(best, p.Trophies) } } if me != nil { w += 0.08 * float64(best-me.Trophies) } return min(max(w, 0.25), 1) }