Add kick player and leave game functionality

- Host can kick players from lobby (removes from game, clears presenter if needed)
- Client can voluntarily leave game
- Fix browser-compatible base64 decoding for document upload (atob vs Buffer)
This commit is contained in:
Joey Yakimowich-Payne 2026-01-19 14:52:57 -07:00
commit 79820f5298
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
7 changed files with 1640 additions and 10 deletions

View file

@ -94,7 +94,9 @@ function App() {
resumeGame,
presenterId,
setPresenterPlayer,
sendAdvance
sendAdvance,
kickPlayer,
leaveGame
} = useGame();
const handleSaveQuiz = async () => {
@ -204,6 +206,8 @@ function App() {
hostParticipates={gameConfig.hostParticipates}
presenterId={presenterId}
onSetPresenter={setPresenterPlayer}
onKickPlayer={role === 'HOST' ? kickPlayer : undefined}
onLeaveGame={role === 'CLIENT' ? leaveGame : undefined}
/>
{auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Player } from '../types';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, User, X, Link, Check, QrCode, Crown } from 'lucide-react';
import { Sparkles, User, X, Link, Check, QrCode, Crown, LogOut, UserX } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { PlayerAvatar } from './PlayerAvatar';
import toast from 'react-hot-toast';
@ -17,9 +17,11 @@ interface LobbyProps {
hostParticipates?: boolean;
presenterId?: string | null;
onSetPresenter?: (playerId: string | null) => void;
onKickPlayer?: (playerId: string) => void;
onLeaveGame?: () => void;
}
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter }) => {
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame, currentPlayerId, hostParticipates = false, presenterId, onSetPresenter, onKickPlayer, onLeaveGame }) => {
const isHost = role === 'HOST';
const hostPlayer = players.find(p => p.id === 'host');
const realPlayers = players.filter(p => p.id !== 'host');
@ -209,6 +211,18 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
{isPlayerPresenter && (
<span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full">PRESENTER</span>
)}
{onKickPlayer && (
<button
onClick={(e) => {
e.stopPropagation();
onKickPlayer(player.id);
}}
className="ml-1 p-1 rounded-full hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
title="Kick player"
>
<UserX size={16} />
</button>
)}
</motion.div>
);
})}
@ -272,6 +286,19 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
) : (
<p className="text-lg md:text-2xl font-bold opacity-80">Waiting for the host to start...</p>
)}
{onLeaveGame && (
<motion.button
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={onLeaveGame}
className="mt-8 bg-white/20 hover:bg-white/30 text-white px-6 py-3 rounded-full font-bold flex items-center gap-2 transition-all active:scale-95"
>
<LogOut size={20} />
Leave Game
</motion.button>
)}
</div>
)}
</main>

View file

@ -1446,6 +1446,31 @@ export const useGame = () => {
if (data.type === 'PRESENTER_CHANGED') {
setPresenterId(data.payload.presenterId);
}
if (data.type === 'KICKED') {
if (hostConnectionRef.current) {
hostConnectionRef.current.close();
hostConnectionRef.current = null;
}
if (peerRef.current) {
peerRef.current.destroy();
peerRef.current = null;
}
clearStoredSession();
if (timerRef.current) clearInterval(timerRef.current);
setError(data.payload.reason || 'You were kicked from the game');
setGamePin(null);
setQuiz(null);
setPlayers([]);
setCurrentPlayerId(null);
setCurrentPlayerName(null);
setGameState('LANDING');
navigate('/', { replace: true });
}
if (data.type === 'PLAYER_LEFT') {
setPlayers(prev => prev.filter(p => p.id !== data.payload.playerId));
}
};
const handleAnswer = (arg: boolean | AnswerOption) => {
@ -1535,6 +1560,54 @@ export const useGame = () => {
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: playerId } });
};
const kickPlayer = (playerId: string) => {
if (role !== 'HOST') return;
if (playerId === 'host') return;
const conn = connectionsRef.current.get(playerId);
if (conn?.open) {
conn.send({ type: 'KICKED', payload: { reason: 'You were kicked by the host' } });
conn.close();
}
connectionsRef.current.delete(playerId);
const updatedPlayers = playersRef.current.filter(p => p.id !== playerId);
playersRef.current = updatedPlayers;
setPlayers(updatedPlayers);
if (presenterIdRef.current === playerId) {
setPresenterId(null);
broadcast({ type: 'PRESENTER_CHANGED', payload: { presenterId: null } });
}
broadcast({ type: 'PLAYER_LEFT', payload: { playerId } });
};
const leaveGame = () => {
if (role !== 'CLIENT') return;
if (hostConnectionRef.current?.open) {
hostConnectionRef.current.close();
}
hostConnectionRef.current = null;
if (peerRef.current) {
peerRef.current.destroy();
peerRef.current = null;
}
clearStoredSession();
if (timerRef.current) clearInterval(timerRef.current);
setGamePin(null);
setQuiz(null);
setPlayers([]);
setCurrentPlayerId(null);
setCurrentPlayerName(null);
setGameState('LANDING');
navigate('/', { replace: true });
};
const sendAdvance = (action: 'START' | 'NEXT' | 'SCOREBOARD') => {
if (role !== 'CLIENT' || !hostConnectionRef.current) return;
hostConnectionRef.current.send({ type: 'ADVANCE', payload: { action } });
@ -1544,6 +1617,6 @@ export const useGame = () => {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId,
startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame
};
};

