From 0b88aca9c4bf7e7beb1ea9a250baaaf4f1ef436d Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 6 Apr 2026 00:45:40 -0400 Subject: [PATCH 1/4] Add session win tally for private match play-again sessions Track and display a per-player win counter across play-again cycles in private matches. The winner of each race (fastest completion time) gets their count incremented. The tally is shown as a gold badge above player names in the lobby, race status bar, and results screen. Wins carry over through play-again and reset when leaving the session. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/PlayerStatusBar.css | 13 +++++++ client/src/components/PlayerStatusBar.jsx | 8 +++- client/src/components/Results.css | 13 +++++++ client/src/components/Results.jsx | 11 ++++++ client/src/context/RaceContext.jsx | 17 ++++++--- client/src/pages/Lobby.css | 13 +++++++ client/src/pages/Lobby.jsx | 5 +++ client/src/pages/Race.jsx | 1 + server/controllers/socket-handlers.js | 46 ++++++++++++++++++++--- 9 files changed, 115 insertions(+), 12 deletions(-) diff --git a/client/src/components/PlayerStatusBar.css b/client/src/components/PlayerStatusBar.css index 0526ad86..1852a193 100644 --- a/client/src/components/PlayerStatusBar.css +++ b/client/src/components/PlayerStatusBar.css @@ -203,6 +203,19 @@ display: block; } +.session-wins-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + color: #FFD700; + background: rgba(245, 128, 37, 0.15); + border: 1px solid rgba(245, 128, 37, 0.3); + border-radius: 10px; + padding: 0.05rem 0.45rem; + line-height: 1.3; + white-space: nowrap; +} + .player-name { font-weight: 600; color: var(--text-color); diff --git a/client/src/components/PlayerStatusBar.jsx b/client/src/components/PlayerStatusBar.jsx index 032f640a..313228f2 100644 --- a/client/src/components/PlayerStatusBar.jsx +++ b/client/src/components/PlayerStatusBar.jsx @@ -13,7 +13,8 @@ function PlayerStatusBar({ countdownActive = false, waitingForMinimumPlayers = false, readinessSummary = null, - readinessDetail = null + readinessDetail = null, + sessionWins = null }) { const [enlargedAvatar, setEnlargedAvatar] = useState(null); const { authenticated, user } = useAuth(); @@ -232,6 +233,11 @@ function PlayerStatusBar({ />
+ {sessionWins && sessionWins[player.netid] > 0 && ( + + {sessionWins[player.netid]} {sessionWins[player.netid] === 1 ? 'win' : 'wins'} + + )} {player.netid} {/* Determine the title to display */} {(() => { diff --git a/client/src/components/Results.css b/client/src/components/Results.css index a7e457ca..b729fb10 100644 --- a/client/src/components/Results.css +++ b/client/src/components/Results.css @@ -210,6 +210,19 @@ align-items: flex-start; } +.results-wins { + display: inline-block; + font-size: 0.75rem; + font-weight: 700; + color: #FFD700; + background: rgba(245, 128, 37, 0.15); + border: 1px solid rgba(245, 128, 37, 0.3); + border-radius: 10px; + padding: 0.1rem 0.55rem; + margin-bottom: 0.35rem; + white-space: nowrap; +} + .winner-header { display: flex; align-items: center; diff --git a/client/src/components/Results.jsx b/client/src/components/Results.jsx index b90a2e7e..c4b33ec1 100644 --- a/client/src/components/Results.jsx +++ b/client/src/components/Results.jsx @@ -13,6 +13,7 @@ import ProfileModal from './ProfileModal.jsx'; function Results({ onShowLeaderboard }) { const navigate = useNavigate(); const { raceState, typingState, resetRace, joinPublicRace, playAgain } = useRace(); + const sessionWins = raceState.sessionWins || {}; const { isRunning, endTutorial } = useTutorial(); const { user } = useAuth(); // State for profile modal @@ -251,6 +252,11 @@ function Results({ onShowLeaderboard }) { />
+ {raceState.type === 'private' && sessionWins[winner.netid] > 0 && ( +
+ {sessionWins[winner.netid]} {sessionWins[winner.netid] === 1 ? 'win' : 'wins'} +
+ )}
{winner.netid}
@@ -302,6 +308,11 @@ function Results({ onShowLeaderboard }) { />
+ {raceState.type === 'private' && sessionWins[result.netid] > 0 && ( + + {sessionWins[result.netid]} {sessionWins[result.netid] === 1 ? 'win' : 'wins'} + + )}
{result.netid}
{(() => { const titlesList = resultTitlesMap[result.netid] || []; diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index 62d15e57..57dc95dd 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -99,7 +99,8 @@ export const RaceProvider = ({ children }) => { testDuration: 15, // Add other potential settings here }, - countdown: null // Track countdown seconds + countdown: null, // Track countdown seconds + sessionWins: {} // Win tally per player across play-again sessions { netid: count } }); // Explicit state for TestConfigurator to avoid passing setRaceState @@ -286,7 +287,8 @@ export const RaceProvider = ({ children }) => { hostNetId: data.hostNetId || null, // Explicitly store hostNetId snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null, settings: data.settings || prev.settings, // Store settings from server - players: data.players || [] + players: data.players || [], + sessionWins: data.sessionWins || prev.sessionWins || {} })); }; @@ -419,7 +421,8 @@ export const RaceProvider = ({ children }) => { return { ...prev, inProgress: false, - completed: true + completed: true, + sessionWins: data.sessionWins || prev.sessionWins || {} }; }); }; @@ -600,7 +603,8 @@ export const RaceProvider = ({ children }) => { }, snippetFilters: data.settings?.snippetFilters || { difficulty: 'all', type: 'all', department: 'all' }, settings: data.settings || { testMode: 'snippet', testDuration: 15 }, - countdown: null + countdown: null, + sessionWins: data.sessionWins || {} }); }; @@ -1075,7 +1079,8 @@ export const RaceProvider = ({ children }) => { testMode: 'snippet', testDuration: 15, }, - countdown: null + countdown: null, + sessionWins: {} }); setTypingState({ @@ -1088,7 +1093,7 @@ export const RaceProvider = ({ children }) => { accuracy: 0, lockedPosition: 0 }); - + // Clear race state from session storage sessionStorage.removeItem('raceState'); }; diff --git a/client/src/pages/Lobby.css b/client/src/pages/Lobby.css index ca5fed61..3a6881c8 100644 --- a/client/src/pages/Lobby.css +++ b/client/src/pages/Lobby.css @@ -443,6 +443,19 @@ gap: 1rem; } +.lobby-wins { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + color: #FFD700; + background: rgba(245, 128, 37, 0.15); + border: 1px solid rgba(245, 128, 37, 0.3); + border-radius: 10px; + padding: 0.1rem 0.5rem; + margin: 0.4rem auto 0; + white-space: nowrap; +} + .lobby-page .player-card { background: linear-gradient(135deg, var(--container-color) 0%, rgba(245, 128, 37, 0.05) 100%); border-radius: 10px; diff --git a/client/src/pages/Lobby.jsx b/client/src/pages/Lobby.jsx index 16ba9d5e..e17837a0 100644 --- a/client/src/pages/Lobby.jsx +++ b/client/src/pages/Lobby.jsx @@ -310,6 +310,11 @@ function Lobby() {
{raceState.players?.map(player => (
+ {raceState.sessionWins?.[player.netid] > 0 && ( + + {raceState.sessionWins[player.netid]} {raceState.sessionWins[player.netid] === 1 ? 'win' : 'wins'} + + )} transition in progress +// Store session win tallies for private lobbies across play-again cycles +// lobbyCode -> { netid: winCount } +const sessionWins = new Map(); + // Store host disconnect timers for private lobbies const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds const hostDisconnectTimers = new Map(); // lobbyCode -> { timer: NodeJS.Timeout, userId: number } @@ -237,6 +241,7 @@ const forceDisconnectExistingSessions = async (io, newSocket, userIdToDisconnect console.log(`Lobby ${code} empty after forced disconnect. Cleaning up.`); racePlayers.delete(code); activeRaces.delete(code); + sessionWins.delete(code); // Attempt to terminate private lobbies in DB if (race && race.type === 'private') { try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ } @@ -333,6 +338,7 @@ const leaveCurrentRace = async (io, socket, netid) => { if (players.length === 0) { racePlayers.delete(code); activeRaces.delete(code); + sessionWins.delete(code); console.log(`Cleaned up empty race ${code}`); } else { racePlayers.set(code, players); @@ -871,6 +877,9 @@ const initialize = (io) => { } }); + // Initialize session win tally for new private lobby + sessionWins.set(lobby.code, {}); + // Fetch avatar for the host await fetchUserAvatar(userId, socket.id); @@ -883,7 +892,8 @@ const initialize = (io) => { hostNetId: netid, // Include host netid snippet: activeRaces.get(lobby.code).snippet, settings: activeRaces.get(lobby.code).settings, - players: [hostClientDataCreate] // Use renamed variable + players: [hostClientDataCreate], // Use renamed variable + sessionWins: {} }; socket.emit('race:joined', joinedDataCreate); // Use renamed variable @@ -1061,7 +1071,8 @@ const initialize = (io) => { hostNetId: raceInfo.hostNetId, snippet: raceInfo.snippet, settings: raceInfo.settings, - players: currentPlayersClientDataJoin // Use resolved data + players: currentPlayersClientDataJoin, // Use resolved data + sessionWins: sessionWins.get(lobby.code) || {} }; socket.emit('race:joined', joinedDataJoin); // Use renamed variable @@ -1518,6 +1529,10 @@ const initialize = (io) => { // Build client data for all players const playersClientData = await Promise.all(newPlayers.map(p => getPlayerClientData(p))); + // Carry session wins forward to the new lobby + const prevWins = sessionWins.get(oldCode) || {}; + sessionWins.set(newLobby.code, { ...prevWins }); + const joinedData = { code: newLobby.code, type: 'private', @@ -1525,7 +1540,8 @@ const initialize = (io) => { hostNetId: hostNetid, snippet: newRaceInfo.snippet, settings: newRaceInfo.settings, - players: playersClientData + players: playersClientData, + sessionWins: { ...prevWins } }; // Notify migrated players directly so the room join can't race the event @@ -1537,6 +1553,7 @@ const initialize = (io) => { clearLobbyTransientState(oldCode); activeRaces.delete(oldCode); racePlayers.delete(oldCode); + sessionWins.delete(oldCode); console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`); if (callback) callback({ success: true, lobby: joinedData }); @@ -2022,6 +2039,7 @@ const initialize = (io) => { activeRaces.delete(code); } racePlayers.delete(code); // Ensure players map is cleared + sessionWins.delete(code); return; // Exit timer callback } @@ -2105,6 +2123,7 @@ const initialize = (io) => { console.log(`No players left in race ${code}, cleaning up`); racePlayers.delete(code); activeRaces.delete(code); + sessionWins.delete(code); if (race && race.type === 'private') { try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ } } @@ -2461,8 +2480,25 @@ const endRace = async (io, code) => { console.error(`Error getting final results for race ${code}:`, dbErr); } - // Broadcast race end signal (without results payload) - io.to(code).emit('race:end', { code }); + // Update session win tally for private lobbies + if (race.type === 'private') { + const players = racePlayers.get(code) || []; + // Find the winner: completed player with fastest completion time + const completedPlayers = players + .filter(p => p.completed && playerProgress.has(p.id)) + .map(p => ({ netid: p.netid, completion_time: playerProgress.get(p.id).completion_time })) + .sort((a, b) => a.completion_time - b.completion_time); + if (completedPlayers.length > 0) { + const winnerNetid = completedPlayers[0].netid; + const wins = sessionWins.get(code) || {}; + wins[winnerNetid] = (wins[winnerNetid] || 0) + 1; + sessionWins.set(code, wins); + console.log(`Session wins for ${code}:`, wins); + } + } + + // Broadcast race end signal with session wins + io.to(code).emit('race:end', { code, sessionWins: sessionWins.get(code) || {} }); console.log(`Broadcasted race end signal for ${code}`); } catch (err) { From f8647c5fb001d62aa7a7c610085d4fc78d805278 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Mon, 6 Apr 2026 01:12:32 -0400 Subject: [PATCH 2/4] Fix private session win tally ranking --- client/src/context/RaceContext.jsx | 4 +- server/controllers/socket-handlers.js | 122 ++++++++++++++++++++------ server/tests/socket-handlers.test.js | 68 +++++++++++++- 3 files changed, 164 insertions(+), 30 deletions(-) diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index 57dc95dd..81d8c700 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -288,7 +288,7 @@ export const RaceProvider = ({ children }) => { snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null, settings: data.settings || prev.settings, // Store settings from server players: data.players || [], - sessionWins: data.sessionWins || prev.sessionWins || {} + sessionWins: data.sessionWins || {} })); }; @@ -422,7 +422,7 @@ export const RaceProvider = ({ children }) => { ...prev, inProgress: false, completed: true, - sessionWins: data.sessionWins || prev.sessionWins || {} + sessionWins: data.sessionWins || {} }; }); }; diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 144cf521..bc17597f 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -120,6 +120,84 @@ const resetSocketRaceState = ( stores.suspiciousPlayers.delete(socketId); }; +const buildCompletedPlayerPlacement = ( + player, + race, + stores = { + playerProgress, + playerAvatars + } +) => { + const progress = stores.playerProgress.get(player.id) || {}; + const finishTimestampMs = Number.isFinite(progress.timestamp) && Number.isFinite(race?.startTime) + ? Math.max(0, progress.timestamp - race.startTime) + : null; + + const completionTime = Number.isFinite(progress.completion_time) + ? progress.completion_time + : (Number.isFinite(finishTimestampMs) ? finishTimestampMs / 1000 : null); + + return { + netid: player.netid, + wpm: Number.isFinite(progress.wpm) ? progress.wpm : null, + accuracy: Number.isFinite(progress.accuracy) ? progress.accuracy : null, + completion_time: Number.isFinite(completionTime) ? completionTime : null, + finishTimestampMs: Number.isFinite(finishTimestampMs) ? finishTimestampMs : null, + avatar_url: stores.playerAvatars.get(player.id) || null + }; +}; + +const compareCompletedPlayerPlacements = (a, b, isTimedTest = false) => { + if (isTimedTest) { + const aWpm = Number.isFinite(a.wpm) ? a.wpm : Number.NEGATIVE_INFINITY; + const bWpm = Number.isFinite(b.wpm) ? b.wpm : Number.NEGATIVE_INFINITY; + if (aWpm !== bWpm) { + return bWpm - aWpm; + } + + const aAccuracy = Number.isFinite(a.accuracy) ? a.accuracy : Number.NEGATIVE_INFINITY; + const bAccuracy = Number.isFinite(b.accuracy) ? b.accuracy : Number.NEGATIVE_INFINITY; + if (aAccuracy !== bAccuracy) { + return bAccuracy - aAccuracy; + } + } + + const aTime = Number.isFinite(a.completion_time) ? a.completion_time : Number.POSITIVE_INFINITY; + const bTime = Number.isFinite(b.completion_time) ? b.completion_time : Number.POSITIVE_INFINITY; + if (aTime !== bTime) { + return aTime - bTime; + } + + const aFinishTimestamp = Number.isFinite(a.finishTimestampMs) ? a.finishTimestampMs : Number.POSITIVE_INFINITY; + const bFinishTimestamp = Number.isFinite(b.finishTimestampMs) ? b.finishTimestampMs : Number.POSITIVE_INFINITY; + if (aFinishTimestamp !== bFinishTimestamp) { + return aFinishTimestamp - bFinishTimestamp; + } + + return a.netid.localeCompare(b.netid); +}; + +const getRankedCompletedPlayers = ( + players, + race, + stores = { + playerProgress, + playerAvatars + } +) => { + const isTimedTest = Boolean(race?.snippet?.is_timed_test); + + return (players || []) + .filter(player => player.completed && stores.playerProgress.has(player.id)) + .map(player => buildCompletedPlayerPlacement(player, race, stores)) + .filter(result => ( + Number.isFinite(result.completion_time) || + Number.isFinite(result.finishTimestampMs) || + Number.isFinite(result.wpm) + )) + .sort((a, b) => compareCompletedPlayerPlacements(a, b, isTimedTest)); +}; + // 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 @@ -1562,6 +1640,7 @@ const initialize = (io) => { if (newLobby?.code) { activeRaces.delete(newLobby.code); racePlayers.delete(newLobby.code); + sessionWins.delete(newLobby.code); for (const { socket: migratedSocket } of migratedPlayers) { try { @@ -2415,27 +2494,17 @@ const handlePlayerFinish = async (io, code, playerId, resultData) => { }); // Collect all results from completed players - const allResults = players - .filter(p => p.completed && playerProgress.has(p.id)) - .map(p => { - const prog = playerProgress.get(p.id); - const avatarUrl = playerAvatars.get(p.id); - - // Log avatar status for debugging - console.log(`Player ${p.netid} avatar status:`, { - hasAvatar: !!avatarUrl, - avatarUrl: avatarUrl || 'null' - }); - - return { - netid: p.netid, - wpm: prog.wpm, - accuracy: prog.accuracy, - completion_time: prog.completion_time, - avatar_url: avatarUrl // Include avatar URL - }; - }) - .sort((a, b) => a.completion_time - b.completion_time); // Sort by time initially + const allResults = getRankedCompletedPlayers(players, race).map(result => { + const { finishTimestampMs, ...clientResult } = result; + + // Log avatar status for debugging + console.log(`Player ${result.netid} avatar status:`, { + hasAvatar: !!result.avatar_url, + avatarUrl: result.avatar_url || 'null' + }); + + return clientResult; + }); // Broadcast updated results list io.to(code).emit('race:resultsUpdate', { code, results: allResults }); @@ -2483,11 +2552,7 @@ const endRace = async (io, code) => { // Update session win tally for private lobbies if (race.type === 'private') { const players = racePlayers.get(code) || []; - // Find the winner: completed player with fastest completion time - const completedPlayers = players - .filter(p => p.completed && playerProgress.has(p.id)) - .map(p => ({ netid: p.netid, completion_time: playerProgress.get(p.id).completion_time })) - .sort((a, b) => a.completion_time - b.completion_time); + const completedPlayers = getRankedCompletedPlayers(players, race); if (completedPlayers.length > 0) { const winnerNetid = completedPlayers[0].netid; const wins = sessionWins.get(code) || {}; @@ -2605,6 +2670,9 @@ module.exports = { acquirePlayAgainLock, releasePlayAgainLock, clearLobbyTransientState, - resetSocketRaceState + resetSocketRaceState, + buildCompletedPlayerPlacement, + compareCompletedPlayerPlacements, + getRankedCompletedPlayers } }; diff --git a/server/tests/socket-handlers.test.js b/server/tests/socket-handlers.test.js index 03d09eac..02459254 100644 --- a/server/tests/socket-handlers.test.js +++ b/server/tests/socket-handlers.test.js @@ -4,7 +4,10 @@ const { acquirePlayAgainLock, releasePlayAgainLock, clearLobbyTransientState, - resetSocketRaceState + resetSocketRaceState, + buildCompletedPlayerPlacement, + compareCompletedPlayerPlacements, + getRankedCompletedPlayers } } = require('../controllers/socket-handlers'); @@ -88,4 +91,67 @@ describe('socket-handlers play again helpers', () => { expect(stores.lastProgressUpdate.has('socket-1')).toBe(false); expect(stores.suspiciousPlayers.has('socket-1')).toBe(false); }); + + it('falls back to finish timestamps when completion_time is missing', () => { + const placement = buildCompletedPlayerPlacement( + { id: 'socket-1', netid: 'alice' }, + { startTime: 1000, snippet: { is_timed_test: false } }, + { + playerProgress: new Map([ + ['socket-1', { timestamp: 4600, wpm: 88, accuracy: 97 }] + ]), + playerAvatars: new Map([ + ['socket-1', 'avatar.png'] + ]) + } + ); + + expect(placement.completion_time).toBe(3.6); + expect(placement.finishTimestampMs).toBe(3600); + expect(placement.avatar_url).toBe('avatar.png'); + }); + + it('ranks timed races by wpm before shared duration', () => { + const rankedPlayers = getRankedCompletedPlayers( + [ + { id: 'socket-1', netid: 'alice', completed: true }, + { id: 'socket-2', netid: 'bob', completed: true } + ], + { + startTime: 1000, + snippet: { is_timed_test: true } + }, + { + playerProgress: new Map([ + ['socket-1', { timestamp: 16000, completion_time: 15, wpm: 90, accuracy: 96 }], + ['socket-2', { timestamp: 16000, completion_time: 15, wpm: 110, accuracy: 94 }] + ]), + playerAvatars: new Map() + } + ); + + expect(rankedPlayers.map(player => player.netid)).toEqual(['bob', 'alice']); + }); + + it('breaks timed ties by accuracy before finish order', () => { + const comparison = compareCompletedPlayerPlacements( + { + netid: 'alice', + wpm: 100, + accuracy: 98, + completion_time: 15, + finishTimestampMs: 15000 + }, + { + netid: 'bob', + wpm: 100, + accuracy: 95, + completion_time: 15, + finishTimestampMs: 14000 + }, + true + ); + + expect(comparison).toBeLessThan(0); + }); }); From df835fead007a608adadd8dfe8bee12c53f6701d Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Mon, 6 Apr 2026 01:22:54 -0400 Subject: [PATCH 3/4] Clear stale inactivity warning on ready --- client/src/context/RaceContext.jsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index 81d8c700..82aead34 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -293,6 +293,18 @@ export const RaceProvider = ({ children }) => { }; const handlePlayersUpdate = (data) => { + const currentUserReady = Boolean( + user?.netid && data.players?.some(player => player.netid === user.netid && player.ready) + ); + + if (currentUserReady) { + setInactivityState(prev => ( + prev.warning + ? { ...prev, warning: false, warningMessage: '' } + : prev + )); + } + setRaceState(prev => { // For quick-match public races, if the race is already in progress, we want to keep // any players previously marked as disconnected even if they are no longer in the @@ -360,6 +372,12 @@ export const RaceProvider = ({ children }) => { }); if (shouldResetTyping) { + setInactivityState(prev => ( + prev.warning + ? { ...prev, warning: false, warningMessage: '' } + : prev + )); + // Reset typing state setTypingState({ input: '', @@ -657,7 +675,7 @@ export const RaceProvider = ({ children }) => { socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener }; // Add raceState.snippet?.id to dependency array to reset typing state on snippet change - }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState]); + }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState, user?.netid]); // Methods for race actions const joinPracticeMode = () => { @@ -700,6 +718,11 @@ export const RaceProvider = ({ children }) => { const setPlayerReady = () => { if (!socket || !connected) return; + setInactivityState(prev => ( + prev.warning + ? { ...prev, warning: false, warningMessage: '' } + : prev + )); // console.log('Setting player ready...'); socket.emit('player:ready'); }; From 9c254958ef284aa3495f51166fc420e7f79b766e Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Mon, 6 Apr 2026 01:37:21 -0400 Subject: [PATCH 4/4] Harden session win review fixes --- client/src/context/RaceContext.jsx | 28 ++++----- server/controllers/socket-handlers.js | 81 +++++++++++++++++++++------ server/tests/socket-handlers.test.js | 78 +++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 35 deletions(-) diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index 82aead34..e91ae64b 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -197,6 +197,14 @@ export const RaceProvider = ({ children }) => { redirectToHome: false }); + const clearInactivityWarning = useCallback(() => { + setInactivityState(prev => ( + prev.warning + ? { ...prev, warning: false, warningMessage: '' } + : prev + )); + }, []); + // Update session storage when inactivity state changes useEffect(() => { saveInactivityState(inactivityState); @@ -298,11 +306,7 @@ export const RaceProvider = ({ children }) => { ); if (currentUserReady) { - setInactivityState(prev => ( - prev.warning - ? { ...prev, warning: false, warningMessage: '' } - : prev - )); + clearInactivityWarning(); } setRaceState(prev => { @@ -372,11 +376,7 @@ export const RaceProvider = ({ children }) => { }); if (shouldResetTyping) { - setInactivityState(prev => ( - prev.warning - ? { ...prev, warning: false, warningMessage: '' } - : prev - )); + clearInactivityWarning(); // Reset typing state setTypingState({ @@ -675,7 +675,7 @@ export const RaceProvider = ({ children }) => { socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener }; // Add raceState.snippet?.id to dependency array to reset typing state on snippet change - }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState, user?.netid]); + }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState, clearInactivityWarning, user?.netid]); // Methods for race actions const joinPracticeMode = () => { @@ -718,11 +718,7 @@ export const RaceProvider = ({ children }) => { const setPlayerReady = () => { if (!socket || !connected) return; - setInactivityState(prev => ( - prev.warning - ? { ...prev, warning: false, warningMessage: '' } - : prev - )); + clearInactivityWarning(); // console.log('Setting player ready...'); socket.emit('player:ready'); }; diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index bc17597f..a76e1781 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -120,6 +120,23 @@ const resetSocketRaceState = ( stores.suspiciousPlayers.delete(socketId); }; +const cloneSessionWins = (wins = null) => Object.assign(Object.create(null), wins || {}); + +const serializeSessionWins = (wins = null) => Object.fromEntries( + Object.entries(wins || Object.create(null)) +); + +const carrySessionWinsForward = (oldCode, newCode, winsStore = sessionWins) => { + const nextWins = cloneSessionWins(winsStore.get(oldCode)); + winsStore.set(newCode, nextWins); + return nextWins; +}; + +const clearLobbySessionWins = (code, winsStore = sessionWins) => { + if (!code) return; + winsStore.delete(code); +}; + const buildCompletedPlayerPlacement = ( player, race, @@ -198,6 +215,31 @@ const getRankedCompletedPlayers = ( .sort((a, b) => compareCompletedPlayerPlacements(a, b, isTimedTest)); }; +const updateSessionWinsForRace = ( + race, + players, + stores = { + playerProgress, + playerAvatars + }, + existingWins = null +) => { + const wins = cloneSessionWins(existingWins); + + if (race?.type !== 'private') { + return wins; + } + + const completedPlayers = getRankedCompletedPlayers(players, race, stores); + if (!completedPlayers.length) { + return wins; + } + + const winnerNetid = completedPlayers[0].netid; + wins[winnerNetid] = (wins[winnerNetid] || 0) + 1; + return wins; +}; + // 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 @@ -319,7 +361,7 @@ const forceDisconnectExistingSessions = async (io, newSocket, userIdToDisconnect console.log(`Lobby ${code} empty after forced disconnect. Cleaning up.`); racePlayers.delete(code); activeRaces.delete(code); - sessionWins.delete(code); + clearLobbySessionWins(code); // Attempt to terminate private lobbies in DB if (race && race.type === 'private') { try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ } @@ -416,7 +458,7 @@ const leaveCurrentRace = async (io, socket, netid) => { if (players.length === 0) { racePlayers.delete(code); activeRaces.delete(code); - sessionWins.delete(code); + clearLobbySessionWins(code); console.log(`Cleaned up empty race ${code}`); } else { racePlayers.set(code, players); @@ -956,7 +998,8 @@ const initialize = (io) => { }); // Initialize session win tally for new private lobby - sessionWins.set(lobby.code, {}); + const initialSessionWins = cloneSessionWins(); + sessionWins.set(lobby.code, initialSessionWins); // Fetch avatar for the host await fetchUserAvatar(userId, socket.id); @@ -971,7 +1014,7 @@ const initialize = (io) => { snippet: activeRaces.get(lobby.code).snippet, settings: activeRaces.get(lobby.code).settings, players: [hostClientDataCreate], // Use renamed variable - sessionWins: {} + sessionWins: serializeSessionWins(initialSessionWins) }; socket.emit('race:joined', joinedDataCreate); // Use renamed variable @@ -1150,7 +1193,7 @@ const initialize = (io) => { snippet: raceInfo.snippet, settings: raceInfo.settings, players: currentPlayersClientDataJoin, // Use resolved data - sessionWins: sessionWins.get(lobby.code) || {} + sessionWins: serializeSessionWins(sessionWins.get(lobby.code)) }; socket.emit('race:joined', joinedDataJoin); // Use renamed variable @@ -1608,8 +1651,7 @@ const initialize = (io) => { const playersClientData = await Promise.all(newPlayers.map(p => getPlayerClientData(p))); // Carry session wins forward to the new lobby - const prevWins = sessionWins.get(oldCode) || {}; - sessionWins.set(newLobby.code, { ...prevWins }); + const prevWins = carrySessionWinsForward(oldCode, newLobby.code); const joinedData = { code: newLobby.code, @@ -1619,7 +1661,7 @@ const initialize = (io) => { snippet: newRaceInfo.snippet, settings: newRaceInfo.settings, players: playersClientData, - sessionWins: { ...prevWins } + sessionWins: serializeSessionWins(prevWins) }; // Notify migrated players directly so the room join can't race the event @@ -1631,7 +1673,7 @@ const initialize = (io) => { clearLobbyTransientState(oldCode); activeRaces.delete(oldCode); racePlayers.delete(oldCode); - sessionWins.delete(oldCode); + clearLobbySessionWins(oldCode); console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`); if (callback) callback({ success: true, lobby: joinedData }); @@ -1640,7 +1682,7 @@ const initialize = (io) => { if (newLobby?.code) { activeRaces.delete(newLobby.code); racePlayers.delete(newLobby.code); - sessionWins.delete(newLobby.code); + clearLobbySessionWins(newLobby.code); for (const { socket: migratedSocket } of migratedPlayers) { try { @@ -2118,7 +2160,7 @@ const initialize = (io) => { activeRaces.delete(code); } racePlayers.delete(code); // Ensure players map is cleared - sessionWins.delete(code); + clearLobbySessionWins(code); return; // Exit timer callback } @@ -2202,7 +2244,7 @@ const initialize = (io) => { console.log(`No players left in race ${code}, cleaning up`); racePlayers.delete(code); activeRaces.delete(code); - sessionWins.delete(code); + clearLobbySessionWins(code); if (race && race.type === 'private') { try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ } } @@ -2554,16 +2596,14 @@ const endRace = async (io, code) => { const players = racePlayers.get(code) || []; const completedPlayers = getRankedCompletedPlayers(players, race); if (completedPlayers.length > 0) { - const winnerNetid = completedPlayers[0].netid; - const wins = sessionWins.get(code) || {}; - wins[winnerNetid] = (wins[winnerNetid] || 0) + 1; + const wins = updateSessionWinsForRace(race, players, undefined, sessionWins.get(code)); sessionWins.set(code, wins); - console.log(`Session wins for ${code}:`, wins); + console.log(`Session wins for ${code}:`, serializeSessionWins(wins)); } } // Broadcast race end signal with session wins - io.to(code).emit('race:end', { code, sessionWins: sessionWins.get(code) || {} }); + io.to(code).emit('race:end', { code, sessionWins: serializeSessionWins(sessionWins.get(code)) }); console.log(`Broadcasted race end signal for ${code}`); } catch (err) { @@ -2671,8 +2711,13 @@ module.exports = { releasePlayAgainLock, clearLobbyTransientState, resetSocketRaceState, + cloneSessionWins, + serializeSessionWins, + carrySessionWinsForward, + clearLobbySessionWins, buildCompletedPlayerPlacement, compareCompletedPlayerPlacements, - getRankedCompletedPlayers + getRankedCompletedPlayers, + updateSessionWinsForRace } }; diff --git a/server/tests/socket-handlers.test.js b/server/tests/socket-handlers.test.js index 02459254..50c241b4 100644 --- a/server/tests/socket-handlers.test.js +++ b/server/tests/socket-handlers.test.js @@ -5,9 +5,14 @@ const { releasePlayAgainLock, clearLobbyTransientState, resetSocketRaceState, + cloneSessionWins, + serializeSessionWins, + carrySessionWinsForward, + clearLobbySessionWins, buildCompletedPlayerPlacement, compareCompletedPlayerPlacements, - getRankedCompletedPlayers + getRankedCompletedPlayers, + updateSessionWinsForRace } } = require('../controllers/socket-handlers'); @@ -154,4 +159,75 @@ describe('socket-handlers play again helpers', () => { expect(comparison).toBeLessThan(0); }); + + it('increments the private-race winner in a null-prototype tally', () => { + const wins = updateSessionWinsForRace( + { + type: 'private', + startTime: 1000, + snippet: { is_timed_test: false } + }, + [ + { id: 'socket-1', netid: 'alice', completed: true }, + { id: 'socket-2', netid: 'bob', completed: true } + ], + { + playerProgress: new Map([ + ['socket-1', { completion_time: 11.2, timestamp: 12200 }], + ['socket-2', { completion_time: 13.8, timestamp: 14800 }] + ]), + playerAvatars: new Map() + }, + { alice: 1 } + ); + + expect(Object.getPrototypeOf(wins)).toBe(null); + expect(serializeSessionWins(wins)).toEqual({ alice: 2 }); + }); + + it('does not increment tallies for non-private races', () => { + const wins = updateSessionWinsForRace( + { + type: 'practice', + startTime: 1000, + snippet: { is_timed_test: false } + }, + [ + { id: 'socket-1', netid: 'alice', completed: true } + ], + { + playerProgress: new Map([ + ['socket-1', { completion_time: 11.2, timestamp: 12200 }] + ]), + playerAvatars: new Map() + }, + { alice: 1 } + ); + + expect(serializeSessionWins(wins)).toEqual({ alice: 1 }); + }); + + it('carries tallies forward without mutating the previous lobby', () => { + const winsStore = new Map([ + ['ROOM42', cloneSessionWins({ alice: 2 })] + ]); + + const nextWins = carrySessionWinsForward('ROOM42', 'ROOM43', winsStore); + nextWins.alice = 3; + + expect(serializeSessionWins(winsStore.get('ROOM42'))).toEqual({ alice: 2 }); + expect(serializeSessionWins(winsStore.get('ROOM43'))).toEqual({ alice: 3 }); + }); + + it('clears only the target lobby tally during cleanup', () => { + const winsStore = new Map([ + ['ROOM42', cloneSessionWins({ alice: 2 })], + ['ROOM99', cloneSessionWins({ bob: 1 })] + ]); + + clearLobbySessionWins('ROOM42', winsStore); + + expect(winsStore.has('ROOM42')).toBe(false); + expect(serializeSessionWins(winsStore.get('ROOM99'))).toEqual({ bob: 1 }); + }); });