feat: implement initial Deno server with WebSocket API, static file serving, and development Docker Compose.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s

This commit is contained in:
2026-01-03 22:07:34 +01:00
parent 70be1e7e39
commit c9be49d988
11 changed files with 258 additions and 69 deletions

44
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,44 @@
# Development Docker Compose for Hitstar
# Enables hot reload and debugging for local development
#
# Usage:
# docker compose -f docker-compose.dev.yml up --build
#
# Debugging:
# Connect to chrome://inspect or VS Code debugger at localhost:9229
services:
hitstar-dev:
build:
context: .
dockerfile: Dockerfile
target: development
image: hitstar-deno:dev
container_name: hitstar-dev
environment:
- DENO_ENV=development
- PORT=5173
ports:
# Application port
- "5173:5173"
# Deno inspector/debugger port
- "9229:9229"
volumes:
# Mount source code for hot reload
- ./src/server-deno:/app:cached
# Mount data directory
- ./data:/app/data
# Override CMD to enable debugging with inspector
command: >
deno run --allow-net --allow-read --allow-env --allow-write --watch --inspect=0.0.0.0:9229 main.ts
networks:
- hitstar-dev-network
# Restart on crash during development
restart: unless-stopped
# Enable stdin for interactive debugging
stdin_open: true
tty: true
networks:
hitstar-dev-network:
driver: bridge

View File

