const http = require('http'); const fs = require('fs'); const path = require('path'); const { WebSocketServer } = require('ws'); const PORT = parseInt(process.env.PORT, 10) || 8080; const ROOT_DIR = path.resolve(__dirname, '..'); // ─── Static file server ──────────────────────────────────────────────── const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; const httpServer = http.createServer((req, res) => { let urlPath = decodeURIComponent(req.url.split('?')[0]); if (urlPath === '/') urlPath = '/index.html'; const filePath = path.join(ROOT_DIR, urlPath); // Prevent path traversal if (!filePath.startsWith(ROOT_DIR)) { res.writeHead(403); res.end(); return; } fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end('Not found'); return; } const ext = path.extname(filePath).toLowerCase(); const mime = MIME[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': mime }); res.end(data); }); }); // ─── WebSocket server ────────────────────────────────────────────────── const wss = new WebSocketServer({ server: httpServer }); // Room storage: roomCode -> { state, players: Map, claims: Map, nextPlayerId } const rooms = new Map(); let globalPlayerId = 1; wss.on('connection', (ws) => { let playerId = null; let roomCode = null; ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch { return; } switch (msg.type) { // ─── Room lifecycle ──────────────────────────────────────── case 'create_room': { roomCode = msg.roomCode; playerId = globalPlayerId++; rooms.set(roomCode, { state: msg.state, players: new Map([[playerId, ws]]), claims: new Map(), // groupId -> playerId nextPlayerId: playerId + 1, }); send(ws, { type: 'room_created', roomCode, playerId, }); console.log(`Room ${roomCode} created by player ${playerId}`); break; } case 'join_room': { roomCode = msg.roomCode; const room = rooms.get(roomCode); if (!room) { send(ws, { type: 'error', message: 'Room not found' }); return; } playerId = room.nextPlayerId++; room.players.set(playerId, ws); // Build claims list as { pieceId -> playerId } for the joiner const claimsList = {}; for (const [gid, pid] of room.claims) { claimsList[gid] = pid; } // Build player list const playerList = Array.from(room.players.keys()); send(ws, { type: 'room_joined', roomCode, playerId, state: room.state, players: playerList, claims: claimsList, }); // Notify existing players broadcast(room, playerId, { type: 'player_joined', playerId, }); console.log(`Player ${playerId} joined room ${roomCode}`); break; } // ─── Piece interaction ───────────────────────────────────── case 'claim': { const room = rooms.get(roomCode); if (!room) return; const pieceId = msg.pieceId; // Find which group this piece belongs to using the room state const groupId = findGroupForPiece(room.state.groups, pieceId); if (groupId === null) return; // Check if any piece in this group is already claimed if (room.claims.has(groupId)) { const heldBy = room.claims.get(groupId); if (heldBy !== playerId) { send(ws, { type: 'claim_denied', pieceId, heldBy }); return; } } room.claims.set(groupId, playerId); // Broadcast claim to all players (including the claimer for confirmation) broadcastAll(room, { type: 'claim_ok', pieceId, playerId, }); break; } case 'move': { const room = rooms.get(roomCode); if (!room) return; // Relay to all other players broadcast(room, playerId, { type: 'move', pieceId: msg.pieceId, playerId, dx: msg.dx, dy: msg.dy, }); break; } case 'release': { const room = rooms.get(roomCode); if (!room) return; // Update canonical state with new positions if (msg.positions) { const posMap = new Map(msg.positions.map(p => [p.id, p])); room.state.pieces.forEach(p => { const update = posMap.get(p.id); if (update) { p.x = update.x; p.y = update.y; } }); } // Update canonical groups if (msg.groups) { room.state.groups = msg.groups; } // Release the claim const pieceId = msg.pieceId; const groupId = findGroupForPiece(room.state.groups, pieceId); if (groupId !== null) { room.claims.delete(groupId); } // Also clean up any old group claims by this player (groups may have merged) for (const [gid, pid] of room.claims) { if (pid === playerId) { room.claims.delete(gid); } } // Check completion const completed = room.state.groups.length === 1 && room.state.groups[0].length === room.state.pieces.length; if (completed) { room.state.completed = true; } // Broadcast to all other players broadcast(room, playerId, { type: 'release', pieceId: msg.pieceId, playerId, positions: msg.positions, groups: msg.groups, }); if (completed) { broadcastAll(room, { type: 'completed' }); } break; } } }); ws.on('close', () => { if (!roomCode || !playerId) return; const room = rooms.get(roomCode); if (!room) return; // Release all claims by this player for (const [gid, pid] of room.claims) { if (pid === playerId) { room.claims.delete(gid); } } room.players.delete(playerId); if (room.players.size === 0) { // Keep room alive for a while in case of reconnection setTimeout(() => { const r = rooms.get(roomCode); if (r && r.players.size === 0) { rooms.delete(roomCode); console.log(`Room ${roomCode} cleaned up (empty)`); } }, 5 * 60 * 1000); // 5 minutes } else { broadcast(room, playerId, { type: 'player_left', playerId, }); } console.log(`Player ${playerId} left room ${roomCode}`); }); }); // ─── Helpers ─────────────────────────────────────────────────────────── function send(ws, obj) { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(obj)); } } function broadcast(room, excludePlayerId, obj) { const data = JSON.stringify(obj); for (const [pid, ws] of room.players) { if (pid !== excludePlayerId && ws.readyState === ws.OPEN) { ws.send(data); } } } function broadcastAll(room, obj) { const data = JSON.stringify(obj); for (const [, ws] of room.players) { if (ws.readyState === ws.OPEN) { ws.send(data); } } } /** Find the index of the group containing pieceId in the groups array. */ function findGroupForPiece(groups, pieceId) { for (let i = 0; i < groups.length; i++) { if (groups[i].includes(pieceId)) { return i; } } return null; } // ─── Start ───────────────────────────────────────────────────────────── httpServer.listen(PORT, () => { console.log(`iPuzzle server running on http://localhost:${PORT}`); });