/* Playlist engine — server-driven so every viewer sees the same ghosts. * * Config: * { * slots: 5, // concurrent ROTATING ghosts (residents are extra) * dwellSeconds: 120, * crossfadeSeconds: 3, * order: 'weighted' | 'shuffle' | 'roster', * include: { colors:[], rarities:[], ids:[] }, // empty = all * 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 }, // appearance odds * movementScale: { Common: 1.0, Rare: 1.0, Epic: 0.7, Legendary: 0.4 } // how much they move * }, * residents: [ { id, spawnId?, pathId?, behavior: 'static'|'wander'|'path' } ], // permanent * timeWindows: [] // [{start:"09:00",end:"17:00"}] — empty = always on * } * * Broadcasts: spawn / despawn / active (snapshot). Permanent residents have until: null. */ class Playlist { constructor(state, broadcast) { this.state = state; this.broadcast = broadcast; this.cfg = state.getPlaylistConfig(Playlist.defaults()); this.cfg = { ...Playlist.defaults(), ...this.cfg }; this.active = new Map(); this.queue = []; this.uidCounter = 1; this.timer = null; } static defaults() { return { slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'weighted', include: { colors: [], rarities: [], ids: [] }, 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: [], timeWindows: [], }; } getConfig() { return this.cfg; } setConfig(cfg) { if (!cfg || typeof cfg !== 'object') throw new Error('bad config'); this.cfg = { ...Playlist.defaults(), ...this.cfg, ...cfg }; this.state.putPlaylistConfig(this.cfg); for (const uid of [...this.active.keys()]) this.despawn(uid, true); this.queue = []; this.tick(); this.broadcast({ type: 'active', ghosts: this.activeSnapshot() }); return this.cfg; } allGhosts() { return this.state.getGhosts().ghosts; } roster() { const inc = this.cfg.include || {}; const residentIds = new Set((this.cfg.residents || []).map(r => r.id)); return this.allGhosts().filter(g => !residentIds.has(g.id) && (!inc.colors?.length || inc.colors.includes(g.color)) && (!inc.rarities?.length || inc.rarities.includes(g.rarity)) && (!inc.ids?.length || inc.ids.includes(g.id))); } scale(g) { return this.cfg.rarity?.movementScale?.[g.rarity] ?? 1; } // ---------- ghost selection ---------- pickGhost() { const activeIds = new Set([...this.active.values()].map(x => x.id)); const pool = this.roster().filter(g => !activeIds.has(g.id)); if (!pool.length) return null; if (this.cfg.order === 'weighted') { const w = this.cfg.rarity?.weights || {}; const weights = pool.map(g => Math.max(0, w[g.rarity] ?? 1)); const sum = weights.reduce((a, b) => a + b, 0); if (sum <= 0) return pool[Math.floor(Math.random() * pool.length)]; let r = Math.random() * sum; for (let i = 0; i < pool.length; i++) { r -= weights[i]; if (r <= 0) return pool[i]; } return pool[pool.length - 1]; } // shuffle / roster: queue-based full rotation if (!this.queue.length) { const ids = this.roster().map(g => g.id); if (this.cfg.order === 'shuffle') for (let i = ids.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [ids[i], ids[j]] = [ids[j], ids[i]]; } this.queue = ids; } while (this.queue.length) { const id = this.queue.shift(); const g = pool.find(x => x.id === id); if (g) return g; } return null; } // ---------- locations ---------- occupied() { const spawns = new Set(), paths = new Set(); for (const g of this.active.values()) { if (g.spawnId) spawns.add(g.spawnId); if (g.pathId) paths.add(g.pathId); } return { spawns, paths }; } freeSpawn(occ) { const s = (this.state.getScene().spawns || []).filter(x => x.enabled !== false && !occ.spawns.has(x.id)); return s.length ? s[Math.floor(Math.random() * s.length)] : null; } freePath(occ) { const p = (this.state.getScene().paths || []).filter(x => x.enabled !== false && (x.points || []).length >= 2 && !occ.paths.has(x.id)); return p.length ? p[Math.floor(Math.random() * p.length)] : null; } // ---------- behavior builders (movement scale applied here) ---------- 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, permanent = false) { return { uid: permanent ? 'res-' + g.id : 'g' + (this.uidCounter++), id: g.id, name: g.name, color: g.color, rarity: g.rarity, spawnId: spawn ? spawn.id : null, pathId: path ? path.id : null, pos: spawn ? [...spawn.position] : [...path.points[0]], behavior, spawnedAt: Date.now(), until: permanent ? null : Date.now() + (this.cfg.dwellSeconds || 120) * 1000, crossfade: this.cfg.crossfadeSeconds || 3, permanent, }; } // ---------- residents: permanently in their place ---------- 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(); const occ = this.occupied(); 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) { spawn = this.freeSpawn(occ); if (!spawn) path = this.freePath(occ); } if (!path && !spawn) continue; const kind = r.behavior || (path ? 'path' : 'static'); const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), true); this.active.set(uid, rec); this.broadcast({ type: 'spawn', ghost: rec }); } // remove residents that were deleted from config 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); } // ---------- rotation ---------- spawnOne() { const g = this.pickGhost(); if (!g) return false; const occ = this.occupied(); const b = this.cfg.behaviors || {}; let path = null, spawn = null; if (Math.random() < (b.pathChance ?? 0.4)) path = this.freePath(occ); if (!path) spawn = this.freeSpawn(occ); if (!path && !spawn) return false; const kind = path ? 'path' : (Math.random() < (b.staticChance ?? 0.5) ? 'static' : 'wander'); const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), false); this.active.set(rec.uid, rec); this.broadcast({ type: 'spawn', ghost: rec }); return true; } despawn(uid, silentOk) { 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); // residents sleep outside hours too return; } this.ensureResidents(); for (const [uid, g] of this.active) if (!g.permanent && g.until != null && now >= g.until) this.despawn(uid); const scene = this.state.getScene(); const capacity = (scene.spawns || []).filter(s => s.enabled !== false).length + (scene.paths || []).filter(p => p.enabled !== false && (p.points || []).length >= 2).length; const rotatingNow = [...this.active.values()].filter(g => !g.permanent).length; const residents = this.active.size - rotatingNow; const want = Math.min(this.cfg.slots || 5, Math.max(0, capacity - residents)); let n = rotatingNow; while (n < want && this.spawnOne()) n++; } start() { this.tick(); this.timer = setInterval(() => this.tick(), 1000); } } module.exports = Playlist;