diff --git a/server/playlist.js b/server/playlist.js index f96d105..062a691 100644 --- a/server/playlist.js +++ b/server/playlist.js @@ -1,124 +1,163 @@ /* Playlist engine — server-driven so every viewer sees the same ghosts. * - * Config: + * 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: * { - * 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 + * 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 * } * - * Broadcasts: spawn / despawn / active (snapshot). Permanent residents have until: null. + * 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; - this.cfg = state.getPlaylistConfig(Playlist.defaults()); - this.cfg = { ...Playlist.defaults(), ...this.cfg }; + const saved = state.getPlaylistConfig(Playlist.defaults()); + this.cfg = Playlist.migrate({ ...Playlist.defaults(), ...saved }); this.active = new Map(); - this.queue = []; + this.trackState = new Map(); // trackId -> { nextFireAt, cursor } this.uidCounter = 1; this.timer = null; } static defaults() { return { - slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'weighted', - include: { colors: [], rarities: [], ids: [] }, + 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: [], }; } - getConfig() { return this.cfg; } + /* 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'); - this.cfg = { ...Playlist.defaults(), ...this.cfg, ...cfg }; + 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, true); - this.queue = []; + for (const uid of [...this.active.keys()]) this.despawn(uid); + this.trackState.clear(); this.tick(); this.broadcast({ type: 'active', ghosts: this.activeSnapshot() }); - return this.cfg; + return this.getConfig(); } allGhosts() { return this.state.getGhosts().ghosts; } - roster() { - const inc = this.cfg.include || {}; + /* Ghosts a track may use, minus those pinned as residents. */ + trackSet(track) { 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))); + 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; } - // ---------- 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; - } + // ---------- 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; } - // ---------- 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; + /* 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 builders (movement scale applied here) ---------- + // ---------- behavior ---------- makeBehavior(kind, g, path) { const b = this.cfg.behaviors || {}; const k = this.scale(g); @@ -133,22 +172,25 @@ class Playlist { return { type: 'static', bobAmp: (5 + Math.random() * 5) * k, bobHz: 0.3 + Math.random() * 0.3 }; } - makeRecord(g, { spawn, path }, behavior, permanent = false) { + makeRecord(g, { spawn, path }, behavior, opts = {}) { + const perm = !!opts.permanent; return { - uid: permanent ? 'res-' + g.id : 'g' + (this.uidCounter++), + 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: permanent ? null : Date.now() + (this.cfg.dwellSeconds || 120) * 1000, + until: perm ? null : Date.now() + (opts.runEach || 30) * 1000, crossfade: this.cfg.crossfadeSeconds || 3, - permanent, + permanent: perm, }; } - // ---------- residents: permanently in their place ---------- + // ---------- residents (layer 1) ---------- ensureResidents() { for (const r of this.cfg.residents || []) { const uid = 'res-' + r.id; @@ -156,41 +198,87 @@ class Playlist { 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; + 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), true); + 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 }); } - // 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(); + // ---------- 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 occ = this.occupied(); + const loc = this.resolveLocation(track); + if (!loc) return false; 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); + 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, silentOk) { + despawn(uid) { if (!this.active.has(uid)) return; this.active.delete(uid); this.broadcast({ type: 'despawn', uid }); @@ -214,20 +302,15 @@ class Playlist { tick() { const now = Date.now(); if (!this.inTimeWindow()) { - for (const uid of [...this.active.keys()]) this.despawn(uid); // residents sleep outside hours too + 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); - - 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++; + // 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() {