Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions client/src/components/Results.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import ProfileModal from './ProfileModal.jsx';

function Results({ onShowLeaderboard }) {
const navigate = useNavigate();
const { raceState, typingState, resetRace, joinPublicRace, playAgain } = useRace();
const { raceState, typingState, resetRace, joinPublicRace } = useRace();
const { isRunning, endTutorial } = useTutorial();
const { user } = useAuth();
// State for profile modal
Expand Down Expand Up @@ -353,20 +353,13 @@ function Results({ onShowLeaderboard }) {

{raceState.type === 'practice' ? renderPracticeResults() : renderRaceResults()}

{/* Play Again button for private match host */}
{raceState.type === 'private' && raceState.completed && user?.netid === raceState.hostNetId && (
<button className="back-btn" onClick={playAgain}>
Play Again
</button>
)}

{/* Queue Next Race button for quick matches */}
{raceState.type === 'public' && (
<button className="back-btn" onClick={handleQueueNext}>
Queue Another Race
</button>
)}

<button className="back-btn back-to-menu-btn" onClick={handleBack}>
Back to Menu
</button>
Expand Down
43 changes: 0 additions & 43 deletions client/src/context/RaceContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,36 +567,6 @@ 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();
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);
Expand All @@ -618,7 +588,6 @@ 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 () => {
Expand All @@ -642,7 +611,6 @@ 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
Expand Down Expand Up @@ -1112,16 +1080,6 @@ 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) => {
Expand Down Expand Up @@ -1234,7 +1192,6 @@ export const RaceProvider = ({ children }) => {
kickPlayer,
updateLobbySettings,
startPrivateRace,
playAgain,
setPlayerReady,
handleInput,
updateProgress,
Expand Down
13 changes: 0 additions & 13 deletions client/src/pages/Race.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,6 @@ 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 = () => {
Expand Down
151 changes: 0 additions & 151 deletions server/controllers/socket-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -1315,157 +1315,6 @@ 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;
const { code: oldCode } = data;

try {
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
const 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 newPlayers = [];
const connectedOldPlayers = oldPlayers || [];

for (const player of connectedOldPlayers) {
const playerSocket = io.sockets.sockets.get(player.id);
if (!playerSocket) continue; // Skip disconnected players

// Leave old socket room, join new one
playerSocket.leave(oldCode);
playerSocket.join(newLobby.code);

const isHost = player.userId === hostUserId;
const newPlayer = {
id: player.id,
netid: player.netid,
userId: player.userId,
ready: isHost, // Host is implicitly ready
lobbyId: newLobby.id,
snippetId: snippetId
};
newPlayers.push(newPlayer);

// Add player to the new lobby in DB
try {
await RaceModel.addPlayerToLobby(newLobby.id, player.userId, isHost);
} catch (dbErr) {
console.error(`Error adding player ${player.netid} to new lobby:`, dbErr);
}
}

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 all players in the new room about the new lobby
io.to(newLobby.code).emit('lobby:playAgain', joinedData);

// Clean up old lobby from memory
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) {
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' });
}
});

// --- End Private Lobby Handlers ---

// Handle player ready status
Expand Down
Loading