Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+110
View File
@@ -96,3 +96,113 @@ func TestE2EBotGame(t *testing.T) {
}
t.Logf("reached phase %s; bot played its shop turns", v.Phase)
}
// TestE2EFourPlayerBotGame runs a four-seat game (one human, three bots) over
// the API and plays a full round. It's the multiplayer counterpart to
// TestE2EBotGame: the bot driver has to keep three seats moving through a
// shop that takes turns four ways, and the round has to resolve two battles at
// once rather than one.
func TestE2EFourPlayerBotGame(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(), 3*time.Minute)
defer cancel()
resp, err := http.Post(ts.URL+"/api/games", "application/json",
bytes.NewBufferString(`{"name":"Human"}`))
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, "")
if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby {
t.Fatalf("phase = %s, want lobby on create", p)
}
// Four seats need two packs shuffled together, per the rulebook.
send(t, ctx, ws, map[string]any{"type": "setPacks", "packs": []string{"turtle", "golden"}})
for _, level := range []string{"easy", "medium", "hard"} {
send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": level})
}
send(t, ctx, ws, map[string]any{"type": "start"})
var v *game.View
deadline := time.Now().Add(2 * time.Minute)
for time.Now().Before(deadline) {
rctx, rcancel := context.WithTimeout(ctx, 20*time.Second)
v = readState(t, rctx, ws)
rcancel()
if v.Phase == game.PhaseLobby {
continue
}
if len(v.Players) != 4 {
t.Fatalf("expected 4 seats, got %d", len(v.Players))
}
// Play the human side: pass as soon as it's our turn, then submit our
// deck as-is. The round can only progress if the three bots take their
// own turns around us.
switch v.Phase {
case game.PhaseShop:
if v.Turn == v.YouSeat && !v.Players[v.YouSeat].Ready && v.Pending == nil &&
v.PendingReveal == nil && v.PendingSacrifice == nil {
send(t, ctx, ws, map[string]any{"type": "pass"})
}
case game.PhaseArrange:
if !v.Players[v.YouSeat].Ready {
ids := []string{}
for _, c := range v.Players[v.YouSeat].Deck {
ids = append(ids, c.ID)
}
send(t, ctx, ws, map[string]any{"type": "arrange", "order": ids})
}
case game.PhaseBattle:
goto resolved
}
}
t.Fatalf("never reached the battle phase; stuck in %s", v.Phase)
resolved:
// Every bot name should be distinct, or the table is unreadable.
names := map[string]bool{}
for _, p := range v.Players {
if names[p.Name] {
t.Errorf("two seats are both called %q", p.Name)
}
names[p.Name] = true
}
// Four players pair into two simultaneous battles, together seating everyone.
if len(v.Battles) != 2 {
t.Fatalf("round resolved %d battles, want 2", len(v.Battles))
}
fought := map[int]bool{}
for _, b := range v.Battles {
for _, seat := range b.Seats {
fought[seat] = true
}
}
if len(fought) != 4 {
t.Errorf("%d of 4 seats fought this round", len(fought))
}
if v.Battle == nil || !v.Battle.Has(v.YouSeat) {
t.Error("the view should single out the battle the human fought")
}
t.Logf("four-player round resolved: %d battles, seats %v", len(v.Battles), fought)
}
+38 -13
View File
@@ -2,6 +2,7 @@ package server
import (
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"time"
@@ -10,19 +11,23 @@ import (
"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. 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.
// 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
name string
names []string
}{
"easy": {0.25, "Robo Rookie"},
"medium": {0.60, "Robo Rival"},
"hard": {1.00, "Robo Ace"},
"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)
@@ -36,17 +41,37 @@ func requireHost(g *game.Game, playerID string) error {
}
// 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.
// 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(bot.name, bot.level)
_, 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
+15 -6
View File
@@ -51,14 +51,23 @@ func (s *Server) Handler() http.Handler {
return mux
}
// handleCatalog returns every card in a pack (?pack=…, default Turtle), for the
// debug panel. Unknown packs fall back to the default.
// handleCatalog returns every card in the requested packs, for the debug panel
// and the event log's card previews. Packs come as a repeated or
// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back
// to the default pack's cards.
func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) {
pack := req.URL.Query().Get("pack")
if pack == "" {
pack = game.DefaultPack
var packs []string
for _, v := range req.URL.Query()["pack"] {
for _, id := range strings.Split(v, ",") {
if id = strings.TrimSpace(id); id != "" {
packs = append(packs, id)
}
}
}
writeJSON(w, game.CatalogForPack(pack))
if len(packs) == 0 {
packs = []string{game.DefaultPack}
}
writeJSON(w, game.CatalogForPacks(packs))
}
// room is one live game plus its connections.
+3 -3
View File
@@ -28,7 +28,7 @@ type clientMessage struct {
Pick int `json:"pick"` // tradeChoose
Order []string `json:"order"` // arrange
Name string `json:"name"` // debugAdd
Pack string `json:"pack"` // setPack
Packs []string `json:"packs"` // setPacks
Difficulty string `json:"difficulty"` // addBot
Target string `json:"target"` // removePlayer (player ID)
Card string `json:"card"` // revealChoose (Cockatoo): pet card id
@@ -123,9 +123,9 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
g := r.game
var err error
switch msg.Type {
case "setPack":
case "setPacks":
if err = requireHost(g, c.playerID); err == nil {
err = g.SetPack(msg.Pack)
err = g.SetPacks(msg.Packs)
}
case "addBot":
if err = requireHost(g, c.playerID); err == nil {