When the server's DEBUG env var is on, expose a card catalog endpoint and a collapsible client panel that drops any pet or food straight into your deck (free, off-turn). Gated server-side so it is inert in normal play.
126 lines
3.4 KiB
Go
126 lines
3.4 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
func readState(t *testing.T, ctx context.Context, ws *websocket.Conn) *game.View {
|
|
t.Helper()
|
|
for {
|
|
_, data, err := ws.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ws read: %v", err)
|
|
}
|
|
var m serverMessage
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if m.Type == "error" {
|
|
t.Logf("SERVER ERROR: %s", m.Error)
|
|
continue
|
|
}
|
|
if m.Type == "state" && m.State != nil {
|
|
return m.State
|
|
}
|
|
}
|
|
}
|
|
|
|
func send(t *testing.T, ctx context.Context, ws *websocket.Conn, v any) {
|
|
t.Helper()
|
|
data, _ := json.Marshal(v)
|
|
if err := ws.Write(ctx, websocket.MessageText, data); err != nil {
|
|
t.Fatalf("ws write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestE2EBattleAckReturnsToShop(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()
|
|
base := ts.URL
|
|
wsBase := "ws" + strings.TrimPrefix(base, "http")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Directly build a game via the room to control decks, then drive acks
|
|
// over the wire the way the two browsers do.
|
|
g := game.New()
|
|
p1, _ := g.AddPlayer("Alice")
|
|
p2, _ := g.AddPlayer("Bob")
|
|
r := &room{game: g, conns: map[*client]struct{}{}}
|
|
srv.rooms[g.ID] = r
|
|
if err := st.Save(g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dialWS := func(pid, token string) *websocket.Conn {
|
|
u := wsBase + "/api/ws?game=" + g.ID + "&player=" + pid + "&token=" + token
|
|
c, _, err := websocket.Dial(ctx, u, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial %s: %v", pid, err)
|
|
}
|
|
return c
|
|
}
|
|
ws1 := dialWS(p1.ID, p1.Token)
|
|
defer ws1.Close(websocket.StatusNormalClosure, "")
|
|
ws2 := dialWS(p2.ID, p2.Token)
|
|
defer ws2.Close(websocket.StatusNormalClosure, "")
|
|
|
|
// Force a battle under the lock.
|
|
r.mu.Lock()
|
|
g.Players[0].Deck = []game.Card{{ID: "c1", Kind: game.KindPet, Name: "A", Tier: 1, Power: 3, Suit: game.SuitRed}}
|
|
g.Players[1].Deck = []game.Card{{ID: "c2", Kind: game.KindPet, Name: "B", Tier: 1, Power: 1, Suit: game.SuitRed}}
|
|
g.Phase = game.PhaseArrange
|
|
g.Players[0].Ready = false
|
|
g.Players[1].Ready = false
|
|
g.SubmitOrder(p1.ID, []string{g.Players[0].Deck[0].ID})
|
|
g.SubmitOrder(p2.ID, []string{g.Players[1].Deck[0].ID})
|
|
r.broadcastLocked()
|
|
r.mu.Unlock()
|
|
|
|
readUntilPhase(t, ctx, ws1, game.PhaseBattle)
|
|
readUntilPhase(t, ctx, ws2, game.PhaseBattle)
|
|
|
|
// Both acknowledge.
|
|
send(t, ctx, ws1, map[string]string{"type": "ready"})
|
|
send(t, ctx, ws2, map[string]string{"type": "ready"})
|
|
|
|
// Each client should eventually observe the shop phase.
|
|
got1 := readUntilPhase(t, ctx, ws1, game.PhaseShop)
|
|
got2 := readUntilPhase(t, ctx, ws2, game.PhaseShop)
|
|
t.Logf("ws1 final phase=%s, ws2 final phase=%s", got1, got2)
|
|
}
|
|
|
|
func readUntilPhase(t *testing.T, ctx context.Context, ws *websocket.Conn, want game.Phase) game.Phase {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var last game.Phase
|
|
for time.Now().Before(deadline) {
|
|
rctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
v := readState(t, rctx, ws)
|
|
cancel()
|
|
last = v.Phase
|
|
if v.Phase == want {
|
|
return v.Phase
|
|
}
|
|
}
|
|
t.Fatalf("never reached %s; last=%s", want, last)
|
|
return last
|
|
}
|