- deploy/deploy.sh: build --base=/turncraft/ -> stage -> docker cp into forum-nginx -> reload -> curl verify; syncs relay code + restarts it. - deploy/setup-relay.sh: one-time relay container on forum-nginx's docker network + the nginx location block for /turncraft/ws. - relay: MAX_EDITS=400k grief ceiling (unbounded diff map could reach ~550MB if a bot painted the whole booth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
// 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 dirty = false;
|
||
|
||
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 ?? {});
|
||
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,
|
||
}));
|
||
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 },
|
||
});
|
||
|
||
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;
|
||
}
|
||
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})`);
|