Built by the Opus session per docs/briefs/SOCIAL_RESET.md; integrated and verified by Fable (typecheck clean, relay validation reviewed, 3D avatar gear/face pixel-confirmed live — the one item the build session couldn't capture in its frozen preview pane). - Avatars: hue/gear/face enums in src/net/avatarLook.ts shared by the 3D build and the Menu AVATAR tab's 2D mirror; live sync with client-side coalescing (two-tab testing caught the relay rate limit permanently desyncing rapid editors - trailing send fixes convergence). - Emotes Z/X/C/V: wave, beat-synced nod, point, rate-limited airhorn (detuned saws + confetti), server + client caps. - Reset ritual: after a win the fuse takes a deliberate 5s hold (reuses the workshop radial meter as the warning dial) -> server-authoritative reset with 10-min cooldown; music brakes to silence, lights die over 3s, workshop resets to wires-off with a SERVER-generated random weight notch (both tabs land identical), player builds survive, quest replayable. - Roster: hold Tab for who's-in-the-booth with hue swatches. - interact/ addition (flagged friction): additive setBreakGuard hook so interaction.ts never learns what a fuse is. - launch.json: main dev config uses autoPort (5173 collisions with parallel sessions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
11 KiB
JavaScript
304 lines
11 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;
|
||
// 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})`);
|