From 69e72c6c7aa599a75cb67adf06074ea2f62ec9f7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 3 Aug 2026 14:23:26 +1000 Subject: [PATCH] Add named layouts: save/load/delete scene+playlist profiles --- server/state.js | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/server/state.js b/server/state.js index 6c96463..668a934 100644 --- a/server/state.js +++ b/server/state.js @@ -15,7 +15,21 @@ 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; } @@ -53,6 +67,64 @@ 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']) { @@ -122,5 +194,9 @@ module.exports = { }, 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), };