View file

@ -130,11 +130,20 @@ function transformToQuiz(data: any): Quiz {
}
async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Promise<{ uri: string; mimeType: string }> {
const buffer = typeof doc.content === 'string'
? Buffer.from(doc.content, 'base64')
: doc.content;
let data: ArrayBuffer;
const blob = new Blob([buffer], { type: doc.mimeType });
if (typeof doc.content === 'string') {
const binaryString = atob(doc.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
data = bytes.buffer as ArrayBuffer;
} else {
data = doc.content;
}
const blob = new Blob([data], { type: doc.mimeType });
const uploadedFile = await ai.files.upload({
file: blob,

View file

@ -14,6 +14,7 @@ vi.mock('react-hot-toast', () => ({
vi.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <div {...props}>{children}</div>,
button: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <button {...props}>{children}</button>,
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
@ -541,4 +542,604 @@ describe('Lobby', () => {
expect(presenterBadges).toHaveLength(1);
});
});
describe('kick player feature - host view', () => {
const playersWithKick = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
];
it('shows kick button on each player when onKickPlayer provided', () => {
render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={vi.fn()}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
expect(kickButtons).toHaveLength(2);
});
it('does not show kick button when onKickPlayer not provided', () => {
render(
<Lobby
{...defaultProps}
players={playersWithKick}
/>
);
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
});
it('calls onKickPlayer with correct player id when kick button clicked', async () => {
const user = userEvent.setup();
const onKickPlayer = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={onKickPlayer}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
await user.click(kickButtons[0]);
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
});
it('kick button click does not trigger presenter selection', async () => {
const user = userEvent.setup();
const onKickPlayer = vi.fn();
const onSetPresenter = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithKick}
hostParticipates={false}
onKickPlayer={onKickPlayer}
onSetPresenter={onSetPresenter}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
await user.click(kickButtons[0]);
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
expect(onSetPresenter).not.toHaveBeenCalled();
});
it('does not show kick button for host player', () => {
const playersWithHost = [
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
...playersWithKick,
];
render(
<Lobby
{...defaultProps}
players={playersWithHost}
hostParticipates={true}
onKickPlayer={vi.fn()}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
expect(kickButtons).toHaveLength(2);
});
it('can kick second player without affecting first player kick button', async () => {
const user = userEvent.setup();
const onKickPlayer = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={onKickPlayer}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
await user.click(kickButtons[1]);
expect(onKickPlayer).toHaveBeenCalledWith('player-2');
expect(onKickPlayer).toHaveBeenCalledTimes(1);
});
it('can kick multiple players in sequence', async () => {
const user = userEvent.setup();
const onKickPlayer = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={onKickPlayer}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
await user.click(kickButtons[0]);
await user.click(kickButtons[1]);
expect(onKickPlayer).toHaveBeenCalledTimes(2);
expect(onKickPlayer).toHaveBeenNthCalledWith(1, 'player-1');
expect(onKickPlayer).toHaveBeenNthCalledWith(2, 'player-2');
});
it('shows kick button for presenter player', () => {
render(
<Lobby
{...defaultProps}
players={playersWithKick}
hostParticipates={false}
presenterId="player-1"
onKickPlayer={vi.fn()}
onSetPresenter={vi.fn()}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
expect(kickButtons).toHaveLength(2);
});
it('can kick presenter player', async () => {
const user = userEvent.setup();
const onKickPlayer = vi.fn();
render(
<Lobby
{...defaultProps}
players={playersWithKick}
hostParticipates={false}
presenterId="player-1"
onKickPlayer={onKickPlayer}
onSetPresenter={vi.fn()}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
await user.click(kickButtons[0]);
expect(onKickPlayer).toHaveBeenCalledWith('player-1');
});
it('shows kick button with single player', () => {
const singlePlayer = [{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 }];
render(
<Lobby
{...defaultProps}
players={singlePlayer}
onKickPlayer={vi.fn()}
/>
);
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
});
it('shows kick button for many players', () => {
const manyPlayers = Array.from({ length: 10 }, (_, i) => ({
id: `player-${i}`,
name: `Player ${i}`,
score: 0,
avatarSeed: i * 0.1,
}));
render(
<Lobby
{...defaultProps}
players={manyPlayers}
onKickPlayer={vi.fn()}
/>
);
const kickButtons = screen.getAllByTitle('Kick player');
expect(kickButtons).toHaveLength(10);
});
it('updates kick buttons when players list changes', () => {
const { rerender } = render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={vi.fn()}
/>
);
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
const newPlayers = [
...playersWithKick,
{ id: 'player-3', name: 'Charlie', score: 0, avatarSeed: 0.3 },
];
rerender(
<Lobby
{...defaultProps}
players={newPlayers}
onKickPlayer={vi.fn()}
/>
);
expect(screen.getAllByTitle('Kick player')).toHaveLength(3);
});
it('removes kick buttons when onKickPlayer becomes undefined', () => {
const { rerender } = render(
<Lobby
{...defaultProps}
players={playersWithKick}
onKickPlayer={vi.fn()}
/>
);
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
rerender(
<Lobby
{...defaultProps}
players={playersWithKick}
/>
);
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
});
it('does not show kick buttons for client role even if onKickPlayer provided', () => {
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={playersWithKick}
currentPlayerId="player-1"
onKickPlayer={vi.fn()}
/>
);
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
});
});
describe('leave game feature - client view', () => {
it('shows leave game button when onLeaveGame provided', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('does not show leave game button when onLeaveGame not provided', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
/>
);
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
});
it('calls onLeaveGame when leave button clicked', async () => {
const user = userEvent.setup();
const onLeaveGame = vi.fn();
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={onLeaveGame}
/>
);
await user.click(screen.getByText('Leave Game'));
expect(onLeaveGame).toHaveBeenCalled();
});
it('does not show leave game button for host', () => {
const players = [
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
];
render(
<Lobby
{...defaultProps}
role="HOST"
players={players}
currentPlayerId="host"
onLeaveGame={vi.fn()}
/>
);
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
});
it('shows leave game button alongside presenter info', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
presenterId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('You are the Presenter')).toBeInTheDocument();
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('calls onLeaveGame only once per click', async () => {
const user = userEvent.setup();
const onLeaveGame = vi.fn();
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={onLeaveGame}
/>
);
await user.click(screen.getByText('Leave Game'));
expect(onLeaveGame).toHaveBeenCalledTimes(1);
});
it('leave button remains clickable after multiple clicks', async () => {
const user = userEvent.setup();
const onLeaveGame = vi.fn();
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={onLeaveGame}
/>
);
await user.click(screen.getByText('Leave Game'));
await user.click(screen.getByText('Leave Game'));
await user.click(screen.getByText('Leave Game'));
expect(onLeaveGame).toHaveBeenCalledTimes(3);
});
it('shows leave button when player is not in players list', () => {
const players = [
{ id: 'player-2', name: 'Other Player', score: 0, avatarSeed: 0.5 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('shows leave button with empty players list', () => {
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={[]}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('removes leave button when onLeaveGame becomes undefined', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
const { rerender } = render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
rerender(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
/>
);
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
});
it('removes leave button when role changes to HOST', () => {
const players = [
{ id: 'player-1', name: 'My Name', score: 0, avatarSeed: 0.5 },
];
const { rerender } = render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
rerender(
<Lobby
{...defaultProps}
role="HOST"
players={players}
currentPlayerId="player-1"
onLeaveGame={vi.fn()}
/>
);
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
});
});
describe('kick and leave interaction edge cases', () => {
it('host view shows kick buttons but not leave button', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
];
render(
<Lobby
{...defaultProps}
role="HOST"
players={players}
currentPlayerId="host"
onKickPlayer={vi.fn()}
onLeaveGame={vi.fn()}
/>
);
expect(screen.getAllByTitle('Kick player')).toHaveLength(2);
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
});
it('client view shows leave button but not kick buttons', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
{ id: 'player-2', name: 'Bob', score: 0, avatarSeed: 0.2 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId="player-1"
onKickPlayer={vi.fn()}
onLeaveGame={vi.fn()}
/>
);
expect(screen.queryByTitle('Kick player')).not.toBeInTheDocument();
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('handles undefined currentPlayerId for client gracefully', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId={undefined}
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('handles null currentPlayerId for client gracefully', () => {
const players = [
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
];
render(
<Lobby
{...defaultProps}
role="CLIENT"
players={players}
currentPlayerId={null}
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByText('Leave Game')).toBeInTheDocument();
});
it('shows correct UI when both kick and leave callbacks provided but role is HOST', () => {
const players = [
{ id: 'host', name: 'Host', score: 0, avatarSeed: 0.99 },
{ id: 'player-1', name: 'Alice', score: 0, avatarSeed: 0.1 },
];
render(
<Lobby
{...defaultProps}
role="HOST"
players={players}
currentPlayerId="host"
hostParticipates={true}
onKickPlayer={vi.fn()}
onLeaveGame={vi.fn()}
/>
);
expect(screen.getByTitle('Kick player')).toBeInTheDocument();
expect(screen.queryByText('Leave Game')).not.toBeInTheDocument();
expect(screen.getByText('End Game')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,913 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Player, GameState } from '../../types';
describe('Kick and Leave Feature Logic', () => {
const createPlayer = (overrides: Partial<Player> = {}): Player => ({
id: 'player-1',
name: 'Test Player',
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
selectedShape: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: 0.5,
color: '#ff0000',
...overrides,
});
describe('kickPlayer logic', () => {
const processKick = (
playerId: string,
players: Player[],
presenterId: string | null,
role: 'HOST' | 'CLIENT'
): {
updatedPlayers: Player[];
newPresenterId: string | null;
shouldKick: boolean;
} => {
if (role !== 'HOST') {
return { updatedPlayers: players, newPresenterId: presenterId, shouldKick: false };
}
if (playerId === 'host') {
return { updatedPlayers: players, newPresenterId: presenterId, shouldKick: false };
}
const updatedPlayers = players.filter(p => p.id !== playerId);
const newPresenterId = presenterId === playerId ? null : presenterId;
return { updatedPlayers, newPresenterId, shouldKick: true };
};
it('removes player from players list', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('player-2');
expect(result.shouldKick).toBe(true);
});
it('does not allow kick when role is CLIENT', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processKick('player-1', players, null, 'CLIENT');
expect(result.updatedPlayers).toHaveLength(2);
expect(result.shouldKick).toBe(false);
});
it('does not allow kicking host player', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processKick('host', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(2);
expect(result.shouldKick).toBe(false);
});
it('clears presenter when kicked player was presenter', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processKick('player-1', players, 'player-1', 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.newPresenterId).toBeNull();
expect(result.shouldKick).toBe(true);
});
it('preserves presenter when different player kicked', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processKick('player-2', players, 'player-1', 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.newPresenterId).toBe('player-1');
expect(result.shouldKick).toBe(true);
});
it('handles kicking non-existent player gracefully', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processKick('player-999', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.shouldKick).toBe(true);
});
it('handles kicking last player', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processKick('player-1', players, 'player-1', 'HOST');
expect(result.updatedPlayers).toHaveLength(0);
expect(result.newPresenterId).toBeNull();
expect(result.shouldKick).toBe(true);
});
it('handles empty player id string', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processKick('', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.shouldKick).toBe(true);
});
it('handles player id with special characters', () => {
const players = [
createPlayer({ id: 'peer-abc123-xyz', name: 'Alice' }),
createPlayer({ id: 'peer-def456-uvw', name: 'Bob' }),
];
const result = processKick('peer-abc123-xyz', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('peer-def456-uvw');
expect(result.shouldKick).toBe(true);
});
it('handles kicking from empty players array', () => {
const players: Player[] = [];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(0);
expect(result.shouldKick).toBe(true);
});
it('preserves player scores and data when kicking another player', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice', score: 1000, streak: 5 }),
createPlayer({ id: 'player-2', name: 'Bob', score: 500, streak: 2 }),
];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers[0].score).toBe(500);
expect(result.updatedPlayers[0].streak).toBe(2);
});
it('handles kicking player with same name as host', () => {
const players = [
createPlayer({ id: 'host', name: 'Host' }),
createPlayer({ id: 'player-1', name: 'Host' }),
];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('host');
expect(result.shouldKick).toBe(true);
});
it('handles multiple sequential kicks', () => {
let players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result1 = processKick('player-1', players, null, 'HOST');
players = result1.updatedPlayers;
const result2 = processKick('player-2', players, null, 'HOST');
players = result2.updatedPlayers;
expect(players).toHaveLength(1);
expect(players[0].id).toBe('player-3');
});
it('handles kicking when multiple players have similar ids', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-10', name: 'Bob' }),
createPlayer({ id: 'player-100', name: 'Charlie' }),
];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(2);
expect(result.updatedPlayers.find(p => p.id === 'player-10')).toBeDefined();
expect(result.updatedPlayers.find(p => p.id === 'player-100')).toBeDefined();
});
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;
processKick('player-1', players, null, 'HOST');
expect(players).toHaveLength(originalLength);
});
it('handles bot players same as regular players', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice', isBot: false }),
createPlayer({ id: 'bot-1', name: 'Bot Player', isBot: true }),
];
const result = processKick('bot-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.shouldKick).toBe(true);
});
it('handles player who answered correctly being kicked', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice', lastAnswerCorrect: true, selectedShape: 'triangle' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processKick('player-1', players, null, 'HOST');
expect(result.updatedPlayers).toHaveLength(1);
expect(result.shouldKick).toBe(true);
});
});
describe('leaveGame logic', () => {
interface LeaveGameContext {
role: 'HOST' | 'CLIENT';
gameState: GameState;
hasConnection: boolean;
hasPeer: boolean;
}
const processLeave = (context: LeaveGameContext): {
shouldLeave: boolean;
shouldCloseConnection: boolean;
shouldDestroyPeer: boolean;
shouldClearSession: boolean;
shouldClearTimers: boolean;
newGameState: GameState;
} => {
if (context.role !== 'CLIENT') {
return {
shouldLeave: false,
shouldCloseConnection: false,
shouldDestroyPeer: false,
shouldClearSession: false,
shouldClearTimers: false,
newGameState: context.gameState,
};
}
return {
shouldLeave: true,
shouldCloseConnection: context.hasConnection,
shouldDestroyPeer: context.hasPeer,
shouldClearSession: true,
shouldClearTimers: true,
newGameState: 'LANDING',
};
};
it('allows client to leave', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: true,
});
expect(result.shouldLeave).toBe(true);
});
it('does not allow host to leave via leaveGame', () => {
const result = processLeave({
role: 'HOST',
gameState: 'LOBBY',
hasConnection: false,
hasPeer: true,
});
expect(result.shouldLeave).toBe(false);
});
it('closes connection when leaving with active connection', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: true,
});
expect(result.shouldCloseConnection).toBe(true);
});
it('does not close connection when no connection exists', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: false,
hasPeer: true,
});
expect(result.shouldCloseConnection).toBe(false);
});
it('destroys peer when leaving', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: true,
});
expect(result.shouldDestroyPeer).toBe(true);
});
it('does not destroy peer when none exists', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: false,
});
expect(result.shouldDestroyPeer).toBe(false);
});
it('clears session when leaving', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: true,
});
expect(result.shouldClearSession).toBe(true);
});
it('clears timers when leaving', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'QUESTION',
hasConnection: true,
hasPeer: true,
});
expect(result.shouldClearTimers).toBe(true);
});
it('sets game state to LANDING when leaving', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'LOBBY',
hasConnection: true,
hasPeer: true,
});
expect(result.newGameState).toBe('LANDING');
});
it('preserves game state when host tries to leave', () => {
const result = processLeave({
role: 'HOST',
gameState: 'QUESTION',
hasConnection: false,
hasPeer: true,
});
expect(result.newGameState).toBe('QUESTION');
});
it('handles leaving during different game states', () => {
const states: GameState[] = ['LOBBY', 'COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'];
states.forEach(state => {
const result = processLeave({
role: 'CLIENT',
gameState: state,
hasConnection: true,
hasPeer: true,
});
expect(result.shouldLeave).toBe(true);
expect(result.newGameState).toBe('LANDING');
});
});
it('handles leaving when already disconnected', () => {
const result = processLeave({
role: 'CLIENT',
gameState: 'DISCONNECTED',
hasConnection: false,
hasPeer: false,
});
expect(result.shouldLeave).toBe(true);
expect(result.newGameState).toBe('LANDING');
});
});
describe('KICKED message handling', () => {
interface KickedPayload {
reason?: string;
}
interface KickedContext {
hasHostConnection: boolean;
hasPeer: boolean;
hasTimer: boolean;
}
const processKickedMessage = (
payload: KickedPayload,
currentGameState: string,
context: KickedContext = { hasHostConnection: true, hasPeer: true, hasTimer: true }
): {
newGameState: 'LANDING';
error: string;
shouldClearSession: boolean;
shouldCloseConnection: boolean;
shouldDestroyPeer: boolean;
shouldClearTimer: boolean;
shouldClearGameData: boolean;
} => {
return {
newGameState: 'LANDING',
error: payload.reason || 'You were kicked from the game',
shouldClearSession: true,
shouldCloseConnection: context.hasHostConnection,
shouldDestroyPeer: context.hasPeer,
shouldClearTimer: context.hasTimer,
shouldClearGameData: true,
};
};
it('sets game state to LANDING when kicked', () => {
const result = processKickedMessage({ reason: 'Kicked by host' }, 'LOBBY');
expect(result.newGameState).toBe('LANDING');
});
it('uses provided reason as error message', () => {
const result = processKickedMessage({ reason: 'Custom kick reason' }, 'LOBBY');
expect(result.error).toBe('Custom kick reason');
});
it('uses default error message when no reason provided', () => {
const result = processKickedMessage({}, 'LOBBY');
expect(result.error).toBe('You were kicked from the game');
});
it('clears session when kicked', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'LOBBY');
expect(result.shouldClearSession).toBe(true);
});
it('handles kick during different game states', () => {
const states = ['LOBBY', 'QUESTION', 'REVEAL', 'SCOREBOARD'];
states.forEach(state => {
const result = processKickedMessage({ reason: 'Kicked' }, state);
expect(result.newGameState).toBe('LANDING');
expect(result.shouldClearSession).toBe(true);
});
});
it('handles empty reason string', () => {
const result = processKickedMessage({ reason: '' }, 'LOBBY');
expect(result.error).toBe('You were kicked from the game');
});
it('handles reason with special characters', () => {
const result = processKickedMessage({ reason: 'Kicked for <script>alert("xss")</script>' }, 'LOBBY');
expect(result.error).toBe('Kicked for <script>alert("xss")</script>');
});
it('handles very long reason string', () => {
const longReason = 'A'.repeat(1000);
const result = processKickedMessage({ reason: longReason }, 'LOBBY');
expect(result.error).toBe(longReason);
});
it('closes host connection when kicked', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldCloseConnection).toBe(true);
});
it('does not try to close connection when none exists', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: false, hasPeer: true, hasTimer: true }
);
expect(result.shouldCloseConnection).toBe(false);
});
it('destroys peer when kicked', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'LOBBY',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldDestroyPeer).toBe(true);
});
it('clears timer when kicked during question', () => {
const result = processKickedMessage(
{ reason: 'Kicked' },
'QUESTION',
{ hasHostConnection: true, hasPeer: true, hasTimer: true }
);
expect(result.shouldClearTimer).toBe(true);
});
it('handles kick when already in LANDING state', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'LANDING');
expect(result.newGameState).toBe('LANDING');
expect(result.shouldClearSession).toBe(true);
});
it('handles kick during COUNTDOWN', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'COUNTDOWN');
expect(result.newGameState).toBe('LANDING');
});
it('clears all game data when kicked', () => {
const result = processKickedMessage({ reason: 'Kicked' }, 'QUESTION');
expect(result.shouldClearGameData).toBe(true);
});
it('handles undefined reason gracefully', () => {
const result = processKickedMessage({ reason: undefined }, 'LOBBY');
expect(result.error).toBe('You were kicked from the game');
});
});
describe('PLAYER_LEFT message handling', () => {
const processPlayerLeft = (
playerId: string,
players: Player[],
presenterId: string | null = null
): { updatedPlayers: Player[]; shouldUpdatePresenter: boolean; newPresenterId: string | null } => {
const updatedPlayers = players.filter(p => p.id !== playerId);
const shouldUpdatePresenter = presenterId === playerId;
const newPresenterId = shouldUpdatePresenter ? null : presenterId;
return { updatedPlayers, shouldUpdatePresenter, newPresenterId };
};
it('removes player from list when they leave', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers).toHaveLength(2);
expect(result.updatedPlayers.find(p => p.id === 'player-2')).toBeUndefined();
expect(result.updatedPlayers.find(p => p.id === 'player-1')).toBeDefined();
expect(result.updatedPlayers.find(p => p.id === 'player-3')).toBeDefined();
});
it('handles player leaving who does not exist', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processPlayerLeft('player-999', players);
expect(result.updatedPlayers).toHaveLength(1);
});
it('handles last player leaving', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(0);
});
it('updates presenter when presenter leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-1', players, 'player-1');
expect(result.shouldUpdatePresenter).toBe(true);
expect(result.newPresenterId).toBeNull();
});
it('does not update presenter when non-presenter leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-2', players, 'player-1');
expect(result.shouldUpdatePresenter).toBe(false);
expect(result.newPresenterId).toBe('player-1');
});
it('preserves player order when someone leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers[0].id).toBe('player-1');
expect(result.updatedPlayers[1].id).toBe('player-3');
});
it('preserves player scores when someone leaves', () => {
const players = [
createPlayer({ id: 'player-1', name: 'Alice', score: 1000 }),
createPlayer({ id: 'player-2', name: 'Bob', score: 500 }),
];
const result = processPlayerLeft('player-2', players);
expect(result.updatedPlayers[0].score).toBe(1000);
});
it('handles empty players array', () => {
const players: Player[] = [];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(0);
});
it('handles player with complex id leaving', () => {
const players = [
createPlayer({ id: 'peer-abc123-xyz789-def', name: 'Alice' }),
createPlayer({ id: 'peer-111222-333444-555', name: 'Bob' }),
];
const result = processPlayerLeft('peer-abc123-xyz789-def', players);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('peer-111222-333444-555');
});
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;
processPlayerLeft('player-1', players);
expect(players).toHaveLength(originalLength);
});
it('handles multiple players leaving in sequence', () => {
let players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
createPlayer({ id: 'player-3', name: 'Charlie' }),
createPlayer({ id: 'player-4', name: 'Diana' }),
];
const result1 = processPlayerLeft('player-1', players);
players = result1.updatedPlayers;
const result2 = processPlayerLeft('player-3', players);
players = result2.updatedPlayers;
expect(players).toHaveLength(2);
expect(players.map(p => p.id)).toEqual(['player-2', 'player-4']);
});
it('handles leaving player with active answer state', () => {
const players = [
createPlayer({
id: 'player-1',
name: 'Alice',
lastAnswerCorrect: true,
selectedShape: 'triangle',
pointsBreakdown: { basePoints: 100, streakBonus: 0, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 100 }
}),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const result = processPlayerLeft('player-1', players);
expect(result.updatedPlayers).toHaveLength(1);
expect(result.updatedPlayers[0].id).toBe('player-2');
});
});
describe('edge cases and race conditions', () => {
it('handles kick request for already-kicked player', () => {
const processKick = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
];
const afterFirstKick = processKick('player-1', players);
const afterSecondKick = processKick('player-1', afterFirstKick);
expect(afterSecondKick).toHaveLength(0);
});
it('handles concurrent kick and leave for same player', () => {
const processRemoval = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const afterKick = processRemoval('player-1', players);
const afterLeave = processRemoval('player-1', afterKick);
expect(afterLeave).toHaveLength(1);
expect(afterLeave[0].id).toBe('player-2');
});
it('handles kick during answer submission', () => {
const player = createPlayer({
id: 'player-1',
name: 'Alice',
lastAnswerCorrect: null,
selectedShape: null,
});
const players = [player];
const updatedPlayers = players.filter(p => p.id !== 'player-1');
expect(updatedPlayers).toHaveLength(0);
});
it('handles multiple rapid kicks', () => {
const processKick = (playerId: string, players: Player[]) => {
return players.filter(p => p.id !== playerId);
};
let players = Array.from({ length: 10 }, (_, i) =>
createPlayer({ id: `player-${i}`, name: `Player ${i}` })
);
for (let i = 0; i < 10; i++) {
players = processKick(`player-${i}`, players);
}
expect(players).toHaveLength(0);
});
it('validates player id before kick', () => {
const isValidKickTarget = (playerId: string, role: 'HOST' | 'CLIENT') => {
if (role !== 'HOST') return false;
if (playerId === 'host') return false;
if (!playerId || playerId.trim() === '') return false;
return true;
};
expect(isValidKickTarget('player-1', 'HOST')).toBe(true);
expect(isValidKickTarget('player-1', 'CLIENT')).toBe(false);
expect(isValidKickTarget('host', 'HOST')).toBe(false);
expect(isValidKickTarget('', 'HOST')).toBe(false);
expect(isValidKickTarget(' ', 'HOST')).toBe(false);
});
it('handles kick when game is in PODIUM state', () => {
const canKickInState = (gameState: GameState) => {
return gameState !== 'PODIUM';
};
expect(canKickInState('LOBBY')).toBe(true);
expect(canKickInState('QUESTION')).toBe(true);
expect(canKickInState('PODIUM')).toBe(false);
});
it('handles leave when game transitions state', () => {
const canLeaveInState = (gameState: GameState) => {
const nonLeavableStates: GameState[] = ['PODIUM', 'LANDING'];
return !nonLeavableStates.includes(gameState);
};
expect(canLeaveInState('LOBBY')).toBe(true);
expect(canLeaveInState('QUESTION')).toBe(true);
expect(canLeaveInState('SCOREBOARD')).toBe(true);
expect(canLeaveInState('PODIUM')).toBe(false);
expect(canLeaveInState('LANDING')).toBe(false);
});
it('preserves game integrity after player removal', () => {
const validateGameIntegrity = (players: Player[]) => {
const ids = players.map(p => p.id);
const uniqueIds = new Set(ids);
return ids.length === uniqueIds.size;
};
const players = [
createPlayer({ id: 'player-1', name: 'Alice' }),
createPlayer({ id: 'player-2', name: 'Bob' }),
];
const afterRemoval = players.filter(p => p.id !== 'player-1');
expect(validateGameIntegrity(afterRemoval)).toBe(true);
});
});
describe('network message validation', () => {
it('validates KICK message structure', () => {
const isValidKickMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { playerId?: string } };
return msg.type === 'KICK' &&
typeof msg.payload?.playerId === 'string' &&
msg.payload.playerId.length > 0;
};
expect(isValidKickMessage({ type: 'KICK', payload: { playerId: 'player-1' } })).toBe(true);
expect(isValidKickMessage({ type: 'KICK', payload: { playerId: '' } })).toBe(false);
expect(isValidKickMessage({ type: 'KICK', payload: {} })).toBe(false);
expect(isValidKickMessage({ type: 'KICK' })).toBe(false);
expect(isValidKickMessage({ type: 'OTHER', payload: { playerId: 'player-1' } })).toBe(false);
expect(isValidKickMessage(null)).toBe(false);
expect(isValidKickMessage(undefined)).toBe(false);
});
it('validates KICKED message structure', () => {
const isValidKickedMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { reason?: string } };
return msg.type === 'KICKED' && typeof msg.payload === 'object';
};
expect(isValidKickedMessage({ type: 'KICKED', payload: { reason: 'test' } })).toBe(true);
expect(isValidKickedMessage({ type: 'KICKED', payload: {} })).toBe(true);
expect(isValidKickedMessage({ type: 'KICKED' })).toBe(false);
expect(isValidKickedMessage({ type: 'OTHER', payload: {} })).toBe(false);
});
it('validates PLAYER_LEFT message structure', () => {
const isValidPlayerLeftMessage = (message: unknown): boolean => {
if (typeof message !== 'object' || message === null) return false;
const msg = message as { type?: string; payload?: { playerId?: string } };
return msg.type === 'PLAYER_LEFT' &&
typeof msg.payload?.playerId === 'string';
};
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: { playerId: 'player-1' } })).toBe(true);
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT', payload: {} })).toBe(false);
expect(isValidPlayerLeftMessage({ type: 'PLAYER_LEFT' })).toBe(false);
});
});
});

View file

@ -129,7 +129,7 @@ export interface QuizListItem {
export interface ProcessedDocument {
type: 'native' | 'text';
content: string | Buffer;
content: string | ArrayBuffer;
mimeType?: string;
}
@ -226,4 +226,7 @@ export type NetworkMessage =
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
| { type: 'GAME_OVER'; payload: { players: Player[] } }
| { type: 'PRESENTER_CHANGED'; payload: { presenterId: string | null } }
| { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } };
| { type: 'ADVANCE'; payload: { action: 'START' | 'NEXT' | 'SCOREBOARD' } }
| { type: 'KICK'; payload: { playerId: string } }
| { type: 'KICKED'; payload: { reason?: string } }
| { type: 'PLAYER_LEFT'; payload: { playerId: string } };