Skip to content

Commit f8647c5

Browse files
committed
Fix private session win tally ranking
1 parent 0b88aca commit f8647c5

File tree

3 files changed

+164
-30
lines changed

3 files changed

+164
-30
lines changed

client/src/context/RaceContext.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ export const RaceProvider = ({ children }) => {
288288
snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null,
289289
settings: data.settings || prev.settings, // Store settings from server
290290
players: data.players || [],
291-
sessionWins: data.sessionWins || prev.sessionWins || {}
291+
sessionWins: data.sessionWins || {}
292292
}));
293293
};
294294

@@ -422,7 +422,7 @@ export const RaceProvider = ({ children }) => {
422422
...prev,
423423
inProgress: false,
424424
completed: true,
425-
sessionWins: data.sessionWins || prev.sessionWins || {}
425+
sessionWins: data.sessionWins || {}
426426
};
427427
});
428428
};

server/controllers/socket-handlers.js

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,84 @@ const resetSocketRaceState = (
120120
stores.suspiciousPlayers.delete(socketId);
121121
};
122122

123+
const buildCompletedPlayerPlacement = (
124+
player,
125+
race,
126+
stores = {
127+
playerProgress,
128+
playerAvatars
129+
}
130+
) => {
131+
const progress = stores.playerProgress.get(player.id) || {};
132+
const finishTimestampMs = Number.isFinite(progress.timestamp) && Number.isFinite(race?.startTime)
133+
? Math.max(0, progress.timestamp - race.startTime)
134+
: null;
135+
136+
const completionTime = Number.isFinite(progress.completion_time)
137+
? progress.completion_time
138+
: (Number.isFinite(finishTimestampMs) ? finishTimestampMs / 1000 : null);
139+
140+
return {
141+
netid: player.netid,
142+
wpm: Number.isFinite(progress.wpm) ? progress.wpm : null,
143+
accuracy: Number.isFinite(progress.accuracy) ? progress.accuracy : null,
144+
completion_time: Number.isFinite(completionTime) ? completionTime : null,
145+
finishTimestampMs: Number.isFinite(finishTimestampMs) ? finishTimestampMs : null,
146+
avatar_url: stores.playerAvatars.get(player.id) || null
147+
};
148+
};
149+
150+
const compareCompletedPlayerPlacements = (a, b, isTimedTest = false) => {
151+
if (isTimedTest) {
152+
const aWpm = Number.isFinite(a.wpm) ? a.wpm : Number.NEGATIVE_INFINITY;
153+
const bWpm = Number.isFinite(b.wpm) ? b.wpm : Number.NEGATIVE_INFINITY;
154+
if (aWpm !== bWpm) {
155+
return bWpm - aWpm;
156+
}
157+
158+
const aAccuracy = Number.isFinite(a.accuracy) ? a.accuracy : Number.NEGATIVE_INFINITY;
159+
const bAccuracy = Number.isFinite(b.accuracy) ? b.accuracy : Number.NEGATIVE_INFINITY;
160+
if (aAccuracy !== bAccuracy) {
161+
return bAccuracy - aAccuracy;
162+
}
163+
}
164+
165+
const aTime = Number.isFinite(a.completion_time) ? a.completion_time : Number.POSITIVE_INFINITY;
166+
const bTime = Number.isFinite(b.completion_time) ? b.completion_time : Number.POSITIVE_INFINITY;
167+
if (aTime !== bTime) {
168+
return aTime - bTime;
169+
}
170+
171+
const aFinishTimestamp = Number.isFinite(a.finishTimestampMs) ? a.finishTimestampMs : Number.POSITIVE_INFINITY;
172+
const bFinishTimestamp = Number.isFinite(b.finishTimestampMs) ? b.finishTimestampMs : Number.POSITIVE_INFINITY;
173+
if (aFinishTimestamp !== bFinishTimestamp) {
174+
return aFinishTimestamp - bFinishTimestamp;
175+
}
176+
177+
return a.netid.localeCompare(b.netid);
178+
};
179+
180+
const getRankedCompletedPlayers = (
181+
players,
182+
race,
183+
stores = {
184+
playerProgress,
185+
playerAvatars
186+
}
187+
) => {
188+
const isTimedTest = Boolean(race?.snippet?.is_timed_test);
189+
190+
return (players || [])
191+
.filter(player => player.completed && stores.playerProgress.has(player.id))
192+
.map(player => buildCompletedPlayerPlacement(player, race, stores))
193+
.filter(result => (
194+
Number.isFinite(result.completion_time) ||
195+
Number.isFinite(result.finishTimestampMs) ||
196+
Number.isFinite(result.wpm)
197+
))
198+
.sort((a, b) => compareCompletedPlayerPlacements(a, b, isTimedTest));
199+
};
200+
123201
// Get player data for client, including avatar URL and basic stats
124202
const getPlayerClientData = async (player) => { // Make async
125203
// Use cached avatar if available, otherwise use null
@@ -1562,6 +1640,7 @@ const initialize = (io) => {
15621640
if (newLobby?.code) {
15631641
activeRaces.delete(newLobby.code);
15641642
racePlayers.delete(newLobby.code);
1643+
sessionWins.delete(newLobby.code);
15651644

15661645
for (const { socket: migratedSocket } of migratedPlayers) {
15671646
try {
@@ -2415,27 +2494,17 @@ const handlePlayerFinish = async (io, code, playerId, resultData) => {
24152494
});
24162495

24172496
// Collect all results from completed players
2418-
const allResults = players
2419-
.filter(p => p.completed && playerProgress.has(p.id))
2420-
.map(p => {
2421-
const prog = playerProgress.get(p.id);
2422-
const avatarUrl = playerAvatars.get(p.id);
2423-
2424-
// Log avatar status for debugging
2425-
console.log(`Player ${p.netid} avatar status:`, {
2426-
hasAvatar: !!avatarUrl,
2427-
avatarUrl: avatarUrl || 'null'
2428-
});
2429-
2430-
return {
2431-
netid: p.netid,
2432-
wpm: prog.wpm,
2433-
accuracy: prog.accuracy,
2434-
completion_time: prog.completion_time,
2435-
avatar_url: avatarUrl // Include avatar URL
2436-
};
2437-
})
2438-
.sort((a, b) => a.completion_time - b.completion_time); // Sort by time initially
2497+
const allResults = getRankedCompletedPlayers(players, race).map(result => {
2498+
const { finishTimestampMs, ...clientResult } = result;
2499+
2500+
// Log avatar status for debugging
2501+
console.log(`Player ${result.netid} avatar status:`, {
2502+
hasAvatar: !!result.avatar_url,
2503+
avatarUrl: result.avatar_url || 'null'
2504+
});
2505+
2506+
return clientResult;
2507+
});
24392508

24402509
// Broadcast updated results list
24412510
io.to(code).emit('race:resultsUpdate', { code, results: allResults });
@@ -2483,11 +2552,7 @@ const endRace = async (io, code) => {
24832552
// Update session win tally for private lobbies
24842553
if (race.type === 'private') {
24852554
const players = racePlayers.get(code) || [];
2486-
// Find the winner: completed player with fastest completion time
2487-
const completedPlayers = players
2488-
.filter(p => p.completed && playerProgress.has(p.id))
2489-
.map(p => ({ netid: p.netid, completion_time: playerProgress.get(p.id).completion_time }))
2490-
.sort((a, b) => a.completion_time - b.completion_time);
2555+
const completedPlayers = getRankedCompletedPlayers(players, race);
24912556
if (completedPlayers.length > 0) {
24922557
const winnerNetid = completedPlayers[0].netid;
24932558
const wins = sessionWins.get(code) || {};
@@ -2605,6 +2670,9 @@ module.exports = {
26052670
acquirePlayAgainLock,
26062671
releasePlayAgainLock,
26072672
clearLobbyTransientState,
2608-
resetSocketRaceState
2673+
resetSocketRaceState,
2674+
buildCompletedPlayerPlacement,
2675+
compareCompletedPlayerPlacements,
2676+
getRankedCompletedPlayers
26092677
}
26102678
};

server/tests/socket-handlers.test.js

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ const {
44
acquirePlayAgainLock,
55
releasePlayAgainLock,
66
clearLobbyTransientState,
7-
resetSocketRaceState
7+
resetSocketRaceState,
8+
buildCompletedPlayerPlacement,
9+
compareCompletedPlayerPlacements,
10+
getRankedCompletedPlayers
811
}
912
} = require('../controllers/socket-handlers');
1013

@@ -88,4 +91,67 @@ describe('socket-handlers play again helpers', () => {
8891
expect(stores.lastProgressUpdate.has('socket-1')).toBe(false);
8992
expect(stores.suspiciousPlayers.has('socket-1')).toBe(false);
9093
});
94+
95+
it('falls back to finish timestamps when completion_time is missing', () => {
96+
const placement = buildCompletedPlayerPlacement(
97+
{ id: 'socket-1', netid: 'alice' },
98+
{ startTime: 1000, snippet: { is_timed_test: false } },
99+
{
100+
playerProgress: new Map([
101+
['socket-1', { timestamp: 4600, wpm: 88, accuracy: 97 }]
102+
]),
103+
playerAvatars: new Map([
104+
['socket-1', 'avatar.png']
105+
])
106+
}
107+
);
108+
109+
expect(placement.completion_time).toBe(3.6);
110+
expect(placement.finishTimestampMs).toBe(3600);
111+
expect(placement.avatar_url).toBe('avatar.png');
112+
});
113+
114+
it('ranks timed races by wpm before shared duration', () => {
115+
const rankedPlayers = getRankedCompletedPlayers(
116+
[
117+
{ id: 'socket-1', netid: 'alice', completed: true },
118+
{ id: 'socket-2', netid: 'bob', completed: true }
119+
],
120+
{
121+
startTime: 1000,
122+
snippet: { is_timed_test: true }
123+
},
124+
{
125+
playerProgress: new Map([
126+
['socket-1', { timestamp: 16000, completion_time: 15, wpm: 90, accuracy: 96 }],
127+
['socket-2', { timestamp: 16000, completion_time: 15, wpm: 110, accuracy: 94 }]
128+
]),
129+
playerAvatars: new Map()
130+
}
131+
);
132+
133+
expect(rankedPlayers.map(player => player.netid)).toEqual(['bob', 'alice']);
134+
});
135+
136+
it('breaks timed ties by accuracy before finish order', () => {
137+
const comparison = compareCompletedPlayerPlacements(
138+
{
139+
netid: 'alice',
140+
wpm: 100,
141+
accuracy: 98,
142+
completion_time: 15,
143+
finishTimestampMs: 15000
144+
},
145+
{
146+
netid: 'bob',
147+
wpm: 100,
148+
accuracy: 95,
149+
completion_time: 15,
150+
finishTimestampMs: 14000
151+
},
152+
true
153+
);
154+
155+
expect(comparison).toBeLessThan(0);
156+
});
91157
});

0 commit comments

Comments
 (0)