Files
super-auto-pets-board-game/internal/ai/ai.go
T
Greyson Parrelli 72e198a750 Fix shop action costs and pass semantics.
Only buying costs gold; selling and trading (Triple) are free. Pass is
now a final action, legal only at or under the pet limit: it forfeits
remaining gold and ends that player's shopping for the round, with the
shop closing once everyone has passed. That makes the separate cleanup
phase unreachable (you sell down in-shop before passing), so it is
removed. The client asks for confirmation before passing, and the bot
knows buys are the only coin sink, when passing is legal, and that it
must sell down before it can pass.
2026-07-23 16:11:07 -04:00

173 lines
5.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" | "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
}
// 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
}
// 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.Ready
case 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)
}