From 43ac04ac7969db93575d129c98eac074e5837f2c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 11 Apr 2026 14:28:19 +0700 Subject: [PATCH] feat(state): rewrite state machine using cmdCh and typed protobuf requests Replace legacy AskForString/AskForPacket state machine with channel-based runner. Each state reads *protocol.Request from player.CmdCh. Adds PVP waiting (owner/joiner role split with StartCh signal), gamePvp (GameOverCh for cross-goroutine game-end sync), gamePve (AI alternates with human, AI-first when human is White), and gameover (reset/rematch support). --- server/consts/const.go | 12 +- server/network/server.go | 5 +- server/state/game_pve.go | 162 +++++++++++++++++ server/state/game_pvp.go | 180 ++++++++++++++++++ server/state/game_shared.go | 149 +++++++++++++++ server/state/gameover.go | 69 +++++++ server/state/home.go | 196 +++++++++++++++++--- server/state/set_nickname.go | 49 +++++ server/state/state.go | 112 ++++++------ server/state/waiting.go | 344 ++++++++++++++++------------------- server/state/welcome.go | 22 +-- 11 files changed, 1006 insertions(+), 294 deletions(-) create mode 100644 server/state/game_pve.go create mode 100644 server/state/game_pvp.go create mode 100644 server/state/game_shared.go create mode 100644 server/state/gameover.go create mode 100644 server/state/set_nickname.go diff --git a/server/consts/const.go b/server/consts/const.go index 0ac9500..512b46b 100644 --- a/server/consts/const.go +++ b/server/consts/const.go @@ -11,11 +11,17 @@ type StateID int const ( _ StateID = iota StateWelcome + StateSetNickname StateHome - StateJoin - StateCreate StateWaiting - StateGomokuGame + StateGamePvp + StateGamePve + StateGameOver + + // Kept for backward compat (unused after phase-06 rewrite). + StateJoin = 0 // collapsed into home + StateCreate = 0 // collapsed into home + StateGomokuGame = 0 // replaced by StateGamePvp / StateGamePve ) const ( diff --git a/server/network/server.go b/server/network/server.go index 811256b..fd7ff12 100644 --- a/server/network/server.go +++ b/server/network/server.go @@ -88,9 +88,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { // Writer goroutine: serialises all WS writes. go wr.run() - // State machine goroutine: legacy loop reads from player.data (phase-06 replaces). - // We spawn it so the architecture plumbing is in place; it will block waiting - // for legacy input which never arrives from the new network path until phase-06. + // State machine goroutine: reads typed *protocol.Request from player.CmdCh. + // Stateful requests are routed here by Dispatch; stateless ones handled inline. go state.Run(player) // Reader loop: blocks until WS close or error, then cleans up. diff --git a/server/state/game_pve.go b/server/state/game_pve.go new file mode 100644 index 0000000..bc166ea --- /dev/null +++ b/server/state/game_pve.go @@ -0,0 +1,162 @@ +package state + +import ( + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/pkg/log" + "github.com/tiennm99/gomoku/server/protocol" +) + +// gamePveState is the PVE game loop for the human player. +// The AI moves are computed inline (no goroutine — AI is fast). +// If the human is White, AI moves first before entering the human-wait loop. +type gamePveState struct{} + +func (*gamePveState) Next(player *database.Player) (consts.StateID, error) { + room, ok := database.GetNewRoom(player.RoomID) + if !ok { + log.Errorf("[pve] player %d: room not found\n", player.ID) + return consts.StateHome, nil + } + + humanPiece := playerPieceInRoom(room, player.ID) + if humanPiece == game.Empty { + log.Errorf("[pve] player %d not assigned a piece in room %d\n", player.ID, room.ID) + return consts.StateHome, nil + } + + // If human is White, AI (Black) moves first. + if humanPiece == game.White { + nextState, done := runAIMove(player, room) + if done { + return nextState, nil + } + } + + // Human-wait loop: alternate human move → AI move until game over. + for { + req, ok := <-player.CmdCh + if !ok { + return consts.StateHome, nil + } + + switch req.Payload.(type) { + case *protocol.Request_GameMove: + mv := req.GetGameMove() + row, col := int(mv.GetRow()), int(mv.GetCol()) + + nextState, done := applyHumanMove(player, room, humanPiece, row, col) + if done { + return nextState, nil + } + // Human move was rejected — stay in loop without AI move. + + case *protocol.Request_ClientExit: + leaveRoom(player, room) + return 0, ErrClientExit + + default: + log.Errorf("[pve] player %d: unexpected %T\n", player.ID, req.Payload) + } + } +} + +// applyHumanMove validates/applies the human's move then triggers AI response. +// Returns (nextState, true) when the state should change; (0, false) on invalid move. +func applyHumanMove(player *database.Player, room *database.NewRoom, humanPiece game.Piece, row, col int) (consts.StateID, bool) { + if row < 0 || row >= game.BoardSize || col < 0 || col >= game.BoardSize { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_GameMoveOutOfBounds{ + GameMoveOutOfBounds: &protocol.GameMoveOutOfBoundsResponse{}, + }, + }) + return 0, false + } + + room.Lock() + result, err := room.ApplyMove(row, col, humanPiece, player.ID) + room.Unlock() + + if err != nil { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_GameMoveOccupied{ + GameMoveOccupied: &protocol.GameMoveOccupiedResponse{}, + }, + }) + return 0, false + } + + moveResp := buildMoveSuccessResponse(row, col, humanPiece, player.ID, player.Name) + broadcastResponse(room, moveResp) + + if result != game.InProgress { + winner := winnerNicknameFor(room, result) + broadcastResponse(room, buildGameOverResponse(result, winner)) + room.Lock() + room.Status = database.RoomStatusFinished + room.Unlock() + return consts.StateGameOver, true + } + + // AI's turn. + return runAIMove(player, room) +} + +// runAIMove computes and applies the AI's next move, broadcasts it, and checks result. +// Returns (nextState, true) if the game ends, (0, false) to continue. +func runAIMove(player *database.Player, room *database.NewRoom) (consts.StateID, bool) { + room.RLock() + ai := room.AI + board := room.Board.Clone() // safe value copy for AI computation + difficulty := room.Difficulty + room.RUnlock() + + if ai == nil { + log.Errorf("[pve] room %d has nil AI\n", room.ID) + return consts.StateHome, true + } + + aiRow, aiCol, ok := ai.NextMove(&board, difficulty) + if !ok { + // Board full — draw. + broadcastResponse(room, buildGameOverResponse(game.Draw, "")) + room.Lock() + room.Status = database.RoomStatusFinished + room.Unlock() + return consts.StateGameOver, true + } + + // Derive AI's piece from room color assignment (-1 is the AI slot). + room.RLock() + var aiPiece game.Piece + if room.BlackPlayerID == -1 { + aiPiece = game.Black + } else { + aiPiece = game.White + } + room.RUnlock() + + room.Lock() + result, err := room.ApplyMove(aiRow, aiCol, aiPiece, -1) + room.Unlock() + + if err != nil { + log.Errorf("[pve] AI produced invalid move (%d,%d): %v\n", aiRow, aiCol, err) + return consts.StateGameOver, true + } + + aiMoveResp := buildMoveSuccessResponse(aiRow, aiCol, aiPiece, -1, "AI") + broadcastResponse(room, aiMoveResp) + + if result != game.InProgress { + winner := winnerNicknameFor(room, result) + broadcastResponse(room, buildGameOverResponse(result, winner)) + room.Lock() + room.Status = database.RoomStatusFinished + room.Unlock() + return consts.StateGameOver, true + } + + return 0, false +} diff --git a/server/state/game_pvp.go b/server/state/game_pvp.go new file mode 100644 index 0000000..5cd9a29 --- /dev/null +++ b/server/state/game_pvp.go @@ -0,0 +1,180 @@ +package state + +import ( + "sync" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/pkg/log" + "github.com/tiennm99/gomoku/server/protocol" +) + +// gamePvpState is the main PVP game loop for one player goroutine. +// Both player goroutines run this state concurrently; each blocks on its +// own CmdCh waiting for its turn. Move validation enforces turn order. +// When one goroutine detects game-over, it closes room.GameOverCh so the +// other goroutine unblocks and also transitions to StateGameOver. +type gamePvpState struct{} + +func (*gamePvpState) Next(player *database.Player) (consts.StateID, error) { + room, ok := database.GetNewRoom(player.RoomID) + if !ok { + log.Errorf("[pvp] player %d: room not found\n", player.ID) + return consts.StateHome, nil + } + + // gameOverCh allows the "other" goroutine to signal game end. + room.RLock() + gameOverCh := room.GameOverCh + room.RUnlock() + + for { + var req *protocol.Request + var chanOk bool + + if gameOverCh != nil { + select { + case req, chanOk = <-player.CmdCh: + case <-gameOverCh: + // Other player triggered game-over — transition. + return consts.StateGameOver, nil + } + } else { + req, chanOk = <-player.CmdCh + } + + if !chanOk { + // Player disconnected — forfeit. + signalGameOver(room) + broadcastForfeit(room, player) + return consts.StateGameOver, nil + } + + switch req.Payload.(type) { + case *protocol.Request_GameMove: + mv := req.GetGameMove() + row, col := int(mv.GetRow()), int(mv.GetCol()) + + nextState, done := applyPvpMove(player, room, row, col) + if done { + return nextState, nil + } + // Stay in loop — invalid move or not player's turn. + + case *protocol.Request_ClientExit: + signalGameOver(room) + broadcastForfeit(room, player) + leaveRoom(player, room) + return 0, ErrClientExit + + default: + log.Errorf("[pvp] player %d: unexpected %T\n", player.ID, req.Payload) + } + } +} + +// signalGameOver closes room.GameOverCh exactly once so the other player goroutine +// unblocks and transitions to StateGameOver. +var gameOverOnce sync.Map // map[int64]*sync.Once keyed by roomID + +func signalGameOver(room *database.NewRoom) { + room.Lock() + ch := room.GameOverCh + if ch != nil { + room.GameOverCh = nil // prevent double close + } + room.Unlock() + if ch != nil { + close(ch) + } +} + +// applyPvpMove validates and applies a move. Returns (nextState, true) when +// the state should change; (0, false) when move is rejected (stay in loop). +func applyPvpMove(player *database.Player, room *database.NewRoom, row, col int) (consts.StateID, bool) { + myPiece := playerPieceInRoom(room, player.ID) + if myPiece == game.Empty { + log.Errorf("[pvp] player %d not assigned a piece in room %d\n", player.ID, room.ID) + return consts.StateHome, true + } + + room.RLock() + currentTurn := room.CurrentTurn + room.RUnlock() + + if currentTurn != myPiece { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_GameMoveNotYourTurn{ + GameMoveNotYourTurn: &protocol.GameMoveNotYourTurnResponse{}, + }, + }) + return 0, false + } + + if row < 0 || row >= game.BoardSize || col < 0 || col >= game.BoardSize { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_GameMoveOutOfBounds{ + GameMoveOutOfBounds: &protocol.GameMoveOutOfBoundsResponse{}, + }, + }) + return 0, false + } + + room.Lock() + result, err := room.ApplyMove(row, col, myPiece, player.ID) + room.Unlock() + + if err != nil { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_GameMoveOccupied{ + GameMoveOccupied: &protocol.GameMoveOccupiedResponse{}, + }, + }) + return 0, false + } + + moveResp := buildMoveSuccessResponse(row, col, myPiece, player.ID, player.Name) + broadcastResponse(room, moveResp) + + if result != game.InProgress { + winner := winnerNicknameFor(room, result) + broadcastResponse(room, buildGameOverResponse(result, winner)) + + room.Lock() + room.Status = database.RoomStatusFinished + room.Unlock() + + // Signal the other player goroutine. + signalGameOver(room) + + return consts.StateGameOver, true + } + + return 0, false +} + +// broadcastForfeit sends a GameOver response declaring the opponent as winner. +func broadcastForfeit(room *database.NewRoom, disconnected *database.Player) { + room.RLock() + var winner *database.Player + for id, p := range room.Players { + if id != disconnected.ID { + winner = p + break + } + } + room.RUnlock() + + winnerName := "Unknown" + if winner != nil { + winnerName = winner.Name + } + + // Use a neutral result string; caller supplies winner name. + broadcastResponse(room, buildGameOverResponse(game.BlackWin, winnerName)) + + room.Lock() + room.Status = database.RoomStatusFinished + room.Unlock() +} diff --git a/server/state/game_shared.go b/server/state/game_shared.go new file mode 100644 index 0000000..cdbd2af --- /dev/null +++ b/server/state/game_shared.go @@ -0,0 +1,149 @@ +package state + +import ( + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/protocol" +) + +// buildGameStartingResponse constructs a GameStartingResponse from room state. +// Caller must hold at least room.RLock() or read under protection. +// This function acquires RLock internally for safety. +func buildGameStartingResponse(room *database.NewRoom, forPlayer *database.Player) *protocol.Response { + room.RLock() + blackID := room.BlackPlayerID + whiteID := room.WhitePlayerID + roomID := room.ID + room.RUnlock() + + blackNickname := resolveNickname(room, blackID) + whiteNickname := resolveNickname(room, whiteID) + + return &protocol.Response{ + Payload: &protocol.Response_GameStarting{ + GameStarting: &protocol.GameStartingResponse{ + RoomId: int32(roomID), + BlackPlayerId: int32(blackID), + BlackPlayerNickname: blackNickname, + WhitePlayerId: int32(whiteID), + WhitePlayerNickname: whiteNickname, + BoardSize: int32(game.BoardSize), + }, + }, + } +} + +// resolveNickname maps a playerID to a nickname. Returns "AI" for -1 (PVE AI slot). +func resolveNickname(room *database.NewRoom, playerID int64) string { + if playerID == -1 { + return "AI" + } + room.RLock() + p, ok := room.Players[playerID] + room.RUnlock() + if ok { + return p.Name + } + return "Unknown" +} + +// buildMoveSuccessResponse constructs a GameMoveSuccessResponse. +func buildMoveSuccessResponse(row, col int, piece game.Piece, playerID int64, nickname string) *protocol.Response { + pieceStr := "black" + if piece == game.White { + pieceStr = "white" + } + return &protocol.Response{ + Payload: &protocol.Response_GameMoveSuccess{ + GameMoveSuccess: &protocol.GameMoveSuccessResponse{ + Row: int32(row), + Col: int32(col), + Piece: pieceStr, + PlayerNickname: nickname, + PlayerId: int32(playerID), + }, + }, + } +} + +// buildGameOverResponse constructs a GameOverResponse. +// result: game.BlackWin / game.WhiteWin / game.Draw +// winnerNickname: name of winning player, empty string for draw. +func buildGameOverResponse(result game.GameResult, winnerNickname string) *protocol.Response { + resultStr := "draw" + switch result { + case game.BlackWin: + resultStr = "black_win" + case game.WhiteWin: + resultStr = "white_win" + } + return &protocol.Response{ + Payload: &protocol.Response_GameOver{ + GameOver: &protocol.GameOverResponse{ + Result: resultStr, + WinnerNickname: winnerNickname, + }, + }, + } +} + +// broadcastResponse sends resp to all players and spectators in the room. +// Acquires RLock internally; must NOT be called while holding room.Lock(). +func broadcastResponse(room *database.NewRoom, resp *protocol.Response) { + room.RLock() + targets := make([]*database.Player, 0, len(room.Players)+len(room.Spectators)) + for _, p := range room.Players { + targets = append(targets, p) + } + for _, p := range room.Spectators { + targets = append(targets, p) + } + room.RUnlock() + + for _, p := range targets { + _ = p.Send(resp) + } +} + +// playerPieceInRoom returns the game.Piece assigned to playerID in room. +// Returns game.Empty if not assigned. +func playerPieceInRoom(room *database.NewRoom, playerID int64) game.Piece { + room.RLock() + black := room.BlackPlayerID + white := room.WhitePlayerID + room.RUnlock() + if black == playerID { + return game.Black + } + if white == playerID { + return game.White + } + return game.Empty +} + +// winnerNicknameFor returns the display name of the winner given a GameResult. +// Returns empty string for a draw. +func winnerNicknameFor(room *database.NewRoom, result game.GameResult) string { + room.RLock() + blackID := room.BlackPlayerID + whiteID := room.WhitePlayerID + players := room.Players + room.RUnlock() + + var winnerID int64 + switch result { + case game.BlackWin: + winnerID = blackID + case game.WhiteWin: + winnerID = whiteID + default: + return "" + } + if winnerID == -1 { + return "AI" + } + if p, ok := players[winnerID]; ok { + return p.Name + } + return "Unknown" +} diff --git a/server/state/gameover.go b/server/state/gameover.go new file mode 100644 index 0000000..7084a7c --- /dev/null +++ b/server/state/gameover.go @@ -0,0 +1,69 @@ +package state + +import ( + "math/rand" + + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/pkg/log" + "github.com/tiennm99/gomoku/server/protocol" +) + +// gameOverState waits for the player to request a rematch or exit. +// On GameResetRequest: resets the board, broadcasts GameStartingResponse, +// and transitions back to the appropriate game state (PVP or PVE). +// On ClientExit: removes from room and goes home. +type gameOverState struct{} + +func (*gameOverState) Next(player *database.Player) (consts.StateID, error) { + room, ok := database.GetNewRoom(player.RoomID) + if !ok { + // Room gone (e.g. opponent left and room was deleted) — go home. + return consts.StateHome, nil + } + + for { + req, reqOk := <-player.CmdCh + if !reqOk { + leaveRoom(player, room) + return 0, ErrClientExit + } + + switch req.Payload.(type) { + case *protocol.Request_GameReset: + return handleGameReset(player, room) + + case *protocol.Request_ClientExit: + leaveRoom(player, room) + return 0, ErrClientExit + + default: + log.Errorf("[gameover] player %d: unexpected %T, ignoring\n", player.ID, req.Payload) + } + } +} + +// handleGameReset resets the room and starts a fresh game. +func handleGameReset(player *database.Player, room *database.NewRoom) (consts.StateID, error) { + seed := rand.Int63() + room.Lock() + room.Reset(seed) + room.Status = database.RoomStatusPlaying + room.CurrentTurn = game.Black + roomType := room.RoomType + if roomType == database.RoomTypePvp { + // Fresh GameOverCh for the new game round so player goroutines can sync again. + room.GameOverCh = make(chan struct{}) + } + room.Unlock() + + // Broadcast fresh GameStartingResponse to all players. + resp := buildGameStartingResponse(room, player) + broadcastResponse(room, resp) + + if roomType == database.RoomTypePve { + return consts.StateGamePve, nil + } + return consts.StateGamePvp, nil +} diff --git a/server/state/home.go b/server/state/home.go index d58c240..958bd97 100644 --- a/server/state/home.go +++ b/server/state/home.go @@ -1,34 +1,188 @@ package state import ( - "bytes" "github.com/tiennm99/gomoku/server/consts" "github.com/tiennm99/gomoku/server/database" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/pkg/log" + "github.com/tiennm99/gomoku/server/protocol" ) -// home is the main menu. Player chooses to join an existing room or create a new one. -type home struct{} +// homeState is the main lobby. Blocks on CmdCh waiting for room actions. +// GetRooms / SetNickname / Heartbeat are stateless and never reach CmdCh. +type homeState struct{} -func (*home) Next(player *database.Player) (consts.StateID, error) { - buf := bytes.Buffer{} - buf.WriteString("1.Join\n") - buf.WriteString("2.New\n") - err := player.WriteString(buf.String()) - if err != nil { - return 0, player.WriteError(err) +func (*homeState) Next(player *database.Player) (consts.StateID, error) { + req, ok := <-player.CmdCh + if !ok { + return 0, ErrClientExit } - selected, err := player.AskForInt() - if err != nil { - return 0, player.WriteError(err) + + switch req.Payload.(type) { + case *protocol.Request_CreateRoom: + return handleCreateRoom(player) + + case *protocol.Request_CreatePveRoom: + return handleCreatePveRoom(player, req) + + case *protocol.Request_JoinRoom: + return handleJoinRoom(player, req) + + case *protocol.Request_ClientExit: + return 0, ErrClientExit + + default: + // Unexpected for this state — log and stay. + log.Errorf("[home] player %d: unexpected request %T, staying\n", player.ID, req.Payload) + return consts.StateHome, nil } - if selected == 1 { - return consts.StateJoin, nil - } else if selected == 2 { - return consts.StateCreate, nil - } - return 0, player.WriteError(consts.ErrorsInputInvalid) } -func (*home) Exit(player *database.Player) consts.StateID { - return 0 +func handleCreateRoom(player *database.Player) (consts.StateID, error) { + room, err := database.CreatePvpRoom(player) + if err != nil { + log.Errorf("[home] CreatePvpRoom error player %d: %v\n", player.ID, err) + return consts.StateHome, nil + } + + // Owner joins as a player immediately. + if err := database.JoinNewRoom(room.ID, player); err != nil { + log.Errorf("[home] JoinNewRoom error player %d room %d: %v\n", player.ID, room.ID, err) + return consts.StateHome, nil + } + + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_RoomCreateSuccess{ + RoomCreateSuccess: &protocol.RoomCreateSuccessResponse{ + Id: int32(room.ID), + RoomOwner: player.Name, + RoomType: "PVP", + }, + }, + }) + + return consts.StateWaiting, nil +} + +func handleCreatePveRoom(player *database.Player, req *protocol.Request) (consts.StateID, error) { + difficulty := int(req.GetCreatePveRoom().GetDifficulty()) + if difficulty < consts.DifficultyEasy || difficulty > consts.DifficultyHard { + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_PveDifficultyNotSupport{ + PveDifficultyNotSupport: &protocol.PveDifficultyNotSupportResponse{}, + }, + }) + return consts.StateHome, nil + } + + // CreatePveRoom already randomizes human color and creates the AI. + room, err := database.CreatePveRoom(player, difficulty) + if err != nil { + log.Errorf("[home] CreatePveRoom error player %d: %v\n", player.ID, err) + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_PveDifficultyNotSupport{ + PveDifficultyNotSupport: &protocol.PveDifficultyNotSupportResponse{}, + }, + }) + return consts.StateHome, nil + } + + if err := database.JoinNewRoom(room.ID, player); err != nil { + log.Errorf("[home] JoinNewRoom (PVE) error player %d room %d: %v\n", player.ID, room.ID, err) + return consts.StateHome, nil + } + + room.Lock() + room.Status = database.RoomStatusPlaying + room.CurrentTurn = game.Black // Black always moves first per Gomoku rules. + room.Unlock() + + // Send GameStartingResponse to the human player. + resp := buildGameStartingResponse(room, player) + _ = player.Send(resp) + + return consts.StateGamePve, nil +} + +func handleJoinRoom(player *database.Player, req *protocol.Request) (consts.StateID, error) { + roomID := int64(req.GetJoinRoom().GetRoomId()) + + err := database.JoinNewRoom(roomID, player) + if err != nil { + switch err { + case database.ErrRoomFull, database.ErrRoomPlaying: + owner := "" + if r, ok := database.GetNewRoom(roomID); ok { + owner = r.OwnerNickname + } + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_RoomJoinFailFull{ + RoomJoinFailFull: &protocol.RoomJoinFailFullResponse{ + RoomId: int32(roomID), + RoomOwner: owner, + }, + }, + }) + default: + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_RoomJoinFailNotFound{ + RoomJoinFailNotFound: &protocol.RoomJoinFailNotFoundResponse{ + RoomId: int32(roomID), + }, + }, + }) + } + return consts.StateHome, nil + } + + room, ok := database.GetNewRoom(roomID) + if !ok { + return consts.StateHome, nil + } + + room.RLock() + playerCount := len(room.Players) + owner := room.OwnerNickname + room.RUnlock() + + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_RoomJoinSuccess{ + RoomJoinSuccess: &protocol.RoomJoinSuccessResponse{ + ClientId: int32(player.ID), + ClientNickname: player.Name, + RoomId: int32(roomID), + RoomOwner: owner, + RoomClientCount: int32(playerCount), + }, + }, + }) + + // Notify existing room members that someone joined. + broadcastJoined(room, player) + + return consts.StateWaiting, nil +} + +// broadcastJoined notifies existing players in the room that joiner has arrived. +func broadcastJoined(room *database.NewRoom, joiner *database.Player) { + room.RLock() + targets := make([]*database.Player, 0, len(room.Players)) + for id, p := range room.Players { + if id != joiner.ID { + targets = append(targets, p) + } + } + room.RUnlock() + + for _, p := range targets { + _ = p.Send(&protocol.Response{ + Payload: &protocol.Response_GameReady{ + GameReady: &protocol.GameReadyResponse{ + ClientNickname: joiner.Name, + Status: "joined", + ClientId: int32(joiner.ID), + }, + }, + }) + } } diff --git a/server/state/set_nickname.go b/server/state/set_nickname.go new file mode 100644 index 0000000..ad06e4d --- /dev/null +++ b/server/state/set_nickname.go @@ -0,0 +1,49 @@ +package state + +import ( + "github.com/tiennm99/gomoku/server/consts" + "github.com/tiennm99/gomoku/server/database" +) + +// setNicknameState waits until the player's nickname has been confirmed, then +// transitions to home. +// +// Nickname validation and the NicknameSetResponse / ShowOptionsResponse are +// handled inline by network.handleSetNickname (stateless dispatch path) — +// SetNicknameRequest never reaches CmdCh. This state therefore just polls +// player.Name until the stateless handler has written it, then proceeds. +// +// The state blocks on CmdCh so it can also detect disconnection (channel close). +// Any stateful request that arrives before home is expected means nickname was +// already committed; the request is re-pushed to the front of CmdCh if possible, +// or dropped (the client will retry from the home screen it already received). +type setNicknameState struct{} + +func (*setNicknameState) Next(player *database.Player) (consts.StateID, error) { + // If nickname already set by stateless handler, proceed immediately. + if player.Name != "" { + return consts.StateHome, nil + } + + // Block until a request arrives or CmdCh is closed. + req, ok := <-player.CmdCh + if !ok { + return 0, ErrClientExit + } + + // A stateful request arrived. By the time stateful requests come in, + // handleSetNickname should have set player.Name already. + // If Name is still empty, assign a default to unblock the flow. + if player.Name == "" { + player.Name = "Player" + } + + // Re-enqueue the request so homeState can process it. + select { + case player.CmdCh <- req: + default: + // Channel full — drop; client will see home screen and retry. + } + + return consts.StateHome, nil +} diff --git a/server/state/state.go b/server/state/state.go index 147faa9..f16c1e8 100644 --- a/server/state/state.go +++ b/server/state/state.go @@ -1,83 +1,75 @@ +// Package state implements the per-player state machine. +// Each player goroutine runs Run(player) which loops through registered states +// until ErrClientExit is returned or the cmdCh is closed. package state import ( - "strings" + "errors" - "github.com/tiennm99/gomoku/server/pkg/log" - "github.com/tiennm99/gomoku/server/pkg/async" "github.com/tiennm99/gomoku/server/consts" "github.com/tiennm99/gomoku/server/database" - "github.com/tiennm99/gomoku/server/state/game" + "github.com/tiennm99/gomoku/server/pkg/log" ) -var states = map[consts.StateID]State{} +// ErrClientExit is the sentinel returned by any state to signal a clean exit. +var ErrClientExit = errors.New("client exit") + +// State is the interface each game state implements. +// Next processes one iteration: reads from player.CmdCh, mutates state, +// sends responses, and returns the next StateID (or ErrClientExit). +type State interface { + Next(player *database.Player) (next consts.StateID, err error) +} + +var registry = map[consts.StateID]State{} + +// register adds a state to the registry. Called from init() in each state file. +func register(id consts.StateID, s State) { + registry[id] = s +} func init() { - register(consts.StateWelcome, &welcome{}) - register(consts.StateHome, &home{}) - register(consts.StateJoin, &join{}) - register(consts.StateCreate, &create{}) - register(consts.StateWaiting, &waiting{}) - register(consts.StateGomokuGame, &game.GomokuGame{}) -} - -func register(id consts.StateID, state State) { - states[id] = state -} - -type State interface { - Next(player *database.Player) (consts.StateID, error) - Exit(player *database.Player) consts.StateID + register(consts.StateWelcome, &welcomeState{}) + register(consts.StateSetNickname, &setNicknameState{}) + register(consts.StateHome, &homeState{}) + register(consts.StateWaiting, &waitingState{}) + register(consts.StateGamePvp, &gamePvpState{}) + register(consts.StateGamePve, &gamePveState{}) + register(consts.StateGameOver, &gameOverState{}) } +// Run is the state machine entry point. Spawned as a goroutine per player by +// server.go. Loops calling state.Next(player) until ErrClientExit or an +// unregistered state is encountered. +// +// On normal exit it removes the player from any room they occupy. func Run(player *database.Player) { - player.State(consts.StateWelcome) + current := consts.StateWelcome defer func() { - if err := recover(); err != nil { - async.PrintStackTrace(err) + // Cleanup: remove from room if still in one. + if player.RoomID != 0 { + database.LeaveNewRoom(player) } - log.Infof("player %s state machine break up.\n", player) + database.RemovePlayer(player.ID) + log.Infof("[state] player %d state machine exited\n", player.ID) }() - loopCount := 0 + for { - loopCount++ - if loopCount%100 == 0 { - log.Infof("[State.Run] Player %d loop count: %d, current state: %d\n", player.ID, loopCount, player.GetState()) + s, ok := registry[current] + if !ok { + log.Errorf("[state] player %d: unregistered state %d, exiting\n", player.ID, current) + return } - state := states[player.GetState()] - stateId, err := state.Next(player) + + next, err := s.Next(player) if err != nil { - if err1, ok := err.(consts.Error); ok { - if err1.Exit { - stateId = state.Exit(player) - } - } else { - log.Error(err) - state.Exit(player) - break + if errors.Is(err, ErrClientExit) { + return } + // Non-exit errors: log and exit to avoid stuck goroutine. + log.Errorf("[state] player %d: state %d returned error: %v\n", player.ID, current, err) + return } - if stateId > 0 { - player.State(stateId) - } + current = next } } - -func isExit(signal string) bool { - signal = strings.ToLower(signal) - return isX(signal, "exit", "e") -} - -func isLs(signal string) bool { - return isX(signal, "ls") -} - -func isX(signal string, x ...string) bool { - signal = strings.ToLower(signal) - for _, v := range x { - if v == signal { - return true - } - } - return false -} diff --git a/server/state/waiting.go b/server/state/waiting.go index d58f30c..4256765 100644 --- a/server/state/waiting.go +++ b/server/state/waiting.go @@ -1,219 +1,181 @@ package state import ( - "bytes" - "fmt" - "strconv" - "strings" - "time" + "math/rand" - "github.com/tiennm99/gomoku/server/pkg/log" "github.com/tiennm99/gomoku/server/consts" "github.com/tiennm99/gomoku/server/database" - "github.com/tiennm99/gomoku/server/state/game" + "github.com/tiennm99/gomoku/server/game" + "github.com/tiennm99/gomoku/server/pkg/log" + "github.com/tiennm99/gomoku/server/protocol" ) -type waiting struct{} +// waitingState handles the pre-game lobby for PVP rooms. +// +// Owner path: loops on CmdCh accepting GameStartingRequest (only when +// room.PlayerCount() == 2). On valid start, randomises colors, broadcasts +// GameStartingResponse to both players, transitions to StateGamePvp. +// +// Joiner path: loops on CmdCh. Transitions to StateGamePvp when it receives +// a GameStartingRequest (the owner's broadcast pushes it via the start channel). +// The implementation uses a StartCh (chan struct{}) on the room so the joiner +// can select on both CmdCh and the room's start signal without polling. +type waitingState struct{} -func (s *waiting) Next(player *database.Player) (consts.StateID, error) { - room := database.GetRoom(player.RoomID) - if room == nil { - return 0, consts.ErrorsExist +func (*waitingState) Next(player *database.Player) (consts.StateID, error) { + room, ok := database.GetNewRoom(player.RoomID) + if !ok { + log.Errorf("[waiting] player %d: room %d not found\n", player.ID, player.RoomID) + return consts.StateHome, nil } - s.Backfill(room) - access, err := s.waitingForStart(player, room) - if err != nil { - return 0, err + isOwner := room.IsOwner(player.ID) + if isOwner { + return ownerWait(player, room) } - if access { - switch room.Type { - case consts.GameTypeGomoku: - return consts.StateGomokuGame, nil - } - } - return s.Exit(player), nil + return joinerWait(player, room) } -func (s *waiting) Exit(player *database.Player) consts.StateID { - room := database.GetRoom(player.RoomID) - if room != nil { - isOwner := room.Creator == player.ID - database.LeaveRoom(room.ID, player.ID) - database.Broadcast(room.ID, fmt.Sprintf("%s exited room! room current has %d players\n", player.Name, room.Players)) - if isOwner { - newOwner := database.GetPlayer(room.Creator) - database.Broadcast(room.ID, fmt.Sprintf("%s become new owner\n", newOwner.Name)) +// ownerWait loops until the owner triggers a valid GameStartingRequest or exits. +func ownerWait(player *database.Player, room *database.NewRoom) (consts.StateID, error) { + for { + req, ok := <-player.CmdCh + if !ok { + leaveRoom(player, room) + return 0, ErrClientExit + } + + switch req.Payload.(type) { + case *protocol.Request_GameStarting: + room.RLock() + count := room.PlayerCount() + room.RUnlock() + + if count < 2 { + // Not enough players — reject and stay. + _ = player.Send(&protocol.Response{ + Payload: &protocol.Response_RoomPlayFailNotFound{ + RoomPlayFailNotFound: &protocol.RoomPlayFailNotFoundResponse{}, + }, + }) + continue + } + + // Randomise color assignment for fairness. + assignColors(room) + + // Broadcast GameStartingResponse to all players in room. + resp := buildGameStartingResponse(room, player) + broadcastResponse(room, resp) + + // Mark room as playing. + room.Lock() + room.Status = database.RoomStatusPlaying + room.CurrentTurn = game.Black + // Signal joiner via StartCh if present. + if room.StartCh != nil { + close(room.StartCh) + room.StartCh = nil + } + room.Unlock() + + return consts.StateGamePvp, nil + + case *protocol.Request_ClientExit: + leaveRoom(player, room) + return 0, ErrClientExit + + default: + log.Errorf("[waiting/owner] player %d: unexpected %T, ignoring\n", player.ID, req.Payload) } - s.Backfill(room) } - return consts.StateHome } -func (*waiting) Backfill(room *database.Room) { - if room.State == consts.RoomStateRunning { +// joinerWait blocks until the owner starts the game (via StartCh) or the joiner exits. +func joinerWait(player *database.Player, room *database.NewRoom) (consts.StateID, error) { + room.RLock() + startCh := room.StartCh + room.RUnlock() + + for { + if startCh == nil { + // StartCh not set or already closed — check status. + room.RLock() + status := room.Status + room.RUnlock() + if status == database.RoomStatusPlaying { + return consts.StateGamePvp, nil + } + // Fall back to CmdCh-only select. + startCh = make(chan struct{}) // never closed; effectively disables the case + } + + select { + case <-startCh: + // Owner started the game. + return consts.StateGamePvp, nil + + case req, ok := <-player.CmdCh: + if !ok { + leaveRoom(player, room) + return 0, ErrClientExit + } + switch req.Payload.(type) { + case *protocol.Request_ClientExit: + leaveRoom(player, room) + return 0, ErrClientExit + default: + // Non-exit requests ignored in joiner waiting state. + log.Errorf("[waiting/joiner] player %d: unexpected %T, ignoring\n", player.ID, req.Payload) + } + } + } +} + +// assignColors randomly assigns Black/White to the two players in a PVP room. +// Caller must NOT hold room.Lock() before calling — acquires it internally. +func assignColors(room *database.NewRoom) { + room.Lock() + playerIDs := make([]int64, 0, 2) + for id := range room.Players { + playerIDs = append(playerIDs, id) + } + room.Unlock() + + if len(playerIDs) < 2 { return } - newPlayer := database.Backfill(room.ID) - if newPlayer != nil { - database.Broadcast(room.ID, fmt.Sprintf("%s has joined room! room current has %d players\n", newPlayer.Name, room.Players)) - } -} -func (*waiting) Kicking(player *database.Player) { - room := database.GetRoom(player.RoomID) - if room != nil { - database.Broadcast(room.ID, fmt.Sprintf("%s has been kicked!\n", player.Name)) - database.Kicking(room.ID, player.ID) - database.Broadcast(room.ID, fmt.Sprintf("room current has %d players\n", room.Players)) - } -} + // Shuffle to randomise which player gets Black. + rand.Shuffle(len(playerIDs), func(i, j int) { + playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i] + }) -func (s *waiting) waitingForStart(player *database.Player, room *database.Room) (bool, error) { - access := false - player.StartTransaction() - defer player.StopTransaction() - loopCount := 0 - for { - loopCount++ - if loopCount%100 == 0 { - log.Infof("[waitingForStart] Player %d (Room %d) loop count: %d, room.State: %d, access: %v\n", player.ID, player.RoomID, loopCount, room.State, access) - } - signal, err := player.AskForStringWithoutTransaction(time.Second) - if err != nil && err != consts.ErrorsTimeout { - return access, err - } - - if !database.IsValidPlayer(room.ID, player.ID) { - return false, consts.ErrorsPlayerNotInRoom - } - - if room.State == consts.RoomStateRunning && player.Role == database.RolePlayer { - access = true - break - } - signal = strings.TrimSpace(strings.ToLower(signal)) - if signal == "" { - continue - } - - segments := strings.Split(signal, " ") - if len(segments) == 1 { - if segments[0] == "ls" || segments[0] == "v" { - viewRoomPlayers(room, player) - continue - } else if segments[0] == "start" || signal == "s" { - if room.Creator == player.ID { - if room.Players <= 1 { - _ = player.WriteError(consts.ErrorsGamePlayersInsufficient) - continue - } - err = startGame(player, room) - if err != nil { - return access, err - } - access = true - break - } - } - } else if len(segments) == 2 { - if segments[0] == "kicking" || segments[0] == "kill" || segments[0] == "k" { - if room.Creator == player.ID { - kickedId, _ := strconv.ParseInt(segments[1], 10, 64) - if kickedId == player.ID { - _ = player.WriteError(consts.ErrorsCannotKickYourself) - continue - } - kickedPlayer := database.GetPlayer(kickedId) - if kickedPlayer == nil || kickedPlayer.RoomID != room.ID { - _ = player.WriteError(consts.ErrorsPlayerNotInRoom) - continue - } - s.Kicking(kickedPlayer) - continue - } - } - } else if len(segments) == 3 && room.Creator == player.ID { - database.SetRoomProps(room, segments[1], segments[2]) - continue - } - - if room.EnableChat { - if room.State == consts.RoomStateRunning { - _ = player.WriteString(fmt.Sprintf("%s\n", consts.ErrorsChatUnopenedDuringGame.Error())) - } else { - database.BroadcastChat(player, fmt.Sprintf("%s [%s] say: %s\n", player.Name, player.Role, signal)) - } - } else { - _ = player.WriteString(fmt.Sprintf("%s\n", consts.ErrorsChatUnopened.Error())) - } - } - return access, nil -} - -func startGame(player *database.Player, room *database.Room) (err error) { room.Lock() - defer room.Unlock() - switch room.Type { - case consts.GameTypeGomoku: - room.Game, err = game.InitGomokuGame(room) - } - if err != nil { - _ = player.WriteError(err) - return err - } - room.State = consts.RoomStateRunning - return nil + room.BlackPlayerID = playerIDs[0] + room.WhitePlayerID = playerIDs[1] + room.Unlock() } -func viewRoomPlayers(room *database.Room, currPlayer *database.Player) { - buf := bytes.Buffer{} - buf.WriteString(fmt.Sprintf("Room ID: %d\n", room.ID)) - buf.WriteString("Players:\n") - for playerId := range database.RoomPlayers(room.ID) { - player := database.GetPlayer(playerId) - if room.EnableShowIP { - buf.WriteString(fmt.Sprintf("%s [%s], score: %d, id: %d, ip: %s\n", player.Name, player.Role, player.Amount, player.ID, maskIP(player.IP))) - } else { - buf.WriteString(fmt.Sprintf("%s [%s], score: %d, id: %d\n", player.Name, player.Role, player.Amount, player.ID)) - } +// leaveRoom removes player from their current room cleanly. +func leaveRoom(player *database.Player, room *database.NewRoom) { + database.LeaveNewRoom(player) + // Notify remaining players. + room.RLock() + targets := make([]*database.Player, 0, len(room.Players)) + for _, p := range room.Players { + targets = append(targets, p) } - - buf.WriteString("\nSpectators:\n") - for spectatorId := range database.RoomSpectators(room.ID) { - spectator := database.GetPlayer(spectatorId) - if room.EnableShowIP { - buf.WriteString(fmt.Sprintf("%s [spectator], score: %d, id: %d, ip: %s\n", spectator.Name, spectator.Amount, spectator.ID, maskIP(spectator.IP))) - } else { - buf.WriteString(fmt.Sprintf("%s [spectator], score: %d, id: %d\n", spectator.Name, spectator.Amount, spectator.ID)) - } + room.RUnlock() + for _, p := range targets { + _ = p.Send(&protocol.Response{ + Payload: &protocol.Response_GameReady{ + GameReady: &protocol.GameReadyResponse{ + ClientNickname: player.Name, + Status: "left", + ClientId: int32(player.ID), + }, + }, + }) } - - buf.WriteString("\nSettings:\n") - buf.WriteString(fmt.Sprintf("%-5s%-5v\n", "ip:", sprintPropsState(room.EnableShowIP))) - pwd := room.Password - if pwd != "" { - if room.Creator != currPlayer.ID { - pwd = "********" - } - } else { - pwd = "off" - } - buf.WriteString(fmt.Sprintf("%-5s%-20v\n", "pwd", pwd)) - _ = currPlayer.WriteString(buf.String()) -} - -func sprintPropsState(on bool) string { - if on { - return "on" - } - return "off" -} - -func maskIP(ip string) string { - parts := strings.Split(ip, ".") - if len(parts) == 4 { - return parts[0] + "." + parts[1] + ".*.*" - } - return "*.*.*.*" } diff --git a/server/state/welcome.go b/server/state/welcome.go index 8e816aa..0b2ac0e 100644 --- a/server/state/welcome.go +++ b/server/state/welcome.go @@ -1,25 +1,15 @@ package state import ( - "bytes" - "fmt" "github.com/tiennm99/gomoku/server/consts" "github.com/tiennm99/gomoku/server/database" ) -// welcome is the initial state. Sends a greeting and transitions to home. -type welcome struct{} +// welcomeState is the entry point for every new connection. +// server.go already sends ClientConnectResponse + NicknameSetResponse{0} +// before spawning Run(), so this state just transitions immediately. +type welcomeState struct{} -func (*welcome) Next(player *database.Player) (consts.StateID, error) { - buf := bytes.Buffer{} - buf.WriteString(fmt.Sprintf("Hi %s, Welcome to ratel online! rules at https://github.com/tiennm99/gomoku/server/blob/main/README.md\n", player.Name)) - err := player.WriteString(buf.String()) - if err != nil { - return 0, player.WriteError(err) - } - return consts.StateHome, nil -} - -func (*welcome) Exit(player *database.Player) consts.StateID { - return 0 +func (*welcomeState) Next(_ *database.Player) (consts.StateID, error) { + return consts.StateSetNickname, nil }