Only buying costs gold; selling and trading (Triple) are free. Pass is now a final action, legal only at or under the pet limit: it forfeits remaining gold and ends that player's shopping for the round, with the shop closing once everyone has passed. That makes the separate cleanup phase unreachable (you sell down in-shop before passing), so it is removed. The client asks for confirmation before passing, and the bot knows buys are the only coin sink, when passing is legal, and that it must sell down before it can pass.
178 lines
4.8 KiB
Go
178 lines
4.8 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.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
|
|
}
|