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
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
44
docker-compose.dev.yml
Normal file
44
docker-compose.dev.yml
Normal 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
|
||||
@@ -87,4 +87,41 @@ export class TrackService {
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ async function main() {
|
||||
|
||||
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
|
||||
const wsServer = new WebSocketServer(roomService, gameService);
|
||||
wsServer.initialize(config.corsOrigin);
|
||||
|
||||
@@ -129,6 +129,10 @@ export class WebSocketServer {
|
||||
this.handleSelectPlaylist(msg, player);
|
||||
break;
|
||||
|
||||
case WS_EVENTS.SET_GOAL:
|
||||
this.handleSetGoal(msg, player);
|
||||
break;
|
||||
|
||||
case WS_EVENTS.START_GAME:
|
||||
await this.handleStartGame(player);
|
||||
break;
|
||||
@@ -331,6 +335,26 @@ export class WebSocketServer {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
<!-- Playlist Selection (only shown in lobby for host) -->
|
||||
<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">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Playlist Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
🎵 Playlist auswählen
|
||||
</label>
|
||||
@@ -137,8 +140,23 @@
|
||||
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="default">Lade Playlists...</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
Als Host kannst du die Playlist für dieses Spiel wählen.
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +177,11 @@
|
||||
<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>
|
||||
</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 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">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
$npYear,
|
||||
} from "./dom.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 { applySync, loadTrack, getSound } from "./audio.js";
|
||||
|
||||
@@ -189,9 +189,21 @@ export function onMessage(ev) {
|
||||
renderRoom(state.room);
|
||||
} catch { }
|
||||
}
|
||||
// Restore player name after successful resume
|
||||
import("./session.js").then(({ reusePlayerName }) => {
|
||||
reusePlayerName();
|
||||
});
|
||||
} 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");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -146,8 +146,10 @@ export function renderRoom(room) {
|
||||
|
||||
// Mark that we've populated the dropdown
|
||||
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
|
||||
// This handles both initial population AND returning to lobby after a game
|
||||
if (
|
||||
!room.state.playlist &&
|
||||
isHost &&
|
||||
@@ -155,11 +157,18 @@ export function renderRoom(room) {
|
||||
state.playlists.length > 0
|
||||
) {
|
||||
// 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(() => {
|
||||
state._autoSelectPending = false;
|
||||
// Double-check we're still in the right state
|
||||
if (state.room?.state?.status === 'lobby' && !state.room?.state?.playlist) {
|
||||
const firstPlaylistId = state.playlists[0].id;
|
||||
$playlistSelect.value = firstPlaylistId;
|
||||
// Trigger the change event to send to server
|
||||
$playlistSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -169,9 +178,23 @@ export function renderRoom(room) {
|
||||
$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) {
|
||||
$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) {
|
||||
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export function reusePlayerName() {
|
||||
|
||||
export function reconnectLastRoom() {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
const slot = parseInt($slotSelect.value, 10);
|
||||
sendMsg({ type: "place_guess", slot });
|
||||
|
||||
@@ -56,3 +56,12 @@ export function cacheLastRoomId(id) {
|
||||
localStorage.setItem('lastRoomId', id);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Clear stale session data (e.g., after server restart)
|
||||
export function clearSessionId() {
|
||||
sessionId = null;
|
||||
try {
|
||||
localStorage.removeItem('sessionId');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const WS_EVENTS = {
|
||||
SET_SPECTATOR: 'set_spectator',
|
||||
KICK_PLAYER: 'kick_player',
|
||||
SELECT_PLAYLIST: 'select_playlist',
|
||||
SET_GOAL: 'set_goal',
|
||||
|
||||
// Server -> Client
|
||||
CONNECTED: 'connected',
|
||||
|
||||
Reference in New Issue
Block a user