import 'dotenv/config'; import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const PORT = process.env.PORT || 34034; const TICK_HZ = Number(process.env.TICK_HZ || 20); // server broadcast rate // ---- Load scene ---------------------------------------------------------- // Two files: // data/scene.json -> committed "seed" (tracked in git). Default/first-run. // data/scene.live.json -> authored scene written by the admin (git-IGNORED), // so `git reset --hard` on deploy never clobbers it. // We read live if it exists, else fall back to the seed. Saves always go to live. const SEED_PATH = join(ROOT, 'data', 'scene.json'); const LIVE_PATH = join(ROOT, 'data', 'scene.live.json'); function loadScene() { const path = existsSync(LIVE_PATH) ? LIVE_PATH : SEED_PATH; return JSON.parse(readFileSync(path, 'utf-8')); } let scene = loadScene(); // ---- Ghost roster (for the admin picker) --------------------------------- // Full enriched roster lives in data/ghosts.json. We serve a trimmed view with // just what the scene editor needs to pick a ghost and auto-fill its colour. let roster = []; try { const rraw = readFileSync(join(ROOT, 'data', 'ghosts.json'), 'utf-8'); roster = JSON.parse(rraw).map((g) => ({ id: g.id, name: g.name, color: g.color, rarity: g.rarity, rarityTier: g.rarityTier, isBoss: !!g.isBoss, ability: g.abilityId, })); } catch (_) { roster = []; // roster optional; editor falls back to free-text id } // ---- Motion solvers ------------------------------------------------------ // Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }. // Positions are in world millimetres, matching scene.json. function solveHover(spawn, t) { const p = spawn.position; const r = spawn.hoverRadiusMm ?? 0; const hoverW = (2 * Math.PI) / (spawn.hoverPeriodS ?? 6); const bobA = spawn.bobAmplitudeMm ?? 0; const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); const yaw = ((spawn.yawDegPerS ?? 0) * t) % 360; return { x: p.x + Math.cos(hoverW * t) * r, y: p.y + Math.sin(bobW * t) * bobA, z: p.z + Math.sin(hoverW * t) * r, yawDeg: yaw, }; } function solvePatrol(spawn, t) { const path = spawn.path || []; if (path.length === 0) return { x: 0, y: 0, z: 0, yawDeg: 0 }; if (path.length === 1) return { ...path[0], yawDeg: 0 }; const period = spawn.patrolPeriodS ?? 12; const segCount = path.length; // looped path const phase = ((t % period) / period) * segCount; // 0..segCount const seg = Math.floor(phase) % segCount; const frac = phase - Math.floor(phase); const a = path[seg]; const b = path[(seg + 1) % segCount]; const x = a.x + (b.x - a.x) * frac; const y = a.y + (b.y - a.y) * frac; const z = a.z + (b.z - a.z) * frac; const bobA = spawn.bobAmplitudeMm ?? 0; const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); let yawDeg = 0; if (spawn.faceTravel) { yawDeg = (Math.atan2(b.x - a.x, b.z - a.z) * 180) / Math.PI; } return { x, y: y + Math.sin(bobW * t) * bobA, z, yawDeg }; } const SOLVERS = { hover: solveHover, patrol: solvePatrol }; function solveSpawn(spawn, t) { const solver = SOLVERS[spawn.motion] || solveHover; return solver(spawn, t); } // ---- Authoritative state ------------------------------------------------- const startTime = Date.now(); function nowSeconds() { return (Date.now() - startTime) / 1000; } function buildSnapshot() { const t = nowSeconds(); const ghosts = scene.spawns.map((spawn) => { const pose = solveSpawn(spawn, t); return { spawnId: spawn.id, ghostId: spawn.ghostId, label: spawn.label, pos: { x: round(pose.x), y: round(pose.y), z: round(pose.z) }, yawDeg: round(pose.yawDeg), }; }); return { type: 'state', serverTime: t, ghosts }; } function round(n) { return Math.round(n * 10) / 10; } // ---- HTTP + static ------------------------------------------------------- const app = express(); app.use(express.json({ limit: '4mb' })); // for the admin save endpoint app.use(express.static(join(ROOT, 'public'))); // Full scene for the admin editor (everything) and clients (anchors+spawns). app.get('/api/scene', (_req, res) => { res.json(scene); }); // Ghost roster for the admin picker (trimmed). app.get('/api/ghosts', (_req, res) => { res.json(roster); }); // Admin save: validate, back up the old file, write, hot-reload. app.post('/api/scene', (req, res) => { const incoming = req.body; const err = validateScene(incoming); if (err) return res.status(400).json({ ok: false, error: err }); try { const dataDir = join(ROOT, 'data'); const target = LIVE_PATH; // authored scene -> live file (survives deploys) // timestamped backup so a bad save is always recoverable const backupDir = join(dataDir, 'backups'); if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true }); if (existsSync(target)) { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); copyFileSync(target, join(backupDir, `scene-${stamp}.json`)); } writeFileSync(target, JSON.stringify(incoming, null, 2), 'utf-8'); scene = incoming; // hot-reload: live ghosts update immediately broadcastHello(); // push new anchors/world to connected viewers res.json({ ok: true, spawns: scene.spawns.length, anchors: scene.anchors.length, blockers: (scene.blockers || []).length }); } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }); } }); // Minimal schema guard so a malformed save can't crash the motion loop. function validateScene(s) { if (!s || typeof s !== 'object') return 'scene must be an object'; if (!s.world || !s.world.buildSize) return 'missing world.buildSize'; if (!Array.isArray(s.anchors)) return 'anchors must be an array'; if (!Array.isArray(s.spawns)) return 'spawns must be an array'; if (s.blockers && !Array.isArray(s.blockers)) return 'blockers must be an array'; for (const sp of s.spawns) { if (!sp.id || !sp.motion) return `spawn missing id/motion: ${JSON.stringify(sp).slice(0, 80)}`; if (sp.motion === 'patrol' && (!Array.isArray(sp.path) || sp.path.length < 2)) { return `patrol spawn "${sp.id}" needs a path of >=2 points`; } if (sp.motion === 'hover' && !sp.position) return `hover spawn "${sp.id}" needs a position`; } return null; } app.get('/api/health', (_req, res) => { res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ }); }); const httpServer = createServer(app); // ---- WebSocket broadcast ------------------------------------------------- const wss = new WebSocketServer({ server: httpServer, path: '/sync' }); function helloPayload() { return JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors, blockers: scene.blockers || [], }); } function broadcastHello() { const payload = helloPayload(); for (const client of wss.clients) { if (client.readyState === 1) client.send(payload); } } wss.on('connection', (socket) => { // Send scene + immediate snapshot on connect so a new phone is instantly aligned. socket.send(helloPayload()); socket.send(JSON.stringify(buildSnapshot())); }); setInterval(() => { if (wss.clients.size === 0) return; const payload = JSON.stringify(buildSnapshot()); for (const client of wss.clients) { if (client.readyState === 1) client.send(payload); } }, 1000 / TICK_HZ); httpServer.listen(PORT, () => { console.log(`Newbury exhibit sync server on :${PORT} (broadcast ${TICK_HZ}Hz)`); console.log(` Viewer: http://localhost:${PORT}/`); console.log(` Scene: ${scene.spawns.length} spawns, ${scene.anchors.length} anchors` + ` (${existsSync(LIVE_PATH) ? 'live: scene.live.json' : 'seed: scene.json'})`); });