diff --git a/Dockerfile b/Dockerfile index 5f73241..4dbdc40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,4 +15,4 @@ ENV NODE_ENV=production \ EXPOSE 5173 -CMD ["node", "server.js"] +CMD ["node", "src/server/index.js"] diff --git a/data/years.json b/data/years.json index c1ff079..f52d851 100644 --- a/data/years.json +++ b/data/years.json @@ -4615,8 +4615,8 @@ "title": "I Heard It Through The Grapevine", "artist": "Marvin Gaye", "mbid": "d97b9286-07ce-436f-bb31-2d880b9476ee", - "earliestDate": "1988", - "year": 1988, + "earliestDate": "1968", + "year": 1968, "confidence": { "mbScore": 100, "titleSim": 1, @@ -5314,8 +5314,8 @@ "title": "La Bamba", "artist": "Ritchie Valens", "mbid": "821f5395-f90f-432f-a807-da8dbb45ceb6", - "earliestDate": "1963", - "year": 1963, + "earliestDate": "1958", + "year": 1958, "confidence": { "mbScore": 96, "titleSim": 1, diff --git a/package.json b/package.json index ecb1fb9..bfad438 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,11 @@ "version": "0.1.0", "private": true, "description": "Local Hitster-like multiplayer web app using WebSockets and local MP3s", - "main": "server.js", + "main": "src/server/index.js", "type": "module", "scripts": { - "start": "node server.js", - "dev": "nodemon server.js", + "start": "node src/server/index.js", + "dev": "nodemon src/server/index.js", "years:resolve": "node scripts/resolve-years.js", "years:resolve:10": "node scripts/resolve-years.js --max 10", "years:force": "node scripts/resolve-years.js --force" diff --git a/public/client.js b/public/client.js index 4a2a367..0bf1acf 100644 --- a/public/client.js +++ b/public/client.js @@ -1,3 +1,5 @@ +import { badgeColorForYear } from './utils/colors.js'; + let ws; let reconnectAttempts = 0; let reconnectTimer = null; @@ -233,17 +235,7 @@ function shortName(id) { function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } -// Stable distinct color per year for the year badge -function badgeColorForYear(y) { - const val = (y === undefined || y === null) ? '?' : y; - if (val === '?' || Number.isNaN(Number(val))) { - // Neutral slate for unknown years - return 'background-color: hsl(215 16% 34%);'; - } - const n = Number(val); - const hue = ((n * 23) % 360 + 360) % 360; // spread hues deterministically - return `background-color: hsl(${hue} 70% 42%);`; -} +// color helper imported from utils/colors.js function handleMessage(ev) { const msg = JSON.parse(ev.data); diff --git a/public/index.html b/public/index.html index ebead02..063cf8b 100644 --- a/public/index.html +++ b/public/index.html @@ -152,6 +152,6 @@ - + diff --git a/public/js/audio.js b/public/js/audio.js new file mode 100644 index 0000000..dfd4ff4 --- /dev/null +++ b/public/js/audio.js @@ -0,0 +1,47 @@ +import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js'; +import { state } from './state.js'; + +export function initAudioUI() { + try { + if ('preservesPitch' in $audio) $audio.preservesPitch = true; + if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true; + if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true; + } catch {} + $audio.addEventListener('timeupdate', () => { + const dur = $audio.duration || 0; if (!dur || !$progressFill) return; + const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100)); + $progressFill.style.width = pct + '%'; + }); + const showBuffer = (v) => { + state.isBuffering = v; + if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v); + if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused); + }; + $audio.addEventListener('waiting', () => showBuffer(true)); + $audio.addEventListener('stalled', () => showBuffer(true)); + $audio.addEventListener('canplay', () => showBuffer(false)); + $audio.addEventListener('playing', () => showBuffer(false)); + $audio.addEventListener('ended', () => { if ($recordDisc) $recordDisc.classList.remove('spin-record'); $audio.playbackRate = 1.0; }); +} + +export function applySync(startAt, serverNow) { + if (!startAt || !serverNow) return; + if (state.room?.state?.paused) return; + if (state.isBuffering) return; + const now = Date.now(); + const elapsed = (now - startAt) / 1000; + const drift = ($audio.currentTime || 0) - elapsed; + const abs = Math.abs(drift); + if (abs > 1.0) { + $audio.currentTime = Math.max(0, elapsed); + if ($audio.paused) $audio.play().catch(()=>{}); + $audio.playbackRate = 1.0; + } else if (abs > 0.12) { + const maxNudge = 0.03; + const sign = drift > 0 ? -1 : 1; + const rate = 1 + sign * Math.min(maxNudge, abs * 0.5); + $audio.playbackRate = Math.max(0.8, Math.min(1.2, rate)); + } else { + if (Math.abs($audio.playbackRate - 1) > 0.001) { $audio.playbackRate = 1.0; } + } +} diff --git a/public/js/dom.js b/public/js/dom.js new file mode 100644 index 0000000..4e0f8f0 --- /dev/null +++ b/public/js/dom.js @@ -0,0 +1,43 @@ +const el = (id) => document.getElementById(id); + +export const $lobby = el('lobby'); +export const $room = el('room'); +export const $roomId = el('roomId'); +export const $status = el('status'); +export const $guesser = el('guesser'); +export const $timeline = el('timeline'); +export const $tokens = el('tokens'); +export const $audio = el('audio'); +export const $np = el('nowPlaying'); +export const $npTitle = el('npTitle'); +export const $npArtist = el('npArtist'); +export const $npYear = el('npYear'); +export const $readyChk = el('readyChk'); +export const $startGame = el('startGame'); +export const $revealBanner = el('revealBanner'); +export const $placeArea = el('placeArea'); +export const $slotSelect = el('slotSelect'); +export const $placeBtn = el('placeBtn'); +export const $mediaControls = el('mediaControls'); +export const $playBtn = el('playBtn'); +export const $pauseBtn = el('pauseBtn'); +export const $nextArea = el('nextArea'); +export const $nextBtn = el('nextBtn'); +export const $recordDisc = el('recordDisc'); +export const $progressFill = el('progressFill'); +export const $volumeSlider = el('volumeSlider'); +export const $bufferBadge = el('bufferBadge'); +export const $copyRoomCode = el('copyRoomCode'); +export const $nameLobby = el('name'); +export const $setNameLobby = el('setName'); +export const $nameDisplay = el('nameDisplay'); +export const $createRoom = el('createRoom'); +export const $joinRoom = el('joinRoom'); +export const $roomCode = el('roomCode'); +export const $leaveRoom = el('leaveRoom'); +export const $earnToken = el('earnToken'); +export const $dashboardList = el('dashboardList'); +export const $toast = el('toast'); + +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 new file mode 100644 index 0000000..fea085a --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,261 @@ +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 { state } from './state.js'; +import { connectWS, sendMsg } from './ws.js'; +import { renderRoom } from './render.js'; +import { initAudioUI, applySync } from './audio.js'; + +function showToast(msg) { + const el = document.getElementById('toast'); + if (!el) { + return; + } + el.textContent = msg; + el.style.opacity = '1'; + setTimeout(() => { + el.style.opacity = '0'; + }, 1200); +} + +function handleConnected(msg) { + state.playerId = msg.playerId; + const stored = localStorage.getItem('playerName'); + if (stored) { + if ($nameLobby && $nameLobby.value !== stored) { + $nameLobby.value = stored; + } + if ($nameDisplay) { + $nameDisplay.textContent = stored; + } + sendMsg({ type: 'set_name', name: stored }); + } + if (state.room?.id) { + sendMsg({ type: 'join_room', code: state.room.id }); + } +} + +function handleRoomUpdate(msg) { + renderRoom(msg.room); +} + +function handlePlayTrack(msg) { + const t = msg.track; + state.lastTrack = t; + state.revealed = false; + $npTitle.textContent = '???'; + $npArtist.textContent = ''; + $npYear.textContent = ''; + try { + $audio.preload = 'auto'; + } catch {} + $audio.src = t.url; + const pf = document.getElementById('progressFill'); + if (pf) { + pf.style.width = '0%'; + } + const rd = document.getElementById('recordDisc'); + if (rd) { + rd.classList.remove('spin-record'); + } + const { startAt, serverNow } = msg; + const now = Date.now(); + const offsetMs = startAt - serverNow; + const localStart = now + offsetMs; + const delay = Math.max(0, localStart - now); + setTimeout(() => { + $audio.currentTime = 0; + $audio.play().catch(() => {}); + const disc = document.getElementById('recordDisc'); + if (disc) { + disc.classList.add('spin-record'); + } + }, delay); + if (state.room) { + renderRoom(state.room); + } +} + +function handleSync(msg) { + applySync(msg.startAt, msg.serverNow); +} + +function handleControl(msg) { + const { action, startAt, serverNow } = msg; + if (action === 'pause') { + $audio.pause(); + const disc = document.getElementById('recordDisc'); + if (disc) { + disc.classList.remove('spin-record'); + } + $audio.playbackRate = 1.0; + } else if (action === 'play') { + if (startAt && serverNow) { + const now = Date.now(); + const elapsed = (now - startAt) / 1000; + $audio.currentTime = Math.max(0, elapsed); + } + $audio.play().catch(() => {}); + const disc = document.getElementById('recordDisc'); + if (disc) { + disc.classList.add('spin-record'); + } + } +} + +function handleReveal(msg) { + const { result, track } = msg; + $npTitle.textContent = track.title || track.id || 'Track'; + $npArtist.textContent = track.artist ? ` – ${track.artist}` : ''; + $npYear.textContent = track.year ? ` (${track.year})` : ''; + state.revealed = true; + const $rb = document.getElementById('revealBanner'); + if ($rb) { + if (result.correct) { + $rb.textContent = 'Richtig!'; + $rb.className = 'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium'; + } else { + $rb.textContent = 'Falsch!'; + $rb.className = 'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium'; + } + } + const $placeArea = document.getElementById('placeArea'); + if ($placeArea) { + $placeArea.classList.add('hidden'); + } +} + +function handleGameEnded(msg) { + alert(`Gewinner: ${shortName(msg.winner)}`); +} + +function onMessage(ev) { + const msg = JSON.parse(ev.data); + switch (msg.type) { + case 'connected': + handleConnected(msg); + break; + case 'room_update': + handleRoomUpdate(msg); + break; + case 'play_track': + handlePlayTrack(msg); + break; + case 'sync': + handleSync(msg); + break; + case 'control': + handleControl(msg); + break; + case 'reveal': + handleReveal(msg); + break; + case 'game_ended': + handleGameEnded(msg); + break; + default: + break; + } +} + +function shortName(id) { + if (!id) return '-'; + const p = state.room?.players.find((x) => x.id === id); + return p ? p.name : id.slice(0, 4); +} + +function wire(el, type, handler) { + if (el) { + el.addEventListener(type, handler); + } +} + +function wireUi() { + initAudioUI(); + wire($setNameLobby, 'click', () => { + const name = $nameLobby.value.trim(); + if (!name) return; + localStorage.setItem('playerName', name); + if ($nameDisplay) { + $nameDisplay.textContent = name; + } + sendMsg({ type: 'set_name', name }); + }); + wire($createRoom, 'click', () => sendMsg({ type: 'create_room' })); + wire($joinRoom, 'click', () => { + const code = $roomCode.value.trim(); + if (code) { + sendMsg({ type: 'join_room', code }); + } + }); + wire($leaveRoom, 'click', () => { + sendMsg({ type: 'leave_room' }); + state.room = null; + $lobby.classList.remove('hidden'); + $room.classList.add('hidden'); + }); + wire($startGame, 'click', () => sendMsg({ type: 'start_game' })); + wire($readyChk, 'change', (e) => { + const val = !!e.target.checked; + state.pendingReady = val; + sendMsg({ type: 'set_ready', ready: val }); + }); + wire($placeBtn, 'click', () => { + const slot = parseInt($slotSelect.value, 10); + sendMsg({ type: 'place_guess', slot }); + }); + wire($playBtn, 'click', () => sendMsg({ type: 'player_control', action: 'play' })); + wire($pauseBtn, 'click', () => sendMsg({ type: 'player_control', action: 'pause' })); + wire($nextBtn, 'click', () => sendMsg({ type: 'next_track' })); + if ($volumeSlider && $audio) { + try { + $volumeSlider.value = String($audio.volume ?? 1); + } catch {} + $volumeSlider.addEventListener('input', () => { + $audio.volume = parseFloat($volumeSlider.value); + }); + } + if ($copyRoomCode) { + $copyRoomCode.style.display = 'inline-block'; + wire($copyRoomCode, 'click', () => { + if (state.room?.id) { + navigator.clipboard.writeText(state.room.id).then(() => { + $copyRoomCode.textContent = '✔️'; + showToast('Code kopiert!'); + setTimeout(() => { + $copyRoomCode.textContent = '📋'; + }, 1200); + }); + } + }); + } + if ($roomId) { + wire($roomId, 'click', () => { + if (state.room?.id) { + navigator.clipboard.writeText(state.room.id).then(() => { + $roomId.title = 'Kopiert!'; + showToast('Code kopiert!'); + setTimeout(() => { + $roomId.title = 'Klicken zum Kopieren'; + }, 1200); + }); + } + }); + $roomId.style.cursor = 'pointer'; + } +} + +// boot +wireUi(); +connectWS(onMessage); + +// restore name immediately if present +(() => { + const saved = localStorage.getItem('playerName'); + if (saved) { + if ($nameLobby && $nameLobby.value !== saved) { + $nameLobby.value = saved; + } + if ($nameDisplay) { + $nameDisplay.textContent = saved; + } + } +})(); diff --git a/public/js/render.js b/public/js/render.js new file mode 100644 index 0000000..72756b3 --- /dev/null +++ b/public/js/render.js @@ -0,0 +1,98 @@ +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'; + +export function renderRoom(room) { + state.room = room; if (!room) { $lobby.classList.remove('hidden'); $room.classList.add('hidden'); return; } + $lobby.classList.add('hidden'); $room.classList.remove('hidden'); + $roomId.textContent = room.id; + $status.textContent = room.state.status; + $guesser.textContent = shortName(room.state.currentGuesser); + const me = room.players.find(p=>p.id===state.playerId); + if ($nameDisplay) $nameDisplay.textContent = (me?.name || localStorage.getItem('playerName') || '-'); + if ($dashboardList) { + $dashboardList.innerHTML = room.players.map(p => { + const connected = p.connected ? 'online' : 'offline'; + const ready = p.ready ? 'bereit' : '-'; + const score = (room.state.timeline?.[p.id]?.length) ?? 0; + const isMe = p.id === state.playerId; + return ` + + +
+ ${escapeHtml(p.name)}${p.spectator ? ' 👻' : ''} + ${p.id===room.hostId ? '\u2B50' : ''} + ${isMe ? '(du)' : ''} +
+ + ${connected} + ${ready} + ${score} + `; + }).join(''); + } + const myTl = room.state.timeline?.[state.playerId] || []; + $timeline.innerHTML = myTl.map(t => { + const title = escapeHtml(t.title || t.trackId || 'Unbekannt'); + const artist = t.artist ? escapeHtml(t.artist) : ''; + const year = (t.year ?? '?'); + const badgeStyle = badgeColorForYear(year); + return ` +
+
${year}
+
+
${title}
+
${artist}
+
+
+ `; + }).join(''); + $tokens.textContent = room.state.tokens?.[state.playerId] ?? 0; + if ($readyChk) { + const serverReady = !!me?.ready; + if (state.pendingReady === null || state.pendingReady === undefined) { $readyChk.checked = serverReady; } + else { $readyChk.checked = !!state.pendingReady; if (serverReady === state.pendingReady) state.pendingReady = null; } + $readyChk.parentElement.classList.toggle('hidden', room.state.status !== 'lobby'); + } + const isHost = state.playerId === room.hostId; + const allReady = room.players.length>0 && room.players.every(p=>p.ready); + if ($startGame) $startGame.classList.toggle('hidden', !(isHost && room.state.status==='lobby' && allReady)); + const isMyTurn = room.state.status==='playing' && room.state.phase==='guess' && room.state.currentGuesser===state.playerId && room.state.currentTrack; + const canGuess = isMyTurn; + // Build slot options for insertion positions when it's my turn + if ($placeArea && $slotSelect) { + if (canGuess) { + const tl = room.state.timeline?.[state.playerId] || []; + $slotSelect.innerHTML = ''; + for (let i = 0; i <= tl.length; i++) { + const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null; + const right = i < tl.length ? (tl[i]?.year ?? '?') : null; + let label = ''; + if (tl.length === 0) label = 'Einsetzen'; + else if (i === 0) label = `Vor (${right})`; + else if (i === tl.length) label = `Nach (${left})`; + else label = `Zwischen (${left} / ${right})`; + const opt = document.createElement('option'); + opt.value = String(i); + opt.textContent = label; + $slotSelect.appendChild(opt); + } + } else { + // Clear options when not guessing + $slotSelect.innerHTML = ''; + } + $placeArea.classList.toggle('hidden', !canGuess); + } + $np.classList.toggle('hidden', !room.state.currentTrack); + 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); +} + +export function shortName(id) { + if (!id) return '-'; + const p = state.room?.players.find(x=>x.id===id); + return p ? p.name : id.slice(0,4); +} + +export function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..66cc6a7 --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,8 @@ +export const state = { + playerId: null, + room: null, + lastTrack: null, + revealed: false, + pendingReady: null, + isBuffering: false, +}; diff --git a/public/js/ws.js b/public/js/ws.js new file mode 100644 index 0000000..e813fd1 --- /dev/null +++ b/public/js/ws.js @@ -0,0 +1,35 @@ +import { state } from './state.js'; + +let ws; +let reconnectAttempts = 0; +let reconnectTimer = null; +const outbox = []; + +export function wsIsOpen() { return ws && ws.readyState === WebSocket.OPEN; } +export function sendMsg(obj) { if (wsIsOpen()) ws.send(JSON.stringify(obj)); else outbox.push(obj); } + +function scheduleReconnect(connect) { + if (reconnectTimer) return; + const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts)); + reconnectAttempts++; + reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); +} + +export function connectWS(onMessage) { + const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host; + ws = new WebSocket(url); + ws.addEventListener('open', () => { + reconnectAttempts = 0; + setTimeout(() => { while (outbox.length && wsIsOpen()) { try { ws.send(JSON.stringify(outbox.shift())); } catch { break; } } }, 100); + }); + ws.addEventListener('message', (ev) => onMessage(ev)); + ws.addEventListener('close', () => { scheduleReconnect(() => connectWS(onMessage)); }); + ws.addEventListener('error', () => { try { ws.close(); } catch {} }); +} + +window.addEventListener('online', () => { + if (!wsIsOpen()) { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + // Kick off a reconnect by calling connectWS from app main again + } +}); diff --git a/public/utils/colors.js b/public/utils/colors.js new file mode 100644 index 0000000..8a325aa --- /dev/null +++ b/public/utils/colors.js @@ -0,0 +1,11 @@ +// Stable distinct color per year for the year badge +export function badgeColorForYear(y) { + const val = (y === undefined || y === null) ? '?' : y; + if (val === '?' || Number.isNaN(Number(val))) { + // Neutral slate for unknown years + return 'background-color: hsl(215 16% 34%);'; + } + const n = Number(val); + const hue = ((n * 23) % 360 + 360) % 360; // spread hues deterministically + return `background-color: hsl(${hue} 70% 42%);`; +} diff --git a/src/server/config.js b/src/server/config.js new file mode 100644 index 0000000..1842088 --- /dev/null +++ b/src/server/config.js @@ -0,0 +1,12 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '..', '..'); + +export const PORT = process.env.PORT || 5173; +export const DATA_DIR = path.resolve(ROOT_DIR, 'data'); +export const PUBLIC_DIR = path.resolve(ROOT_DIR, 'public'); +export const YEARS_PATH = path.join(DATA_DIR, 'years.json'); +export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH }; diff --git a/src/server/game.js b/src/server/game.js new file mode 100644 index 0000000..4559b3e --- /dev/null +++ b/src/server/game.js @@ -0,0 +1,81 @@ +import { WebSocketServer } from 'ws'; +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'; + +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.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) }); + startSyncTimer(room); +} + +export function setupWebSocket(server) { + 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 }; + const 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 === '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 (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' }); + 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 = await loadDeck(); room.discard = []; room.state.phase = 'guess'; room.state.lastResult = null; drawNextTrack(room); return; } + if (msg.type === 'player_control') { + const room = rooms.get(player.roomId); if (!room) return; const { action } = msg; + if (room.state.status !== 'playing') return; if (room.state.phase !== 'guess') return; if (room.state.currentGuesser !== player.id) return; if (!room.state.currentTrack) 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' }); } + 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 === 'place_guess') { + const room = rooms.get(player.roomId); if (!room) return; const { position, slot: rawSlot } = msg; + 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' }); + 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; + let correct = false; + if (current.year != null) { + if (n === 0) correct = slot === 0; + 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) { 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); } + room.state.phase = 'reveal'; room.state.lastResult = { playerId: player.id, correct }; broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack }); broadcast(room, 'room_update', { room: roomSummary(room) }); + const tlNow = room.state.timeline[player.id] || []; if (correct && tlNow.length >= room.state.goal) { room.state.status = 'ended'; room.state.winner = player.id; broadcast(room, 'game_ended', { winner: player.id }); return; } + 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; const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser; if (!isAuthorized) return; + 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); broadcast(room, 'room_update', { room: roomSummary(room) }); } }); + }); +} diff --git a/src/server/game/deck.js b/src/server/game/deck.js new file mode 100644 index 0000000..0c49c23 --- /dev/null +++ b/src/server/game/deck.js @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; +import { parseFile as mmParseFile } from 'music-metadata'; +import { DATA_DIR } from '../config.js'; +import { loadYearsIndex } from '../years.js'; +import { shuffle } from './state.js'; + +export async function loadDeck() { + const years = loadYearsIndex(); + 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, title = path.parse(f).name, 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[f]?.year ?? year; return { id: f, file: f, title, artist, year: y }; + })); + return shuffle(tracks); +} diff --git a/src/server/game/state.js b/src/server/game/state.js new file mode 100644 index 0000000..cb45292 --- /dev/null +++ b/src/server/game/state.js @@ -0,0 +1,71 @@ +export const rooms = new Map(); + +export 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; +} + +export 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]; +} + +export 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', + turnOrder: [], + currentGuesser: null, + currentTrack: null, + timeline: {}, + tokens: {}, + ready: { [host.id]: false }, + spectators: {}, + phase: 'guess', + lastResult: null, + trackStartAt: null, + paused: false, + pausedPosSec: 0, + goal: 10, + }, + }; + rooms.set(id, room); + return room; +} + +export function broadcast(room, type, payload) { + for (const p of room.players.values()) { + try { p.ws.send(JSON.stringify({ type, ...payload })); } catch {} + } +} + +export 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, + }; +} diff --git a/src/server/game/sync.js b/src/server/game/sync.js new file mode 100644 index 0000000..f14aa90 --- /dev/null +++ b/src/server/game/sync.js @@ -0,0 +1,13 @@ +import { broadcast } from './state.js'; + +export 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); +} + +export function stopSyncTimer(room) { + if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; } +} diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..e29edde --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,37 @@ +import express from 'express'; +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js'; +import { registerAudioRoutes } from './routes/audio.js'; +import { registerTracksApi } from './tracks.js'; +import { loadYearsIndex } from './years.js'; +import { setupWebSocket } from './game.js'; + +// Ensure data dir exists +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +const app = express(); + +// Static client +app.use(express.static(PUBLIC_DIR)); + +// Years reload endpoint +app.get('/api/reload-years', (req, res) => { + const years = loadYearsIndex(); + res.json({ ok: true, count: Object.keys(years).length }); +}); + +// Routes +registerAudioRoutes(app); +registerTracksApi(app); + +const server = http.createServer(app); +setupWebSocket(server); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Hitstar server running on http://localhost:${PORT}`); +}); diff --git a/src/server/routes/audio.js b/src/server/routes/audio.js new file mode 100644 index 0000000..a8e1c49 --- /dev/null +++ b/src/server/routes/audio.js @@ -0,0 +1,58 @@ +import fs from 'fs'; +import path from 'path'; +import mime from 'mime'; +import { DATA_DIR } from '../config.js'; + +export function registerAudioRoutes(app) { + 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; + 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); + } + }); +} diff --git a/src/server/tracks.js b/src/server/tracks.js new file mode 100644 index 0000000..54bd46b --- /dev/null +++ b/src/server/tracks.js @@ -0,0 +1,38 @@ +import fs from 'fs'; +import path from 'path'; +import { parseFile as mmParseFile } from 'music-metadata'; +import { DATA_DIR } from './config.js'; +import { loadYearsIndex } from './years.js'; + +export async function listTracks() { + const years = loadYearsIndex(); + 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[f]?.year ?? year; + return { id: f, file: f, title, artist, year: y }; + }) + ); + return tracks; +} + +export function registerTracksApi(app) { + app.get('/api/tracks', async (req, res) => { + try { + const tracks = await listTracks(); + res.json({ tracks }); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); +} diff --git a/src/server/years.js b/src/server/years.js new file mode 100644 index 0000000..3c0bb26 --- /dev/null +++ b/src/server/years.js @@ -0,0 +1,11 @@ +import fs from 'fs'; +import { YEARS_PATH } from './config.js'; + +export 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 {}; +}