refactor: enhance session management and room joining logic in WebSocket handling
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s

This commit is contained in:
2025-09-04 18:22:30 +02:00
parent a63c5858f7
commit 33aa410c09
8 changed files with 199 additions and 18 deletions

View File

@@ -4,6 +4,8 @@ let ws;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let reconnectTimer = null; let reconnectTimer = null;
const outbox = []; const outbox = [];
let sessionId = localStorage.getItem('sessionId') || null;
let lastRoomId = localStorage.getItem('lastRoomId') || null;
function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; } function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
function sendMsg(obj) { function sendMsg(obj) {
if (wsIsOpen()) ws.send(JSON.stringify(obj)); if (wsIsOpen()) ws.send(JSON.stringify(obj));
@@ -23,6 +25,10 @@ function connectWS() {
ws = new WebSocket(url); ws = new WebSocket(url);
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
reconnectAttempts = 0; reconnectAttempts = 0;
// Try to resume session immediately
if (sessionId) {
try { ws.send(JSON.stringify({ type: 'resume', sessionId })); } catch {}
}
// Flush queued messages // Flush queued messages
setTimeout(() => { setTimeout(() => {
while (outbox.length && wsIsOpen()) { while (outbox.length && wsIsOpen()) {
@@ -89,6 +95,7 @@ function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hi
function renderRoom(room) { function renderRoom(room) {
state.room = room; state.room = room;
try { if (room?.id) { lastRoomId = room.id; localStorage.setItem('lastRoomId', room.id); } } catch {}
if (!room) { showLobby(); return; } if (!room) { showLobby(); return; }
showRoom(); showRoom();
$roomId.textContent = room.id; $roomId.textContent = room.id;
@@ -241,6 +248,10 @@ function handleMessage(ev) {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === 'connected') { if (msg.type === 'connected') {
state.playerId = msg.playerId; state.playerId = msg.playerId;
if (msg.sessionId && !sessionId) {
sessionId = msg.sessionId;
try { localStorage.setItem('sessionId', sessionId); } catch {}
}
// Try to auto-apply stored name // Try to auto-apply stored name
const stored = localStorage.getItem('playerName'); const stored = localStorage.getItem('playerName');
if (stored) { if (stored) {
@@ -252,8 +263,28 @@ function handleMessage(ev) {
} }
sendMsg({ type: 'set_name', name: stored }); sendMsg({ type: 'set_name', name: stored });
} }
// Try to rejoin room if known // If we already have a sessionId, we'll resume instead of auto-joining here
if (state.room?.id) sendMsg({ type: 'join_room', code: state.room.id }); 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') { if (msg.type === 'room_update') {
renderRoom(msg.room); renderRoom(msg.room);

View File

@@ -45,3 +45,13 @@ export function applySync(startAt, serverNow) {
if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; } 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 {}
}

View File

@@ -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 { $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 { state } from './state.js';
import { connectWS, sendMsg } from './ws.js'; import { connectWS, sendMsg, cacheSessionId, cacheLastRoomId } from './ws.js';
import { renderRoom } from './render.js'; import { renderRoom } from './render.js';
import { initAudioUI, applySync } from './audio.js'; import { initAudioUI, applySync, stopAudioPlayback } from './audio.js';
function showToast(msg) { function showToast(msg) {
const el = document.getElementById('toast'); const el = document.getElementById('toast');
@@ -18,6 +18,9 @@ function showToast(msg) {
function handleConnected(msg) { function handleConnected(msg) {
state.playerId = msg.playerId; state.playerId = msg.playerId;
if (msg.sessionId) {
cacheSessionId(msg.sessionId);
}
const stored = localStorage.getItem('playerName'); const stored = localStorage.getItem('playerName');
if (stored) { if (stored) {
if ($nameLobby && $nameLobby.value !== stored) { if ($nameLobby && $nameLobby.value !== stored) {
@@ -28,12 +31,14 @@ function handleConnected(msg) {
} }
sendMsg({ type: 'set_name', name: stored }); sendMsg({ type: 'set_name', name: stored });
} }
if (state.room?.id) { const last = state.room?.id || localStorage.getItem('lastRoomId');
sendMsg({ type: 'join_room', code: state.room.id }); if (last && !localStorage.getItem('sessionId')) {
sendMsg({ type: 'join_room', code: last });
} }
} }
function handleRoomUpdate(msg) { function handleRoomUpdate(msg) {
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
renderRoom(msg.room); renderRoom(msg.room);
} }
@@ -134,6 +139,16 @@ function handleGameEnded(msg) {
function onMessage(ev) { function onMessage(ev) {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
switch (msg.type) { 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': case 'connected':
handleConnected(msg); handleConnected(msg);
break; break;
@@ -211,6 +226,7 @@ function wireUi() {
}); });
wire($leaveRoom, 'click', () => { wire($leaveRoom, 'click', () => {
sendMsg({ type: 'leave_room' }); sendMsg({ type: 'leave_room' });
stopAudioPlayback();
state.room = null; state.room = null;
$lobby.classList.remove('hidden'); $lobby.classList.remove('hidden');
$room.classList.add('hidden'); $room.classList.add('hidden');

View File

@@ -1,9 +1,10 @@
import { state } from './state.js'; import { state } from './state.js';
import { badgeColorForYear } from '../utils/colors.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) { export function renderRoom(room) {
state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; } 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'); $lobby.classList.add('hidden'); $room.classList.remove('hidden');
$roomId.textContent = room.id; $roomId.textContent = room.id;
$status.textContent = room.state.status; $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)); 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 isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack;
const canGuess = isMyTurn; 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 // Build slot options for insertion positions when it's my turn
if ($placeArea && $slotSelect) { if ($placeArea && $slotSelect) {
if (canGuess) { if (canGuess) {

View File

@@ -4,6 +4,8 @@ let ws;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let reconnectTimer = null; let reconnectTimer = null;
const outbox = []; 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 wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; }
export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); } 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 = new WebSocket(url);
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
reconnectAttempts = 0; 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); setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100);
}); });
ws.addEventListener('message', (ev) => onMessage(ev)); ws.addEventListener('message', (ev) => onMessage(ev));
@@ -33,3 +39,15 @@ window.addEventListener('online', () => {
// Kick off a reconnect by calling connectWS from app main again // 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 {}
}

14
scripts/test-score.js Normal file
View File

@@ -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 }));
}

View File

@@ -21,13 +21,50 @@ export function setupWebSocket(server) {
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
const id = uuidv4(); const id = uuidv4();
const player = { id, name: `Player-${id.slice(0, 4)}`, ws, connected: true, roomId: null }; 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 {} }; 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) => { ws.on('message', async (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } 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) // Automatic answer check (anyone can try during guess phase)
if (msg.type === 'submit_answer') { if (msg.type === 'submit_answer') {
const room = rooms.get(player.roomId); 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 === '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') { 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' }); 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; 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;
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; } 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; } broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
if (msg.type === 'leave_room') { 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; } 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; } 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) }); } }); 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) }); } });
}); });
} }

View File

@@ -28,6 +28,23 @@ function cleanTitleNoise(raw) {
function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); } function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); }
function normalizeArtist(s) { return normalizeCommon(s).replace(/\bthe\b/g, ' ').replace(/\s+/g, ' ').trim(); } 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 tokenize(s) { return s ? String(s).split(' ').filter(Boolean) : []; }
function tokenSet(s) { return new Set(tokenize(s)); } function tokenSet(s) { return new Set(tokenize(s)); }
function jaccard(a, b) { function jaccard(a, b) {
@@ -79,12 +96,29 @@ const TITLE_JACCARD_THRESHOLD = 0.8;
const ARTIST_SIM_THRESHOLD = 0.82; const ARTIST_SIM_THRESHOLD = 0.82;
export function scoreTitle(guessRaw, truthRaw) { export function scoreTitle(guessRaw, truthRaw) {
const g = normalizeTitle(guessRaw); // Full normalized (keeps parentheses/quotes content after punctuation cleanup)
const t = normalizeTitle(truthRaw); 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 sim = simRatio(g, t);
const jac = jaccard(g, t); const jac = jaccard(g, t);
const pass = sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD; if (sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD) pass = true;
return { pass, sim, jac, g, t }; 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) { export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {