Playlist tracks: parallel scheduled/ambient groups with priority eviction and ApproxRuntime
This commit is contained in:
+187
-104
@@ -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 };
|
||||
/* 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
|
||||
}
|
||||
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;
|
||||
return { spawn, path };
|
||||
}
|
||||
|
||||
// ---------- behavior builders (movement scale applied here) ----------
|
||||
// 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);
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user