/* 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(); } 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;