209 lines
6.4 KiB
Go
209 lines
6.4 KiB
Go
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"}`))
|
|
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, "")
|
|
|
|
// The host opens in the lobby, adds a bot, then starts the game — the
|
|
// unified lobby flow a real client drives.
|
|
if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby {
|
|
t.Fatalf("phase = %s, want lobby on create", p)
|
|
}
|
|
send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": "easy"})
|
|
send(t, ctx, ws, map[string]any{"type": "start"})
|
|
|
|
var v *game.View
|
|
startDeadline := time.Now().Add(5 * time.Second)
|
|
for (v == nil || v.Phase != game.PhaseShop) && time.Now().Before(startDeadline) {
|
|
rctx, rcancel := context.WithTimeout(ctx, 3*time.Second)
|
|
v = readState(t, rctx, ws)
|
|
rcancel()
|
|
}
|
|
if v.Phase != game.PhaseShop {
|
|
t.Fatalf("phase = %s, want shop after host starts", 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 on our first turn (passing is final, so one
|
|
// is all we get). The game can only reach the arrange phase if the bot
|
|
// shops and passes on its own 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].Ready && 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|