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. 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 "sell": 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 (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.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 }