Files
tiennm99 81234b5ab9 fix(wsapi): stop a kicked token resuming into another seat, harden limits
A held seat now remembers which connection it waits for, so a token
from a kicked player is refused instead of landing in whoever took the
seat. A room answers every input left in its inbox when it exits,
disconnect notices no longer share the lossy inbox, quick-match no
longer leaves autoStart armed after a pairing that never started, only
lobby changes restart the idle window, and draining refuses new queue
entries.

Sockets that never send Hello close after ten seconds and the room
budget is charged per address, so one client cannot hold the global
caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide
rate limit with a suppressed counter. Responses carry nosniff,
frame-ancestors and referrer headers. Unicode spaces in a word become
spaces rather than vanishing, and blank-rendering letters are dropped
from names and chat. A resume into a lobby whose game ended during the
absence is replayed that seat's GameOver. Unknown payloads get
unknown_message.
2026-09-29 20:33:16 +07:00

630 lines
22 KiB
Go

package wsapi
import (
"context"
"iter"
"log/slog"
"sync/atomic"
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/bot"
"github.com/tiennm99dev/noitu/server/internal/dictionary"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// This file holds the room's core type, its constructor, its input loop,
// and the small seat helpers every other file in this package reads. The
// handlers are split by topic (room_lobby.go, room_game.go,
// room_presence.go, room_chat.go) — a file boundary, not a change to who may
// touch a *room: all of it runs on the room goroutine.
// roomModeBot and roomModePvP are the two values a room's mode ever takes.
// They double as the label under which every mode-keyed metric and the
// word_rejected log line group their counts, so a reader checking one against
// the other is checking against the same string everywhere.
const (
roomModeBot = "bot"
roomModePvP = "pvp"
)
// botPlayerID is the seat the bot occupies. It is a normal player to the
// engine, which is the whole point: the bot's moves go through the same
// validation as a human's, so there is one rule implementation rather than two.
const botPlayerID game.PlayerID = "bot"
// maxPlayers is how many seats a room has, and minPlayers how many it takes
// to start one. Both are sent to the client in RoomState rather than compiled
// into it, so the lobby draws whatever the server allows and widening a room
// is a server change alone.
const (
maxPlayers = 4
minPlayers = 2
)
// seatIDs are the engine seat names, indexed by position. An id says which
// seat a player is in and nothing about their role: an owner who leaves hands
// that on, and the seat they vacate is refilled by an ordinary guest.
var seatIDs = [maxPlayers]game.PlayerID{"p1", "p2", "p3", "p4"}
// minOpeningOutDegree keeps the first word from being a dead end. Opening on a
// syllable with two continuations makes for a game that ends before it starts.
const minOpeningOutDegree = 20
// maxSuggestions is how many of the words still playable a losing player is
// shown. Enough to see what the position wanted, few enough that it reads as
// a hint rather than a dump of the dictionary.
const maxSuggestions = 3
// chatHistoryLimit is how many messages a room keeps, and the same window the
// client holds. Enough to catch up on after a reload, few enough that a room
// that lives all day cannot grow.
const chatHistoryLimit = 20
// maxChatRunes caps one message, counted in runes for the reason
// maxNicknameRunes is.
const maxChatRunes = 200
// maxWordRunes caps a submitted word before the engine sees it. The longest
// dictionary entries are well under this, so it bounds abuse without ever
// deciding a real move.
const maxWordRunes = 64
// maxChatMarks caps mark stacking in a message, as maxNicknameMarks does for a
// name. A message is ten times longer, so the same stack does ten times more
// damage.
const maxChatMarks = 2
// roomInputCap buffers the room's inbox. A sender that finds it full is either
// flooding past the rate limiter or racing a room that is shutting down;
// neither is worth blocking a session goroutine for.
const roomInputCap = 32
// defaultIdleWindow is how long a lobby nobody starts a game in stays open
// when nothing else is configured.
//
// A room now outlives its games, so something has to bound it: without this
// one open tab holds a goroutine and a room code for the life of the process.
// Long enough to read an invite and talk about it, short enough that abandoned
// rooms do not accumulate. A running game needs no such bound — the turn clock
// already ends it.
const defaultIdleWindow = 10 * time.Minute
// seat is one side of a game.
type seat struct {
id game.PlayerID
nickname string
sess *session // nil for the bot, or while a human is disconnected
// ready is this seat's declaration that it wants the next game to start.
// Only ever set on the guest's seat: the owner's readiness is StartGame
// itself. Cleared whenever a game begins, so every game is agreed again.
ready bool
// chatFrom is where the room's conversation stood when this seat was
// filled. A replay starts there, which is what keeps a stranger who walks
// in with the code from being handed what the last two people said.
chatFrom uint64
// wins counts the games this seat has taken since it was filled. A room
// outlives its games, so a running tally has to live on something that
// does too; the seat is the shortest-lived thing that still spans them,
// and vacating it is exactly when the tally stops meaning one player.
wins uint32
// graceUntil is when this seat stops being held for the player who dropped
// out of it, and zero while they are connected. Per seat rather than per
// room because any number of them can be waiting at once.
graceUntil time.Time
// heldFor is the connection whose drop opened graceUntil. A resume is
// honoured only for that connection's token: seat ids are reused, and a
// seat that was vacated and refilled is a new *seat, so a token from the
// player who used to hold the id can never match it.
heldFor *session
// missedResult is the GameOver this seat was owed when the last game ended
// while nobody was connected to it, rendered for this seat. It is replayed
// once to the connection that resumes into the lobby, and dropped when the
// next game begins.
missedResult *noituv1.ServerMessage
}
// room owns one game.
//
// Every field below is touched only by the room goroutine after start. The
// exceptions are inputs and ctx, which exist precisely to be used from outside.
type room struct {
code string
inputs chan any
ctx context.Context
cancel context.CancelFunc
hub *hub
dict Dictionary
engine *game.Engine
opening string
strategy bot.Strategy
turnLimit time.Duration
graceFor time.Duration
idleFor time.Duration
// mode is roomModeBot or roomModePvP, fixed at creation. It is the label
// every mode-keyed metric and the word_rejected log line use, kept as its
// own field rather than re-derived from strategy == nil so the hub can set
// it before the room goroutine has seated anyone or built an engine.
mode string
// liveCounted mirrors whether this room's game is the one hub.liveGames is
// currently counting. Atomic rather than plain, because drain reads
// hub.liveGameCount(), and a seated session asks whether it is mid-game,
// from outside the room goroutine while this flips on the goroutine itself; the CompareAndSwap in run's teardown is what
// guarantees exactly one hub.gameFinished() per hub.gameStarted() even when
// the room is cancelled mid-game instead of finishing normally.
liveCounted atomic.Bool
// autoStart marks a room opened by a quick match. Once both seats are
// filled and connected it begins its own first game — see handleJoin —
// and the first join the room handles consumes it, whether or not a game
// started, so every later game is agreed with readiness and StartGame like
// any other.
autoStart bool
seats [maxPlayers]*seat
// owner is the seat that may start a game and free the other one. It is a
// field rather than "seats[0]" because the role outlives the player who
// held it: an owner who leaves hands it to whoever is still here, and the
// seat they vacate is then filled by an ordinary guest.
owner game.PlayerID
// turnSeq increments on every turn change. A client stamps its submission
// with the sequence it was answering, so a move that crosses the deadline
// is identifiable rather than silently applied to the next turn.
turnSeq uint32
// outWire overrides how one player's elimination is reported, for the
// cases the engine cannot know about. A reconnect window running out is
// the only one: to the engine that is a resignation, and to the other
// players it is somebody who left.
outWire map[game.PlayerID]noituv1.GameEndReason
// chat is the room's recent conversation, oldest first, capped at
// chatHistoryLimit. It belongs to the room, so it outlives each game and
// dies only with the room itself.
chat []chatEntry
// chatSeq counts every message the room has accepted, ever. It keeps
// rising as the history is trimmed, which is what makes a seat's chatFrom
// meaningful after the entry it pointed at has been dropped.
chatSeq uint64
// lobbyChanged marks that something a player can see about the room's
// occupants has changed: a seat filled or freed, a readiness set, an owner
// promoted, a game finished. The run loop turns it into exactly one
// RoomState broadcast per input, which is why no handler has to remember
// to send one.
lobbyChanged bool
// resumeReplays are seats whose resume has been accepted and that are owed
// their missed result. It waits here instead of being sent from the
// handler because the RoomState it must follow is broadcast by the run
// loop, after the handler returns.
resumeReplays []*seat
}
// Dictionary is everything the transport layer needs from the wordlist: the
// engine's own contract, a way to pick an opening, and the meanings a word
// travels to the client with. The engine never sees a meaning; only the room
// attaches them, where it renders a word for a recipient.
//
// An interface rather than *dictionary.Store so a test can play a whole game
// against a hand-built graph of a dozen words, where the expected outcome is
// something a reader can verify by eye. *dictionary.Store satisfies it as
// written.
type Dictionary interface {
game.Dictionary
RandomOpeningWord(minOutDegree int) (string, error)
// Meanings returns a canonical word's senses in order, nil for none.
Meanings(word string) []dictionary.Sense
// NearMiss finds the one real word a normalized submission differs from by
// diacritics alone, reported only when exactly one such word exists.
NearMiss(normalized string) (string, bool)
}
func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration, mode string) *room {
if idleFor <= 0 {
idleFor = defaultIdleWindow
}
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,
idleFor: idleFor,
mode: mode,
}
}
// send hands a message to the room without ever blocking the caller.
//
// A session goroutine must not be able to stall on a room: that would let one
// wedged game hold a connection open with no way out. A dropped message is
// recoverable — the client retries or the game times out — while a deadlock is
// not.
func (r *room) send(msg any) bool {
// Check for a finished room first, on its own. Folding this into the
// select below would make it a coin flip: the buffered channel and the
// done channel are both ready, so select picks at random and half the
// sends into a dead room report success. The caller then believes the
// message is on its way to a goroutine that stopped reading, and whoever
// was waiting for the reply waits forever.
select {
case <-r.ctx.Done():
return false
default:
}
select {
case r.inputs <- msg:
return true
case <-r.ctx.Done():
return false
default:
slog.Warn("room inbox full, dropping message", "room", r.code)
return false
}
}
// sendReliably hands a message to the room, waiting for space in its inbox.
//
// For the notices whose loss is never recoverable — a connection has gone, so
// its seat must start its reconnect window. Only for callers that may block
// safely: a session goroutine that is ending, or one spawned for the purpose.
// It gives up when the room is finished, since nothing will read it then.
func (r *room) sendReliably(msg any) {
select {
case r.inputs <- msg:
case <-r.ctx.Done():
}
}
// run is the room goroutine. It is the only place the engine is touched.
func (r *room) run() {
// Registered first, so it runs last: after cancel, when nothing new can be
// accepted, it answers whatever was still queued behind the final input.
defer r.refusePending()
defer r.hub.evict(r.code)
defer metrics.roomsLive.Add(r.mode, -1)
// Catches a room cancelled with a game still running — drain forcing the
// last stragglers closed, or a shutdown mid-game — which never reaches
// broadcastGameOver's own call.
defer r.stopCountingLive()
// Whatever ended the room — everybody leaving, the idle window, a server
// shutdown — the connections still seated in it must stop pointing here.
// A session that keeps a dead room would answer every later action with
// "not in a room" and could never be seated anywhere else cleanly.
defer r.detachAll()
// Registered last, so it runs first: from here on send refuses, and the
// queue refusePending drains can only shrink.
defer r.cancel()
var turnTimer, graceTimer, idleTimer *time.Timer
stop := func(t *time.Timer) {
if t != nil {
t.Stop()
}
}
defer func() {
stop(turnTimer)
stop(graceTimer)
stop(idleTimer)
}()
// resetTurnTimer rebuilds the deadline timer after anything that changes
// whose turn it is. Recreating rather than resetting sidesteps the drain
// problem entirely: a stopped timer's stale fire can never reach the
// select because that channel is no longer the one being read.
resetTurnTimer := func() {
stop(turnTimer)
turnTimer = nil
if r.engine == nil || r.engine.Over() {
return
}
turnTimer = time.NewTimer(time.Until(r.engine.Deadline()))
}
// resetGraceTimer arms one timer for the earliest reconnect window still
// open. Several seats can be waiting at once, and a timer each would be a
// timer per player to stop, drain and reason about; one wakeup at the
// nearest deadline settles every window that has passed by the time it
// fires.
resetGraceTimer := func() {
stop(graceTimer)
graceTimer = nil
next, waiting := r.nextGraceExpiry()
if !waiting {
return
}
graceTimer = time.NewTimer(time.Until(next))
}
// resetIdleTimer restarts the lobby's own deadline. It runs only while no
// game does: a game is bounded by the turn clock, and a room that is being
// played in is not idle.
resetIdleTimer := func() {
stop(idleTimer)
idleTimer = nil
if r.strategy != nil || !r.inLobby() {
return
}
idleTimer = time.NewTimer(r.idleFor)
}
for {
var turnC, graceC, idleC <-chan time.Time
if turnTimer != nil {
turnC = turnTimer.C
}
if graceTimer != nil {
graceC = graceTimer.C
}
if idleTimer != nil {
idleC = idleTimer.C
}
select {
case <-r.ctx.Done():
return
case msg := <-r.inputs:
switch m := msg.(type) {
case createInput:
r.handleCreate(m)
case startBotInput:
r.handleStartBot(m)
case joinInput:
r.handleJoin(m)
case submitInput:
r.handleSubmit(m)
case botMoveInput:
r.handleBotMove(m)
case lobbyInput:
r.handleLobby(m)
case chatInput:
r.handleChat(m)
case resignInput:
r.handleResign(m)
case claimDeadEndInput:
r.handleClaimDeadEnd(m)
case reportWordInput:
r.handleReportWord(m)
case disconnectInput:
// A dropped connection is not a player leaving: the seat is
// held for the reconnect window whether a game is running or
// the room is sitting in its lobby, so a refresh does not cost
// somebody their room.
r.handleDisconnect(m)
case resumeInput:
r.handleResume(m)
}
// Every input can move the turn, open or close a reconnect window,
// or both — an elimination does all of it at once. Recomputing both
// timers here rather than in each arm is what keeps a new input
// type from silently forgetting one.
resetTurnTimer()
resetGraceTimer()
case <-turnC:
// The timer and every message land on the same select, so a move
// that arrives at the deadline is either strictly before or
// strictly after it. There is no window where both apply.
if r.engine != nil {
before := r.mark()
if r.engine.Timeout(time.Now()) {
r.applyEliminations(before)
}
}
resetTurnTimer()
case <-graceC:
graceTimer = nil
r.handleGraceExpiry()
resetTurnTimer()
resetGraceTimer()
case <-idleC:
// A lobby nobody started a game in. Whoever is still sitting in it
// is told why it closed rather than watching their buttons stop
// working.
r.broadcastError(codeRoomIdleClosed)
return
}
// Whether the room changed is what keeps a lobby alive, so it is read
// before the broadcast below clears it. An input that was refused, or
// that only talked, changed nothing and leaves the idle window running;
// otherwise a refused action every few minutes would hold a lobby open
// for good.
changed := r.lobbyChanged
// One broadcast per input, from the one place that knows the input is
// finished. A kick, a grace window running out and a game ending all
// leave the room in the same state — a lobby — and this is where that
// state goes out.
if r.strategy == nil && r.lobbyChanged {
r.lobbyChanged = false
r.broadcastRoomState()
// After the state, so a resumed client is shown the lobby before
// the result of the game that ended in its absence.
r.flushResumeReplays()
}
// A bot room is its game: there is no lobby to return to and nobody to
// wait for, so it closes with the last move.
if r.strategy != nil && r.engine != nil && r.engine.Over() {
return
}
// Everyone has left, or the last reconnect window ran out. Nothing is
// coming that could fill the room again — a joiner needs a code the
// hub is about to forget.
if !r.occupied() {
return
}
// The window also has to start or stop when the room crosses between
// lobby and game, which a game beginning does without marking the
// lobby changed.
if wantIdle := r.strategy == nil && r.inLobby(); changed || wantIdle != (idleTimer != nil) {
resetIdleTimer()
}
}
}
// refusePending answers every input still queued as the room exits. Each one
// came from a caller waiting on a reply — a join that lost the race with the
// last player leaving, a resume behind the last grace timer — and without an
// answer that caller waits forever. Called once the room is cancelled, so
// send already refuses new input and the drain is finite.
func (r *room) refusePending() {
for {
select {
case msg := <-r.inputs:
switch m := msg.(type) {
case createInput:
m.sess.send(errorMsg(codeRoomStartFailed))
case startBotInput:
m.sess.send(errorMsg(codeRoomStartFailed))
case joinInput:
m.sess.send(errorMsg(codeRoomNotFound))
case resumeInput:
m.sess.send(errorMsg(codeSessionNotResumable))
case submitInput:
m.sess.send(errorMsg(codeNotInAGame))
case resignInput:
m.sess.send(errorMsg(codeNotInAGame))
case claimDeadEndInput:
m.sess.send(errorMsg(codeNotInAGame))
case lobbyInput:
m.sess.send(errorMsg(codeNotInARoom))
case chatInput:
m.sess.send(errorMsg(codeNotInARoom))
case reportWordInput:
m.sess.send(errorMsg(codeNotInARoom))
}
default:
return
}
}
}
// occupies reports whether this connection is the one seated at p.
//
// The seat, not the claimed id, is the authority: a session that was never
// seated here — or was replaced by a reconnect — must not be able to act.
func (r *room) occupies(sess *session, p game.PlayerID) bool {
s := r.seatOf(p)
return s != nil && s.sess != nil && s.sess == sess
}
// inLobby reports whether the room is between games. Everything a lobby
// allows is refused while a game is running, and the engine is the authority
// on that.
func (r *room) inLobby() bool { return r.engine == nil || r.engine.Over() }
// occupied reports whether anybody still holds a seat, including a player
// inside their reconnect window. An empty room has nothing left to wait for.
func (r *room) occupied() bool { return r.seatedCount() > 0 }
// freeSeat returns the index a joiner would take, or -1 when the room is full.
func (r *room) freeSeat() int {
for i, s := range r.seats {
if s == nil {
return i
}
}
return -1
}
// seatedCount is how many seats are held, including by players inside their
// reconnect window.
func (r *room) seatedCount() int {
n := 0
for _, s := range r.seats {
if s != nil {
n++
}
}
return n
}
// allConnected reports whether every seated player has a socket. A game cannot
// start without one, because the first thing it does is deal everybody a turn.
func (r *room) allConnected() bool {
for _, s := range r.seats {
if s != nil && s.sess == nil {
return false
}
}
return true
}
// connected yields every seat with a socket behind it, in seat order: the
// recipients of anything the room says. A seat inside its reconnect window
// is skipped, and is caught up by the resume that ends it.
func (r *room) connected() iter.Seq[*seat] {
return func(yield func(*seat) bool) {
for _, s := range r.seats {
if s != nil && s.sess != nil && !yield(s) {
return
}
}
}
}
func (r *room) broadcastError(code errCode) {
for s := range r.connected() {
s.sess.send(errorMsg(code))
}
}
func (r *room) seatOf(p game.PlayerID) *seat {
for _, s := range r.seats {
if s != nil && s.id == p {
return s
}
}
return nil
}
// takeSeat puts sess in seat index i and binds the connection to it.
//
// Every way into a room goes through here — creating one, joining one,
// starting a bot game — so each is also where the connection stops waiting
// for a quick match: a plain CreateRoom might be seating somebody who was
// queued from another attempt, and one dequeue serves every entry path.
//
// The caller checks the connection is still alive afterwards. It can die
// between the hub handing this room the seating message and the room
// goroutine draining it, and nothing else would ever tell the room so.
func (r *room) takeSeat(i int, nickname string, sess *session) *seat {
s := &seat{
id: seatIDs[i],
nickname: nickname,
sess: sess,
// Seated now, so the conversation up to this point is not theirs to
// read. A room code is pasted into group chats by design.
chatFrom: r.chatSeq,
}
r.seats[i] = s
sess.attach(r, s.id)
r.hub.cancelQuickMatch(sess)
return s
}
// stopCountingLive ends this room's share of hub.liveGames, once. Both a game
// finishing and the room exiting mid-game reach here, and the CompareAndSwap
// is what keeps the pair from counting one game's end twice.
func (r *room) stopCountingLive() {
if r.liveCounted.CompareAndSwap(true, false) {
r.hub.gameFinished()
}
}