diff --git a/server/internal/wsapi/hub.go b/server/internal/wsapi/hub.go index be16f68..249bab8 100644 --- a/server/internal/wsapi/hub.go +++ b/server/internal/wsapi/hub.go @@ -38,8 +38,9 @@ type hub struct { ctx context.Context dict Dictionary - turnLimit time.Duration - graceFor time.Duration + turnLimit time.Duration + graceFor time.Duration + rematchAfter time.Duration mu sync.Mutex rooms map[string]*room @@ -48,15 +49,16 @@ type hub struct { joinLimiter *keyedLimiter } -func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor time.Duration) *hub { +func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, rematchAfter time.Duration) *hub { return &hub{ - ctx: ctx, - dict: dict, - turnLimit: turnLimit, - graceFor: graceFor, - rooms: map[string]*room{}, - sessions: map[string]*session{}, - joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor), + ctx: ctx, + dict: dict, + turnLimit: turnLimit, + graceFor: graceFor, + rematchAfter: rematchAfter, + rooms: map[string]*room{}, + sessions: map[string]*session{}, + joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor), } } @@ -141,7 +143,7 @@ func (h *hub) newRegisteredRoom() (*room, error) { return nil, err } - r := newRoom(h, code, h.turnLimit, h.graceFor) + r := newRoom(h, code, h.turnLimit, h.graceFor, h.rematchAfter) h.mu.Lock() h.rooms[code] = r diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index f32f08b..93ae419 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -26,6 +26,11 @@ const minOpeningOutDegree = 20 // neither is worth blocking a session goroutine for. const roomInputCap = 32 +// defaultRematchWindow is how long a finished room waits for both players to +// ask for another game when nothing else is configured. It bounds how long a +// room outlives its game. +const defaultRematchWindow = 30 * time.Second + // Room input messages. Everything that can change a game arrives as one of // these on a single channel, which is what makes the engine safe without a // lock: the room goroutine is its only reader. @@ -59,6 +64,12 @@ type submitInput struct { turnSeq uint32 } +// rematchInput is one player asking to play the same room again. +type rematchInput struct { + sess *session + player game.PlayerID +} + type resignInput struct { sess *session player game.PlayerID @@ -75,6 +86,10 @@ type disconnectInput struct { type resumeInput struct { player game.PlayerID sess *session + // prior is the connection being replaced. The room retires it only once it + // has decided the resume is allowed, because closing it on a refusal would + // end the very game the client was trying to rejoin. + prior *session } type botMoveInput struct { @@ -90,6 +105,9 @@ type seat struct { id game.PlayerID nickname string sess *session // nil for the bot, or while a human is disconnected + // wantsRematch is this seat's answer to the offer that opens when a game + // ends. Cleared whenever a new game starts. + wantsRematch bool } // room owns one game. @@ -102,13 +120,14 @@ type room struct { ctx context.Context cancel context.CancelFunc - hub *hub - dict Dictionary - engine *game.Engine - opening string - strategy bot.Strategy - turnLimit time.Duration - graceFor time.Duration + hub *hub + dict Dictionary + engine *game.Engine + opening string + strategy bot.Strategy + turnLimit time.Duration + graceFor time.Duration + rematchAfter time.Duration seats [2]*seat @@ -121,6 +140,11 @@ type room struct { // or nil. Only one seat can be waiting: if the second also drops, there is // nobody left to win and the room ends. disconnected *seat + + // rematchUntil is when the offer that follows a finished game expires, or + // the zero time when no offer is open. It is the one flag that keeps a + // room alive past its game. + rematchUntil time.Time } // Dictionary is everything the transport layer needs from the wordlist: the @@ -135,17 +159,21 @@ type Dictionary interface { RandomOpeningWord(minOutDegree int) (string, error) } -func newRoom(h *hub, code string, turnLimit, graceFor time.Duration) *room { +func newRoom(h *hub, code string, turnLimit, graceFor, rematchAfter time.Duration) *room { + if rematchAfter <= 0 { + rematchAfter = defaultRematchWindow + } ctx, cancel := context.WithCancel(h.ctx) return &room{ - code: code, - inputs: make(chan any, roomInputCap), - ctx: ctx, - cancel: cancel, - hub: h, - dict: h.dict, - turnLimit: turnLimit, - graceFor: graceFor, + code: code, + inputs: make(chan any, roomInputCap), + ctx: ctx, + cancel: cancel, + hub: h, + dict: h.dict, + turnLimit: turnLimit, + graceFor: graceFor, + rematchAfter: rematchAfter, } } @@ -184,7 +212,7 @@ func (r *room) run() { defer r.cancel() defer r.hub.evict(r.code) - var turnTimer, graceTimer *time.Timer + var turnTimer, graceTimer, rematchTimer *time.Timer stop := func(t *time.Timer) { if t != nil { t.Stop() @@ -193,6 +221,7 @@ func (r *room) run() { defer func() { stop(turnTimer) stop(graceTimer) + stop(rematchTimer) }() // resetTurnTimer rebuilds the deadline timer after anything that changes @@ -209,13 +238,16 @@ func (r *room) run() { } for { - var turnC, graceC <-chan time.Time + var turnC, graceC, rematchC <-chan time.Time if turnTimer != nil { turnC = turnTimer.C } if graceTimer != nil { graceC = graceTimer.C } + if rematchTimer != nil { + rematchC = rematchTimer.C + } select { case <-r.ctx.Done(): @@ -247,7 +279,21 @@ func (r *room) run() { } resetTurnTimer() case disconnectInput: - if r.handleDisconnect(m) { + // Leaving is how a rematch is declined, so a player who drops + // while the offer is open ends the room rather than leaving + // the other one watching a countdown that cannot resolve. + // + // handleDisconnect returning false means the notice was stale — + // from a connection the seat no longer holds — and acting on + // that would end a room whose players are both still here. + if r.offeringRematch() { + if applied, _ := r.handleDisconnect(m); applied { + r.abandonRematch() + return + } + break + } + if _, live := r.handleDisconnect(m); live { stop(graceTimer) graceTimer = time.NewTimer(r.graceFor) } @@ -257,10 +303,11 @@ func (r *room) run() { stop(graceTimer) graceTimer = nil resetTurnTimer() - } - - if r.engine != nil && r.engine.Over() { - return + case rematchInput: + r.handleRematch(m, time.Now()) + // A rematch that both sides accepted has already started a new + // game, so the turn clock has to come back with it. + resetTurnTimer() } case <-turnC: @@ -269,13 +316,33 @@ func (r *room) run() { // strictly after it. There is no window where both apply. if r.engine != nil && r.engine.Timeout(time.Now()) { r.broadcastGameOver() - return } resetTurnTimer() case <-graceC: r.endForAbandonment() return + + case <-rematchC: + // Nobody, or only one of them, asked in time. + r.abandonRematch() + return + } + + // Every way a game can end arrives here: a move, a resignation, a + // disconnection, or the turn clock. A finished game closes the room + // unless both players are still present to be asked for another. The + // offer check keeps this from firing again while one is already open. + if r.engine != nil && r.engine.Over() && !r.offeringRematch() { + if !r.offerRematch(time.Now()) { + return + } + stop(rematchTimer) + rematchTimer = time.NewTimer(r.rematchAfter) + } + if !r.offeringRematch() { + stop(rematchTimer) + rematchTimer = nil } } } @@ -364,7 +431,14 @@ func (r *room) beginGame() error { } r.engine = engine r.opening = opening - r.turnSeq = 1 + // Never restarts at 1. A rematch reuses the same connections, so a + // submission still in flight from the previous game would otherwise be + // able to match a turn in this one and be applied to it. + r.turnSeq++ + for _, s := range r.seats { + s.wantsRematch = false + } + r.rematchUntil = time.Time{} for _, s := range r.seats { r.sendGameStarted(s) @@ -543,12 +617,16 @@ func (r *room) broadcastGameOver() { // handleDisconnect holds the seat open, reporting whether a grace window // should now run. -func (r *room) handleDisconnect(m disconnectInput) bool { +// handleDisconnect reports two separate things, because the caller needs both +// and they are not the same question: whether the notice actually applied to +// the seat, and whether a game is still running that the player could come +// back to. A disconnection during a rematch offer applies and is not live. +func (r *room) handleDisconnect(m disconnectInput) (applied, live bool) { s := r.seatOf(m.player) // A stale notice from a connection the player already replaced. Evicting // on it would drop the seat the new socket is sitting in. if s == nil || s.sess == nil || s.sess != m.sess { - return false + return false, false } s.sess = nil @@ -556,7 +634,7 @@ func (r *room) handleDisconnect(m disconnectInput) bool { // room open for. if r.disconnected != nil && r.disconnected != s { r.cancel() - return false + return true, false } r.disconnected = s @@ -565,13 +643,17 @@ func (r *room) handleDisconnect(m disconnectInput) bool { // forever, holding a goroutine and a room code for a game nobody is in. if r.engine == nil { r.cancel() - return false + return true, false } + live = !r.engine.Over() if other := r.opponentSeat(s.id); other != nil && other.sess != nil { - other.sess.send(opponentLeftMsg(true, uint32(r.graceFor.Milliseconds()))) + // can_reconnect only means something while there is a game to come + // back to. Promising it after the final move contradicts the frame + // that follows it. + other.sess.send(opponentLeftMsg(live, uint32(r.graceFor.Milliseconds()))) } - return r.engine != nil && !r.engine.Over() + return true, live } // handleResume rebinds a seat to a new connection and replays the position. @@ -585,10 +667,21 @@ func (r *room) handleResume(m resumeInput) { m.sess.send(errorMsg("session_not_resumable")) return } + // A finished game has no seat to take, including one still waiting on a + // rematch answer. The old connection stays exactly as it was. if r.engine != nil && r.engine.Over() { m.sess.send(errorMsg("game_already_over")) return } + + // Accepted. Only now is the old connection finished: its token is spent and + // its socket is either gone or about to be, and leaving it registered would + // let a third connection claim the same seat. + m.sess.attach(r, string(m.player)) + if m.prior != nil { + m.sess.hub.unregister(m.prior.resumeToken) + m.prior.close() + } s.sess = m.sess // The seat keeps the name it was given. Re-reading it from the new // connection would let a reconnect rename a player mid-game, including @@ -597,6 +690,13 @@ func (r *room) handleResume(m resumeInput) { r.disconnected = nil } + // Tell the other player their opponent is back. Without this the seat is + // restored but the waiting player is left watching a disconnect banner for + // somebody who is already playing again. + if other := r.opponentSeat(s.id); other != nil && other.sess != nil { + other.sess.send(roomJoinedMsg(r.code, s.nickname)) + } + // Resumed into a room whose game has not started: the seat is restored and // the client waits for an opponent exactly as it was. if r.engine == nil { @@ -648,6 +748,90 @@ func (r *room) endForAbandonment() { } } +// offerRematch decides what a finished game means for the room, and reports +// whether the room should keep running. +// +// Only a room with two connected humans can offer one. A bot room has nothing +// to negotiate — the client simply asks for another game — and a room whose +// opponent has already gone has nobody to ask. +func (r *room) offerRematch(now time.Time) bool { + if r.strategy != nil { + return false + } + for _, s := range r.seats { + if s == nil || s.sess == nil { + return false + } + s.wantsRematch = false + } + + r.rematchUntil = now.Add(r.rematchAfter) + r.broadcastRematchState(now) + return true +} + +// offeringRematch reports whether an offer is currently open. +func (r *room) offeringRematch() bool { return !r.rematchUntil.IsZero() } + +// handleRematch records one player's answer and starts the next game once both +// have given it. +func (r *room) handleRematch(m rematchInput, now time.Time) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if !r.offeringRematch() { + m.sess.send(errorMsg("no_rematch_offered")) + return + } + + s := r.seatOf(m.player) + s.wantsRematch = true + + if !r.seats[0].wantsRematch || !r.seats[1].wantsRematch { + r.broadcastRematchState(now) + return + } + + // beginGame clears the offer and the acceptances, so the state broadcast + // above is not repeated here: GameStarted is the answer. + if err := r.beginGame(); err != nil { + slog.Error("could not start rematch", "room", r.code, "err", err) + r.broadcastError("game_start_failed") + r.cancel() + } +} + +// broadcastRematchState tells each player where both answers stand. It is +// built per recipient because "mine" and "theirs" are different for each. +func (r *room) broadcastRematchState(now time.Time) { + left := uint32(max(0, r.rematchUntil.Sub(now).Milliseconds())) + + for i, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RematchState{ + RematchState: &noituv1.RematchState{ + IAccepted: s.wantsRematch, + OpponentAccepted: r.seats[1-i].wantsRematch, + ExpiresInMs: left, + }, + }}) + } +} + +// abandonRematch tells whoever is still here that the offer is dead, so their +// countdown resolves into an answer instead of just running out. +func (r *room) abandonRematch() { + for _, s := range r.seats { + if s == nil || s.sess == nil { + continue + } + s.sess.send(opponentLeftMsg(false, 0)) + } +} + func (r *room) broadcastError(code string) { for _, s := range r.seats { if s != nil && s.sess != nil { diff --git a/server/internal/wsapi/server.go b/server/internal/wsapi/server.go index e68bff4..8a6b91c 100644 --- a/server/internal/wsapi/server.go +++ b/server/internal/wsapi/server.go @@ -20,6 +20,9 @@ type Config struct { TurnLimit time.Duration // GraceFor is how long a disconnected seat is held open. GraceFor time.Duration + // RematchFor is how long a finished room waits for both players to ask for + // another game. Zero falls back to a built-in default. + RematchFor time.Duration // AllowedOrigins is matched by coder/websocket against the Origin header. // Empty means same-origin only, which is the right default for a binary // that also serves the frontend. @@ -42,7 +45,7 @@ func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server { ctx, cancel := context.WithCancel(ctx) s := &Server{ - hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor), + hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.RematchFor), mux: http.NewServeMux(), cancel: cancel, cfg: cfg, diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go index 95d17ab..13f2020 100644 --- a/server/internal/wsapi/session.go +++ b/server/internal/wsapi/session.go @@ -111,9 +111,9 @@ func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP stri readCtx, cancelRead := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(ctx) return &session{ - readCtx: readCtx, - cancelRead: cancelRead, - flushed: make(chan struct{}), + readCtx: readCtx, + cancelRead: cancelRead, + flushed: make(chan struct{}), id: randomToken(), resumeToken: randomToken(), remoteIP: remoteIP, @@ -142,9 +142,19 @@ func (s *session) setNickname(n string) { // attach binds this connection to a room seat. func (s *session) attach(r *room, seatName string) { s.mu.Lock() - defer s.mu.Unlock() + previous, previousID := s.room, s.playerID s.room = r s.playerID = playerIDFor(seatName) + s.mu.Unlock() + + // Releasing the old room is not tidiness. Nothing else tells it this + // connection has gone: leaveRoom only ever notifies the current room, so an + // unreleased room parks in select forever, holding a goroutine and a room + // code for the life of the process. One connection asking for several rooms + // is all it takes. + if previous != nil && previous != r { + previous.send(disconnectInput{player: previousID, sess: s}) + } } func (s *session) currentRoom() (*room, game.PlayerID) { @@ -391,6 +401,24 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error { s.send(errorMsg("not_in_a_game")) } + case *noituv1.ClientMessage_RequestRematch: + // Rate-limited like a submission: every accepted request is broadcast + // to both seats, so an unbounded one lets a player flood the opponent's + // outbox until their session is closed for falling behind. + if !s.submitLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return nil + } + // A dropped request leaves the player watching a countdown that will + // never resolve, so it is worth an explicit refusal. + if r, id := s.currentRoom(); r != nil { + if !r.send(rematchInput{sess: s, player: id}) { + s.send(errorMsg("game_already_over")) + } + } else { + s.send(errorMsg("not_in_a_game")) + } + case *noituv1.ClientMessage_Ping: s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli())) } @@ -440,17 +468,13 @@ func (s *session) resumeFrom(prior *session) { s.send(errorMsg("game_already_over")) return } - s.attach(r, string(id)) - if !r.send(resumeInput{player: id, sess: s}) { + if !r.send(resumeInput{player: id, sess: s, prior: prior}) { s.send(errorMsg("game_already_over")) return } - - // The old connection is finished: its token is spent and its socket is - // either gone or about to be. Leaving it registered would let a third - // connection claim the same seat. - s.hub.unregister(prior.resumeToken) - prior.close() + // Deliberately no attach and no close here. The room has not decided yet, + // and a refused resume that had already closed the old connection would end + // the game it was trying to rejoin. } func (s *session) handleSubmit(w *noituv1.SubmitWord) { diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index 811466e..9baa69f 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -3,6 +3,7 @@ package wsapi import ( "context" "errors" + "fmt" "iter" "net/http/httptest" "runtime" @@ -111,6 +112,9 @@ func newTestServer(t *testing.T, dict Dictionary, cfg Config) (*Server, string) 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) @@ -201,8 +205,12 @@ func payloadCase(m *noituv1.ServerMessage) string { return "error" case *noituv1.ServerMessage_Pong: return "pong" + case *noituv1.ServerMessage_RematchState: + return "rematch_state" } - return "" + // 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 { @@ -911,3 +919,304 @@ func settle() { } 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") +}