TURNCRAFT/server/relay.mjs
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:22:11 +10:00

334 lines
13 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
// SIDE B crate worlds — mirrors src/core/worlds.ts WORLD_KEYS.
const STAMP_WORLDS = new Set(['acid', 'dub', 'disco']);
// 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)
/** SIDE B stamps (world keys). Global like repairs; NEVER cleared by reset —
* stamps are pressed into the records, not the booth. */
const stamps = new Set();
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);
for (const w of s.stamps ?? []) if (STAMP_WORLDS.has(w)) stamps.add(w);
// 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;
// A throwing write (ENOSPC, perms) must neither crash the relay from inside
// a ws message handler nor mark the unsaved state clean — restore `dirty` so
// the periodic saver retries once the disk recovers.
try {
const tmp = STATE_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify({
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
stamps: [...stamps],
}));
renameSync(tmp, STATE_FILE);
} catch (e) {
dirty = true;
console.error('[relay] state save failed (will retry):', e.message);
}
}
// ---- 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, stamps: [...stamps] },
});
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.
// NOTE: `stamps` is deliberately untouched too — stamps are pressed
// into the records, not the booth (SIDE B M5: they survive the reset).
// 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;
}
case 'stamp': {
// SIDE B (default-closed): w must be exactly a known world key; a
// repeat stamp is dropped (idempotent — no rebroadcast spam). Stamps
// are meta-progress, so they persist immediately, and the reset
// ritual deliberately never touches them.
if (typeof m.w !== 'string' || !STAMP_WORLDS.has(m.w)) return;
if (stamps.has(m.w)) return;
stamps.add(m.w);
dirty = true;
broadcast({ t: 'stamp', w: m.w }, id);
saveState();
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})`);