mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-09-02 08:20:36 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user