mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
A finished room now stays alive to ask both players whether they want another, and restarts with a fresh opening word once both do. Only a room with two connected humans offers one: a bot has nothing to negotiate, so a bot room closes the moment its game ends rather than leaking a goroutine and an engine per finished game. turn_seq no longer restarts at one. A rematch reuses the same connections, so a submission still in flight from the previous game could otherwise match a turn in the new one and be applied to it. The end-of-game decision sits after the whole select, so every way a game can end reaches it. Opening the offer from the message arm alone meant the turn clock — the most common natural ending — closed the room with nothing to accept. A resume is refused into a finished game, including one waiting on a rematch answer, so the room now retires the connection being replaced only once it has agreed to the swap. Retiring it up front ended the game the client was trying to rejoin, which a duplicated tab was enough to trigger. attach releases the room it is leaving. Nothing else told that room the connection had gone, so a session asking for several rooms stranded all but the last, each parked in select holding a goroutine and a room code for the life of the process. Also: RequestRematch is rate limited, because it is the only client message that fans out to both players and an unbounded one lets a burst fill the opponent's outbox until their session is closed for falling behind. And a resume announces itself to the opponent, who was otherwise left watching a disconnect banner for someone already playing again.
1223 lines
40 KiB
Go
1223 lines
40 KiB
Go
package wsapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"iter"
|
||
"net/http/httptest"
|
||
"runtime"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/coder/websocket"
|
||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||
"google.golang.org/protobuf/proto"
|
||
)
|
||
|
||
// testDict is a hand-built word graph.
|
||
//
|
||
// A dozen words with edges a reader can follow beats a real dictionary here:
|
||
// when a test says the bot must lose, the reason is visible in the graph
|
||
// rather than buried in 48,000 entries.
|
||
type testDict struct {
|
||
// words maps a canonical word to its first and last syllable.
|
||
words map[string][2]string
|
||
opening string
|
||
}
|
||
|
||
func newTestDict(opening string, words ...string) *testDict {
|
||
d := &testDict{words: map[string][2]string{}, opening: opening}
|
||
for _, w := range append(words, opening) {
|
||
parts := strings.Fields(w)
|
||
d.words[w] = [2]string{parts[0], parts[len(parts)-1]}
|
||
}
|
||
return d
|
||
}
|
||
|
||
func (d *testDict) Resolve(word string) (string, bool) {
|
||
_, ok := d.words[word]
|
||
return word, ok
|
||
}
|
||
|
||
func (d *testDict) FirstSyllable(word string) (string, bool) {
|
||
e, ok := d.words[word]
|
||
return e[0], ok
|
||
}
|
||
|
||
func (d *testDict) LastSyllable(word string) (string, bool) {
|
||
e, ok := d.words[word]
|
||
return e[1], ok
|
||
}
|
||
|
||
func (d *testDict) WordsStartingWith(syllable string) iter.Seq[string] {
|
||
return func(yield func(string) bool) {
|
||
// Sorted so a failing test reproduces rather than depending on map
|
||
// iteration order.
|
||
for _, w := range slices.Sorted(maps(d.words)) {
|
||
if d.words[w][0] == syllable && !yield(w) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (d *testDict) OutDegree(syllable string) (int, error) {
|
||
n := 0
|
||
for range d.WordsStartingWith(syllable) {
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
func (d *testDict) RandomOpeningWord(int) (string, error) { return d.opening, nil }
|
||
|
||
func maps(m map[string][2]string) iter.Seq[string] {
|
||
return func(yield func(string) bool) {
|
||
for k := range m {
|
||
if !yield(k) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// chainDict builds a graph that is a single forced path, so the whole game is
|
||
// predictable: each word has exactly one legal continuation.
|
||
//
|
||
// a b -> b c -> c d -> d e (dead end)
|
||
func chainDict() *testDict {
|
||
return newTestDict("a b", "b c", "c d", "d e")
|
||
}
|
||
|
||
// --- test client -----------------------------------------------------------
|
||
|
||
type testClient struct {
|
||
t *testing.T
|
||
conn *websocket.Conn
|
||
ctx context.Context
|
||
}
|
||
|
||
func newTestServer(t *testing.T, dict Dictionary, cfg Config) (*Server, string) {
|
||
t.Helper()
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
t.Cleanup(cancel)
|
||
|
||
if cfg.TurnLimit == 0 {
|
||
cfg.TurnLimit = 2 * time.Second
|
||
}
|
||
if cfg.GraceFor == 0 {
|
||
cfg.GraceFor = time.Second
|
||
}
|
||
if cfg.RematchFor == 0 {
|
||
cfg.RematchFor = time.Second
|
||
}
|
||
|
||
api := NewServer(ctx, dict, cfg)
|
||
hs := httptest.NewServer(api)
|
||
t.Cleanup(func() {
|
||
api.Shutdown()
|
||
hs.Close()
|
||
})
|
||
return api, "ws" + strings.TrimPrefix(hs.URL, "http")
|
||
}
|
||
|
||
func dial(t *testing.T, url string) *testClient {
|
||
t.Helper()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
t.Cleanup(cancel)
|
||
|
||
conn, _, err := websocket.Dial(ctx, url+"/ws", nil)
|
||
if err != nil {
|
||
t.Fatalf("dial: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") })
|
||
|
||
return &testClient{t: t, conn: conn, ctx: ctx}
|
||
}
|
||
|
||
func (c *testClient) send(m *noituv1.ClientMessage) {
|
||
c.t.Helper()
|
||
raw, err := proto.Marshal(m)
|
||
if err != nil {
|
||
c.t.Fatalf("marshal: %v", err)
|
||
}
|
||
if err := c.conn.Write(c.ctx, websocket.MessageBinary, raw); err != nil {
|
||
c.t.Fatalf("write: %v", err)
|
||
}
|
||
}
|
||
|
||
func (c *testClient) recv() *noituv1.ServerMessage {
|
||
c.t.Helper()
|
||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||
defer cancel()
|
||
|
||
typ, raw, err := c.conn.Read(ctx)
|
||
if err != nil {
|
||
c.t.Fatalf("read: %v", err)
|
||
}
|
||
if typ != websocket.MessageBinary {
|
||
c.t.Fatalf("expected a binary frame, got %v", typ)
|
||
}
|
||
var m noituv1.ServerMessage
|
||
if err := proto.Unmarshal(raw, &m); err != nil {
|
||
c.t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
return &m
|
||
}
|
||
|
||
// await reads until a message of the wanted case arrives, so a test states the
|
||
// event it cares about rather than every frame that precedes it.
|
||
func (c *testClient) await(want string) *noituv1.ServerMessage {
|
||
c.t.Helper()
|
||
for range 20 {
|
||
m := c.recv()
|
||
if payloadCase(m) == want {
|
||
return m
|
||
}
|
||
}
|
||
c.t.Fatalf("never received a %q message", want)
|
||
return nil
|
||
}
|
||
|
||
func payloadCase(m *noituv1.ServerMessage) string {
|
||
switch m.GetPayload().(type) {
|
||
case *noituv1.ServerMessage_Welcome:
|
||
return "welcome"
|
||
case *noituv1.ServerMessage_RoomCreated:
|
||
return "room_created"
|
||
case *noituv1.ServerMessage_RoomJoined:
|
||
return "room_joined"
|
||
case *noituv1.ServerMessage_GameStarted:
|
||
return "game_started"
|
||
case *noituv1.ServerMessage_TurnUpdate:
|
||
return "turn_update"
|
||
case *noituv1.ServerMessage_MoveRejected:
|
||
return "move_rejected"
|
||
case *noituv1.ServerMessage_GameOver:
|
||
return "game_over"
|
||
case *noituv1.ServerMessage_OpponentLeft:
|
||
return "opponent_left"
|
||
case *noituv1.ServerMessage_Error:
|
||
return "error"
|
||
case *noituv1.ServerMessage_Pong:
|
||
return "pong"
|
||
case *noituv1.ServerMessage_RematchState:
|
||
return "rematch_state"
|
||
}
|
||
// Named rather than empty: a missing arm here makes every await for that
|
||
// message time out with nothing to say about why.
|
||
return fmt.Sprintf("unmapped(%T)", m.GetPayload())
|
||
}
|
||
|
||
func (c *testClient) hello(nickname string) *noituv1.Welcome {
|
||
c.t.Helper()
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||
ProtocolVersion: ProtocolVersion,
|
||
Nickname: nickname,
|
||
}}})
|
||
return c.await("welcome").GetWelcome()
|
||
}
|
||
|
||
func (c *testClient) submit(word string, seq uint32) {
|
||
c.t.Helper()
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SubmitWord{
|
||
SubmitWord: &noituv1.SubmitWord{Word: word, TurnSeq: seq},
|
||
}})
|
||
}
|
||
|
||
// --- tests -----------------------------------------------------------------
|
||
|
||
// TestBotGamePlaysToCompletion walks a whole vs-bot game with no frontend
|
||
// involved. The graph is a forced chain, so the ending is not a matter of
|
||
// which word the bot happens to pick.
|
||
func TestBotGamePlaysToCompletion(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Ăn")
|
||
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
|
||
started := c.await("game_started").GetGameStarted()
|
||
if started.GetOpeningWord() != "a b" {
|
||
t.Fatalf("opening = %q, want %q", started.GetOpeningWord(), "a b")
|
||
}
|
||
if !started.GetMyTurn() {
|
||
t.Fatal("human should move first in a bot game")
|
||
}
|
||
if started.GetCurrentSyllable() != "b" {
|
||
t.Fatalf("current syllable = %q, want b", started.GetCurrentSyllable())
|
||
}
|
||
|
||
// "b c" is the only legal reply. The bot then has only "c d", which leaves
|
||
// the human "d e", after which the bot has nothing and loses.
|
||
c.submit("b c", started.GetTurnSeq())
|
||
|
||
// The player's own move comes back first — every accepted move is fanned
|
||
// out to both seats — so the bot's reply is the second update.
|
||
if own := c.await("turn_update").GetTurnUpdate(); !own.GetPlayed().GetByMe() {
|
||
t.Fatal("first update after submitting should be the player's own move")
|
||
}
|
||
botReply := c.await("turn_update").GetTurnUpdate()
|
||
if botReply.GetPlayed().GetByMe() {
|
||
t.Fatal("expected the bot's move, not another echo")
|
||
}
|
||
if !botReply.GetMyTurn() {
|
||
t.Fatal("human should be on turn after the bot replies")
|
||
}
|
||
c.submit("d e", botReply.GetTurnSeq())
|
||
|
||
over := c.await("game_over").GetGameOver()
|
||
if !over.GetIWon() {
|
||
t.Errorf("human should win when the bot runs out of words, got %+v", over)
|
||
}
|
||
}
|
||
|
||
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
|
||
// that each sees the other's move rendered from its own side.
|
||
func TestPvPGameAlternatesTurns(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
|
||
host := dial(t, url)
|
||
host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
|
||
hostStart := host.await("game_started").GetGameStarted()
|
||
guestStart := guest.await("game_started").GetGameStarted()
|
||
|
||
if hostStart.GetMyTurn() == guestStart.GetMyTurn() {
|
||
t.Fatal("exactly one player must be on turn")
|
||
}
|
||
if !hostStart.GetMyTurn() {
|
||
t.Fatal("the room creator takes the first turn")
|
||
}
|
||
|
||
host.submit("b c", hostStart.GetTurnSeq())
|
||
|
||
// The same move, rendered per recipient: by_me flips, my_turn flips.
|
||
hostUpdate := host.await("turn_update").GetTurnUpdate()
|
||
guestUpdate := guest.await("turn_update").GetTurnUpdate()
|
||
|
||
if !hostUpdate.GetPlayed().GetByMe() {
|
||
t.Error("mover should see by_me = true")
|
||
}
|
||
if guestUpdate.GetPlayed().GetByMe() {
|
||
t.Error("opponent should see by_me = false")
|
||
}
|
||
if hostUpdate.GetMyTurn() {
|
||
t.Error("mover should not be on turn after moving")
|
||
}
|
||
if !guestUpdate.GetMyTurn() {
|
||
t.Error("opponent should now be on turn")
|
||
}
|
||
if hostUpdate.GetMyScore() != guestUpdate.GetOpponentScore() {
|
||
t.Errorf("scores disagree across recipients: %d vs %d",
|
||
hostUpdate.GetMyScore(), guestUpdate.GetOpponentScore())
|
||
}
|
||
}
|
||
|
||
// TestTurnTimeoutEndsGameServerSide proves the clock is the server's. The
|
||
// client sends nothing at all after the game starts.
|
||
func TestTurnTimeoutEndsGameServerSide(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 250 * time.Millisecond})
|
||
|
||
host := dial(t, url)
|
||
host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
|
||
host.await("game_started")
|
||
guest.await("game_started")
|
||
|
||
over := guest.await("game_over").GetGameOver()
|
||
if !over.GetIWon() {
|
||
t.Error("the player who did not time out should win")
|
||
}
|
||
if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_TIMEOUT {
|
||
t.Errorf("reason = %v, want TIMEOUT", over.GetReason())
|
||
}
|
||
}
|
||
|
||
// TestRejectionsCarryTheRightReason checks each rejection a player can
|
||
// actually provoke reaches the client as a distinct enum, since the UI copy
|
||
// keys off exactly this value.
|
||
func TestRejectionsCarryTheRightReason(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
word string
|
||
want noituv1.RejectReason
|
||
}{
|
||
{"unknown word", "khong co", noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY},
|
||
{"wrong link", "c d", noituv1.RejectReason_REJECT_REASON_WRONG_LINK},
|
||
{"single syllable", "b", noituv1.RejectReason_REJECT_REASON_TOO_FEW_SYLLABLES},
|
||
}
|
||
|
||
for _, tc := range tests {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
started := c.await("game_started").GetGameStarted()
|
||
|
||
c.submit(tc.word, started.GetTurnSeq())
|
||
|
||
got := c.await("move_rejected").GetMoveRejected()
|
||
if got.GetReason() != tc.want {
|
||
t.Errorf("reason = %v, want %v", got.GetReason(), tc.want)
|
||
}
|
||
if got.GetWord() != tc.word {
|
||
t.Errorf("rejection echoed %q, want %q", got.GetWord(), tc.word)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestReplayingAWordIsRejected needs its own graph: the engine checks the link
|
||
// before reuse, so replaying the opening word on the very first turn reports
|
||
// WRONG_LINK. Only a word that links correctly *and* has been played can
|
||
// surface ALREADY_USED, which takes a cycle in the graph and a move to reach.
|
||
func TestReplayingAWordIsRejected(t *testing.T) {
|
||
// a b -> b a -> back to a word starting with "a", which is the opening.
|
||
dict := newTestDict("a b", "b a", "a c", "c a")
|
||
_, url := newTestServer(t, dict, Config{TurnLimit: 10 * time.Second})
|
||
|
||
host := dial(t, url)
|
||
host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
hostStart := host.await("game_started").GetGameStarted()
|
||
guest.await("game_started")
|
||
|
||
// Opening "a b" is used and current syllable is "b". Play "b a" so the
|
||
// syllable returns to "a", where the opening word now links legally.
|
||
host.submit("b a", hostStart.GetTurnSeq())
|
||
guestTurn := guest.await("turn_update").GetTurnUpdate()
|
||
|
||
guest.submit("a b", guestTurn.GetTurnSeq())
|
||
|
||
got := guest.await("move_rejected").GetMoveRejected()
|
||
if got.GetReason() != noituv1.RejectReason_REJECT_REASON_ALREADY_USED {
|
||
t.Errorf("reason = %v, want ALREADY_USED", got.GetReason())
|
||
}
|
||
}
|
||
|
||
// TestStaleTurnSeqIsRejected covers the double-submit guard: a word stamped
|
||
// with a turn that has already passed must not be applied to the current one.
|
||
func TestStaleTurnSeqIsRejected(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
started := c.await("game_started").GetGameStarted()
|
||
|
||
c.submit("b c", started.GetTurnSeq()+99)
|
||
|
||
got := c.await("move_rejected").GetMoveRejected()
|
||
if got.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN {
|
||
t.Errorf("reason = %v, want NOT_YOUR_TURN", got.GetReason())
|
||
}
|
||
}
|
||
|
||
// TestResumeWithinGraceRestoresGame drops a connection mid-game and brings it
|
||
// back with the resume token.
|
||
func TestResumeWithinGraceRestoresGame(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 5 * time.Second})
|
||
|
||
host := dial(t, url)
|
||
welcome := host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
host.await("game_started")
|
||
guest.await("game_started")
|
||
|
||
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
||
|
||
if left := guest.await("opponent_left").GetOpponentLeft(); !left.GetCanReconnect() {
|
||
t.Error("opponent should be told the seat is being held")
|
||
}
|
||
|
||
// Reconnect with the token and expect the position back.
|
||
back := dial(t, url)
|
||
back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||
ProtocolVersion: ProtocolVersion,
|
||
Nickname: "Chủ phòng",
|
||
ResumeToken: welcome.GetResumeToken(),
|
||
}}})
|
||
back.await("welcome")
|
||
|
||
restored := back.await("game_started").GetGameStarted()
|
||
if restored.GetOpeningWord() != "a b" {
|
||
t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord())
|
||
}
|
||
if !restored.GetMyTurn() {
|
||
t.Error("the resumed player was on turn and should still be")
|
||
}
|
||
}
|
||
|
||
// TestGraceExpiryAwardsTheGame is the other half: nobody comes back.
|
||
func TestGraceExpiryAwardsTheGame(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second, GraceFor: 200 * time.Millisecond})
|
||
|
||
host := dial(t, url)
|
||
host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
host.await("game_started")
|
||
guest.await("game_started")
|
||
|
||
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
||
|
||
over := guest.await("game_over").GetGameOver()
|
||
if !over.GetIWon() {
|
||
t.Error("the player who stayed should win")
|
||
}
|
||
if over.GetReason() != noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT {
|
||
t.Errorf("reason = %v, want OPPONENT_LEFT", over.GetReason())
|
||
}
|
||
}
|
||
|
||
// TestUnknownRoomCodeIsRefused checks a join against a code nobody holds.
|
||
func TestUnknownRoomCodeIsRefused(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Khách")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: "ZZZZZZ"},
|
||
}})
|
||
|
||
if code := c.await("error").GetError().GetCode(); code != "room_not_found" {
|
||
t.Errorf("error code = %q, want room_not_found", code)
|
||
}
|
||
}
|
||
|
||
// TestProtocolVersionMismatchIsRefused proves an incompatible client is told
|
||
// so rather than left to misread frames.
|
||
func TestProtocolVersionMismatchIsRefused(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||
ProtocolVersion: ProtocolVersion + 1,
|
||
Nickname: "Cũ",
|
||
}}})
|
||
|
||
if code := c.await("error").GetError().GetCode(); code != "protocol_version_mismatch" {
|
||
t.Errorf("error code = %q, want protocol_version_mismatch", code)
|
||
}
|
||
}
|
||
|
||
// TestHandshakeIsRequiredFirst rejects a client that skips Hello.
|
||
func TestHandshakeIsRequiredFirst(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
|
||
if code := c.await("error").GetError().GetCode(); code != "handshake_required" {
|
||
t.Errorf("error code = %q, want handshake_required", code)
|
||
}
|
||
}
|
||
|
||
// TestOversizeFrameClosesConnection covers the read limit. The frame is built
|
||
// past maxFrameBytes, so the socket must close rather than the decoder be
|
||
// asked to parse it.
|
||
func TestOversizeFrameClosesConnection(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
|
||
c.submit(strings.Repeat("x", maxFrameBytes+1), 1)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
for {
|
||
_, _, err := c.conn.Read(ctx)
|
||
if err == nil {
|
||
continue
|
||
}
|
||
// The status matters, not merely that the socket closed: any unrelated
|
||
// failure would otherwise let this pass while the read limit did
|
||
// nothing.
|
||
if got := websocket.CloseStatus(err); got != websocket.StatusMessageTooBig {
|
||
t.Errorf("close status = %v, want StatusMessageTooBig", got)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// TestSubmitRateLimit checks the token bucket refuses a burst well past what a
|
||
// person types, since each submit costs a dictionary lookup.
|
||
func TestSubmitRateLimit(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
started := c.await("game_started").GetGameStarted()
|
||
|
||
for range submitBurst + 5 {
|
||
c.submit("khong co", started.GetTurnSeq())
|
||
}
|
||
|
||
for range 40 {
|
||
m := c.recv()
|
||
if payloadCase(m) == "error" && m.GetError().GetCode() == "too_fast" {
|
||
return
|
||
}
|
||
}
|
||
t.Error("never rate-limited despite submitting past the burst")
|
||
}
|
||
|
||
// TestOriginIsChecked confirms a cross-origin handshake is refused when the
|
||
// allowlist does not include it.
|
||
func TestOriginIsChecked(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{AllowedOrigins: []string{"example.com"}})
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
_, _, err := websocket.Dial(ctx, url+"/ws", &websocket.DialOptions{
|
||
HTTPHeader: map[string][]string{"Origin": {"http://evil.example"}},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("a disallowed origin completed the handshake")
|
||
}
|
||
}
|
||
|
||
// TestRoomIsEvictedWhenGameEnds guards against rooms accumulating: a finished
|
||
// game must leave the registry so its code is reusable.
|
||
func TestRoomIsEvictedWhenGameEnds(t *testing.T) {
|
||
api, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
started := c.await("game_started").GetGameStarted()
|
||
c.submit("b c", started.GetTurnSeq())
|
||
c.await("game_over")
|
||
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if api.hub.roomCount() == 0 {
|
||
return
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
t.Errorf("room still registered after the game ended: %d live", api.hub.roomCount())
|
||
}
|
||
|
||
// TestPingIsAnswered covers the clock-offset path the client uses to render a
|
||
// countdown against the server's absolute deadline.
|
||
func TestPingIsAnswered(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Ping{
|
||
Ping: &noituv1.Ping{ClientTimeMs: 1234},
|
||
}})
|
||
|
||
pong := c.await("pong").GetPong()
|
||
if pong.GetClientTimeMs() != 1234 {
|
||
t.Errorf("pong echoed %d, want 1234", pong.GetClientTimeMs())
|
||
}
|
||
if pong.GetServerTimeMs() == 0 {
|
||
t.Error("pong carried no server clock")
|
||
}
|
||
}
|
||
|
||
// TestTextFrameIsRejected keeps the protocol binary-only: guessing at another
|
||
// encoding is how a parser becomes an attack surface.
|
||
func TestTextFrameIsRejected(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
c := dial(t, url)
|
||
|
||
if err := c.conn.Write(c.ctx, websocket.MessageText, []byte("hello")); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
for {
|
||
if _, _, err := c.conn.Read(ctx); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDecodeRejectsTextFrames(t *testing.T) {
|
||
if _, err := Decode(websocket.MessageText, nil); !errors.Is(err, ErrNotBinary) {
|
||
t.Errorf("Decode(text) error = %v, want ErrNotBinary", err)
|
||
}
|
||
}
|
||
|
||
func TestHealthz(t *testing.T) {
|
||
api, _ := newTestServer(t, chainDict(), Config{})
|
||
req := httptest.NewRequest("GET", "/healthz", nil)
|
||
rec := httptest.NewRecorder()
|
||
api.ServeHTTP(rec, req)
|
||
|
||
if rec.Code != 200 {
|
||
t.Errorf("healthz returned %d, want 200", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestRoomCodeAlphabetAvoidsLookalikes(t *testing.T) {
|
||
for _, bad := range []rune{'0', 'O', '1', 'I', 'L'} {
|
||
if strings.ContainsRune(roomCodeAlphabet, bad) {
|
||
t.Errorf("alphabet contains the easily-confused %q", bad)
|
||
}
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
for range 1000 {
|
||
code := randomCode()
|
||
if len(code) != roomCodeLen {
|
||
t.Fatalf("code %q is %d chars, want %d", code, len(code), roomCodeLen)
|
||
}
|
||
for _, r := range code {
|
||
if !strings.ContainsRune(roomCodeAlphabet, r) {
|
||
t.Fatalf("code %q contains %q, outside the alphabet", code, r)
|
||
}
|
||
}
|
||
seen[code] = true
|
||
}
|
||
// Not a randomness test — just proof the generator is not returning a
|
||
// constant, which a broken modulo could.
|
||
if len(seen) < 900 {
|
||
t.Errorf("only %d distinct codes in 1000 draws", len(seen))
|
||
}
|
||
}
|
||
|
||
func TestFreezeBoardIncludesTheOpeningWord(t *testing.T) {
|
||
// The engine counts the opening word as played but Snapshot's history does
|
||
// not list it. A frozen board that missed it would let the bot choose a
|
||
// word the engine then rejects as already used — the two would disagree
|
||
// about the position while appearing to share one dictionary.
|
||
dict := chainDict()
|
||
e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, "a b", time.Second, time.Now())
|
||
if err != nil {
|
||
t.Fatalf("engine: %v", err)
|
||
}
|
||
|
||
board := freezeBoard(e, "a b")
|
||
if !board.Used("a b") {
|
||
t.Error("frozen board does not consider the opening word played")
|
||
}
|
||
if !slices.Contains(board.LegalMoves(), "b c") {
|
||
t.Errorf("legal moves = %v, want the one continuation", board.LegalMoves())
|
||
}
|
||
}
|
||
|
||
func TestSanitizeNickname(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{"plain", "Minh", "Minh"},
|
||
{"vietnamese kept", "Nguyễn Thuý", "Nguyễn Thuý"},
|
||
{"empty falls back", "", defaultNickname},
|
||
{"whitespace only falls back", " \t\n ", defaultNickname},
|
||
{"control characters stripped", "Mi\x00nh\x07", "Minh"},
|
||
{"zero width stripped", "Minh", "Minh"},
|
||
{"bidi override stripped", "Minh", "Minh"},
|
||
{"whitespace collapsed", " Minh Nguyen ", "Minh Nguyen"},
|
||
{"newlines become spaces", "Minh\nNguyen", "Minh Nguyen"},
|
||
{"over length truncated", strings.Repeat("a", 40), strings.Repeat("a", maxNicknameRunes)},
|
||
}
|
||
|
||
for _, tc := range tests {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if got := sanitizeNickname(tc.in); got != tc.want {
|
||
t.Errorf("sanitizeNickname(%q) = %q, want %q", tc.in, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSanitizeNicknameCountsRunesNotBytes guards the cap against being applied
|
||
// in bytes, which would cut a Vietnamese name to a third of the length a Latin
|
||
// one keeps.
|
||
func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) {
|
||
in := strings.Repeat("ữ", maxNicknameRunes)
|
||
got := sanitizeNickname(in)
|
||
if n := len([]rune(got)); n != maxNicknameRunes {
|
||
t.Errorf("kept %d runes, want %d (byte-based truncation?)", n, maxNicknameRunes)
|
||
}
|
||
}
|
||
|
||
// TestDistinguishSeparatesIdenticalNames covers the collision the fallback
|
||
// creates — two players who both send nothing — and the one it always could:
|
||
// two players who choose the same name.
|
||
func TestDistinguishSeparatesIdenticalNames(t *testing.T) {
|
||
if got := distinguish(defaultNickname, defaultNickname); got == defaultNickname {
|
||
t.Error("two unnamed players must not render identically")
|
||
}
|
||
if got := distinguish("Minh", "Thuý"); got != "Minh" {
|
||
t.Errorf("distinct names should be left alone, got %q", got)
|
||
}
|
||
|
||
// The suffix must not push the name past the cap.
|
||
long := strings.Repeat("a", maxNicknameRunes)
|
||
if got := distinguish(long, long); len([]rune(got)) > maxNicknameRunes {
|
||
t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes)
|
||
}
|
||
}
|
||
|
||
func TestBucketRefills(t *testing.T) {
|
||
now := time.Now()
|
||
b := newBucket(5, 2, now)
|
||
|
||
if !b.allow(now) || !b.allow(now) {
|
||
t.Fatal("burst should cover the first two")
|
||
}
|
||
if b.allow(now) {
|
||
t.Fatal("third call should exhaust the bucket")
|
||
}
|
||
|
||
// 5/sec means one token back after 200ms.
|
||
if !b.allow(now.Add(220 * time.Millisecond)) {
|
||
t.Error("bucket did not refill")
|
||
}
|
||
}
|
||
|
||
func TestKeyedLimiterIsPerKey(t *testing.T) {
|
||
now := time.Now()
|
||
l := newKeyedLimiter(1, 1, time.Minute)
|
||
|
||
if !l.allow("a", now) {
|
||
t.Fatal("first call for a key should pass")
|
||
}
|
||
if l.allow("a", now) {
|
||
t.Fatal("second call for the same key should be refused")
|
||
}
|
||
if !l.allow("b", now) {
|
||
t.Error("a different key must have its own bucket")
|
||
}
|
||
}
|
||
|
||
func TestKeyedLimiterSweepsIdleBuckets(t *testing.T) {
|
||
now := time.Now()
|
||
l := newKeyedLimiter(10, 5, time.Minute)
|
||
l.allow("gone", now)
|
||
|
||
l.sweep(now.Add(2 * time.Minute))
|
||
|
||
l.mu.Lock()
|
||
n := len(l.buckets)
|
||
l.mu.Unlock()
|
||
if n != 0 {
|
||
t.Errorf("%d buckets survived the sweep, want 0", n)
|
||
}
|
||
}
|
||
|
||
func TestErrorMessagesAreUIKeysNotProse(t *testing.T) {
|
||
// Error copy lives in the frontend. A server that sent prose would put
|
||
// Vietnamese strings in two places, and an internal error string would
|
||
// leak server detail to anyone holding a socket.
|
||
m := errorMsg("room_not_found").GetError()
|
||
if strings.ContainsAny(m.GetCode(), " .") {
|
||
t.Errorf("error code %q looks like prose", m.GetCode())
|
||
}
|
||
if m.GetMessage() != m.GetCode() {
|
||
t.Errorf("message %q diverged from code %q", m.GetMessage(), m.GetCode())
|
||
}
|
||
}
|
||
|
||
// Fail the build, not a test, if the helper drifts from what the server needs.
|
||
var _ Dictionary = (*testDict)(nil)
|
||
|
||
// TestGoroutinesReturnToBaseline guards the leak the risk table names: a bot
|
||
// worker, a room, or a session that outlives its game costs a goroutine per
|
||
// abandoned match, which only shows up under sustained play.
|
||
func TestGoroutinesReturnToBaseline(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 5 * time.Second})
|
||
|
||
// Warm up first: the HTTP server and the dialer start pools of their own,
|
||
// and counting those as a leak would make this test lie.
|
||
playOneBotGame(t, url)
|
||
settle()
|
||
baseline := runtime.NumGoroutine()
|
||
|
||
for range 10 {
|
||
playOneBotGame(t, url)
|
||
}
|
||
settle()
|
||
|
||
// A little slack for the transport's own bookkeeping; a real leak here is
|
||
// one goroutine per game, so ten games would show ten or more.
|
||
if got := runtime.NumGoroutine(); got > baseline+5 {
|
||
t.Errorf("goroutines grew from %d to %d over 10 games", baseline, got)
|
||
}
|
||
}
|
||
|
||
func playOneBotGame(t *testing.T, url string) {
|
||
t.Helper()
|
||
c := dial(t, url)
|
||
c.hello("Người thử")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
started := c.await("game_started").GetGameStarted()
|
||
c.submit("b c", started.GetTurnSeq())
|
||
c.await("turn_update")
|
||
botReply := c.await("turn_update").GetTurnUpdate()
|
||
c.submit("d e", botReply.GetTurnSeq())
|
||
c.await("game_over")
|
||
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
||
}
|
||
|
||
// settle waits for teardown to finish. Rooms and sessions end asynchronously,
|
||
// so sampling immediately would count goroutines that are already on their way
|
||
// out.
|
||
func settle() {
|
||
for range 20 {
|
||
runtime.Gosched()
|
||
time.Sleep(25 * time.Millisecond)
|
||
}
|
||
runtime.GC()
|
||
}
|
||
|
||
// --- rematch ---------------------------------------------------------------
|
||
|
||
// pvpRoom seats two players and returns them with the opening position, so a
|
||
// rematch test can get to a finished game without restating the setup.
|
||
func pvpRoom(t *testing.T, url string) (host, guest *testClient, start *noituv1.GameStarted) {
|
||
t.Helper()
|
||
|
||
host = dial(t, url)
|
||
host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest = dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
|
||
start = host.await("game_started").GetGameStarted()
|
||
guest.await("game_started")
|
||
return host, guest, start
|
||
}
|
||
|
||
func (c *testClient) requestRematch() {
|
||
c.t.Helper()
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_RequestRematch{
|
||
RequestRematch: &noituv1.RequestRematch{},
|
||
}})
|
||
}
|
||
|
||
// resignAndSettle ends the game and drains the offer that follows, returning
|
||
// the rematch state each player was shown.
|
||
func resignAndSettle(t *testing.T, host, guest *testClient) (hostState, guestState *noituv1.RematchState) {
|
||
t.Helper()
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||
host.await("game_over")
|
||
guest.await("game_over")
|
||
return host.await("rematch_state").GetRematchState(),
|
||
guest.await("rematch_state").GetRematchState()
|
||
}
|
||
|
||
// TestRematchOfferFollowsAFinishedPvPGame checks the room outlives its game.
|
||
// Before rematch existed the room goroutine returned the moment the engine was
|
||
// over, so there was nothing left to ask.
|
||
func TestRematchOfferFollowsAFinishedPvPGame(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
|
||
hostState, guestState := resignAndSettle(t, host, guest)
|
||
|
||
for name, s := range map[string]*noituv1.RematchState{"host": hostState, "guest": guestState} {
|
||
if s.GetIAccepted() || s.GetOpponentAccepted() {
|
||
t.Errorf("%s: the opening offer should have nobody accepted yet, got %+v", name, s)
|
||
}
|
||
if s.GetExpiresInMs() == 0 {
|
||
t.Errorf("%s: the offer must carry the time left to answer", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRematchStateIsRenderedPerRecipient is the guard against the two booleans
|
||
// being swapped, which would show a player their opponent's answer as theirs.
|
||
func TestRematchStateIsRenderedPerRecipient(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
resignAndSettle(t, host, guest)
|
||
|
||
host.requestRematch()
|
||
|
||
hostState := host.await("rematch_state").GetRematchState()
|
||
guestState := guest.await("rematch_state").GetRematchState()
|
||
|
||
if !hostState.GetIAccepted() || hostState.GetOpponentAccepted() {
|
||
t.Errorf("the asker should see only their own acceptance, got %+v", hostState)
|
||
}
|
||
if guestState.GetIAccepted() || !guestState.GetOpponentAccepted() {
|
||
t.Errorf("the other player should see only the opponent's acceptance, got %+v", guestState)
|
||
}
|
||
}
|
||
|
||
// TestRematchStartsANewGameWhenBothAccept covers the whole point of the
|
||
// feature, and the two properties a fresh game has to have.
|
||
func TestRematchStartsANewGameWhenBothAccept(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, guest, first := pvpRoom(t, url)
|
||
resignAndSettle(t, host, guest)
|
||
|
||
host.requestRematch()
|
||
host.await("rematch_state")
|
||
guest.requestRematch()
|
||
|
||
second := host.await("game_started").GetGameStarted()
|
||
guest.await("game_started")
|
||
|
||
if second.GetTurnSeq() <= first.GetTurnSeq() {
|
||
t.Errorf("turn_seq must keep rising across a rematch: %d then %d, so a submission "+
|
||
"still in flight from the first game could be applied to the second",
|
||
first.GetTurnSeq(), second.GetTurnSeq())
|
||
}
|
||
if second.GetOpeningWord() == "" {
|
||
t.Error("a rematch needs its own opening word")
|
||
}
|
||
}
|
||
|
||
// TestRematchIsRefusedWhileTheGameIsLive keeps the offer from being a way to
|
||
// abandon a game in progress.
|
||
func TestRematchIsRefusedWhileTheGameIsLive(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, _, _ := pvpRoom(t, url)
|
||
|
||
host.requestRematch()
|
||
|
||
if got := host.await("error").GetError().GetCode(); got != "no_rematch_offered" {
|
||
t.Errorf("asking mid-game returned %q, want no_rematch_offered", got)
|
||
}
|
||
}
|
||
|
||
// TestLeavingDeclinesTheRematch: there is no decline message, so the socket
|
||
// closing has to be the one, and the other player must be told rather than
|
||
// left watching a countdown that cannot resolve.
|
||
func TestLeavingDeclinesTheRematch(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
resignAndSettle(t, host, guest)
|
||
|
||
_ = guest.conn.Close(websocket.StatusNormalClosure, "")
|
||
|
||
left := host.await("opponent_left").GetOpponentLeft()
|
||
if left.GetCanReconnect() {
|
||
t.Error("a player who left during the rematch offer is not coming back")
|
||
}
|
||
}
|
||
|
||
// TestBotRoomDoesNotOfferARematch: a bot has nothing to negotiate, and the
|
||
// client simply asks for another game. Keeping the room alive would leave one
|
||
// goroutine and one engine per finished bot game.
|
||
func TestBotRoomDoesNotOfferARematch(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
|
||
c := dial(t, url)
|
||
c.hello("Người chơi")
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||
}})
|
||
c.await("game_started")
|
||
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||
c.await("game_over")
|
||
|
||
// game_already_over is the room reporting that it has stopped reading,
|
||
// which is the evidence wanted here: the goroutine and engine are gone
|
||
// rather than parked waiting for an answer no bot can give.
|
||
c.requestRematch()
|
||
if got := c.await("error").GetError().GetCode(); got != "game_already_over" {
|
||
t.Errorf("a finished bot room answered %q, want it to be gone", got)
|
||
}
|
||
}
|
||
|
||
// TestRematchOfferExpires bounds how long a room outlives its game.
|
||
func TestRematchOfferExpires(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{RematchFor: 150 * time.Millisecond})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
resignAndSettle(t, host, guest)
|
||
|
||
// Only one side asks, so the offer can only end by running out.
|
||
host.requestRematch()
|
||
host.await("rematch_state")
|
||
|
||
if got := host.await("opponent_left").GetOpponentLeft(); got.GetCanReconnect() {
|
||
t.Error("an expired offer is final, not a reconnect window")
|
||
}
|
||
}
|
||
|
||
// TestRematchIsOfferedAfterATimeout is the regression for a rematch that could
|
||
// only follow some endings. The offer was opened from the message arm of the
|
||
// room loop, so the turn clock — the most common way a game actually ends —
|
||
// closed the room with nothing to accept, while the client still showed the
|
||
// button.
|
||
func TestRematchIsOfferedAfterATimeout(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
|
||
// Neither player moves, so the only thing that can end this is the clock.
|
||
host.await("game_over")
|
||
guest.await("game_over")
|
||
|
||
if got := host.await("rematch_state").GetRematchState(); got.GetExpiresInMs() == 0 {
|
||
t.Error("a game that ended on the clock should still offer a rematch")
|
||
}
|
||
guest.await("rematch_state")
|
||
|
||
host.requestRematch()
|
||
host.await("rematch_state")
|
||
guest.requestRematch()
|
||
|
||
host.await("game_started")
|
||
guest.await("game_started")
|
||
}
|
||
|
||
// TestRefusedResumeLeavesTheLiveGameAlone is the regression for a resume that
|
||
// retired the connection it was replacing before the room had agreed to the
|
||
// swap. During a rematch offer the room refuses, so a second connection
|
||
// presenting the same token used to disconnect the player who was still there
|
||
// and take the room down with them.
|
||
func TestRefusedResumeLeavesTheLiveGameAlone(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
|
||
host := dial(t, url)
|
||
welcome := host.hello("Chủ phòng")
|
||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||
|
||
guest := dial(t, url)
|
||
guest.hello("Khách")
|
||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||
}})
|
||
host.await("game_started")
|
||
guest.await("game_started")
|
||
|
||
resignAndSettle(t, host, guest)
|
||
|
||
// A duplicated tab carries the same token and tries to reclaim the seat.
|
||
second := dial(t, url)
|
||
second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||
ProtocolVersion: ProtocolVersion,
|
||
Nickname: "Chủ phòng",
|
||
ResumeToken: welcome.GetResumeToken(),
|
||
}}})
|
||
second.await("welcome")
|
||
|
||
if got := second.await("error").GetError().GetCode(); got != "game_already_over" {
|
||
t.Errorf("resume into a finished game returned %q", got)
|
||
}
|
||
|
||
// The original connection is untouched: the offer it is holding still
|
||
// works, which it would not if the room had closed underneath it.
|
||
host.requestRematch()
|
||
if got := host.await("rematch_state"); !got.GetRematchState().GetIAccepted() {
|
||
t.Error("the player who never left should still be able to accept the rematch")
|
||
}
|
||
guest.requestRematch()
|
||
host.await("game_started")
|
||
}
|
||
|
||
// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived
|
||
// the only connection that could ever end them. attach overwrote the session's
|
||
// room pointer and nothing told the old room, so it parked in select forever
|
||
// holding a goroutine and a room code.
|
||
func TestOneConnectionCannotStrandRooms(t *testing.T) {
|
||
api, url := newTestServer(t, chainDict(), Config{})
|
||
|
||
c := dial(t, url)
|
||
c.hello("Người chơi")
|
||
|
||
const rooms = 4
|
||
for range rooms {
|
||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||
c.await("room_created")
|
||
}
|
||
|
||
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
||
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for {
|
||
api.hub.mu.Lock()
|
||
left := len(api.hub.rooms)
|
||
api.hub.mu.Unlock()
|
||
|
||
if left == 0 {
|
||
return
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("%d of %d rooms outlived the only connection that was ever in them", left, rooms)
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// TestRematchRequestsAreRateLimited: every accepted request is broadcast to
|
||
// both seats, so an unbounded one lets a player fill the opponent's outbox
|
||
// until the server closes their session for falling behind.
|
||
func TestRematchRequestsAreRateLimited(t *testing.T) {
|
||
_, url := newTestServer(t, chainDict(), Config{})
|
||
host, guest, _ := pvpRoom(t, url)
|
||
resignAndSettle(t, host, guest)
|
||
|
||
for range submitBurst + 5 {
|
||
host.requestRematch()
|
||
}
|
||
|
||
// The limiter answers before the room does, so a refusal has to appear in
|
||
// the stream rather than an unbroken run of rematch states.
|
||
for range 30 {
|
||
if payloadCase(host.recv()) == "error" {
|
||
return
|
||
}
|
||
}
|
||
t.Error("a burst of rematch requests was never refused")
|
||
}
|