/* 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: 60, wanderSpeed: 0.15, wanderVertical: 10 }, * 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: 60, wanderSpeed: 0.15, wanderVertical: 10 }, 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: 5 + Math.random() * 5, bobHz: 0.3 + Math.random() * 0.3 } : { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, vertical: b.wanderVertical ?? 10, 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;