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:
2026-04-11 15:08:15 +07:00
parent e8281e5411
commit 34b0f8f2ce
6 changed files with 209 additions and 1 deletions
+1
View File
@@ -17,6 +17,7 @@ const (
StateGamePvp
StateGamePve
StateGameOver
StateWatching
// Kept for backward compat (unused after phase-06 rewrite).
StateJoin = 0 // collapsed into home
+64 -1
View File
@@ -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 {
+54
View File
@@ -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.
+29
View File
@@ -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()
+1
View File
@@ -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
+60
View File
@@ -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
}
}