Skip to content

Commit f7f1a51

Browse files
authored
Merge pull request #13 from TigerAppsOrg/feature/private-match-play-again
Add play again for private matches
2 parents 9f1aaf3 + 7289891 commit f7f1a51

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed

client/src/components/Results.jsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import ProfileModal from './ProfileModal.jsx';
1212

1313
function Results({ onShowLeaderboard }) {
1414
const navigate = useNavigate();
15-
const { raceState, typingState, resetRace, joinPublicRace } = useRace();
15+
const { raceState, typingState, resetRace, joinPublicRace, playAgain } = useRace();
1616
const { isRunning, endTutorial } = useTutorial();
1717
const { user } = useAuth();
1818
// State for profile modal
@@ -353,13 +353,20 @@ function Results({ onShowLeaderboard }) {
353353

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

356+
{/* Play Again button for private match host */}
357+
{raceState.type === 'private' && raceState.completed && user?.netid === raceState.hostNetId && (
358+
<button className="back-btn" onClick={playAgain}>
359+
Play Again
360+
</button>
361+
)}
362+
356363
{/* Queue Next Race button for quick matches */}
357364
{raceState.type === 'public' && (
358365
<button className="back-btn" onClick={handleQueueNext}>
359366
Queue Another Race
360367
</button>
361368
)}
362-
369+
363370
<button className="back-btn back-to-menu-btn" onClick={handleBack}>
364371
Back to Menu
365372
</button>

client/src/context/RaceContext.jsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,36 @@ export const RaceProvider = ({ children }) => {
567567
resetAnticheatState();
568568
};
569569

570+
// Handle play again – host created a new lobby and all players are migrated
571+
const handleLobbyPlayAgain = (data) => {
572+
console.log('Play again – joining new lobby:', data.code);
573+
resetAnticheatState();
574+
setTypingState({
575+
input: '', position: 0, correctChars: 0, errors: 0,
576+
completed: false, wpm: 0, accuracy: 0, lockedPosition: 0
577+
});
578+
setRaceState({
579+
code: data.code,
580+
type: data.type || 'private',
581+
lobbyId: data.lobbyId,
582+
hostNetId: data.hostNetId,
583+
snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null,
584+
players: data.players || [],
585+
startTime: null,
586+
inProgress: false,
587+
completed: false,
588+
results: [],
589+
manuallyStarted: false,
590+
timedTest: {
591+
enabled: data.settings?.testMode === 'timed',
592+
duration: data.settings?.testDuration || 15
593+
},
594+
snippetFilters: data.settings?.snippetFilters || { difficulty: 'all', type: 'all', department: 'all' },
595+
settings: data.settings || { testMode: 'snippet', testDuration: 15 },
596+
countdown: null
597+
});
598+
};
599+
570600
// Register event listeners
571601
socket.on('race:joined', handleRaceJoined);
572602
socket.on('race:playersUpdate', handlePlayersUpdate);
@@ -588,6 +618,7 @@ export const RaceProvider = ({ children }) => {
588618
socket.on('race:playerLeft', handlePlayerLeft);
589619
socket.on('anticheat:lock', handleAnticheatLock);
590620
socket.on('anticheat:reset', handleAnticheatReset);
621+
socket.on('lobby:playAgain', handleLobbyPlayAgain);
591622

592623
// Clean up on unmount
593624
return () => {
@@ -611,6 +642,7 @@ export const RaceProvider = ({ children }) => {
611642
socket.off('race:playerLeft', handlePlayerLeft);
612643
socket.off('anticheat:lock', handleAnticheatLock);
613644
socket.off('anticheat:reset', handleAnticheatReset);
645+
socket.off('lobby:playAgain', handleLobbyPlayAgain);
614646
socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener
615647
};
616648
// Add raceState.snippet?.id to dependency array to reset typing state on snippet change
@@ -1080,6 +1112,16 @@ export const RaceProvider = ({ children }) => {
10801112
});
10811113
};
10821114

1115+
const playAgain = () => {
1116+
if (!socket || !connected || !raceState.code || raceState.type !== 'private') return;
1117+
socket.emit('lobby:playAgain', { code: raceState.code }, (response) => {
1118+
if (!response.success) {
1119+
console.error('Failed to play again:', response.error);
1120+
}
1121+
// State update handled by lobby:playAgain listener
1122+
});
1123+
};
1124+
10831125
// joinPrivateLobby is declared earlier with useCallback to avoid TDZ
10841126

10851127
const kickPlayer = (targetNetId) => {
@@ -1192,6 +1234,7 @@ export const RaceProvider = ({ children }) => {
11921234
kickPlayer,
11931235
updateLobbySettings,
11941236
startPrivateRace,
1237+
playAgain,
11951238
setPlayerReady,
11961239
handleInput,
11971240
updateProgress,

client/src/pages/Race.jsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ function Race() {
7070
navigate('/home', { replace: true });
7171
}
7272
}, [raceState.code, navigate]);
73+
74+
// After play again, navigate back to the new private lobby
75+
useEffect(() => {
76+
if (
77+
raceState.type === 'private' &&
78+
raceState.code &&
79+
!raceState.inProgress &&
80+
!raceState.completed &&
81+
raceState.countdown === null
82+
) {
83+
navigate(`/lobby/${raceState.code}`, { replace: true });
84+
}
85+
}, [raceState.code, raceState.type, raceState.inProgress, raceState.completed, raceState.countdown, navigate]);
7386

7487
// Handle back button
7588
const handleBack = () => {

server/controllers/socket-handlers.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,157 @@ const initialize = (io) => {
13151315
}
13161316
});
13171317

1318+
// Handle "Play Again" for private lobbies (host only)
1319+
// Creates a new lobby with the same settings and migrates all connected players
1320+
socket.on('lobby:playAgain', async (data, callback) => {
1321+
const { user: hostNetid, userId: hostUserId } = socket.userInfo;
1322+
const { code: oldCode } = data;
1323+
1324+
try {
1325+
console.log(`Host ${hostNetid} requesting play again for lobby ${oldCode}`);
1326+
const oldRace = activeRaces.get(oldCode);
1327+
const oldPlayers = racePlayers.get(oldCode);
1328+
1329+
if (!oldRace || oldRace.type !== 'private') {
1330+
throw new Error('Lobby not found or not private.');
1331+
}
1332+
1333+
if (oldRace.hostId !== hostUserId) {
1334+
throw new Error('Only the host can start a new match.');
1335+
}
1336+
1337+
if (oldRace.status !== 'finished') {
1338+
throw new Error('Race has not finished yet.');
1339+
}
1340+
1341+
// Use previous lobby settings to generate a new snippet
1342+
const prevSettings = oldRace.settings || {};
1343+
let snippetId = null;
1344+
let snippet = null;
1345+
1346+
if (prevSettings.testMode === 'timed' && prevSettings.testDuration) {
1347+
const duration = parseInt(prevSettings.testDuration) || 30;
1348+
snippet = createTimedTestSnippet(duration);
1349+
} else {
1350+
const { difficulty, type, department } = prevSettings.snippetFilters || {};
1351+
const difficultyMap = { Easy: 1, Medium: 2, Hard: 3 };
1352+
const numericDifficulty = difficultyMap[difficulty] || null;
1353+
const category = type && type !== 'all'
1354+
? (type === 'course_reviews' ? 'course-reviews' : type)
1355+
: null;
1356+
const subject = category === 'course-reviews' && department && department !== 'all'
1357+
? department
1358+
: null;
1359+
const combos = [];
1360+
if (numericDifficulty != null && category && subject) combos.push({ difficulty: numericDifficulty, category, subject });
1361+
if (numericDifficulty != null && category) combos.push({ difficulty: numericDifficulty, category });
1362+
if (numericDifficulty != null && subject) combos.push({ difficulty: numericDifficulty, subject });
1363+
if (numericDifficulty != null) combos.push({ difficulty: numericDifficulty });
1364+
if (category && subject) combos.push({ category, subject });
1365+
if (category) combos.push({ category });
1366+
combos.push({});
1367+
1368+
let found = null;
1369+
for (const f of combos) {
1370+
const candidate = await SnippetModel.getRandom(f);
1371+
if (candidate) {
1372+
found = candidate;
1373+
break;
1374+
}
1375+
}
1376+
if (!found) throw new Error('Failed to load snippet for new match.');
1377+
snippet = found;
1378+
snippetId = snippet.id;
1379+
}
1380+
1381+
// Create a new lobby in the database
1382+
const newLobby = await RaceModel.create('private', snippetId, hostUserId);
1383+
console.log(`Created new private lobby ${newLobby.code} (play again from ${oldCode})`);
1384+
1385+
// Build new race info in memory
1386+
const newRaceInfo = {
1387+
id: newLobby.id,
1388+
code: newLobby.code,
1389+
snippet: {
1390+
id: snippet?.id,
1391+
text: sanitizeSnippetText(snippet.text),
1392+
is_timed_test: snippet.is_timed_test || false,
1393+
duration: snippet.duration || null,
1394+
princeton_course_url: snippet.princeton_course_url || null,
1395+
course_name: snippet.course_name || null
1396+
},
1397+
status: 'waiting',
1398+
type: 'private',
1399+
hostId: hostUserId,
1400+
hostNetId: hostNetid,
1401+
startTime: null,
1402+
settings: { ...prevSettings }
1403+
};
1404+
activeRaces.set(newLobby.code, newRaceInfo);
1405+
1406+
// Migrate all connected players from the old lobby to the new one
1407+
const newPlayers = [];
1408+
const connectedOldPlayers = oldPlayers || [];
1409+
1410+
for (const player of connectedOldPlayers) {
1411+
const playerSocket = io.sockets.sockets.get(player.id);
1412+
if (!playerSocket) continue; // Skip disconnected players
1413+
1414+
// Leave old socket room, join new one
1415+
playerSocket.leave(oldCode);
1416+
playerSocket.join(newLobby.code);
1417+
1418+
const isHost = player.userId === hostUserId;
1419+
const newPlayer = {
1420+
id: player.id,
1421+
netid: player.netid,
1422+
userId: player.userId,
1423+
ready: isHost, // Host is implicitly ready
1424+
lobbyId: newLobby.id,
1425+
snippetId: snippetId
1426+
};
1427+
newPlayers.push(newPlayer);
1428+
1429+
// Add player to the new lobby in DB
1430+
try {
1431+
await RaceModel.addPlayerToLobby(newLobby.id, player.userId, isHost);
1432+
} catch (dbErr) {
1433+
console.error(`Error adding player ${player.netid} to new lobby:`, dbErr);
1434+
}
1435+
}
1436+
1437+
racePlayers.set(newLobby.code, newPlayers);
1438+
1439+
// Build client data for all players
1440+
const playersClientData = await Promise.all(newPlayers.map(p => getPlayerClientData(p)));
1441+
1442+
const joinedData = {
1443+
code: newLobby.code,
1444+
type: 'private',
1445+
lobbyId: newLobby.id,
1446+
hostNetId: hostNetid,
1447+
snippet: newRaceInfo.snippet,
1448+
settings: newRaceInfo.settings,
1449+
players: playersClientData
1450+
};
1451+
1452+
// Notify all players in the new room about the new lobby
1453+
io.to(newLobby.code).emit('lobby:playAgain', joinedData);
1454+
1455+
// Clean up old lobby from memory
1456+
activeRaces.delete(oldCode);
1457+
racePlayers.delete(oldCode);
1458+
1459+
console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`);
1460+
if (callback) callback({ success: true, lobby: joinedData });
1461+
1462+
} catch (err) {
1463+
console.error(`Error in play again for lobby ${oldCode}:`, err);
1464+
socket.emit('error', { message: err.message || 'Failed to start new match' });
1465+
if (callback) callback({ success: false, error: err.message || 'Failed to start new match' });
1466+
}
1467+
});
1468+
13181469
// --- End Private Lobby Handlers ---
13191470

13201471
// Handle player ready status

0 commit comments

Comments
 (0)