Add bot to play against.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user