diff --git a/server/consts/const.go b/server/consts/const.go index 512b46b..643c0de 100644 --- a/server/consts/const.go +++ b/server/consts/const.go @@ -17,6 +17,7 @@ const ( StateGamePvp StateGamePve StateGameOver + StateWatching // Kept for backward compat (unused after phase-06 rewrite). StateJoin = 0 // collapsed into home diff --git a/server/database/store.go b/server/database/store.go index b210c58..40ce8ac 100644 --- a/server/database/store.go +++ b/server/database/store.go @@ -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 { diff --git a/server/state/game_shared.go b/server/state/game_shared.go index cdbd2af..b6f1a96 100644 --- a/server/state/game_shared.go +++ b/server/state/game_shared.go @@ -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. diff --git a/server/state/home.go b/server/state/home.go index 958bd97..730e3da 100644 --- a/server/state/home.go +++ b/server/state/home.go @@ -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() diff --git a/server/state/state.go b/server/state/state.go index f16c1e8..39f381c 100644 --- a/server/state/state.go +++ b/server/state/state.go @@ -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 diff --git a/server/state/watching.go b/server/state/watching.go new file mode 100644 index 0000000..fed565d --- /dev/null +++ b/server/state/watching.go @@ -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 + } +}