258 lines
8.4 KiB
Go
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)
|
|
}
|