@@ -11,7 +11,7 @@ export class TrackService {
constructor( constructor(
private readonly fileSystem: FileSystemService, private readonly fileSystem: FileSystemService,
private readonly metadata: MetadataService, private readonly metadata: MetadataService,
) {} ) { }
/** /**
* Get list of available playlists * Get list of available playlists
@@ -24,7 +24,7 @@ export class TrackService {
// Check root directory for default playlist // Check root directory for default playlist
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i'); const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern); const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern);
if (rootFiles.length > 0) { if (rootFiles.length > 0) {
playlists.push({ playlists.push({
id: 'default', id: 'default',
@@ -35,11 +35,11 @@ export class TrackService {
// Check subdirectories // Check subdirectories
const subdirs = await this.fileSystem.listDirectories(dataDir); const subdirs = await this.fileSystem.listDirectories(dataDir);
for (const dir of subdirs) { for (const dir of subdirs) {
const dirPath = this.fileSystem.getPlaylistDir(dir); const dirPath = this.fileSystem.getPlaylistDir(dir);
const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern); const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern);
if (dirFiles.length > 0) { if (dirFiles.length > 0) {
playlists.push({ playlists.push({
id: dir, id: dir,
@@ -82,9 +82,46 @@ export class TrackService {
async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> { async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> {
const yearsIndex = await this.metadata.loadYearsIndex(playlistId); const yearsIndex = await this.metadata.loadYearsIndex(playlistId);
const count = Object.keys(yearsIndex).length; const count = Object.keys(yearsIndex).length;
logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`); logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`);
return { count }; return { count };
} }
/**
* Preload all playlists into cache at startup
* This prevents slow loading when the first game starts
*/
async preloadAllPlaylists(): Promise<void> {
const startTime = Date.now();
logger.info('🎵 Starting playlist preload...');
try {
const playlists = await this.getAvailablePlaylists();
if (playlists.length === 0) {
logger.warn('⚠️ No playlists found to preload');
return;
}
logger.info(`📋 Found ${playlists.length} playlist(s) to preload`);
let totalTracks = 0;
for (const playlist of playlists) {
const playlistStart = Date.now();
const tracks = await this.loadPlaylistTracks(playlist.id);
const playlistDuration = Date.now() - playlistStart;
totalTracks += tracks.length;
logger.info(` ✓ Loaded playlist '${playlist.name}': ${tracks.length} tracks in ${playlistDuration}ms`);
}
const totalDuration = Date.now() - startTime;
logger.info(`✅ Playlist preload complete: ${totalTracks} total tracks from ${playlists.length} playlist(s) in ${totalDuration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`❌ Playlist preload failed after ${duration}ms: ${error}`);
}
}
} }

View File

@@ -58,6 +58,10 @@ async function main() {
logger.info('Application services initialized'); logger.info('Application services initialized');
// Preload playlists in the background (non-blocking)
// This ensures fast game starts by caching track metadata
trackService.preloadAllPlaylists();
// Initialize WebSocket server // Initialize WebSocket server
const wsServer = new WebSocketServer(roomService, gameService); const wsServer = new WebSocketServer(roomService, gameService);
wsServer.initialize(config.corsOrigin); wsServer.initialize(config.corsOrigin);
@@ -65,22 +69,22 @@ async function main() {
// Create combined handler // Create combined handler
const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => { const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
// Socket.IO requests // Socket.IO requests
if (url.pathname.startsWith('/socket.io/')) { if (url.pathname.startsWith('/socket.io/')) {
// Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility // Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility
// Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr // Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr
const connInfo = { const connInfo = {
localAddr: { localAddr: {
transport: 'tcp' as const, transport: 'tcp' as const,
hostname: config.host, hostname: config.host,
port: config.port port: config.port
}, },
remoteAddr: info.remoteAddr, remoteAddr: info.remoteAddr,
}; };
return await wsServer.getHandler()(request, connInfo); return await wsServer.getHandler()(request, connInfo);
} }
// API endpoints // API endpoints
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
if (url.pathname === '/api/playlists') { if (url.pathname === '/api/playlists') {
@@ -89,7 +93,7 @@ async function main() {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}); });
} }
if (url.pathname === '/api/tracks') { if (url.pathname === '/api/tracks') {
const playlistId = url.searchParams.get('playlist') || 'default'; const playlistId = url.searchParams.get('playlist') || 'default';
const tracks = await trackService.loadPlaylistTracks(playlistId); const tracks = await trackService.loadPlaylistTracks(playlistId);
@@ -97,7 +101,7 @@ async function main() {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}); });
} }
if (url.pathname === '/api/reload-years') { if (url.pathname === '/api/reload-years') {
const playlistId = url.searchParams.get('playlist') || 'default'; const playlistId = url.searchParams.get('playlist') || 'default';
const result = await trackService.reloadYearsIndex(playlistId); const result = await trackService.reloadYearsIndex(playlistId);
@@ -106,7 +110,7 @@ async function main() {
}); });
} }
} }
// Audio streaming // Audio streaming
if (url.pathname.startsWith('/audio/t/')) { if (url.pathname.startsWith('/audio/t/')) {
const token = url.pathname.split('/audio/t/')[1]; const token = url.pathname.split('/audio/t/')[1];
@@ -116,7 +120,7 @@ async function main() {
request: { headers: new Headers(request.headers), url }, request: { headers: new Headers(request.headers), url },
response: { headers: new Headers(), status: 200 }, response: { headers: new Headers(), status: 200 },
}; };
if (request.method === 'HEAD') { if (request.method === 'HEAD') {
await audioStreaming.handleHeadRequest(ctx, token); await audioStreaming.handleHeadRequest(ctx, token);
return new Response(null, { return new Response(null, {
@@ -134,7 +138,7 @@ async function main() {
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
} }
// Cover art // Cover art
if (url.pathname.startsWith('/cover/')) { if (url.pathname.startsWith('/cover/')) {
const encodedFileName = url.pathname.split('/cover/')[1]; const encodedFileName = url.pathname.split('/cover/')[1];
@@ -149,10 +153,10 @@ async function main() {
}, },
}); });
} }
} catch {} } catch { }
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
// Static files // Static files
try { try {
return await serveDir(request, { return await serveDir(request, {
@@ -174,7 +178,7 @@ async function main() {
// Start server // Start server
logger.info(`Server starting on http://${config.host}:${config.port}`); logger.info(`Server starting on http://${config.host}:${config.port}`);
await serve(handler, { await serve(handler, {
hostname: config.host, hostname: config.host,
port: config.port, port: config.port,

View File

@@ -17,7 +17,7 @@ export class WebSocketServer {
constructor( constructor(
private readonly roomService: RoomService, private readonly roomService: RoomService,
private readonly gameService: GameService, private readonly gameService: GameService,
) {} ) { }
/** /**
* Initialize Socket.IO server * Initialize Socket.IO server
@@ -129,6 +129,10 @@ export class WebSocketServer {
this.handleSelectPlaylist(msg, player); this.handleSelectPlaylist(msg, player);
break; break;
case WS_EVENTS.SET_GOAL:
this.handleSetGoal(msg, player);
break;
case WS_EVENTS.START_GAME: case WS_EVENTS.START_GAME:
await this.handleStartGame(player); await this.handleStartGame(player);
break; break;
@@ -204,13 +208,13 @@ export class WebSocketServer {
} }
existingPlayer.setConnected(true); existingPlayer.setConnected(true);
// Remove the temporary player that was created in handleConnection // Remove the temporary player that was created in handleConnection
this.playerSockets.delete(newPlayerId); this.playerSockets.delete(newPlayerId);
// Update socket mapping to point to the resumed player // Update socket mapping to point to the resumed player
this.playerSockets.set(existingPlayer.id, socket); this.playerSockets.set(existingPlayer.id, socket);
socket.emit('message', { socket.emit('message', {
type: WS_EVENTS.RESUME_RESULT, type: WS_EVENTS.RESUME_RESULT,
ok: true, ok: true,
@@ -260,7 +264,7 @@ export class WebSocketServer {
try { try {
const { room } = this.roomService.joinRoomWithPlayer(roomId, player); const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
socket.emit('message', { socket.emit('message', {
type: 'room_joined', type: 'room_joined',
room: room.toSummary(), room: room.toSummary(),
@@ -282,7 +286,7 @@ export class WebSocketServer {
if (player.roomId) { if (player.roomId) {
const room = this.roomService.getRoom(player.roomId); const room = this.roomService.getRoom(player.roomId);
this.roomService.leaveRoom(player.id); this.roomService.leaveRoom(player.id);
if (room) { if (room) {
this.broadcastRoomUpdate(room); this.broadcastRoomUpdate(room);
} }
@@ -295,7 +299,7 @@ export class WebSocketServer {
private handleSetName(msg: any, player: PlayerModel): void { private handleSetName(msg: any, player: PlayerModel): void {
if (msg.name) { if (msg.name) {
this.roomService.setPlayerName(player.id, msg.name); this.roomService.setPlayerName(player.id, msg.name);
if (player.roomId) { if (player.roomId) {
const room = this.roomService.getRoom(player.roomId); const room = this.roomService.getRoom(player.roomId);
if (room) { if (room) {
@@ -331,6 +335,26 @@ export class WebSocketServer {
this.broadcastRoomUpdate(room); this.broadcastRoomUpdate(room);
} }
/**
* Set goal (win condition)
*/
private handleSetGoal(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.hostId !== player.id) return;
// Only allow changing goal in lobby
if (room.state.status !== 'lobby') return;
const goal = parseInt(msg.goal, 10);
if (isNaN(goal) || goal < 1 || goal > 50) return;
room.state.goal = goal;
logger.info(`Room ${room.id}: Goal set to ${goal} by host ${player.id}`);
this.broadcastRoomUpdate(room);
}
/** /**
* Start game * Start game
*/ */
@@ -405,7 +429,7 @@ export class WebSocketServer {
try { try {
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist); const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
socket.emit('message', { socket.emit('message', {
type: 'answer_result', type: 'answer_result',
ok: true, ok: true,
@@ -460,7 +484,7 @@ export class WebSocketServer {
try { try {
const result = await this.gameService.placeInTimeline(room, player.id, slot); const result = await this.gameService.placeInTimeline(room, player.id, slot);
// Store the result // Store the result
room.state.lastResult = { room.state.lastResult = {
playerId: player.id, playerId: player.id,
@@ -471,7 +495,7 @@ export class WebSocketServer {
// Move to reveal phase - DON'T auto-skip, wait for user to click next // Move to reveal phase - DON'T auto-skip, wait for user to click next
room.state.phase = GamePhase.REVEAL; room.state.phase = GamePhase.REVEAL;
// Broadcast reveal with track info and result // Broadcast reveal with track info and result
this.broadcast(room, 'reveal', { this.broadcast(room, 'reveal', {
result: room.state.lastResult, result: room.state.lastResult,
@@ -531,7 +555,7 @@ export class WebSocketServer {
const posSec = room.state.paused const posSec = room.state.paused
? room.state.pausedPosSec ? room.state.pausedPosSec
: Math.max(0, (now - (room.state.trackStartAt || now)) / 1000); : Math.max(0, (now - (room.state.trackStartAt || now)) / 1000);
room.state.trackStartAt = now - Math.floor(posSec * 1000); room.state.trackStartAt = now - Math.floor(posSec * 1000);
room.state.paused = false; room.state.paused = false;
this.startSyncTimer(room); this.startSyncTimer(room);

View File

@@ -130,15 +130,33 @@
<!-- Playlist Selection (only shown in lobby for host) --> <!-- Playlist Selection (only shown in lobby for host) -->
<div id="playlistSection" <div id="playlistSection"
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4"> class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
🎵 Playlist auswählen <!-- Playlist Selection -->
</label> <div>
<select id="playlistSelect" <label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500"> 🎵 Playlist auswählen
<option value="default">Lade Playlists...</option> </label>
</select> <select id="playlistSelect"
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400"> class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
Als Host kannst du die Playlist für dieses Spiel wählen. <option value="default">Lade Playlists...</option>
</select>
</div>
<!-- Goal/Score Selection -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
🏆 Siegpunkte
</label>
<select id="goalSelect"
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="5">5 Karten</option>
<option value="10" selected>10 Karten</option>
<option value="15">15 Karten</option>
<option value="20">20 Karten</option>
</select>
</div>
</div>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
Als Host kannst du die Playlist und Siegpunkte für dieses Spiel wählen.
</p> </p>
</div> </div>
@@ -159,6 +177,11 @@
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span> <span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span> <span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
</div> </div>
<div id="goalInfoSection" class="text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<span class="text-base">🏆</span>
<span class="font-medium text-slate-500 dark:text-slate-400">Ziel:</span>
<span id="goalInfo" class="font-semibold text-slate-900 dark:text-slate-100">10 Karten</span>
</div>
</div> </div>
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end"> <div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none"> <label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">

View File

@@ -7,7 +7,7 @@ import {
$npYear, $npYear,
} from "./dom.js"; } from "./dom.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { cacheLastRoomId, cacheSessionId, sendMsg } from "./ws.js"; import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
import { renderRoom } from "./render.js"; import { renderRoom } from "./render.js";
import { applySync, loadTrack, getSound } from "./audio.js"; import { applySync, loadTrack, getSound } from "./audio.js";
@@ -19,10 +19,10 @@ function updatePlayerIdFromRoom(r) {
state.playerId = only.id; state.playerId = only.id;
try { try {
localStorage.setItem("playerId", only.id); localStorage.setItem("playerId", only.id);
} catch {} } catch { }
} }
} }
} catch {} } catch { }
} }
function shortName(id) { function shortName(id) {
@@ -35,7 +35,7 @@ export function handleConnected(msg) {
state.playerId = msg.playerId; state.playerId = msg.playerId;
try { try {
if (msg.playerId) localStorage.setItem("playerId", msg.playerId); if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
} catch {} } catch { }
if (msg.sessionId) { if (msg.sessionId) {
const existing = localStorage.getItem("sessionId"); const existing = localStorage.getItem("sessionId");
if (!existing) cacheSessionId(msg.sessionId); if (!existing) cacheSessionId(msg.sessionId);
@@ -51,7 +51,7 @@ export function handleConnected(msg) {
try { try {
updatePlayerIdFromRoom(state.room); updatePlayerIdFromRoom(state.room);
renderRoom(state.room); renderRoom(state.room);
} catch {} } catch { }
} }
} }
@@ -179,7 +179,7 @@ export function onMessage(ev) {
state.playerId = msg.playerId; state.playerId = msg.playerId;
try { try {
localStorage.setItem("playerId", msg.playerId); localStorage.setItem("playerId", msg.playerId);
} catch {} } catch { }
} }
const code = const code =
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId"); msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
@@ -187,11 +187,23 @@ export function onMessage(ev) {
if (state.room) { if (state.room) {
try { try {
renderRoom(state.room); renderRoom(state.room);
} catch {} } catch { }
} }
// Restore player name after successful resume
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
} else { } else {
// Resume failed - session expired or server restarted
// Clear stale session ID so next connect gets a fresh one
clearSessionId();
// Still try to join the last room
const code = state.room?.id || localStorage.getItem("lastRoomId"); const code = state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code }); if (code) sendMsg({ type: "join_room", roomId: code });
// Restore player name even on failed resume (new player, same name)
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
} }
return; return;
} }

View File

@@ -29,7 +29,7 @@ export function renderRoom(room) {
} }
try { try {
localStorage.setItem('lastRoomId', room.id); localStorage.setItem('lastRoomId', room.id);
} catch {} } catch { }
$lobby.classList.add('hidden'); $lobby.classList.add('hidden');
$room.classList.remove('hidden'); $room.classList.remove('hidden');
$roomId.textContent = room.id; $roomId.textContent = room.id;
@@ -43,7 +43,7 @@ export function renderRoom(room) {
state.playerId = sole.id; state.playerId = sole.id;
try { try {
localStorage.setItem('playerId', sole.id); localStorage.setItem('playerId', sole.id);
} catch {} } catch { }
} }
} }
const me = room.players.find((p) => p.id === state.playerId); const me = room.players.find((p) => p.id === state.playerId);
@@ -146,20 +146,29 @@ export function renderRoom(room) {
// Mark that we've populated the dropdown // Mark that we've populated the dropdown
state.playlistsPopulated = true; state.playlistsPopulated = true;
}
// Auto-select first playlist if host and in lobby (only on first population) // Auto-select first playlist if host and in lobby but no playlist is set on server
if ( // This handles both initial population AND returning to lobby after a game
!room.state.playlist && if (
isHost && !room.state.playlist &&
room.state.status === 'lobby' && isHost &&
state.playlists.length > 0 room.state.status === 'lobby' &&
) { state.playlists.length > 0
// Use setTimeout to ensure the change event is properly triggered after render ) {
// Use setTimeout to ensure the change event is properly triggered after render
// Use a flag to prevent multiple auto-selects during the same render cycle
if (!state._autoSelectPending) {
state._autoSelectPending = true;
setTimeout(() => { setTimeout(() => {
const firstPlaylistId = state.playlists[0].id; state._autoSelectPending = false;
$playlistSelect.value = firstPlaylistId; // Double-check we're still in the right state
// Trigger the change event to send to server if (state.room?.state?.status === 'lobby' && !state.room?.state?.playlist) {
$playlistSelect.dispatchEvent(new Event('change')); const firstPlaylistId = state.playlists[0].id;
$playlistSelect.value = firstPlaylistId;
// Trigger the change event to send to server
$playlistSelect.dispatchEvent(new Event('change'));
}
}, 100); }, 100);
} }
} }
@@ -169,9 +178,23 @@ export function renderRoom(room) {
$playlistSelect.value = room.state.playlist; $playlistSelect.value = room.state.playlist;
} }
} }
// Sync goal selector with server state
const $goalSelect = document.getElementById('goalSelect');
if ($goalSelect && room.state.goal) {
$goalSelect.value = String(room.state.goal);
}
if ($currentPlaylist) { if ($currentPlaylist) {
$currentPlaylist.textContent = room.state.playlist || 'default'; $currentPlaylist.textContent = room.state.playlist || 'default';
} }
// Update goal info display
const $goalInfo = document.getElementById('goalInfo');
if ($goalInfo) {
$goalInfo.textContent = `${room.state.goal || 10} Karten`;
}
if ($playlistInfo) { if ($playlistInfo) {
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby'); $playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
} }

View File

@@ -17,7 +17,9 @@ export function reusePlayerName() {
export function reconnectLastRoom() { export function reconnectLastRoom() {
const last = state.room?.id || localStorage.getItem('lastRoomId'); const last = state.room?.id || localStorage.getItem('lastRoomId');
if (last && !localStorage.getItem('sessionId')) { // Always try to rejoin the last room - resume is handled separately in ws.js
if (last) {
sendMsg({ type: 'join_room', roomId: last }); sendMsg({ type: 'join_room', roomId: last });
} }
} }

View File

@@ -119,7 +119,7 @@ export function wireUi() {
localStorage.removeItem("sessionId"); localStorage.removeItem("sessionId");
localStorage.removeItem("dashboardHintSeen"); localStorage.removeItem("dashboardHintSeen");
localStorage.removeItem("lastRoomId"); localStorage.removeItem("lastRoomId");
} catch {} } catch { }
stopAudioPlayback(); stopAudioPlayback();
state.room = null; state.room = null;
if ($nameLobby) { if ($nameLobby) {
@@ -134,7 +134,7 @@ export function wireUi() {
if ($readyChk) { if ($readyChk) {
try { try {
$readyChk.checked = false; $readyChk.checked = false;
} catch {} } catch { }
} }
$lobby.classList.remove("hidden"); $lobby.classList.remove("hidden");
$room.classList.add("hidden"); $room.classList.add("hidden");
@@ -164,6 +164,16 @@ export function wireUi() {
}); });
} }
// Goal/score selection
const $goalSelect = document.getElementById("goalSelect");
if ($goalSelect) {
wire($goalSelect, "change", (e) => {
const goal = parseInt(e.target.value, 10);
sendMsg({ type: "set_goal", goal });
});
}
wire($placeBtn, "click", () => { wire($placeBtn, "click", () => {
const slot = parseInt($slotSelect.value, 10); const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: "place_guess", slot }); sendMsg({ type: "place_guess", slot });
@@ -234,7 +244,7 @@ export function wireUi() {
dashboardHint.classList.add("hidden"); dashboardHint.classList.add("hidden");
try { try {
localStorage.setItem("dashboardHintSeen", "1"); localStorage.setItem("dashboardHintSeen", "1");
} catch {} } catch { }
dashboard.removeEventListener("toggle", hide); dashboard.removeEventListener("toggle", hide);
dashboard.removeEventListener("click", hide); dashboard.removeEventListener("click", hide);
}; };
@@ -244,6 +254,6 @@ export function wireUi() {
if (!localStorage.getItem("dashboardHintSeen")) hide(); if (!localStorage.getItem("dashboardHintSeen")) hide();
}, 6000); }, 6000);
} }
} catch {} } catch { }
} }
} }

