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) }