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.
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
|
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
|
)
|
|
|
|
// The protocol half of a connection: routing one decoded ClientMessage to
|
|
// whatever it means, and the handshake and resume flow that has to run
|
|
// before anything else is meaningful.
|
|
|
|
// dispatch routes one client message.
|
|
//
|
|
// Hello must come first: everything else needs a sanitized nickname and a
|
|
// registered resume token, and accepting them before the handshake would mean
|
|
// carrying "maybe not greeted yet" through every branch below.
|
|
func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
|
if _, isHello := msg.GetPayload().(*noituv1.ClientMessage_Hello); !isHello && s.nickname() == "" {
|
|
s.send(errorMsg("handshake_required"))
|
|
return errHandshake
|
|
}
|
|
|
|
switch p := msg.GetPayload().(type) {
|
|
case *noituv1.ClientMessage_Hello:
|
|
return s.handleHello(p.Hello)
|
|
|
|
case *noituv1.ClientMessage_StartBotGame:
|
|
// The limiter is charged before the payload is inspected, so a bad
|
|
// difficulty costs the same as a good one and cannot be used to probe
|
|
// for free.
|
|
if !s.roomLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_many_rooms"))
|
|
return nil
|
|
}
|
|
difficulty, ok := Difficulty(p.StartBotGame.GetDifficulty())
|
|
if !ok {
|
|
s.send(errorMsg("unknown_difficulty"))
|
|
return nil
|
|
}
|
|
if err := s.hub.startBotRoom(s, difficulty); err != nil {
|
|
s.send(roomCreateError(s.id, err))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_CreateRoom:
|
|
// Creating a room allocates a goroutine and an engine, so one
|
|
// connection must not be able to mint them without limit.
|
|
if !s.roomLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_many_rooms"))
|
|
return nil
|
|
}
|
|
if err := s.hub.createRoom(s); err != nil {
|
|
s.send(roomCreateError(s.id, err))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_JoinRoom:
|
|
if !s.hub.joinLimiter.allow(s.remoteIP, time.Now()) {
|
|
metrics.joinsRefused.Add("too_many_attempts", 1)
|
|
s.send(errorMsg("too_many_attempts"))
|
|
return nil
|
|
}
|
|
if err := s.hub.joinRoom(p.JoinRoom.GetRoomCode(), s); err != nil {
|
|
metrics.joinsRefused.Add("room_not_found", 1)
|
|
s.send(errorMsg("room_not_found"))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_QuickMatch:
|
|
if r, _ := s.currentRoom(); r != nil {
|
|
s.send(errorMsg("already_in_a_room"))
|
|
return nil
|
|
}
|
|
// A match mints a room exactly as CreateRoom does, so it is charged
|
|
// the same way and for the same reason.
|
|
if !s.roomLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_many_rooms"))
|
|
return nil
|
|
}
|
|
if err := s.hub.quickMatch(s); err != nil {
|
|
if errors.Is(err, errAlreadyQueued) {
|
|
s.send(errorMsg("already_queued"))
|
|
} else {
|
|
s.send(roomCreateError(s.id, err))
|
|
}
|
|
}
|
|
|
|
case *noituv1.ClientMessage_CancelQuickMatch:
|
|
// Idempotent by design: a cancel that finds nothing queued is not an
|
|
// error, it is the answer the player wanted.
|
|
s.hub.cancelQuickMatch(s)
|
|
s.send(quickMatchStatusMsg(false))
|
|
|
|
case *noituv1.ClientMessage_SubmitWord:
|
|
s.handleSubmit(p.SubmitWord)
|
|
|
|
case *noituv1.ClientMessage_Resign:
|
|
// A silently dropped resignation leaves the player staring at a board
|
|
// they thought they had left.
|
|
if r, id := s.currentRoom(); r != nil {
|
|
if !r.send(resignInput{sess: s, player: id}) {
|
|
s.send(errorMsg("game_already_over"))
|
|
}
|
|
} else {
|
|
s.send(errorMsg("not_in_a_game"))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_ClaimDeadEnd:
|
|
// Rate-limited on the same budget as a submission: a claim is the
|
|
// alternative to playing a word, not a second action alongside it.
|
|
if !s.submitLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_fast"))
|
|
return nil
|
|
}
|
|
if r, id := s.currentRoom(); r != nil {
|
|
if !r.send(claimDeadEndInput{sess: s, player: id}) {
|
|
s.send(errorMsg("busy"))
|
|
}
|
|
} else {
|
|
s.send(errorMsg("not_in_a_game"))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_ReportWord:
|
|
s.handleReportWord(p.ReportWord)
|
|
|
|
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, target: playerIDFor(p.KickPlayer.GetPlayerId())})
|
|
|
|
case *noituv1.ClientMessage_LeaveRoom:
|
|
s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
|
|
|
|
case *noituv1.ClientMessage_SendChat:
|
|
// Its own budget, so a talkative player never runs out of moves. The
|
|
// seat itself is checked by the room, which is the only place that
|
|
// knows whether this connection still holds one.
|
|
if !s.chatLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_fast"))
|
|
return nil
|
|
}
|
|
r, id := s.currentRoom()
|
|
if r == nil {
|
|
s.send(errorMsg("not_in_a_room"))
|
|
return nil
|
|
}
|
|
// A dropped line would leave the player watching their own message
|
|
// fail to appear with no reason given.
|
|
if !r.send(chatInput{sess: s, player: id, text: p.SendChat.GetText()}) {
|
|
s.send(errorMsg("busy"))
|
|
}
|
|
|
|
case *noituv1.ClientMessage_Ping:
|
|
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// roomCreateError names the refusal a room could not be opened for. A full
|
|
// server is the player's business — they should wait, not retry at once — and
|
|
// anything else is the server's, logged here because the client is only told
|
|
// that it failed.
|
|
func roomCreateError(sessionID string, err error) *noituv1.ServerMessage {
|
|
if errors.Is(err, errServerFull) {
|
|
return errorMsg("server_full")
|
|
}
|
|
if errors.Is(err, errDraining) {
|
|
// The same key Shutdown sends to everyone already seated: a room
|
|
// refused for this reason will not open a moment later the way a full
|
|
// one might, so the client is told the same thing either way.
|
|
return errorMsg("server_restarting")
|
|
}
|
|
slog.Error("open room", "session", sessionID, "err", err)
|
|
return errorMsg("room_start_failed")
|
|
}
|
|
|
|
// toRoom forwards one lobby action to the room this connection is seated in.
|
|
//
|
|
// Rate-limited like a submission: every accepted action is broadcast to every
|
|
// seat, 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 {
|
|
if v := h.GetProtocolVersion(); v != ProtocolVersion {
|
|
s.send(errorMsg("protocol_version_mismatch"))
|
|
return errors.New("wsapi: protocol version mismatch")
|
|
}
|
|
|
|
// The handshake is a one-shot transition. A second Hello would re-register
|
|
// the session and rewrite the nickname of a player already seated in a
|
|
// game, which nothing downstream expects.
|
|
s.mu.Lock()
|
|
repeat := s.greeted
|
|
s.greeted = true
|
|
s.mu.Unlock()
|
|
if repeat {
|
|
s.send(errorMsg("already_greeted"))
|
|
return errors.New("wsapi: repeated hello")
|
|
}
|
|
|
|
s.setNickname(sanitizeNickname(h.GetNickname()))
|
|
s.hub.register(s)
|
|
s.send(welcomeMsg(s.id, s.resumeToken, s.nickname()))
|
|
|
|
token := h.GetResumeToken()
|
|
switch prior, ok := s.hub.resumable(token); {
|
|
case ok && prior != s:
|
|
s.resumeFrom(prior)
|
|
case token != "" && !ok:
|
|
// A token the server restarted since, or that outlived its grace
|
|
// window, resolves to nothing. Silence here left the client's resume
|
|
// latch waiting forever for a reply that was never coming — this
|
|
// connection is answered and carries on as a fresh session instead of
|
|
// being closed, since a fresh session is exactly what it is.
|
|
s.send(errorMsg("session_not_resumable"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resumeFrom takes over the seat a previous connection held.
|
|
//
|
|
// Every failing branch has to say so. A token can outlive its game — the turn
|
|
// clock keeps running through the grace window, so a player who dropped on
|
|
// their own turn loses before the window closes — and a client that got a
|
|
// Welcome and then silence has nothing to render and no reason to stop
|
|
// waiting.
|
|
func (s *session) resumeFrom(prior *session) {
|
|
metrics.resumesAttempted.Add(1)
|
|
r, id := prior.currentRoom()
|
|
if r == nil {
|
|
s.send(errorMsg("game_already_over"))
|
|
return
|
|
}
|
|
if !r.send(resumeInput{player: id, sess: s, prior: prior}) {
|
|
s.send(errorMsg("game_already_over"))
|
|
return
|
|
}
|
|
// 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) {
|
|
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_game"))
|
|
return
|
|
}
|
|
// A dropped submission would otherwise leave the player waiting out the
|
|
// turn clock with no idea their word never arrived.
|
|
if !r.send(submitInput{sess: s, player: id, word: w.GetWord(), turnSeq: w.GetTurnSeq()}) {
|
|
s.send(errorMsg("busy"))
|
|
}
|
|
}
|
|
|
|
// handleReportWord validates a word report and, once it is worth logging,
|
|
// hands it to the current room for the context only the room goroutine may
|
|
// read — the syllable in play, and the room's own mode and code.
|
|
//
|
|
// Validation happens here rather than in the room because it is entirely
|
|
// about this connection: its own rate budget, and its own running count of
|
|
// distinct words already filed. Neither needs the room at all, and a session
|
|
// playing no game — smoke-testing the wire directly, per the README — can
|
|
// still file a report, acknowledged with mode "none" and no link.
|
|
func (s *session) handleReportWord(m *noituv1.ReportWord) {
|
|
if !s.chatLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_fast"))
|
|
return
|
|
}
|
|
|
|
word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks))
|
|
if err != nil || !vietnamese.HasEnoughSyllables(syllables) {
|
|
s.send(errorMsg("word_report_refused"))
|
|
return
|
|
}
|
|
|
|
if _, already := s.reportedWords[word]; !already {
|
|
if len(s.reportedWords) >= maxWordReportsPerSession {
|
|
s.send(errorMsg("word_report_limit"))
|
|
return
|
|
}
|
|
s.reportedWords[word] = struct{}{}
|
|
}
|
|
|
|
// currentRoom's player id is not needed here: the log line is about the
|
|
// word and the room's context, never about who filed it.
|
|
if r, _ := s.currentRoom(); r != nil {
|
|
if !r.send(reportWordInput{sess: s, word: word}) {
|
|
s.send(errorMsg("busy"))
|
|
}
|
|
return
|
|
}
|
|
|
|
metrics.wordsReported.Add(1)
|
|
slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "")
|
|
s.send(wordReportedMsg(word))
|
|
}
|
|
|
|
// leaveRoom tells the room this connection is gone, so the seat enters its
|
|
// grace window rather than the game simply stalling.
|
|
func (s *session) leaveRoom() {
|
|
r, id := s.currentRoom()
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.send(disconnectInput{player: id, sess: s})
|
|
}
|