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

627 lines
21 KiB
Go

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(codeNotYourSeat))
return
}
// A lobby answers, as a dead-end claim does there, because the player is
// waiting to see a resignation land. A game that has just ended does not:
// its game_over is already on the way and says everything a reply would.
if r.engine == nil {
m.sess.send(errorMsg(codeGameNotStarted))
return
}
if r.engine.Over() {
return
}
if r.engine.Turn() != m.player {
m.sess.send(errorMsg(codeNotYourTurn))
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(codeNotYourSeat))
return
}
if r.engine == nil {
m.sess.send(errorMsg(codeGameNotStarted))
return
}
if r.engine.Over() {
return
}
if r.engine.Turn() != m.player {
m.sess.send(errorMsg(codeNotYourTurn))
return
}
if r.engine.HasLegalMove() {
metrics.deadEndClaims.Add("false", 1)
m.sess.send(errorMsg(codeNotADeadEnd))
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(codeRoomStartFailed))
r.cancel()
return
}
r.strategy = strategy
s := r.takeSeat(0, m.sess.nickname(), m.sess)
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
r.owner = s.id
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
// 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(codeGameStartFailed))
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
s.missedResult = nil
}
}
metrics.gamesStarted.Add(r.mode, 1)
r.hub.gameStarted()
r.liveCounted.Store(true)
state := r.engine.Snapshot()
for s := range r.connected() {
r.sendGameStarted(s, state)
}
r.maybeScheduleBot()
return nil
}
// sendGameStarted renders the opening position for one connected 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) {
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(codeNotYourSeat))
return
}
if r.engine == nil {
m.sess.send(errorMsg(codeGameNotStarted))
return
}
metrics.wordsSubmitted.Add(1)
// 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.
if m.turnSeq != r.turnSeq {
r.recordRejection(game.ReasonNotYourTurn, m.word)
m.sess.send(moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, ""))
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 {
// Counted before the player is told, as every metric here is: a
// reader who sees the refusal must also see it counted.
r.recordRejection(reason, word)
m.sess.send(moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(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 that feeds 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])
}
corpusLog.info(time.Now(), "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)
corpusLog.info(time.Now(), "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.connected() {
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 connected 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) {
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.connected() {
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)
r.stopCountingLive()
// 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.id == botPlayerID {
continue
}
msg := &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),
},
}}
if s.sess == nil {
// Nobody is connected to hear it. Keep this seat's own version for
// the connection that resumes into the lobby this game returns to.
s.missedResult = msg
continue
}
s.missedResult = nil
s.sess.send(msg)
}
// 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
}