TURNCRAFT/server/relay.mjs
jing b9ea2f7258 Deploy scripts + relay edit-cap (ship-check finding)
- 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>
2026-07-13 23:11:54 +10:00

189 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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})`);