Skip to content

Commit 0b88aca

Browse files
DIodideclaude
authored andcommitted
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) <noreply@anthropic.com>
1 parent 537080e commit 0b88aca

File tree

9 files changed

+115
-12
lines changed

9 files changed

+115
-12
lines changed

client/src/components/PlayerStatusBar.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,19 @@
203203
display: block;
204204
}
205205

206+
.session-wins-badge {
207+
display: inline-block;
208+
font-size: 0.7rem;
209+
font-weight: 700;
210+
color: #FFD700;
211+
background: rgba(245, 128, 37, 0.15);
212+
border: 1px solid rgba(245, 128, 37, 0.3);
213+
border-radius: 10px;
214+
padding: 0.05rem 0.45rem;
215+
line-height: 1.3;
216+
white-space: nowrap;
217+
}
218+
206219
.player-name {
207220
font-weight: 600;
208221
color: var(--text-color);

client/src/components/PlayerStatusBar.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ function PlayerStatusBar({
1313
countdownActive = false,
1414
waitingForMinimumPlayers = false,
1515
readinessSummary = null,
16-
readinessDetail = null
16+
readinessDetail = null,
17+
sessionWins = null
1718
}) {
1819
const [enlargedAvatar, setEnlargedAvatar] = useState(null);
1920
const { authenticated, user } = useAuth();
@@ -232,6 +233,11 @@ function PlayerStatusBar({
232233
/>
233234
</div>
234235
<div className="player-text">
236+
{sessionWins && sessionWins[player.netid] > 0 && (
237+
<span className="session-wins-badge">
238+
{sessionWins[player.netid]} {sessionWins[player.netid] === 1 ? 'win' : 'wins'}
239+
</span>
240+
)}
235241
<span className="player-name">{player.netid}</span>
236242
{/* Determine the title to display */}
237243
{(() => {

client/src/components/Results.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,19 @@
210210
align-items: flex-start;
211211
}
212212

213+
.results-wins {
214+
display: inline-block;
215+
font-size: 0.75rem;
216+
font-weight: 700;
217+
color: #FFD700;
218+
background: rgba(245, 128, 37, 0.15);
219+
border: 1px solid rgba(245, 128, 37, 0.3);
220+
border-radius: 10px;
221+
padding: 0.1rem 0.55rem;
222+
margin-bottom: 0.35rem;
223+
white-space: nowrap;
224+
}
225+
213226
.winner-header {
214227
display: flex;
215228
align-items: center;

client/src/components/Results.jsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import ProfileModal from './ProfileModal.jsx';
1313
function Results({ onShowLeaderboard }) {
1414
const navigate = useNavigate();
1515
const { raceState, typingState, resetRace, joinPublicRace, playAgain } = useRace();
16+
const sessionWins = raceState.sessionWins || {};
1617
const { isRunning, endTutorial } = useTutorial();
1718
const { user } = useAuth();
1819
// State for profile modal
@@ -251,6 +252,11 @@ function Results({ onShowLeaderboard }) {
251252
/>
252253
</div>
253254
<div className="winner-details">
255+
{raceState.type === 'private' && sessionWins[winner.netid] > 0 && (
256+
<div className="session-wins-badge results-wins">
257+
{sessionWins[winner.netid]} {sessionWins[winner.netid] === 1 ? 'win' : 'wins'}
258+
</div>
259+
)}
254260
<div className="winner-header">
255261
<div className="winner-trophy"><i className="bi bi-trophy"></i></div>
256262
<div className="winner-netid">{winner.netid}</div>
@@ -302,6 +308,11 @@ function Results({ onShowLeaderboard }) {
302308
/>
303309
</div>
304310
<div className="result-text">
311+
{raceState.type === 'private' && sessionWins[result.netid] > 0 && (
312+
<span className="session-wins-badge results-wins">
313+
{sessionWins[result.netid]} {sessionWins[result.netid] === 1 ? 'win' : 'wins'}
314+
</span>
315+
)}
305316
<div className="result-netid">{result.netid}</div>
306317
{(() => {
307318
const titlesList = resultTitlesMap[result.netid] || [];

client/src/context/RaceContext.jsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export const RaceProvider = ({ children }) => {
9999
testDuration: 15,
100100
// Add other potential settings here
101101
},
102-
countdown: null // Track countdown seconds
102+
countdown: null, // Track countdown seconds
103+
sessionWins: {} // Win tally per player across play-again sessions { netid: count }
103104
});
104105

105106
// Explicit state for TestConfigurator to avoid passing setRaceState
@@ -286,7 +287,8 @@ export const RaceProvider = ({ children }) => {
286287
hostNetId: data.hostNetId || null, // Explicitly store hostNetId
287288
snippet: data.snippet ? { ...data.snippet, text: sanitizeSnippetText(data.snippet.text) } : null,
288289
settings: data.settings || prev.settings, // Store settings from server
289-
players: data.players || []
290+
players: data.players || [],
291+
sessionWins: data.sessionWins || prev.sessionWins || {}
290292
}));
291293
};
292294

@@ -419,7 +421,8 @@ export const RaceProvider = ({ children }) => {
419421
return {
420422
...prev,
421423
inProgress: false,
422-
completed: true
424+
completed: true,
425+
sessionWins: data.sessionWins || prev.sessionWins || {}
423426
};
424427
});
425428
};
@@ -600,7 +603,8 @@ export const RaceProvider = ({ children }) => {
600603
},
601604
snippetFilters: data.settings?.snippetFilters || { difficulty: 'all', type: 'all', department: 'all' },
602605
settings: data.settings || { testMode: 'snippet', testDuration: 15 },
603-
countdown: null
606+
countdown: null,
607+
sessionWins: data.sessionWins || {}
604608
});
605609
};
606610

@@ -1075,7 +1079,8 @@ export const RaceProvider = ({ children }) => {
10751079
testMode: 'snippet',
10761080
testDuration: 15,
10771081
},
1078-
countdown: null
1082+
countdown: null,
1083+
sessionWins: {}
10791084
});
10801085

10811086
setTypingState({
@@ -1088,7 +1093,7 @@ export const RaceProvider = ({ children }) => {
10881093
accuracy: 0,
10891094
lockedPosition: 0
10901095
});
1091-
1096+
10921097
// Clear race state from session storage
10931098
sessionStorage.removeItem('raceState');
10941099
};

client/src/pages/Lobby.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,19 @@
443443
gap: 1rem;
444444
}
445445

