// TURNCRAFT multiplayer relay — one shared booth. // // A deliberately small WebSocket room server: relays player positions, // voxel edits, quest repairs and platter states; keeps the authoritative // world-diff so late joiners see the same booth; persists it to disk. // // Run: node relay.mjs (PORT=8433 HOST=127.0.0.1 by default) // Prod: bind 127.0.0.1 behind an nginx `location /turncraft/ws` proxy. import { WebSocketServer } from 'ws'; import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const PORT = Number(process.env.PORT ?? 8433); const HOST = process.env.HOST ?? '127.0.0.1'; // Mirrors src/core/constants.ts (server is standalone by design). const WORLD = { x: 448, y: 160, z: 256 }; const MAX_BLOCK_ID = 30; const NODES = new Set(['stylus', 'rca', 'crossfader', 'fuse', 'power']); // Limits (ship-check): bounded room, bounded frames, bounded rates. const MAX_PEERS = 24; const MAX_FRAME_BYTES = 2048; const MAX_MSGS_PER_SEC = 40; const MAX_NAME_LEN = 24; // Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry // would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB. const MAX_EDITS = 400_000; const ALLOWED_ORIGINS = [ /^https:\/\/([a-z0-9-]+\.)?partly\.party$/, /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, ]; const STATE_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'booth-state.json'); // ---- room state -------------------------------------------------------- /** edits: "x,y,z" -> blockId (the diff against the generated booth) */ const edits = new Map(); const repaired = new Set(); const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } }; let workshop = null; // last-write-wins Headshell Workshop assembly state (or null) let dirty = false; /** Validate + clamp a Headshell Workshop state; return a clean copy or null. */ function validWorkshop(s) { if (!s || typeof s !== 'object') return null; if (typeof s.cartSeated !== 'boolean' || typeof s.tested !== 'boolean') return null; if (![0, 1, 2, 3].includes(s.cartRotation)) return null; if (!Array.isArray(s.wires) || s.wires.length !== 4) return null; const wires = []; for (const w of s.wires) { if (w === null || [0, 1, 2, 3].includes(w)) wires.push(w); else return null; } if (!Array.isArray(s.screws) || s.screws.length !== 2) return null; if (!s.screws.every((v) => typeof v === 'number' && Number.isFinite(v))) return null; if (typeof s.weightNotch !== 'number' || !Number.isFinite(s.weightNotch)) return null; const cl = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); return { cartSeated: s.cartSeated, cartRotation: s.cartRotation, wires, screws: [cl(s.screws[0]), cl(s.screws[1])], weightNotch: Math.max(0, Math.min(10, Math.round(s.weightNotch))), tested: s.tested, }; } function loadState() { if (!existsSync(STATE_FILE)) return; try { const s = JSON.parse(readFileSync(STATE_FILE, 'utf8')); for (const [k, v] of s.edits ?? []) edits.set(k, v); for (const n of s.repaired ?? []) if (NODES.has(n)) repaired.add(n); Object.assign(platters.A, s.platters?.A ?? {}); Object.assign(platters.B, s.platters?.B ?? {}); workshop = validWorkshop(s.workshop); console.log(`[relay] loaded state: ${edits.size} edits, ${repaired.size} repairs`); } catch (e) { console.error('[relay] state load failed, starting fresh:', e.message); } } function saveState() { if (!dirty) return; dirty = false; const tmp = STATE_FILE + '.tmp'; writeFileSync(tmp, JSON.stringify({ edits: [...edits.entries()], repaired: [...repaired], platters, workshop, })); renameSync(tmp, STATE_FILE); } // ---- helpers ----------------------------------------------------------- const isInt = (v) => Number.isInteger(v); const inBounds = (x, y, z) => isInt(x) && isInt(y) && isInt(z) && x >= 0 && x < WORLD.x && y >= 0 && y < WORLD.y && z >= 0 && z < WORLD.z; const finiteVec3 = (p) => Array.isArray(p) && p.length === 3 && p.every(Number.isFinite); const cleanName = (n) => (typeof n === 'string' ? n : '').replace(/[^\w \-.]/g, '').slice(0, MAX_NAME_LEN) || 'lil dj'; let nextId = 1; const peers = new Map(); // id -> { ws, name, alive, msgTimes } function send(ws, msg) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); } function broadcast(msg, exceptId = null) { const raw = JSON.stringify(msg); for (const [id, p] of peers) { if (id !== exceptId && p.ws.readyState === p.ws.OPEN) p.ws.send(raw); } } // ---- server ------------------------------------------------------------ loadState(); const wss = new WebSocketServer({ host: HOST, port: PORT, maxPayload: MAX_FRAME_BYTES, clientTracking: false, verifyClient: ({ origin }) => !origin || ALLOWED_ORIGINS.some((re) => re.test(origin)), }); wss.on('connection', (ws) => { if (peers.size >= MAX_PEERS) { send(ws, { t: 'full' }); ws.close(1013, 'room full'); return; } const id = nextId++; const peer = { ws, name: 'lil dj', alive: true, budget: MAX_MSGS_PER_SEC }; peers.set(id, peer); send(ws, { t: 'hello', id, peers: [...peers.entries()].filter(([pid]) => pid !== id).map(([pid, p]) => ({ id: pid, name: p.name })), state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop }, }); ws.on('pong', () => { peer.alive = true; }); ws.on('message', (data, isBinary) => { if (isBinary || data.length > MAX_FRAME_BYTES) return; if (--peer.budget < 0) return; // over rate: drop silently (budget refills each second) let m; try { m = JSON.parse(data.toString()); } catch { return; } if (!m || typeof m.t !== 'string') return; switch (m.t) { case 'hi': { peer.name = cleanName(m.name); broadcast({ t: 'join', id, name: peer.name }, id); break; } case 'pos': { if (!finiteVec3(m.p) || !finiteVec3(m.look)) return; broadcast({ t: 'pos', id, p: m.p, look: m.look }, id); break; } case 'edit': { if (!inBounds(m.x, m.y, m.z)) return; if (!isInt(m.id) || m.id < 0 || m.id > MAX_BLOCK_ID) return; const key = `${m.x},${m.y},${m.z}`; if (!edits.has(key) && edits.size >= MAX_EDITS) return; edits.set(key, m.id); dirty = true; broadcast({ t: 'edit', x: m.x, y: m.y, z: m.z, id: m.id }, id); break; } case 'repair': { if (!NODES.has(m.node) || repaired.has(m.node)) return; repaired.add(m.node); dirty = true; broadcast({ t: 'repair', node: m.node }, id); break; } case 'platter': { if (m.deck !== 'A' && m.deck !== 'B') return; if (typeof m.playing !== 'boolean' || (m.rpm !== 33 && m.rpm !== 45)) return; platters[m.deck] = { playing: m.playing, rpm: m.rpm }; dirty = true; broadcast({ t: 'platter', deck: m.deck, playing: m.playing, rpm: m.rpm }, id); break; } case 'workshop': { const s = validWorkshop(m.s); if (!s) return; // fuzz: bad shapes dropped workshop = s; dirty = true; broadcast({ t: 'workshop', s }, id); break; } default: // unknown type: ignore } }); ws.on('close', () => { peers.delete(id); broadcast({ t: 'leave', id }); }); ws.on('error', () => { /* close follows */ }); }); setInterval(() => { for (const p of peers.values()) p.budget = MAX_MSGS_PER_SEC; }, 1000); setInterval(() => { for (const [id, p] of peers) { if (!p.alive) { p.ws.terminate(); peers.delete(id); broadcast({ t: 'leave', id }); continue; } p.alive = false; p.ws.ping(); } }, 30_000); setInterval(saveState, 30_000); for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { saveState(); process.exit(0); }); } console.log(`[relay] turncraft relay on ws://${HOST}:${PORT} (room cap ${MAX_PEERS})`);