From 3c54a0f4d9fdb0b154d8df9a3a5ebe8aa77deb1a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 19 Jan 2026 15:46:17 -0700 Subject: [PATCH] Add client leaving host notification --- hooks/useGame.ts | 18 +++ tests/hooks/useGame.kickLeave.test.tsx | 145 +++++++++++++++++++++++++ types.ts | 1 + 3 files changed, 164 insertions(+) diff --git a/hooks/useGame.ts b/hooks/useGame.ts index dd5168e..983db1e 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -966,6 +966,23 @@ export const useGame = () => { showScoreboard(); } } + + if (data.type === 'LEAVE') { + const playerId = conn.peer; + const updatedPlayers = playersRef.current.filter(p => p.id !== playerId); + playersRef.current = updatedPlayers; + setPlayers(updatedPlayers); + connectionsRef.current.delete(playerId); + + if (presenterIdRef.current === playerId) { + const realPlayers = updatedPlayers.filter(p => p.id !== 'host'); + const newPresenter = realPlayers.length > 0 ? realPlayers[0].id : null; + setPresenterId(newPresenter); + broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: newPresenter } }); + } + + broadcast({ type: 'PLAYER_LEFT', payload: { playerId } }); + } }; useEffect(() => { @@ -1587,6 +1604,7 @@ export const useGame = () => { if (role !== 'CLIENT') return; if (hostConnectionRef.current?.open) { + hostConnectionRef.current.send({ type: 'LEAVE', payload: {} }); hostConnectionRef.current.close(); } hostConnectionRef.current = null; diff --git a/tests/hooks/useGame.kickLeave.test.tsx b/tests/hooks/useGame.kickLeave.test.tsx index dd68651..d2e4a74 100644 --- a/tests/hooks/useGame.kickLeave.test.tsx +++ b/tests/hooks/useGame.kickLeave.test.tsx @@ -909,5 +909,150 @@ describe('Kick and Leave Feature Logic', () => { expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: {} })).toBe(false); expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT' })).toBe(false); }); + + it('validates LEAVE message structure', () => { + const isValidLeaveMessage = (message: unknown): boolean => { + if (typeof message !== 'object' || message === null) return false; + const msg = message as { type?: string; payload?: object }; + return msg.type === 'LEAVE' && typeof msg.payload === 'object'; + }; + + expect(isValidLeaveMessage({ type: 'LEAVE', payload: {} })).toBe(true); + expect(isValidLeaveMessage({ type: 'LEAVE' })).toBe(false); + expect(isValidLeaveMessage({ type: 'OTHER', payload: {} })).toBe(false); + expect(isValidLeaveMessage(null)).toBe(false); + }); + }); + + describe('host handling LEAVE message', () => { + const processLeaveMessage = ( + leavingPlayerId: string, + players: Player[], + presenterId: string | null + ): { + updatedPlayers: Player[]; + newPresenterId: string | null; + shouldBroadcastPlayerLeft: boolean; + shouldBroadcastPresenterChanged: boolean; + } => { + const updatedPlayers = players.filter(p => p.id !== leavingPlayerId); + const wasPresenter = presenterId === leavingPlayerId; + + let newPresenterId = presenterId; + if (wasPresenter) { + const realPlayers = updatedPlayers.filter(p => p.id !== 'host'); + newPresenterId = realPlayers.length > 0 ? realPlayers[0].id : null; + } + + return { + updatedPlayers, + newPresenterId, + shouldBroadcastPlayerLeft: true, + shouldBroadcastPresenterChanged: wasPresenter, + }; + }; + + it('removes leaving player from players list', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + ]; + + const result = processLeaveMessage('player-1', players, null); + + expect(result.updatedPlayers).toHaveLength(1); + expect(result.updatedPlayers[0].id).toBe('player-2'); + }); + + it('broadcasts PLAYER_LEFT to remaining players', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + ]; + + const result = processLeaveMessage('player-1', players, null); + + expect(result.shouldBroadcastPlayerLeft).toBe(true); + }); + + it('reassigns presenter when leaving player was presenter', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + ]; + + const result = processLeaveMessage('player-1', players, 'player-1'); + + expect(result.newPresenterId).toBe('player-2'); + expect(result.shouldBroadcastPresenterChanged).toBe(true); + }); + + it('preserves presenter when non-presenter leaves', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + ]; + + const result = processLeaveMessage('player-2', players, 'player-1'); + + expect(result.newPresenterId).toBe('player-1'); + expect(result.shouldBroadcastPresenterChanged).toBe(false); + }); + + it('sets presenter to null when last non-host player leaves', () => { + const players = [ + createPlayer({ id: 'host', name: 'Host' }), + createPlayer({ id: 'player-1', name: 'Alice' }), + ]; + + const result = processLeaveMessage('player-1', players, 'player-1'); + + expect(result.newPresenterId).toBeNull(); + expect(result.shouldBroadcastPresenterChanged).toBe(true); + }); + + it('handles leave when player does not exist', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + ]; + + const result = processLeaveMessage('player-999', players, null); + + expect(result.updatedPlayers).toHaveLength(1); + expect(result.shouldBroadcastPlayerLeft).toBe(true); + }); + + it('does not modify original players array', () => { + const players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + ]; + const originalLength = players.length; + + processLeaveMessage('player-1', players, null); + + expect(players).toHaveLength(originalLength); + }); + + it('handles multiple sequential leaves', () => { + let players = [ + createPlayer({ id: 'player-1', name: 'Alice' }), + createPlayer({ id: 'player-2', name: 'Bob' }), + createPlayer({ id: 'player-3', name: 'Charlie' }), + ]; + let presenterId: string | null = 'player-1'; + + const result1 = processLeaveMessage('player-1', players, presenterId); + players = result1.updatedPlayers; + presenterId = result1.newPresenterId; + + const result2 = processLeaveMessage('player-2', players, presenterId); + players = result2.updatedPlayers; + presenterId = result2.newPresenterId; + + expect(players).toHaveLength(1); + expect(players[0].id).toBe('player-3'); + expect(presenterId).toBe('player-3'); + }); }); }); diff --git a/types.ts b/types.ts index 47c00d5..a86750a 100644 --- a/types.ts +++ b/types.ts @@ -229,4 +229,5 @@ export type NetworkMessage = | { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } } | { type: 'KICK'; payload: { playerId: string } } | { type: 'KICKED'; payload: { reason?: string } } + | { type: 'LEAVE'; payload: {} } | { type: 'PLAYER_LEFT'; payload: { playerId: string } }; \ No newline at end of file