Refactor code structure for improved readability and maintainability
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s

This commit is contained in:
2025-09-04 21:53:54 +02:00
parent 80f8c4ca90
commit 8c5ca0044f
27 changed files with 3398 additions and 326 deletions

View File

@@ -1,4 +1,4 @@
import { WebSocketServer } from 'ws';
import { Server as SocketIOServer } from 'socket.io';
import { v4 as uuidv4 } from 'uuid';
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
import { loadDeck } from './game/deck.js';
@@ -7,24 +7,50 @@ import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
function drawNextTrack(room) {
const track = room.deck.shift();
if (!track) { room.state.status = 'ended'; room.state.winner = null; broadcast(room, 'game_ended', { winner: null }); return; }
if (!track) {
room.state.status = 'ended';
room.state.winner = null;
broadcast(room, 'game_ended', { winner: null });
return;
}
room.state.currentTrack = { ...track, url: `/audio/${encodeURIComponent(track.file)}` };
room.state.phase = 'guess'; room.state.lastResult = null; room.state.paused = false; room.state.pausedPosSec = 0;
room.state.phase = 'guess';
room.state.lastResult = null;
room.state.paused = false;
room.state.pausedPosSec = 0;
room.state.awardedThisRound = {}; // reset per-round coin awards
room.state.trackStartAt = Date.now() + 800;
broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() });
broadcast(room, 'play_track', {
track: room.state.currentTrack,
startAt: room.state.trackStartAt,
serverNow: Date.now(),
});
broadcast(room, 'room_update', { room: roomSummary(room) });
startSyncTimer(room);
}
export function setupWebSocket(server) {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
// Create a tentative player identity, but don't immediately commit or send it.
const newId = uuidv4();
const newSessionId = uuidv4();
let player = { id: newId, sessionId: newSessionId, name: `Player-${newId.slice(0, 4)}`, ws, connected: true, roomId: null };
const send = (type, payload) => { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} };
const io = new SocketIOServer(server, {
transports: ['websocket'],
cors: { origin: true, methods: ['GET', 'POST'] },
});
io.on('connection', (socket) => {
// Create a tentative player identity, but don't immediately commit or send it.
const newId = uuidv4();
const newSessionId = uuidv4();
let player = {
id: newId,
sessionId: newSessionId,
name: `Player-${newId.slice(0, 4)}`,
ws: socket,
connected: true,
roomId: null,
};
const send = (type, payload) => {
try {
socket.emit('message', { type, ...payload });
} catch {}
};
// To avoid overwriting an existing session on reconnect, delay the initial
// welcome until we see if the client sends a resume message.
@@ -42,30 +68,49 @@ export function setupWebSocket(server) {
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;
if (room.state.timeline && Object.hasOwn(room.state.timeline, pid)) return true;
if (room.state.tokens && Object.hasOwn(room.state.tokens, pid)) return true;
return false;
}
ws.on('message', async (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
socket.on('message', async (msg) => {
if (!msg || typeof msg !== 'object') return;
// Allow client to resume by session token
if (msg.type === 'resume') {
clearTimeout(helloTimer);
const reqSession = String(msg.sessionId || '');
if (!reqSession) { send('resume_result', { ok: false, reason: 'no_session' }); return; }
let found = null; let foundRoom = null;
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 (p.sessionId === reqSession) {
found = p;
foundRoom = room;
break;
}
}
if (found) break;
}
if (!found) { send('resume_result', { ok: false, reason: 'not_found' }); return; }
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
try {
if (found.ws?.id && found.ws.id !== socket.id) {
try {
found.ws.disconnect(true);
} catch {}
}
} catch {}
found.ws = socket;
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)) {
@@ -75,10 +120,10 @@ export function setupWebSocket(server) {
// Notify room
broadcast(foundRoom, 'room_update', { room: roomSummary(foundRoom) });
}
// Send resume result and an explicit connected that preserves their original session.
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
helloSent = true;
send('connected', { playerId: found.id, sessionId: found.sessionId });
// Send resume result and an explicit connected that preserves their original session.
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
helloSent = true;
send('connected', { playerId: found.id, sessionId: found.sessionId });
return;
}
@@ -87,21 +132,35 @@ export function setupWebSocket(server) {
const room = rooms.get(player.roomId);
if (!room) return;
const current = room.state.currentTrack;
if (!current) { send('answer_result', { ok: false, error: 'no_track' }); return; }
if (room.state.status !== 'playing' || room.state.phase !== 'guess') { send('answer_result', { ok: false, error: 'not_accepting' }); return; }
if (room.state.spectators?.[player.id]) { send('answer_result', { ok: false, error: 'spectator' }); return; }
if (!current) {
send('answer_result', { ok: false, error: 'no_track' });
return;
}
if (room.state.status !== 'playing' || room.state.phase !== 'guess') {
send('answer_result', { ok: false, error: 'not_accepting' });
return;
}
if (room.state.spectators?.[player.id]) {
send('answer_result', { ok: false, error: 'spectator' });
return;
}
const guess = msg.guess || {};
const guessTitle = String(guess.title || '').slice(0, 200);
const guessArtist = String(guess.artist || '').slice(0, 200);
if (!guessTitle || !guessArtist) { send('answer_result', { ok: false, error: 'invalid' }); return; }
if (!guessTitle || !guessArtist) {
send('answer_result', { ok: false, error: 'invalid' });
return;
}
const titleScore = scoreTitle(guessTitle, current.title || current.id || '');
const artistScore = scoreArtist(guessArtist, splitArtists(current.artist || ''), 1);
const correct = !!(titleScore.pass && artistScore.pass);
let awarded = false; let alreadyAwarded = false;
let awarded = false;
let alreadyAwarded = false;
if (correct) {
room.state.awardedThisRound = room.state.awardedThisRound || {};
if (room.state.awardedThisRound[player.id]) { alreadyAwarded = true; }
else {
if (room.state.awardedThisRound[player.id]) {
alreadyAwarded = true;
} else {
const currentTokens = room.state.tokens[player.id] ?? 0;
room.state.tokens[player.id] = Math.min(5, currentTokens + 1);
room.state.awardedThisRound[player.id] = true;
@@ -114,80 +173,244 @@ export function setupWebSocket(server) {
correctArtist: artistScore.pass,
scoreTitle: { sim: +titleScore.sim.toFixed(3), jaccard: +titleScore.jac.toFixed(3) },
scoreArtist: +artistScore.best.toFixed(3),
normalized: { guessTitle: titleScore.g, truthTitle: titleScore.t, guessArtists: artistScore.guessArtists, truthArtists: artistScore.truthArtists },
normalized: {
guessTitle: titleScore.g,
truthTitle: titleScore.t,
guessArtists: artistScore.guessArtists,
truthArtists: artistScore.truthArtists,
},
awarded,
alreadyAwarded,
});
if (awarded) { broadcast(room, 'room_update', { room: roomSummary(room) }); }
if (awarded) {
broadcast(room, 'room_update', { room: roomSummary(room) });
}
return;
}
if (msg.type === 'set_name') { player.name = String(msg.name || '').slice(0, 30) || player.name; if (player.roomId && rooms.has(player.roomId)) broadcast(rooms.get(player.roomId), 'room_update', { room: roomSummary(rooms.get(player.roomId)) }); 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 === 'set_name') {
player.name = String(msg.name || '').slice(0, 30) || player.name;
if (player.roomId && rooms.has(player.roomId)) {
const r = rooms.get(player.roomId);
broadcast(r, 'room_update', { room: roomSummary(r) });
}
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') {
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' });
// If there's an existing player in this room with the same session, merge to avoid duplicates.
let existing = null;
for (const p of room.players.values()) { if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) { existing = p; break; } }
if (existing) {
try { if (existing.ws && existing.ws !== ws) { try { existing.ws.terminate(); } catch {} } } catch {}
existing.ws = ws; existing.connected = true; existing.roomId = room.id; player = existing;
for (const p of room.players.values()) {
if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) {
existing = p;
break;
}
}
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 (existing) {
try {
if (existing.ws?.id && existing.ws.id !== socket.id) {
try {
existing.ws.disconnect(true);
} catch {}
}
} catch {}
existing.ws = socket;
existing.connected = true;
existing.roomId = room.id;
player = existing;
}
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 (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; }
if (msg.type === 'set_ready') { const room = rooms.get(player.roomId); if (!room) return; const value = !!msg.ready; room.state.ready[player.id] = value; 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;
}
if (msg.type === 'set_ready') {
const room = rooms.get(player.roomId);
if (!room) return;
const value = !!msg.ready;
room.state.ready[player.id] = value;
broadcast(room, 'room_update', { room: roomSummary(room) });
return;
}
if (msg.type === 'start_game') {
const room = rooms.get(player.roomId); if (!room) return;
const room = rooms.get(player.roomId);
if (!room) return;
if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
const active = [...room.players.values()].filter(p => !room.state.spectators?.[p.id] && p.connected);
const allReady = active.length>0 && active.every(p => !!room.state.ready?.[p.id]);
const active = [...room.players.values()].filter(
(p) => !room.state.spectators?.[p.id] && p.connected
);
const allReady = active.length > 0 && active.every((p) => !!room.state.ready?.[p.id]);
if (!allReady) return send('error', { message: 'All active players must be ready' });
const pids = active.map(p => p.id);
room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0];
const pids = active.map((p) => p.id);
room.state.status = 'playing';
room.state.turnOrder = shuffle(pids);
room.state.currentGuesser = room.state.turnOrder[0];
room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
room.deck = await loadDeck(); room.discard = []; room.state.phase = 'guess'; room.state.lastResult = null; drawNextTrack(room); return; }
room.deck = await loadDeck();
room.discard = [];
room.state.phase = 'guess';
room.state.lastResult = null;
drawNextTrack(room);
return;
}
if (msg.type === 'player_control') {
const room = rooms.get(player.roomId); if (!room) return; const { action } = msg;
if (room.state.status !== 'playing') return; if (room.state.phase !== 'guess') return; if (room.state.currentGuesser !== player.id) return; if (!room.state.currentTrack) return;
if (action === 'pause') { if (!room.state.paused) { const now = Date.now(); if (room.state.trackStartAt) { room.state.pausedPosSec = Math.max(0, (now - room.state.trackStartAt) / 1000); } room.state.paused = true; stopSyncTimer(room); } broadcast(room, 'control', { action: 'pause' }); }
if (action === 'play') { const now = Date.now(); const posSec = room.state.paused ? room.state.pausedPosSec : Math.max(0, (now - (room.state.trackStartAt || now)) / 1000); room.state.trackStartAt = now - Math.floor(posSec * 1000); room.state.paused = false; startSyncTimer(room); broadcast(room, 'control', { action: 'play', startAt: room.state.trackStartAt, serverNow: now }); }
return; }
const room = rooms.get(player.roomId);
if (!room) return;
const { action } = msg;
if (room.state.status !== 'playing') return;
if (room.state.phase !== 'guess') return;
if (room.state.currentGuesser !== player.id) return;
if (!room.state.currentTrack) return;
if (action === 'pause') {
if (!room.state.paused) {
const now = Date.now();
if (room.state.trackStartAt) {
room.state.pausedPosSec = Math.max(0, (now - room.state.trackStartAt) / 1000);
}
room.state.paused = true;
stopSyncTimer(room);
}
broadcast(room, 'control', { action: 'pause' });
}
if (action === 'play') {
const now = Date.now();
const posSec = room.state.paused
? room.state.pausedPosSec
: Math.max(0, (now - (room.state.trackStartAt || now)) / 1000);
room.state.trackStartAt = now - Math.floor(posSec * 1000);
room.state.paused = false;
startSyncTimer(room);
broadcast(room, 'control', {
action: 'play',
startAt: room.state.trackStartAt,
serverNow: now,
});
}
return;
}
if (msg.type === 'place_guess') {
const room = rooms.get(player.roomId); if (!room) return; const { position, slot: rawSlot } = msg;
const room = rooms.get(player.roomId);
if (!room) return;
const { position, slot: rawSlot } = msg;
if (room.state.status !== 'playing') return send('error', { message: 'Game not playing' });
if (room.state.phase !== 'guess') return send('error', { message: 'Not accepting guesses now' });
if (room.state.currentGuesser !== player.id) return send('error', { message: 'Not your turn' });
const current = room.state.currentTrack; if (!current) return send('error', { message: 'No current track' });
const tl = room.state.timeline[player.id] || []; const n = tl.length;
let slot = Number.isInteger(rawSlot) ? rawSlot : null; if (slot == null) { if (position === 'before') slot = 0; else if (position === 'after') slot = n; }
if (room.state.phase !== 'guess')
return send('error', { message: 'Not accepting guesses now' });
if (room.state.currentGuesser !== player.id)
return send('error', { message: 'Not your turn' });
const current = room.state.currentTrack;
if (!current) return send('error', { message: 'No current track' });
const tl = room.state.timeline[player.id] || [];
const n = tl.length;
let slot = Number.isInteger(rawSlot) ? rawSlot : null;
if (slot == null) {
if (position === 'before') slot = 0;
else if (position === 'after') slot = n;
}
if (typeof slot !== 'number' || slot < 0 || slot > n) slot = n;
let correct = false;
if (current.year != null) {
if (n === 0) correct = slot === 0;
else { const left = slot > 0 ? tl[slot - 1]?.year : null; const right = slot < n ? tl[slot]?.year : null; const leftOk = (left == null) || (current.year >= left); const rightOk = (right == null) || (current.year <= right); correct = leftOk && rightOk; }
else {
const left = slot > 0 ? tl[slot - 1]?.year : null;
const right = slot < n ? tl[slot]?.year : null;
const leftOk = left == null || current.year >= left;
const rightOk = right == null || current.year <= right;
correct = leftOk && rightOk;
}
}
if (correct) { const newTl = tl.slice(); newTl.splice(slot, 0, { trackId: current.id, year: current.year, title: current.title, artist: current.artist }); room.state.timeline[player.id] = newTl; }
else { room.discard.push(current); }
room.state.phase = 'reveal'; room.state.lastResult = { playerId: player.id, correct }; broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack }); broadcast(room, 'room_update', { room: roomSummary(room) });
const tlNow = room.state.timeline[player.id] || []; if (correct && tlNow.length >= room.state.goal) { room.state.status = 'ended'; room.state.winner = player.id; broadcast(room, 'game_ended', { winner: player.id }); return; }
return; }
if (msg.type === 'earn_token') { const room = rooms.get(player.roomId); if (!room) return; const tokens = room.state.tokens[player.id] ?? 0; room.state.tokens[player.id] = Math.min(5, tokens + 1); broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
if (correct) {
const newTl = tl.slice();
newTl.splice(slot, 0, {
trackId: current.id,
year: current.year,
title: current.title,
artist: current.artist,
});
room.state.timeline[player.id] = newTl;
} else {
room.discard.push(current);
}
room.state.phase = 'reveal';
room.state.lastResult = { playerId: player.id, correct };
broadcast(room, 'reveal', {
result: room.state.lastResult,
track: room.state.currentTrack,
});
broadcast(room, 'room_update', { room: roomSummary(room) });
const tlNow = room.state.timeline[player.id] || [];
if (correct && tlNow.length >= room.state.goal) {
room.state.status = 'ended';
room.state.winner = player.id;
broadcast(room, 'game_ended', { winner: player.id });
return;
}
return;
}
if (msg.type === 'earn_token') {
const room = rooms.get(player.roomId);
if (!room) return;
const tokens = room.state.tokens[player.id] ?? 0;
room.state.tokens[player.id] = Math.min(5, tokens + 1);
broadcast(room, 'room_update', { room: roomSummary(room) });
return;
}
if (msg.type === 'next_track') {
const room = rooms.get(player.roomId); if (!room) return; if (room.state.status !== 'playing') return; if (room.state.phase !== 'reveal') return; const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser; if (!isAuthorized) return;
room.state.currentTrack = null; room.state.trackStartAt = null; room.state.paused = false; room.state.pausedPosSec = 0; stopSyncTimer(room);
room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser); room.state.phase = 'guess'; broadcast(room, 'room_update', { room: roomSummary(room) }); drawNextTrack(room); return; }
const room = rooms.get(player.roomId);
if (!room) return;
if (room.state.status !== 'playing') return;
if (room.state.phase !== 'reveal') return;
const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser;
if (!isAuthorized) return;
room.state.currentTrack = null;
room.state.trackStartAt = null;
room.state.paused = false;
room.state.pausedPosSec = 0;
stopSyncTimer(room);
room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser);
room.state.phase = 'guess';
broadcast(room, 'room_update', { room: roomSummary(room) });
drawNextTrack(room);
}
});
ws.on('close', () => {
socket.on('disconnect', () => {
clearTimeout(helloTimer);
// Mark player disconnected but keep them in the room for resume
try {

View File

@@ -1,7 +1,9 @@
// Fuzzy matching helpers for title/artist guessing
function stripDiacritics(s) {
return String(s).normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
return String(s)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
}
function normalizeCommon(s) {
@@ -15,18 +17,29 @@ function normalizeCommon(s) {
function cleanTitleNoise(raw) {
let s = String(raw);
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
.replace(/\(([^)]*version[^)]*)\)/gi, '')
.replace(/\(([^)]*live[^)]*)\)/gi, '')
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
s = s
.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
.replace(/\(([^)]*version[^)]*)\)/gi, '')
.replace(/\(([^)]*live[^)]*)\)/gi, '')
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
s = s.replace(
/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi,
''
);
return s;
}
function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); }
function normalizeArtist(s) { return normalizeCommon(s).replace(/\bthe\b/g, ' ').replace(/\s+/g, ' ').trim(); }
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) {
@@ -45,12 +58,18 @@ function normalizeTitleBaseOptional(s) {
return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s)));
}
function tokenize(s) { return s ? String(s).split(' ').filter(Boolean) : []; }
function tokenSet(s) { return new Set(tokenize(s)); }
function tokenize(s) {
return s ? String(s).split(' ').filter(Boolean) : [];
}
function tokenSet(s) {
return new Set(tokenize(s));
}
function jaccard(a, b) {
const A = tokenSet(a), B = tokenSet(b);
const A = tokenSet(a),
B = tokenSet(b);
if (A.size === 0 && B.size === 0) return 1;
let inter = 0; for (const t of A) if (B.has(t)) inter++;
let inter = 0;
for (const t of A) if (B.has(t)) inter++;
const union = A.size + B.size - inter;
return union ? inter / union : 0;
}
@@ -58,7 +77,8 @@ function jaccard(a, b) {
function levenshtein(a, b) {
a = String(a);
b = String(b);
const m = a.length, n = b.length;
const m = a.length,
n = b.length;
if (!m) return n;
if (!n) return m;
const dp = new Array(n + 1);
@@ -110,12 +130,19 @@ export function scoreTitle(guessRaw, truthRaw) {
[gBase, tBase],
];
let bestSim = 0; let bestJac = 0; let pass = false; let bestPair = pairs[0];
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]; }
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] };
@@ -134,7 +161,10 @@ export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {
}
const primary = truthArtists.slice(0, primaryCount || truthArtists.length);
const pass = primary.some((p) => matches.has(p)); // accept any one artist
let best = 0; for (const ga of guessArtists) { for (const ta of truthSet) best = Math.max(best, simRatio(ga, ta)); }
let best = 0;
for (const ga of guessArtists) {
for (const ta of truthSet) best = Math.max(best, simRatio(ga, ta));
}
return { pass, best, matched: Array.from(matches), guessArtists, truthArtists };
}

