feat: restructure client-side code

This commit is contained in:
2025-09-04 12:33:17 +02:00
parent edaf9ea94e
commit bbce3cbadf
21 changed files with 854 additions and 20 deletions

12
src/server/config.js Normal file
View File

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

81
src/server/game.js Normal file
View File

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

18
src/server/game/deck.js Normal file
View File

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

71
src/server/game/state.js Normal file
View File

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

13
src/server/game/sync.js Normal file
View File

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

37
src/server/index.js Normal file
View File

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

View File

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

38
src/server/tracks.js Normal file
View File

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

11
src/server/years.js Normal file
View File

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