feat(wsapi): offer a rematch when an online game ends

A finished room now stays alive to ask both players whether they want another,
and restarts with a fresh opening word once both do. Only a room with two
connected humans offers one: a bot has nothing to negotiate, so a bot room
closes the moment its game ends rather than leaking a goroutine and an engine
per finished game.

turn_seq no longer restarts at one. A rematch reuses the same connections, so a
submission still in flight from the previous game could otherwise match a turn
in the new one and be applied to it.

The end-of-game decision sits after the whole select, so every way a game can
end reaches it. Opening the offer from the message arm alone meant the turn
clock — the most common natural ending — closed the room with nothing to accept.

A resume is refused into a finished game, including one waiting on a rematch
answer, so the room now retires the connection being replaced only once it has
agreed to the swap. Retiring it up front ended the game the client was trying
to rejoin, which a duplicated tab was enough to trigger.

attach releases the room it is leaving. Nothing else told that room the
connection had gone, so a session asking for several rooms stranded all but the
last, each parked in select holding a goroutine and a room code for the life of
the process.

Also: RequestRematch is rate limited, because it is the only client message
that fans out to both players and an unbounded one lets a burst fill the
opponent's outbox until their session is closed for falling behind. And a
resume announces itself to the opponent, who was otherwise left watching a
disconnect banner for someone already playing again.
This commit is contained in:
tiennm99 committed 2026-09-05 14:17:47 +07:00
1 parent b541ddf0b5
commit aea9ea8cc0
5 files changed
+578 -56

No files matched your search

+36 -12
View File
@@ -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) {