TURNCRAFT/server/relay.mjs
jing 05c2765bd8 SOCIAL_RESET: avatars, emotes, the reset ritual, who's-here roster
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>
2026-07-17 02:01:42 +10:00

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