Files
noitu/server/internal/wsapi/room_presence.go
T
tiennm99 8f739e02ae refactor(wsapi): split room.go and session.go along their seams
room.go was 1919 lines with every room concern in one file. Pure
moves, no signature or behaviour changes: room.go keeps the struct,
its constructor, the input loop and the small seat-authority helpers;
room_inputs.go the message types; room_lobby.go seating and the lobby
between games; room_game.go everything that touches a running game;
room_presence.go the reconnect window and resume; room_chat.go the
room's own conversation; bot_board.go the bot's frozen view of a
position.

session.go was two unrelated halves in one 762-line file: the socket
(session.go, kept) and the protocol (dispatch.go, new) — dispatch and
the handshake/resume flow it routes into.

Verified with go build, go vet, golangci-lint and go test -race, and
by counting: every one of the 89 room.go and 27 session.go top-level
declarations appears in the split exactly once.
2026-09-21 16:25:20 +07:00

178 lines
6.1 KiB
Go

package wsapi
import (
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
)
// Presence: a seat's reconnect window, opening it, closing it, and what a
// resume does once a connection comes back inside one.
// disconnectGhostSeat opens the seat's reconnect window the moment it is
// filled, for a connection that turns out to have already torn down.
//
// The session can die between the hub handing this room the seating message
// and the room goroutine draining it off the queue — nothing else ever learns
// that, because leaveRoom only notifies a room the session was already
// attached to, and attaching is exactly what has not happened yet. Left
// seated as if connected, allConnected() would report true and quick match's
// own auto-start (see handleJoin) could begin a game against a socket nobody
// is behind. Applying the same grace window handleDisconnect would reuses the
// one mechanism that already bounds this instead of adding a second one.
func (r *room) disconnectGhostSeat(s *seat) {
s.sess = nil
s.graceUntil = time.Now().Add(r.graceFor)
}
// handleDisconnect holds the seat open for the player who dropped out of it.
//
// A dropped connection is not a player leaving. The seat is kept for the
// reconnect window whether a game is running or the room is sitting in its
// lobby, so refreshing the page does not cost somebody the room they are in.
//
// The turn clock is deliberately not paused. A player who drops on their own
// turn loses it the way anybody else would; the window decides only whether
// they are still in the game afterwards.
func (r *room) handleDisconnect(m disconnectInput) {
s := r.seatOf(m.player)
// A stale notice from a connection the player already replaced. Acting on
// it would evict the seat the new socket is sitting in.
if s == nil || s.sess == nil || s.sess != m.sess {
return
}
s.sess = nil
s.graceUntil = time.Now().Add(r.graceFor)
// Presence is part of the room's state, and the run loop is what sends it.
// There is nothing extra to say to the players who are still here.
r.lobbyChanged = true
}
// nextGraceExpiry is the earliest reconnect window still open.
func (r *room) nextGraceExpiry() (time.Time, bool) {
var next time.Time
for _, s := range r.seats {
if s == nil || s.sess != nil || s.graceUntil.IsZero() {
continue
}
if next.IsZero() || s.graceUntil.Before(next) {
next = s.graceUntil
}
}
return next, !next.IsZero()
}
// handleGraceExpiry frees every seat whose reconnect window has run out.
//
// The engine goes first, while the seats are still here to be named: once one
// is vacated there is nobody left to attribute the elimination to, and the
// players who stayed would be told that somebody with no name went out.
func (r *room) handleGraceExpiry() {
now := time.Now()
var expired []*seat
for _, s := range r.seats {
if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) {
continue
}
expired = append(expired, s)
}
if len(expired) == 0 {
return
}
before := r.mark()
for _, s := range expired {
r.eliminateAbsent(s, now)
}
r.applyEliminations(before)
for _, s := range expired {
r.vacate(s)
}
r.lobbyChanged = true
}
// eliminateAbsent takes a seat out of a live game once nobody is coming back
// to it.
//
// The engine is told this is a resignation, because that is the only shape it
// has for a player who stops playing. What the room reports is the transport
// fact instead: from everybody else's side this is somebody who left, not
// somebody who chose to give up.
func (r *room) eliminateAbsent(s *seat, now time.Time) {
if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) {
return
}
r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT
r.engine.Resign(s.id, now)
}
// handleResume rebinds a seat to a new connection and replays the position.
//
// The replay is built from the engine, never from stored copies of past
// messages: a recorded stream can drift from the real state, and the resumed
// client would then be shown a board the server does not believe in.
func (r *room) handleResume(m resumeInput) {
s := r.seatOf(m.player)
if s == nil {
m.sess.send(errorMsg("session_not_resumable"))
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.
metrics.resumesSucceeded.Add(1)
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
s.graceUntil = time.Time{}
// 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
// into somebody else's name.
// Everybody needs the room's state again: this player to render the lobby
// they came back to, the rest to stop watching a disconnect banner for
// somebody who is already back. The run loop sends it to all of them.
r.lobbyChanged = true
if m.sess.ctx.Err() != nil {
// The new connection can die between the client's Hello landing and
// this resume being drained off the room's queue, the same race
// handleCreate and handleJoin guard against. Reopening the window it
// just closed leaves the seat exactly as reachable as it was before
// this resume was ever attempted.
r.disconnectGhostSeat(s)
return
}
// Before the lobby return below, not after it: a refresh in the lobby is
// the commonest resume there is, and it is exactly the one that would miss
// a replay hung off the end of this function.
r.sendChatHistory(s)
// Resumed between games, or before the first one. The lobby state above is
// the whole answer; there is no position to replay.
if r.inLobby() {
return
}
state := r.engine.Snapshot()
r.sendGameStarted(s, state)
if last, ok := r.engine.LastMove(); ok {
r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last))
}
}
// detachAll releases every connection still bound to this room as it exits.
func (r *room) detachAll() {
for _, s := range r.seats {
if s != nil && s.sess != nil {
s.sess.release(r)
}
}
}