View File

@@ -20,7 +20,7 @@ export function connectWS(onMessage) {
if (sessionId) { if (sessionId) {
try { try {
socket.emit('message', { type: 'resume', sessionId }); socket.emit('message', { type: 'resume', sessionId });
} catch {} } catch { }
} }
// flush queued // flush queued
setTimeout(() => { setTimeout(() => {
@@ -47,12 +47,21 @@ export function cacheSessionId(id) {
sessionId = id; sessionId = id;
try { try {
localStorage.setItem('sessionId', id); localStorage.setItem('sessionId', id);
} catch {} } catch { }
} }
export function cacheLastRoomId(id) { export function cacheLastRoomId(id) {
if (!id) return; if (!id) return;
_lastRoomId = id; _lastRoomId = id;
try { try {
localStorage.setItem('lastRoomId', id); localStorage.setItem('lastRoomId', id);
} catch {} } catch { }
} }
// Clear stale session data (e.g., after server restart)
export function clearSessionId() {
sessionId = null;
try {
localStorage.removeItem('sessionId');
} catch { }
}

View File

@@ -44,6 +44,7 @@ export const WS_EVENTS = {
SET_SPECTATOR: 'set_spectator', SET_SPECTATOR: 'set_spectator',
KICK_PLAYER: 'kick_player', KICK_PLAYER: 'kick_player',
SELECT_PLAYLIST: 'select_playlist', SELECT_PLAYLIST: 'select_playlist',
SET_GOAL: 'set_goal',
// Server -> Client // Server -> Client
CONNECTED: 'connected', CONNECTED: 'connected',