mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-07-29 14:21:29 +00:00
Bug: after clicking "Leave" on the game-over modal, the client landed on the nickname entry screen and got stuck — typing a nickname produced no response for up to 90 seconds. Root cause chain: 1. Server handleClientExit (stateless) closed SendCh and nilled it but did not close the WS connection. Writer goroutine drained + exited without closing the conn; reader stayed blocked on ReadMessage; state machine stayed blocked on CmdCh. Zombie session. 2. Any subsequent player.Send() returned nil (SendCh was nil), silently dropping responses. The setNickname reply never reached the client. 3. Client only unstuck when reader's 90 s readDeadline fired, finally closing the WS and triggering the auto-reconnect path. 4. Client MenuScene.create() always called showNicknameScreen() without checking gameState.nickname, so even after the reconnect the user had to re-type their name. Fix: "exit" now means "leave current room, return to home lobby" — not "terminate WS session". Server: - dispatch.go: move Request_ClientExit from stateless to stateful path. - handlers_stateless.go: delete handleClientExit entirely. - waiting.go leaveRoom: broadcast ClientExitResponse via broadcastResponse BEFORE removing the player, so the exiting player also receives their own exit confirmation. Removes duplicated self-notification logic. - waiting/gamePvp/gamePve/gameover/watching: on ClientExitRequest, call leaveRoom + return consts.StateHome (was return 0, ErrClientExit). - home.go: on ClientExitRequest, send bare ClientExitResponse and stay in home (no room to leave). - Update 4 state tests that asserted ErrClientExit → now assert StateHome. - Rename TestWatching_ClientExitReturnsErrClientExit → ReturnsHome. Client: - menu-scene.js create(): if gameState.nickname is set, skip nickname screen and call showLobby() directly. Otherwise showNicknameScreen(). - game-scene.js _onClientExit: drop the explicit showLobby() call; let MenuScene.create() decide the panel based on nickname state. Remove now-unused menu-ui showLobby import. Result: click Leave → server sends ClientExitResponse, state machine transitions to StateHome, WS stays open, client transitions to MenuScene → sees gameState.nickname is set → goes straight to lobby. No reconnect, no re-typing, no 90-second stall. go vet + go test ./... green. npm run build green.
68 lines
2.2 KiB
Go
68 lines
2.2 KiB
Go
package network
|
|
|
|
import (
|
|
"github.com/tiennm99/gomoku/server/lobby"
|
|
"github.com/tiennm99/gomoku/server/pkg/log"
|
|
"github.com/tiennm99/gomoku/server/protocol"
|
|
)
|
|
|
|
// Dispatch routes an incoming Request to the appropriate handler.
|
|
//
|
|
// Stateless requests (heartbeat, get_rooms, set_nickname, set_client_info)
|
|
// are handled inline on the reader goroutine — they must return fast.
|
|
//
|
|
// Stateful requests (create_room, join_room, game_move, client_exit, etc.)
|
|
// are pushed onto player.CmdCh for the state machine goroutine to consume
|
|
// in order.
|
|
//
|
|
// Overflow policy: if CmdCh is full, the request is logged and dropped rather
|
|
// than blocking the reader goroutine (prevents backpressure deadlocks).
|
|
func Dispatch(player *lobby.Player, req *protocol.Request) {
|
|
switch req.Payload.(type) {
|
|
// --- Stateless handlers (inline) ---
|
|
case *protocol.Request_Heartbeat:
|
|
handleHeartbeat(player, req)
|
|
|
|
case *protocol.Request_SetNickname:
|
|
handleSetNickname(player, req)
|
|
|
|
case *protocol.Request_GetRooms:
|
|
handleGetRooms(player, req)
|
|
|
|
case *protocol.Request_SetClientInfo:
|
|
handleSetClientInfo(player, req)
|
|
|
|
// --- Stateful handlers (pushed to cmdCh for state machine) ---
|
|
// ClientExit is stateful: it must leave the current room and transition
|
|
// the state machine back to home, keeping the WS alive.
|
|
case *protocol.Request_CreateRoom,
|
|
*protocol.Request_CreatePveRoom,
|
|
*protocol.Request_JoinRoom,
|
|
*protocol.Request_GameStarting,
|
|
*protocol.Request_GameMove,
|
|
*protocol.Request_GameReset,
|
|
*protocol.Request_WatchGame,
|
|
*protocol.Request_WatchGameExit,
|
|
*protocol.Request_ClientExit:
|
|
pushToCmdCh(player, req)
|
|
|
|
default:
|
|
log.Errorf("[dispatch] player %d: unhandled request type %T, dropping\n", player.ID, req.Payload)
|
|
}
|
|
}
|
|
|
|
// pushToCmdCh enqueues req on player.CmdCh without blocking.
|
|
// If the channel is full or nil, the request is dropped with a warning.
|
|
func pushToCmdCh(player *lobby.Player, req *protocol.Request) {
|
|
if player.CmdCh == nil {
|
|
log.Errorf("[dispatch] player %d: CmdCh is nil, dropping %T\n", player.ID, req.Payload)
|
|
return
|
|
}
|
|
select {
|
|
case player.CmdCh <- req:
|
|
default:
|
|
log.Errorf("[dispatch] player %d: CmdCh full (cap %d), dropping %T\n",
|
|
player.ID, cap(player.CmdCh), req.Payload)
|
|
}
|
|
}
|