feat: implement answer submission and scoring system for guessing game
This commit is contained in:
@@ -125,6 +125,20 @@
|
|||||||
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Answer form: everyone can try to guess title & artist for a coin -->
|
||||||
|
<form id="answerForm" class="mt-4 w-full flex flex-col md:flex-row gap-2 md:items-end">
|
||||||
|
<label class="flex-1 text-sm">
|
||||||
|
<span class="text-slate-700 dark:text-slate-300">Titel</span>
|
||||||
|
<input id="guessTitle" name="title" placeholder="Songtitel" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
||||||
|
</label>
|
||||||
|
<label class="flex-1 text-sm">
|
||||||
|
<span class="text-slate-700 dark:text-slate-300">Künstler</span>
|
||||||
|
<input id="guessArtist" name="artist" placeholder="Künstler" autocomplete="off" class="mt-1 w-full h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3" />
|
||||||
|
</label>
|
||||||
|
<button id="submitAnswer" type="submit" class="h-10 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium">Abschicken</button>
|
||||||
|
</form>
|
||||||
|
<div id="answerResult" class="mt-1 text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
@@ -147,7 +161,6 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
<div class="flex items-center gap-3 text-slate-700 dark:text-slate-300">
|
||||||
Tokens: <span id="tokens" class="font-semibold">0</span>
|
Tokens: <span id="tokens" class="font-semibold">0</span>
|
||||||
<button id="earnToken" class="h-9 px-3 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-sm">+1 (Titel & Künstler richtig)</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export const $leaveRoom = el('leaveRoom');
|
|||||||
export const $earnToken = el('earnToken');
|
export const $earnToken = el('earnToken');
|
||||||
export const $dashboardList = el('dashboardList');
|
export const $dashboardList = el('dashboardList');
|
||||||
export const $toast = el('toast');
|
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 showLobby() { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); }
|
||||||
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
|
export function showRoom() { $lobby.classList.add('hidden'); $room.classList.remove('hidden'); }
|
||||||
|
|||||||
@@ -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 { state } from './state.js';
|
||||||
import { connectWS, sendMsg } from './ws.js';
|
import { connectWS, sendMsg } from './ws.js';
|
||||||
import { renderRoom } from './render.js';
|
import { renderRoom } from './render.js';
|
||||||
@@ -44,6 +44,10 @@ function handlePlayTrack(msg) {
|
|||||||
$npTitle.textContent = '???';
|
$npTitle.textContent = '???';
|
||||||
$npArtist.textContent = '';
|
$npArtist.textContent = '';
|
||||||
$npYear.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 {
|
try {
|
||||||
$audio.preload = 'auto';
|
$audio.preload = 'auto';
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -151,6 +155,25 @@ function onMessage(ev) {
|
|||||||
case 'game_ended':
|
case 'game_ended':
|
||||||
handleGameEnded(msg);
|
handleGameEnded(msg);
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,6 +264,20 @@ function wireUi() {
|
|||||||
});
|
});
|
||||||
$roomId.style.cursor = 'pointer';
|
$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
|
// boot
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { badgeColorForYear } from '../utils/colors.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) {
|
export function renderRoom(room) {
|
||||||
state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; }
|
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=''; } }
|
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);
|
const canNext = room.state.status==='playing' && room.state.phase==='reveal' && (isHost || room.state.currentGuesser===state.playerId);
|
||||||
if ($nextArea) $nextArea.classList.toggle('hidden', !canNext);
|
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) {
|
export function shortName(id) {
|
||||||
|
|||||||
495
server.js
495
server.js
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -3,12 +3,14 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
||||||
import { loadDeck } from './game/deck.js';
|
import { loadDeck } from './game/deck.js';
|
||||||
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
|
||||||
|
import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
||||||
|
|
||||||
function drawNextTrack(room) {
|
function drawNextTrack(room) {
|
||||||
const track = room.deck.shift();
|
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.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;
|
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) });
|
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||||
@@ -26,6 +28,46 @@ export function setupWebSocket(server) {
|
|||||||
ws.on('message', async (raw) => {
|
ws.on('message', async (raw) => {
|
||||||
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
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 === '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 === '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') {
|
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