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