// 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; // Social & Reset Ritual limits: emotes and avatar churn are per-peer rate // limited (drop, never queue) or the booth becomes hell; reset is global. const EMOTE_COOLDOWN_MS = 4000; // 1 airhorn/wave per 4 s per peer const AVATAR_COOLDOWN_MS = 2000; // 1 avatar change per 2 s per peer const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1) const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn // 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; /** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */ let winAt = 0; /** Clamp an avatar to bounded ints; returns a clean copy (defaults on garbage). */ function validAvatar(a) { const pick = (v, n) => (Number.isInteger(v) && v >= 0 && v < n ? v : 0); if (!a || typeof a !== 'object') return { hue: 0, gear: 0, face: 0 }; return { hue: pick(a.hue, AVATAR_RANGES.hue), gear: pick(a.gear, AVATAR_RANGES.gear), face: pick(a.face, AVATAR_RANGES.face), }; } /** * The workshop's post-reset pose: assembled-but-undone. The fun parts replay * (wires, screws, balance); the tedium (finding + seating the cart) doesn't. */ function resetWorkshopPose() { return { cartSeated: true, cartRotation: 0, // already square — re-seating isn't the fun part wires: [null, null, null, null], // all four popped off: re-crimp them screws: [0.5, 0.5], weightNotch: 3 + Math.floor(Math.random() * 4), // random 3..6 — re-balance it tested: 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); // Old state files predate winAt: treat as "won long ago" so the reset // cooldown is already expired rather than blocking forever. winAt = Number.isFinite(s.winAt) ? s.winAt : 0; 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, winAt, })); 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, avatar: validAvatar(null), lastEmote: 0, lastAvatar: 0, }; peers.set(id, peer); send(ws, { t: 'hello', id, peers: [...peers.entries()].filter(([pid]) => pid !== id) .map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })), 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); peer.avatar = validAvatar(m.avatar); // absent/garbage -> defaults broadcast({ t: 'join', id, name: peer.name, avatar: peer.avatar }, id); break; } case 'avatar': { const now = Date.now(); if (now - peer.lastAvatar < AVATAR_COOLDOWN_MS) return; // drop, don't queue peer.lastAvatar = now; peer.avatar = validAvatar(m.avatar); broadcast({ t: 'avatar', id, avatar: peer.avatar }, id); break; } case 'emote': { if (!isInt(m.kind) || m.kind < 0 || m.kind >= EMOTE_KINDS) return; const now = Date.now(); if (now - peer.lastEmote < EMOTE_COOLDOWN_MS) return; // spam ceiling peer.lastEmote = now; broadcast({ t: 'emote', id, kind: m.kind }, id); break; } case 'reset': { // Server-authoritative. Only a won booth resets, and only after the // anti-grief cooldown. Rejections are silent-ish: the client shows // "the fuse is still hot" off its own read of the state. if (repaired.size < NODES.size) return; if (Date.now() - winAt < RESET_COOLDOWN_MS) return; repaired.clear(); platters.A = { playing: false, rpm: 33 }; platters.B = { playing: false, rpm: 33 }; workshop = resetWorkshopPose(); winAt = 0; dirty = true; // NOTE: `edits` is deliberately untouched — player builds survive. // Carry the workshop pose so live clients land on the same state a late // joiner would get from `hello.state.workshop`. broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker saveState(); 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); // The moment the booth comes alive, start the reset cooldown clock. if (repaired.size === NODES.size) winAt = Date.now(); 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})`);