From 158d144721aaeaf71714715f1839375adc5f944e Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 15:11:03 +0200 Subject: [PATCH] feat: implement answer submission and scoring system for guessing game --- public/index.html | 15 +- public/js/dom.js | 5 + public/js/main.js | 39 ++- public/js/render.js | 6 +- server.js | 495 --------------------------------- src/server/game.js | 42 +++ src/server/game/answerCheck.js | 107 +++++++ 7 files changed, 211 insertions(+), 498 deletions(-) delete mode 100644 server.js create mode 100644 src/server/game/answerCheck.js diff --git a/public/index.html b/public/index.html index 063cf8b..18c5f19 100644 --- a/public/index.html +++ b/public/index.html @@ -125,6 +125,20 @@ + + +
+ + + +
+
@@ -147,7 +161,6 @@
Tokens: 0 -
diff --git a/public/js/dom.js b/public/js/dom.js index 4e0f8f0..ddd7be1 100644 --- a/public/js/dom.js +++ b/public/js/dom.js @@ -38,6 +38,11 @@ export const $leaveRoom = el('leaveRoom'); export const $earnToken = el('earnToken'); export const $dashboardList = el('dashboardList'); export const $toast = el('toast'); +// Answer form elements +export const $answerForm = el('answerForm'); +export const $guessTitle = el('guessTitle'); +export const $guessArtist = el('guessArtist'); +export const $answerResult = el('answerResult'); export function showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); } export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); } diff --git a/public/js/main.js b/public/js/main.js index fea085a..a2b92d5 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,4 +1,4 @@ -import { $audio, $copyRoomCode, $leaveRoom, $nameDisplay, $nameLobby, $npArtist, $npTitle, $npYear, $pauseBtn, $placeBtn, $readyChk, $roomId, $roomCode, $slotSelect, $startGame, $volumeSlider, $playBtn, $nextBtn, $createRoom, $joinRoom, $lobby, $room, $setNameLobby } 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 { connectWS, sendMsg } from './ws.js'; import { renderRoom } from './render.js'; @@ -44,6 +44,10 @@ function handlePlayTrack(msg) { $npTitle.textContent = '???'; $npArtist.textContent = ''; $npYear.textContent = ''; + // reset answer UI + if ($guessTitle) $guessTitle.value = ''; + if ($guessArtist) $guessArtist.value = ''; + if ($answerResult) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; } try { $audio.preload = 'auto'; } catch {} @@ -151,6 +155,25 @@ function onMessage(ev) { case 'game_ended': handleGameEnded(msg); break; + case 'answer_result': { + if ($answerResult) { + if (!msg.ok) { + $answerResult.textContent = '⛔ Eingabe ungültig oder gerade nicht möglich'; + $answerResult.className = 'mt-1 text-sm text-rose-600'; + } else { + const okBoth = !!(msg.correctTitle && msg.correctArtist); + const parts = []; + parts.push(msg.correctTitle ? 'Titel ✓' : 'Titel ✗'); + parts.push(msg.correctArtist ? 'Künstler ✓' : 'Künstler ✗'); + let coin = ''; + if (msg.awarded) coin = ' +1 Token'; + else if (msg.alreadyAwarded) coin = ' (bereits erhalten)'; + $answerResult.textContent = `${parts.join(' · ')}${coin}`; + $answerResult.className = okBoth ? 'mt-1 text-sm text-emerald-600' : 'mt-1 text-sm text-amber-600'; + } + } + break; + } default: break; } @@ -241,6 +264,20 @@ function wireUi() { }); $roomId.style.cursor = 'pointer'; } + // Answer submit + const form = document.getElementById('answerForm'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + const title = ($guessTitle?.value || '').trim(); + const artist = ($guessArtist?.value || '').trim(); + if (!title || !artist) { + if ($answerResult) { $answerResult.textContent = 'Bitte Titel und Künstler eingeben'; $answerResult.className = 'mt-1 text-sm text-amber-600'; } + return; + } + sendMsg({ type: 'submit_answer', guess: { title, artist } }); + }); + } } // boot diff --git a/public/js/render.js b/public/js/render.js index 72756b3..c83f2e3 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -1,6 +1,6 @@ import { state } from './state.js'; import { badgeColorForYear } from '../utils/colors.js'; -import { $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, $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; } @@ -87,6 +87,10 @@ export function renderRoom(room) { if ($revealBanner) { const inReveal = room.state.phase === 'reveal'; if (!inReveal) { $revealBanner.className = 'hidden'; $revealBanner.textContent=''; } } const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId); if ($nextArea) $nextArea.classList.toggle('hidden', !canNext); + // Answer form visible during guess phase while a track is active + const showAnswer = room.state.status==='playing' && room.state.phase==='guess' && !!room.state.currentTrack; + if ($answerForm) $answerForm.classList.toggle('hidden', !showAnswer); + if ($answerResult && !showAnswer) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; } } export function shortName(id) { diff --git a/server.js b/server.js deleted file mode 100644 index 8962ba3..0000000 --- a/server.js +++ /dev/null @@ -1,495 +0,0 @@ -import express from 'express'; -import { WebSocketServer } from 'ws'; -import http from 'http'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; -import { v4 as uuidv4 } from 'uuid'; -import mime from 'mime'; -import { parseFile as mmParseFile } from 'music-metadata'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Config -const PORT = process.env.PORT || 5173; -const DATA_DIR = path.resolve(__dirname, 'data'); -const PUBLIC_DIR = path.resolve(__dirname, 'public'); -const YEARS_PATH = path.join(DATA_DIR, 'years.json'); - -// Ensure data dir exists -if (!fs.existsSync(DATA_DIR)) { - fs.mkdirSync(DATA_DIR, { recursive: true }); -} - -const app = express(); -// Load years.json helper -function loadYearsIndex() { - try { - const raw = fs.readFileSync(YEARS_PATH, 'utf8'); - const j = JSON.parse(raw); - if (j && j.byFile && typeof j.byFile === 'object') return j.byFile; - } catch {} - return {}; -} -let YEARS_INDEX = loadYearsIndex(); - -app.get('/api/reload-years', (req, res) => { - YEARS_INDEX = loadYearsIndex(); - res.json({ ok: true, count: Object.keys(YEARS_INDEX).length }); -}); - -// Static files -app.use(express.static(PUBLIC_DIR)); - -// Serve audio files safely from data folder with byte-range support -app.head('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = path.join(DATA_DIR, name); - if (!filePath.startsWith(DATA_DIR)) { - return res.status(400).end(); - } - if (!fs.existsSync(filePath)) { - return res.status(404).end(); - } - const stat = fs.statSync(filePath); - const type = mime.getType(filePath) || 'audio/mpeg'; - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', type); - res.setHeader('Content-Length', stat.size); - res.setHeader('Cache-Control', 'no-store'); - return res.status(200).end(); -}); - -app.get('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = path.join(DATA_DIR, name); - if (!filePath.startsWith(DATA_DIR)) { - return res.status(400).send('Invalid path'); - } - if (!fs.existsSync(filePath)) { - return res.status(404).send('Not found'); - } - const stat = fs.statSync(filePath); - const range = req.headers.range; - const type = mime.getType(filePath) || 'audio/mpeg'; - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Cache-Control', 'no-store'); - if (range) { - const match = /bytes=(\d+)-(\d+)?/.exec(range); - let start = match && match[1] ? parseInt(match[1], 10) : 0; - let end = match && match[2] ? parseInt(match[2], 10) : stat.size - 1; - if (Number.isNaN(start)) start = 0; - if (Number.isNaN(end)) end = stat.size - 1; - // Clamp and validate - start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); - end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1)); - if (start > end || start >= stat.size) { - res.setHeader('Content-Range', `bytes */${stat.size}`); - return res.status(416).end(); - } - const chunkSize = end - start + 1; - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${stat.size}`, - 'Content-Length': chunkSize, - 'Content-Type': type, - }); - fs.createReadStream(filePath, { start, end }).pipe(res); - } else { - res.writeHead(200, { - 'Content-Length': stat.size, - 'Content-Type': type, - }); - fs.createReadStream(filePath).pipe(res); - } -}); - -// List tracks with minimal metadata -app.get('/api/tracks', async (req, res) => { - try { - 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; - let title = path.parse(f).name; - let 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_INDEX[f]?.year ?? year; - return { id: f, file: f, title, artist, year: y }; - }) - ); - res.json({ tracks }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -const server = http.createServer(app); - -// --- Game State --- -const rooms = new Map(); // roomId -> { id, name, hostId, players: Map, state } - -function createRoom(name, host) { - const id = (Math.random().toString(36).slice(2, 8)).toUpperCase(); - const room = { - id, - name: name || `Room ${id}`, - hostId: host.id, - players: new Map([[host.id, host]]), - deck: [], - discard: [], - revealTimer: null, - syncTimer: null, - state: { - status: 'lobby', // lobby | playing | ended - turnOrder: [], - currentGuesser: null, - currentTrack: null, - timeline: {}, // playerId -> [{trackId, year, title, artist}] - tokens: {}, // playerId -> number - ready: { [host.id]: false }, // playerId -> boolean - spectators: {}, // playerId -> boolean (joined after game start) - phase: 'guess', // 'guess' | 'reveal' - lastResult: null, // { playerId, correct } - trackStartAt: null, // ms epoch for synced start time - paused: false, - pausedPosSec: 0, - goal: 10, - }, - }; - rooms.set(id, room); - return room; -} - -function broadcast(room, type, payload) { - for (const p of room.players.values()) { - try { - p.ws.send(JSON.stringify({ type, ...payload })); - } catch {} - } -} - -function roomSummary(room) { - return { - id: room.id, - name: room.name, - hostId: room.hostId, - players: [...room.players.values()].map((p) => ({ - id: p.id, - name: p.name, - connected: p.connected, - ready: !!room.state.ready?.[p.id], - spectator: !!room.state.spectators?.[p.id] || !!p.spectator, - })), - state: room.state, - }; -} - -async function loadDeck() { - // Load directly from DATA_DIR to avoid HTTP dependency - 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; - let title = path.parse(f).name; - let 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_INDEX[f]?.year ?? year; - return { id: f, file: f, title, artist, year: y }; - }) - ); - return tracks; -} - -function nextPlayer(turnOrder, currentId) { - if (!turnOrder.length) return null; - if (!currentId) return turnOrder[0]; - const idx = turnOrder.indexOf(currentId); - return turnOrder[(idx + 1) % turnOrder.length]; -} - -function shuffle(arr) { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -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; - broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() }); - }, 1000); -} - -function stopSyncTimer(room) { - if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; } -} - -function drawNextTrack(room) { - const track = room.deck.shift(); - if (!track) { - room.state.status = 'ended'; - room.state.winner = null; // deck exhausted - 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.trackStartAt = Date.now() + 800; // synchronized start in ~0.8s - broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() }); - broadcast(room, 'room_update', { room: roomSummary(room) }); - startSyncTimer(room); -} - -// WebSocket handling -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 }; - - function send(type, payload) { - try { ws.send(JSON.stringify({ type, ...payload })); } catch {} - } - - send('connected', { playerId: id }); - - ws.on('message', async (raw) => { - let msg; - try { msg = JSON.parse(raw.toString()); } catch { return; } - - if (msg.type === 'player_control') { - const room = rooms.get(player.roomId); - if (!room) return; - const { action } = msg; // 'play' | 'pause' - if (room.state.status !== 'playing') return; - if (room.state.phase !== 'guess') return; // control only while guessing - if (room.state.currentGuesser !== player.id) return; // only current guesser controls - if (!room.state.currentTrack) return; - if (action !== 'play' && action !== 'pause') 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' }); - } else 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 === '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 === '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 the game already started, mark as spectator - 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; - } - 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 (msg.type === 'start_game') { - const room = rooms.get(player.roomId); - if (!room) return; - if (room.hostId !== player.id) return send('error', { message: 'Only host can start' }); - // All players must be ready - const pids = [...room.players.values()] - .filter(p => !room.state.spectators?.[p.id]) - .map(p => p.id); - const allReady = pids.every((pid) => !!room.state.ready?.[pid]); - if (!allReady) return send('error', { message: 'All players must be ready' }); - 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 = shuffle(await loadDeck()); - room.discard = []; - room.state.phase = 'guess'; - room.state.lastResult = null; - // draw first track automatically - drawNextTrack(room); - return; - } - - // 'scan_track' is no longer used; server auto draws. - - if (msg.type === 'place_guess') { - const room = rooms.get(player.roomId); - if (!room) return; - const { position, slot: rawSlot } = msg; // 'before' | 'after' | slot index - 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' }); - // Simplified: if year known, check relative placement against player's timeline - 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; // default to after - let correct = false; - if (current.year != null) { - if (n === 0) { - correct = slot === 0; // only one slot - } 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) { - // Insert at chosen slot; equal years allowed anywhere - 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); - } - // Enter reveal phase (song keeps playing), announce result - room.state.phase = 'reveal'; - room.state.lastResult = { playerId: player.id, correct }; - broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack }); - // Also push a room update so clients know we're in 'reveal' (for Next button visibility) - broadcast(room, 'room_update', { room: roomSummary(room) }); - // Win check after revealing - const tlNow = room.state.timeline[player.id] || []; - if (correct && tlNow.length >= room.state.goal) { - room.state.status = 'ended'; - room.state.winner = player.id; - // Inform game ended immediately - broadcast(room, 'game_ended', { winner: player.id }); - return; - } - // Manual advance: wait for 'next_track' - 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; // can only advance during reveal - const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser; - if (!isAuthorized) return; - // Advance to next round - 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; - } - }); - - ws.on('close', () => { - player.connected = false; - if (player.roomId && rooms.has(player.roomId)) { - const room = rooms.get(player.roomId); - // Keep player in room but mark disconnected - broadcast(room, 'room_update', { room: roomSummary(room) }); - } - }); -}); - -server.listen(PORT, () => { - console.log(`Hitstar server running on http://localhost:${PORT}`); -}); diff --git a/src/server/game.js b/src/server/game.js index 4559b3e..ae18ecc 100644 --- a/src/server/game.js +++ b/src/server/game.js @@ -3,12 +3,14 @@ import { v4 as uuidv4 } from 'uuid'; import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js'; import { loadDeck } from './game/deck.js'; import { startSyncTimer, stopSyncTimer } from './game/sync.js'; +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; } 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.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, 'room_update', { room: roomSummary(room) }); @@ -26,6 +28,46 @@ export function setupWebSocket(server) { ws.on('message', async (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } + // Automatic answer check (anyone can try during guess phase) + if (msg.type === 'submit_answer') { + 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; } + 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; } + 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; + if (correct) { + room.state.awardedThisRound = room.state.awardedThisRound || {}; + 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; + awarded = true; + } + } + send('answer_result', { + ok: true, + correctTitle: titleScore.pass, + 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 }, + awarded, + alreadyAwarded, + }); + 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 === 'join_room') { diff --git a/src/server/game/answerCheck.js b/src/server/game/answerCheck.js new file mode 100644 index 0000000..7793813 --- /dev/null +++ b/src/server/game/answerCheck.js @@ -0,0 +1,107 @@ +// Fuzzy matching helpers for title/artist guessing + +function stripDiacritics(s) { + return String(s).normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); +} + +function normalizeCommon(s) { + return stripDiacritics(String(s)) + .toLowerCase() + .replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +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, ''); + return s; +} + +function normalizeTitle(s) { return normalizeCommon(cleanTitleNoise(s)); } +function normalizeArtist(s) { return normalizeCommon(s).replace(/\bthe\b/g, ' ').replace(/\s+/g, ' ').trim(); } + +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); + if (A.size === 0 && B.size === 0) return 1; + 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; +} + +function levenshtein(a, b) { + a = String(a); + b = String(b); + const m = a.length, n = b.length; + if (!m) return n; + if (!n) return m; + const dp = new Array(n + 1); + for (let j = 0; j <= n; j++) dp[j] = j; + for (let i = 1; i <= m; i++) { + let prev = dp[0]; + dp[0] = i; + for (let j = 1; j <= n; j++) { + const temp = dp[j]; + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost); + prev = temp; + } + } + return dp[n]; +} +function simRatio(a, b) { + if (!a && !b) return 1; + if (!a || !b) return 0; + const d = levenshtein(a, b); + return 1 - d / Math.max(String(a).length, String(b).length); +} + +export function splitArtists(raw) { + const unified = String(raw) + .replace(/\s*(?:,|&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/gi, ',') + .replace(/,+/g, ',') + .replace(/(^,|,$)/g, ''); + const parts = unified.split(',').map(normalizeArtist).filter(Boolean); + return Array.from(new Set(parts)); +} + +const TITLE_SIM_THRESHOLD = 0.86; +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 }; +} + +export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) { + const truthArtists = (truthArtistsRaw || []).map((a) => normalizeArtist(a)); + const truthSet = new Set(truthArtists); + const guessArtists = splitArtists(guessRaw); + const matches = new Set(); + for (const ga of guessArtists) { + for (const ta of truthSet) { + const s = simRatio(ga, ta); + if (s >= ARTIST_SIM_THRESHOLD) matches.add(ta); + } + } + 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)); } + return { pass, best, matched: Array.from(matches), guessArtists, truthArtists }; +} + +export default { scoreTitle, scoreArtist, splitArtists };