@@ -35,6 +35,7 @@ const MAX_PROGRESS_STEP = 35; // max characters allowed per progress update (inc
3535const MIN_PROGRESS_INTERVAL = 25 ; // min ms between progress packets (unused, kept for reference)
3636const MAX_ALLOWED_WPM = 350 ; // anything above is flagged
3737const 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
4041const 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
53120const 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
24512565module . exports = {
2452- initialize
2566+ initialize,
2567+ __testables : {
2568+ normalizeLobbyCode,
2569+ acquirePlayAgainLock,
2570+ releasePlayAgainLock,
2571+ clearLobbyTransientState,
2572+ resetSocketRaceState
2573+ }
24532574} ;
0 commit comments