Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions domains/games/apis/games_ws_backend/golf/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
120 changes: 120 additions & 0 deletions domains/games/apis/games_ws_backend/golf/game_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions domains/games/apis/games_ws_backend/golf/golf_hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading