mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-06 18:16:38 +00:00
The hub keeps a FIFO of waiting sessions; a second QuickMatch pops the first, opens a room via the existing createInput/joinInput path, and marks it to begin its own first game once both seats are connected in the lobby. CancelQuickMatch, a dropped connection, and entering a room by code all remove a session from the queue. Counts queued, cancelled and matched pairings in metrics.
2309 lines
77 KiB
Go
2309 lines
77 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"iter"
|
|
"math/rand/v2"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/coder/websocket"
|
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
|
"github.com/tiennm99dev/noitu/server/internal/bot"
|
|
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
|
"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 tens of thousands of entries.
|
|
type testDict struct {
|
|
// words maps a canonical word to its first and last syllable.
|
|
words map[string][2]string
|
|
opening string
|
|
// senses holds the meanings of the few words a test gives one to; every
|
|
// other word has none, which is also a case the client must handle.
|
|
senses map[string][]dictionary.Sense
|
|
}
|
|
|
|
func newTestDict(opening string, words ...string) *testDict {
|
|
d := &testDict{words: map[string][2]string{}, opening: opening, senses: map[string][]dictionary.Sense{}}
|
|
for _, w := range append(words, opening) {
|
|
parts := strings.Fields(w)
|
|
d.words[w] = [2]string{parts[0], parts[len(parts)-1]}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// define gives a word one sense, so a test can see it arrive on the wire.
|
|
func (d *testDict) define(word, pos, gloss string) *testDict {
|
|
d.senses[word] = append(d.senses[word], dictionary.Sense{Pos: pos, Gloss: gloss})
|
|
return d
|
|
}
|
|
|
|
func (d *testDict) Meanings(word string) []dictionary.Sense { return d.senses[word] }
|
|
|
|
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
|
|
}
|
|
// Long enough that no test loses its lobby to the idle clock. The one
|
|
// test that exercises that clock sets its own.
|
|
if cfg.IdleFor == 0 {
|
|
cfg.IdleFor = 30 * 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
|
|
}
|
|
|
|
// mySlot is the recipient's own row in a RoomState, which is the only place
|
|
// their role and their readiness live now.
|
|
func mySlot(state *noituv1.RoomState) *noituv1.PlayerSlot {
|
|
for _, p := range state.GetPlayers() {
|
|
if p.GetIsMe() {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// otherSlot is the one other player in a two-player room, or nil when the
|
|
// recipient is alone in it. Most of the lobby tests below are about two
|
|
// people, so this is the shape they read the list in.
|
|
func otherSlot(state *noituv1.RoomState) *noituv1.PlayerSlot {
|
|
for _, p := range state.GetPlayers() {
|
|
if !p.GetIsMe() {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// slotFor finds one named seat, for the tests that seat more than two.
|
|
func slotFor(state *noituv1.RoomState, id string) *noituv1.PlayerSlot {
|
|
for _, p := range state.GetPlayers() {
|
|
if p.GetPlayerId() == id {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// myScore and otherScore read a two-player turn update the way it used to
|
|
// carry the numbers, out of the table that replaced the two fields.
|
|
func myScore(u *noituv1.TurnUpdate) uint32 {
|
|
for _, p := range u.GetPlayers() {
|
|
if p.GetIsMe() {
|
|
return p.GetScore()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func otherScore(u *noituv1.TurnUpdate) uint32 {
|
|
for _, p := range u.GetPlayers() {
|
|
if !p.GetIsMe() {
|
|
return p.GetScore()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func payloadCase(m *noituv1.ServerMessage) string {
|
|
switch m.GetPayload().(type) {
|
|
case *noituv1.ServerMessage_Welcome:
|
|
return "welcome"
|
|
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_PlayerEliminated:
|
|
return "player_eliminated"
|
|
case *noituv1.ServerMessage_Error:
|
|
return "error"
|
|
case *noituv1.ServerMessage_Pong:
|
|
return "pong"
|
|
case *noituv1.ServerMessage_RoomState:
|
|
return "room_state"
|
|
case *noituv1.ServerMessage_ChatMessage:
|
|
return "chat_message"
|
|
case *noituv1.ServerMessage_ChatHistory:
|
|
return "chat_history"
|
|
case *noituv1.ServerMessage_QuickMatchStatus:
|
|
return "quick_match_status"
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestTheFirstTurnIsDrawn checks that opening the room is not the same as
|
|
// opening the game. Moving first is an advantage, and handing it to the owner
|
|
// every time would make them favourite in every game of a series.
|
|
func TestTheFirstTurnIsDrawn(t *testing.T) {
|
|
// Started here rather than over a pair of sockets: a series long enough to
|
|
// tell a draw from a fixed lead is far more starts than a lobby's rate
|
|
// limiter allows, and none of what is being checked is on the wire.
|
|
// beginGame reports to the hub's live-game gauge, so this hand-built room
|
|
// needs one even though nothing here reads it back.
|
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second}
|
|
r.seats[0] = &seat{id: "p1"}
|
|
r.seats[1] = &seat{id: "p2"}
|
|
|
|
// There are two outcomes, so a series that only ever shows one of them is
|
|
// a lead that is not being drawn. A fixed lead fails this every time; a
|
|
// fair draw fails it about once in five hundred million runs.
|
|
const games = 30
|
|
led := map[game.PlayerID]int{}
|
|
for range games {
|
|
if err := r.beginGame(); err != nil {
|
|
t.Fatalf("beginGame: %v", err)
|
|
}
|
|
led[r.engine.Turn()]++
|
|
}
|
|
|
|
if led["p1"] == 0 || led["p2"] == 0 {
|
|
t.Errorf("over %d games p1 led %d and p2 %d; the first turn is not being drawn",
|
|
games, led["p1"], led["p2"])
|
|
}
|
|
}
|
|
|
|
// The one room where the lead is not drawn: the human opens against the bot.
|
|
func TestABotGameOpensWithTheHuman(t *testing.T) {
|
|
strategy, err := bot.New(bot.Easy, rand.New(rand.NewPCG(1, 2)))
|
|
if err != nil {
|
|
t.Fatalf("bot.New: %v", err)
|
|
}
|
|
r := &room{hub: &hub{}, dict: chainDict(), turnLimit: time.Second, strategy: strategy}
|
|
r.seats[0] = &seat{id: "p1"}
|
|
r.seats[1] = &seat{id: botPlayerID}
|
|
|
|
for range 20 {
|
|
if err := r.beginGame(); err != nil {
|
|
t.Fatalf("beginGame: %v", err)
|
|
}
|
|
if got := r.engine.Turn(); got != "p1" {
|
|
t.Fatalf("the bot game opened on %q, want the human", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
|
|
lead, waits, start := awaitLead(t, host, guest)
|
|
|
|
lead.submit("b c", start.GetTurnSeq())
|
|
|
|
// The same move, rendered per recipient: by_me flips, my_turn flips.
|
|
moverUpdate := lead.await("turn_update").GetTurnUpdate()
|
|
otherUpdate := waits.await("turn_update").GetTurnUpdate()
|
|
|
|
if !moverUpdate.GetPlayed().GetByMe() {
|
|
t.Error("mover should see by_me = true")
|
|
}
|
|
if otherUpdate.GetPlayed().GetByMe() {
|
|
t.Error("opponent should see by_me = false")
|
|
}
|
|
if moverUpdate.GetMyTurn() {
|
|
t.Error("mover should not be on turn after moving")
|
|
}
|
|
if !otherUpdate.GetMyTurn() {
|
|
t.Error("opponent should now be on turn")
|
|
}
|
|
if myScore(moverUpdate) != otherScore(otherUpdate) {
|
|
t.Errorf("scores disagree across recipients: %d vs %d",
|
|
myScore(moverUpdate), otherScore(otherUpdate))
|
|
}
|
|
}
|
|
|
|
// 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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
|
|
_, waits, _ := awaitLead(t, host, guest)
|
|
|
|
over := waits.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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
lead, waits, start := awaitLead(t, host, guest)
|
|
|
|
// 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.
|
|
lead.submit("b a", start.GetTurnSeq())
|
|
replyTurn := waits.await("turn_update").GetTurnUpdate()
|
|
|
|
waits.submit("a b", replyTurn.GetTurnSeq())
|
|
|
|
got := waits.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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
hostStart := host.await("game_started").GetGameStarted()
|
|
guest.await("game_started")
|
|
|
|
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
|
|
|
// Presence is part of the room's state now, so the seat being held is
|
|
// something the other player reads there rather than in a message of its
|
|
// own.
|
|
if away := otherSlot(guest.await("room_state").GetRoomState()); away == nil || away.GetConnected() {
|
|
t.Error("opponent should be shown as away while the seat is 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() != hostStart.GetMyTurn() {
|
|
t.Error("the resumed player should come back to the turn they left")
|
|
}
|
|
}
|
|
|
|
// 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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
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", "Mi\u200bnh\u200d", "Minh"},
|
|
{"bidi override stripped", "Minh\u202e", "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 — players who all send nothing — and the one it always could:
|
|
// players who choose the same name.
|
|
func TestDistinguishSeparatesIdenticalNames(t *testing.T) {
|
|
if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" {
|
|
t.Errorf("distinct names should be left alone, got %q", got)
|
|
}
|
|
|
|
// A whole room of unnamed players, seated one at a time. Every one of them
|
|
// has to end up with a name none of the others is already using: two
|
|
// suffixed identically is the same failure as two unsuffixed.
|
|
var taken []string
|
|
for range maxPlayers {
|
|
got := distinguish(defaultNickname, taken)
|
|
if slices.Contains(taken, got) {
|
|
t.Fatalf("distinguish returned %q, which is already in %v", got, taken)
|
|
}
|
|
if n := len([]rune(got)); n > maxNicknameRunes {
|
|
t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes)
|
|
}
|
|
taken = append(taken, got)
|
|
}
|
|
|
|
// The suffix must not push a name that is already at the cap past it.
|
|
long := strings.Repeat("a", maxNicknameRunes)
|
|
if got := distinguish(long, []string{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)
|
|
|
|
for i := range 2 {
|
|
if !b.allow(now) {
|
|
t.Fatalf("burst should cover the first two, call %d refused", i+1)
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
|
|
// --- the lobby -------------------------------------------------------------
|
|
|
|
// pvpRoom seats two players and plays them into a game, so a test about what
|
|
// happens next does not restate the whole handshake.
|
|
func pvpRoom(t *testing.T, url string) (host, guest *testClient, start *noituv1.GameStarted) {
|
|
t.Helper()
|
|
|
|
host, guest, _ = pvpLobby(t, url)
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
|
|
start = host.await("game_started").GetGameStarted()
|
|
guest.await("game_started")
|
|
return host, guest, start
|
|
}
|
|
|
|
// pvpLobby seats two players and stops there: the room exists, nobody is ready
|
|
// and no game has been started.
|
|
func pvpLobby(t *testing.T, url string) (host, guest *testClient, code string) {
|
|
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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest = dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
return host, guest, code
|
|
}
|
|
|
|
// readyAndStart takes a seated pair from their lobby into a game, which is
|
|
// what every test that is about the game itself needs to get past.
|
|
func readyAndStart(t *testing.T, host, guest *testClient) {
|
|
t.Helper()
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
}
|
|
|
|
// awaitLead reads both game_started messages and sorts the pair into the one
|
|
// that drew the first turn and the one that waits.
|
|
//
|
|
// The lead is random per game, so a test that needs to play a move has to ask
|
|
// who may play it rather than assume the room's creator. It returns the
|
|
// leader's GameStarted for its turn_seq.
|
|
func awaitLead(t *testing.T, host, guest *testClient) (lead, waits *testClient, started *noituv1.GameStarted) {
|
|
t.Helper()
|
|
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() {
|
|
return host, guest, hostStart
|
|
}
|
|
return guest, host, guestStart
|
|
}
|
|
|
|
func (c *testClient) setReady(ready bool) {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{
|
|
SetReady: &noituv1.SetReady{Ready: ready},
|
|
}})
|
|
}
|
|
|
|
func (c *testClient) startGame() {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}})
|
|
}
|
|
|
|
func (c *testClient) kickPlayer(target string) {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{
|
|
KickPlayer: &noituv1.KickPlayer{PlayerId: target},
|
|
}})
|
|
}
|
|
|
|
func (c *testClient) say(text string) {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SendChat{
|
|
SendChat: &noituv1.SendChat{Text: text},
|
|
}})
|
|
}
|
|
|
|
func (c *testClient) leaveRoom() {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}})
|
|
}
|
|
|
|
func (c *testClient) quickMatch() {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_QuickMatch{QuickMatch: &noituv1.QuickMatch{}}})
|
|
}
|
|
|
|
func (c *testClient) cancelQuickMatch() {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{
|
|
Payload: &noituv1.ClientMessage_CancelQuickMatch{CancelQuickMatch: &noituv1.CancelQuickMatch{}},
|
|
})
|
|
}
|
|
|
|
func (c *testClient) resign() {
|
|
c.t.Helper()
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
|
}
|
|
|
|
// resignFrom has one player of a two-player game give up, whichever of them
|
|
// drew the first turn.
|
|
//
|
|
// Only the player to act may give up, and who leads is drawn, so a test that
|
|
// needs a particular player to lose has to walk the turn to them first: the
|
|
// fixture graph is a forced path, so the other side has exactly one legal word
|
|
// and playing it hands the turn over.
|
|
//
|
|
// start is the loser's own GameStarted, which is where my_turn is rendered for
|
|
// them.
|
|
func resignFrom(t *testing.T, loser, other *testClient, start *noituv1.GameStarted) {
|
|
t.Helper()
|
|
if !start.GetMyTurn() {
|
|
other.submit("b c", start.GetTurnSeq())
|
|
loser.await("turn_update")
|
|
other.await("turn_update")
|
|
}
|
|
loser.resign()
|
|
}
|
|
|
|
// resignAndSettle has the owner give up and returns the lobby each player
|
|
// lands back in. start is the owner's own GameStarted.
|
|
func resignAndSettle(t *testing.T, host, guest *testClient, start *noituv1.GameStarted) (hostState, guestState *noituv1.RoomState) {
|
|
t.Helper()
|
|
resignFrom(t, host, guest, start)
|
|
host.await("game_over")
|
|
guest.await("game_over")
|
|
return host.await("room_state").GetRoomState(),
|
|
guest.await("room_state").GetRoomState()
|
|
}
|
|
|
|
// TestLobbyOpensWithNobodyReady is the room a joiner lands in: it exists, the
|
|
// owner is known, and nothing has started.
|
|
func TestLobbyOpensWithNobodyReady(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
|
|
// Set up by hand rather than through pvpLobby: this test is about the
|
|
// frames the handshake produces, and the helper consumes them.
|
|
host := dial(t, url)
|
|
host.hello("Chủ phòng")
|
|
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
|
code := host.await("room_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
|
|
hostState := host.await("room_state").GetRoomState()
|
|
guestState := guest.await("room_state").GetRoomState()
|
|
|
|
if !mySlot(hostState).GetIsOwner() {
|
|
t.Error("the player who created the room does not own it")
|
|
}
|
|
if mySlot(guestState).GetIsOwner() {
|
|
t.Error("the player who joined was made owner")
|
|
}
|
|
if hostState.GetCanStart() || guestState.GetCanStart() {
|
|
t.Error("a game can start with nobody ready")
|
|
}
|
|
if otherSlot(hostState) == nil || otherSlot(hostState).GetName() == "" {
|
|
t.Errorf("the owner cannot see who joined: %+v", hostState)
|
|
}
|
|
// Nothing starts on its own. Joining used to be the start signal, and a
|
|
// player who joined to look at the room found themselves on the clock.
|
|
silentFor(t, guest, "game_started", 250*time.Millisecond)
|
|
}
|
|
|
|
// TestReadyIsRenderedPerRecipient is the guard against the two flags being
|
|
// swapped, which would show a player their opponent's readiness as their own.
|
|
func TestReadyIsRenderedPerRecipient(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
guest.setReady(true)
|
|
|
|
guestState := guest.await("room_state").GetRoomState()
|
|
hostState := host.await("room_state").GetRoomState()
|
|
|
|
if !mySlot(guestState).GetReady() || otherSlot(guestState).GetReady() {
|
|
t.Errorf("the guest should see only their own readiness, got %+v", guestState)
|
|
}
|
|
if mySlot(hostState).GetReady() || !otherSlot(hostState).GetReady() {
|
|
t.Errorf("the owner should see only the guest's readiness, got %+v", hostState)
|
|
}
|
|
if !hostState.GetCanStart() {
|
|
t.Error("the owner cannot start a game their guest is ready for")
|
|
}
|
|
}
|
|
|
|
// TestOwnerHasNoReadinessOfTheirOwn: Start is the owner's readiness, and a
|
|
// second flag they would have to set first buys nothing.
|
|
func TestOwnerHasNoReadinessOfTheirOwn(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, _, _ := pvpLobby(t, url)
|
|
|
|
host.setReady(true)
|
|
|
|
if got := host.await("error").GetError().GetCode(); got != "owner_needs_no_ready" {
|
|
t.Errorf("the owner readying returned %q", got)
|
|
}
|
|
}
|
|
|
|
// TestStartIsRefusedUntilTheGuestIsReady covers each way a start is not yet a
|
|
// game.
|
|
func TestStartIsRefusedUntilTheGuestIsReady(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_state").GetRoomState().GetRoomCode()
|
|
|
|
// Alone in the room.
|
|
host.startGame()
|
|
if got := host.await("error").GetError().GetCode(); got != "need_more_players" {
|
|
t.Errorf("starting alone returned %q, want need_more_players", got)
|
|
}
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
|
|
// Seated, but not ready.
|
|
host.startGame()
|
|
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
|
t.Errorf("starting with an unready guest returned %q, want not_everyone_ready", got)
|
|
}
|
|
|
|
// A guest who readies and takes it back is not ready either.
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
guest.setReady(false)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
|
t.Errorf("starting after the guest unreadied returned %q", got)
|
|
}
|
|
}
|
|
|
|
// TestOnlyTheOwnerStartsAndKicks: the guest holds a room code, and a code is
|
|
// pasted into group chats by design.
|
|
func TestOnlyTheOwnerStartsAndKicks(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
_ = host
|
|
|
|
guest.startGame()
|
|
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
|
t.Errorf("a guest starting the game returned %q, want not_the_owner", got)
|
|
}
|
|
guest.kickPlayer("p1")
|
|
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
|
t.Errorf("a guest kicking returned %q, want not_the_owner", got)
|
|
}
|
|
}
|
|
|
|
// TestNextGameNeedsAFreshReady is the whole replay flow: a finished game
|
|
// returns both players to the lobby, and the readiness that started the last
|
|
// one is spent.
|
|
func TestNextGameNeedsAFreshReady(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, first := pvpRoom(t, url)
|
|
|
|
hostState, guestState := resignAndSettle(t, host, guest, first)
|
|
if otherSlot(hostState).GetReady() || mySlot(guestState).GetReady() {
|
|
t.Error("the readiness that started the last game survived it")
|
|
}
|
|
if hostState.GetCanStart() {
|
|
t.Error("the owner can start a game nobody has readied for")
|
|
}
|
|
|
|
host.startGame()
|
|
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
|
t.Errorf("starting a second game without a fresh ready returned %q", got)
|
|
}
|
|
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
|
|
second := host.await("game_started").GetGameStarted()
|
|
guest.await("game_started")
|
|
|
|
if second.GetTurnSeq() <= first.GetTurnSeq() {
|
|
t.Errorf("turn_seq must keep rising across games: %d then %d, so a submission "+
|
|
"still in flight from the first could be applied to the second",
|
|
first.GetTurnSeq(), second.GetTurnSeq())
|
|
}
|
|
if second.GetOpeningWord() == "" {
|
|
t.Error("the second game needs its own opening word")
|
|
}
|
|
}
|
|
|
|
// TestRoomKeepsARunningWinTally: a room outlives its games, so the score of
|
|
// the series is a room fact. It is credited before the lobby is broadcast, so
|
|
// the players see it the moment a game ends rather than one input later.
|
|
func TestRoomKeepsARunningWinTally(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, first := pvpRoom(t, url)
|
|
|
|
// The owner resigns, so the guest takes the first game.
|
|
hostState, guestState := resignAndSettle(t, host, guest, first)
|
|
if got := mySlot(guestState).GetWins(); got != 1 {
|
|
t.Errorf("the winner's tally is %d after one game, want 1", got)
|
|
}
|
|
if got := mySlot(hostState).GetWins(); got != 0 {
|
|
t.Errorf("the loser's tally is %d, want 0", got)
|
|
}
|
|
// And each player is told the whole table, not just their own row.
|
|
if got := otherSlot(hostState).GetWins(); got != 1 {
|
|
t.Errorf("the owner sees the guest's tally as %d, want 1", got)
|
|
}
|
|
|
|
// A second game the other way round leaves the series level.
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
host.await("game_started")
|
|
second := guest.await("game_started").GetGameStarted()
|
|
|
|
resignFrom(t, guest, host, second)
|
|
host.await("game_over")
|
|
guest.await("game_over")
|
|
hostState = host.await("room_state").GetRoomState()
|
|
|
|
if got := mySlot(hostState).GetWins(); got != 1 {
|
|
t.Errorf("the owner's tally is %d after winning one of two, want 1", got)
|
|
}
|
|
if got := otherSlot(hostState).GetWins(); got != 1 {
|
|
t.Errorf("the guest's tally is %d after winning one of two, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestLobbyActionsAreRefusedDuringAGame keeps the lobby's own business out of a
|
|
// game in progress. Leaving is not part of it: a player may walk out of a game
|
|
// whether or not it is their turn, which
|
|
// TestLeavingMidGameFreesTheSeatAndLeavesTheRestPlaying covers.
|
|
func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpRoom(t, url)
|
|
|
|
guest.setReady(false)
|
|
if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" {
|
|
t.Errorf("unreadying mid-game returned %q, want game_in_progress", got)
|
|
}
|
|
host.kickPlayer("p2")
|
|
if got := host.await("error").GetError().GetCode(); got != "game_in_progress" {
|
|
t.Errorf("kicking mid-game returned %q, want game_in_progress", got)
|
|
}
|
|
}
|
|
|
|
// TestLeavingNeedsAnUnreadyFirst is the friction the lobby is meant to have: a
|
|
// player the owner is waiting on has to take that back before walking away.
|
|
func TestLeavingNeedsAnUnreadyFirst(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
guest.setReady(true)
|
|
guest.await("room_state")
|
|
host.await("room_state")
|
|
|
|
guest.leaveRoom()
|
|
if got := guest.await("error").GetError().GetCode(); got != "must_unready_first" {
|
|
t.Errorf("leaving while ready returned %q, want must_unready_first", got)
|
|
}
|
|
|
|
guest.setReady(false)
|
|
guest.await("room_state")
|
|
host.await("room_state")
|
|
guest.leaveRoom()
|
|
|
|
// The room survives: the owner is still in it, now on their own.
|
|
alone := host.await("room_state").GetRoomState()
|
|
if otherSlot(alone) != nil {
|
|
t.Errorf("the owner still sees a guest who left: %+v", alone)
|
|
}
|
|
if !mySlot(alone).GetIsOwner() || alone.GetCanStart() {
|
|
t.Errorf("the room the owner is left with is wrong: %+v", alone)
|
|
}
|
|
}
|
|
|
|
// TestKickFreesAnUnreadySeatOnly: readiness is a commitment, and the owner
|
|
// does not get to overrule one.
|
|
func TestKickFreesAnUnreadySeatOnly(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, code := pvpLobby(t, url)
|
|
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.kickPlayer("p2")
|
|
if got := host.await("error").GetError().GetCode(); got != "player_is_ready" {
|
|
t.Errorf("kicking a ready guest returned %q, want player_is_ready", got)
|
|
}
|
|
|
|
guest.setReady(false)
|
|
host.await("room_state")
|
|
host.kickPlayer("p2")
|
|
|
|
if got := guest.await("error").GetError().GetCode(); got != "kicked" {
|
|
t.Errorf("the kicked player was told %q", got)
|
|
}
|
|
if got := host.await("room_state").GetRoomState(); otherSlot(got) != nil {
|
|
t.Errorf("the kicked seat is still occupied: %+v", got)
|
|
}
|
|
|
|
// The seat is free, and a kick is not a ban.
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
if got := guest.await("room_state").GetRoomState(); otherSlot(got) == nil {
|
|
t.Errorf("a kicked player could not come back: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestOwnerLeavingPromotesTheOtherPlayer: the role outlives the player who
|
|
// held it, or the room would be one nobody can start.
|
|
func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, code := pvpLobby(t, url)
|
|
|
|
host.leaveRoom()
|
|
|
|
promoted := guest.await("room_state").GetRoomState()
|
|
if !mySlot(promoted).GetIsOwner() {
|
|
t.Errorf("the player left behind was not promoted: %+v", promoted)
|
|
}
|
|
if otherSlot(promoted) != nil {
|
|
t.Errorf("the owner who left is still shown as present: %+v", promoted)
|
|
}
|
|
|
|
// And the promotion is real: the new owner can start a game with the next
|
|
// person to walk in.
|
|
third := dial(t, url)
|
|
third.hello("Người mới")
|
|
third.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
guest.await("room_state")
|
|
third.await("room_state")
|
|
third.setReady(true)
|
|
guest.await("room_state")
|
|
guest.startGame()
|
|
|
|
guest.await("game_started")
|
|
third.await("game_started")
|
|
}
|
|
|
|
// TestPromotedOwnerLosesTheirReadiness: their readiness is Start now, and a
|
|
// flag left set from being a guest would mean nothing.
|
|
func TestPromotedOwnerLosesTheirReadiness(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
// Drained on both sides, so the state read below is the promotion and not
|
|
// the readiness that preceded it.
|
|
guest.await("room_state")
|
|
host.leaveRoom()
|
|
|
|
promoted := guest.await("room_state").GetRoomState()
|
|
if mySlot(promoted).GetReady() {
|
|
t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted)
|
|
}
|
|
}
|
|
|
|
// TestLastPlayerOutClosesTheRoom bounds the code and the goroutine: nothing is
|
|
// coming that could fill a room whose code the hub is about to forget.
|
|
func TestLastPlayerOutClosesTheRoom(t *testing.T) {
|
|
api, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
guest.leaveRoom()
|
|
host.await("room_state")
|
|
host.leaveRoom()
|
|
|
|
awaitNoRooms(t, api, "a room nobody is in")
|
|
}
|
|
|
|
// TestIdleLobbyCloses bounds a room nobody starts a game in. One open tab
|
|
// would otherwise hold a code and a goroutine for the life of the process.
|
|
func TestIdleLobbyCloses(t *testing.T) {
|
|
api, url := newTestServer(t, chainDict(), Config{IdleFor: 150 * time.Millisecond})
|
|
|
|
host := dial(t, url)
|
|
host.hello("Chủ phòng")
|
|
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
|
host.await("room_state")
|
|
|
|
if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" {
|
|
t.Errorf("an idle room closed with %q", got)
|
|
}
|
|
awaitNoRooms(t, api, "an idle room")
|
|
}
|
|
|
|
// TestBotRoomHasNoLobby: a bot room is its game. Keeping it open would leave
|
|
// one goroutine and one engine per finished bot game.
|
|
func TestBotRoomHasNoLobby(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.resign()
|
|
c.await("game_over")
|
|
|
|
// not_in_a_room is the session reporting that the room has gone: the
|
|
// goroutine and engine are released rather than parked in a lobby no bot
|
|
// can ready for.
|
|
c.setReady(true)
|
|
if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" {
|
|
t.Errorf("a finished bot room answered %q, want it to be gone", got)
|
|
}
|
|
}
|
|
|
|
// --- chat -------------------------------------------------------------------
|
|
|
|
// resumeAs reconnects with a live token and returns the new connection, having
|
|
// consumed its Welcome. Reading a room's conversation back is what it is for:
|
|
// a seat's own resume is the only replay that carries the whole window.
|
|
func resumeAs(t *testing.T, url, nickname, token string) *testClient {
|
|
t.Helper()
|
|
|
|
c := dial(t, url)
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Nickname: nickname,
|
|
ResumeToken: token,
|
|
}}})
|
|
c.await("welcome")
|
|
return c
|
|
}
|
|
|
|
// TestChatReachesBothSeatsRenderedPerRecipient is the guard against from_me
|
|
// being computed once and shared, which would show a player their opponent's
|
|
// words as their own.
|
|
func TestChatReachesBothSeatsRenderedPerRecipient(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
host.say("Chào bạn")
|
|
|
|
mine := host.await("chat_message").GetChatMessage()
|
|
theirs := guest.await("chat_message").GetChatMessage()
|
|
|
|
if !mine.GetFromMe() {
|
|
t.Errorf("the sender was not shown their own message as theirs: %+v", mine)
|
|
}
|
|
if theirs.GetFromMe() {
|
|
t.Errorf("the recipient was shown the sender's message as their own: %+v", theirs)
|
|
}
|
|
if theirs.GetText() != "Chào bạn" || theirs.GetAuthor() == "" {
|
|
t.Errorf("the recipient's copy is wrong: %+v", theirs)
|
|
}
|
|
if theirs.GetSentUnixMs() == 0 {
|
|
t.Error("a message carries no time")
|
|
}
|
|
// The seat behind a line is the same fact for everybody: from_me is the
|
|
// only field that is relative to the reader, and a client colours a line
|
|
// by its author rather than by matching names.
|
|
if mine.GetPlayerId() != theirs.GetPlayerId() || theirs.GetPlayerId() == "" {
|
|
t.Errorf("the author's seat differs between recipients: %q and %q",
|
|
mine.GetPlayerId(), theirs.GetPlayerId())
|
|
}
|
|
}
|
|
|
|
// TestChatWorksInTheLobbyAndInAGame: the conversation belongs to the room, not
|
|
// to a game, so neither phase is a special case.
|
|
func TestChatWorksInTheLobbyAndInAGame(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
host.say("trước ván")
|
|
if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trước ván" {
|
|
t.Errorf("lobby message = %q", got)
|
|
}
|
|
|
|
// Started inline rather than through readyAndStart: pvpLobby has already
|
|
// drained the room states that helper waits for.
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
host.await("game_started")
|
|
guest.await("game_started")
|
|
|
|
host.say("trong ván")
|
|
if got := guest.await("chat_message").GetChatMessage().GetText(); got != "trong ván" {
|
|
t.Errorf("in-game message = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestAJoinerSeesNothingSaidBeforeTheySatDown is the disclosure boundary. A
|
|
// room code is pasted into group chats by design, so redeeming one must not
|
|
// hand over a conversation the joiner was never part of.
|
|
func TestAJoinerSeesNothingSaidBeforeTheySatDown(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_state").GetRoomState().GetRoomCode()
|
|
|
|
host.say("bí mật")
|
|
host.await("chat_message")
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
|
|
if got := guest.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 {
|
|
t.Errorf("a joiner was handed %d messages from before they arrived: %+v", len(got), got)
|
|
}
|
|
|
|
// And from there they share a conversation like anybody else.
|
|
host.say("chào")
|
|
if got := guest.await("chat_message").GetChatMessage().GetText(); got != "chào" {
|
|
t.Errorf("message after joining = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestCreatingARoomReplaysAnEmptyHistory: it is what overwrites the panel a
|
|
// client may still be holding from the room it was in before this one.
|
|
func TestCreatingARoomReplaysAnEmptyHistory(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
|
|
c := dial(t, url)
|
|
c.hello("Chủ phòng")
|
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
|
|
|
if got := c.await("chat_history").GetChatHistory().GetMessages(); len(got) != 0 {
|
|
t.Errorf("a new room came with %d messages", len(got))
|
|
}
|
|
}
|
|
|
|
// TestChatHistoryIsReplayedOnResumeInTheLobby is the commonest refresh there
|
|
// is, and the one a replay hung off the end of handleResume would miss: that
|
|
// function returns early for a lobby resume.
|
|
func TestChatHistoryIsReplayedOnResumeInTheLobby(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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
|
|
host.say("một")
|
|
host.await("chat_message")
|
|
guest.say("hai")
|
|
host.await("chat_message")
|
|
|
|
// The tab reloads: same token, new socket, still in the lobby.
|
|
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
|
back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken())
|
|
|
|
history := back.await("chat_history").GetChatHistory().GetMessages()
|
|
if len(history) != 2 {
|
|
t.Fatalf("the resumed lobby replayed %d messages, want 2: %+v", len(history), history)
|
|
}
|
|
if history[0].GetText() != "một" || history[1].GetText() != "hai" {
|
|
t.Errorf("history is out of order: %+v", history)
|
|
}
|
|
if !history[0].GetFromMe() || history[1].GetFromMe() {
|
|
t.Errorf("authorship did not survive the resume: %+v", history)
|
|
}
|
|
}
|
|
|
|
// TestChatHistoryIsReplayedOnResumeMidGame covers the same during a game,
|
|
// where handleResume takes its other path.
|
|
func TestChatHistoryIsReplayedOnResumeMidGame(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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
readyAndStart(t, host, guest)
|
|
host.await("game_started")
|
|
guest.await("game_started")
|
|
|
|
host.say("đang chơi")
|
|
host.await("chat_message")
|
|
|
|
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
|
back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken())
|
|
|
|
if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 {
|
|
t.Errorf("a mid-game resume replayed %d messages, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
// TestChatHistoryIsCappedAndOrdered bounds what one room holds. Driven at the
|
|
// room directly: the cap is room state, and a socket would only add a rate
|
|
// limiter to wait out.
|
|
func TestChatHistoryIsCappedAndOrdered(t *testing.T) {
|
|
sess := offlineSession(t, chatHistoryLimit+64)
|
|
r := &room{
|
|
code: "TESTRM",
|
|
seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}},
|
|
owner: "p1",
|
|
}
|
|
|
|
const sent = chatHistoryLimit + 5
|
|
for i := range sent {
|
|
r.handleChat(chatInput{sess: sess, player: "p1", text: fmt.Sprintf("tin %d", i)})
|
|
}
|
|
|
|
if len(r.chat) != chatHistoryLimit {
|
|
t.Fatalf("the room kept %d messages, want %d", len(r.chat), chatHistoryLimit)
|
|
}
|
|
if got, want := r.chat[0].text, fmt.Sprintf("tin %d", sent-chatHistoryLimit); got != want {
|
|
t.Errorf("oldest kept message = %q, want %q", got, want)
|
|
}
|
|
if got, want := r.chat[len(r.chat)-1].text, fmt.Sprintf("tin %d", sent-1); got != want {
|
|
t.Errorf("newest kept message = %q, want %q", got, want)
|
|
}
|
|
// The sequence keeps rising past the trim, which is what makes a seat's
|
|
// watermark meaningful after the entry it pointed at is gone.
|
|
if r.chatSeq != sent {
|
|
t.Errorf("chatSeq = %d after %d messages", r.chatSeq, sent)
|
|
}
|
|
}
|
|
|
|
// TestChatHistorySurvivesAGame: the conversation is the room's, and a game
|
|
// starting and finishing inside it changes nothing about that.
|
|
func TestChatHistorySurvivesAGame(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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
|
|
host.say("trước ván")
|
|
host.await("chat_message")
|
|
|
|
guest.setReady(true)
|
|
host.await("room_state")
|
|
host.startGame()
|
|
start := host.await("game_started").GetGameStarted()
|
|
guest.await("game_started")
|
|
resignAndSettle(t, host, guest, start)
|
|
|
|
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
|
back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken())
|
|
|
|
if got := back.await("chat_history").GetChatHistory().GetMessages(); len(got) != 1 {
|
|
t.Errorf("a finished game left %d messages, want the 1 said before it", len(got))
|
|
}
|
|
}
|
|
|
|
// TestChatTextIsSanitizedAndCapped covers the three ways text is made safe to
|
|
// render in a stranger's browser.
|
|
func TestChatTextIsSanitizedAndCapped(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
// Invisible characters: a zero-width joiner and a bidi override.
|
|
host.say("xin" + string(zeroWidthSpace) + "chào" + string(bidiOverride))
|
|
if got := guest.await("chat_message").GetChatMessage().GetText(); got != "xinchào" {
|
|
t.Errorf("invisible characters survived: %q", got)
|
|
}
|
|
|
|
// A stack of combining marks renders as a glyph cluster tall enough to
|
|
// cover the board, and the rune cap alone does not stop it.
|
|
host.say("a" + strings.Repeat("\u0350", 199))
|
|
stacked := guest.await("chat_message").GetChatMessage().GetText()
|
|
if marks := strings.Count(stacked, "\u0350"); marks > maxChatMarks {
|
|
t.Errorf("a %d-mark stack survived, want at most %d", marks, maxChatMarks)
|
|
}
|
|
|
|
// Over the cap, cut on a rune boundary rather than through a character.
|
|
host.say(strings.Repeat("ữ", maxChatRunes+100))
|
|
long := guest.await("chat_message").GetChatMessage().GetText()
|
|
if runes := []rune(long); len(runes) != maxChatRunes {
|
|
t.Errorf("a long message came back %d runes, want %d", len(runes), maxChatRunes)
|
|
}
|
|
if !utf8.ValidString(long) {
|
|
t.Error("the cap cut a character in half")
|
|
}
|
|
}
|
|
|
|
// TestEmptyChatIsDroppedWithoutAnError: nothing survived the sanitizer, so
|
|
// there is no message to refuse and nobody who typed one.
|
|
func TestEmptyChatIsDroppedWithoutAnError(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
// Sent first, then a real one. Messages arrive in order, so a guest whose
|
|
// next message is the real one never received the empty one — and unlike
|
|
// silentFor, this leaves the connection alive to prove it.
|
|
host.say(" " + string(zeroWidthSpace) + " ")
|
|
host.say("thật")
|
|
|
|
if got := guest.await("chat_message").GetChatMessage().GetText(); got != "thật" {
|
|
t.Errorf("the empty message was delivered as %q", got)
|
|
}
|
|
// And nothing follows it.
|
|
silentFor(t, guest, "chat_message", 250*time.Millisecond)
|
|
}
|
|
|
|
// TestChatIsRateLimitedOnItsOwnBudget: a burst is refused, and it does not
|
|
// cost the sender their moves.
|
|
func TestChatIsRateLimitedOnItsOwnBudget(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, start := pvpRoom(t, url)
|
|
|
|
// The player on turn does the talking, so the move that follows is one
|
|
// they are allowed to make.
|
|
mover, other := host, guest
|
|
if !start.GetMyTurn() {
|
|
mover, other = guest, host
|
|
}
|
|
|
|
for range chatBurst + 5 {
|
|
mover.say("spam")
|
|
}
|
|
|
|
var refused bool
|
|
for range 40 {
|
|
if payloadCase(mover.recv()) == "error" {
|
|
refused = true
|
|
break
|
|
}
|
|
}
|
|
if !refused {
|
|
t.Fatal("a burst of chat was never refused")
|
|
}
|
|
|
|
// The move budget is separate, so a word still reaches the engine: the
|
|
// opponent seeing the turn arrive is the proof it was accepted.
|
|
mover.submit("b c", start.GetTurnSeq())
|
|
awaitMyTurn(t, other)
|
|
}
|
|
|
|
// TestChatFromASeatlessConnectionIsRefused: losing the seat is not losing the
|
|
// socket. A kicked player's connection is still open and still believes it was
|
|
// in a room, which is the reachable half of the guard that also covers a
|
|
// connection replaced by a reconnect.
|
|
func TestChatFromASeatlessConnectionIsRefused(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
host.kickPlayer("p2")
|
|
if got := guest.await("error").GetError().GetCode(); got != "kicked" {
|
|
t.Fatalf("the guest was told %q rather than being kicked", got)
|
|
}
|
|
|
|
guest.say("cho tôi vào lại")
|
|
if got := guest.await("error").GetError().GetCode(); got != "not_in_a_room" {
|
|
t.Errorf("a kicked connection chatting returned %q, want not_in_a_room", got)
|
|
}
|
|
silentFor(t, host, "chat_message", 250*time.Millisecond)
|
|
}
|
|
|
|
// TestChatNeedsASeat: holding a socket is not holding a seat.
|
|
func TestChatNeedsASeat(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
|
|
c := dial(t, url)
|
|
c.hello("Người chơi")
|
|
c.say("có ai không")
|
|
|
|
if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" {
|
|
t.Errorf("chatting from no room returned %q, want not_in_a_room", got)
|
|
}
|
|
}
|
|
|
|
// TestBotRoomHasNoChat: there is nobody to talk to, and the check belongs on
|
|
// the room goroutine, which is the only place that knows.
|
|
func TestBotRoomHasNoChat(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.say("chào máy")
|
|
if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" {
|
|
t.Errorf("chatting at a bot returned %q, want not_in_a_room", got)
|
|
}
|
|
}
|
|
|
|
// TestChatDoesNotKeepARoomAlive: talking is not playing. Without this a room
|
|
// is held open for the life of the process by one message every nine minutes.
|
|
func TestChatDoesNotKeepARoomAlive(t *testing.T) {
|
|
api, url := newTestServer(t, chainDict(), Config{IdleFor: 300 * time.Millisecond})
|
|
|
|
host := dial(t, url)
|
|
host.hello("Chủ phòng")
|
|
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
|
host.await("room_state")
|
|
|
|
// Chatting throughout the window; the clock must keep running anyway.
|
|
for range 4 {
|
|
host.say("vẫn ở đây")
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" {
|
|
t.Errorf("a chatted-in room closed with %q, want room_idle_closed", got)
|
|
}
|
|
awaitNoRooms(t, api, "a room held open by chat")
|
|
}
|
|
|
|
// TestVacatedSeatKeepsItsWordsButLosesItsAuthor: the words stay, the
|
|
// attribution does not — checked from the side that stayed, because a name
|
|
// left behind is a name the next joiner can ask for.
|
|
func TestVacatedSeatKeepsItsWordsButLosesItsAuthor(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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
|
|
guest.say("tôi là khách")
|
|
host.await("chat_message")
|
|
guest.leaveRoom()
|
|
host.await("room_state")
|
|
|
|
// The player who stayed reloads and reads the room back.
|
|
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
|
back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken())
|
|
|
|
history := back.await("chat_history").GetChatHistory().GetMessages()
|
|
if len(history) != 1 {
|
|
t.Fatalf("the departed player's words are gone: %+v", history)
|
|
}
|
|
if got := history[0]; got.GetText() != "tôi là khách" || got.GetAuthor() != "" || got.GetFromMe() {
|
|
t.Errorf("a vacated seat's message is still attributed: %+v", got)
|
|
}
|
|
// The seat goes with the name. A line still carrying it would be coloured
|
|
// as whoever fills that seat next.
|
|
if got := history[0].GetPlayerId(); got != "" {
|
|
t.Errorf("a vacated seat's message still names its seat: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTheRemainingPlayerIsResyncedWhenASeatIsVacated is the live half of the
|
|
// authorship rule. Clearing the store is not enough: the player who stayed is
|
|
// already holding frames that carry the departed name, and nothing else in the
|
|
// protocol would correct them before a reload.
|
|
func TestTheRemainingPlayerIsResyncedWhenASeatIsVacated(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
host, guest, _ := pvpLobby(t, url)
|
|
|
|
guest.say("số của tôi là …")
|
|
host.await("chat_message")
|
|
|
|
guest.leaveRoom()
|
|
|
|
// No reload, no resume: the correction has to arrive on its own.
|
|
history := host.await("chat_history").GetChatHistory().GetMessages()
|
|
if len(history) != 1 {
|
|
t.Fatalf("the remaining player was resynced with %d messages, want 1", len(history))
|
|
}
|
|
if got := history[0]; got.GetAuthor() != "" || got.GetText() != "số của tôi là …" {
|
|
t.Errorf("the departed player's line is still attributed: %+v", got)
|
|
}
|
|
}
|
|
|
|
// offlineSession builds a session with no websocket behind it, for the two
|
|
// guarantees that live below the transport: who may speak for a seat, and what
|
|
// a full outbox costs the player behind it.
|
|
func offlineSession(t *testing.T, capacity int) *session {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
return &session{
|
|
id: randomToken(),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
out: make(chan []byte, capacity),
|
|
flushed: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// queued reads whatever the session has waiting for its writer, decoded.
|
|
// Deliberately not called drain: (*session).drain means the opposite — flush
|
|
// what is queued out to the socket.
|
|
func queued(t *testing.T, s *session) []*noituv1.ServerMessage {
|
|
t.Helper()
|
|
|
|
var out []*noituv1.ServerMessage
|
|
for {
|
|
select {
|
|
case raw := <-s.out:
|
|
var m noituv1.ServerMessage
|
|
if err := proto.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
out = append(out, &m)
|
|
default:
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChatFromAConnectionThatLostItsSeatIsRefused covers handleChat's own
|
|
// authorization, which the transport cannot reach: a frame already queued when
|
|
// the seat was freed arrives after it, and by then the seat may be somebody
|
|
// else's. Driven directly because that ordering is a race over the wire.
|
|
func TestChatFromAConnectionThatLostItsSeatIsRefused(t *testing.T) {
|
|
evicted := offlineSession(t, 8)
|
|
seated := offlineSession(t, 8)
|
|
|
|
r := &room{
|
|
code: "TESTRM",
|
|
seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}},
|
|
owner: "p1",
|
|
}
|
|
|
|
// The seat changes hands while this connection's message is in flight —
|
|
// a reconnect, or a kick and a new arrival.
|
|
r.seats[0].sess = seated
|
|
r.handleChat(chatInput{sess: evicted, player: "p1", text: "tôi vẫn ở đây"})
|
|
|
|
refusals := queued(t, evicted)
|
|
if len(refusals) != 1 || refusals[0].GetError().GetCode() != "not_your_seat" {
|
|
t.Fatalf("a connection with no seat was answered %+v, want not_your_seat", refusals)
|
|
}
|
|
if len(r.chat) != 0 {
|
|
t.Errorf("the room stored a message from a connection that holds no seat: %+v", r.chat)
|
|
}
|
|
if got := queued(t, seated); len(got) != 0 {
|
|
t.Errorf("the seated player was sent %d frames from an impostor", len(got))
|
|
}
|
|
}
|
|
|
|
// TestChatToAFullOutboxIsDroppedNotFatal is the guarantee the chat budget
|
|
// rests on: a player who cannot keep up loses a line, not their session. Every
|
|
// other frame closes a session at this point, which mid-game costs the game.
|
|
//
|
|
// Driven through handleChat rather than trySend directly, because the property
|
|
// under test is which delivery the chat path chooses — a test that called
|
|
// trySend itself would pass just as happily after somebody swapped the call
|
|
// site back to send.
|
|
func TestChatToAFullOutboxIsDroppedNotFatal(t *testing.T) {
|
|
sender := offlineSession(t, 8)
|
|
slow := offlineSession(t, 1)
|
|
slow.out <- []byte("already queued")
|
|
|
|
r := &room{
|
|
code: "TESTRM",
|
|
seats: [maxPlayers]*seat{
|
|
{id: "p1", nickname: "Chủ phòng", sess: sender},
|
|
{id: "p2", nickname: "Khách", sess: slow},
|
|
},
|
|
owner: "p1",
|
|
}
|
|
|
|
r.handleChat(chatInput{sess: sender, player: "p1", text: "bạn còn đó không"})
|
|
|
|
select {
|
|
case <-slow.ctx.Done():
|
|
t.Fatal("a chat line closed the session of the player who could not keep up")
|
|
default:
|
|
}
|
|
if len(r.chat) != 1 {
|
|
t.Errorf("the room stored %d messages, want 1", len(r.chat))
|
|
}
|
|
// The sender is unaffected: their own copy went out and nothing was
|
|
// refused.
|
|
for _, m := range queued(t, sender) {
|
|
if m.GetError() != nil {
|
|
t.Errorf("the sender was refused: %q", m.GetError().GetCode())
|
|
}
|
|
}
|
|
}
|
|
|
|
// awaitNoRooms waits for the hub to forget every room it holds.
|
|
func awaitNoRooms(t *testing.T, api *Server, what string) {
|
|
t.Helper()
|
|
|
|
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("%s was never evicted from the hub", what)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// TestResumeReclaimsALobbySeat: a room outlives its games now, so a player who
|
|
// refreshes between them has a lobby to come back to rather than a refusal.
|
|
func TestResumeReclaimsALobbySeat(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_state").GetRoomState().GetRoomCode()
|
|
|
|
guest := dial(t, url)
|
|
guest.hello("Khách")
|
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
|
}})
|
|
host.await("room_state")
|
|
guest.await("room_state")
|
|
|
|
// The owner's tab reloads: same token, new socket.
|
|
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
|
guest.await("room_state")
|
|
|
|
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")
|
|
|
|
back := second.await("room_state").GetRoomState()
|
|
if !mySlot(back).GetIsOwner() {
|
|
t.Errorf("the owner came back as a guest: %+v", back)
|
|
}
|
|
if otherSlot(back) == nil || back.GetRoomCode() != code {
|
|
t.Errorf("the resumed lobby is not the one they left: %+v", back)
|
|
}
|
|
|
|
// And the room still works from both sides.
|
|
guest.setReady(true)
|
|
second.await("room_state")
|
|
second.startGame()
|
|
second.await("game_started")
|
|
guest.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_state")
|
|
}
|
|
|
|
_ = 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)
|
|
}
|
|
}
|
|
|
|
// TestLobbyActionsAreRateLimited: every accepted action 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 TestLobbyActionsAreRateLimited(t *testing.T) {
|
|
_, url := newTestServer(t, chainDict(), Config{})
|
|
_, guest, _ := pvpLobby(t, url)
|
|
|
|
for i := range submitBurst + 5 {
|
|
guest.setReady(i%2 == 0)
|
|
}
|
|
|
|
// The limiter answers before the room does, so a refusal has to appear in
|
|
// the stream rather than an unbroken run of room states.
|
|
for range 30 {
|
|
if payloadCase(guest.recv()) == "error" {
|
|
return
|
|
}
|
|
}
|
|
t.Error("a burst of lobby actions was never refused")
|
|
}
|