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);
+ });
+});