mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-20 02:18:08 +00:00
refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with connected(), and share seating and reconnect-window code. Declare every error code once in errcodes.go, which the web vocabulary test now reads. Fixes: - a kicked player is released before being told, so their next action is answered not_in_a_room - a rejected word is counted before move_rejected is sent - room code draw and registration share one lock hold - two concurrent resumes with one token can no longer both take a seat - multiplayer tests ready guests one at a time instead of racing Behaviour changes: - opening or joining another room while a game is running is refused with already_in_a_game - resigning in the lobby answers game_not_started
This commit is contained in:
1 parent
5848751c6b
commit
55abdb36c1
26 files changed
+643
-584
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
||||
)
|
||||
|
||||
@@ -19,8 +20,8 @@ import (
|
||||
// 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"))
|
||||
if _, isHello := msg.GetPayload().(*noituv1.ClientMessage_Hello); !isHello && !s.greeted {
|
||||
s.send(errorMsg(codeHandshakeRequired))
|
||||
return errHandshake
|
||||
}
|
||||
|
||||
@@ -29,16 +30,18 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
return s.handleHello(p.Hello)
|
||||
|
||||
case *noituv1.ClientMessage_StartBotGame:
|
||||
if s.refuseMidGame() {
|
||||
return nil
|
||||
}
|
||||
// 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"))
|
||||
if !s.allowRoom() {
|
||||
return nil
|
||||
}
|
||||
difficulty, ok := Difficulty(p.StartBotGame.GetDifficulty())
|
||||
if !ok {
|
||||
s.send(errorMsg("unknown_difficulty"))
|
||||
s.send(errorMsg(codeUnknownDifficulty))
|
||||
return nil
|
||||
}
|
||||
if err := s.hub.startBotRoom(s, difficulty); err != nil {
|
||||
@@ -46,10 +49,12 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_CreateRoom:
|
||||
if s.refuseMidGame() {
|
||||
return nil
|
||||
}
|
||||
// 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"))
|
||||
if !s.allowRoom() {
|
||||
return nil
|
||||
}
|
||||
if err := s.hub.createRoom(s); err != nil {
|
||||
@@ -57,30 +62,35 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_JoinRoom:
|
||||
if s.refuseMidGame() {
|
||||
return nil
|
||||
}
|
||||
if !s.hub.joinLimiter.allow(s.remoteIP, time.Now()) {
|
||||
metrics.joinsRefused.Add("too_many_attempts", 1)
|
||||
s.send(errorMsg("too_many_attempts"))
|
||||
s.send(errorMsg(codeTooManyAttempts))
|
||||
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"))
|
||||
s.send(errorMsg(codeRoomNotFound))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_QuickMatch:
|
||||
if s.refuseMidGame() {
|
||||
return nil
|
||||
}
|
||||
if r, _ := s.currentRoom(); r != nil {
|
||||
s.send(errorMsg("already_in_a_room"))
|
||||
s.send(errorMsg(codeAlreadyInARoom))
|
||||
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"))
|
||||
if !s.allowRoom() {
|
||||
return nil
|
||||
}
|
||||
if err := s.hub.quickMatch(s); err != nil {
|
||||
if errors.Is(err, errAlreadyQueued) {
|
||||
s.send(errorMsg("already_queued"))
|
||||
s.send(errorMsg(codeAlreadyQueued))
|
||||
} else {
|
||||
s.send(roomCreateError(s.id, err))
|
||||
}
|
||||
@@ -93,67 +103,54 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
s.send(quickMatchStatusMsg(false))
|
||||
|
||||
case *noituv1.ClientMessage_SubmitWord:
|
||||
s.handleSubmit(p.SubmitWord)
|
||||
// A dropped submission would otherwise leave the player waiting out the
|
||||
// turn clock with no idea their word never arrived.
|
||||
word, seq := p.SubmitWord.GetWord(), p.SubmitWord.GetTurnSeq()
|
||||
s.toRoom(s.submitLimiter, codeNotInAGame, codeBusy, func(id game.PlayerID) any {
|
||||
return submitInput{sess: s, player: id, word: word, turnSeq: seq}
|
||||
})
|
||||
|
||||
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"))
|
||||
}
|
||||
// No budget of its own beyond the frame limiter: only one can ever be
|
||||
// accepted per game, and a refusal goes to the sender alone. A silently
|
||||
// dropped resignation leaves the player staring at a board they thought
|
||||
// they had left.
|
||||
s.toRoom(nil, codeNotInAGame, codeGameAlreadyOver, func(id game.PlayerID) any {
|
||||
return resignInput{sess: s, player: id}
|
||||
})
|
||||
|
||||
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"))
|
||||
}
|
||||
s.toRoom(s.submitLimiter, codeNotInAGame, codeBusy, func(id game.PlayerID) any {
|
||||
return claimDeadEndInput{sess: s, player: id}
|
||||
})
|
||||
|
||||
case *noituv1.ClientMessage_ReportWord:
|
||||
s.handleReportWord(p.ReportWord)
|
||||
|
||||
case *noituv1.ClientMessage_SetReady:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
|
||||
s.toLobby(lobbyInput{action: lobbyReady, ready: p.SetReady.GetReady()})
|
||||
|
||||
case *noituv1.ClientMessage_StartGame:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyStart})
|
||||
s.toLobby(lobbyInput{action: lobbyStart})
|
||||
|
||||
case *noituv1.ClientMessage_KickPlayer:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyKick, target: playerIDFor(p.KickPlayer.GetPlayerId())})
|
||||
s.toLobby(lobbyInput{action: lobbyKick, target: game.PlayerID(p.KickPlayer.GetPlayerId())})
|
||||
|
||||
case *noituv1.ClientMessage_LeaveRoom:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
|
||||
s.toLobby(lobbyInput{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"))
|
||||
}
|
||||
// knows whether this connection still holds one. A dropped line would
|
||||
// leave the player watching their own message fail to appear with no
|
||||
// reason given.
|
||||
text := p.SendChat.GetText()
|
||||
s.toRoom(s.chatLimiter, codeNotInARoom, codeBusy, func(id game.PlayerID) any {
|
||||
return chatInput{sess: s, player: id, text: text}
|
||||
})
|
||||
|
||||
case *noituv1.ClientMessage_Ping:
|
||||
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
|
||||
@@ -161,65 +158,107 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowRoom charges the room budget, answering too_many_rooms when it is
|
||||
// spent. Every path that mints a room — a bot game, a code, a quick match —
|
||||
// holds a goroutine and an engine, so all three share it.
|
||||
func (s *session) allowRoom() bool {
|
||||
if s.roomLimiter.allow(time.Now()) {
|
||||
return true
|
||||
}
|
||||
s.send(errorMsg(codeTooManyRooms))
|
||||
return false
|
||||
}
|
||||
|
||||
// refuseMidGame answers already_in_a_game, and reports true, when this
|
||||
// connection is seated in a room whose game is still being played.
|
||||
//
|
||||
// Opening or joining another room is what walks a player out of the one they
|
||||
// are in, and mid-game that would be an abandonment the other players only
|
||||
// learn of when the reconnect window runs out. Leaving is a deliberate act
|
||||
// with its own message; a room change is not allowed to be a quiet way of
|
||||
// doing it. A lobby or a finished game is left freely, as before.
|
||||
//
|
||||
// Checked before any limiter is charged, so a refusal costs nothing. The
|
||||
// flag is read outside the room goroutine, which makes it a snapshot: a game
|
||||
// starting at the same instant is a race the grace window already settles.
|
||||
func (s *session) refuseMidGame() bool {
|
||||
r, _ := s.currentRoom()
|
||||
if r == nil || !r.liveCounted.Load() {
|
||||
return false
|
||||
}
|
||||
s.send(errorMsg(codeAlreadyInAGame))
|
||||
return true
|
||||
}
|
||||
|
||||
// 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")
|
||||
return errorMsg(codeServerFull)
|
||||
}
|
||||
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")
|
||||
return errorMsg(codeServerRestarting)
|
||||
}
|
||||
slog.Error("open room", "session", sessionID, "err", err)
|
||||
return errorMsg("room_start_failed")
|
||||
return errorMsg(codeRoomStartFailed)
|
||||
}
|
||||
|
||||
// toRoom forwards one lobby action to the room this connection is seated in.
|
||||
// toRoom forwards one input 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"))
|
||||
// Every failure answers, because every input here is something the player
|
||||
// is waiting to see the effect of: limiter, when set, is charged first and
|
||||
// refuses with too_fast; notIn answers a connection seated nowhere; and
|
||||
// dropped answers a room that would not take the input — finished, or with
|
||||
// its inbox full. build receives the seat the connection holds, read at the
|
||||
// same moment as the room so the two cannot disagree.
|
||||
func (s *session) toRoom(limiter *bucket, notIn, dropped errCode, build func(game.PlayerID) any) {
|
||||
if limiter != nil && !limiter.allow(time.Now()) {
|
||||
s.send(errorMsg(codeTooFast))
|
||||
return
|
||||
}
|
||||
r, id := s.currentRoom()
|
||||
if r == nil {
|
||||
s.send(errorMsg("not_in_a_room"))
|
||||
s.send(errorMsg(notIn))
|
||||
return
|
||||
}
|
||||
in.player = id
|
||||
if !r.send(in) {
|
||||
s.send(errorMsg("not_in_a_room"))
|
||||
if !r.send(build(id)) {
|
||||
s.send(errorMsg(dropped))
|
||||
}
|
||||
}
|
||||
|
||||
// toLobby forwards one lobby action.
|
||||
//
|
||||
// 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.
|
||||
func (s *session) toLobby(in lobbyInput) {
|
||||
s.toRoom(s.submitLimiter, codeNotInARoom, codeNotInARoom, func(id game.PlayerID) any {
|
||||
in.sess, in.player = s, id
|
||||
return in
|
||||
})
|
||||
}
|
||||
|
||||
// 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"))
|
||||
s.send(errorMsg(codeProtocolVersionMismatch))
|
||||
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"))
|
||||
if s.greeted {
|
||||
s.send(errorMsg(codeAlreadyGreeted))
|
||||
return errors.New("wsapi: repeated hello")
|
||||
}
|
||||
s.greeted = true
|
||||
|
||||
s.setNickname(sanitizeNickname(h.GetNickname()))
|
||||
s.hub.register(s)
|
||||
@@ -235,7 +274,7 @@ func (s *session) handleHello(h *noituv1.Hello) error {
|
||||
// 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"))
|
||||
s.send(errorMsg(codeSessionNotResumable))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -251,11 +290,11 @@ func (s *session) resumeFrom(prior *session) {
|
||||
metrics.resumesAttempted.Add(1)
|
||||
r, id := prior.currentRoom()
|
||||
if r == nil {
|
||||
s.send(errorMsg("game_already_over"))
|
||||
s.send(errorMsg(codeGameAlreadyOver))
|
||||
return
|
||||
}
|
||||
if !r.send(resumeInput{player: id, sess: s, prior: prior}) {
|
||||
s.send(errorMsg("game_already_over"))
|
||||
s.send(errorMsg(codeGameAlreadyOver))
|
||||
return
|
||||
}
|
||||
// Deliberately no attach and no close here. The room has not decided yet,
|
||||
@@ -263,23 +302,6 @@ func (s *session) resumeFrom(prior *session) {
|
||||
// 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.
|
||||
@@ -291,19 +313,19 @@ func (s *session) handleSubmit(w *noituv1.SubmitWord) {
|
||||
// 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"))
|
||||
s.send(errorMsg(codeTooFast))
|
||||
return
|
||||
}
|
||||
|
||||
word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks))
|
||||
if err != nil || !vietnamese.HasEnoughSyllables(syllables) {
|
||||
s.send(errorMsg("word_report_refused"))
|
||||
s.send(errorMsg(codeWordReportRefused))
|
||||
return
|
||||
}
|
||||
|
||||
if _, already := s.reportedWords[word]; !already {
|
||||
if len(s.reportedWords) >= maxWordReportsPerSession {
|
||||
s.send(errorMsg("word_report_limit"))
|
||||
s.send(errorMsg(codeWordReportLimit))
|
||||
return
|
||||
}
|
||||
s.reportedWords[word] = struct{}{}
|
||||
@@ -313,7 +335,7 @@ func (s *session) handleReportWord(m *noituv1.ReportWord) {
|
||||
// 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"))
|
||||
s.send(errorMsg(codeBusy))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user