diff --git a/client/src/components/Results.jsx b/client/src/components/Results.jsx index 701f4df3..b90a2e7e 100644 --- a/client/src/components/Results.jsx +++ b/client/src/components/Results.jsx @@ -12,7 +12,7 @@ import ProfileModal from './ProfileModal.jsx'; function Results({ onShowLeaderboard }) { const navigate = useNavigate(); - const { raceState, typingState, resetRace, joinPublicRace } = useRace(); + const { raceState, typingState, resetRace, joinPublicRace, playAgain } = useRace(); const { isRunning, endTutorial } = useTutorial(); const { user } = useAuth(); // State for profile modal @@ -353,13 +353,20 @@ function Results({ onShowLeaderboard }) { {raceState.type === 'practice' ? renderPracticeResults() : renderRaceResults()} + {/* Play Again button for private match host */} + {raceState.type === 'private' && raceState.completed && user?.netid === raceState.hostNetId && ( + + )} + {/* Queue Next Race button for quick matches */} {raceState.type === 'public' && ( )} - + diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index 4d70d16f..62d15e57 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -567,6 +567,43 @@ export const RaceProvider = ({ children }) => { resetAnticheatState(); }; + // Handle play again – host created a new lobby and all players are migrated + const handleLobbyPlayAgain = (data) => { + console.log('Play again – joining new lobby:', data.code); + resetAnticheatState(); + setInactivityState({ + warning: false, + warningMessage: '', + kicked: false, + kickMessage: '', + redirectToHome: false + }); + setTypingState({ + input: '', position: 0, correctChars: 0, errors: 0, + completed: false, wpm: 0, accuracy: 0, lockedPosition: 0 + }); + setRaceState({ + code: data.code, + type: data.type || 'private', + lobbyId: data.lobbyId, + hostNetId: data.hostNetId, + snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null, + players: data.players || [], + startTime: null, + inProgress: false, + completed: false, + results: [], + manuallyStarted: false, + timedTest: { + enabled: data.settings?.testMode === 'timed', + duration: data.settings?.testDuration || 15 + }, + snippetFilters: data.settings?.snippetFilters || { difficulty: 'all', type: 'all', department: 'all' }, + settings: data.settings || { testMode: 'snippet', testDuration: 15 }, + countdown: null + }); + }; + // Register event listeners socket.on('race:joined', handleRaceJoined); socket.on('race:playersUpdate', handlePlayersUpdate); @@ -588,6 +625,7 @@ export const RaceProvider = ({ children }) => { socket.on('race:playerLeft', handlePlayerLeft); socket.on('anticheat:lock', handleAnticheatLock); socket.on('anticheat:reset', handleAnticheatReset); + socket.on('lobby:playAgain', handleLobbyPlayAgain); // Clean up on unmount return () => { @@ -611,6 +649,7 @@ export const RaceProvider = ({ children }) => { socket.off('race:playerLeft', handlePlayerLeft); socket.off('anticheat:lock', handleAnticheatLock); socket.off('anticheat:reset', handleAnticheatReset); + socket.off('lobby:playAgain', handleLobbyPlayAgain); socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener }; // Add raceState.snippet?.id to dependency array to reset typing state on snippet change @@ -1080,6 +1119,16 @@ export const RaceProvider = ({ children }) => { }); }; + const playAgain = () => { + if (!socket || !connected || !raceState.code || raceState.type !== 'private') return; + socket.emit('lobby:playAgain', { code: raceState.code }, (response) => { + if (!response.success) { + console.error('Failed to play again:', response.error); + } + // State update handled by lobby:playAgain listener + }); + }; + // joinPrivateLobby is declared earlier with useCallback to avoid TDZ const kickPlayer = (targetNetId) => { @@ -1192,6 +1241,7 @@ export const RaceProvider = ({ children }) => { kickPlayer, updateLobbySettings, startPrivateRace, + playAgain, setPlayerReady, handleInput, updateProgress, diff --git a/client/src/pages/Lobby.jsx b/client/src/pages/Lobby.jsx index aa9986cf..16ba9d5e 100644 --- a/client/src/pages/Lobby.jsx +++ b/client/src/pages/Lobby.jsx @@ -91,12 +91,26 @@ function Lobby() { // --- TestConfigurator State --- // Use settings directly from raceState now that context handles it const currentSettings = raceState.settings || { testMode: 'snippet', testDuration: 15 }; - // Local state for filters not yet in raceState.settings - const [snippetDifficulty, setSnippetDifficulty] = useState(''); - const [snippetCategory, setSnippetCategory] = useState(''); - const [snippetSubject, setSnippetSubject] = useState(''); + const currentSnippetFilters = currentSettings.snippetFilters || { + difficulty: 'all', + type: 'all', + department: 'all' + }; + const [snippetDifficulty, setSnippetDifficulty] = useState(currentSnippetFilters.difficulty || 'all'); + const [snippetCategory, setSnippetCategory] = useState(currentSnippetFilters.type || 'all'); + const [snippetSubject, setSnippetSubject] = useState(currentSnippetFilters.department || 'all'); // --- --- + useEffect(() => { + setSnippetDifficulty(currentSnippetFilters.difficulty || 'all'); + setSnippetCategory(currentSnippetFilters.type || 'all'); + setSnippetSubject(currentSnippetFilters.department || 'all'); + }, [ + currentSnippetFilters.department, + currentSnippetFilters.difficulty, + currentSnippetFilters.type + ]); + // Handler for settings changes (only host can trigger) // Generic handler factory that maps a particular setter (identified by a string // rather than the actual function reference) to a callback that @@ -273,7 +287,7 @@ function Lobby() { loadNewSnippet={loadNewSnippet} snippetError={snippetError} isLobby - allowTimed={false} + allowTimed={currentSettings.testMode === 'timed'} onShowLeaderboard={() => {}} // Disable leaderboard button in lobby /> ) : ( diff --git a/client/src/pages/Race.jsx b/client/src/pages/Race.jsx index 512dad59..bd4ebcf6 100644 --- a/client/src/pages/Race.jsx +++ b/client/src/pages/Race.jsx @@ -70,6 +70,19 @@ function Race() { navigate('/home', { replace: true }); } }, [raceState.code, navigate]); + + // After play again, navigate back to the new private lobby + useEffect(() => { + if ( + raceState.type === 'private' && + raceState.code && + !raceState.inProgress && + !raceState.completed && + raceState.countdown === null + ) { + navigate(`/lobby/${raceState.code}`, { replace: true }); + } + }, [raceState.code, raceState.type, raceState.inProgress, raceState.completed, raceState.countdown, navigate]); // Handle back button const handleBack = () => { diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 1ddd77db..5d112123 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -35,6 +35,7 @@ const MAX_PROGRESS_STEP = 35; // max characters allowed per progress update (inc const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets (unused, kept for reference) const MAX_ALLOWED_WPM = 350; // anything above is flagged const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this +const playAgainTransitions = new Set(); // lobbyCode -> transition in progress // Store host disconnect timers for private lobbies const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds @@ -49,6 +50,72 @@ const sanitizeSnippetText = (text) => { return text.replace(/(?:\r?\n)+\s*$/u, ''); }; +const normalizeLobbyCode = (payload = {}) => { + const normalized = typeof payload?.code === 'string' + ? payload.code.trim().toUpperCase() + : payload?.code; + + if (!normalized) { + throw new Error('Lobby code is required.'); + } + + return normalized; +}; + +const acquirePlayAgainLock = (code, locks = playAgainTransitions) => { + if (locks.has(code)) { + throw new Error('A new match is already being created.'); + } + + locks.add(code); +}; + +const releasePlayAgainLock = (code, locks = playAgainTransitions) => { + if (!code) return; + locks.delete(code); +}; + +const clearLobbyTransientState = ( + code, + stores = { + inactivityTimers, + hostDisconnectTimers, + countdownTimers + } +) => { + const hostTimerInfo = stores.hostDisconnectTimers.get(code); + if (hostTimerInfo) { + clearTimeout(hostTimerInfo.timer); + stores.hostDisconnectTimers.delete(code); + } + + const countdownTimer = stores.countdownTimers.get(code); + if (countdownTimer) { + clearTimeout(countdownTimer); + stores.countdownTimers.delete(code); + } + + for (const [key, timerInfo] of stores.inactivityTimers.entries()) { + if (!key.startsWith(`${code}-`)) continue; + clearTimeout(timerInfo.warningTimer); + clearTimeout(timerInfo.kickTimer); + stores.inactivityTimers.delete(key); + } +}; + +const resetSocketRaceState = ( + socketId, + stores = { + playerProgress, + lastProgressUpdate, + suspiciousPlayers + } +) => { + stores.playerProgress.delete(socketId); + stores.lastProgressUpdate.delete(socketId); + stores.suspiciousPlayers.delete(socketId); +}; + // Get player data for client, including avatar URL and basic stats const getPlayerClientData = async (player) => { // Make async // Use cached avatar if available, otherwise use null @@ -1315,6 +1382,204 @@ const initialize = (io) => { } }); + // Handle "Play Again" for private lobbies (host only) + // Creates a new lobby with the same settings and migrates all connected players + socket.on('lobby:playAgain', async (data = {}, callback) => { + const { user: hostNetid, userId: hostUserId } = socket.userInfo; + let oldCode = null; + let newLobby = null; + let playAgainLocked = false; + const addedPlayerIds = []; + const migratedPlayers = []; + + try { + oldCode = normalizeLobbyCode(data); + acquirePlayAgainLock(oldCode); + playAgainLocked = true; + + console.log(`Host ${hostNetid} requesting play again for lobby ${oldCode}`); + const oldRace = activeRaces.get(oldCode); + const oldPlayers = racePlayers.get(oldCode); + + if (!oldRace || oldRace.type !== 'private') { + throw new Error('Lobby not found or not private.'); + } + + if (oldRace.hostId !== hostUserId) { + throw new Error('Only the host can start a new match.'); + } + + if (oldRace.status !== 'finished') { + throw new Error('Race has not finished yet.'); + } + + // Use previous lobby settings to generate a new snippet + const prevSettings = oldRace.settings || {}; + let snippetId = null; + let snippet = null; + + if (prevSettings.testMode === 'timed' && prevSettings.testDuration) { + const duration = parseInt(prevSettings.testDuration) || 30; + snippet = createTimedTestSnippet(duration); + } else { + const { difficulty, type, department } = prevSettings.snippetFilters || {}; + const difficultyMap = { Easy: 1, Medium: 2, Hard: 3 }; + const numericDifficulty = difficultyMap[difficulty] || null; + const category = type && type !== 'all' + ? (type === 'course_reviews' ? 'course-reviews' : type) + : null; + const subject = category === 'course-reviews' && department && department !== 'all' + ? department + : null; + const combos = []; + if (numericDifficulty != null && category && subject) combos.push({ difficulty: numericDifficulty, category, subject }); + if (numericDifficulty != null && category) combos.push({ difficulty: numericDifficulty, category }); + if (numericDifficulty != null && subject) combos.push({ difficulty: numericDifficulty, subject }); + if (numericDifficulty != null) combos.push({ difficulty: numericDifficulty }); + if (category && subject) combos.push({ category, subject }); + if (category) combos.push({ category }); + combos.push({}); + + let found = null; + for (const f of combos) { + const candidate = await SnippetModel.getRandom(f); + if (candidate) { + found = candidate; + break; + } + } + if (!found) throw new Error('Failed to load snippet for new match.'); + snippet = found; + snippetId = snippet.id; + } + + // Create a new lobby in the database + newLobby = await RaceModel.create('private', snippetId, hostUserId); + console.log(`Created new private lobby ${newLobby.code} (play again from ${oldCode})`); + + // Build new race info in memory + const newRaceInfo = { + id: newLobby.id, + code: newLobby.code, + snippet: { + id: snippet?.id, + text: sanitizeSnippetText(snippet.text), + is_timed_test: snippet.is_timed_test || false, + duration: snippet.duration || null, + princeton_course_url: snippet.princeton_course_url || null, + course_name: snippet.course_name || null + }, + status: 'waiting', + type: 'private', + hostId: hostUserId, + hostNetId: hostNetid, + startTime: null, + settings: { ...prevSettings } + }; + activeRaces.set(newLobby.code, newRaceInfo); + + // Migrate all connected players from the old lobby to the new one + const connectedPlayers = []; + for (const player of oldPlayers || []) { + const playerSocket = io.sockets.sockets.get(player.id); + if (!playerSocket) continue; // Skip disconnected players + connectedPlayers.push({ + player, + playerSocket, + isHost: player.userId === hostUserId + }); + } + + for (const { player, isHost } of connectedPlayers) { + await RaceModel.addPlayerToLobby(newLobby.id, player.userId, isHost); + addedPlayerIds.push(player.userId); + } + + const newPlayers = []; + for (const { player, playerSocket, isHost } of connectedPlayers) { + await playerSocket.join(newLobby.code); + await playerSocket.leave(oldCode); + resetSocketRaceState(player.id); + + const newPlayer = { + id: player.id, + netid: player.netid, + userId: player.userId, + ready: isHost, // Host is implicitly ready + lobbyId: newLobby.id, + snippetId: snippetId + }; + migratedPlayers.push({ socket: playerSocket, playerId: player.id }); + newPlayers.push(newPlayer); + } + + racePlayers.set(newLobby.code, newPlayers); + + // Build client data for all players + const playersClientData = await Promise.all(newPlayers.map(p => getPlayerClientData(p))); + + const joinedData = { + code: newLobby.code, + type: 'private', + lobbyId: newLobby.id, + hostNetId: hostNetid, + snippet: newRaceInfo.snippet, + settings: newRaceInfo.settings, + players: playersClientData + }; + + // Notify migrated players directly so the room join can't race the event + for (const { socket: migratedSocket } of migratedPlayers) { + migratedSocket.emit('lobby:playAgain', joinedData); + } + + // Clean up old lobby from memory + clearLobbyTransientState(oldCode); + activeRaces.delete(oldCode); + racePlayers.delete(oldCode); + + console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`); + if (callback) callback({ success: true, lobby: joinedData }); + + } catch (err) { + if (newLobby?.code) { + activeRaces.delete(newLobby.code); + racePlayers.delete(newLobby.code); + + for (const { socket: migratedSocket } of migratedPlayers) { + try { + await migratedSocket.join(oldCode); + await migratedSocket.leave(newLobby.code); + } catch (rollbackErr) { + console.error(`Error rolling back socket room move for ${oldCode}:`, rollbackErr); + } + } + + for (const userId of addedPlayerIds) { + try { + await RaceModel.removePlayerFromLobby(newLobby.id, userId); + } catch (rollbackErr) { + console.error(`Error rolling back lobby player for ${newLobby.code}:`, rollbackErr); + } + } + + try { + await RaceModel.softTerminate(newLobby.id); + } catch (rollbackErr) { + console.error(`Error terminating failed replacement lobby ${newLobby.code}:`, rollbackErr); + } + } + + console.error(`Error in play again for lobby ${oldCode}:`, err); + socket.emit('error', { message: err.message || 'Failed to start new match' }); + if (callback) callback({ success: false, error: err.message || 'Failed to start new match' }); + } finally { + if (playAgainLocked) { + releasePlayAgainLock(oldCode); + } + } + }); + // --- End Private Lobby Handlers --- // Handle player ready status @@ -2298,5 +2563,12 @@ const clearInactivityTimers = (code, playerId) => { }; module.exports = { - initialize + initialize, + __testables: { + normalizeLobbyCode, + acquirePlayAgainLock, + releasePlayAgainLock, + clearLobbyTransientState, + resetSocketRaceState + } }; diff --git a/server/tests/socket-handlers.test.js b/server/tests/socket-handlers.test.js new file mode 100644 index 00000000..03d09eac --- /dev/null +++ b/server/tests/socket-handlers.test.js @@ -0,0 +1,91 @@ +const { + __testables: { + normalizeLobbyCode, + acquirePlayAgainLock, + releasePlayAgainLock, + clearLobbyTransientState, + resetSocketRaceState + } +} = require('../controllers/socket-handlers'); + +describe('socket-handlers play again helpers', () => { + it('normalizes a play again lobby code', () => { + expect(normalizeLobbyCode({ code: ' ab12cd ' })).toBe('AB12CD'); + }); + + it('rejects missing play again lobby codes', () => { + expect(() => normalizeLobbyCode({})).toThrow('Lobby code is required.'); + expect(() => normalizeLobbyCode(null)).toThrow('Lobby code is required.'); + }); + + it('prevents duplicate play again locks', () => { + const locks = new Set(); + + acquirePlayAgainLock('ROOM42', locks); + expect(locks.has('ROOM42')).toBe(true); + expect(() => acquirePlayAgainLock('ROOM42', locks)).toThrow('A new match is already being created.'); + + releasePlayAgainLock('ROOM42', locks); + expect(locks.has('ROOM42')).toBe(false); + }); + + it('clears only transient state for the specified lobby', () => { + const warningTimer = setTimeout(() => {}, 1000); + const kickTimer = setTimeout(() => {}, 1000); + const otherWarningTimer = setTimeout(() => {}, 1000); + const otherKickTimer = setTimeout(() => {}, 1000); + const hostTimer = setTimeout(() => {}, 1000); + const otherHostTimer = setTimeout(() => {}, 1000); + const countdownTimer = setTimeout(() => {}, 1000); + const otherCountdownTimer = setTimeout(() => {}, 1000); + + const stores = { + inactivityTimers: new Map([ + ['ROOM42-socket-1', { warningTimer, kickTimer }], + ['ROOM99-socket-2', { warningTimer: otherWarningTimer, kickTimer: otherKickTimer }] + ]), + hostDisconnectTimers: new Map([ + ['ROOM42', { timer: hostTimer, userId: 1 }], + ['ROOM99', { timer: otherHostTimer, userId: 2 }] + ]), + countdownTimers: new Map([ + ['ROOM42', countdownTimer], + ['ROOM99', otherCountdownTimer] + ]) + }; + + clearLobbyTransientState('ROOM42', stores); + + expect(stores.inactivityTimers.has('ROOM42-socket-1')).toBe(false); + expect(stores.inactivityTimers.has('ROOM99-socket-2')).toBe(true); + expect(stores.hostDisconnectTimers.has('ROOM42')).toBe(false); + expect(stores.hostDisconnectTimers.has('ROOM99')).toBe(true); + expect(stores.countdownTimers.has('ROOM42')).toBe(false); + expect(stores.countdownTimers.has('ROOM99')).toBe(true); + + clearTimeout(otherWarningTimer); + clearTimeout(otherKickTimer); + clearTimeout(otherHostTimer); + clearTimeout(otherCountdownTimer); + }); + + it('resets per-socket race state before the next lobby starts', () => { + const stores = { + playerProgress: new Map([ + ['socket-1', { completed: true, finishHandled: true }] + ]), + lastProgressUpdate: new Map([ + ['socket-1', Date.now()] + ]), + suspiciousPlayers: new Map([ + ['socket-1', { locked: true }] + ]) + }; + + resetSocketRaceState('socket-1', stores); + + expect(stores.playerProgress.has('socket-1')).toBe(false); + expect(stores.lastProgressUpdate.has('socket-1')).toBe(false); + expect(stores.suspiciousPlayers.has('socket-1')).toBe(false); + }); +});