package server import ( "errors" "fmt" "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 pool of // table names. 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. // // A table can hold up to five bots, so each difficulty carries a list of names // rather than one: the first unused name is taken, keeping every seat // distinguishable while the name still hints at how hard it plays. var botDifficulty = map[string]struct { level float64 names []string }{ "easy": {0.25, []string{"Robo Rookie", "Bitsy", "Clunk", "Pip", "Sprocket"}}, "medium": {0.60, []string{"Robo Rival", "Gizmo", "Widget", "Rusty", "Cogsworth"}}, "hard": {1.00, []string{"Robo Ace", "Vex", "Apex", "Onyx", "Zenith"}}, } // 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 and giving it a name nobody at the table is // already using. 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(botName(g, bot.names), bot.level) return err } // botName picks the first name in the pool that no seat has taken. If a host // somehow exhausts the pool, it falls back to numbering the first name. func botName(g *game.Game, pool []string) string { taken := make(map[string]bool, len(g.Players)) for _, p := range g.Players { taken[p.Name] = true } for _, name := range pool { if !taken[name] { return name } } for n := 2; ; n++ { name := fmt.Sprintf("%s %d", pool[0], n) if !taken[name] { return name } } } // 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 }