diff --git a/domains/games/apis/games_ws_backend/golf/game.go b/domains/games/apis/games_ws_backend/golf/game.go index 05c3f13e..8df5e599 100644 --- a/domains/games/apis/games_ws_backend/golf/game.go +++ b/domains/games/apis/games_ws_backend/golf/game.go @@ -125,7 +125,8 @@ func (g *Game) AddPlayer(clientID string, playerID string, playerName string) (* return player, nil } -// RemovePlayer removes a player from the game +// RemovePlayer removes a player from the game. +// If the game is in progress and fewer than 2 players remain, the game ends. func (g *Game) RemovePlayer(clientID string) error { g.mu.Lock() defer g.mu.Unlock() @@ -145,9 +146,15 @@ func (g *Game) RemovePlayer(clientID string) error { delete(g.playersByClient, clientID) - // If game is in progress and it's this player's turn, advance to next player - if g.state.GamePhase == "playing" && len(g.state.Players) > 0 { - g.state.CurrentPlayerIndex = g.state.CurrentPlayerIndex % len(g.state.Players) + // If game is in progress, handle the reduced player count + if g.state.GamePhase == "playing" || g.state.GamePhase == "knocked" || g.state.GamePhase == "peeking" { + if len(g.state.Players) < 2 { + // Not enough players to continue — end the game + g.state.GamePhase = "ended" + g.calculateFinalScores() + } else { + g.state.CurrentPlayerIndex = g.state.CurrentPlayerIndex % len(g.state.Players) + } } return nil diff --git a/domains/games/apis/games_ws_backend/golf/game_test.go b/domains/games/apis/games_ws_backend/golf/game_test.go index 10fc713a..ef1a63e1 100644 --- a/domains/games/apis/games_ws_backend/golf/game_test.go +++ b/domains/games/apis/games_ws_backend/golf/game_test.go @@ -479,6 +479,126 @@ func TestRemovePlayer(t *testing.T) { } } +func TestRemovePlayerEndsGameWhenTooFewPlayers(t *testing.T) { + game := NewGame("TEST123", &players.DeterministicIDGenerator{}) + addTestPlayerToGame(game, "client1") + addTestPlayerToGame(game, "client2") + game.StartGame() + + if game.state.GamePhase != "playing" { + t.Fatalf("Expected playing phase, got %s", game.state.GamePhase) + } + + // Remove a player during an active game — should end it + err := game.RemovePlayer("client2") + if err != nil { + t.Fatalf("Failed to remove player: %v", err) + } + + if game.state.GamePhase != "ended" { + t.Errorf("Expected ended phase after removal left <2 players, got %s", game.state.GamePhase) + } + + // Remaining player should have final scores calculated + if len(game.state.Players) != 1 { + t.Fatalf("Expected 1 player remaining, got %d", len(game.state.Players)) + } + if len(game.state.Players[0].RevealedCards) != 4 { + t.Errorf("Expected all 4 cards revealed, got %d", len(game.state.Players[0].RevealedCards)) + } +} + +func TestRemovePlayerDuringKnockedPhaseEndsGame(t *testing.T) { + game := NewGame("TEST123", &players.DeterministicIDGenerator{}) + addTestPlayerToGame(game, "client1") + addTestPlayerToGame(game, "client2") + addTestPlayerToGame(game, "client3") + game.StartGame() + + // Complete peek phase for all players, then hide to return to playing + game.PeekCard("client1", 0) + game.PeekCard("client1", 1) + game.PeekCard("client2", 0) + game.PeekCard("client2", 1) + game.PeekCard("client3", 0) + game.PeekCard("client3", 1) + game.HidePeekedCards() + + // Player 1 knocks (must be at start of turn, before drawing) + if err := game.Knock("client1"); err != nil { + t.Fatalf("Failed to knock: %v", err) + } + + if game.state.GamePhase != "knocked" { + t.Fatalf("Expected knocked phase, got %s", game.state.GamePhase) + } + + // Remove both non-knocking players — should end the game + game.RemovePlayer("client2") + if game.state.GamePhase != "knocked" { + t.Fatalf("Expected still knocked with 2 players, got %s", game.state.GamePhase) + } + + game.RemovePlayer("client3") + if game.state.GamePhase != "ended" { + t.Errorf("Expected ended phase after removal left <2 players, got %s", game.state.GamePhase) + } +} + +func TestRemovePlayerDuringWaitingDoesNotEnd(t *testing.T) { + game := NewGame("TEST123", &players.DeterministicIDGenerator{}) + addTestPlayerToGame(game, "client1") + addTestPlayerToGame(game, "client2") + + // Remove during waiting — game hasn't started, shouldn't transition to ended + err := game.RemovePlayer("client2") + if err != nil { + t.Fatalf("Failed to remove player: %v", err) + } + + if game.state.GamePhase != "waiting" { + t.Errorf("Expected waiting phase, got %s", game.state.GamePhase) + } +} + +func TestRemoveCurrentPlayerAdvancesTurn(t *testing.T) { + game := NewGame("TEST123", &players.DeterministicIDGenerator{}) + addTestPlayerToGame(game, "client1") + addTestPlayerToGame(game, "client2") + addTestPlayerToGame(game, "client3") + game.StartGame() + + // Complete peek phase for all players, then hide to return to playing + game.PeekCard("client1", 0) + game.PeekCard("client1", 1) + game.PeekCard("client2", 0) + game.PeekCard("client2", 1) + game.PeekCard("client3", 0) + game.PeekCard("client3", 1) + game.HidePeekedCards() + + // It's client1's turn (index 0). Remove client1. + if game.state.CurrentPlayerIndex != 0 { + t.Fatalf("Expected current player index 0, got %d", game.state.CurrentPlayerIndex) + } + + game.RemovePlayer("client1") + + // Game should still be playing with 2 players + if game.state.GamePhase != "playing" { + t.Fatalf("Expected playing phase, got %s", game.state.GamePhase) + } + if len(game.state.Players) != 2 { + t.Fatalf("Expected 2 players, got %d", len(game.state.Players)) + } + + // CurrentPlayerIndex should be valid (0, pointing to what was client2) + if game.state.CurrentPlayerIndex >= len(game.state.Players) { + t.Errorf("CurrentPlayerIndex %d out of bounds for %d players", + game.state.CurrentPlayerIndex, len(game.state.Players)) + } +} + func TestValidateCardIndex(t *testing.T) { tests := []struct { index int diff --git a/domains/games/apis/games_ws_backend/golf/golf_hub.go b/domains/games/apis/games_ws_backend/golf/golf_hub.go index 6046f2aa..48eedf6c 100644 --- a/domains/games/apis/games_ws_backend/golf/golf_hub.go +++ b/domains/games/apis/games_ws_backend/golf/golf_hub.go @@ -276,6 +276,8 @@ func (h *GolfHub) handleGameMessage(msgData hub.GameMessageData) { h.handleKnock(msgData.Sender) case "hideCards": h.handleHideCards(msgData.Sender) + case "leaveGame": + h.handleLeaveGame(msgData.Sender) default: h.sendError(msgData.Sender, "Unknown message type: "+msg.Type) } @@ -690,6 +692,70 @@ func (h *GolfHub) handleLeaveRoom(client *hub.Client, roomID string) { "clientAddr", getClientAddr(client)) } +// handleLeaveGame removes a player from their current game but keeps them in the room. +// If the game was in progress and fewer than 2 players remain, the game ends. +func (h *GolfHub) handleLeaveGame(client *hub.Client) { + var room *Room + var game *Game + var gameEnded bool + + func() { + h.mu.Lock() + defer h.mu.Unlock() + + ctx := h.clientContexts[client] + if ctx == nil || ctx.GameID == "" { + h.sendError(client, "Not in a game") + return + } + + room = h.rooms[ctx.RoomID] + if room == nil { + h.sendError(client, "Room not found") + return + } + + var exists bool + game, exists = room.Games[ctx.GameID] + if !exists { + // Game already gone — just clear context + ctx.GameID = "" + return + } + + clientID := getClientID(client) + phaseBefore := game.state.GamePhase + + if err := game.RemovePlayer(clientID); err != nil { + h.sendError(client, err.Error()) + game = nil + return + } + + gameEnded = phaseBefore != "ended" && phaseBefore != "waiting" && game.state.GamePhase == "ended" + + ctx.GameID = "" + room.LastActivity = time.Now() + }() + + if game == nil { + return + } + + if gameEnded { + h.handleGameEnded(game) + } else { + h.broadcastGameState(game) + } + + if room != nil { + h.broadcastRoomState(room) + } + + slog.Info("Player left game", + "clientAddr", getClientAddr(client)) +} + // handleCreateGame creates a new game within an existing room func (h *GolfHub) handleCreateGame(client *hub.Client, roomID string) { var room *Room diff --git a/domains/games/apis/games_ws_backend/golf/golf_hub_test.go b/domains/games/apis/games_ws_backend/golf/golf_hub_test.go index 5addc17d..85f3b5f4 100644 --- a/domains/games/apis/games_ws_backend/golf/golf_hub_test.go +++ b/domains/games/apis/games_ws_backend/golf/golf_hub_test.go @@ -1794,3 +1794,184 @@ func TestGetClientID_UsesIDFieldOverRemoteAddr(t *testing.T) { } }) } + +func TestHub_LeaveGame(t *testing.T) { + golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) + go golfHub.Run() + + // Create 3 clients + client1 := newMockClient("c1") + client1.collectMessages() + client2 := newMockClient("c2") + client2.collectMessages() + client3 := newMockClient("c3") + client3.collectMessages() + + hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} + hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} + hubClient3 := &hub.Client{Hub: golfHub, Send: client3.send} + + golfHub.Register(hubClient1) + golfHub.Register(hubClient2) + golfHub.Register(hubClient3) + time.Sleep(10 * time.Millisecond) + authenticateMockClient(golfHub, hubClient1, client1) + authenticateMockClient(golfHub, hubClient2, client2) + authenticateMockClient(golfHub, hubClient3, client3) + + // Client 1 creates room + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"createRoom"}`), + Sender: hubClient1, + }) + time.Sleep(10 * time.Millisecond) + + msgs := client1.getMessages() + var roomMsg RoomJoinedMessage + json.Unmarshal(msgs[0], &roomMsg) + roomID := roomMsg.RoomState.ID + client1.clearMessages() + + // Clients 2 and 3 join room + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"joinRoom","roomId":"` + roomID + `"}`), + Sender: hubClient2, + }) + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"joinRoom","roomId":"` + roomID + `"}`), + Sender: hubClient3, + }) + time.Sleep(10 * time.Millisecond) + client1.clearMessages() + client2.clearMessages() + client3.clearMessages() + + // Client 1 creates game + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"createGame","roomId":"` + roomID + `"}`), + Sender: hubClient1, + }) + time.Sleep(100 * time.Millisecond) + + msgs = client1.getMessages() + var gameMsg GameJoinedMessage + for _, m := range msgs { + if json.Unmarshal(m, &gameMsg) == nil && gameMsg.Type == "gameJoined" { + break + } + } + gameID := gameMsg.GameState.ID + client1.clearMessages() + client2.clearMessages() + client3.clearMessages() + + // Clients 2 and 3 join game + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"joinGame","roomId":"` + roomID + `","gameId":"` + gameID + `"}`), + Sender: hubClient2, + }) + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"joinGame","roomId":"` + roomID + `","gameId":"` + gameID + `"}`), + Sender: hubClient3, + }) + time.Sleep(100 * time.Millisecond) + client1.clearMessages() + client2.clearMessages() + client3.clearMessages() + + // Start game + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"startGame"}`), + Sender: hubClient1, + }) + time.Sleep(10 * time.Millisecond) + client1.clearMessages() + client2.clearMessages() + client3.clearMessages() + + t.Run("leave game returns player to room", func(t *testing.T) { + // Client 3 leaves the game + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"leaveGame"}`), + Sender: hubClient3, + }) + time.Sleep(100 * time.Millisecond) + + // Client 3's context should have no game + golfHub.(*GolfHub).mu.RLock() + ctx3 := golfHub.(*GolfHub).clientContexts[hubClient3] + golfHub.(*GolfHub).mu.RUnlock() + + if ctx3 == nil { + t.Fatal("Expected client3 context to still exist") + } + if ctx3.GameID != "" { + t.Errorf("Expected empty GameID after leaving, got %s", ctx3.GameID) + } + if ctx3.RoomID != roomID { + t.Errorf("Expected RoomID %s, got %s", roomID, ctx3.RoomID) + } + }) + + t.Run("game still active with 2 players", func(t *testing.T) { + golfHub.(*GolfHub).mu.RLock() + room := golfHub.(*GolfHub).rooms[roomID] + game := room.Games[gameID] + golfHub.(*GolfHub).mu.RUnlock() + + if game == nil { + t.Fatal("Expected game to still exist") + } + if game.state.GamePhase == "ended" { + t.Error("Game should not have ended — still 2 players") + } + }) + + t.Run("leave game ends game when fewer than 2 players remain", func(t *testing.T) { + client1.clearMessages() + client2.clearMessages() + + // Client 2 leaves — only 1 player left, game should end + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"leaveGame"}`), + Sender: hubClient2, + }) + time.Sleep(100 * time.Millisecond) + + // Check that client 1 received a gameEnded message + msgs := client1.getMessages() + foundGameEnded := false + for _, m := range msgs { + var parsed map[string]interface{} + if json.Unmarshal(m, &parsed) == nil && parsed["type"] == "gameEnded" { + foundGameEnded = true + break + } + } + if !foundGameEnded { + t.Error("Expected client1 to receive gameEnded message") + } + }) + + t.Run("leave game when not in a game returns error", func(t *testing.T) { + client3.clearMessages() + golfHub.GameMessage(hub.GameMessageData{ + Message: []byte(`{"type":"leaveGame"}`), + Sender: hubClient3, + }) + time.Sleep(10 * time.Millisecond) + + msgs := client3.getMessages() + foundError := false + for _, m := range msgs { + var parsed map[string]interface{} + if json.Unmarshal(m, &parsed) == nil && parsed["type"] == "error" { + foundError = true + break + } + } + if !foundError { + t.Error("Expected error when leaving game while not in one") + } + }) +}