View File

@@ -8,11 +8,21 @@ import { shuffle } from './state.js';
export async function loadDeck() {
const years = loadYearsIndex();
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
const tracks = await Promise.all(files.map(async (f) => {
const fp = path.join(DATA_DIR, f);
let year = null, title = path.parse(f).name, artist = '';
try { const meta = await mmParseFile(fp, { duration: false }); title = meta.common.title || title; artist = meta.common.artist || artist; year = meta.common.year || null; } catch {}
const y = years[f]?.year ?? year; return { id: f, file: f, title, artist, year: y };
}));
const tracks = await Promise.all(
files.map(async (f) => {
const fp = path.join(DATA_DIR, f);
let year = null,
title = path.parse(f).name,
artist = '';
try {
const meta = await mmParseFile(fp, { duration: false });
title = meta.common.title || title;
artist = meta.common.artist || artist;
year = meta.common.year || null;
} catch {}
const y = years[f]?.year ?? year;
return { id: f, file: f, title, artist, year: y };
})
);
return shuffle(tracks);
}

View File

@@ -17,7 +17,7 @@ export function nextPlayer(turnOrder, currentId) {
}
export function createRoom(name, host) {
const id = (Math.random().toString(36).slice(2, 8)).toUpperCase();
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
const room = {
id,
name: name || `Room ${id}`,
@@ -50,7 +50,9 @@ export function createRoom(name, host) {
export function broadcast(room, type, payload) {
for (const p of room.players.values()) {
try { p.ws.send(JSON.stringify({ type, ...payload })); } catch {}
try {
p.ws?.emit?.('message', { type, ...payload });
} catch {}
}
}

View File

@@ -3,11 +3,20 @@ import { broadcast } from './state.js';
export function startSyncTimer(room) {
if (room.syncTimer) clearInterval(room.syncTimer);
room.syncTimer = setInterval(() => {
if (room.state.status !== 'playing' || !room.state.currentTrack || !room.state.trackStartAt || room.state.paused) return;
if (
room.state.status !== 'playing' ||
!room.state.currentTrack ||
!room.state.trackStartAt ||
room.state.paused
)
return;
broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() });
}, 1000);
}
export function stopSyncTimer(room) {
if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; }
if (room.syncTimer) {
clearInterval(room.syncTimer);
room.syncTimer = null;
}
}

View File

@@ -1,7 +1,6 @@
import express from 'express';
import http from 'http';
import fs from 'fs';
import path from 'path';
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
import { registerAudioRoutes } from './routes/audio.js';
import { registerTracksApi } from './tracks.js';
@@ -32,6 +31,5 @@ const server = http.createServer(app);
setupWebSocket(server);
server.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Hitstar server running on http://localhost:${PORT}`);
});

View File

@@ -87,7 +87,8 @@ export function registerAudioRoutes(app) {
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'no-store');
return res.status(200).end(buf);
} catch (e) {
} catch (error) {
console.error('Error reading cover:', error);
return res.status(500).send('Error reading cover');
}
});