213 lines
6.0 KiB
Go
213 lines
6.0 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.
|
|
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 "battleChoose":
|
|
return g.BattleChoose(playerID, a.Value)
|
|
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.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:
|
|
if g.PendingBattle != nil && g.Players[g.PendingBattle.Seat].ID == playerID {
|
|
return g.BattleChoose(playerID, g.PendingBattle.Max)
|
|
}
|
|
return g.AcknowledgeBattle(playerID)
|
|
}
|
|
return game.ErrInvalidAction
|
|
}
|