|
|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
import { WebSocketServer } from 'ws';
|
|
|
|
|
import { Server as SocketIOServer } from 'socket.io';
|
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
|
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
|
|
|
|
|
import { loadDeck } from './game/deck.js';
|
|
|
|
|
@@ -7,24 +7,50 @@ import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
|
|
|
|
|
|
|
|
|
|
function drawNextTrack(room) {
|
|
|
|
|
const track = room.deck.shift();
|
|
|
|
|
if (!track) { room.state.status = 'ended'; room.state.winner = null; broadcast(room, 'game_ended', { winner: null }); return; }
|
|
|
|
|
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.phase = 'guess';
|
|
|
|
|
room.state.lastResult = null;
|
|
|
|
|
room.state.paused = false;
|
|
|
|
|
room.state.pausedPosSec = 0;
|
|
|
|
|
room.state.awardedThisRound = {}; // reset per-round coin awards
|
|
|
|
|
room.state.trackStartAt = Date.now() + 800;
|
|
|
|
|
broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() });
|
|
|
|
|
broadcast(room, '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) => {
|
|
|
|
|
// Create a tentative player identity, but don't immediately commit or send it.
|
|
|
|
|
const newId = uuidv4();
|
|
|
|
|
const newSessionId = uuidv4();
|
|
|
|
|
let player = { id: newId, sessionId: newSessionId, name: `Player-${newId.slice(0, 4)}`, ws, connected: true, roomId: null };
|
|
|
|
|
const send = (type, payload) => { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} };
|
|
|
|
|
const io = new SocketIOServer(server, {
|
|
|
|
|
transports: ['websocket'],
|
|
|
|
|
cors: { origin: true, methods: ['GET', 'POST'] },
|
|
|
|
|
});
|
|
|
|
|
io.on('connection', (socket) => {
|
|
|
|
|
// Create a tentative player identity, but don't immediately commit or send it.
|
|
|
|
|
const newId = uuidv4();
|
|
|
|
|
const newSessionId = uuidv4();
|
|
|
|
|
let player = {
|
|
|
|
|
id: newId,
|
|
|
|
|
sessionId: newSessionId,
|
|
|
|
|
name: `Player-${newId.slice(0, 4)}`,
|
|
|
|
|
ws: socket,
|
|
|
|
|
connected: true,
|
|
|
|
|
roomId: null,
|
|
|
|
|
};
|
|
|
|
|
const send = (type, payload) => {
|
|
|
|
|
try {
|
|
|
|
|
socket.emit('message', { type, ...payload });
|
|
|
|
|
} catch {}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// To avoid overwriting an existing session on reconnect, delay the initial
|
|
|
|
|
// welcome until we see if the client sends a resume message.
|
|
|
|
|
@@ -42,30 +68,49 @@ export function setupWebSocket(server) {
|
|
|
|
|
function isParticipant(room, pid) {
|
|
|
|
|
if (!room) return false;
|
|
|
|
|
if (room.state.turnOrder?.includes(pid)) return true;
|
|
|
|
|
if (room.state.timeline && Object.prototype.hasOwnProperty.call(room.state.timeline, pid)) return true;
|
|
|
|
|
if (room.state.tokens && Object.prototype.hasOwnProperty.call(room.state.tokens, pid)) return true;
|
|
|
|
|
if (room.state.timeline && Object.hasOwn(room.state.timeline, pid)) return true;
|
|
|
|
|
if (room.state.tokens && Object.hasOwn(room.state.tokens, pid)) return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.on('message', async (raw) => {
|
|
|
|
|
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
|
|
|
socket.on('message', async (msg) => {
|
|
|
|
|
if (!msg || typeof msg !== 'object') return;
|
|
|
|
|
|
|
|
|
|
// Allow client to resume by session token
|
|
|
|
|
if (msg.type === 'resume') {
|
|
|
|
|
clearTimeout(helloTimer);
|
|
|
|
|
const reqSession = String(msg.sessionId || '');
|
|
|
|
|
if (!reqSession) { send('resume_result', { ok: false, reason: 'no_session' }); return; }
|
|
|
|
|
let found = null; let foundRoom = null;
|
|
|
|
|
if (!reqSession) {
|
|
|
|
|
send('resume_result', { ok: false, reason: 'no_session' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let found = null;
|
|
|
|
|
let foundRoom = null;
|
|
|
|
|
for (const room of rooms.values()) {
|
|
|
|
|
for (const p of room.players.values()) {
|
|
|
|
|
if (p.sessionId === reqSession) { found = p; foundRoom = room; break; }
|
|
|
|
|
if (p.sessionId === reqSession) {
|
|
|
|
|
found = p;
|
|
|
|
|
foundRoom = room;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (found) break;
|
|
|
|
|
}
|
|
|
|
|
if (!found) { send('resume_result', { ok: false, reason: 'not_found' }); return; }
|
|
|
|
|
if (!found) {
|
|
|
|
|
send('resume_result', { ok: false, reason: 'not_found' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Rebind socket and mark connected
|
|
|
|
|
try { if (found.ws && found.ws !== ws) { try { found.ws.terminate(); } catch {} } } catch {}
|
|
|
|
|
found.ws = ws; found.connected = true; player = found; // switch our local reference to the existing player
|
|
|
|
|
try {
|
|
|
|
|
if (found.ws?.id && found.ws.id !== socket.id) {
|
|
|
|
|
try {
|
|
|
|
|
found.ws.disconnect(true);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
found.ws = socket;
|
|
|
|
|
found.connected = true;
|
|
|
|
|
player = found; // switch our local reference to the existing player
|
|
|
|
|
// If they were a participant, ensure they are not marked spectator
|
|
|
|
|
if (foundRoom) {
|
|
|
|
|
if (isParticipant(foundRoom, found.id)) {
|
|
|
|
|
@@ -75,10 +120,10 @@ export function setupWebSocket(server) {
|
|
|
|
|
// Notify room
|
|
|
|
|
broadcast(foundRoom, 'room_update', { room: roomSummary(foundRoom) });
|
|
|
|
|
}
|
|
|
|
|
// Send resume result and an explicit connected that preserves their original session.
|
|
|
|
|
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
|
|
|
|
|
helloSent = true;
|
|
|
|
|
send('connected', { playerId: found.id, sessionId: found.sessionId });
|
|
|
|
|
// Send resume result and an explicit connected that preserves their original session.
|
|
|
|
|
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
|
|
|
|
|
helloSent = true;
|
|
|
|
|
send('connected', { playerId: found.id, sessionId: found.sessionId });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -87,21 +132,35 @@ export function setupWebSocket(server) {
|
|
|
|
|
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; }
|
|
|
|
|
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; }
|
|
|
|
|
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;
|
|
|
|
|
let awarded = false;
|
|
|
|
|
let alreadyAwarded = false;
|
|
|
|
|
if (correct) {
|
|
|
|
|
room.state.awardedThisRound = room.state.awardedThisRound || {};
|
|
|
|
|
if (room.state.awardedThisRound[player.id]) { alreadyAwarded = true; }
|
|
|
|
|
else {
|
|
|
|
|
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;
|
|
|
|
|
@@ -114,80 +173,244 @@ export function setupWebSocket(server) {
|
|
|
|
|
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 },
|
|
|
|
|
normalized: {
|
|
|
|
|
guessTitle: titleScore.g,
|
|
|
|
|
truthTitle: titleScore.t,
|
|
|
|
|
guessArtists: artistScore.guessArtists,
|
|
|
|
|
truthArtists: artistScore.truthArtists,
|
|
|
|
|
},
|
|
|
|
|
awarded,
|
|
|
|
|
alreadyAwarded,
|
|
|
|
|
});
|
|
|
|
|
if (awarded) { broadcast(room, 'room_update', { room: roomSummary(room) }); }
|
|
|
|
|
if (awarded) {
|
|
|
|
|
broadcast(room, 'room_update', { room: roomSummary(room) });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'set_name') { player.name = String(msg.name || '').slice(0, 30) || player.name; if (player.roomId && rooms.has(player.roomId)) broadcast(rooms.get(player.roomId), 'room_update', { room: roomSummary(rooms.get(player.roomId)) }); return; }
|
|
|
|
|
if (msg.type === 'create_room') { const room = createRoom(msg.name, player); player.roomId = room.id; broadcast(room, 'room_update', { room: roomSummary(room) }); return; }
|
|
|
|
|
if (msg.type === 'set_name') {
|
|
|
|
|
player.name = String(msg.name || '').slice(0, 30) || player.name;
|
|
|
|
|
if (player.roomId && rooms.has(player.roomId)) {
|
|
|
|
|
const r = rooms.get(player.roomId);
|
|
|
|
|
broadcast(r, 'room_update', { room: roomSummary(r) });
|
|
|
|
|
}
|
|
|
|
|
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' });
|
|
|
|
|
const code = String(msg.code || '').toUpperCase();
|
|
|
|
|
const room = rooms.get(code);
|
|
|
|
|
if (!room) return send('error', { message: 'Room not found' });
|
|
|
|
|
// If there's an existing player in this room with the same session, merge to avoid duplicates.
|
|
|
|
|
let existing = null;
|
|
|
|
|
for (const p of room.players.values()) { if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) { existing = p; break; } }
|
|
|
|
|
if (existing) {
|
|
|
|
|
try { if (existing.ws && existing.ws !== ws) { try { existing.ws.terminate(); } catch {} } } catch {}
|
|
|
|
|
existing.ws = ws; existing.connected = true; existing.roomId = room.id; player = existing;
|
|
|
|
|
for (const p of room.players.values()) {
|
|
|
|
|
if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) {
|
|
|
|
|
existing = p;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
room.players.set(player.id, player); player.roomId = room.id; if (!room.state.ready) room.state.ready = {}; if (room.state.ready[player.id] == null) room.state.ready[player.id] = false;
|
|
|
|
|
if (existing) {
|
|
|
|
|
try {
|
|
|
|
|
if (existing.ws?.id && existing.ws.id !== socket.id) {
|
|
|
|
|
try {
|
|
|
|
|
existing.ws.disconnect(true);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
existing.ws = socket;
|
|
|
|
|
existing.connected = true;
|
|
|
|
|
existing.roomId = room.id;
|
|
|
|
|
player = existing;
|
|
|
|
|
}
|
|
|
|
|
room.players.set(player.id, player);
|
|
|
|
|
player.roomId = room.id;
|
|
|
|
|
if (!room.state.ready) room.state.ready = {};
|
|
|
|
|
if (room.state.ready[player.id] == null) room.state.ready[player.id] = false;
|
|
|
|
|
const inProgress = room.state.status === 'playing' || room.state.status === 'ended';
|
|
|
|
|
const wasParticipant = isParticipant(room, player.id);
|
|
|
|
|
if (inProgress) {
|
|
|
|
|
if (wasParticipant) { if (room.state.spectators) delete room.state.spectators[player.id]; player.spectator = false; }
|
|
|
|
|
else { 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 (wasParticipant) {
|
|
|
|
|
if (room.state.spectators) delete room.state.spectators[player.id];
|
|
|
|
|
player.spectator = false;
|
|
|
|
|
} else {
|
|
|
|
|
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 (!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;
|
|
|
|
|
const room = rooms.get(player.roomId);
|
|
|
|
|
if (!room) return;
|
|
|
|
|
if (room.hostId !== player.id) return send('error', { message: 'Only host can start' });
|
|
|
|
|
const active = [...room.players.values()].filter(p => !room.state.spectators?.[p.id] && p.connected);
|
|
|
|
|
const allReady = active.length>0 && active.every(p => !!room.state.ready?.[p.id]);
|
|
|
|
|
const active = [...room.players.values()].filter(
|
|
|
|
|
(p) => !room.state.spectators?.[p.id] && p.connected
|
|
|
|
|
);
|
|
|
|
|
const allReady = active.length > 0 && active.every((p) => !!room.state.ready?.[p.id]);
|
|
|
|
|
if (!allReady) return send('error', { message: 'All active players must be ready' });
|
|
|
|
|
const pids = active.map(p => p.id);
|
|
|
|
|
room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0];
|
|
|
|
|
const pids = active.map((p) => p.id);
|
|
|
|
|
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; }
|
|
|
|
|
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; }
|
|
|
|
|
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;
|
|
|
|
|
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 (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; }
|
|
|
|
|
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 (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; }
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on('close', () => {
|
|
|
|
|
socket.on('disconnect', () => {
|
|
|
|
|
clearTimeout(helloTimer);
|
|
|
|
|
// Mark player disconnected but keep them in the room for resume
|
|
|
|
|
try {
|
|
|
|
|
|