update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/* Playlist engine — server-driven so every viewer sees the same ghosts.
|
||||
*
|
||||
* Config:
|
||||
* {
|
||||
* slots: 5, // concurrent visible ghosts
|
||||
* dwellSeconds: 120, // how long each ghost stays before rotating out
|
||||
* crossfadeSeconds: 3, // fade out/in overlap hint for clients
|
||||
* order: 'shuffle' | 'roster', // rotation order through the roster
|
||||
* include: { colors:[], rarities:[], ids:[] }, // empty = all
|
||||
* behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 },
|
||||
* timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on
|
||||
* }
|
||||
*
|
||||
* Broadcasts:
|
||||
* { type:'spawn', ghost:{ uid, id, name, color, spawnId, pos, behavior, until } }
|
||||
* { type:'despawn', uid }
|
||||
* { type:'active', ghosts:[...] } (full snapshot, sent on join + config change)
|
||||
*/
|
||||
class Playlist {
|
||||
constructor(state, broadcast) {
|
||||
this.state = state;
|
||||
this.broadcast = broadcast;
|
||||
this.cfg = state.getPlaylistConfig({
|
||||
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle',
|
||||
include: { colors: [], rarities: [], ids: [] },
|
||||
behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 },
|
||||
timeWindows: [],
|
||||
});
|
||||
this.active = new Map(); // uid -> ghost record
|
||||
this.queue = [];
|
||||
this.uidCounter = 1;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
getConfig() { return this.cfg; }
|
||||
|
||||
setConfig(cfg) {
|
||||
if (!cfg || typeof cfg !== 'object') throw new Error('bad config');
|
||||
this.cfg = { ...this.cfg, ...cfg };
|
||||
this.state.putPlaylistConfig(this.cfg);
|
||||
// rebuild world: clear and respawn under new rules
|
||||
for (const uid of [...this.active.keys()]) this.despawn(uid);
|
||||
this.queue = [];
|
||||
this.tick();
|
||||
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
|
||||
return this.cfg;
|
||||
}
|
||||
|
||||
roster() {
|
||||
const { ghosts } = this.state.getGhosts();
|
||||
const inc = this.cfg.include || {};
|
||||
return ghosts.filter(g =>
|
||||
(!inc.colors?.length || inc.colors.includes(g.color)) &&
|
||||
(!inc.rarities?.length || inc.rarities.includes(g.rarity)) &&
|
||||
(!inc.ids?.length || inc.ids.includes(g.id)));
|
||||
}
|
||||
|
||||
refillQueue() {
|
||||
const r = this.roster().map(g => g.id);
|
||||
if (!r.length) return;
|
||||
if (this.cfg.order === 'shuffle') {
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[r[i], r[j]] = [r[j], r[i]];
|
||||
}
|
||||
}
|
||||
// avoid immediate repeat of currently-active ghosts at queue head
|
||||
const activeIds = new Set([...this.active.values()].map(g => g.id));
|
||||
this.queue.push(...r.filter(id => !activeIds.has(id)), ...r.filter(id => activeIds.has(id)));
|
||||
}
|
||||
|
||||
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); // handles overnight
|
||||
});
|
||||
}
|
||||
|
||||
freeSpawn() {
|
||||
const scene = this.state.getScene();
|
||||
const used = new Set([...this.active.values()].map(g => g.spawnId));
|
||||
const free = (scene.spawns || []).filter(s => s.enabled !== false && !used.has(s.id));
|
||||
if (!free.length) return null;
|
||||
return free[Math.floor(Math.random() * free.length)];
|
||||
}
|
||||
|
||||
spawnOne() {
|
||||
if (!this.queue.length) this.refillQueue();
|
||||
const id = this.queue.shift();
|
||||
if (!id) return;
|
||||
const g = this.roster().find(x => x.id === id);
|
||||
const spawn = this.freeSpawn();
|
||||
if (!g || !spawn) return;
|
||||
const b = this.cfg.behaviors || {};
|
||||
const isStatic = Math.random() < (b.staticChance ?? 0.5);
|
||||
const uid = 'g' + (this.uidCounter++);
|
||||
const rec = {
|
||||
uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity,
|
||||
spawnId: spawn.id, pos: spawn.position,
|
||||
behavior: isStatic
|
||||
? { type: 'static', bobAmp: 0.05 + Math.random() * 0.05, bobHz: 0.3 + Math.random() * 0.3 }
|
||||
: { type: 'wander', radius: b.wanderRadius ?? 0.6, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) },
|
||||
spawnedAt: Date.now(),
|
||||
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
|
||||
crossfade: this.cfg.crossfadeSeconds || 3,
|
||||
};
|
||||
this.active.set(uid, rec);
|
||||
this.broadcast({ type: 'spawn', ghost: rec });
|
||||
}
|
||||
|
||||
despawn(uid) {
|
||||
if (!this.active.has(uid)) return;
|
||||
this.active.delete(uid);
|
||||
this.broadcast({ type: 'despawn', uid });
|
||||
}
|
||||
|
||||
activeSnapshot() { return [...this.active.values()]; }
|
||||
|
||||
tick() {
|
||||
const now = Date.now();
|
||||
if (!this.inTimeWindow()) {
|
||||
for (const uid of [...this.active.keys()]) this.despawn(uid);
|
||||
return;
|
||||
}
|
||||
for (const [uid, g] of this.active) if (now >= g.until) this.despawn(uid);
|
||||
const want = Math.min(this.cfg.slots || 5, (this.state.getScene().spawns || []).filter(s => s.enabled !== false).length);
|
||||
while (this.active.size < want) {
|
||||
const before = this.active.size;
|
||||
this.spawnOne();
|
||||
if (this.active.size === before) break; // no roster/spawns available
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.tick();
|
||||
this.timer = setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Playlist;
|
||||
@@ -0,0 +1,108 @@
|
||||
/* 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 PORT = process.env.PORT || 33044;
|
||||
const MODE = process.env.EXHIBIT_MODE || 'dev';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
// ---------- 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')));
|
||||
|
||||
// ---------- 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()));
|
||||
|
||||
// ---------- 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: '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() }));
|
||||
|
||||
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.');
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/* 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: [], world: { units: 'm' } });
|
||||
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`);
|
||||
}
|
||||
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),
|
||||
};
|
||||
Reference in New Issue
Block a user