/* Seed/live persistence. * data/scene.json — committed seed (survives in git) * data/live/scene.live.json — runtime edits, volume-mounted in Docker, survives redeploys */ const fs = require('fs'); const path = require('path'); const DATA = path.join(__dirname, '..', 'data'); const LIVE_DIR = process.env.LIVE_DIR || path.join(DATA, 'live'); const SEED = path.join(DATA, 'scene.json'); const LIVE = path.join(LIVE_DIR, 'scene.live.json'); const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json'); const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json'); const MODELS_SEED = path.join(DATA, 'models.json'); fs.mkdirSync(LIVE_DIR, { recursive: true }); function readJSON(p, fallback) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; } } function writeJSON(p, obj) { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], paths: [], world: { units: 'cm' } }); let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []); let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); const listeners = []; function validateScene(s) { if (!s || typeof s !== 'object') throw new Error('scene must be object'); for (const k of ['anchors', 'buildings', 'spawns']) { if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`); } s.paths = Array.isArray(s.paths) ? s.paths : []; for (const pth of s.paths) { if (!pth.id) throw new Error('path.id required'); if (!Array.isArray(pth.points) || pth.points.length < 2) throw new Error(`path ${pth.id} needs >= 2 points`); pth.mode = pth.mode === 'pingpong' ? 'pingpong' : 'loop'; pth.enabled = pth.enabled !== false; } for (const a of s.anchors) { if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required'); if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required'); a.mount = a.mount || 'flat'; // 'flat' | 'wall' | 'custom' a.yawDeg = Number(a.yawDeg || 0); a.pitchDeg = Number(a.pitchDeg || 0); a.rollDeg = Number(a.rollDeg || 0); a.sizeMM = Number(a.sizeMM || 60); // printed marker size a.enabled = a.enabled !== false; } return s; } module.exports = { getScene: () => scene, putScene(s) { scene = validateScene(s); writeJSON(LIVE, scene); listeners.forEach(f => f()); return scene; }, resetToSeed() { scene = readJSON(SEED, scene); writeJSON(LIVE, scene); listeners.forEach(f => f()); return scene; }, getGhosts: () => ({ ghosts, gradients }), getModels: () => models, putModels(m) { if (!m || !Array.isArray(m.models)) throw new Error('models.models must be array'); models = m; writeJSON(MODELS_LIVE, models); return models; }, getPlaylistConfig: (fallback) => readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), fallback), putPlaylistConfig(cfg) { writeJSON(PLAYLIST_LIVE, cfg); return cfg; }, onChange: (f) => listeners.push(f), };