From 33aa410c09ededeb1a5822920127d49a5388ff87 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 18:22:30 +0200 Subject: [PATCH] refactor: enhance session management and room joining logic in WebSocket handling --- public/client.js | 35 ++++++++++++++++-- public/js/audio.js | 10 ++++++ public/js/main.js | 24 ++++++++++--- public/js/render.js | 5 ++- public/js/ws.js | 18 ++++++++++ scripts/test-score.js | 14 ++++++++ src/server/game.js | 65 +++++++++++++++++++++++++++++++--- src/server/game/answerCheck.js | 46 ++++++++++++++++++++---- 8 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 scripts/test-score.js diff --git a/public/client.js b/public/client.js index 0bf1acf..8cd9688 100644 --- a/public/client.js +++ b/public/client.js @@ -4,6 +4,8 @@ let ws; let reconnectAttempts = 0; let reconnectTimer = null; const outbox = []; +let sessionId = localStorage.getItem('sessionId') || null; +let lastRoomId = localStorage.getItem('lastRoomId') || null; function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; } function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); @@ -23,6 +25,10 @@ function connectWS() { ws = new WebSocket(url); ws.addEventListener('open', () => { reconnectAttempts = 0; + // Try to resume session immediately + if (sessionId) { + try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {} + } // Flush queued messages setTimeout(() => { while (outbox.length && wsIsOpen()) { @@ -89,6 +95,7 @@ function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hi function renderRoom(room) { state.room = room; + try { if (room?.id) { lastRoomId = room.id; localStorage.setItem('lastRoomId', room.id); } } catch {} if (!room) { showLobby(); return; } showRoom(); $roomId.textContent = room.id; @@ -241,6 +248,10 @@ function handleMessage(ev) { const msg = JSON.parse(ev.data); if (msg.type === 'connected') { state.playerId = msg.playerId; + if (msg.sessionId && !sessionId) { + sessionId = msg.sessionId; + try { localStorage.setItem('sessionId', sessionId); } catch {} + } // Try to auto-apply stored name const stored = localStorage.getItem('playerName'); if (stored) { @@ -252,8 +263,28 @@ function handleMessage(ev) { } sendMsg({ type: 'set_name', name: stored }); } - // Try to rejoin room if known - if (state.room?.id) sendMsg({ type: 'join_room', code: state.room.id }); + // If we already have a sessionId, we'll resume instead of auto-joining here + if (!sessionId) { + const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); + if (code) sendMsg({ type: 'join_room', code }); + } + } + if (msg.type === 'resume_result') { + if (msg.ok) { + if (msg.playerId) state.playerId = msg.playerId; + // If we have a known room, ensure we rejoin it + if (msg.roomId) { + // Update local roomId (we will receive a room_update shortly) + sendMsg({ type: 'join_room', code: msg.roomId }); + } else { + const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); + if (code) sendMsg({ type: 'join_room', code }); + } + } else { + // If resume failed, try to rejoin by last room code + const code = state.room?.id || lastRoomId || localStorage.getItem('lastRoomId'); + if (code) sendMsg({ type: 'join_room', code }); + } } if (msg.type === 'room_update') { renderRoom(msg.room); diff --git a/public/js/audio.js b/public/js/audio.js index dfd4ff4..9ef3ce6 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -45,3 +45,13 @@ export function applySync(startAt, serverNow) { if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; } } } + +export function stopAudioPlayback() { + try { $audio.pause(); } catch {} + try { $audio.currentTime = 0; } catch {} + try { $audio.src = ''; } catch {} + try { $audio.playbackRate = 1.0; } catch {} + try { if ($recordDisc) $recordDisc.classList.remove('spin-record'); } catch {} + try { if ($progressFill) $progressFill.style.width = '0%'; } catch {} + try { if ($bufferBadge) $bufferBadge.classList.add('hidden'); } catch {} +} diff --git a/public/js/main.js b/public/js/main.js index a2b92d5..f1ba194 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,8 +1,8 @@ import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby, $guessTitle, $guessArtist, $answerResult } from './dom.js'; import { state } from './state.js'; -import { connectWS, sendMsg } from './ws.js'; +import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js'; import { renderRoom } from './render.js'; -import { initAudioUI, applySync } from './audio.js'; +import { initAudioUI, applySync, stopAudioPlayback } from './audio.js'; function showToast(msg) { const el = document.getElementById('toast'); @@ -18,6 +18,9 @@ function showToast(msg) { function handleConnected(msg) { state.playerId = msg.playerId; + if (msg.sessionId) { + cacheSessionId(msg.sessionId); + } const stored = localStorage.getItem('playerName'); if (stored) { if ($nameLobby && $nameLobby.value !== stored) { @@ -28,12 +31,14 @@ function handleConnected(msg) { } sendMsg({ type: 'set_name', name: stored }); } - if (state.room?.id) { - sendMsg({ type: 'join_room', code: state.room.id }); + const last = state.room?.id || localStorage.getItem('lastRoomId'); + if (last && !localStorage.getItem('sessionId')) { + sendMsg({ type: 'join_room', code: last }); } } function handleRoomUpdate(msg) { + if (msg?.room?.id) cacheLastRoomId(msg.room.id); renderRoom(msg.room); } @@ -134,6 +139,16 @@ function handleGameEnded(msg) { function onMessage(ev) { const msg = JSON.parse(ev.data); switch (msg.type) { + case 'resume_result': + if (msg.ok) { + if (msg.playerId) state.playerId = msg.playerId; + const code = msg.roomId || state.room?.id || localStorage.getItem('lastRoomId'); + if (code) sendMsg({ type: 'join_room', code }); + } else { + const code = state.room?.id || localStorage.getItem('lastRoomId'); + if (code) sendMsg({ type: 'join_room', code }); + } + break; case 'connected': handleConnected(msg); break; @@ -211,6 +226,7 @@ function wireUi() { }); wire($leaveRoom, 'click', () => { sendMsg({ type: 'leave_room' }); + stopAudioPlayback(); state.room = null; $lobby.classList.remove('hidden'); $room.classList.add('hidden'); diff --git a/public/js/render.js b/public/js/render.js index c83f2e3..b0a95ab 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -1,9 +1,10 @@ import { state } from './state.js'; import { badgeColorForYear } from '../utils/colors.js'; -import { $answerForm, $answerResult, $dashboardList, $guesser, $lobby, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js'; +import { $answerForm, $answerResult, $dashboardList, $guesser, $lobby, $mediaControls, $nameDisplay, $nextArea, $np, $placeArea, $readyChk, $revealBanner, $room, $roomId, $slotSelect, $startGame, $status, $timeline, $tokens } from './dom.js'; export function renderRoom(room) { state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; } + try { localStorage.setItem('lastRoomId', room.id); } catch {} $lobby.classList.add('hidden'); $room.classList.remove('hidden'); $roomId.textContent = room.id; $status.textContent = room.state.status; @@ -59,6 +60,8 @@ export function renderRoom(room) { if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady)); const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack; const canGuess = isMyTurn; + // Media controls (play/pause) only for current guesser while guessing and a track is active + if ($mediaControls) $mediaControls.classList.toggle('hidden', !isMyTurn); // Build slot options for insertion positions when it's my turn if ($placeArea && $slotSelect) { if (canGuess) { diff --git a/public/js/ws.js b/public/js/ws.js index e813fd1..2402e4a 100644 --- a/public/js/ws.js +++ b/public/js/ws.js @@ -4,6 +4,8 @@ let ws; let reconnectAttempts = 0; let reconnectTimer = null; const outbox = []; +let sessionId = localStorage.getItem('sessionId') || null; +let lastRoomId = localStorage.getItem('lastRoomId') || null; export function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; } export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); } @@ -20,6 +22,10 @@ export function connectWS(onMessage) { ws = new WebSocket(url); ws.addEventListener('open', () => { reconnectAttempts = 0; + // Try to resume session immediately on (re)connect + if (sessionId) { + try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {} + } setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100); }); ws.addEventListener('message', (ev) => onMessage(ev)); @@ -33,3 +39,15 @@ window.addEventListener('online', () => { // Kick off a reconnect by calling connectWS from app main again } }); + +// Helpers to update cached ids from other modules +export function cacheSessionId(id) { + if (!id) return; + sessionId = id; + try { localStorage.setItem('sessionId', id); } catch {} +} +export function cacheLastRoomId(id) { + if (!id) return; + lastRoomId = id; + try { localStorage.setItem('lastRoomId', id); } catch {} +} diff --git a/scripts/test-score.js b/scripts/test-score.js new file mode 100644 index 0000000..4350b82 --- /dev/null +++ b/scripts/test-score.js @@ -0,0 +1,14 @@ +import { scoreTitle } from '../src/server/game/answerCheck.js'; + +const cases = [ + ['Why', '"Why"'], + ['World Hold On', 'World Hold on (Children of the Sky) [Radio Edit]'], + ['Respect', 'Respect (2019 Remaster)'], + ['No Woman No Cry', 'No Woman, No Cry (Live)'], + ['It\'s My Life', 'It\'s My Life (Single Version)'], +]; + +for (const [guess, truth] of cases) { + const r = scoreTitle(guess, truth); + console.log(JSON.stringify({ guess, truth, pass: r.pass, sim: +r.sim.toFixed(3), jac: +r.jac.toFixed(3), g: r.g, t: r.t })); +} diff --git a/src/server/game.js b/src/server/game.js index ae18ecc..cf44918 100644 --- a/src/server/game.js +++ b/src/server/game.js @@ -20,14 +20,51 @@ function drawNextTrack(room) { export function setupWebSocket(server) { const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { - const id = uuidv4(); - const player = { id, name: `Player-${id.slice(0, 4)}`, ws, connected: true, roomId: null }; + const id = uuidv4(); + const sessionId = uuidv4(); + let player = { id, sessionId, name: `Player-${id.slice(0, 4)}`, ws, connected: true, roomId: null }; const send = (type, payload) => { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} }; - send('connected', { playerId: id }); + send('connected', { playerId: id, sessionId }); + + function isParticipant(room, pid) { + if (!room) return false; + if (room.state.turnOrder?.includes(pid)) return true; + if (room.state.timeline && Object.prototype.hasOwnProperty.call(room.state.timeline, pid)) return true; + if (room.state.tokens && Object.prototype.hasOwnProperty.call(room.state.tokens, pid)) return true; + return false; + } ws.on('message', async (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } + // Allow client to resume by session token + if (msg.type === 'resume') { + const reqSession = String(msg.sessionId || ''); + if (!reqSession) { send('resume_result', { ok: false, reason: 'no_session' }); return; } + let found = null; let foundRoom = null; + for (const room of rooms.values()) { + for (const p of room.players.values()) { + if (p.sessionId === reqSession) { found = p; foundRoom = room; break; } + } + if (found) break; + } + if (!found) { send('resume_result', { ok: false, reason: 'not_found' }); return; } + // Rebind socket and mark connected + try { if (found.ws && found.ws !== ws) { try { found.ws.terminate(); } catch {} } } catch {} + found.ws = ws; found.connected = true; player = found; // switch our local reference to the existing player + // If they were a participant, ensure they are not marked spectator + if (foundRoom) { + if (isParticipant(foundRoom, found.id)) { + if (foundRoom.state.spectators) delete foundRoom.state.spectators[found.id]; + found.spectator = false; + } + // Notify room + broadcast(foundRoom, 'room_update', { room: roomSummary(foundRoom) }); + } + send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id }); + return; + } + // Automatic answer check (anyone can try during guess phase) if (msg.type === 'submit_answer') { const room = rooms.get(player.roomId); @@ -72,8 +109,13 @@ export function setupWebSocket(server) { if (msg.type === 'create_room') { const room = createRoom(msg.name, player); player.roomId = room.id; broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'join_room') { const code = String(msg.code || '').toUpperCase(); const room = rooms.get(code); if (!room) return send('error', { message: 'Room not found' }); - room.players.set(player.id, player); player.roomId = room.id; room.state.ready[player.id] = false; - if (room.state.status === 'playing' || room.state.status === 'ended') { room.state.spectators[player.id] = true; player.spectator = true; } else { delete room.state.spectators[player.id]; player.spectator = false; } + room.players.set(player.id, player); player.roomId = room.id; if (!room.state.ready) room.state.ready = {}; if (room.state.ready[player.id] == null) room.state.ready[player.id] = false; + const inProgress = room.state.status === 'playing' || room.state.status === 'ended'; + const wasParticipant = isParticipant(room, player.id); + if (inProgress) { + if (wasParticipant) { if (room.state.spectators) delete room.state.spectators[player.id]; player.spectator = false; } + else { room.state.spectators[player.id] = true; player.spectator = true; } + } else { delete room.state.spectators[player.id]; player.spectator = false; } broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'leave_room') { if (!player.roomId) return; const room = rooms.get(player.roomId); if (!room) return; room.players.delete(player.id); player.roomId = null; if (room.state.ready) delete room.state.ready[player.id]; if (room.state.spectators) delete room.state.spectators[player.id]; if (room.players.size === 0) rooms.delete(room.id); else broadcast(room, 'room_update', { room: roomSummary(room) }); return; } @@ -118,6 +160,19 @@ export function setupWebSocket(server) { room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser); room.state.phase = 'guess'; broadcast(room, 'room_update', { room: roomSummary(room) }); drawNextTrack(room); return; } }); + ws.on('close', () => { + // Mark player disconnected but keep them in the room for resume + try { + if (player) { + player.connected = false; + if (player.roomId && rooms.has(player.roomId)) { + const room = rooms.get(player.roomId); + broadcast(room, 'room_update', { room: roomSummary(room) }); + } + } + } catch {} + }); + ws.on('close', () => { player.connected = false; if (player.roomId && rooms.has(player.roomId)) { const room = rooms.get(player.roomId); broadcast(room, 'room_update', { room: roomSummary(room) }); } }); }); } diff --git a/src/server/game/answerCheck.js b/src/server/game/answerCheck.js index 7793813..72e143d 100644 --- a/src/server/game/answerCheck.js +++ b/src/server/game/answerCheck.js @@ -28,6 +28,23 @@ function cleanTitleNoise(raw) { function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); } function normalizeArtist(s) { return normalizeCommon(s).replace(/\bthe\b/g, ' ').replace(/\s+/g, ' ').trim(); } +// Produce a variant with anything inside parentheses (...) or double quotes "..." removed. +function stripOptionalSegments(raw) { + let s = String(raw); + // Remove double-quoted segments first + s = s.replace(/"[^"]*"/g, ' '); + // Remove parenthetical segments (non-nested) + s = s.replace(/\([^)]*\)/g, ' '); + // Remove square-bracket segments [ ... ] (non-nested) + s = s.replace(/\[[^\]]*\]/g, ' '); + return s; +} + +function normalizeTitleBaseOptional(s) { + // Clean general noise, then drop optional quoted/parenthetical parts, then normalize + return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s))); +} + function tokenize(s) { return s ? String(s).split(' ').filter(Boolean) : []; } function tokenSet(s) { return new Set(tokenize(s)); } function jaccard(a, b) { @@ -79,12 +96,29 @@ const TITLE_JACCARD_THRESHOLD = 0.8; const ARTIST_SIM_THRESHOLD = 0.82; export function scoreTitle(guessRaw, truthRaw) { - const g = normalizeTitle(guessRaw); - const t = normalizeTitle(truthRaw); - const sim = simRatio(g, t); - const jac = jaccard(g, t); - const pass = sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD; - return { pass, sim, jac, g, t }; + // Full normalized (keeps parentheses/quotes content after punctuation cleanup) + const gFull = normalizeTitle(guessRaw); + const tFull = normalizeTitle(truthRaw); + // Base normalized (treat anything in () or "" as optional and remove it) + const gBase = normalizeTitleBaseOptional(guessRaw); + const tBase = normalizeTitleBaseOptional(truthRaw); + + const pairs = [ + [gFull, tFull], + [gFull, tBase], + [gBase, tFull], + [gBase, tBase], + ]; + + let bestSim = 0; let bestJac = 0; let pass = false; let bestPair = pairs[0]; + for (const [g, t] of pairs) { + const sim = simRatio(g, t); + const jac = jaccard(g, t); + if (sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD) pass = true; + if (sim > bestSim || (sim === bestSim && jac > bestJac)) { bestSim = sim; bestJac = jac; bestPair = [g, t]; } + } + + return { pass, sim: bestSim, jac: bestJac, g: bestPair[0], t: bestPair[1] }; } export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {