/* 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'); const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json'); const LANDING_LIVE = path.join(LIVE_DIR, 'landing.live.json'); /* Named layouts: each is a self-contained { name, scene, playlist } saved under * layouts/.json. layouts.index.json tracks the list + which slug is active. * The active layout's scene/playlist are mirrored into scene.live.json / * playlist.live.json so viewers and the playlist engine need no changes. */ const LAYOUTS_DIR = path.join(LIVE_DIR, 'layouts'); const LAYOUTS_INDEX = path.join(LIVE_DIR, 'layouts.index.json'); fs.mkdirSync(LIVE_DIR, { recursive: true }); fs.mkdirSync(LAYOUTS_DIR, { recursive: true }); function slugify(name) { const s = String(name || '').toLowerCase().trim() .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); return s || 'layout'; } 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: [] }); /* characters: per-ghost visual overrides keyed by ghost id, plus defaults. * { defaults: {...}, byId: { 'mad-fenton': { modelId, opacity, topColor, bottomColor, * faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */ let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} }; /* landing: end-user facing start screen + details page. All text is editable except * that the disclaimer is ALWAYS rendered (wording editable, presence is not). */ const LANDING_DEFAULTS = { title: 'NEWBURY NIGHTS', subtitle: 'Point your phone at the Newbury Crests to reveal the hidden side of the town.', logoUrl: '', backgroundUrl: '', startButton: 'Start Ghost Watching', detailsButton: 'About this build', detailsTitle: 'About Newbury Nights', detailsMarkdown: 'Write about your build here.\n\nUse **bold**, *italic*, and blank lines for paragraphs.', disclaimer: 'Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. LEGO® and Hidden Side™ are trademarks of the LEGO Group.', accent: '#51eaf1', textColor: '#e8ecff', overlay: 0.55, }; let landing = Object.assign({}, LANDING_DEFAULTS, readJSON(LANDING_LIVE, null) || {}); const listeners = []; /* ---------- named layouts ---------- */ function readLayoutIndex() { const idx = readJSON(LAYOUTS_INDEX, null); if (idx && Array.isArray(idx.layouts)) return idx; return { layouts: [], activeSlug: null }; } function writeLayoutIndex(idx) { writeJSON(LAYOUTS_INDEX, idx); } function layoutPath(slug) { return path.join(LAYOUTS_DIR, `${slug}.json`); } function listLayouts() { const idx = readLayoutIndex(); return { layouts: idx.layouts, activeSlug: idx.activeSlug }; } /* Snapshot the CURRENT live scene + playlist under a name. Overwrites if the * slug already exists (same name). Returns the updated index. */ function saveLayout(name, playlistCfg) { const slug = slugify(name); const record = { name: String(name || slug), slug, scene, playlist: playlistCfg || readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), {}), savedAt: Date.now(), }; writeJSON(layoutPath(slug), record); const idx = readLayoutIndex(); if (!idx.layouts.some(l => l.slug === slug)) idx.layouts.push({ slug, name: record.name }); else idx.layouts = idx.layouts.map(l => l.slug === slug ? { slug, name: record.name } : l); idx.activeSlug = slug; writeLayoutIndex(idx); return listLayouts(); } /* Make a saved layout active: mirror its scene into the live scene (so viewers * update) and return its playlist so the caller can reload the engine. */ function loadLayout(slug) { const rec = readJSON(layoutPath(slug), null); if (!rec) throw new Error('layout not found: ' + slug); scene = validateScene(rec.scene); writeJSON(LIVE, scene); if (rec.playlist) writeJSON(PLAYLIST_LIVE, rec.playlist); const idx = readLayoutIndex(); idx.activeSlug = slug; writeLayoutIndex(idx); listeners.forEach(f => f()); return { scene, playlist: rec.playlist || null }; } function deleteLayout(slug) { try { fs.unlinkSync(layoutPath(slug)); } catch {} const idx = readLayoutIndex(); idx.layouts = idx.layouts.filter(l => l.slug !== slug); if (idx.activeSlug === slug) idx.activeSlug = null; writeLayoutIndex(idx); return listLayouts(); } 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.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, s.table || {}); s.ghostHeightCm = Number(s.ghostHeightCm || 4); // minifigure-scale default 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 }), getLanding: () => landing, putLanding(l) { if (!l || typeof l !== 'object') throw new Error('landing must be object'); const next = Object.assign({}, LANDING_DEFAULTS, l); // disclaimer may be reworded but never emptied — it protects the project if (!String(next.disclaimer || '').trim()) next.disclaimer = LANDING_DEFAULTS.disclaimer; landing = next; writeJSON(LANDING_LIVE, landing); listeners.forEach(f => f()); return landing; }, getCharacters: () => characters, putCharacters(c) { if (!c || typeof c !== 'object') throw new Error('characters must be object'); characters = { defaults: c.defaults || {}, byId: c.byId || {} }; writeJSON(CHARS_LIVE, characters); listeners.forEach(f => f()); return characters; }, liveDir: LIVE_DIR, 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; }, listLayouts, saveLayout, loadLayout, deleteLayout, onChange: (f) => listeners.push(f), };