Add bot to play against.

This commit is contained in:
Greyson Parrelli
2026-07-23 15:58:12 -04:00
parent 3825dbacec
commit db1a6ef290
23 changed files with 1852 additions and 23 deletions
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
// TestE2EBotGame creates a vs-computer game over the API, plays the human's
// shop turns over the wire, and verifies the scheduled bot actually takes
// its own turns (spends coins) without any second client connected.
func TestE2EBotGame(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := New(st, "", false)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := http.Post(ts.URL+"/api/games", "application/json",
bytes.NewBufferString(`{"name":"Human","bot":"easy"}`))
if err != nil {
t.Fatal(err)
}
var join joinResponse
if err := json.NewDecoder(resp.Body).Decode(&join); err != nil {
t.Fatal(err)
}
resp.Body.Close()
wsBase := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.Dial(ctx,
wsBase+"/api/ws?game="+join.GameID+"&player="+join.PlayerID+"&token="+join.Token, nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close(websocket.StatusNormalClosure, "")
v := readState(t, ctx, ws)
if v.Phase != game.PhaseShop {
t.Fatalf("phase = %s, want shop (bot fills the lobby instantly)", v.Phase)
}
var bot *game.PlayerView
for i := range v.Players {
if v.Players[i].IsBot {
bot = &v.Players[i]
}
}
if bot == nil {
t.Fatal("no bot seat in the game")
}
// Play the human side: pass whenever it's our turn. The game can only
// reach the arrange phase if the bot spends its own three coins too.
deadline := time.Now().Add(25 * time.Second)
for v.Phase == game.PhaseShop && time.Now().Before(deadline) {
if v.Turn == v.YouSeat && v.Players[v.YouSeat].Coins > 0 && v.Pending == nil {
send(t, ctx, ws, map[string]any{"type": "pass"})
}
rctx, rcancel := context.WithTimeout(ctx, 10*time.Second)
v = readState(t, rctx, ws)
rcancel()
}
if v.Phase == game.PhaseShop {
t.Fatalf("shop never ended; bot coins=%d", v.Players[bot.Seat].Coins)
}
t.Logf("reached phase %s; bot played its shop turns", v.Phase)
}
+181
View File
@@ -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
}
+31 -3
View File
@@ -62,6 +62,9 @@ type room struct {
game *game.Game
conns map[*client]struct{}
debug bool // mirrors Server.debug, for broadcastLocked
// botArmed is set while a delayed bot move is scheduled, so only one
// timer exists per room at a time.
botArmed bool
}
// getRoom returns the room for a game ID, loading it from the store if it
@@ -78,6 +81,11 @@ func (s *Server) getRoom(gameID string) (*room, error) {
}
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.rooms[gameID] = r
// If the game was persisted mid-bot-turn (e.g. across a server restart),
// get the bot moving again.
r.mu.Lock()
s.scheduleBotsLocked(r)
r.mu.Unlock()
return r, nil
}
@@ -116,24 +124,45 @@ type joinResponse struct {
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
var body struct {
Name string `json:"name"`
// Bot, when set, fills the other seat with a computer player:
// "easy" | "medium" | "hard".
Bot string `json:"bot"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON body")
return
}
var bot struct {
level float64
name string
}
if body.Bot != "" {
var ok bool
bot, ok = botDifficulty[body.Bot]
if !ok {
httpError(w, http.StatusBadRequest, "unknown bot difficulty")
return
}
}
g := game.New()
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if body.Bot != "" {
if _, err := g.AddBot(bot.name, bot.level); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
}
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.mu.Lock()
s.rooms[g.ID] = r
s.mu.Unlock()
r.mu.Lock()
s.persist(r)
s.commitLocked(r)
r.mu.Unlock()
writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token})
}
@@ -163,9 +192,8 @@ func (s *Server) handleJoin(w http.ResponseWriter, req *http.Request) {
httpError(w, http.StatusConflict, err.Error())
return
}
s.persist(r)
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
r.broadcastLocked()
s.commitLocked(r)
r.mu.Unlock()
writeJSON(w, resp)
}
+4 -2
View File
@@ -73,6 +73,9 @@ func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) {
r.conns[c] = struct{}{}
p.Connected = true
r.broadcastLocked()
// Safety net: if a scheduled bot move was ever lost (crash between
// persist and timer), a player connecting re-arms it.
s.scheduleBotsLocked(r)
r.mu.Unlock()
defer func() {
@@ -147,8 +150,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
c.sendError(err.Error())
return
}
s.persist(r)
r.broadcastLocked()
s.commitLocked(r)
}
// broadcastLocked sends each connected client its own view of the game.