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

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