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.
This commit is contained in:
tiennm99 committed 2026-09-21 16:25:20 +07:00
1 parent 8008bf6318
commit 8f739e02ae
9 files changed
+1817 -1730

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
package wsapi
import (
"iter"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// The bot's read-only view of a position, frozen off the engine on the room
// goroutine before a worker goroutine exists to race it.
// frozenBoard is an immutable position for a bot worker to search.
//
// It satisfies bot.Board without holding the engine. The dictionary is safe to
// share — the store loads once at Open and is read-only thereafter — but the
// used set is engine state, so it is copied.
type frozenBoard struct {
legal []string
used map[string]struct{}
dict game.Dictionary
}
func freezeBoard(e *game.Engine) *frozenBoard {
// UsedWords already includes the opening word — it seeds the engine's own
// set — so there is nothing left to add here, and nothing to copy out of a
// history that grows with the game.
used := make(map[string]struct{})
for word := range e.UsedWords() {
used[word] = struct{}{}
}
return &frozenBoard{legal: e.LegalMoves(), used: used, dict: e.Dict()}
}
func (b *frozenBoard) LegalMoves() []string { return b.legal }
func (b *frozenBoard) Used(word string) bool {
_, ok := b.used[word]
return ok
}
func (b *frozenBoard) WordsStartingWith(syllable string) iter.Seq[string] {
return b.dict.WordsStartingWith(syllable)
}
func (b *frozenBoard) LastSyllable(word string) (string, bool) {
return b.dict.LastSyllable(word)
}
+334
View File
@@ -0,0 +1,334 @@
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})
}
File diff suppressed because it is too large. Load diff
+125
View File
@@ -0,0 +1,125 @@
package wsapi
import (
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// The room's own conversation, independent of whatever game is or is not
// running in it.
// chatEntry is one line of the room's conversation.
type chatEntry struct {
// seq is this message's place in the room's whole conversation, compared
// against a seat's chatFrom to decide what that player may be replayed.
seq uint64
// author and name are cleared together when the seat is vacated: the words
// stay, the attribution does not. Keeping the name would let the next
// person to request that nickname inherit a stranger's messages, since
// distinguish only compares against the seat that is currently occupied.
author game.PlayerID
name string
text string
at time.Time
}
// handleChat delivers one line of text to everybody in the room.
func (r *room) handleChat(m chatInput) {
// The seat, not the claimed id. A connection the room has already retired
// - kicked, or replaced by a reconnect - can still have a frame in flight,
// and by the time the room drains it that seat may belong to somebody else.
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
// A bot room has no conversation. Checked here rather than in the session,
// because r.strategy is room-goroutine state.
if r.strategy != nil {
m.sess.send(errorMsg("not_in_a_room"))
return
}
text := sanitizeText(m.text, maxChatRunes, maxChatMarks)
// Nothing usable survived. There is no message to refuse and nobody to
// tell: the client will not enable its send button for input that reduces
// to this, so anything reaching here typed nothing.
if text == "" {
return
}
from := r.seatOf(m.player)
r.chatSeq++
entry := chatEntry{
seq: r.chatSeq,
author: from.id,
name: from.nickname,
text: text,
at: time.Now(),
}
r.chat = append(r.chat, entry)
if len(r.chat) > chatHistoryLimit {
r.chat = r.chat[len(r.chat)-chatHistoryLimit:]
}
metrics.chatLines.Add(1)
for _, s := range r.seats {
if s == nil || s.sess == nil {
continue
}
// Best effort: a chat frame is dropped rather than allowed to close a
// session whose outbox is full. Losing a line is recoverable - the
// next replay carries it - and closing a session costs its owner the
// game.
s.sess.trySend(chatMessageFor(entry, s.id))
}
}
// sendChatHistory replays one seat's slice of the conversation.
//
// Scoped by the seat's chatFrom: a player is shown what was said while they
// were sitting there and nothing else. Sent from the handler, so it reaches the
// client before that input's RoomState - the client must not depend on the
// order, and does not, because a history replaces its panel wholesale.
func (r *room) sendChatHistory(s *seat) {
if s == nil || s.sess == nil || r.strategy != nil {
return
}
messages := make([]*noituv1.ChatMessage, 0, len(r.chat))
for _, entry := range r.chat {
if entry.seq <= s.chatFrom {
continue
}
messages = append(messages, chatMessageFor(entry, s.id).GetChatMessage())
}
// send, not trySend: this is the frame that corrects a client's whole
// panel, including the empty one that clears a conversation carried in
// from another room. A dropped line recovers on the next replay; a dropped
// replay has nothing behind it.
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatHistory{
ChatHistory: &noituv1.ChatHistory{Messages: messages},
}})
}
// chatMessageFor renders one entry from one seat's point of view.
//
// An entry whose author has been cleared belongs to nobody: it is from_me for
// neither player and carries no name, so the seat's next occupant is not shown
// a stranger's words as their own and the player who stayed cannot have them
// reattributed to whoever arrives next.
func chatMessageFor(entry chatEntry, id game.PlayerID) *noituv1.ServerMessage {
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatMessage{
ChatMessage: &noituv1.ChatMessage{
FromMe: entry.author != "" && entry.author == id,
// Empty together with the name for a vacated seat: a line nobody
// owns must not be coloured as somebody's either.
PlayerId: string(entry.author),
Author: entry.name,
Text: entry.text,
SentUnixMs: entry.at.UnixMilli(),
},
}}
}
+622
View File
@@ -0,0 +1,622 @@
package wsapi
import (
"log/slog"
"math/rand/v2"
"slices"
"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"
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
)
// Everything that touches a running game: starting one, applying a move,
// scoring it, and reporting what an elimination or a game-over means to
// each seat.
// handleResign is one player giving up on their own turn. The seat, not the
// claimed id, is the authority, as everywhere a connection acts on a room.
//
// Only the player to act may give up. Giving up is a move — it is what is
// played instead of a word — and a seat that could spend it while somebody
// else was thinking would be deciding the turn of a player who had not
// finished theirs. Somebody who wants out of a game they are not on turn in
// leaves the room instead, which handleLobby answers.
func (r *room) handleResign(m resignInput) {
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
if r.engine == nil || r.engine.Over() {
return
}
if r.engine.Turn() != m.player {
m.sess.send(errorMsg("not_your_turn"))
return
}
before := r.mark()
if r.engine.Resign(m.player, time.Now()) {
r.applyEliminations(before)
}
}
// handleClaimDeadEnd is the player to act saying the syllable in play has no
// answer left, checked rather than trusted.
//
// A true claim takes them out at once with EndNoLegalMove — exactly what the
// clock would eventually rule, so the game's own outcome is unchanged and
// only the wait is gone. A false claim changes nothing at all: the clock
// keeps running and the claimant is simply told a word exists, which is hint
// enough to be the whole cost of asking wrongly.
func (r *room) handleClaimDeadEnd(m claimDeadEndInput) {
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
if r.engine == nil {
m.sess.send(errorMsg("game_not_started"))
return
}
if r.engine.Over() {
return
}
if r.engine.Turn() != m.player {
m.sess.send(errorMsg("not_your_turn"))
return
}
if r.engine.HasLegalMove() {
metrics.deadEndClaims.Add("false", 1)
m.sess.send(errorMsg("not_a_dead_end"))
return
}
metrics.deadEndClaims.Add("true", 1)
before := r.mark()
if r.engine.NoMove(time.Now()) {
r.applyEliminations(before)
}
}
// handleStartBot seats a bot opposite the player and begins immediately.
func (r *room) handleStartBot(m startBotInput) {
strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))
if err != nil {
m.sess.send(errorMsg("room_start_failed"))
r.cancel()
return
}
r.strategy = strategy
s := &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq}
r.seats[0] = s
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
r.owner = "p1"
m.sess.attach(r, "p1")
r.hub.cancelQuickMatch(m.sess)
if s.sess.ctx.Err() != nil {
// A bot room has no lobby to fall back to and no idle timer covering it
// while there is no engine yet (resetIdleTimer skips any room with a
// strategy) — a grace window here would leave the bot's own seat
// holding the room open forever with nothing left to vacate it. The
// room ends now instead, the same way a failed bot.New or beginGame
// above already does.
r.cancel()
return
}
if err := r.beginGame(); err != nil {
slog.Error("could not start bot game", "room", r.code, "err", err)
m.sess.send(errorMsg("game_start_failed"))
r.cancel()
}
}
// beginGame builds the engine and tells both seats the game is on.
func (r *room) beginGame() error {
opening, err := r.dict.RandomOpeningWord(minOpeningOutDegree)
if err != nil {
return err
}
// Seat order is turn order, so a player's place at the table is the place
// they took in the lobby and nothing has to be shuffled or announced.
ids := make([]game.PlayerID, 0, maxPlayers)
for _, s := range r.seats {
if s != nil {
ids = append(ids, s.id)
}
}
// Who leads is drawn rather than owned. Opening the game is an advantage —
// the first player picks from a whole syllable, everyone after them plays
// what is left of it — and giving it to whoever happened to create the
// room would make the same person favourite in every game of a series.
//
// Rotating rather than shuffling keeps the table intact: everybody still
// plays in the order they sat down, the cycle just starts somewhere else.
// A bot room is left alone; it has no table to be fair about, and the
// human opens.
if r.strategy == nil {
lead := rand.IntN(len(ids))
ids = slices.Concat(ids[lead:], ids[:lead])
}
engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now())
if err != nil {
return err
}
r.engine = engine
r.opening = opening
// Fresh per game: an override from the last one would describe a player
// who has since come back and is playing this one.
r.outWire = make(map[game.PlayerID]noituv1.GameEndReason, len(ids))
// Never restarts at 1. A rematch reuses the same connections, so a
// submission still in flight from the previous game would otherwise be
// able to match a turn in this one and be applied to it.
r.turnSeq++
// Every game is agreed on its own. The readiness that started this one is
// spent, so the lobby they come back to asks again.
for _, s := range r.seats {
if s != nil {
s.ready = false
}
}
metrics.gamesStarted.Add(r.mode, 1)
r.hub.gameStarted()
r.liveCounted.Store(true)
state := r.engine.Snapshot()
for _, s := range r.seats {
r.sendGameStarted(s, state)
}
r.maybeScheduleBot()
return nil
}
// sendGameStarted renders the opening position for one seat. my_turn and is_me
// are per-recipient, which is why this is built per seat rather than broadcast.
func (r *room) sendGameStarted(s *seat, state game.State) {
if s == nil || s.sess == nil {
return
}
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{
GameStarted: &noituv1.GameStarted{
OpeningWord: r.opening,
OpeningMeanings: Senses(r.dict.Meanings(r.opening)),
CurrentSyllable: state.Current,
MyTurn: state.Turn == s.id,
DeadlineUnixMs: state.Deadline.UnixMilli(),
TurnSeq: r.turnSeq,
TurnLimitMs: uint32(r.turnLimit.Milliseconds()),
Players: r.scoreRows(r.engine.Players(), state, s.id, nil),
TurnPlayerId: string(state.Turn),
},
}})
}
// handleSubmit runs one human move through the engine.
func (r *room) handleSubmit(m submitInput) {
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
if r.engine == nil {
r.sendTo(m.player, errorMsg("game_not_started"))
return
}
// A submission stamped with an old turn is answering a position that no
// longer exists — a double-submit, or a word typed as the clock ran out.
// Applying it to the current turn would play a word the player never
// chose for this position.
// The rejection carries the server's sequence, not the client's stale one,
// so the client can resynchronise from the refusal instead of having to
// wait for the next turn update to discover where the game actually is.
metrics.wordsSubmitted.Add(1)
if m.turnSeq != r.turnSeq {
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, ""))
r.recordRejection(game.ReasonNotYourTurn, m.word)
return
}
// The typed text is echoed back to every seat as PlayedWord.typed, so it
// crosses the same trust boundary a chat line does and gets the same
// filter. The engine's own normalization only lowercases and collapses
// whitespace; it does not drop format characters.
word := sanitizeText(m.word, maxWordRunes, maxNicknameMarks)
before := r.mark()
move, reason := r.engine.Submit(m.player, word, time.Now())
if reason != game.ReasonNone {
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word)))
r.recordRejection(reason, word)
// A rejection for an expired turn also took this player out of the
// game, and everybody has to be told which.
r.applyEliminations(before)
return
}
metrics.wordsAccepted.Add(1)
// An accepted move never ends a game: a dead end is left for whoever
// inherits it, which is what Submit's own comment explains.
r.turnSeq++
r.broadcastTurn(&move)
r.maybeScheduleBot()
}
// nearMissFor finds a diacritic-typo suggestion for a word the dictionary
// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection
// means the word IS in the dictionary and was refused for some other reason,
// where a spelling suggestion would be misleading rather than helpful.
func (r *room) nearMissFor(reason game.RejectReason, raw string) string {
if reason != game.ReasonNotInDictionary {
return ""
}
normalized, _, err := vietnamese.Normalize(raw)
if err != nil {
return ""
}
suggestion, ok := r.dict.NearMiss(normalized)
if !ok {
return ""
}
// The dictionary check comes before the link and reuse checks in Submit,
// so a real word can be a near miss and still be unplayable here. Offering
// it would send the player straight into a second refusal.
if first, ok := r.dict.FirstSyllable(suggestion); !ok || first != r.engine.Current() || r.engine.Used(suggestion) {
return ""
}
return suggestion
}
// recordRejection counts one rejected submission and logs it at Info.
//
// This is the corpus feedback loop the improvement report calls the input to
// every decision about the dictionary: which words players actually type that
// the game does not accept, and why. The word logged is never the raw typed
// text — it is normalized the same way the engine would have matched it
// (NFC, lowercase, single-spaced) and capped, so the line is useful for corpus
// review without ever logging what a player literally typed into the box.
func (r *room) recordRejection(reason game.RejectReason, raw string) {
metrics.wordsRejected.Add(reason.String(), 1)
// sanitizeText first: raw may be the untouched client payload (the
// not-your-turn path never reaches the sanitizer below it in
// handleSubmit), and Normalize alone does not drop control or format
// characters.
word, _, err := vietnamese.Normalize(sanitizeText(raw, maxWordRunes, maxNicknameMarks))
if err != nil {
word = ""
}
if runes := []rune(word); len(runes) > maxWordRunes {
word = string(runes[:maxWordRunes])
}
slog.Info("word_rejected",
"reason", reason.String(),
"word", word,
"link", r.engine.Current(),
"mode", r.mode,
"room", r.code,
)
}
// handleReportWord logs one report with this room's context.
//
// The session has already checked the word is long enough and within its own
// per-session cap before routing it here — this is only about what to log,
// and the syllable in play, this room's mode and its code are all room
// goroutine state that only the room may read. Never the reporting player's
// seat or name: recordRejection keeps the same information out of the corpus
// feedback loop for the same reason.
func (r *room) handleReportWord(m reportWordInput) {
link := ""
if r.engine != nil {
link = r.engine.Current()
}
metrics.wordsReported.Add(1)
slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code)
m.sess.send(wordReportedMsg(m.word))
}
// handleBotMove applies what the worker chose.
func (r *room) handleBotMove(m botMoveInput) {
if r.engine == nil || r.engine.Over() {
return
}
// The position moved on while it was thinking; the chosen word answers a
// board that no longer exists.
if m.turnSeq != r.turnSeq || r.engine.Turn() != botPlayerID {
return
}
metrics.botMoves.Add(r.strategy.Difficulty().String(), 1)
now := time.Now()
before := r.mark()
if m.err != nil {
// The bot has nothing to play. A human in this position keeps their
// turn and loses it to the clock; the bot has no clock to spend, so
// the position is settled now and reported for what it is rather than
// as a resignation it never chose.
if !r.engine.NoMove(now) {
r.engine.Resign(botPlayerID, now)
}
r.applyEliminations(before)
return
}
move, reason := r.engine.Submit(botPlayerID, m.word, now)
if reason != game.ReasonNone {
// The bot searched the same dictionary the engine validates against,
// so this means the two disagree — a bug worth seeing, not a move to
// retry.
slog.Error("bot move rejected by engine", "room", r.code, "word", m.word, "reason", reason.String())
r.engine.Resign(botPlayerID, now)
r.applyEliminations(before)
return
}
r.turnSeq++
r.broadcastTurn(&move)
}
// maybeScheduleBot starts the bot thinking if it is now its turn.
func (r *room) maybeScheduleBot() {
if r.strategy == nil || r.engine.Over() || r.engine.Turn() != botPlayerID {
return
}
// The board is frozen here, on the room goroutine, before the worker
// exists. Handing the worker the live engine instead would race every
// resign and disconnect the room processes while the bot thinks — and
// bot.Board.Used reads engine state, so the race would be real, not
// theoretical.
board := freezeBoard(r.engine)
seq := r.turnSeq
strategy := r.strategy
go func() {
word, err := strategy.Choose(board)
// The pause is a courtesy to the player, so it must not outlive the
// room: a bot still sleeping after everyone left is a goroutine leak
// per abandoned game.
select {
case <-time.After(strategy.ThinkingDelay()):
case <-r.ctx.Done():
return
}
r.send(botMoveInput{word: word, err: err, turnSeq: seq})
}()
}
// broadcastTurn sends the position to every seat, rendered for each.
//
// move is nil when the turn moved without a word being played, which is what
// an elimination does: the syllable and the used set survive the player who
// could not answer them, and everybody still needs the new deadline and the
// new player to act.
func (r *room) broadcastTurn(move *game.Move) {
state := r.engine.Snapshot()
meanings := r.moveMeanings(move)
for _, s := range r.seats {
r.sendTurnUpdate(s, state, move, meanings)
}
}
// moveMeanings looks up a played word's senses once per move; they are the
// same for every recipient. nil for no move.
func (r *room) moveMeanings(move *game.Move) []dictionary.Sense {
if move == nil {
return nil
}
return r.dict.Meanings(move.Word)
}
// sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me
// are all per-recipient, which is why there is no single shared frame; the
// move's meanings are not, and arrive looked up.
func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move, meanings []dictionary.Sense) {
if s == nil || s.sess == nil {
return
}
update := &noituv1.TurnUpdate{
CurrentSyllable: state.Current,
MyTurn: state.Turn == s.id,
DeadlineUnixMs: state.Deadline.UnixMilli(),
TurnSeq: r.turnSeq,
ChainLength: uint32(state.ChainLength),
Players: r.scoreRows(r.engine.Players(), state, s.id, nil),
TurnPlayerId: string(state.Turn),
}
if move != nil {
update.Played = PlayedWord(*move, move.Player == s.id, meanings)
}
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}})
}
// inputMark is what the game looked like before an input: how many players
// were out, and who was to act. Remembered across the input so
// applyEliminations can tell that input's doing from what was already true,
// and whether it moved the turn.
type inputMark struct {
out int
turn game.PlayerID
}
// mark reads the current game, or the zero mark when there is no game.
func (r *room) mark() inputMark {
if r.engine == nil {
return inputMark{}
}
return inputMark{out: r.engine.EliminatedCount(), turn: r.engine.Turn()}
}
// applyEliminations reports everybody the last input knocked out, then whatever
// the game became: finished, or one turn further on.
//
// Every path that takes a player out of a game ends here — a timeout, a
// resignation, a bot with nothing to play, a reconnect window running out — so
// there is one place that decides what the room says about it.
func (r *room) applyEliminations(before inputMark) {
if r.engine == nil {
return
}
state := r.engine.Snapshot()
if len(state.Eliminated) == before.out {
return
}
// An elimination does not move the position, so one lookup describes it
// for everybody who went out on this input.
suggestions := r.engine.Suggestions(maxSuggestions)
for _, id := range state.Eliminated[before.out:] {
r.broadcastElimination(id, suggestions)
}
if r.engine.Over() {
r.broadcastGameOver(state)
return
}
// A new turn nobody played into, and the sequence moves with it: a
// submission already in flight was answering the position the player who
// just went out was looking at.
//
// It moves only when the turn does. Somebody forfeiting out of turn — a
// player who left the room, or whose reconnect window ran out — leaves the
// syllable, the deadline and the player to act exactly as they were, so
// the word that player is already sending still answers the board it was
// typed for. Bumping the sequence there would refuse it for something
// somebody else did.
if state.Turn != before.turn {
r.turnSeq++
}
r.broadcastTurn(nil)
}
// broadcastElimination tells the room one player is out.
//
// The suggestions go only to that player. They are what the position still had
// to offer, and the people who could still answer it are not the ones who
// needed to be told — an empty list is the answer for whoever was stuck, and
// noise for everybody else.
func (r *room) broadcastElimination(id game.PlayerID, suggestions []string) {
name := ""
if out := r.seatOf(id); out != nil {
name = out.nickname
}
reason := r.wireEndReason(id)
metrics.eliminations.Add(reason.String(), 1)
for _, s := range r.seats {
if s == nil || s.sess == nil {
continue
}
msg := &noituv1.PlayerEliminated{
PlayerId: string(id),
Name: name,
IsMe: s.id == id,
Reason: reason,
}
if s.id == id {
msg.Suggestions = suggestions
}
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_PlayerEliminated{
PlayerEliminated: msg,
}})
}
}
// wireEndReason says how one player left the game.
//
// The engine's answer, unless the room overrode it: a reconnect window running
// out is a resignation to the engine, because that is the only shape it has
// for a player who stops playing, and somebody who left to everybody in the
// room.
func (r *room) wireEndReason(p game.PlayerID) noituv1.GameEndReason {
if code, overridden := r.outWire[p]; overridden {
return code
}
return EndReason(r.engine.OutReason(p))
}
// broadcastGameOver reports the result from each seat's point of view.
func (r *room) broadcastGameOver(state game.State) {
metrics.gamesFinished.Add(r.mode, 1)
if r.liveCounted.CompareAndSwap(true, false) {
r.hub.gameFinished()
}
// The reason the game ended is the reason the last player went out, which
// with two seats is the only elimination there was.
reason := noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
if n := len(state.Eliminated); n > 0 {
reason = r.wireEndReason(state.Eliminated[n-1])
}
// Credited before anything is sent, so the RoomState the run loop
// broadcasts after a finished game already carries the game just won.
if s := r.seatOf(state.Winner); s != nil {
s.wins++
}
ranks := make(map[game.PlayerID]int, len(state.Standings))
order := make([]game.PlayerID, 0, len(state.Standings))
for _, standing := range state.Standings {
ranks[standing.Player] = standing.Rank
order = append(order, standing.Player)
}
for _, s := range r.seats {
if s == nil || s.sess == nil {
continue
}
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{
GameOver: &noituv1.GameOver{
IWon: state.Winner == s.id,
Reason: reason,
ChainLength: uint32(state.ChainLength),
Standings: r.scoreRows(order, state, s.id, ranks),
},
}})
}
// A finished game is a return to the lobby, and the run loop reports the
// state they are returning to.
r.lobbyChanged = true
}
// scoreRows renders the players table for one recipient.
//
// order is the sequence to report them in — turn order while a game runs,
// finishing order once one has ended — and ranks is empty until there is a
// result, which is what makes a rank of zero mean "still playing" rather than
// needing a field of its own to say so.
func (r *room) scoreRows(order []game.PlayerID, state game.State, me game.PlayerID, ranks map[game.PlayerID]int) []*noituv1.PlayerScore {
rows := make([]*noituv1.PlayerScore, 0, len(order))
for _, id := range order {
row := &noituv1.PlayerScore{
PlayerId: string(id),
IsMe: id == me,
Score: uint32(state.Scores[id]),
// A player the engine no longer knows is a seat that was vacated
// mid-game, which only happens to somebody already out.
Eliminated: !state.Alive[id],
// The bot has no socket to lose, so it is never the one keeping
// the room waiting.
Connected: id == botPlayerID,
Rank: uint32(ranks[id]),
}
if s := r.seatOf(id); s != nil {
row.Name = s.nickname
row.Connected = row.Connected || s.sess != nil
}
rows = append(rows, row)
}
return rows
}
+125
View File
@@ -0,0 +1,125 @@
package wsapi
import (
"github.com/tiennm99dev/noitu/server/internal/bot"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// Room input messages. Everything that can change a room or a game arrives
// as one of these on room.inputs, which is what makes the engine safe
// without a lock: the room goroutine is its only reader.
// createInput and startBotInput seat the first player. Seating is a message
// rather than a direct write so that every touch of room state — seats and
// engine alike — happens on the room goroutine, which makes the ownership
// invariant provable by reading run() rather than by reasoning about which
// writes happened before `go r.run()`.
type createInput struct {
sess *session
// autoStart marks a room a quick match opened rather than a player asking
// for a code: once both seats are filled and connected, the room begins
// its own first game instead of waiting on readiness and StartGame.
autoStart bool
}
type startBotInput struct {
sess *session
difficulty bot.Difficulty
}
type joinInput struct {
sess *session
}
// submitInput and resignInput carry the connection that sent them, not just
// the seat it claims. A room code is a shared secret — it is pasted into group
// chats by design — so holding one must not be enough to act as a player who
// is already seated.
type submitInput struct {
sess *session
player game.PlayerID
word string
turnSeq uint32
}
// lobbyAction is one thing a player does to the room rather than to a game.
type lobbyAction uint8
const (
lobbyReady lobbyAction = iota
lobbyStart
lobbyKick
lobbyLeave
)
// lobbyInput is one lobby action. They share a type because they share every
// authorization step — the seat, the room's mode, and whether a game is
// running — and splitting them would mean four copies of those checks.
type lobbyInput struct {
sess *session
player game.PlayerID
action lobbyAction
// ready is the value a lobbyReady is setting. Explicit rather than a
// toggle: a toggle applied to a state the client is a frame behind on sets
// the opposite of what the player clicked.
ready bool
// target is the seat a lobbyKick names. A room holds up to four people, so
// "the other one" stopped being an answer.
target game.PlayerID
}
// chatInput is one line of text from a seated player. It carries the
// connection, not just the seat it claims, for the same reason submitInput
// does: a room code is a shared secret, and a connection the room has retired
// must not be able to speak as the seat it used to hold.
type chatInput struct {
sess *session
player game.PlayerID
text string
}
type resignInput struct {
sess *session
player game.PlayerID
}
// claimDeadEndInput is the player to act saying the syllable has no answer
// left. Carries the connection, not just the claimed seat, for the same
// reason resignInput does.
type claimDeadEndInput struct {
sess *session
player game.PlayerID
}
// reportWordInput is a word the session has already validated as reportable —
// long enough, and within its own per-session cap — waiting only on the room
// for the context a report is logged with: the syllable in play, if any.
type reportWordInput struct {
sess *session
word string
}
type disconnectInput struct {
player game.PlayerID
// sess identifies which connection dropped. A player who already
// reconnected has a different session, and that stale notice must not
// evict the seat the new connection just took.
sess *session
}
type resumeInput struct {
player game.PlayerID
sess *session
// prior is the connection being replaced. The room retires it only once it
// has decided the resume is allowed, because closing it on a refusal would
// end the very game the client was trying to rejoin.
prior *session
}
type botMoveInput struct {
word string
err error
// turnSeq the bot was thinking about. If the game moved on — a resign
// landed while it thought — the move is stale and dropped.
turnSeq uint32
}
+375
View File
@@ -0,0 +1,375 @@
package wsapi
import (
"log/slog"
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// Seating and the lobby between games: who is in the room, whether they may
// start, and what happens when one of them leaves or is kicked.
// handleCreate seats the room's creator, who owns it, and opens the lobby.
//
// The code goes out in the RoomState the run loop broadcasts, so a client can
// never be handed a code before the seat behind it exists.
func (r *room) handleCreate(m createInput) {
s := &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq}
r.seats[0] = s
r.owner = "p1"
r.autoStart = m.autoStart
m.sess.attach(r, "p1")
r.lobbyChanged = true
// A quick match already popped this session off the pairing queue before
// sending it here, but a plain CreateRoom might still be seating somebody
// who was also waiting in it from another attempt — one dequeue serves
// both room-entry paths.
r.hub.cancelQuickMatch(m.sess)
if s.sess.ctx.Err() != nil {
r.disconnectGhostSeat(s)
return
}
// Deliberately sent to a brand-new room's creator, where it is always
// empty: it is what replaces the conversation a client may still be
// holding from a room it was in before this one.
r.sendChatHistory(s)
}
// handleJoin seats another human in the lobby. It does not start anything: the
// owner does that, once everybody has said they are ready.
//
// The seat is bound here, on the room goroutine, and only on success. Binding
// it in the hub before this decision would leave a refused joiner still
// holding a seat, and every later Submit or Resign it sent would be applied to
// the real player sitting there.
func (r *room) handleJoin(m joinInput) {
free := r.freeSeat()
if free < 0 || !r.occupied() {
metrics.joinsRefused.Add("room_full", 1)
m.sess.send(errorMsg("room_full"))
return
}
// A room can have a free seat and still be mid-game — four people can
// start a game three of them are in. Arriving in the middle of one is not
// something to seat somebody for: they would have no words, no score, and
// no way to be told what they had missed.
if !r.inLobby() {
m.sess.send(errorMsg("game_in_progress"))
return
}
for _, s := range r.seats {
if s != nil && s.sess == m.sess {
m.sess.send(errorMsg("cannot_join_own_room"))
return
}
}
id := seatIDs[free]
s := &seat{
id: id,
nickname: distinguish(m.sess.nickname(), r.takenNicknames(id)),
sess: m.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[free] = s
m.sess.attach(r, string(id))
r.lobbyChanged = true
r.hub.cancelQuickMatch(m.sess)
if s.sess.ctx.Err() != nil {
r.disconnectGhostSeat(s)
return
}
r.sendChatHistory(s)
// A quick match seats both players itself rather than waiting on
// readiness and StartGame — there is no owner here to press it, only two
// strangers who both already asked to be matched. The lobby is shown
// first, with both seats filled, so the wait ends on an ordinary room a
// beat before GameStarted rather than jumping straight into one with no
// seating frame behind it.
if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() {
if r.hub.isDraining() {
// The second seat filled after the drain decision. There is no
// owner here to answer with server_restarting the way lobbyStart
// does, so both seats are told directly; the lobby view they are
// left in still shows each other, via lobbyChanged below.
r.broadcastError("server_restarting")
return
}
r.autoStart = false
r.lobbyChanged = false
r.broadcastRoomState()
if err := r.beginGame(); err != nil {
slog.Error("could not start quick-matched game", "room", r.code, "err", err)
r.broadcastError("game_start_failed")
}
}
}
// handleLobby applies one lobby action.
//
// Every refusal answers with a reason. A lobby button that silently does
// nothing is indistinguishable from one that is broken, and the player cannot
// see the state that refused them.
func (r *room) handleLobby(m lobbyInput) {
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
if r.strategy != nil {
// A bot room has no lobby: one player, no readiness, nobody to kick.
m.sess.send(errorMsg("not_in_a_room"))
return
}
// Leaving is the exception: a player may want out of a game it is not
// their turn in, and resigning is not open to them then. Readying,
// starting and kicking all belong to a room between games.
if !r.inLobby() && m.action != lobbyLeave {
m.sess.send(errorMsg("game_in_progress"))
return
}
mine := r.seatOf(m.player)
isOwner := m.player == r.owner
switch m.action {
case lobbyReady:
if isOwner {
// The owner's readiness is StartGame. A flag of their own would
// only be something they had to set before every single start.
m.sess.send(errorMsg("owner_needs_no_ready"))
return
}
mine.ready = m.ready
r.lobbyChanged = true
case lobbyStart:
if !isOwner {
m.sess.send(errorMsg("not_the_owner"))
return
}
switch {
case r.hub.isDraining():
// newRegisteredRoom already refuses a brand-new room once draining
// starts; this lobby existed before that point, and starting its
// game now would raise hub.liveGames after the drain decided how
// long to wait for exactly that number to reach zero.
m.sess.send(errorMsg("server_restarting"))
return
case r.seatedCount() < minPlayers:
m.sess.send(errorMsg("need_more_players"))
return
case !r.allConnected():
m.sess.send(errorMsg("player_offline"))
return
case !r.guestsReady():
m.sess.send(errorMsg("not_everyone_ready"))
return
}
if err := r.beginGame(); err != nil {
slog.Error("could not start pvp game", "room", r.code, "err", err)
r.broadcastError("game_start_failed")
}
case lobbyKick:
if !isOwner {
m.sess.send(errorMsg("not_the_owner"))
return
}
target := r.seatOf(m.target)
switch {
case target == nil:
m.sess.send(errorMsg("no_one_to_kick"))
return
case target == mine:
// Leaving is what an owner who wants out does, and it hands the
// room on. Kicking yourself would drop the seat and the role
// together while the others were still sitting here.
m.sess.send(errorMsg("cannot_kick_self"))
return
case target.ready:
// Readiness is a commitment, and the owner does not get to
// overrule one: a player who is ready is waiting on the owner,
// not in the way.
m.sess.send(errorMsg("player_is_ready"))
return
}
if target.sess != nil {
target.sess.send(errorMsg("kicked"))
}
r.vacate(target)
r.lobbyChanged = true
case lobbyLeave:
if r.inLobby() {
// Unreadying first is deliberate friction: a player the other one
// is waiting on should have to take that back before walking away.
if mine.ready {
m.sess.send(errorMsg("must_unready_first"))
return
}
} else {
// Out of a running game, which is the same thing to everybody else
// as a reconnect window running out: somebody left. The engine
// goes first, while the seat is still here to be named in what is
// broadcast about it.
before := r.mark()
r.eliminateAbsent(mine, time.Now())
r.applyEliminations(before)
}
r.vacate(mine)
r.lobbyChanged = true
}
}
// guestsReady reports whether every seat but the owner's has said yes. The
// owner's readiness is StartGame itself, which is why they are not counted.
func (r *room) guestsReady() bool {
for _, s := range r.seats {
if s != nil && s.id != r.owner && !s.ready {
return false
}
}
return true
}
// takenNicknames is every name already in this room except one seat's own, so
// a joiner can be told apart from all of them.
func (r *room) takenNicknames(except game.PlayerID) []string {
names := make([]string, 0, maxPlayers)
for _, s := range r.seats {
if s != nil && s.id != except {
names = append(names, s.nickname)
}
}
return names
}
// canStart reports whether StartGame would be accepted. The server answers
// this rather than the client because it owns every condition that feeds it.
func (r *room) canStart() bool {
if r.strategy != nil || !r.inLobby() {
return false
}
return r.seatedCount() >= minPlayers && r.allConnected() && r.guestsReady()
}
// vacate frees a seat for good - the player left, was kicked, or never came
// back - and hands the room on when the seat was the owner's.
func (r *room) vacate(s *seat) {
if s == nil {
return
}
if s.sess != nil {
// The connection stays open; it is simply no longer in this room, so
// anything else it sends here is refused rather than applied to a seat
// somebody else may now be sitting in.
s.sess.release(r)
s.sess = nil
}
for i, existing := range r.seats {
if existing == s {
r.seats[i] = nil
}
}
// The words stay; the attribution goes. Both fields, not just the id: a
// retained name lets the next person to ask for that nickname inherit
// these messages, because distinguish only compares against the seat that
// is occupied.
scrubbed := false
for i := range r.chat {
if r.chat[i].author == s.id {
r.chat[i].author = ""
r.chat[i].name = ""
scrubbed = true
}
}
// Clearing the store is only half of it: the player who stayed is holding
// frames that still carry the departed name, and RoomState carries no
// chat. Without this re-sync they keep that attribution until they happen
// to reload — long enough for somebody to join under the same nickname and
// inherit a stranger's words.
if scrubbed {
// The loop above has already emptied this seat out of r.seats, so what
// is left is exactly the players who need correcting.
for _, other := range r.seats {
r.sendChatHistory(other)
}
}
if r.owner == s.id {
r.promote()
}
}
// promote hands the room to whoever is left.
func (r *room) promote() {
for _, s := range r.seats {
if s != nil {
r.owner = s.id
// The new owner starts games, and starting is their readiness. A
// flag they set as a guest would sit there meaning nothing.
s.ready = false
return
}
}
r.owner = ""
}
// broadcastRoomState sends the whole room to each occupant.
//
// Built per recipient because the field that matters most in it — which of
// these players is you — is relative to who is being told. One snapshot rather
// than a stream of deltas is what lets a client that missed a frame, or has
// just reconnected, be correct again from the next one.
func (r *room) broadcastRoomState() {
canStart := r.canStart()
for _, s := range r.seats {
if s == nil || s.sess == nil {
continue
}
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{
RoomState: &noituv1.RoomState{
RoomCode: r.code,
CanStart: canStart,
Players: r.playerSlots(s.id),
MaxPlayers: maxPlayers,
MinPlayers: minPlayers,
GraceMs: uint32(r.graceFor.Milliseconds()),
},
}})
}
}
// playerSlots renders the seating for one recipient, in seat order. That is
// the order they will play in, but not who plays first: the lead is drawn when
// the game starts, and the table sent with it is the one in turn order.
func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot {
slots := make([]*noituv1.PlayerSlot, 0, maxPlayers)
for _, s := range r.seats {
if s == nil {
continue
}
slots = append(slots, &noituv1.PlayerSlot{
PlayerId: string(s.id),
Name: s.nickname,
IsMe: s.id == me,
IsOwner: s.id == r.owner,
Ready: s.ready,
Connected: s.sess != nil,
Wins: s.wins,
})
}
return slots
}
// 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"}
+177
View File
@@ -0,0 +1,177 @@
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)
}
}
}
+4 -321
View File
@@ -12,9 +12,12 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/game" "github.com/tiennm99dev/noitu/server/internal/game"
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
) )
// The socket half of a connection: reading and writing frames, the
// keepalive that detects a dead peer, and the identity — nickname, room,
// seat — the protocol half below reads and writes through the same mutex.
const ( const (
// outboxCap buffers writes. A client that cannot keep up with this many // outboxCap buffers writes. A client that cannot keep up with this many
// pending frames is not going to catch up, so the session is closed rather // pending frames is not going to catch up, so the session is closed rather
@@ -435,326 +438,6 @@ func (s *session) keepalive() {
} }
} }
// 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})
}
func randomToken() string { func randomToken() string {
raw := make([]byte, 16) raw := make([]byte, 16)
_, _ = rand.Read(raw) _, _ = rand.Read(raw)