TURNCRAFT/server/relay.mjs
jing 9c668d340c Headshell Workshop + Glow-Up phase: assembly minigame, art pass, review fixes
- Workshop (WORKSHOP_CARTRIDGE): five-stage cartridge assembly at Deck A —
  seat/square, crimp four tag-wires, torque screws, ride-the-arm counterweight
  balance, needle-drop diagnostic with per-fault audio + scope. Relay-synced
  (per-field co-op merge: held screw + carried wire survive remote state).
- Glow-Up (G1-G5): 32px atlas with per-voxel variants, selective LED bloom
  (quality-gated), screen-print decal system + party flyers, mixer/PCB worldgen
  density pass, record groove-sheen side texture.
- 10 confirmed multi-agent review fixes, incl. co-op screw-stomp soft-lock,
  ride-snap collider-identity (magnet feet / eaten record-fling), workshop SFX
  exact-match map (rca_seated hijack), beam ride colliders to the head,
  double-crimp guard, WIRING INCOMPLETE diagnosis mode, skate-abort timer,
  completedState wiring, per-tick material churn, trackingHeavy platter drag.
- Crossfader playtest fix: slew-limited sled (3.4 v/s) + ribbed grip caps with
  amber index — the sled reads as a heavy handle, not a teleporting wall.
- Demo harnesses: machinesDemo hold-key wiring, playerDemo seesaw phase
  continuity, audioDemo incomplete fault button.
- Workshop sync: exact 1.0 screw endpoint gets its own emit signature.

Verified: typecheck + vite build clean, live solo quest smoke, two-client
co-op relay smoke (simultaneous torque, no rewind, exact convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:32:07 +10:00

224 lines
7.8 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 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})`);