Spaces:
Sleeping
Sleeping
| // WebSocket hub: live game lobbies (real multiplayer) + studio co-editing. | |
| const { WebSocketServer } = require('ws'); | |
| const { get, save, uid, publicUser } = require('./db'); | |
| const { userFromReq, sanitizePart, applyConfig, MAX_PARTS } = require('./api'); | |
| // instanceId -> lobby | |
| const lobbies = new Map(); | |
| // gameId -> studio room | |
| const studios = new Map(); | |
| // userId -> { gameId, instanceId } | { studio: gameId } | |
| const presence = {}; | |
| const EDITOR_COLORS = ['#e54b4b', '#2a7de1', '#3aa14f', '#f0a020', '#8a4fff', '#19c2ff', '#ff5e9c', '#9ccc2e']; | |
| function send(ws, msg) { | |
| if (ws.readyState === 1) ws.send(JSON.stringify(msg)); | |
| } | |
| function broadcast(room, msg, exceptId) { | |
| const raw = JSON.stringify(msg); | |
| for (const m of room.members.values()) { | |
| if (m.user.id !== exceptId && m.ws.readyState === 1) m.ws.send(raw); | |
| } | |
| } | |
| // ---------------- play lobbies ---------------- | |
| function findOrCreateLobby(game) { | |
| for (const lobby of lobbies.values()) { | |
| if (lobby.gameId === game.id && lobby.members.size < game.config.maxPlayers) return lobby; | |
| } | |
| const lobby = { | |
| id: game.id + ':' + uid(6), | |
| gameId: game.id, | |
| members: new Map(), // userId -> {ws, user, pos, stage, score, lastTouch} | |
| mode: game.config.mode, | |
| modeState: { itId: null, lastTag: 0, coinsTaken: {} }, // coinsTaken: partId -> respawnAt | |
| created: Date.now(), | |
| }; | |
| lobbies.set(lobby.id, lobby); | |
| return lobby; | |
| } | |
| function lobbyPlayers(lobby) { | |
| return [...lobby.members.values()].map((m) => ({ | |
| id: m.user.id, | |
| username: m.user.username, | |
| avatar: m.user.avatar, | |
| pos: m.pos, | |
| stage: m.stage, | |
| score: m.score, | |
| })); | |
| } | |
| function leaderboard(lobby) { | |
| const entries = [...lobby.members.values()].map((m) => ({ | |
| id: m.user.id, | |
| username: m.user.username, | |
| stage: m.stage, | |
| score: m.score, | |
| it: lobby.mode === 'tag' && lobby.modeState.itId === m.user.id, | |
| })); | |
| if (lobby.mode === 'obby') entries.sort((a, b) => b.stage - a.stage); | |
| else entries.sort((a, b) => b.score - a.score); | |
| return entries; | |
| } | |
| function pickIt(lobby) { | |
| const ids = [...lobby.members.keys()]; | |
| if (ids.length < 2) { lobby.modeState.itId = null; return; } | |
| lobby.modeState.itId = ids[Math.floor(Math.random() * ids.length)]; | |
| lobby.modeState.lastTag = Date.now(); | |
| } | |
| function joinLobby(ws, user, game) { | |
| const db = get(); | |
| const lobby = findOrCreateLobby(game); | |
| // one connection per user per lobby; drop stale one | |
| const old = lobby.members.get(user.id); | |
| if (old && old.ws !== ws) try { old.ws.close(); } catch (e) {} | |
| const member = { ws, user, pos: [0, 5, 0], vel: [0, 0, 0], yaw: 0, anim: 'idle', stage: 0, score: 0 }; | |
| lobby.members.set(user.id, member); | |
| ws._room = { kind: 'play', lobby }; | |
| presence[user.id] = { gameId: game.id, instanceId: lobby.id }; | |
| // visit accounting + creator payout | |
| game.visits++; | |
| user.stats.joins++; | |
| const owner = db.users[game.ownerId]; | |
| if (owner && owner.id !== user.id) { | |
| owner.stats.visitsReceived++; | |
| if (!owner.system) owner.glimmers += 1; | |
| } | |
| save(); | |
| // active coin removals (not yet respawned) | |
| const now = Date.now(); | |
| const takenCoins = Object.entries(lobby.modeState.coinsTaken) | |
| .filter(([, t]) => t > now) | |
| .map(([pid, t]) => ({ partId: pid, respawnAt: t })); | |
| send(ws, { | |
| t: 'joined', | |
| selfId: user.id, | |
| instanceId: lobby.id, | |
| world: game.world, | |
| config: game.config, | |
| gameName: game.name, | |
| mode: lobby.mode, | |
| players: lobbyPlayers(lobby).filter((p) => p.id !== user.id), | |
| takenCoins, | |
| serverTime: Date.now(), | |
| }); | |
| broadcast(lobby, { t: 'pjoin', player: { id: user.id, username: user.username, avatar: user.avatar, pos: member.pos } }, user.id); | |
| broadcast(lobby, { t: 'chat', system: true, text: `${user.username} joined the game` }); | |
| if (lobby.mode === 'tag' && !lobby.modeState.itId && lobby.members.size >= 2) pickIt(lobby); | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| } | |
| function leaveRoom(ws) { | |
| const room = ws._room; | |
| if (!room) return; | |
| ws._room = null; | |
| if (room.kind === 'play') { | |
| const lobby = room.lobby; | |
| const member = [...lobby.members.values()].find((m) => m.ws === ws); | |
| if (!member) return; | |
| lobby.members.delete(member.user.id); | |
| delete presence[member.user.id]; | |
| broadcast(lobby, { t: 'pleave', id: member.user.id }); | |
| broadcast(lobby, { t: 'chat', system: true, text: `${member.user.username} left the game` }); | |
| if (lobby.mode === 'tag' && lobby.modeState.itId === member.user.id) pickIt(lobby); | |
| // refresh scoreboard for everyone so the leaver drops off immediately | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| if (lobby.members.size === 0) lobbies.delete(lobby.id); | |
| } else if (room.kind === 'studio') { | |
| const st = room.studio; | |
| const member = [...st.members.values()].find((m) => m.ws === ws); | |
| if (!member) return; | |
| st.members.delete(member.user.id); | |
| delete presence[member.user.id]; | |
| broadcast(st, { t: 'sleave', id: member.user.id }); | |
| if (st.members.size === 0) studios.delete(st.gameId); | |
| } | |
| } | |
| function dist2(a, b) { | |
| const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; | |
| return dx * dx + dy * dy + dz * dz; | |
| } | |
| function partCenterAt(part, timeMs) { | |
| // movers oscillate on a triangle wave keyed to server epoch time | |
| if (part.type !== 'mover') return [part.x, part.y, part.z]; | |
| const t = (timeMs / 1000) / part.period % 2; | |
| const tri = t < 1 ? t : 2 - t; | |
| return [part.x + part.dx * tri, part.y + part.dy * tri, part.z + part.dz * tri]; | |
| } | |
| function handlePlayMsg(ws, member, lobby, msg) { | |
| const db = get(); | |
| const game = db.games[lobby.gameId]; | |
| if (!game) return; | |
| switch (msg.t) { | |
| case 'move': { | |
| if (Array.isArray(msg.p) && msg.p.length === 3 && msg.p.every(Number.isFinite)) { | |
| member.pos = msg.p; | |
| member.yaw = Number(msg.yaw) || 0; | |
| member.anim = typeof msg.anim === 'string' ? msg.anim.slice(0, 12) : 'idle'; | |
| broadcast(lobby, { t: 'p', id: member.user.id, p: member.pos, yaw: member.yaw, anim: member.anim }, member.user.id); | |
| } | |
| break; | |
| } | |
| case 'chat': { | |
| const text = String(msg.text || '').slice(0, 200).trim(); | |
| if (!text) break; | |
| broadcast(lobby, { t: 'chat', id: member.user.id, username: member.user.username, text }); | |
| send(ws, { t: 'chat', id: member.user.id, username: member.user.username, text }); | |
| break; | |
| } | |
| case 'touch': { | |
| // server-validated interactions: coins and goals | |
| const part = game.world.parts.find((p) => p.id === msg.partId); | |
| if (!part) break; | |
| const c = partCenterAt(part, Date.now()); | |
| const reach = Math.max(part.sx, part.sy, part.sz) / 2 + 4; | |
| if (dist2(member.pos, c) > reach * reach) break; // too far — reject | |
| if (part.type === 'coin') { | |
| const taken = lobby.modeState.coinsTaken[part.id]; | |
| if (taken && taken > Date.now()) break; | |
| lobby.modeState.coinsTaken[part.id] = Date.now() + 10000; | |
| member.score++; | |
| broadcast(lobby, { t: 'coin', partId: part.id, by: member.user.id, respawnAt: lobby.modeState.coinsTaken[part.id] }); | |
| send(ws, { t: 'coin', partId: part.id, by: member.user.id, respawnAt: lobby.modeState.coinsTaken[part.id] }); | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| } else if (part.type === 'goal') { | |
| if (member.finished) break; | |
| member.finished = true; | |
| member.score += 10; | |
| // first-completion glimmer prize for obby mode | |
| if (!member.user.system) { member.user.glimmers += 5; save(); } | |
| broadcast(lobby, { t: 'goalhit', by: member.user.id, username: member.user.username }); | |
| send(ws, { t: 'goalhit', by: member.user.id, username: member.user.username, reward: 5 }); | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| } | |
| break; | |
| } | |
| case 'stage': { | |
| const n = Math.round(Number(msg.n) || 0); | |
| if (n > member.stage && n < 1000) { | |
| member.stage = n; | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| send(ws, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| } | |
| break; | |
| } | |
| case 'died': { | |
| broadcast(lobby, { t: 'pdied', id: member.user.id }, member.user.id); | |
| break; | |
| } | |
| } | |
| } | |
| // tag proximity check loop — server decides who is "it" | |
| setInterval(() => { | |
| for (const lobby of lobbies.values()) { | |
| if (lobby.mode !== 'tag' || !lobby.modeState.itId) continue; | |
| if (Date.now() - lobby.modeState.lastTag < 2000) continue; // tagback cooldown | |
| const it = lobby.members.get(lobby.modeState.itId); | |
| if (!it) { pickIt(lobby); continue; } | |
| for (const m of lobby.members.values()) { | |
| if (m.user.id === it.user.id) continue; | |
| if (dist2(it.pos, m.pos) < 2.2 * 2.2) { | |
| lobby.modeState.itId = m.user.id; | |
| lobby.modeState.lastTag = Date.now(); | |
| it.score++; // points for passing the zap | |
| broadcast(lobby, { t: 'tag', itId: m.user.id, by: it.user.id }); | |
| broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); | |
| break; | |
| } | |
| } | |
| } | |
| }, 150); | |
| // ---------------- studio collab ---------------- | |
| function joinStudio(ws, user, game) { | |
| if (!(game.ownerId === user.id || game.collaborators.includes(user.id))) { | |
| send(ws, { t: 'error', error: 'No edit access to this place' }); | |
| ws.close(); | |
| return; | |
| } | |
| let st = studios.get(game.id); | |
| if (!st) { | |
| st = { gameId: game.id, members: new Map(), nextColor: 0 }; | |
| studios.set(game.id, st); | |
| } | |
| const old = st.members.get(user.id); | |
| if (old && old.ws !== ws) try { old.ws.close(); } catch (e) {} | |
| const color = EDITOR_COLORS[st.nextColor++ % EDITOR_COLORS.length]; | |
| const member = { ws, user, color, sel: null }; | |
| st.members.set(user.id, member); | |
| ws._room = { kind: 'studio', studio: st }; | |
| presence[user.id] = { studio: game.id }; | |
| send(ws, { | |
| t: 'sjoined', | |
| selfId: user.id, | |
| world: game.world, | |
| config: game.config, | |
| meta: { name: game.name, desc: game.desc, genre: game.genre, public: game.public }, | |
| isOwner: game.ownerId === user.id, | |
| editors: [...st.members.values()].map((m) => ({ id: m.user.id, username: m.user.username, color: m.color, sel: m.sel })), | |
| }); | |
| broadcast(st, { t: 'sjoin', editor: { id: user.id, username: user.username, color, sel: null } }, user.id); | |
| } | |
| function handleStudioMsg(ws, member, st, msg) { | |
| const db = get(); | |
| const game = db.games[st.gameId]; | |
| if (!game) return; | |
| switch (msg.t) { | |
| case 'op': { | |
| const op = msg.op || {}; | |
| let applied = null; | |
| if (op.kind === 'add') { | |
| if (game.world.parts.length >= MAX_PARTS) { | |
| send(ws, { t: 'error', error: `Part limit (${MAX_PARTS}) reached` }); | |
| return; | |
| } | |
| const part = sanitizePart(op.part); | |
| if (!part) return; | |
| // ensure unique id | |
| if (game.world.parts.some((p) => p.id === part.id)) part.id = uid(8); | |
| game.world.parts.push(part); | |
| applied = { kind: 'add', part }; | |
| } else if (op.kind === 'update') { | |
| const part = sanitizePart(op.part); | |
| if (!part) return; | |
| const i = game.world.parts.findIndex((p) => p.id === part.id); | |
| if (i < 0) return; | |
| game.world.parts[i] = part; | |
| applied = { kind: 'update', part }; | |
| } else if (op.kind === 'del') { | |
| const i = game.world.parts.findIndex((p) => p.id === op.partId); | |
| if (i < 0) return; | |
| game.world.parts.splice(i, 1); | |
| applied = { kind: 'del', partId: op.partId }; | |
| } else if (op.kind === 'meta') { | |
| applyConfig(game, op.data || {}); | |
| applied = { | |
| kind: 'meta', | |
| data: { name: game.name, desc: game.desc, genre: game.genre, public: game.public, config: game.config }, | |
| }; | |
| } | |
| if (applied) { | |
| game.updated = Date.now(); | |
| save(); | |
| broadcast(st, { t: 'op', from: member.user.id, op: applied }, member.user.id); | |
| send(ws, { t: 'opok', op: applied }); | |
| } | |
| break; | |
| } | |
| case 'sel': { | |
| member.sel = typeof msg.partId === 'string' ? msg.partId : null; | |
| broadcast(st, { t: 'sel', id: member.user.id, partId: member.sel }, member.user.id); | |
| break; | |
| } | |
| case 'chat': { | |
| const text = String(msg.text || '').slice(0, 200).trim(); | |
| if (!text) break; | |
| broadcast(st, { t: 'chat', id: member.user.id, username: member.user.username, text }); | |
| send(ws, { t: 'chat', id: member.user.id, username: member.user.username, text }); | |
| break; | |
| } | |
| } | |
| } | |
| // ---------------- wiring ---------------- | |
| function attach(server, apiRouter) { | |
| const wss = new WebSocketServer({ server, path: '/ws', perMessageDeflate: false }); | |
| wss.on('error', () => {}); // http server owns error handling (port retry) | |
| apiRouter.presence = () => presence; | |
| wss.on('connection', (ws, req) => { | |
| console.log('[RBLX-WS] Connection request, cookie:', req.headers.cookie); | |
| const user = userFromReq(req); | |
| console.log('[RBLX-WS] User resolved:', user ? user.username : 'null'); | |
| if (!user) { | |
| send(ws, { t: 'error', error: 'Not logged in' }); | |
| ws.close(); | |
| return; | |
| } | |
| ws.isAlive = true; | |
| ws.on('pong', () => { ws.isAlive = true; }); | |
| ws.on('message', (raw) => { | |
| let msg; | |
| try { msg = JSON.parse(raw); } catch (e) { return; } | |
| if (typeof msg !== 'object' || !msg) return; | |
| const db = get(); | |
| if (msg.t === 'join' || msg.t === 'studio') { | |
| leaveRoom(ws); | |
| const game = db.games[msg.gameId]; | |
| if (!game) { send(ws, { t: 'error', error: 'Game not found' }); return; } | |
| if (msg.t === 'join') { | |
| if (!game.public && !(game.ownerId === user.id || game.collaborators.includes(user.id))) { | |
| send(ws, { t: 'error', error: 'This place is private' }); | |
| return; | |
| } | |
| joinLobby(ws, db.users[user.id], game); | |
| } else { | |
| joinStudio(ws, db.users[user.id], game); | |
| } | |
| return; | |
| } | |
| const room = ws._room; | |
| if (!room) return; | |
| if (room.kind === 'play') { | |
| const member = room.lobby.members.get(user.id); | |
| if (member && member.ws === ws) handlePlayMsg(ws, member, room.lobby, msg); | |
| } else if (room.kind === 'studio') { | |
| const member = room.studio.members.get(user.id); | |
| if (member && member.ws === ws) handleStudioMsg(ws, member, room.studio, msg); | |
| } | |
| }); | |
| ws.on('close', () => leaveRoom(ws)); | |
| ws.on('error', () => leaveRoom(ws)); | |
| }); | |
| // heartbeat: drop dead sockets so lobbies don't fill with ghosts | |
| setInterval(() => { | |
| for (const ws of wss.clients) { | |
| if (!ws.isAlive) { try { ws.terminate(); } catch (e) {} continue; } | |
| ws.isAlive = false; | |
| try { ws.ping(); } catch (e) {} | |
| } | |
| }, 15000); | |
| return wss; | |
| } | |
| module.exports = { attach }; | |