Skip to content

Commit c64cbd3

Browse files
committed
Fix private play again rollback and state handling
1 parent 2b1e90a commit c64cbd3

4 files changed

Lines changed: 259 additions & 26 deletions

File tree

client/src/context/RaceContext.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,13 @@ export const RaceProvider = ({ children }) => {
571571
const handleLobbyPlayAgain = (data) => {
572572
console.log('Play again – joining new lobby:', data.code);
573573
resetAnticheatState();
574+
setInactivityState({
575+
warning: false,
576+
warningMessage: '',
577+
kicked: false,
578+
kickMessage: '',
579+
redirectToHome: false
580+
});
574581
setTypingState({
575582
input: '', position: 0, correctChars: 0, errors: 0,
576583
completed: false, wpm: 0, accuracy: 0, lockedPosition: 0

client/src/pages/Lobby.jsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,26 @@ function Lobby() {
9191
// --- TestConfigurator State ---
9292
// Use settings directly from raceState now that context handles it
9393
const currentSettings = raceState.settings || { testMode: 'snippet', testDuration: 15 };
94-
// Local state for filters not yet in raceState.settings
95-
const [snippetDifficulty, setSnippetDifficulty] = useState('');
96-
const [snippetCategory, setSnippetCategory] = useState('');
97-
const [snippetSubject, setSnippetSubject] = useState('');
94+
const currentSnippetFilters = currentSettings.snippetFilters || {
95+
difficulty: 'all',
96+
type: 'all',
97+
department: 'all'
98+
};
99+
const [snippetDifficulty, setSnippetDifficulty] = useState(currentSnippetFilters.difficulty || 'all');
100+
const [snippetCategory, setSnippetCategory] = useState(currentSnippetFilters.type || 'all');
101+
const [snippetSubject, setSnippetSubject] = useState(currentSnippetFilters.department || 'all');
98102
// --- ---
99103

104+
useEffect(() => {
105+
setSnippetDifficulty(currentSnippetFilters.difficulty || 'all');
106+
setSnippetCategory(currentSnippetFilters.type || 'all');
107+
setSnippetSubject(currentSnippetFilters.department || 'all');
108+
}, [
109+
currentSnippetFilters.department,
110+
currentSnippetFilters.difficulty,
111+
currentSnippetFilters.type
112+
]);
113+
100114
// Handler for settings changes (only host can trigger)
101115
// Generic handler factory that maps a particular setter (identified by a string
102116
// rather than the actual function reference) to a callback that
@@ -273,7 +287,7 @@ function Lobby() {
273287
loadNewSnippet={loadNewSnippet}
274288
snippetError={snippetError}
275289
isLobby
276-
allowTimed={false}
290+
allowTimed={currentSettings.testMode === 'timed'}
277291
onShowLeaderboard={() => {}} // Disable leaderboard button in lobby
278292
/>
279293
) : (

server/controllers/socket-handlers.js

Lines changed: 142 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const MAX_PROGRESS_STEP = 35; // max characters allowed per progress update (inc
3535
const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets (unused, kept for reference)
3636
const MAX_ALLOWED_WPM = 350; // anything above is flagged
3737
const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this
38+
const playAgainTransitions = new Set(); // lobbyCode -> transition in progress
3839

3940
// Store host disconnect timers for private lobbies
4041
const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds
@@ -49,6 +50,72 @@ const sanitizeSnippetText = (text) => {
4950
return text.replace(/(?:\r?\n)+\s*$/u, '');
5051
};
5152

53+
const normalizeLobbyCode = (payload = {}) => {
54+
const normalized = typeof payload?.code === 'string'
55+
? payload.code.trim().toUpperCase()
56+
: payload?.code;
57+
58+
if (!normalized) {
59+
throw new Error('Lobby code is required.');
60+
}
61+
62+
return normalized;
63+
};
64+
65+
const acquirePlayAgainLock = (code, locks = playAgainTransitions) => {
66+
if (locks.has(code)) {
67+
throw new Error('A new match is already being created.');
68+
}
69+
70+
locks.add(code);
71+
};
72+
73+
const releasePlayAgainLock = (code, locks = playAgainTransitions) => {
74+
if (!code) return;
75+
locks.delete(code);
76+
};
77+
78+
const clearLobbyTransientState = (
79+
code,
80+
stores = {
81+
inactivityTimers,
82+
hostDisconnectTimers,
83+
countdownTimers
84+
}
85+
) => {
86+
const hostTimerInfo = stores.hostDisconnectTimers.get(code);
87+
if (hostTimerInfo) {
88+
clearTimeout(hostTimerInfo.timer);
89+
stores.hostDisconnectTimers.delete(code);
90+
}
91+
92+
const countdownTimer = stores.countdownTimers.get(code);
93+
if (countdownTimer) {
94+
clearTimeout(countdownTimer);
95+
stores.countdownTimers.delete(code);
96+
}
97+
98+
for (const [key, timerInfo] of stores.inactivityTimers.entries()) {
99+
if (!key.startsWith(`${code}-`)) continue;
100+
clearTimeout(timerInfo.warningTimer);
101+
clearTimeout(timerInfo.kickTimer);
102+
stores.inactivityTimers.delete(key);
103+
}
104+
};
105+
106+
const resetSocketRaceState = (
107+
socketId,
108+
stores = {
109+
playerProgress,
110+
lastProgressUpdate,
111+
suspiciousPlayers
112+
}
113+
) => {
114+
stores.playerProgress.delete(socketId);
115+
stores.lastProgressUpdate.delete(socketId);
116+
stores.suspiciousPlayers.delete(socketId);
117+
};
118+
52119
// Get player data for client, including avatar URL and basic stats
53120
const getPlayerClientData = async (player) => { // Make async
54121
// Use cached avatar if available, otherwise use null
@@ -1317,11 +1384,19 @@ const initialize = (io) => {
13171384

13181385
// Handle "Play Again" for private lobbies (host only)
13191386
// Creates a new lobby with the same settings and migrates all connected players
1320-
socket.on('lobby:playAgain', async (data, callback) => {
1387+
socket.on('lobby:playAgain', async (data = {}, callback) => {
13211388
const { user: hostNetid, userId: hostUserId } = socket.userInfo;
1322-
const { code: oldCode } = data;
1389+
let oldCode = null;
1390+
let newLobby = null;
1391+
let playAgainLocked = false;
1392+
const addedPlayerIds = [];
1393+
const migratedPlayers = [];
13231394

13241395
try {
1396+
oldCode = normalizeLobbyCode(data);
1397+
acquirePlayAgainLock(oldCode);
1398+
playAgainLocked = true;
1399+
13251400
console.log(`Host ${hostNetid} requesting play again for lobby ${oldCode}`);
13261401
const oldRace = activeRaces.get(oldCode);
13271402
const oldPlayers = racePlayers.get(oldCode);
@@ -1379,7 +1454,7 @@ const initialize = (io) => {
13791454
}
13801455

13811456
// Create a new lobby in the database
1382-
const newLobby = await RaceModel.create('private', snippetId, hostUserId);
1457+
newLobby = await RaceModel.create('private', snippetId, hostUserId);
13831458
console.log(`Created new private lobby ${newLobby.code} (play again from ${oldCode})`);
13841459

13851460
// Build new race info in memory
@@ -1404,18 +1479,28 @@ const initialize = (io) => {
14041479
activeRaces.set(newLobby.code, newRaceInfo);
14051480

14061481
// 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) {
1482+
const connectedPlayers = [];
1483+
for (const player of oldPlayers || []) {
14111484
const playerSocket = io.sockets.sockets.get(player.id);
14121485
if (!playerSocket) continue; // Skip disconnected players
1486+
connectedPlayers.push({
1487+
player,
1488+
playerSocket,
1489+
isHost: player.userId === hostUserId
1490+
});
1491+
}
14131492

1414-
// Leave old socket room, join new one
1415-
playerSocket.leave(oldCode);
1416-
playerSocket.join(newLobby.code);
1493+
for (const { player, isHost } of connectedPlayers) {
1494+
await RaceModel.addPlayerToLobby(newLobby.id, player.userId, isHost);
1495+
addedPlayerIds.push(player.userId);
1496+
}
1497+
1498+
const newPlayers = [];
1499+
for (const { player, playerSocket, isHost } of connectedPlayers) {
1500+
await playerSocket.join(newLobby.code);
1501+
await playerSocket.leave(oldCode);
1502+
resetSocketRaceState(player.id);
14171503

1418-
const isHost = player.userId === hostUserId;
14191504
const newPlayer = {
14201505
id: player.id,
14211506
netid: player.netid,
@@ -1424,14 +1509,8 @@ const initialize = (io) => {
14241509
lobbyId: newLobby.id,
14251510
snippetId: snippetId
14261511
};
1512+
migratedPlayers.push({ socket: playerSocket, playerId: player.id });
14271513
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-
}
14351514
}
14361515

14371516
racePlayers.set(newLobby.code, newPlayers);
@@ -1449,20 +1528,55 @@ const initialize = (io) => {
14491528
players: playersClientData
14501529
};
14511530

1452-
// Notify all players in the new room about the new lobby
1453-
io.to(newLobby.code).emit('lobby:playAgain', joinedData);
1531+
// Notify migrated players directly so the room join can't race the event
1532+
for (const { socket: migratedSocket } of migratedPlayers) {
1533+
migratedSocket.emit('lobby:playAgain', joinedData);
1534+
}
14541535

14551536
// Clean up old lobby from memory
1537+
clearLobbyTransientState(oldCode);
14561538
activeRaces.delete(oldCode);
14571539
racePlayers.delete(oldCode);
14581540

14591541
console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`);
14601542
if (callback) callback({ success: true, lobby: joinedData });
14611543

14621544
} catch (err) {
1545+
if (newLobby?.code) {
1546+
activeRaces.delete(newLobby.code);
1547+
racePlayers.delete(newLobby.code);
1548+
1549+
for (const { socket: migratedSocket } of migratedPlayers) {
1550+
try {
1551+
await migratedSocket.join(oldCode);
1552+
await migratedSocket.leave(newLobby.code);
1553+
} catch (rollbackErr) {
1554+
console.error(`Error rolling back socket room move for ${oldCode}:`, rollbackErr);
1555+
}
1556+
}
1557+
1558+
for (const userId of addedPlayerIds) {
1559+
try {
1560+
await RaceModel.removePlayerFromLobby(newLobby.id, userId);
1561+
} catch (rollbackErr) {
1562+
console.error(`Error rolling back lobby player for ${newLobby.code}:`, rollbackErr);
1563+
}
1564+
}
1565+
1566+
try {
1567+
await RaceModel.softTerminate(newLobby.id);
1568+
} catch (rollbackErr) {
1569+
console.error(`Error terminating failed replacement lobby ${newLobby.code}:`, rollbackErr);
1570+
}
1571+
}
1572+
14631573
console.error(`Error in play again for lobby ${oldCode}:`, err);
14641574
socket.emit('error', { message: err.message || 'Failed to start new match' });
14651575
if (callback) callback({ success: false, error: err.message || 'Failed to start new match' });
1576+
} finally {
1577+
if (playAgainLocked) {
1578+
releasePlayAgainLock(oldCode);
1579+
}
14661580
}
14671581
});
14681582

@@ -2449,5 +2563,12 @@ const clearInactivityTimers = (code, playerId) => {
24492563
};
24502564

24512565
module.exports = {
2452-
initialize
2566+
initialize,
2567+
__testables: {
2568+
normalizeLobbyCode,
2569+
acquirePlayAgainLock,
2570+
releasePlayAgainLock,
2571+
clearLobbyTransientState,
2572+
resetSocketRaceState
2573+
}
24532574
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const {
2+
__testables: {
3+
normalizeLobbyCode,
4+
acquirePlayAgainLock,
5+
releasePlayAgainLock,
6+
clearLobbyTransientState,
7+
resetSocketRaceState
8+
}
9+
} = require('../controllers/socket-handlers');
10+
11+
describe('socket-handlers play again helpers', () => {
12+
it('normalizes a play again lobby code', () => {
13+
expect(normalizeLobbyCode({ code: ' ab12cd ' })).toBe('AB12CD');
14+
});
15+
16+
it('rejects missing play again lobby codes', () => {
17+
expect(() => normalizeLobbyCode({})).toThrow('Lobby code is required.');
18+
expect(() => normalizeLobbyCode(null)).toThrow('Lobby code is required.');
19+
});
20+
21+
it('prevents duplicate play again locks', () => {
22+
const locks = new Set();
23+
24+
acquirePlayAgainLock('ROOM42', locks);
25+
expect(locks.has('ROOM42')).toBe(true);
26+
expect(() => acquirePlayAgainLock('ROOM42', locks)).toThrow('A new match is already being created.');
27+
28+
releasePlayAgainLock('ROOM42', locks);
29+
expect(locks.has('ROOM42')).toBe(false);
30+
});
31+
32+
it('clears only transient state for the specified lobby', () => {
33+
const warningTimer = setTimeout(() => {}, 1000);
34+
const kickTimer = setTimeout(() => {}, 1000);
35+
const otherWarningTimer = setTimeout(() => {}, 1000);
36+
const otherKickTimer = setTimeout(() => {}, 1000);
37+
const hostTimer = setTimeout(() => {}, 1000);
38+
const otherHostTimer = setTimeout(() => {}, 1000);
39+
const countdownTimer = setTimeout(() => {}, 1000);
40+
const otherCountdownTimer = setTimeout(() => {}, 1000);
41+
42+
const stores = {
43+
inactivityTimers: new Map([
44+
['ROOM42-socket-1', { warningTimer, kickTimer }],
45+
['ROOM99-socket-2', { warningTimer: otherWarningTimer, kickTimer: otherKickTimer }]
46+
]),
47+
hostDisconnectTimers: new Map([
48+
['ROOM42', { timer: hostTimer, userId: 1 }],
49+
['ROOM99', { timer: otherHostTimer, userId: 2 }]
50+
]),
51+
countdownTimers: new Map([
52+
['ROOM42', countdownTimer],
53+
['ROOM99', otherCountdownTimer]
54+
])
55+
};
56+
57+
clearLobbyTransientState('ROOM42', stores);
58+
59+
expect(stores.inactivityTimers.has('ROOM42-socket-1')).toBe(false);
60+
expect(stores.inactivityTimers.has('ROOM99-socket-2')).toBe(true);
61+
expect(stores.hostDisconnectTimers.has('ROOM42')).toBe(false);
62+
expect(stores.hostDisconnectTimers.has('ROOM99')).toBe(true);
63+
expect(stores.countdownTimers.has('ROOM42')).toBe(false);
64+
expect(stores.countdownTimers.has('ROOM99')).toBe(true);
65+
66+
clearTimeout(otherWarningTimer);
67+
clearTimeout(otherKickTimer);
68+
clearTimeout(otherHostTimer);
69+
clearTimeout(otherCountdownTimer);
70+
});
71+
72+
it('resets per-socket race state before the next lobby starts', () => {
73+
const stores = {
74+
playerProgress: new Map([
75+
['socket-1', { completed: true, finishHandled: true }]
76+
]),
77+
lastProgressUpdate: new Map([
78+
['socket-1', Date.now()]
79+
]),
80+
suspiciousPlayers: new Map([
81+
['socket-1', { locked: true }]
82+
])
83+
};
84+
85+
resetSocketRaceState('socket-1', stores);
86+
87+
expect(stores.playerProgress.has('socket-1')).toBe(false);
88+
expect(stores.lastProgressUpdate.has('socket-1')).toBe(false);
89+
expect(stores.suspiciousPlayers.has('socket-1')).toBe(false);
90+
});
91+
});

0 commit comments

Comments
 (0)