mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-08-18 06:26:04 +00:00
feat(state): watching state with room snapshot replay and spectator-cannot-act
- Add StateWatching to consts/const.go (iota value 8) - Register watchingState in state.go runner init() - Add sendRoomSnapshot helper in game_shared.go: sends WatchGameSuccessResponse, GameStartingResponse, and one GameMoveSuccessResponse per MoveHistory entry using room.Snapshot() to avoid holding lock during Send calls - Update home.go: handle WatchGameRequest → WatchNewRoom + sendRoomSnapshot → StateWatching; RoomPlayFailNotFoundResponse on missing room - New watching.go: WatchGameExitRequest → UnwatchNewRoom + ShowOptions → StateHome; ClientExitRequest → UnwatchNewRoom → ErrClientExit; GameMoveRequest → SpectatorCannotActResponse + stay; closed CmdCh → UnwatchNewRoom → ErrClientExit - Update store.go LeaveNewRoom: when last player leaves, collect and eject spectators (clear Spectators map under lock, then send ClientExitResponse + push synthetic WatchGameExitRequest to each spectator CmdCh after releasing lock)
This commit is contained in:
@@ -17,6 +17,7 @@ const (
|
||||
StateGamePvp
|
||||
StateGamePve
|
||||
StateGameOver
|
||||
StateWatching
|
||||
|
||||
// Kept for backward compat (unused after phase-06 rewrite).
|
||||
StateJoin = 0 // collapsed into home
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/gomoku/server/game"
|
||||
newproto "github.com/tiennm99/gomoku/server/protocol"
|
||||
)
|
||||
|
||||
// store is the package-level in-memory data store for the new domain model.
|
||||
@@ -257,13 +258,42 @@ func LeaveNewRoom(player *Player) {
|
||||
}
|
||||
}
|
||||
|
||||
empty := len(r.Players) == 0 && len(r.Spectators) == 0
|
||||
noPlayers := len(r.Players) == 0
|
||||
roomID := r.ID
|
||||
|
||||
// Collect spectators to eject if all players have left.
|
||||
// We do this under lock to get a consistent snapshot, then send after unlock.
|
||||
var spectatorsToEject []*Player
|
||||
if noPlayers && len(r.Spectators) > 0 {
|
||||
spectatorsToEject = make([]*Player, 0, len(r.Spectators))
|
||||
for _, sp := range r.Spectators {
|
||||
spectatorsToEject = append(spectatorsToEject, sp)
|
||||
}
|
||||
// Clear spectator map so UnwatchNewRoom calls from their state machines are no-ops.
|
||||
r.Spectators = make(map[int64]*Player)
|
||||
}
|
||||
|
||||
empty := noPlayers && len(r.Spectators) == 0
|
||||
r.Unlock()
|
||||
|
||||
player.RoomID = 0
|
||||
player.Role = ""
|
||||
|
||||
// Notify ejected spectators after releasing room lock (never hold lock while sending).
|
||||
for _, sp := range spectatorsToEject {
|
||||
sp.RoomID = 0
|
||||
sp.Role = ""
|
||||
// Push a ClientExitResponse so the spectator's state machine can react.
|
||||
// The watching state will receive this via player.CmdCh being unblocked
|
||||
// only if the response triggers a CmdCh push — but ClientExitResponse is a
|
||||
// *send* response, not a request. Push a synthetic WatchGameExit to CmdCh
|
||||
// so watchingState.Next() wakes up and transitions to StateHome.
|
||||
_ = sp.Send(ejectSpectatorResponse(roomID))
|
||||
// Enqueue a WatchGameExit on the spectator's CmdCh so the watching state
|
||||
// machine wakes up and transitions back to StateHome cleanly.
|
||||
pushWatchGameExitToCmdCh(sp)
|
||||
}
|
||||
|
||||
if empty {
|
||||
store.mu.Lock()
|
||||
deleteNewRoom(roomID)
|
||||
@@ -271,6 +301,39 @@ func LeaveNewRoom(player *Player) {
|
||||
}
|
||||
}
|
||||
|
||||
// ejectSpectatorResponse builds the ClientExitResponse sent when a room is closed.
|
||||
// Uses exit_client_id=0 and a sentinel nickname to signal "room closed" not a kick.
|
||||
func ejectSpectatorResponse(roomID int64) *newproto.Response {
|
||||
return &newproto.Response{
|
||||
Payload: &newproto.Response_ClientExit{
|
||||
ClientExit: &newproto.ClientExitResponse{
|
||||
RoomId: int32(roomID),
|
||||
ExitClientId: 0,
|
||||
ExitClientNickname: "room_closed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pushWatchGameExitToCmdCh enqueues a synthetic WatchGameExitRequest on the spectator's
|
||||
// CmdCh so the watchingState state machine unblocks and transitions to StateHome.
|
||||
// Non-blocking: drops silently if the channel is full or nil.
|
||||
func pushWatchGameExitToCmdCh(player *Player) {
|
||||
if player.CmdCh == nil {
|
||||
return
|
||||
}
|
||||
req := &newproto.Request{
|
||||
Payload: &newproto.Request_WatchGameExit{
|
||||
WatchGameExit: &newproto.WatchGameExitRequest{},
|
||||
},
|
||||
}
|
||||
select {
|
||||
case player.CmdCh <- req:
|
||||
default:
|
||||
// CmdCh full — spectator will time out or disconnect naturally.
|
||||
}
|
||||
}
|
||||
|
||||
// WatchNewRoom adds a player to a room's Spectators list.
|
||||
// Returns ErrRoomNotFound if the room does not exist.
|
||||
func WatchNewRoom(roomID int64, player *Player) error {
|
||||
|
||||
@@ -6,6 +6,60 @@ import (
|
||||
"github.com/tiennm99/gomoku/server/protocol"
|
||||
)
|
||||
|
||||
// sendRoomSnapshot sends the current room state to a newly-joined spectator.
|
||||
// Order: WatchGameSuccessResponse → GameStartingResponse → one GameMoveSuccessResponse per history entry.
|
||||
// Uses Snapshot() to avoid holding a lock while calling player.Send.
|
||||
func sendRoomSnapshot(player *database.Player, room *database.NewRoom) {
|
||||
snap := room.Snapshot()
|
||||
|
||||
statusStr := "waiting"
|
||||
switch room.Status { // read without lock: string constant; race-safe for a single int read
|
||||
case database.RoomStatusPlaying:
|
||||
statusStr = "playing"
|
||||
case database.RoomStatusFinished:
|
||||
statusStr = "finished"
|
||||
}
|
||||
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_WatchGameSuccess{
|
||||
WatchGameSuccess: &protocol.WatchGameSuccessResponse{
|
||||
Owner: snap.PlayerNames[snap.BlackPlayerID],
|
||||
Status: statusStr,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
blackName := snap.PlayerNames[snap.BlackPlayerID]
|
||||
whiteName := snap.PlayerNames[snap.WhitePlayerID]
|
||||
if blackName == "" {
|
||||
blackName = "Unknown"
|
||||
}
|
||||
if whiteName == "" {
|
||||
whiteName = "Unknown"
|
||||
}
|
||||
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_GameStarting{
|
||||
GameStarting: &protocol.GameStartingResponse{
|
||||
RoomId: int32(snap.ID),
|
||||
BlackPlayerId: int32(snap.BlackPlayerID),
|
||||
BlackPlayerNickname: blackName,
|
||||
WhitePlayerId: int32(snap.WhitePlayerID),
|
||||
WhitePlayerNickname: whiteName,
|
||||
BoardSize: int32(game.BoardSize),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, mv := range snap.MoveHistory {
|
||||
nickname := snap.PlayerNames[mv.PlayerID]
|
||||
if nickname == "" {
|
||||
nickname = "Unknown"
|
||||
}
|
||||
_ = player.Send(buildMoveSuccessResponse(mv.Row, mv.Col, mv.Piece, mv.PlayerID, nickname))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -28,6 +28,9 @@ func (*homeState) Next(player *database.Player) (consts.StateID, error) {
|
||||
case *protocol.Request_JoinRoom:
|
||||
return handleJoinRoom(player, req)
|
||||
|
||||
case *protocol.Request_WatchGame:
|
||||
return handleWatchGame(player, req)
|
||||
|
||||
case *protocol.Request_ClientExit:
|
||||
return 0, ErrClientExit
|
||||
|
||||
@@ -163,6 +166,32 @@ func handleJoinRoom(player *database.Player, req *protocol.Request) (consts.Stat
|
||||
return consts.StateWaiting, nil
|
||||
}
|
||||
|
||||
func handleWatchGame(player *database.Player, req *protocol.Request) (consts.StateID, error) {
|
||||
roomID := int64(req.GetWatchGame().GetRoomId())
|
||||
|
||||
room, ok := database.GetNewRoom(roomID)
|
||||
if !ok {
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_RoomPlayFailNotFound{
|
||||
RoomPlayFailNotFound: &protocol.RoomPlayFailNotFoundResponse{},
|
||||
},
|
||||
})
|
||||
return consts.StateHome, nil
|
||||
}
|
||||
|
||||
if err := database.WatchNewRoom(roomID, player); err != nil {
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_RoomPlayFailNotFound{
|
||||
RoomPlayFailNotFound: &protocol.RoomPlayFailNotFoundResponse{},
|
||||
},
|
||||
})
|
||||
return consts.StateHome, nil
|
||||
}
|
||||
|
||||
sendRoomSnapshot(player, room)
|
||||
return consts.StateWatching, nil
|
||||
}
|
||||
|
||||
// broadcastJoined notifies existing players in the room that joiner has arrived.
|
||||
func broadcastJoined(room *database.NewRoom, joiner *database.Player) {
|
||||
room.RLock()
|
||||
|
||||
@@ -36,6 +36,7 @@ func init() {
|
||||
register(consts.StateGamePvp, &gamePvpState{})
|
||||
register(consts.StateGamePve, &gamePveState{})
|
||||
register(consts.StateGameOver, &gameOverState{})
|
||||
register(consts.StateWatching, &watchingState{})
|
||||
}
|
||||
|
||||
// Run is the state machine entry point. Spawned as a goroutine per player by
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package state — watchingState handles the read-only spectator view of a game.
|
||||
// A spectator sits in this state after successfully calling WatchGame from home.
|
||||
// Live broadcasts (moves, game-over) arrive via player.SendCh (push model) because
|
||||
// broadcastResponse in game_shared.go already iterates room.Spectators.
|
||||
// The state machine here only needs to handle explicit control messages on CmdCh.
|
||||
package state
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/gomoku/server/consts"
|
||||
"github.com/tiennm99/gomoku/server/database"
|
||||
"github.com/tiennm99/gomoku/server/pkg/log"
|
||||
"github.com/tiennm99/gomoku/server/protocol"
|
||||
)
|
||||
|
||||
// watchingState is the per-player state for a spectator watching an in-progress game.
|
||||
// Accepted commands:
|
||||
// - WatchGameExitRequest → unwatch, show options menu, go to StateHome.
|
||||
// - ClientExitRequest → unwatch, return ErrClientExit (connection close).
|
||||
// - GameMoveRequest → rejected with SpectatorCannotActResponse, stay.
|
||||
// - (closed CmdCh) → unwatch, return ErrClientExit.
|
||||
// - any other request → log and stay (spectator cannot act).
|
||||
type watchingState struct{}
|
||||
|
||||
func (*watchingState) Next(player *database.Player) (consts.StateID, error) {
|
||||
req, ok := <-player.CmdCh
|
||||
if !ok {
|
||||
// Connection closed or channel drained — clean up quietly.
|
||||
database.UnwatchNewRoom(player)
|
||||
return 0, ErrClientExit
|
||||
}
|
||||
|
||||
switch req.Payload.(type) {
|
||||
case *protocol.Request_WatchGameExit:
|
||||
database.UnwatchNewRoom(player)
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_ShowOptions{
|
||||
ShowOptions: &protocol.ShowOptionsResponse{},
|
||||
},
|
||||
})
|
||||
return consts.StateHome, nil
|
||||
|
||||
case *protocol.Request_ClientExit:
|
||||
database.UnwatchNewRoom(player)
|
||||
return 0, ErrClientExit
|
||||
|
||||
case *protocol.Request_GameMove:
|
||||
// Spectators may not make moves.
|
||||
_ = player.Send(&protocol.Response{
|
||||
Payload: &protocol.Response_SpectatorCannotAct{
|
||||
SpectatorCannotAct: &protocol.SpectatorCannotActResponse{},
|
||||
},
|
||||
})
|
||||
return consts.StateWatching, nil
|
||||
|
||||
default:
|
||||
log.Errorf("[watching] player %d: unexpected request %T while spectating, ignoring\n",
|
||||
player.ID, req.Payload)
|
||||
return consts.StateWatching, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user