Files
super-auto-pets-board-game/internal/server/bots.go
T

182 lines
4.9 KiB
Go

package server
import (
"log/slog"
"math/rand/v2"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/ai"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// botDifficulty maps the API's difficulty names to a skill level and a
// table name for the bot.
var botDifficulty = map[string]struct {
level float64
name string
}{
"easy": {0.25, "Robo Rookie"},
"medium": {0.60, "Robo Rival"},
"hard": {1.00, "Robo Ace"},
}
// commitLocked is the one path every game mutation goes through: bots update
// their memories from the new public state, the game is persisted, every
// client gets its view, and the next bot move (if any) is scheduled. Callers
// must hold r.mu.
func (s *Server) commitLocked(r *room) {
observeBotsLocked(r.game)
s.persist(r)
r.broadcastLocked()
s.scheduleBotsLocked(r)
}
// observeBotsLocked gives each bot a look at the current state through its
// own player view — the same information a human in that seat would see.
func observeBotsLocked(g *game.Game) {
for _, p := range g.Players {
if !p.IsBot {
continue
}
mem := ai.LoadMemory(p.BotMemory)
view := g.ViewFor(p.ID)
ai.Observe(&view, mem)
p.BotMemory = mem.Marshal()
}
}
// scheduleBotsLocked arms a delayed move for the first bot that owes the
// game an action. The delay is there purely for feel — instant replies make
// the opponent seem like a vending machine. Only one timer runs per room;
// each fired move re-schedules the next.
func (s *Server) scheduleBotsLocked(r *room) {
if r.botArmed {
return
}
for _, p := range r.game.Players {
if !p.IsBot {
continue
}
view := r.game.ViewFor(p.ID)
if !ai.Pending(&view) {
continue
}
r.botArmed = true
playerID := p.ID
time.AfterFunc(botDelay(r.game.Phase), func() { s.runBot(r, playerID) })
return
}
}
// botDelay picks a humanlike pause before a bot move.
func botDelay(phase game.Phase) time.Duration {
ms := func(base, jitter int) time.Duration {
return time.Duration(base+rand.IntN(jitter+1)) * time.Millisecond
}
switch phase {
case game.PhaseShop:
return ms(700, 900)
case game.PhaseCleanup:
return ms(900, 600)
case game.PhaseArrange:
return ms(1600, 1600)
default: // battle acknowledgement
return ms(500, 300)
}
}
// runBot fires one scheduled bot move. The state may have changed while the
// timer ran, so everything is revalidated under the lock.
func (s *Server) runBot(r *room, playerID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.botArmed = false
p := r.game.PlayerByID(playerID)
if p == nil || !p.IsBot {
return
}
view := r.game.ViewFor(playerID)
if !ai.Pending(&view) {
// Someone else moved the game on; check the other seats.
s.scheduleBotsLocked(r)
return
}
act := ai.New(p.BotLevel).Act(&view, ai.LoadMemory(p.BotMemory))
var err error
if act == nil {
err = game.ErrInvalidAction
} else {
err = applyBotAction(r.game, playerID, act)
}
if err != nil {
// A bot must never wedge the game: fall back to the simplest legal
// move for the phase.
slog.Warn("bot action failed; using fallback", "game", r.game.ID, "player", playerID, "err", err)
if err := botFallback(r.game, playerID); err != nil {
slog.Error("bot fallback failed", "game", r.game.ID, "player", playerID, "err", err)
return
}
}
s.commitLocked(r)
}
// applyBotAction maps a bot decision onto the engine, mirroring the client
// message dispatch in apply().
func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
switch a.Type {
case "buy":
return g.Buy(playerID, a.Row)
case "sell":
if g.Phase == game.PhaseCleanup {
return g.CleanupSell(playerID, a.Cards)
}
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "pass":
return g.Pass(playerID)
case "arrange":
return g.SubmitOrder(playerID, a.Order)
case "ready":
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
// botFallback makes the trivially legal move for whatever the game is
// waiting on: pass the shop turn, take the first trade option, sell the
// first excess pets, submit the deck as-is, or acknowledge the battle.
func botFallback(g *game.Game, playerID string) error {
p := g.PlayerByID(playerID)
if p == nil {
return game.ErrInvalidAction
}
switch g.Phase {
case game.PhaseShop:
if g.Pending != nil && g.Pending.PlayerID == playerID {
return g.TradeChoose(playerID, 0)
}
return g.Pass(playerID)
case game.PhaseCleanup:
excess := p.PetCount() - game.MaxPets
ids := make([]string, 0, excess)
for _, c := range p.Deck {
if c.IsPet() && len(ids) < excess {
ids = append(ids, c.ID)
}
}
return g.CleanupSell(playerID, ids)
case game.PhaseArrange:
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
return g.SubmitOrder(playerID, ids)
case game.PhaseBattle:
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}