Files
gomoku/server/state/flow_test.go
T
tiennm99 cbc74c7813 fix(exit): distinguish self vs peer ClientExitResponse
Fixes the server log "gameover player N: unexpected Request_CreateRoom".

Root cause: when ANY player leaves a room, leaveRoom broadcasts
ClientExitResponse to ALL players in room.Players (so peers see who
left). The broadcast fired CLIENT_EXIT on both the exiting client AND
its peer. But the client's handlers treated every CLIENT_EXIT as "I
exited" — transitioning to lobby, clearing gameState.roomId, hiding the
HUD. Meanwhile the peer's server-side state machine was still in
gameoverState. The peer then clicked Create Room from the (now wrongly
shown) lobby, and the request arrived at gameoverState's default case.

Client:
- New services/client-exit-helpers.js with isSelfExit(data, clientId).
  exitClientId === 0 or missing → bare self-ack (home.ClientExit /
  watching.ClientExit / room-closed eject). exitClientId === clientId
  → our own leaveRoom broadcast loopback. Anything else → a peer left;
  stay in state.
- game-state-service CLIENT_EXIT handler: only reset() when isSelfExit.
- game-scene._onClientExit: only transition to MenuScene when isSelfExit;
  peer exits render a toast "<nickname> left the room" instead.
- menu-ui.js module CLIENT_EXIT handler: only showLobby() when isSelfExit.

Server: the client fix alone leaves a new hole — after a peer leaves a
finished PVP room, the remaining player sits in gameoverState with a
game-over modal and two buttons. "Play Again" would try to restart a
PVP game with only one human; "Leave" still works. To close this hole
cleanly:
- state/waiting.go: add kickStaleRoomPeers helper. After a leaveRoom
  in gameoverState, if the room is a PVP room with < 2 remaining human
  players it's unplayable — push a synthetic Request_ClientExit onto
  each remaining peer's CmdCh. Their gameoverState goroutine wakes up,
  processes the synthetic exit, and returns StateHome in lockstep.
- state/gameover.go: call kickStaleRoomPeers after leaveRoom in the
  ClientExit case.

Tests:
- Rename TestFlow_CreateRoomAfterOpponentForfeit →
  TestFlow_OpponentExit_KicksPeerFromGameover and rewrite to assert
  the auto-kick behavior: black leaves → white's gameoverState must
  return StateHome WITHOUT any test-driven CmdCh push. Then both
  players can create fresh rooms.

Full suite + client build green.
2026-04-11 19:39:24 +07:00

824 lines
26 KiB
Go

