feat: implement answer submission and scoring system for guessing game

This commit is contained in:
2025-09-04 15:11:03 +02:00
parent bbce3cbadf
commit 158d144721
7 changed files with 211 additions and 498 deletions

View File

@@ -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') {

View File

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