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).
This commit is contained in:
2026-04-11 14:28:19 +07:00
parent 5ceb7ef924
commit 43ac04ac79
11 changed files with 1006 additions and 294 deletions
+9 -3
View File
@@ -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 (
+2 -3
View File
@@ -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.
+162
View File
@@ -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
}
+180
View File
@@ -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()
}
+149
View File
@@ -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"
}
+69
View File
@@ -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
}
+175 -21
View File
@@ -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),
},
},
})
}
}
+49
View File
@@ -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
}
+52 -60
View File
@@ -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
}
+153 -191
View File
@@ -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 "*.*.*.*"
}
+6 -16
View File
@@ -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
}