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

217 lines
6.3 KiB
Go

package server
import (
"errors"
"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. The levels are calibrated against a competent
// player (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium
// is an even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is
// pinned to the competent level itself; hard is the strongest bot the engine
// can field.
var botDifficulty = map[string]struct {
level float64
name string
}{
"easy": {0.25, "Robo Rookie"},
"medium": {0.60, "Robo Rival"},
"hard": {1.00, "Robo Ace"},
}
// requireHost authorizes a lobby-management action: only the host (seat 0)
// may set the pack, add or remove players, or start the game. Identity lives
// at this trust boundary, keeping the game engine free of it.
func requireHost(g *game.Game, playerID string) error {
if len(g.Players) == 0 || g.Players[0].ID != playerID {
return errors.New("only the host can do that")
}
return nil
}
// addBot seats a computer opponent for a difficulty name, translating the
// name to the engine's skill level. Capacity and phase are enforced by the
// engine's AddPlayer.
func addBot(g *game.Game, difficulty string) error {
bot, ok := botDifficulty[difficulty]
if !ok {
return errors.New("unknown bot difficulty")
}
_, err := g.AddBot(bot.name, bot.level)
return err
}
// 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.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 "buyAvocado":
return g.BuyAvocado(playerID, a.Row)
case "sell":
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "revealChoose":
return g.RevealChoose(playerID, a.CardID)
case "sacrificeChoose":
return g.SacrificeChoose(playerID, a.CardID)
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 (selling down to the pet limit first if
// passing would be refused), take the first trade option, 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.PendingSacrifice != nil && g.PendingSacrifice.PlayerID == playerID {
return g.SacrificeChoose(playerID, g.PendingSacrifice.Options[0])
}
if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID {
return g.RevealChoose(playerID, g.PendingReveal.Options[0])
}
if g.Pending != nil && g.Pending.PlayerID == playerID {
return g.TradeChoose(playerID, 0)
}
if excess := p.PetCount() - game.MaxPets; excess > 0 {
ids := make([]string, 0, excess)
for _, c := range p.Deck {
if c.IsPet() && len(ids) < excess {
ids = append(ids, c.ID)
}
}
return g.Sell(playerID, ids)
}
return g.Pass(playerID)
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
}