package state
import (
"testing"
"time"
"github.com/tiennm99/gomoku/server/consts"
"github.com/tiennm99/gomoku/server/game"
"github.com/tiennm99/gomoku/server/lobby"
"github.com/tiennm99/gomoku/server/protocol"
)
// End-to-end flow tests that drive the state machine through realistic user
// journeys. These catch bugs that unit tests on individual states miss — in
// particular ghosted rooms left behind after a leave, and broken rematch
// synchronisation between two player goroutines.
// runState invokes a state's Next on the player and returns (nextID, err).
// Used where callers need to drive the machine one step at a time.
func runState(id consts.StateID, p *lobby.Player) (consts.StateID, error) {
s, ok := registry[id]
if !ok {
return 0, ErrClientExit
}
return s.Next(p)
}
// TestFlow_HomeCreateLeaveCreate_OneRoomVisible simulates the exact reported
// bug: from home, create a PVP room, leave it from waiting state, create
// another, then assert lobby.GetAllRooms returns only the second room.
// If a "ghost" room is left behind anywhere in the state machine this fails.
func TestFlow_HomeCreateLeaveCreate_OneRoomVisible(t *testing.T) {
alice := makeRegisteredPlayer(t, "alice")
// home → create room 1 → waiting
alice.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}},
}
next, err := runState(consts.StateHome, alice)
if err != nil {
t.Fatalf("home create: %v", err)
}
if next != consts.StateWaiting {
t.Fatalf("expected StateWaiting, got %d", next)
}
// waiting → client exit → home (room 1 should be deleted)
alice.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err = runState(consts.StateWaiting, alice)
if err != nil {
t.Fatalf("waiting exit: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome after exit, got %d", next)
}
// Critical check: after leaving, the store should contain 0 rooms.
rooms := lobby.GetAllRooms()
if len(rooms) != 0 {
ids := make([]int64, 0, len(rooms))
for _, r := range rooms {
ids = append(ids, r.ID)
}
t.Fatalf("after leave: expected 0 rooms, got %d: %v", len(rooms), ids)
}
// home → create room 2 → waiting
alice.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}},
}
next, err = runState(consts.StateHome, alice)
if err != nil {
t.Fatalf("home create 2: %v", err)
}
if next != consts.StateWaiting {
t.Fatalf("expected StateWaiting, got %d", next)
}
// GetRooms must return exactly 1 room (the fresh room 2).
rooms = lobby.GetAllRooms()
if len(rooms) != 1 {
ids := make([]int64, 0, len(rooms))
for _, r := range rooms {
ids = append(ids, r.ID)
}
t.Fatalf("expected 1 room after second create, got %d: %v", len(rooms), ids)
}
}
// TestFlow_RoomListReflectsCreateLeaveCycle verifies that after drive the
// state machine through create→leave→create, a follow-up GetAllRooms returns
// exactly the one currently-active room. Directly targets Bug 2.
func TestFlow_RoomListReflectsCreateLeaveCycle(t *testing.T) {
alice := makeRegisteredPlayer(t, "alice")
// Baseline.
if n := len(lobby.GetAllRooms()); n != 0 {
t.Fatalf("precondition: store should be empty, got %d rooms", n)
}
// Create → 1 room.
alice.CmdCh <- &protocol.Request{Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}}}
if _, err := runState(consts.StateHome, alice); err != nil {
t.Fatalf("home create: %v", err)
}
if n := len(lobby.GetAllRooms()); n != 1 {
t.Fatalf("after create 1: expected 1 room, got %d", n)
}
// Leave → 0 rooms.
alice.CmdCh <- &protocol.Request{Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}}}
if _, err := runState(consts.StateWaiting, alice); err != nil {
t.Fatalf("waiting exit: %v", err)
}
if n := len(lobby.GetAllRooms()); n != 0 {
t.Fatalf("after leave: expected 0 rooms, got %d", n)
}
// Create again → 1 room (not 2).
alice.CmdCh <- &protocol.Request{Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}}}
if _, err := runState(consts.StateHome, alice); err != nil {
t.Fatalf("home create 2: %v", err)
}
if n := len(lobby.GetAllRooms()); n != 1 {
t.Fatalf("after create 2: expected 1 room, got %d — Bug 2 reproduced", n)
}
}
// TestFlow_JoinAutoStartsGame verifies the end-to-end PVP auto-start: owner
// creates a room, a second player sends JoinRoomRequest, and the handler
// (running on joiner's goroutine) should assign colors, flip Status=Playing,
// close StartCh, broadcast GameStartingResponse, and return StateGamePvp.
// In parallel, the owner's waitingState wakes via StartCh and transitions
// to StateGamePvp. No explicit Start click.
func TestFlow_JoinAutoStartsGame(t *testing.T) {
// Setup: owner creates a room and is already in waiting state.
owner := makeRegisteredPlayer(t, "owner")
room, err := lobby.CreatePvpRoom(owner)
if err != nil {
t.Fatalf("CreatePvpRoom: %v", err)
}
if err := lobby.JoinRoom(room.ID, owner); err != nil {
t.Fatalf("owner JoinRoom: %v", err)
}
// Owner goroutine: run waitingState, expect it to transition on StartCh close.
ownerDone := make(chan consts.StateID, 1)
ownerErr := make(chan error, 1)
go func() {
next, err := runState(consts.StateWaiting, owner)
ownerDone <- next
ownerErr <- err
}()
time.Sleep(20 * time.Millisecond) // let owner enter waitingState
// Joiner sends JoinRoomRequest from home state.
joiner := makeRegisteredPlayer(t, "joiner")
joiner.CmdCh <- &protocol.Request{
Payload: &protocol.Request_JoinRoom{JoinRoom: &protocol.JoinRoomRequest{RoomId: int32(room.ID)}},
}
joinerNext, err := runState(consts.StateHome, joiner)
if err != nil {
t.Fatalf("joiner home: %v", err)
}
if joinerNext != consts.StateGamePvp {
t.Fatalf("joiner expected StateGamePvp (auto-start), got %d", joinerNext)
}
// Owner must have transitioned to gamePvp in lockstep.
select {
case next := <-ownerDone:
if next != consts.StateGamePvp {
t.Errorf("owner expected StateGamePvp, got %d", next)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("owner waitingState did not transition within 500ms — auto-start didn't fire StartCh")
}
if err := <-ownerErr; err != nil {
t.Errorf("owner error: %v", err)
}
// Room must be fully set up.
room.RLock()
status := room.Status
blackID := room.BlackPlayerID
whiteID := room.WhitePlayerID
turn := room.CurrentTurn
room.RUnlock()
if status != lobby.RoomStatusPlaying {
t.Errorf("room.Status = %d, want Playing", status)
}
if turn != game.Black {
t.Errorf("room.CurrentTurn = %v, want Black", turn)
}
// Colors assigned to the actual two players.
if !(blackID == owner.ID && whiteID == joiner.ID) &&
!(blackID == joiner.ID && whiteID == owner.ID) {
t.Errorf("colors not assigned to actual players: black=%d white=%d owner=%d joiner=%d",
blackID, whiteID, owner.ID, joiner.ID)
}
// Both players must have received GameStartingResponse.
for name, p := range map[string]*lobby.Player{"owner": owner, "joiner": joiner} {
found := false
for _, r := range drainSend(p) {
if _, ok := r.Payload.(*protocol.Response_GameStarting); ok {
found = true
break
}
}
if !found {
t.Errorf("%s did not receive GameStartingResponse", name)
}
}
}
// TestFlow_HomeExit_DoesNotKillSession verifies that ClientExit from home
// keeps the session alive (returns StateHome, not ErrClientExit) so the
// client isn't stuck after pressing a Back/Leave button in lobby.
func TestFlow_HomeExit_DoesNotKillSession(t *testing.T) {
alice := makeRegisteredPlayer(t, "alice")
alice.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err := runState(consts.StateHome, alice)
if err != nil {
t.Fatalf("home exit should not return error, got %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome, got %d", next)
}
// A ClientExitResponse should have been queued so the client knows to redraw.
found := false
for _, r := range drainSend(alice) {
if _, ok := r.Payload.(*protocol.Response_ClientExit); ok {
found = true
break
}
}
if !found {
t.Error("expected ClientExitResponse from home.ClientExit path")
}
}
// TestFlow_Rematch_BothPlayersTransition is the guard for Bug 1.
// Two players finish a game and both land in gameoverState; only one clicks
// Play Again. Both player goroutines must transition to gamePvpState —
// otherwise the peer is stuck and cannot move when the game restarts.
func TestFlow_Rematch_BothPlayersTransition(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
// Assert preconditions: room is finished, both players wired.
if room.Status != lobby.RoomStatusFinished {
t.Fatalf("precondition: room should be Finished, got %d", room.Status)
}
// Black clicks Play Again.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_GameReset{GameReset: &protocol.GameResetRequest{}},
}
// Start white's gameoverState.Next in a goroutine — it should unblock via RematchCh.
whiteDone := make(chan consts.StateID, 1)
whiteErr := make(chan error, 1)
go func() {
next, err := runState(consts.StateGameOver, white)
whiteDone <- next
whiteErr <- err
}()
// Let white's goroutine enter gameoverState (and create/publish RematchCh).
time.Sleep(20 * time.Millisecond)
// Black processes its GameReset.
blackNext, err := runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("black gameover: %v", err)
}
if blackNext != consts.StateGamePvp {
t.Fatalf("black expected StateGamePvp, got %d", blackNext)
}
// White must unblock and transition to gamePvp as well.
select {
case next := <-whiteDone:
if next != consts.StateGamePvp {
t.Errorf("white expected StateGamePvp after rematch, got %d", next)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("white goroutine did not transition to StateGamePvp within 500ms — rematch sync broken")
}
if err := <-whiteErr; err != nil {
t.Errorf("white error: %v", err)
}
// Room state must be reset and ready for a fresh game.
room.RLock()
status := room.Status
nMoves := len(room.MoveHistory)
room.RUnlock()
if status != lobby.RoomStatusPlaying {
t.Errorf("room.Status after rematch = %d, want Playing", status)
}
if nMoves != 0 {
t.Errorf("room.MoveHistory should be empty after reset, got %d moves", nMoves)
}
// Both players should have received a fresh GameStartingResponse.
for name, p := range map[string]*lobby.Player{"black": black, "white": white} {
found := false
for _, r := range drainSend(p) {
if _, ok := r.Payload.(*protocol.Response_GameStarting); ok {
found = true
break
}
}
if !found {
t.Errorf("%s player did not receive GameStartingResponse after rematch", name)
}
}
}
// TestFlow_CreatePveAfterPvpLeave verifies the user-reported scenario:
// after finishing a PVP game and leaving, the player can immediately
// create a PVE room and play against the AI. Catches state-machine
// residue that would block the transition from home → gamePve.
func TestFlow_CreatePveAfterPvpLeave(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
_ = white
// Black leaves the finished PVP room.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err := runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("gameover exit: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome, got %d", next)
}
if black.RoomID != 0 {
t.Errorf("black.RoomID = %d after exit, want 0", black.RoomID)
}
drainSend(black)
// From home, send CreatePveRoom (medium difficulty).
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreatePveRoom{
CreatePveRoom: &protocol.CreatePveRoomRequest{Difficulty: 2},
},
}
next, err = runState(consts.StateHome, black)
if err != nil {
t.Fatalf("home create pve: %v", err)
}
if next != consts.StateGamePve {
t.Fatalf("expected StateGamePve, got %d", next)
}
// Verify the PVE room exists, black is in it, and GameStartingResponse
// was sent.
if black.RoomID == 0 {
t.Error("black.RoomID should be set to new PVE room")
}
if black.RoomID == room.ID {
t.Error("PVE room reused old PVP room ID")
}
pveRoom, ok := lobby.GetRoom(black.RoomID)
if !ok {
t.Fatal("new PVE room not in store")
}
if pveRoom.RoomType != lobby.RoomTypePve {
t.Errorf("new room type = %v, want PVE", pveRoom.RoomType)
}
if pveRoom.AI == nil {
t.Error("PVE room should have an AI instance")
}
gotStart := false
for _, r := range drainSend(black) {
if _, ok := r.Payload.(*protocol.Response_GameStarting); ok {
gotStart = true
break
}
}
if !gotStart {
t.Error("black did not receive GameStartingResponse for new PVE room")
}
}
// TestFlow_CreatePveAfterPvpRematch covers the specific residue case the
// user flagged: PVP game → rematch → finish → leave → create PVE. The
// rematch path mutates both RematchCh (closed + niled) and GameOverCh
// (fresh channel), which if mishandled could leave the room in a dirty
// state that blocks subsequent PVE creation.
func TestFlow_CreatePveAfterPvpRematch(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
_ = white
// Rematch: black clicks Play Again from gameoverState.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_GameReset{GameReset: &protocol.GameResetRequest{}},
}
next, err := runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("rematch: %v", err)
}
if next != consts.StateGamePvp {
t.Fatalf("expected StateGamePvp after rematch, got %d", next)
}
// Simulate game 2 ending.
room.Lock()
room.Status = lobby.RoomStatusFinished
room.Unlock()
// Leave game 2.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err = runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("leave game 2: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome, got %d", next)
}
if black.RoomID != 0 {
t.Errorf("black.RoomID = %d after leave, want 0", black.RoomID)
}
drainSend(black)
// Now create a PVE room.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreatePveRoom{
CreatePveRoom: &protocol.CreatePveRoomRequest{Difficulty: 3},
},
}
next, err = runState(consts.StateHome, black)
if err != nil {
t.Fatalf("create pve after rematch: %v", err)
}
if next != consts.StateGamePve {
t.Fatalf("expected StateGamePve, got %d", next)
}
// Verify fresh PVE room state.
pveRoom, ok := lobby.GetRoom(black.RoomID)
if !ok {
t.Fatal("PVE room missing from store")
}
if pveRoom.RoomType != lobby.RoomTypePve {
t.Errorf("room type = %v, want PVE", pveRoom.RoomType)
}
if pveRoom.AI == nil {
t.Error("PVE room missing AI")
}
if pveRoom.Status != lobby.RoomStatusPlaying {
t.Errorf("PVE room Status = %d, want Playing", pveRoom.Status)
}
if pveRoom.Board.MoveCount() != 0 {
t.Errorf("new PVE board has %d moves, want 0", pveRoom.Board.MoveCount())
}
}
// TestFlow_CreatePveAfterPvpForfeit covers the variant where the player
// forfeits a PVP game mid-play (via ClientExit while still in gamePvpState)
// and then wants to create a PVE room. The exiting goroutine goes directly
// to StateHome — state machine must be clean enough to handle CreatePveRoom.
func TestFlow_CreatePveAfterPvpForfeit(t *testing.T) {
// Setup: two players in an active PVP game.
black := makeRegisteredPlayer(t, "black")
white := makeRegisteredPlayer(t, "white")
_ = white
room, err := lobby.CreatePvpRoom(black)
if err != nil {
t.Fatalf("CreatePvpRoom: %v", err)
}
if err := lobby.JoinRoom(room.ID, black); err != nil {
t.Fatalf("JoinRoom black: %v", err)
}
if err := lobby.JoinRoom(room.ID, white); err != nil {
t.Fatalf("JoinRoom white: %v", err)
}
room.Lock()
room.BlackPlayerID = black.ID
room.WhitePlayerID = white.ID
room.Status = lobby.RoomStatusPlaying
room.Unlock()
// Black forfeits via ClientExit while in gamePvpState.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err := runState(consts.StateGamePvp, black)
if err != nil {
t.Fatalf("gamepvp exit: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome after forfeit, got %d", next)
}
drainSend(black)
// Create PVE room from home.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreatePveRoom{
CreatePveRoom: &protocol.CreatePveRoomRequest{Difficulty: 1},
},
}
next, err = runState(consts.StateHome, black)
if err != nil {
t.Fatalf("home create pve after forfeit: %v", err)
}
if next != consts.StateGamePve {
t.Fatalf("expected StateGamePve, got %d", next)
}
}
// TestFlow_CreateRoomAfterPvpLeave verifies that after a PVP game ends and
// the player leaves via gameoverState → StateHome, they can immediately
// create a new room. Regression guard for the reported bug where clicking
// Create Room after a finished PVP game did nothing.
func TestFlow_CreateRoomAfterPvpLeave(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
_ = white
// Black's player goroutine is in gameoverState. Simulate: send ClientExit
// so black transitions back to home.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err := runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("gameover exit: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome, got %d", next)
}
// Black should be out of the original room. White is still in it.
if black.RoomID != 0 {
t.Errorf("black.RoomID = %d after leave, want 0", black.RoomID)
}
if _, ok := lobby.GetRoom(room.ID); !ok {
t.Error("original room was deleted but white is still in it")
}
// Drain the ClientExit responses from earlier hops.
drainSend(black)
// Now black sends CreateRoom from home state.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}},
}
next, err = runState(consts.StateHome, black)
if err != nil {
t.Fatalf("home create after pvp: %v", err)
}
if next != consts.StateWaiting {
t.Fatalf("expected StateWaiting after create, got %d", next)
}
// Black must have received RoomCreateSuccessResponse for the new room.
gotCreate := false
var newRoomID int64
for _, r := range drainSend(black) {
if cs := r.GetRoomCreateSuccess(); cs != nil {
gotCreate = true
newRoomID = int64(cs.GetId())
break
}
}
if !gotCreate {
t.Fatal("black did not receive RoomCreateSuccessResponse after CreateRoom")
}
if newRoomID == room.ID {
t.Errorf("new room should have a different ID than the old one (%d)", room.ID)
}
if newRoomID == 0 {
t.Error("new room ID is 0")
}
// Black should now be in the new room, not the old one.
if black.RoomID != newRoomID {
t.Errorf("black.RoomID = %d, want new room %d", black.RoomID, newRoomID)
}
}
// TestFlow_CreateRoomAfterPvpRematchLeave walks through a PVP game to the
// gameoverState, triggers rematch (which closes RematchCh + resets the room),
// then sends ClientExit through the NEW game round, and verifies the player
// can still create a fresh room afterward. Catches residue from RematchCh /
// GameOverCh that could wedge the state machine.
func TestFlow_CreateRoomAfterPvpRematchLeave(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
_ = white
// Rematch: black triggers GameReset from gameoverState.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_GameReset{GameReset: &protocol.GameResetRequest{}},
}
next, err := runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("gameover reset: %v", err)
}
if next != consts.StateGamePvp {
t.Fatalf("expected StateGamePvp after reset, got %d", next)
}
// Room should be fresh and playing.
room.RLock()
if room.Status != lobby.RoomStatusPlaying {
room.RUnlock()
t.Fatalf("room not Playing after rematch, got %d", room.Status)
}
if room.RematchCh != nil {
room.RUnlock()
t.Error("RematchCh should be nil after close+reset, got non-nil")
}
room.RUnlock()
// Simulate game 2 finishing: force room to Finished state, clear CurrentTurn.
room.Lock()
room.Status = lobby.RoomStatusFinished
room.Unlock()
// Now black is conceptually back in gameoverState. Send ClientExit to leave.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
next, err = runState(consts.StateGameOver, black)
if err != nil {
t.Fatalf("gameover exit after rematch: %v", err)
}
if next != consts.StateHome {
t.Fatalf("expected StateHome, got %d", next)
}
// Critical: black should be able to create a new room immediately.
drainSend(black)
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}},
}
next, err = runState(consts.StateHome, black)
if err != nil {
t.Fatalf("home create after rematch+leave: %v", err)
}
if next != consts.StateWaiting {
t.Fatalf("expected StateWaiting after create, got %d", next)
}
// Verify the new room is different from the old one.
gotCreate := false
var newRoomID int64
for _, r := range drainSend(black) {
if cs := r.GetRoomCreateSuccess(); cs != nil {
gotCreate = true
newRoomID = int64(cs.GetId())
break
}
}
if !gotCreate {
t.Fatal("did not receive RoomCreateSuccessResponse")
}
if newRoomID == room.ID {
t.Errorf("new room reuses old room ID %d", room.ID)
}
if black.RoomID != newRoomID {
t.Errorf("black.RoomID = %d, want %d", black.RoomID, newRoomID)
}
}
// TestFlow_OpponentExit_KicksPeerFromGameover verifies the regression fix
// for the server log "gameover player N: unexpected Request_CreateRoom":
// when one player leaves a finished PVP room, the remaining peer should
// NOT be left sitting in gameoverState — kickStaleRoomPeers injects a
// synthetic ClientExit so their goroutine auto-transitions to home.
// Both players must then be free to create fresh rooms.
func TestFlow_OpponentExit_KicksPeerFromGameover(t *testing.T) {
black, white, _ := setupFinishedPvpRoom(t)
// Black leaves the finished PVP room → kickStaleRoomPeers should push
// a synthetic ClientExit onto white.CmdCh.
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_ClientExit{ClientExit: &protocol.ClientExitRequest{}},
}
if _, err := runState(consts.StateGameOver, black); err != nil {
t.Fatalf("black gameover exit: %v", err)
}
if black.RoomID != 0 {
t.Errorf("black.RoomID = %d, want 0", black.RoomID)
}
// White's CmdCh should contain a synthetic ClientExit queued by
// kickStaleRoomPeers. Running gameoverState on white should consume
// it and transition to StateHome WITHOUT any test-driven input.
next, err := runState(consts.StateGameOver, white)
if err != nil {
t.Fatalf("white auto-exit from kick: %v", err)
}
if next != consts.StateHome {
t.Fatalf("white expected StateHome after synthetic kick, got %d", next)
}
if white.RoomID != 0 {
t.Errorf("white.RoomID = %d after auto-kick, want 0", white.RoomID)
}
if len(lobby.GetAllRooms()) != 0 {
t.Errorf("expected 0 rooms after both players kicked, got %d", len(lobby.GetAllRooms()))
}
// Both players should now be able to create fresh rooms from home.
for _, p := range []*lobby.Player{black, white} {
drainSend(p)
p.CmdCh <- &protocol.Request{
Payload: &protocol.Request_CreateRoom{CreateRoom: &protocol.CreateRoomRequest{}},
}
next, err := runState(consts.StateHome, p)
if err != nil {
t.Fatalf("%s create after exit: %v", p.Name, err)
}
if next != consts.StateWaiting {
t.Errorf("%s expected StateWaiting, got %d", p.Name, next)
}
if p.RoomID == 0 {
t.Errorf("%s RoomID = 0, want non-zero", p.Name)
}
}
if len(lobby.GetAllRooms()) != 2 {
t.Errorf("expected 2 rooms (one per player), got %d", len(lobby.GetAllRooms()))
}
}
// TestFlow_Rematch_NewGameAcceptsMovesFromBoth verifies that after rematch,
// the fresh game actually accepts moves. Regression guard for the reported
// bug where the board cleared but clicks did nothing.
//
// We drive black's gamePvpState in a goroutine, feed it a move, and then
// shut it down via a game-over signal so the goroutine exits cleanly.
func TestFlow_Rematch_NewGameAcceptsMovesFromBoth(t *testing.T) {
black, white, room := setupFinishedPvpRoom(t)
_ = white
// Trigger rematch: reset room + broadcast fresh GameStartingResponse.
if _, err := handleGameReset(room); err != nil {
t.Fatalf("handleGameReset: %v", err)
}
// Drain the GameStartingResponse sent via broadcast.
drainSend(black)
drainSend(white)
// Precondition: fresh board, Playing status, Black's turn.
room.RLock()
if room.Status != lobby.RoomStatusPlaying {
room.RUnlock()
t.Fatalf("room not Playing after reset")
}
if room.CurrentTurn != game.Black {
room.RUnlock()
t.Errorf("CurrentTurn = %v, want Black", room.CurrentTurn)
}
if room.Board.MoveCount() != 0 {
room.RUnlock()
t.Errorf("Board.MoveCount = %d, want 0", room.Board.MoveCount())
}
room.RUnlock()
// Run black's gamePvpState in a goroutine. Feed it one GameMove, then
// trigger game-over to make the goroutine return. If the rematch-broken
// bug were still present, the state would silently drop the move.
done := make(chan consts.StateID, 1)
go func() {
next, _ := runState(consts.StateGamePvp, black)
done <- next
}()
black.CmdCh <- &protocol.Request{
Payload: &protocol.Request_GameMove{GameMove: &protocol.GameMoveRequest{Row: 7, Col: 7}},
}
// Give the goroutine a moment to process the move, then close the game
// so gamePvpState.Next returns (via GameOverCh select).
time.Sleep(50 * time.Millisecond)
signalGameOver(room)
select {
case next := <-done:
if next != consts.StateGameOver {
t.Errorf("expected StateGameOver after signalGameOver, got %d", next)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("black's gamePvpState did not exit within 500ms after signalGameOver")
}
// Move was applied: (7,7) has a black stone, turn flipped to White.
room.RLock()
piece := room.Board.GetPiece(7, 7)
nMoves := room.Board.MoveCount()
room.RUnlock()
if piece != game.Black {
t.Errorf("after move, (7,7) piece = %v, want Black", piece)
}
if nMoves != 1 {
t.Errorf("Board.MoveCount = %d, want 1", nMoves)
}
// Black should have received exactly one GameMoveSuccessResponse.
gotMoveAck := 0
for _, r := range drainSend(black) {
if _, ok := r.Payload.(*protocol.Response_GameMoveSuccess); ok {
gotMoveAck++
}
}
if gotMoveAck != 1 {
t.Errorf("black received %d GameMoveSuccessResponse, want 1", gotMoveAck)
}
}