iPuzzle/server/server.js

316 lines
8.6 KiB
JavaScript

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<playerId, ws>, claims: Map<groupId, playerId>, 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}`);
});