mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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.
178 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|