Add client leaving host notification

This commit is contained in:
Joey Yakimowich-Payne 2026-01-19 15:46:17 -07:00
commit 3c54a0f4d9
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
3 changed files with 164 additions and 0 deletions

View file

@ -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;

View file

@ -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');
});
});
});

View file

@ -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 } };