feat(online): make a room a lobby with an owner and a ready-up

A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
tiennm99 committed 2026-09-07 16:32:14 +07:00
1 parent ef638e52fd
commit d481a093ec
39 files changed
+2352 -1215

No files matched your search

+47 -17
View File
@@ -157,6 +157,20 @@ func (s *session) attach(r *room, seatName string) {
}
}
// release forgets a room this connection is no longer seated in, because it
// left or was kicked. The connection itself stays open.
//
// Guarded by identity: a release from a room the connection has already moved
// on from must not detach it from the one it is sitting in now.
func (s *session) release(r *room) {
s.mu.Lock()
defer s.mu.Unlock()
if s.room == r {
s.room = nil
s.playerID = ""
}
}
func (s *session) currentRoom() (*room, game.PlayerID) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -401,23 +415,17 @@ 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_SetReady:
s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
case *noituv1.ClientMessage_StartGame:
s.toRoom(lobbyInput{sess: s, action: lobbyStart})
case *noituv1.ClientMessage_KickPlayer:
s.toRoom(lobbyInput{sess: s, action: lobbyKick})
case *noituv1.ClientMessage_LeaveRoom:
s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
case *noituv1.ClientMessage_Ping:
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
@@ -425,6 +433,28 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
return nil
}
// toRoom forwards one lobby action to the room this connection is seated in.
//
// Rate-limited like a submission: every accepted action is broadcast to both
// seats, so an unbounded one lets a player flood the other's outbox until
// their session is closed for falling behind. A dropped action would leave a
// button that did nothing and no reason why, so every failure answers.
func (s *session) toRoom(in lobbyInput) {
if !s.submitLimiter.allow(time.Now()) {
s.send(errorMsg("too_fast"))
return
}
r, id := s.currentRoom()
if r == nil {
s.send(errorMsg("not_in_a_room"))
return
}
in.player = id
if !r.send(in) {
s.send(errorMsg("not_in_a_room"))
}
}
// handleHello completes the handshake, resuming a prior game when the client
// presents a token that is still live.
func (s *session) handleHello(h *noituv1.Hello) error {