Import v2 base (unchanged files)

This commit is contained in:
load-v3
2026-08-13 12:45:29 +00:00
parent 81f2168717
commit 290976a6cc
39 changed files with 9948 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
/* Simple token auth for final mode. */
const crypto = require('crypto');
const tokens = new Map(); // token -> expiry
const TTL = 12 * 60 * 60 * 1000;
module.exports = {
login(password) {
const expected = process.env.ADMIN_PASSWORD;
if (!expected || password !== expected) return null;
const t = crypto.randomBytes(24).toString('hex');
tokens.set(t, Date.now() + TTL);
return t;
},
check(t) {
if (!t) return false;
const exp = tokens.get(t);
if (!exp) return false;
if (Date.now() > exp) { tokens.delete(t); return false; }
return true;
},
};
+334
View File
@@ -0,0 +1,334 @@
/* Playlist engine — server-driven so every viewer sees the same ghosts.
*
* THREE LAYERS, running simultaneously:
* 1. residents — permanent, never rotate out, own their spot
* 2. tracks — scheduled/ambient groups (the playlist proper)
* 3. (legacy) — global settings kept for behavior tuning
*
* A TRACK:
* {
* id, name,
* set: 'all' | { ids: [...] } | { colors:[], rarities:[] }, // who can appear
* spawnTime: 'random' | seconds, // 'random' = continuous ambient; number = interval between cycles
* spawnPoint: 'random' | 'spawn-1' | 'path:main-street',
* runEach: seconds, // how long each ghost stays visible
* concurrent: n, // how many of this track's set are visible at once
* priority: n, // higher wins a contested spawn point (scheduled default 10, ambient 0)
* enabled: bool
* }
*
* ApproxRuntime (computed, read-only):
* spawnTime 'random' -> setSize * runEach (one full pass through the set)
* spawnTime number -> spawnTime + runEach (one complete cycle: wait, then run)
*
* Scheduled tracks outrank ambient ones: when a timed track fires and its spawn point is
* held by a lower-priority ghost, that ghost is faded out and replaced.
*
* Broadcasts: spawn / despawn / active. Permanent records have until: null.
*/
class Playlist {
constructor(state, broadcast) {
this.state = state;
this.broadcast = broadcast;
const saved = state.getPlaylistConfig(Playlist.defaults());
this.cfg = Playlist.migrate({ ...Playlist.defaults(), ...saved });
this.active = new Map();
this.trackState = new Map(); // trackId -> { nextFireAt, cursor }
this.uidCounter = 1;
this.timer = null;
}
static defaults() {
return {
crossfadeSeconds: 3,
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10, pathSpeed: 8, pathChance: 0.4 },
rarity: {
weights: { Common: 1.0, Rare: 0.5, Epic: 0.25, Legendary: 0.1 },
movementScale: { Common: 1.0, Rare: 1.0, Epic: 0.7, Legendary: 0.4 },
},
residents: [],
tracks: [{
id: 'ambient', name: 'All ghosts (ambient)', set: 'all',
spawnTime: 'random', spawnPoint: 'random', runEach: 30,
concurrent: 5, priority: 0, enabled: true,
}],
timeWindows: [],
};
}
/* Old single-rotation configs become the ambient track. */
static migrate(cfg) {
if (!Array.isArray(cfg.tracks) || !cfg.tracks.length) {
cfg.tracks = [{
id: 'ambient', name: 'All ghosts (ambient)',
set: (cfg.include && (cfg.include.ids?.length || cfg.include.colors?.length || cfg.include.rarities?.length))
? { ids: cfg.include.ids || [], colors: cfg.include.colors || [], rarities: cfg.include.rarities || [] }
: 'all',
spawnTime: 'random', spawnPoint: 'random',
runEach: cfg.dwellSeconds || 30,
concurrent: cfg.slots || 5, priority: 0, enabled: true,
}];
}
delete cfg.slots; delete cfg.dwellSeconds; delete cfg.order; delete cfg.include;
return cfg;
}
getConfig() {
return { ...this.cfg, tracks: this.cfg.tracks.map(t => ({ ...t, approxRuntimeSeconds: this.approxRuntime(t) })) };
}
setConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('bad config');
const merged = Playlist.migrate({ ...Playlist.defaults(), ...this.cfg, ...cfg });
for (const t of merged.tracks || []) {
if (!t.id) throw new Error('track.id required');
t.runEach = Math.max(1, Number(t.runEach) || 30);
t.concurrent = Math.max(1, Number(t.concurrent) || 1);
t.priority = Number(t.priority ?? (t.spawnTime === 'random' ? 0 : 10));
t.enabled = t.enabled !== false;
}
this.cfg = merged;
this.state.putPlaylistConfig(this.cfg);
for (const uid of [...this.active.keys()]) this.despawn(uid);
this.trackState.clear();
this.tick();
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
return this.getConfig();
}
/* Re-read the persisted playlist config from state (used after a layout load
* swaps playlist.live.json) and do the same clean reset as setConfig. */
reloadFromState() {
const saved = this.state.getPlaylistConfig(Playlist.defaults());
this.cfg = Playlist.migrate({ ...Playlist.defaults(), ...saved });
for (const uid of [...this.active.keys()]) this.despawn(uid);
this.trackState.clear();
this.tick();
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
return this.getConfig();
}
allGhosts() { return this.state.getGhosts().ghosts; }
/* Ghosts a track may use, minus those pinned as residents. */
trackSet(track) {
const residentIds = new Set((this.cfg.residents || []).map(r => r.id));
const all = this.allGhosts().filter(g => !residentIds.has(g.id));
if (!track.set || track.set === 'all') return all;
const s = track.set;
if (s.ids && s.ids.length) return all.filter(g => s.ids.includes(g.id));
return all.filter(g =>
(!s.colors?.length || s.colors.includes(g.color)) &&
(!s.rarities?.length || s.rarities.includes(g.rarity)));
}
approxRuntime(track) {
const n = this.trackSet(track).length;
if (track.spawnTime === 'random' || track.spawnTime == null) return n * (track.runEach || 30);
return (Number(track.spawnTime) || 0) + (track.runEach || 30);
}
scale(g) { return this.cfg.rarity?.movementScale?.[g.rarity] ?? 1; }
// ---------- locations ----------
holderOf(spawnId, pathId) {
for (const [uid, g] of this.active)
if ((spawnId && g.spawnId === spawnId) || (pathId && g.pathId === pathId)) return { uid, g };
return null;
}
/* Resolve a track's spawnPoint into { spawn, path } or null if unavailable.
* Scheduled tracks may evict a lower-priority occupant. */
resolveLocation(track) {
const scene = this.state.getScene();
const sp = track.spawnPoint || 'random';
if (sp !== 'random') {
let spawn = null, path = null;
if (String(sp).startsWith('path:')) path = (scene.paths || []).find(p => p.id === String(sp).slice(5));
else spawn = (scene.spawns || []).find(s => s.id === sp);
if (!spawn && !path) return null;
const held = this.holderOf(spawn?.id, path?.id);
if (held) {
if (held.g.permanent) return null; // never evict a resident
if ((held.g.priority ?? 0) >= (track.priority ?? 0)) return null;
this.despawn(held.uid); // scheduled beats ambient
}
return { spawn, path };
}
// random: prefer a free spawn point, else a free path
const usedS = new Set([...this.active.values()].map(g => g.spawnId).filter(Boolean));
const usedP = new Set([...this.active.values()].map(g => g.pathId).filter(Boolean));
const b = this.cfg.behaviors || {};
const freeP = (scene.paths || []).filter(p => p.enabled !== false && (p.points || []).length >= 2 && !usedP.has(p.id));
const freeS = (scene.spawns || []).filter(s => s.enabled !== false && !usedS.has(s.id));
if (freeP.length && Math.random() < (b.pathChance ?? 0.4)) return { path: freeP[Math.floor(Math.random() * freeP.length)] };
if (freeS.length) return { spawn: freeS[Math.floor(Math.random() * freeS.length)] };
if (freeP.length) return { path: freeP[Math.floor(Math.random() * freeP.length)] };
return null;
}
// ---------- behavior ----------
makeBehavior(kind, g, path) {
const b = this.cfg.behaviors || {};
const k = this.scale(g);
if (kind === 'path' && path) {
return { type: 'path', points: path.points.map(p => [...p]), mode: path.mode || 'loop',
speed: (b.pathSpeed ?? 8) * k, phase: Math.random() * 1000, seed: Math.floor(Math.random() * 1e6) };
}
if (kind === 'wander') {
return { type: 'wander', radius: (b.wanderRadius ?? 60) * k, speed: (b.wanderSpeed ?? 0.15) * k,
vertical: (b.wanderVertical ?? 10) * k, seed: Math.floor(Math.random() * 1e6) };
}
return { type: 'static', bobAmp: (5 + Math.random() * 5) * k, bobHz: 0.3 + Math.random() * 0.3 };
}
makeRecord(g, { spawn, path }, behavior, opts = {}) {
const perm = !!opts.permanent;
return {
uid: perm ? 'res-' + g.id : 'g' + (this.uidCounter++),
id: g.id, name: g.name, color: g.color, rarity: g.rarity,
trackId: opts.trackId || null,
priority: opts.priority ?? 0,
spawnId: spawn ? spawn.id : null,
pathId: path ? path.id : null,
pos: spawn ? [...spawn.position] : [...path.points[0]],
behavior,
spawnedAt: Date.now(),
until: perm ? null : Date.now() + (opts.runEach || 30) * 1000,
crossfade: this.cfg.crossfadeSeconds || 3,
permanent: perm,
};
}
// ---------- residents (layer 1) ----------
ensureResidents() {
for (const r of this.cfg.residents || []) {
const uid = 'res-' + r.id;
if (this.active.has(uid)) continue;
const g = this.allGhosts().find(x => x.id === r.id);
if (!g) continue;
const scene = this.state.getScene();
let spawn = null, path = null;
if (r.pathId) path = (scene.paths || []).find(p => p.id === r.pathId && (p.points || []).length >= 2);
if (!path && r.spawnId) spawn = (scene.spawns || []).find(s => s.id === r.spawnId);
if (!path && !spawn) {
const loc = this.resolveLocation({ spawnPoint: 'random', priority: 999 });
if (!loc) continue;
spawn = loc.spawn; path = loc.path;
} else {
const held = this.holderOf(spawn?.id, path?.id);
if (held && !held.g.permanent) this.despawn(held.uid); // residents outrank everything
else if (held) continue;
}
const kind = r.behavior || (path ? 'path' : 'static');
const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), { permanent: true, priority: 999 });
this.active.set(uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
}
const wanted = new Set((this.cfg.residents || []).map(r => 'res-' + r.id));
for (const uid of [...this.active.keys()])
if (uid.startsWith('res-') && !wanted.has(uid)) this.despawn(uid);
}
// ---------- tracks (layer 2) ----------
pickFromTrack(track, st) {
const pool = this.trackSet(track);
if (!pool.length) return null;
const activeIds = new Set([...this.active.values()].map(x => x.id));
if (track.spawnTime === 'random' || track.spawnTime == null) {
// ambient: rarity-weighted pick from whoever isn't already out
const avail = pool.filter(g => !activeIds.has(g.id));
if (!avail.length) return null;
const w = this.cfg.rarity?.weights || {};
const weights = avail.map(g => Math.max(0, w[g.rarity] ?? 1));
const sum = weights.reduce((a, b) => a + b, 0);
if (sum <= 0) return avail[Math.floor(Math.random() * avail.length)];
let r = Math.random() * sum;
for (let i = 0; i < avail.length; i++) { r -= weights[i]; if (r <= 0) return avail[i]; }
return avail[avail.length - 1];
}
// scheduled: strict round-robin so "Jimothy and Joe Rotten" alternate at concurrent 1
for (let i = 0; i < pool.length; i++) {
const g = pool[(st.cursor + i) % pool.length];
if (!activeIds.has(g.id)) { st.cursor = (st.cursor + i + 1) % pool.length; return g; }
}
return null;
}
runTrack(track, now) {
if (!this.trackState.has(track.id)) this.trackState.set(track.id, { nextFireAt: 0, cursor: 0 });
const st = this.trackState.get(track.id);
const mine = [...this.active.values()].filter(g => g.trackId === track.id);
const timed = !(track.spawnTime === 'random' || track.spawnTime == null);
if (timed) {
if (now < st.nextFireAt) return;
if (mine.length >= track.concurrent) return; // cycle still on screen
// fire a full batch, then wait spawnTime from the END of this appearance
let placed = 0;
for (let i = mine.length; i < track.concurrent; i++) if (this.spawnFor(track, st)) placed++;
if (placed) st.nextFireAt = now + (Number(track.spawnTime) * 1000) + (track.runEach * 1000);
return;
}
// ambient: keep topped up continuously
for (let i = mine.length; i < track.concurrent; i++) if (!this.spawnFor(track, st)) break;
}
spawnFor(track, st) {
const g = this.pickFromTrack(track, st);
if (!g) return false;
const loc = this.resolveLocation(track);
if (!loc) return false;
const b = this.cfg.behaviors || {};
const kind = loc.path ? 'path' : (Math.random() < (b.staticChance ?? 0.5) ? 'static' : 'wander');
const rec = this.makeRecord(g, loc, this.makeBehavior(kind, g, loc.path),
{ trackId: track.id, priority: track.priority ?? 0, runEach: track.runEach });
this.active.set(rec.uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
return true;
}
despawn(uid) {
if (!this.active.has(uid)) return;
this.active.delete(uid);
this.broadcast({ type: 'despawn', uid });
}
activeSnapshot() { return [...this.active.values()]; }
inTimeWindow() {
const w = this.cfg.timeWindows || [];
if (!w.length) return true;
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
return w.some(({ start, end }) => {
const [sh, sm] = String(start).split(':').map(Number);
const [eh, em] = String(end).split(':').map(Number);
const s = sh * 60 + (sm || 0), e = eh * 60 + (em || 0);
return s <= e ? (mins >= s && mins < e) : (mins >= s || mins < e);
});
}
tick() {
const now = Date.now();
if (!this.inTimeWindow()) {
for (const uid of [...this.active.keys()]) this.despawn(uid);
return;
}
this.ensureResidents();
for (const [uid, g] of this.active) if (!g.permanent && g.until != null && now >= g.until) this.despawn(uid);
// scheduled tracks first so they can claim/evict before ambient fills up
const tracks = (this.cfg.tracks || []).filter(t => t.enabled !== false)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
for (const t of tracks) this.runTrack(t, now);
}
start() {
this.tick();
this.timer = setInterval(() => this.tick(), 1000);
}
}
module.exports = Playlist;
+162
View File
@@ -0,0 +1,162 @@
/* Newbury Exhibit v2 — server
* Express static host + WebSocket sync + admin API + playlist engine.
* Modes: EXHIBIT_MODE=dev -> admin open, verbose client error overlay enabled
* EXHIBIT_MODE=final -> admin password-protected (ADMIN_PASSWORD env)
*/
const express = require('express');
const http = require('http');
const path = require('path');
const { WebSocketServer } = require('ws');
const state = require('./state');
const auth = require('./auth');
const Playlist = require('./playlist');
const { Assets } = require('./uploads');
const PORT = process.env.PORT || 33044;
const MODE = process.env.EXHIBIT_MODE || 'dev';
const app = express();
app.use(express.json({ limit: '20mb' })); // base64 asset uploads
// ---------- public static ----------
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json')));
app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json')));
// ---------- uploaded assets (models + textures) ----------
const assets = new Assets(state.liveDir);
app.use('/assets', express.static(path.join(state.liveDir, 'assets'), { maxAge: '1h' }));
app.get('/api/assets', (req, res) => res.json(assets.list()));
app.post('/api/assets', guard, (req, res) => {
try { res.json(assets.save(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/assets/:id', guard, (req, res) => {
try { res.json(assets.remove(req.params.id)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- landing page ----------
app.get('/api/landing', (req, res) => res.json(state.getLanding()));
app.put('/api/landing', guard, (req, res) => {
try { res.json(state.putLanding(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- character overrides ----------
app.get('/api/characters', (req, res) => res.json(state.getCharacters()));
app.put('/api/characters', guard, (req, res) => {
try { res.json(state.putCharacters(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- runtime info ----------
app.get('/api/info', (req, res) => {
res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 });
});
// ---------- auth ----------
app.post('/api/login', (req, res) => {
if (MODE !== 'final') return res.json({ token: 'dev' });
const t = auth.login(req.body && req.body.password);
if (!t) return res.status(401).json({ error: 'bad password' });
res.json({ token: t });
});
function guard(req, res, next) {
if (MODE !== 'final') return next();
if (auth.check(req.headers['x-exhibit-token'])) return next();
res.status(401).json({ error: 'unauthorized' });
}
// ---------- scene (anchors, buildings, spawns) ----------
app.get('/api/scene', (req, res) => res.json(state.getScene()));
app.put('/api/scene', guard, (req, res) => {
try { res.json(state.putScene(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.post('/api/scene/reset', guard, (req, res) => res.json(state.resetToSeed()));
// ---------- named layouts (scene + its playlist) ----------
app.get('/api/layouts', (req, res) => res.json(state.listLayouts()));
app.post('/api/layouts', guard, (req, res) => {
try {
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name required' });
res.json(state.saveLayout(name, playlist.getConfig()));
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.post('/api/layouts/:slug/load', guard, (req, res) => {
try {
const { scene, playlist: pl } = state.loadLayout(req.params.slug);
if (pl) playlist.reloadFromState();
res.json({ scene, layouts: state.listLayouts() });
} catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/layouts/:slug', guard, (req, res) => {
try { res.json(state.deleteLayout(req.params.slug)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- playlist config ----------
app.get('/api/playlist', (req, res) => res.json(playlist.getConfig()));
app.put('/api/playlist', guard, (req, res) => {
try { res.json(playlist.setConfig(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- ghost roster ----------
app.get('/api/ghosts', (req, res) => res.json(state.getGhosts()));
// ---------- model manifests (multi-part OBJ ghosts) ----------
app.get('/api/models', (req, res) => res.json(state.getModels()));
app.put('/api/models', guard, (req, res) => {
try { res.json(state.putModels(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- client error reporting (dev mode) ----------
const clientErrors = [];
app.post('/api/client-error', (req, res) => {
clientErrors.push({ t: Date.now(), ...req.body });
if (clientErrors.length > 500) clientErrors.shift();
console.warn('[client-error]', JSON.stringify(req.body).slice(0, 500));
res.json({ ok: true });
});
app.get('/api/client-errors', guard, (req, res) => res.json(clientErrors));
// ---------- server + websocket ----------
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
function broadcast(obj) {
const msg = JSON.stringify(obj);
for (const c of wss.clients) if (c.readyState === 1) c.send(msg);
}
const playlist = new Playlist(state, broadcast);
playlist.start();
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() }));
ws.send(JSON.stringify({ type: 'characters', characters: state.getCharacters() }));
ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() }));
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; }
if (m.type === 'ping') ws.send(JSON.stringify({ type: 'pong', t: m.t, server: Date.now() }));
});
});
// push scene updates to viewers when admin saves
state.onChange(() => {
broadcast({ type: 'scene', scene: state.getScene() });
broadcast({ type: 'characters', characters: state.getCharacters() });
});
server.listen(PORT, () => {
console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`);
if (MODE === 'final' && !process.env.ADMIN_PASSWORD)
console.warn('WARNING: final mode with no ADMIN_PASSWORD set — admin is locked out.');
});
+202
View File
@@ -0,0 +1,202 @@
/* 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/<slug>.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),
};
+64
View File
@@ -0,0 +1,64 @@
/* uploads.js — model/texture asset handling for the character manager.
* Files land in LIVE_DIR/assets (Docker volume) and are served at /assets/...
* so uploads survive image rebuilds without being baked into the repo.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ALLOWED = {
'.obj': 'model/obj', '.mtl': 'model/mtl',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
};
const MAX_BYTES = 12 * 1024 * 1024;
function safeName(name) {
const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_');
return base.slice(-80) || 'file';
}
class Assets {
constructor(liveDir) {
this.dir = path.join(liveDir, 'assets');
this.indexPath = path.join(liveDir, 'assets.json');
fs.mkdirSync(this.dir, { recursive: true });
this.index = this.read();
}
read() {
try { return JSON.parse(fs.readFileSync(this.indexPath, 'utf8')); } catch { return { assets: [] }; }
}
write() { fs.writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2)); }
list() { return this.index; }
/* body: { name, kind: 'model'|'texture', dataBase64 } */
save({ name, kind, dataBase64 }) {
const clean = safeName(name);
const ext = path.extname(clean).toLowerCase();
if (!ALLOWED[ext]) throw new Error(`unsupported file type ${ext || '(none)'} — allowed: ${Object.keys(ALLOWED).join(', ')}`);
const buf = Buffer.from(String(dataBase64 || ''), 'base64');
if (!buf.length) throw new Error('empty file');
if (buf.length > MAX_BYTES) throw new Error(`file too large (${(buf.length / 1048576).toFixed(1)} MB, max 12 MB)`);
const id = crypto.randomBytes(6).toString('hex');
const stored = `${id}${ext}`;
fs.writeFileSync(path.join(this.dir, stored), buf);
const rec = {
id, name: clean, kind: kind === 'texture' ? 'texture' : 'model',
url: `/assets/${stored}`, bytes: buf.length, uploadedAt: Date.now(),
};
this.index.assets.push(rec);
this.write();
return rec;
}
remove(id) {
const i = this.index.assets.findIndex(a => a.id === id);
if (i < 0) throw new Error('asset not found');
const [rec] = this.index.assets.splice(i, 1);
try { fs.unlinkSync(path.join(this.dir, path.basename(rec.url))); } catch {}
this.write();
return rec;
}
}
module.exports = { Assets, ALLOWED, MAX_BYTES };