446+
.lobby-wins {
447+
display: inline-block;
448+
font-size: 0.7rem;
449+
font-weight: 700;
450+
color: #FFD700;
451+
background: rgba(245, 128, 37, 0.15);
452+
border: 1px solid rgba(245, 128, 37, 0.3);
453+
border-radius: 10px;
454+
padding: 0.1rem 0.5rem;
455+
margin: 0.4rem auto 0;
456+
white-space: nowrap;
457+
}
458+
446459
.lobby-page .player-card {
447460
background: linear-gradient(135deg, var(--container-color) 0%, rgba(245, 128, 37, 0.05) 100%);
448461
border-radius: 10px;

client/src/pages/Lobby.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@ function Lobby() {
310310
<div className="player-grid">
311311
{raceState.players?.map(player => (
312312
<div key={player.netid} className="player-card">
313+
{raceState.sessionWins?.[player.netid] > 0 && (
314+
<span className="session-wins-badge lobby-wins">
315+
{raceState.sessionWins[player.netid]} {raceState.sessionWins[player.netid] === 1 ? 'win' : 'wins'}
316+
</span>
317+
)}
313318
<ProfileWidget
314319
// Pass user object including avg_wpm fetched from server
315320
user={{ netid: player.netid, avatar_url: player.avatar_url, avg_wpm: player.avg_wpm }}

client/src/pages/Race.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ function Race() {
212212
players={players}
213213
isRaceInProgress={raceState.inProgress}
214214
currentUser={window.user}
215+
sessionWins={raceState.type === 'private' ? raceState.sessionWins : null}
215216
onReadyClick={setPlayerReady}
216217
countdownActive={countdownActive}
217218
waitingForMinimumPlayers={shouldShowLobbyStatus && waitingForMinimumPlayers}

server/controllers/socket-handlers.js

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const MAX_ALLOWED_WPM = 350; // anything above is flagged
3737
const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this
3838
const playAgainTransitions = new Set(); // lobbyCode -> transition in progress
3939

40+
// Store session win tallies for private lobbies across play-again cycles
41+
// lobbyCode -> { netid: winCount }
42+
const sessionWins = new Map();
43+
4044
// Store host disconnect timers for private lobbies
4145
const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds
4246
const hostDisconnectTimers = new Map(); // lobbyCode -> { timer: NodeJS.Timeout, userId: number }
@@ -237,6 +241,7 @@ const forceDisconnectExistingSessions = async (io, newSocket, userIdToDisconnect
237241
console.log(`Lobby ${code} empty after forced disconnect. Cleaning up.`);
238242
racePlayers.delete(code);
239243
activeRaces.delete(code);
244+
sessionWins.delete(code);
240245
// Attempt to terminate private lobbies in DB
241246
if (race && race.type === 'private') {
242247
try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ }
@@ -333,6 +338,7 @@ const leaveCurrentRace = async (io, socket, netid) => {
333338
if (players.length === 0) {
334339
racePlayers.delete(code);
335340
activeRaces.delete(code);
341+
sessionWins.delete(code);
336342
console.log(`Cleaned up empty race ${code}`);
337343
} else {
338344
racePlayers.set(code, players);
@@ -871,6 +877,9 @@ const initialize = (io) => {
871877
}
872878
});
873879

880+
// Initialize session win tally for new private lobby
881+
sessionWins.set(lobby.code, {});
882+
874883
// Fetch avatar for the host
875884
await fetchUserAvatar(userId, socket.id);
876885

@@ -883,7 +892,8 @@ const initialize = (io) => {
883892
hostNetId: netid, // Include host netid
884893
snippet: activeRaces.get(lobby.code).snippet,
885894
settings: activeRaces.get(lobby.code).settings,
886-
players: [hostClientDataCreate] // Use renamed variable
895+
players: [hostClientDataCreate], // Use renamed variable
896+
sessionWins: {}
887897
};
888898
socket.emit('race:joined', joinedDataCreate); // Use renamed variable
889899

@@ -1061,7 +1071,8 @@ const initialize = (io) => {
10611071
hostNetId: raceInfo.hostNetId,
10621072
snippet: raceInfo.snippet,
10631073
settings: raceInfo.settings,
1064-
players: currentPlayersClientDataJoin // Use resolved data
1074+
players: currentPlayersClientDataJoin, // Use resolved data
1075+
sessionWins: sessionWins.get(lobby.code) || {}
10651076
};
10661077
socket.emit('race:joined', joinedDataJoin); // Use renamed variable
10671078

@@ -1518,14 +1529,19 @@ const initialize = (io) => {
15181529
// Build client data for all players
15191530
const playersClientData = await Promise.all(newPlayers.map(p => getPlayerClientData(p)));
15201531

1532+
// Carry session wins forward to the new lobby
1533+
const prevWins = sessionWins.get(oldCode) || {};
1534+
sessionWins.set(newLobby.code, { ...prevWins });
1535+
15211536
const joinedData = {
15221537
code: newLobby.code,
15231538
type: 'private',
15241539
lobbyId: newLobby.id,
15251540
hostNetId: hostNetid,
15261541
snippet: newRaceInfo.snippet,
15271542
settings: newRaceInfo.settings,
1528-
players: playersClientData
1543+
players: playersClientData,
1544+
sessionWins: { ...prevWins }
15291545
};
15301546

15311547
// Notify migrated players directly so the room join can't race the event
@@ -1537,6 +1553,7 @@ const initialize = (io) => {
15371553
clearLobbyTransientState(oldCode);
15381554
activeRaces.delete(oldCode);
15391555
racePlayers.delete(oldCode);
1556+
sessionWins.delete(oldCode);
15401557

15411558
console.log(`Play again: migrated ${newPlayers.length} players from ${oldCode} to ${newLobby.code}`);
15421559
if (callback) callback({ success: true, lobby: joinedData });
@@ -2022,6 +2039,7 @@ const initialize = (io) => {
20222039
activeRaces.delete(code);
20232040
}
20242041
racePlayers.delete(code); // Ensure players map is cleared
2042+
sessionWins.delete(code);
20252043
return; // Exit timer callback
20262044
}
20272045

@@ -2105,6 +2123,7 @@ const initialize = (io) => {
21052123
console.log(`No players left in race ${code}, cleaning up`);
21062124
racePlayers.delete(code);
21072125
activeRaces.delete(code);
2126+
sessionWins.delete(code);
21082127
if (race && race.type === 'private') {
21092128
try { await RaceModel.softTerminate(race.id); } catch(e) { /* ignore */ }
21102129
}
@@ -2461,8 +2480,25 @@ const endRace = async (io, code) => {
24612480
console.error(`Error getting final results for race ${code}:`, dbErr);
24622481
}
24632482

2464-
// Broadcast race end signal (without results payload)
2465-
io.to(code).emit('race:end', { code });
2483+
// Update session win tally for private lobbies
2484+
if (race.type === 'private') {
2485+
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);
2491+
if (completedPlayers.length > 0) {
2492+
const winnerNetid = completedPlayers[0].netid;
2493+
const wins = sessionWins.get(code) || {};
2494+
wins[winnerNetid] = (wins[winnerNetid] || 0) + 1;
2495+
sessionWins.set(code, wins);
2496+
console.log(`Session wins for ${code}:`, wins);
2497+
}
2498+
}
2499+
2500+
// Broadcast race end signal with session wins
2501+
io.to(code).emit('race:end', { code, sessionWins: sessionWins.get(code) || {} });
24662502
console.log(`Broadcasted race end signal for ${code}`);
24672503

24682504
} catch (err) {

0 commit comments

Comments
 (0)