Add bot to play against.

This commit is contained in:
Greyson Parrelli
2026-07-23 15:58:12 -04:00
parent 3825dbacec
commit db1a6ef290
23 changed files with 1852 additions and 23 deletions
+173
View File
@@ -0,0 +1,173 @@
// 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" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready"
Row int // buy
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
}
// 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.Pending != nil {
if v.Pending.PlayerID == me.ID {
return b.decideTradeChoose(v, mem)
}
return nil
}
if v.Turn == v.YouSeat && me.Coins > 0 {
return b.decideShop(v, mem)
}
case game.PhaseCleanup:
if !me.Ready {
return b.decideCleanup(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
}
// 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.Pending != nil {
return v.Pending.PlayerID == me.ID
}
return v.Turn == v.YouSeat && me.Coins > 0
case game.PhaseCleanup, game.PhaseArrange, 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]
}
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]
}
// 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)
}