feat: implement answer submission and scoring system for guessing game
This commit is contained in:
@@ -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') {
|
||||
|
||||
107
src/server/game/answerCheck.js
Normal file
107
src/server/game/answerCheck.js
Normal 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 };
|
||||
Reference in New Issue
Block a user