diff --git a/Choreograph/1.0.0/Choreograph.js b/Choreograph/1.0.0/Choreograph.js new file mode 100644 index 0000000000..99875063ab --- /dev/null +++ b/Choreograph/1.0.0/Choreograph.js @@ -0,0 +1,3426 @@ +// ============================================================================= +// Choreograph v1.0.0 +// Last Updated: 2026-08-11 +// Author: Kenan Millet +// +// Description: +// Meta-sequencer for Roll20 tokens. Define scenes in handouts — filter +// tokens, compute per-token timing, and fire commands at the right moments. +// +// Dependencies: SelectManager +// +// Commands: +// !choreograph run [flags] Execute a scene +// !choreograph new Create blank scene handout +// !choreograph list List all scenes +// !choreograph edit Open scene handout +// !choreograph delete [--force] Delete a scene +// !choreograph stop [name] Stop running scene(s) +// !choreograph refresh Regenerate handout from cache +// !choreograph fx ... Spawn FX (auto-detects point vs between) +// ============================================================================= + +/* global state, on, sendChat, getObj, createObj, findObjs, Campaign, + playerIsGM, log, _, setInterval, clearInterval, setTimeout, Date, + spawnFx, spawnFxBetweenPoints */ + +var Choreograph = Choreograph || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Choreograph'; + const SCRIPT_VERSION = '1.0.0'; + const CMD_TOKEN = '!choreograph'; + + // ========================================================================= + // State helpers + // ========================================================================= + + const s = () => state[SCRIPT_NAME]; + + // ========================================================================= + // Extension API Registries + // ========================================================================= + + const EXT_FUNCTIONS = {}; // { 'namespace/name': { name, namespace, fn, description, args, returns, pure } } + const EXT_TOKEN_VARS = {}; // { 'namespace/name': { name, namespace, fn, description } } + const EXT_CONSTANTS = {}; // { 'namespace/name': { name, namespace, value, description, type } } + const EXT_PARAM_TYPES = {}; // { 'typeName': { name, description, parse, validate } } + const EXT_LIFECYCLE = []; // [{ source, commands: [RegExp], start, stop, pause, resume }] + const EXT_SYNC = []; // [{ source, commands: [RegExp], waiting: fn }] + + // Schedule help handout regeneration after extensions register + const scheduleHandoutRegen = () => { + if (typeof ScriptKit === 'undefined') return; + if (typeof ScriptKit.updateHandout !== 'function') return; + ScriptKit.updateHandout(SCRIPT_NAME, 'usr'); + }; + + const validIdent = (s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s); + + const registerFunction = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + const { name, namespace = 'core', fn } = struct; + if (!name || !validIdent(name)) { + log(`${SCRIPT_NAME}: [${src}] registerFunction — invalid name "${name}"`); + return false; + } + if (typeof fn !== 'function') { + log(`${SCRIPT_NAME}: [${src}] registerFunction — "${name}" missing fn`); + return false; + } + const key = `${namespace}/${name}`; + if (EXT_FUNCTIONS[key]) { + const existing = EXT_FUNCTIONS[key].source || SCRIPT_NAME; + if (existing !== src) log(`${SCRIPT_NAME}: [${src}] registerFunction — "${name}" already registered by [${existing}]`); + return false; + } + EXT_FUNCTIONS[key] = Object.assign({ namespace, source: src, pure: true, description: '', args: [], returns: 'any', examples: [] }, struct); + scheduleHandoutRegen(); + return true; + }; + + const registerTokenVariable = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + const { name, namespace = 'core', fn } = struct; + if (!name || !validIdent(name)) { + log(`${SCRIPT_NAME}: [${src}] registerTokenVariable — invalid name "${name}"`); + return false; + } + if (typeof fn !== 'function') { + log(`${SCRIPT_NAME}: [${src}] registerTokenVariable — "${name}" missing fn`); + return false; + } + const key = `${namespace}/${name}`; + if (EXT_TOKEN_VARS[key]) { + const existing = EXT_TOKEN_VARS[key].source || SCRIPT_NAME; + if (existing !== src) log(`${SCRIPT_NAME}: [${src}] registerTokenVariable — "${name}" already registered by [${existing}]`); + return false; + } + EXT_TOKEN_VARS[key] = Object.assign({ namespace, source: src, description: '' }, struct); + scheduleHandoutRegen(); + return true; + }; + + const registerParameterType = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + const { name, parse } = struct; + if (!name) { + log(`${SCRIPT_NAME}: [${src}] registerParameterType — missing name`); + return false; + } + if (typeof parse !== 'function') { + log(`${SCRIPT_NAME}: [${src}] registerParameterType — "${name}" missing parse`); + return false; + } + if (EXT_PARAM_TYPES[name]) { + const existing = EXT_PARAM_TYPES[name].source || SCRIPT_NAME; + if (existing !== src) log(`${SCRIPT_NAME}: [${src}] registerParameterType — "${name}" already registered by [${existing}]`); + return false; + } + EXT_PARAM_TYPES[name] = Object.assign({ source: src, description: '', validate: null }, struct); + scheduleHandoutRegen(); + return true; + }; + + const registerConstant = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + const { name, namespace = 'core', value } = struct; + if (!name || !validIdent(name)) { + log(`${SCRIPT_NAME}: [${src}] registerConstant — invalid name "${name}"`); + return false; + } + if (value === undefined) { + log(`${SCRIPT_NAME}: [${src}] registerConstant — "${name}" missing value`); + return false; + } + const key = `${namespace}/${name}`; + if (EXT_CONSTANTS[key]) { + const existing = EXT_CONSTANTS[key].source || SCRIPT_NAME; + if (existing !== src) log(`${SCRIPT_NAME}: [${src}] registerConstant — "${name}" already registered by [${existing}]`); + return false; + } + EXT_CONSTANTS[key] = Object.assign({ namespace, source: src, description: '', type: typeof value }, struct); + scheduleHandoutRegen(); + return true; + }; + + const registerLifecycleHook = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + if (!struct.commands || !Array.isArray(struct.commands)) { + log(`${SCRIPT_NAME}: [${src}] registerLifecycleHook — missing commands array`); + return false; + } + // Prevent duplicate registration from same source + if (EXT_LIFECYCLE.some(h => h.source === src)) return false; + EXT_LIFECYCLE.push(Object.assign({ source: src, start: null, stop: null, pause: null, resume: null }, struct)); + return true; + }; + + const buildHookContext = (instance, entry) => ({ + type: 'api', + content: entry.command, + who: instance.who || 'gm', + playerid: instance.playerid || 'API', + selected: (entry.tokens || []).map(t => ({ _id: t.get('id'), _type: 'graphic' })), + sceneInfo: { + instanceId: instance.id, + sceneName: instance.name, + instanceName: instance.instanceName, + }, + }); + + const fireLifecycleHooks = (event, instance) => { + const firedCommands = instance.firedCommands || []; + EXT_LIFECYCLE.forEach(hook => { + const fn = hook[event]; + if (typeof fn !== 'function') return; + firedCommands.forEach(entry => { + const matches = hook.commands.some(rx => rx.test(entry.command)); + if (!matches) return; + fn(buildHookContext(instance, entry)); + }); + }); + }; + + const registerSyncParticipant = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + if (typeof struct.waiting !== 'function') { + log(`${SCRIPT_NAME}: [${src}] registerSyncParticipant — missing waiting function`); + return false; + } + if (!struct.commands || !Array.isArray(struct.commands)) { + log(`${SCRIPT_NAME}: [${src}] registerSyncParticipant — missing commands array`); + return false; + } + // Prevent duplicate registration from same source + if (EXT_SYNC.some(p => p.source === src)) return false; + EXT_SYNC.push(Object.assign({ source: src }, struct)); + return true; + }; + + /** + * Fire sync — calls all registered sync participants and invokes onResolved + * when all have called done() or timeout expires. + */ + const fireSync = (instance, onResolved, timeoutMs) => { + const allEntries = (instance.firedCommands || []).map(entry => buildHookContext(instance, entry)); + const sceneInfo = { + instanceId: instance.id, + sceneName: instance.name, + instanceName: instance.instanceName, + }; + + // Build filtered context per participant; skip those with no matching entries + const participants = []; + EXT_SYNC.forEach(p => { + const filtered = allEntries.filter(e => p.commands.some(rx => rx.test(e.content))); + if (filtered.length > 0) participants.push({ participant: p, entries: filtered }); + }); + + if (participants.length === 0) { onResolved(); return; } + + let remaining = participants.length; + let resolved = false; + + const checkDone = () => { + if (resolved) return; + remaining--; + if (remaining <= 0) { + resolved = true; + onResolved(); + } + }; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + log(`${SCRIPT_NAME}: sync timeout (${timeoutMs}ms) — proceeding`); + onResolved(); + } + }, timeoutMs || 30000); + + participants.forEach(({ participant, entries }) => { + let called = false; + participant.waiting({ + entries, + sceneInfo, + done: () => { + if (called) return; + called = true; + checkDone(); + if (resolved) clearTimeout(timeout); + }, + }); + }); + }; + + /** + * Register an example scene that can be generated via !choreograph example . + * @param {string} sourceId - registering script name + * @param {object} struct - { name, description, scene } + * scene: { notes, params, variables, rows } (same shape as parseScene output) + */ + + + + const generateExtensionHandout = (sourceId, opts = {}) => { + const src = sourceId || SCRIPT_NAME; + const { name = src, description = '', sections = [] } = opts; + const handoutName = `Help: ${SCRIPT_NAME}/${name}`; + let hh = findObjs({ type: 'handout', name: handoutName })[0]; + if (!hh) { + hh = createObj('handout', { + name: handoutName, + archived: false, + }); + } + + let html = `

${name}

`; + if (description) html += `

${description}

`; + + const fmtFn = (r) => { + const argList = (r.args || []).map(a => a.name).join(', '); + const ns = r.namespace === 'core' ? '' : `${r.namespace}.`; + return `

${ns}${r.name}(${argList})${r.returns || 'any'}
${r.description || ''}

`; + }; + + sections.forEach(section => { + const ns = section.namespace; + html += `

${ns}

`; + if (section.description) html += `

${section.description}

`; + + const fns = Object.values(EXT_FUNCTIONS).filter(r => r.namespace === ns); + const vars = Object.values(EXT_TOKEN_VARS).filter(r => r.namespace === ns); + const consts = Object.values(EXT_CONSTANTS).filter(r => r.namespace === ns); + + if (fns.length) { + html += `

Functions

`; + fns.forEach(r => { html += fmtFn(r); }); + } + if (vars.length) { + html += `

Token Variables

`; + vars.forEach(r => { html += `

${r.name} — ${r.description || ''}

`; }); + } + if (consts.length) { + html += `

Constants

`; + consts.forEach(r => { html += `

${r.name} = ${r.value} — ${r.description || ''}

`; }); + } + }); + + hh.set('notes', html); + log(`${SCRIPT_NAME}: generated help handout "${handoutName}"`); + }; + + // ========================================================================= + // Chat helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text, noarchive = false) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(`${SCRIPT_NAME}${prefix}`, `/w "${recipient}" ${body}`, + null, noarchive ? { noarchive: true } : undefined); + }; + + const replyError = (msg, text) => reply(msg, 'Error', text); + + // CSV-style array parser: splits on commas, respects double-quoted segments + const parseCSV = (str) => { + if (!str) return []; + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + if (ch === '"' && (i === 0 || str[i - 1] !== '\\')) { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + result.push(current.trim()); + return result.filter(s => s.length > 0); + }; + + const escHtml = (str) => String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const cellHtml = (str) => escHtml(str) || '
'; + + // ========================================================================= + // Handout helpers + // ========================================================================= + + const HandoutRegex = /^\[([^\]]+)\] (.+)$/; + + class HandoutCache { + constructor(tag, parser) { + this.tag = tag; + this.parser = parser; + this.cache = {}; + } + + static handoutTag = (tag) => `[${tag}]`; + static handoutNametag = (tag, name) => `${HandoutCache.handoutTag(tag)} ${name}`; + static getHandoutTagAndName = (nametag) => { + const match = nametag.match(HandoutRegex); + return match ? [match[1], match[2]] : [null, null]; + }; + + handoutName = (nametag) => { + const handoutTag = HandoutCache.handoutTag(this.tag); + if (!nametag || !nametag.startsWith(handoutTag)) return null; + return nametag.slice(handoutTag.length).trim(); + }; + + find = (name) => { + const results = findObjs({ _type: 'handout', name: `${HandoutCache.handoutNametag(this.tag, name)}` }); + return results.length > 0 ? results[0] : undefined; + }; + + findAll = () => findObjs({ _type: 'handout' }).filter(h => h.get('name').startsWith(HandoutCache.handoutTag(this.tag))); + + getOrCreate = (name) => { + const existing = this.find(name); + if (existing) return existing; + return createObj('handout', { + name: HandoutCache.handoutNametag(this.tag, name), + archived: false, + }); + }; + + load = (name, callback) => { + if (this.cache[name]) { callback(this.cache[name]); return; } + const handout = this.find(name); + if (!handout) { callback(null); return; } + getHandoutNotes(handout, (html) => { + if (!html) { callback(null); return; } + const result = this.parser(name, html); + this.cache[name] = result; + callback(result); + }); + }; + } + + const handoutCache = {}; + + const addHandoutCache = (tag, parser) => { + handoutCache[tag] = new HandoutCache(tag, parser); + }; + + const getHandoutNotes = (handout, callback) => { + handout.get('notes', (notes) => callback(notes || '')); + }; + + const setHandoutNotes = (handout, html) => { + handout.set('notes', html); + }; + + // ========================================================================= + // Scene System + // ========================================================================= + + const STYLE = { + btn: 'display:inline-block;margin:2px;padding:2px 8px;background:#444;color:#fff;' + + 'border-radius:3px;text-decoration:none;font-size:11px;', + th: 'background:#222;color:#fff;padding:3px 6px;border:1px solid #555;white-space:nowrap;', + td: 'padding:2px 5px;border:1px solid #ccc;', + }; + + const btnHtml = (label, cmd) => { + const href = cmd.startsWith('!') ? cmd : `!${cmd}`; + return `${escHtml(label)}`; + }; + + const generateSceneHtml = (name, scene) => { + let html = ''; + + // Metadata + html += `
`; + html += `Notes: ${escHtml(scene.notes || '')}
`; + html += `
`; + + // Action buttons + html += `
`; + html += btnHtml('▶ Run', `${CMD_TOKEN} run ${name}`); + html += btnHtml('+ Row', `${CMD_TOKEN} add-row ${name}`); + html += btnHtml('Refresh', `${CMD_TOKEN} refresh ${name}`); + html += btnHtml('🔍 Dump', `${CMD_TOKEN} dump-html ${name}`); + html += btnHtml('⚠ Delete', `${CMD_TOKEN} delete ${name}`); + html += `
`; + + // Parameter table + html += `

Parameters

`; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + (scene.params || []).forEach(p => { + html += ``; + html += ``; + html += ``; + html += ``; + }); + html += `
NameTypeDefaultDescription
${cellHtml(p.name)}${cellHtml(p.type)}${cellHtml(p.default || '')}${cellHtml(p.description)}
`; + + // Variables table + html += `

Variables

`; + html += ``; + html += ``; + html += ``; + (scene.variables || []).forEach(v => { + html += ``; + html += ``; + }); + html += `
VariableExpression
${cellHtml(v.name)}${cellHtml(v.expression)}
`; + + // Roles table + if (scene.roles && scene.roles.length > 0) { + html += ``; + html += ``; + html += ``; + html += ``; + scene.roles.forEach(r => { + html += ``; + html += ``; + html += ``; + }); + html += `
RoleMinMax
${cellHtml(r.name)}${r.min != null ? r.min : '
'}
${r.max != null ? r.max : '
'}
`; + } + + // Scene table + html += `

Scene

`; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + (scene.rows || []).forEach(row => { + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + }); + html += `
FilterDelay (ms)WhenCommandNotes
${cellHtml(row.filter)}${cellHtml(row.delay)}${cellHtml(row.when || '')}${cellHtml((row.commands || [row.command]).join('\n'))}${cellHtml(row.notes)}
`; + + return html; + }; + + const generateBlankScene = (name) => { + const scene = { + name, + notes: '', + params: [ + { name: 'cast', type: 'token[]', default: 'selected', description: 'Tokens to run the scene on (built-in)' }, + ], + variables: [ + { name: '', expression: '' }, + ], + rows: [ + { filter: '*', delay: '0', commands: [], notes: 'Example row — add your command here' }, + ], + }; + return generateSceneHtml(name, scene); + }; + + // ========================================================================= + // Scene Handout parser + // ========================================================================= + + /** + * Parse a scene handout's HTML into a scene object. + * Returns { name, notes, params, rows } or null on failure. + * + * params: [{ name, type, default, description }] + * rows: [{ filter, delay, command, notes }] + */ + const parseScene = (name, html) => { + const decode = (s) => String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); + + const body = decode(html) + .replace(/<\/?p[^>]*>/gi, '\n') + .replace(/]*>/gi, '\n') + .replace(/\r\n/g, '\n'); + + const stripTags = (s) => String(s).replace(/<[^>]+>/g, '').trim(); + + // Parse metadata + const scene = { name, notes: '', params: [], rows: [], variables: [] }; + + const metaVal = (label) => { + const re = new RegExp(label + '[^<]*(?:<[^>]+>)?\\s*([^<\\n]+)', 'i'); + const m = body.match(re); + return m ? stripTags(m[1]).trim() : null; + }; + const notesVal = metaVal('Notes'); + if (notesVal) scene.notes = notesVal; + + // Parse tables + const tableRe = /]*>([\s\S]*?)<\/table>/gi; + const tables = []; + let tableMatch; + while ((tableMatch = tableRe.exec(body)) !== null) { + tables.push(tableMatch[1]); + } + + // Identify tables by headers + tables.forEach(tableHtml => { + const headerMatch = tableHtml.match(/]*>([\s\S]*?)<\/tr>/i); + if (!headerMatch) return; + const headerHtml = headerMatch[1]; + const headers = []; + const thRe = /]*>([\s\S]*?)<\/th>/gi; + let thMatch; + while ((thMatch = thRe.exec(headerHtml)) !== null) { + headers.push(stripTags(thMatch[1]).toLowerCase()); + } + + const isParamTable = headers.includes('name') && headers.includes('type'); + const isSceneTable = headers.includes('filter') && headers.some(h => h.startsWith('delay')); + const isVarTable = headers.includes('variable') && headers.includes('expression'); + const isRoleTable = headers.includes('role') && headers.includes('min'); + + // Parse rows + const rowRe = /]*>([\s\S]*?)<\/tr>/gi; + rowRe.exec(tableHtml); // skip header row + let rowMatch; + while ((rowMatch = rowRe.exec(tableHtml)) !== null) { + const cells = []; + const rawCells = []; + const tdRe = /]*>([\s\S]*?)<\/td>/gi; + let tdMatch; + while ((tdMatch = tdRe.exec(rowMatch[1])) !== null) { + cells.push(stripTags(tdMatch[1])); + rawCells.push(tdMatch[1]); + } + + if (isParamTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; + scene.params.push({ + name: cells[0] || '', + type: cells[1] || 'text', + default: cells[2] || null, + description: cells[3] || '', + }); + } else if (isVarTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; + scene.variables.push({ + name: cells[0] || '', + expression: cells[1] || '', + }); + } else if (isSceneTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; + // Detect column layout by headers + const whenIdx = headers.indexOf('when'); + const cmdIdx = whenIdx >= 0 ? whenIdx + 1 : 2; + const notesIdx = cmdIdx + 1; + // Parse command cell: split on

boundaries for multi-command cells + const rawCmd = rawCells[cmdIdx] || ''; + const commands = rawCmd + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(/<\/?p[^>]*>/gi, '') + .replace(/]*>/gi, '\n') + .replace(/<[^>]+>/g, '') + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + const row = { + filter: cells[0] || '', + delay: cells[1] || '0', + commands: commands, + notes: cells[notesIdx] || '', + }; + if (whenIdx >= 0 && cells[whenIdx]) row.when = cells[whenIdx]; + scene.rows.push(row); + } else if (isRoleTable && cells.length >= 1) { + if (cells.every(c => !c)) continue; + const role = { name: cells[0] || '' }; + if (cells[1]) role.min = parseInt(cells[1], 10) || undefined; + if (cells[2]) role.max = parseInt(cells[2], 10) || undefined; + if (!scene.roles) scene.roles = []; + scene.roles.push(role); + } + } + }); + + // Ensure cast param exists + if (!scene.params.find(p => p.name === 'cast')) { + scene.params.unshift({ + name: 'cast', type: 'token[]', default: 'selected', + description: 'Tokens to run the scene on (built-in)', + }); + } + + return scene; + }; + + const sceneHandoutTag = 'Scene'; + const scenes = () => handoutCache[sceneHandoutTag]; + + // ========================================================================= + // Cast System + // ========================================================================= + + const castHandoutTag = 'Cast'; + const casts = () => handoutCache[castHandoutTag]; + + /** + * Parse a cast handout into { roles: { roleName: [tokenId, ...] } } + * Format: + * role1: -id1, -id2, -id3 + * role2: -id4 + * -id5, -id6 (no role — stored under '') + */ + const parseCast = (name, html) => { + const decode = (s) => String(s) + .replace(/&/g, '&').replace(/</g, '<') + .replace(/>/g, '>').replace(/"/g, '"') + .replace(/ /g, ' '); + + const text = decode(html) + .replace(/<\/?p[^>]*>/gi, '\n') + .replace(/]*>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/\r\n/g, '\n'); + + const roles = {}; + text.split('\n').forEach(line => { + line = line.trim(); + if (!line) return; + const colonIdx = line.indexOf(':'); + let role = ''; + let idsStr = line; + if (colonIdx !== -1) { + const beforeColon = line.slice(0, colonIdx).trim(); + // Only treat as role if the part before colon doesn't look like an ID + if (!/^-[A-Za-z0-9_-]+$/.test(beforeColon)) { + role = beforeColon; + idsStr = line.slice(colonIdx + 1); + } + } + const ids = idsStr.split(',') + .map(s => s.trim()) + .filter(s => /^-[A-Za-z0-9_-]+$/.test(s)); + if (ids.length === 0) return; + if (!roles[role]) roles[role] = []; + roles[role].push(...ids); + }); + return { roles }; + }; + + /** + * Generate cast handout HTML from a roles object. + */ + const generateCastHtml = (name, roles) => { + let html = `

`; + Object.entries(roles).forEach(([role, ids]) => { + if (role) { + html += `${escHtml(role)}: ${ids.join(', ')}
`; + } else { + html += `${ids.join(', ')}
`; + } + }); + html += `
`; + return html; + }; + + /** + * Get all token IDs from a cast (all roles combined). + */ + const getAllCastIds = (cast) => { + const ids = []; + Object.values(cast.roles).forEach(roleIds => ids.push(...roleIds)); + return [...new Set(ids)]; + }; + + /** + * Get token IDs for a specific role. + */ + const getCastRoleIds = (cast, role) => cast.roles[role] || []; + + // Register handout caches (after parsers are defined) + addHandoutCache(sceneHandoutTag, parseScene); + addHandoutCache(castHandoutTag, parseCast); + + // ========================================================================= + // Running scenes + // ========================================================================= + + // { instanceId: { id, name, queue, timers, cast, params, state, startTime, firedCommands, remaining } } + const runningScenes = {}; + + // ---- Scene signals ---- + const sceneStartListeners = []; + const sceneFinishListeners = []; + + const emitSceneStart = (instance) => { + const info = { name: instance.name, instanceId: instance.id, cast: instance.cast, params: instance.params }; + sceneStartListeners.forEach(fn => { try { fn(info); } catch (e) { log(`${SCRIPT_NAME}: onSceneStart listener error: ${e}`); } }); + }; + const emitSceneFinish = (instance) => { + const info = { name: instance.name, instanceId: instance.id, cast: instance.cast, params: instance.params }; + sceneFinishListeners.forEach(fn => { try { fn(info); } catch (e) { log(`${SCRIPT_NAME}: onSceneFinish listener error: ${e}`); } }); + }; + + const onSceneStart = (fn) => { + sceneStartListeners.push(fn); + return () => { const i = sceneStartListeners.indexOf(fn); if (i >= 0) sceneStartListeners.splice(i, 1); }; + }; + const onSceneFinish = (fn) => { + sceneFinishListeners.push(fn); + return () => { const i = sceneFinishListeners.indexOf(fn); if (i >= 0) sceneFinishListeners.splice(i, 1); }; + }; + + const waitForScene = (sceneName) => ({ + onEnter: (ctx, advance) => { + ctx._waitFired = false; + const unsubStart = onSceneStart((info) => { + if (ctx._waitFired || info.name !== sceneName) return; + const unsubFinish = onSceneFinish((finishInfo) => { + if (ctx._waitFired || finishInfo.instanceId !== info.instanceId) return; + ctx._waitFired = true; + unsubStart(); + unsubFinish(); + setTimeout(() => advance(), 750); + }); + }); + }, + onExit: (ctx) => { ctx._waitFired = true; }, + }); + + let instanceCounter = 0; + const genInstanceId = () => `${SCRIPT_NAME}-${++instanceCounter}-${Date.now()}`; + + // Human-readable instance names + const adjectives = ['swift','bold','red','blue','dark','bright','wild','calm','iron','silver']; + const nouns = ['wolf','hawk','storm','flame','wave','frost','shadow','tide','spark','wind']; + const genInstanceName = () => { + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + return `${adj}-${noun}-${instanceCounter}`; + }; + + const stopScene = (instanceId) => { + const instance = runningScenes[instanceId]; + if (!instance) return; + (instance.timers || []).forEach(t => clearTimeout(t)); + fireLifecycleHooks('stop', instance); + delete runningScenes[instanceId]; + }; + + const pauseScene = (instanceId) => { + const instance = runningScenes[instanceId]; + if (!instance || instance.state === 'paused') return; + // Clear pending timers and save remaining queue entries with adjusted times + (instance.timers || []).forEach(t => clearTimeout(t)); + instance.timers = []; + const elapsed = Date.now() - instance.startTime; + instance.remaining = (instance.remaining || instance.queue) + .filter(entry => entry.time > elapsed) + .map(entry => Object.assign({}, entry, { time: entry.time - elapsed })); + instance.pausedAt = Date.now(); + instance.state = 'paused'; + fireLifecycleHooks('pause', instance); + }; + + const resumeScene = (instanceId, msg) => { + const instance = runningScenes[instanceId]; + if (!instance || instance.state !== 'paused') return; + instance.state = 'running'; + instance.startTime = Date.now(); + const sender = getPlayerName(msg && msg.playerid); + // Re-schedule remaining entries + let i = 0; + const queue = instance.remaining || []; + while (i < queue.length) { + const batchTime = queue[i].time; + const batch = []; + while (i < queue.length && queue[i].time === batchTime) { + batch.push(queue[i]); + i++; + } + const timer = setTimeout(() => { + const byCommand = {}; + batch.forEach(entry => { + if (!byCommand[entry.command]) byCommand[entry.command] = []; + byCommand[entry.command].push(entry.tokenId); + }); + dispatchCommands(byCommand, instance, sender); + }, batchTime); + instance.timers.push(timer); + } + instance.remaining = null; + fireLifecycleHooks('resume', instance); + }; + + const stopAll = () => { + Object.keys(runningScenes).forEach(stopScene); + }; + + // ========================================================================= + // Filter evaluation + // ========================================================================= + + /** + * Evaluate a single filter condition against a token. + * Returns true if token matches. + */ + const evalFilterCondition = (condition, token, castData, scope) => { + const c = condition.trim(); + if (!c || c === '*') return true; + + // Negation + if (c.startsWith('!')) { + return !evalFilterCondition(c.slice(1), token, castData, scope); + } + + // key=value patterns + const eqIdx = c.indexOf('='); + if (eqIdx !== -1) { + const key = c.slice(0, eqIdx).toLowerCase(); + const raw = c.slice(eqIdx + 1); + const val = (raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")) + ? raw.slice(1, -1) : raw; + + if (key === 'layer') return token.get('layer') === val; + if (key === 'id') return token.get('id') === val; + if (key === 'status' || key === 'statusmarkers') { + const markers = (token.get('statusmarkers') || '').split(','); + return markers.includes(val); + } + if (key === 'name') { + const name = token.get('name') || ''; + if (val.includes('*')) { + const re = new RegExp('^' + val.replace(/\*/g, '.*') + '$', 'i'); + return re.test(name); + } + return name === val; + } + if (key === 'role') { + if (!castData) return false; + const roleIds = castData.roles[val] || []; + return roleIds.includes(token.get('id')); + } + } + + // Expression fallback — evaluate as boolean if scope is available + if (scope) { + const result = evalDelay(c, scope); + return !!result && isFinite(result); + } + + return false; + }; + + /** + * Evaluate a full filter string (space-separated AND conditions). + */ + const evalFilter = (filterStr, token, castData, scope) => { + const trimmed = filterStr.trim(); + if (!trimmed) return false; // empty = no match + if (trimmed === '*') return true; + + // If the filter contains comparison/logical operators, treat as a single expression + if (/[<>!&|]/.test(trimmed) && !/^!?[a-z]+=/.test(trimmed)) { + // Expression filter — evaluate as boolean + if (scope) { + const decls = Object.keys(scope).map(k => + `var ${k} = __scope["${k}"];` + ).join(' '); + try { + const __scope = scope; + return !!eval(decls + '(' + trimmed + ')'); + } catch(e) { + log(`${SCRIPT_NAME}: filter expression error: ${e.message} (expr: "${trimmed}")`); + return false; + } + } + return false; + } + + // Simple filters: space-separated AND conditions + const conditions = trimmed.split(/\s+/); + return conditions.every(c => evalFilterCondition(c, token, castData, scope)); + }; + + // ========================================================================= + // TokenProxy — rich wrapper for tokens in expression scope + // ========================================================================= + + // Registry of token variable definitions (used by TokenProxy to build getters) + // Each entry: { name, namespace, fn, evaluation: 'eager'|'lazy'|'computed' } + const TOKEN_VAR_DEFS = []; + + /** + * Register a token variable definition for use by TokenProxy. + * Called during checkInstall (for core vars) and by extensions (via registerTokenVariable). + */ + const addTokenVarDef = (reg) => { + TOKEN_VAR_DEFS.push(reg); + }; + + /** + * NamespaceProxy — lazy sub-proxy for a specific namespace on a token. + * Created once per namespace per TokenProxy instance. + */ + class NamespaceProxy { + constructor(rawToken, namespace, ctx) { + this._token = rawToken; + this._namespace = namespace; + this._ctx = ctx; + this._cache = {}; + + // Attach getters for all token vars in this namespace + TOKEN_VAR_DEFS + .filter(d => d.namespace === namespace) + .forEach(d => { + Object.defineProperty(this, d.name, { + get: () => { + const eval_ = d.evaluation || 'lazy'; + if (eval_ === 'computed') return d.fn(this._token, this._ctx); + if (eval_ === 'lazy' || eval_ === 'eager') { + if (!(d.name in this._cache)) this._cache[d.name] = d.fn(this._token, this._ctx); + return this._cache[d.name]; + } + return d.fn(this._token, this._ctx); + }, + enumerable: true, + }); + }); + } + } + + /** + * TokenProxy — wraps a Roll20 graphic object with namespaced getters. + * Core properties (left, top, name, etc.) are direct getters. + * Extension namespaces are lazy NamespaceProxy instances. + */ + class TokenProxy { + constructor(rawToken, ctx) { + this._token = rawToken; + this._ctx = ctx || {}; + this._nsCache = {}; + + // Attach core namespace getters directly + TOKEN_VAR_DEFS + .filter(d => d.namespace === 'core') + .forEach(d => { + Object.defineProperty(this, d.name, { + get: () => d.fn(this._token, this._ctx), + enumerable: true, + }); + }); + + // Attach namespace sub-proxies as lazy getters + const namespaces = [...new Set(TOKEN_VAR_DEFS.map(d => d.namespace).filter(ns => ns !== 'core'))]; + namespaces.forEach(ns => { + Object.defineProperty(this, ns, { + get: () => { + if (!this._nsCache[ns]) this._nsCache[ns] = new NamespaceProxy(this._token, ns, this._ctx); + return this._nsCache[ns]; + }, + enumerable: true, + }); + }); + } + + // Allow access to the raw Roll20 object for interop + get _id() { return this._token.get('id'); } + get(prop) { return this._token.get(prop); } + toString() { return this._token.get('name') || this._token.get('id'); } + } + + /** + * Wrap a Roll20 graphic object (or array of them) in TokenProxy. + */ + const wrapToken = (rawToken, ctx) => rawToken ? new TokenProxy(rawToken, ctx) : null; + const wrapTokens = (arr, ctx) => arr.map(t => wrapToken(t, ctx)); + + // LINQ-inspired enriched array — returned by cast(), role(), and token[] params + const itemId = (t) => { + if (typeof t === 'string' || typeof t === 'number') return t; + if (t && t._id) return t._id; + if (t && typeof t.get === 'function') return t.get('id'); + return t; + }; + + const enrichArray = (arr) => { + arr.from = (other) => { + const ids = new Set((other || []).map(itemId)); + return enrichArray(arr.filter(t => ids.has(itemId(t)))); + }; + arr.without = (other) => { + const ids = new Set((other || []).map(itemId)); + return enrichArray(arr.filter(t => !ids.has(itemId(t)))); + }; + arr.where = (fn) => enrichArray(arr.filter(fn)); + arr.select = (fn) => enrichArray(arr.map(fn)); + arr.orderBy = (attr) => { + if (typeof attr === 'function') return enrichArray([...arr].sort((a, b) => attr(a) - attr(b))); + return enrichArray([...arr].sort((a, b) => { + const av = a && typeof a === 'object' ? (a[attr] !== undefined ? a[attr] : (a.get ? a.get(attr) : 0)) : a; + const bv = b && typeof b === 'object' ? (b[attr] !== undefined ? b[attr] : (b.get ? b.get(attr) : 0)) : b; + return (av || 0) - (bv || 0); + })); + }; + arr.first = (n) => n === undefined ? arr[0] : enrichArray(arr.slice(0, n)); + arr.last = (n) => n === undefined ? arr[arr.length - 1] : enrichArray(arr.slice(-n)); + arr.any = (fn) => fn ? arr.some(fn) : arr.length > 0; + arr.count = (fn) => fn ? arr.filter(fn).length : arr.length; + arr.ids = () => enrichArray(arr.map(itemId)); + return arr; + }; + + // ========================================================================= + // Delay expression evaluation + // ========================================================================= + + /** + * Build the expression scope for a token in context. + */ + const buildTokenScope = (token, filteredTokens, params) => { + const scope = { + // Flat backward-compat aliases (also accessible via token.X proxy) + left: token.get('left'), + top: token.get('top'), + name: token.get('name') || '', + layer: token.get('layer'), + width: token.get('width'), + height: token.get('height'), + count: filteredTokens.length, + }; + + // actors(filter?) — returns tokens sorted by distance from current token + // actor_ids(filter?) — returns token ID strings + // LINQ-inspired enriched array — uses module-level enrichArray/itemId + + const ctx = { tokens: filteredTokens, params }; + + // Insert a value into scope at the given namespace path + const insertIntoScope = (ns, name, val) => { + if (ns === 'core') { scope[name] = val; return; } + const parts = ns.split('.'); + let node = scope; + parts.forEach(p => { if (!node[p] || typeof node[p] !== 'object') node[p] = {}; node = node[p]; }); + node[name] = val; + }; + + // Auto-wrap return values based on declared returns type + const autoWrap = (val, returns) => { + if (returns === 'token' && val && !( val instanceof TokenProxy)) return wrapToken(val, ctx); + if (returns === 'token[]' && Array.isArray(val)) return enrichArray(val.filter(Boolean).map(t => t instanceof TokenProxy ? t : wrapToken(t, ctx))); + return val; + }; + + // Inject registered extension functions + Object.values(EXT_FUNCTIONS).forEach(reg => { + insertIntoScope(reg.namespace, reg.name, (...args) => autoWrap(reg.fn(token, filteredTokens, params, ...args), reg.returns)); + }); + + // Inject registered token variables + Object.values(EXT_TOKEN_VARS).forEach(reg => { + const val = reg.fn(token, { tokens: filteredTokens, params }); + insertIntoScope(reg.namespace, reg.name, autoWrap(val, reg.returns)); + }); + + // Inject registered constants + Object.values(EXT_CONSTANTS).forEach(reg => { + insertIntoScope(reg.namespace, reg.name, reg.value); + }); + + return scope; + }; + + /** + * Evaluate a delay expression string in the given scope. + * Returns a number (ms) or Infinity. + */ + const evalDelay = (expr, scope) => { + if (!expr || !expr.trim()) return 0; + const trimmed = expr.trim(); + // Quick numeric check + const num = parseFloat(trimmed); + if (!isNaN(num) && /^[\d.]+$/.test(trimmed)) return num; + + // Build scope declarations for eval + const decls = Object.keys(scope).map(k => + `var ${k} = __scope["${k}"];` + ).join(' '); + + try { + const __scope = scope; + const result = eval(decls + '(' + trimmed + ')'); + if (typeof result !== 'number' || isNaN(result)) return Infinity; + return result; + } catch(e) { + log(`${SCRIPT_NAME}: delay expression error: ${e.message} (expr: "${trimmed}")`); + return Infinity; + } + }; + + // General-purpose expression eval — preserves any return type + const evalExpr = (expr, scope) => { + if (!expr || !expr.trim()) return undefined; + const trimmed = expr.trim(); + const decls = Object.keys(scope).map(k => + `var ${k} = __scope["${k}"];` + ).join(' '); + try { + const __scope = scope; + return eval(decls + '(' + trimmed + ')'); + } catch(e) { + log(`${SCRIPT_NAME}: expression error: ${e.message} (expr: "${trimmed}")`); + return undefined; + } + }; + + // ========================================================================= + // Command template evaluation + // ========================================================================= + + /** + * Evaluate a command template string with ${} substitutions. + */ + const evalCommand = (template, scope) => { + if (!template || !template.trim()) return ''; + + const decls = Object.keys(scope).map(k => + `var ${k} = __scope["${k}"];` + ).join(' '); + + try { + const __scope = scope; + return eval(decls + '`' + template + '`'); + } catch(e) { + log(`${SCRIPT_NAME}: command template error: ${e.message} (template: "${template}")`); + return ''; + } + }; + + // ========================================================================= + // Command dispatch helper + // ========================================================================= + + /** + * Dispatch a batch of commands grouped by command string. + * Handles start hooks, {& select} injection, depth enforcement. + */ + const dispatchCommands = (byCommand, instance, sender) => { + const instanceId = instance.id; + Object.entries(byCommand).forEach(([command, tokenIds]) => { + let finalCmd = command; + // Auto-inject --parent and --depth for chained choreograph runs + if (finalCmd.startsWith('!choreograph run ') || finalCmd.startsWith(`${CMD_TOKEN} run `)) { + if (instance.depth <= 0) return; + finalCmd += ` --parent ${instanceId} --depth ${instance.depth - 1}`; + } + + const tokens = tokenIds.map(id => getObj('graphic', id)).filter(Boolean); + const ctx = buildHookContext(instance, { command: finalCmd, tokens }); + + // Check if any lifecycle hook wants to handle this via start + let handled = false; + EXT_LIFECYCLE.forEach(hook => { + if (!hook.start) return; + const matches = hook.commands.some(rx => rx.test(finalCmd)); + if (matches) { + hook.start(ctx); + handled = true; + } + }); + + // Fall back to sendChat if no start hook handled it + if (!handled) { + if (finalCmd.startsWith('!')) { + const selectSuffix = ` {& select ${tokenIds.join(', ')}}`; + sendChat(sender, finalCmd + selectSuffix); + } else { + sendChat(sender, finalCmd); + } + } + + instance.firedCommands.push({ tokens, command: finalCmd }); + }); + }; + + // ========================================================================= + // Scene execution + // ========================================================================= + + /** + * Execute a scene: gather cast, evaluate rows, build queue, fire commands. + */ + const executeScene = (scene, cast, params, msg, castData, loopOpts, runtimeOpts) => { + const instanceId = genInstanceId(); + const queue = []; + + // Resolve params — merge defaults with provided values + const resolvedParams = {}; + scene.params.forEach(p => { + if (p.name === 'cast') return; // handled separately + let val = params[p.name] !== undefined ? params[p.name] : (p.default || null); + // Resolve token-type parameters to TokenProxy + if (p.type === 'token' && val && typeof val === 'string') { + const obj = getObj('graphic', val); + if (obj) val = wrapToken(obj, { tokens: cast, params: resolvedParams }); + } else if (p.type === 'token[]' && val && typeof val === 'string') { + val = enrichArray(parseCSV(val) + .map(id => getObj('graphic', id.trim())) + .filter(Boolean) + .map(obj => wrapToken(obj, { tokens: cast, params: resolvedParams }))); + } else if (p.type === 'path' && val && typeof val === 'string') { + val = getObj('path', val) || val; + } else if (p.type === 'path[]' && val && typeof val === 'string') { + val = enrichArray(parseCSV(val) + .map(id => getObj('path', id.trim())) + .filter(Boolean)); + } + resolvedParams[p.name] = val; + }); + + // Attach execution context for registered functions that need full cast access + resolvedParams.__ctx = { allTokens: cast, castData }; + + // Validate required params (no default = required) + const missingParams = scene.params + .filter(p => p.name !== 'cast' && !p.default && resolvedParams[p.name] == null) + .map(p => p.name); + if (missingParams.length > 0) { + const errMsg = `Missing required parameter(s): ${missingParams.join(', ')}`; + if (msg) replyError(msg, errMsg); + else log(`${SCRIPT_NAME}: ${errMsg}`); + return null; + } + + // Validate role constraints (min/max) + if (scene.roles && scene.roles.length > 0 && castData && castData.roles) { + for (const roleDef of scene.roles) { + const assigned = (castData.roles[roleDef.name] || []).length; + if (roleDef.min && assigned < roleDef.min) { + const errMsg = `Role "${roleDef.name}" requires at least ${roleDef.min} token(s) (got ${assigned}).`; + if (msg) replyError(msg, errMsg); + else log(`${SCRIPT_NAME}: ${errMsg}`); + return null; + } + } + } + + // Precompute variables per token + const tokenVars = {}; + if (scene.variables && scene.variables.length > 0) { + cast.forEach(token => { + const scope = buildTokenScope(token, cast, resolvedParams); + Object.assign(scope, resolvedParams); + scope.token = wrapToken(token, { tokens: cast, params: resolvedParams }); + const vars = {}; + scene.variables.forEach(v => { + if (!v.name || !v.expression) return; + scope[v.name] = evalExpr(v.expression, scope); + vars[v.name] = scope[v.name]; + }); + tokenVars[token.get('id')] = vars; + }); + } + + // For each row, evaluate filter on all cast, then compute delays + scene.rows.forEach((row, rowIndex) => { + // Check for sync delay — creates a chunk boundary + if (row.delay.trim().toLowerCase() === 'sync') { + queue.push({ time: -1, rowIndex, isSync: true }); + // If the sync row has commands, queue them to fire after the sync resolves (time 0 in next chunk) + const commands = row.commands || [row.command]; + const hasCommand = commands.some(c => c && c.trim()); + if (hasCommand) { + // Filter cast for this row + const filtered = cast.filter(token => { + const filterScope = buildTokenScope(token, cast, resolvedParams); + Object.assign(filterScope, resolvedParams); + Object.assign(filterScope, tokenVars[token.get('id')] || {}); + return evalFilter(row.filter, token, castData, filterScope); + }); + filtered.forEach(token => { + const scope = buildTokenScope(token, filtered, resolvedParams); + Object.assign(scope, resolvedParams); + Object.assign(scope, tokenVars[token.get('id')] || {}); + const tokenProxy = wrapToken(token, { tokens: filtered, params: resolvedParams }); + scope.token = tokenProxy; + scope.tokenId = token.get('id'); + scope.tokenName = token.get('name') || ''; + scope.pageId = token.get('_pageid'); + scope.self = scene.name; + scope.castName = (runtimeOpts && runtimeOpts.castName) || ''; + scope.__parent = instanceId; + scope.__depth = Math.max(0, ((runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10) - 1); + + // Evaluate 'when' condition + if (row.when) { + try { + const decls = Object.keys(scope).map(k => `var ${k} = __scope["${k}"];`).join(' '); + const __scope = scope; + if (!eval(decls + '(' + row.when + ')')) return; + } catch(e) { + log(`${SCRIPT_NAME}: when expression error: ${e.message} (expr: "${row.when}")`); + return; + } + } + + commands.forEach(cmdTemplate => { + const command = evalCommand(cmdTemplate, scope); + if (!command) return; + queue.push({ time: 0, rowIndex, tokenId: token.get('id'), command }); + }); + }); + } + return; + } + + // Filter cast + const filtered = cast.filter(token => { + const filterScope = buildTokenScope(token, cast, resolvedParams); + Object.assign(filterScope, resolvedParams); + Object.assign(filterScope, tokenVars[token.get('id')] || {}); + return evalFilter(row.filter, token, castData, filterScope); + }); + if (filtered.length === 0) return; + + // For each matching token, evaluate delay and build queue entry + filtered.forEach(token => { + const scope = buildTokenScope(token, filtered, resolvedParams); + // Add resolved params to scope + Object.assign(scope, resolvedParams); + // Add computed variables + Object.assign(scope, tokenVars[token.get('id')] || {}); + // Add token proxy and scene metadata + const tokenProxy = wrapToken(token, { tokens: filtered, params: resolvedParams }); + scope.token = tokenProxy; + // Deprecated aliases (kept for backward compat) + scope.tokenId = token.get('id'); + scope.tokenName = token.get('name') || ''; + scope.pageId = token.get('_pageid'); + scope.self = scene.name; + scope.castName = (runtimeOpts && runtimeOpts.castName) || ''; + scope.__parent = instanceId; + scope.__depth = Math.max(0, ((runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10) - 1); + + const delay = evalDelay(row.delay, scope); + if (!isFinite(delay)) return; // INF/SKIP + + // Evaluate 'when' condition — skip if false + if (row.when) { + try { + const decls = Object.keys(scope).map(k => `var ${k} = __scope["${k}"];`).join(' '); + const __scope = scope; + if (!eval(decls + '(' + row.when + ')')) return; + } catch(e) { + log(`${SCRIPT_NAME}: when expression error: ${e.message} (expr: "${row.when}")`); + return; + } + } + + const commands = row.commands || [row.command]; + commands.forEach(cmdTemplate => { + const command = evalCommand(cmdTemplate, scope); + if (!command) return; + queue.push({ time: delay, rowIndex, tokenId: token.get('id'), command }); + }); + }); + }); + + // Split queue into chunks at sync markers (preserving row order), then sort each chunk + const chunks = [[]]; + queue.sort((a, b) => a.rowIndex - b.rowIndex); // row order first + queue.forEach(entry => { + if (entry.isSync) { + chunks.push([]); + } else { + chunks[chunks.length - 1].push(entry); + } + }); + // Sort each chunk by time, break ties by rowIndex + chunks.forEach(chunk => chunk.sort((a, b) => a.time - b.time || a.rowIndex - b.rowIndex)); + + const sender = getPlayerName(msg.playerid); + const senderPlayerId = msg.playerid; + const senderWho = msg.who; + + // Register running scene + const instance = { + id: instanceId, + instanceName: genInstanceName(), + name: scene.name, + queue, + timers: [], + cast, + castName: (runtimeOpts && runtimeOpts.castName) || null, + castData: castData || null, + params: resolvedParams, + state: 'running', + startTime: Date.now(), + firedCommands: [], + who: senderWho, + playerid: senderPlayerId, + loop: loopOpts || null, + parentId: (runtimeOpts && runtimeOpts.parent) || null, + children: [], + depth: (runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10, + }; + runningScenes[instanceId] = instance; + emitSceneStart(instance); + + // Register as child of parent + if (instance.parentId && runningScenes[instance.parentId]) { + runningScenes[instance.parentId].children.push(instanceId); + } + + // Handle scene completion — loop or cleanup + const finishScene = () => { + const loop = instance.loop; + if (!loop) { + // Show completion card (only for top-level scenes) + if (instance.playerid !== 'API' && !instance.parentId) { + setTimeout(() => { + const sceneName = instance.name; + const sceneHandout = scenes().find(sceneName); + const openLink = sceneHandout ? ` [open]` : ''; + const castIdStr = (instance.cast || []).map(t => t.get ? t.get('id') : t).join(' '); + const castFlag = instance.castName ? ` --cast ${instance.castName}` : ''; + let card = `
`; + card += `${escHtml(sceneName)}${openLink} — Finished

`; + card += btnHtml('▶ Replay', `${CMD_TOKEN} run ${sceneName} ignore-selected${castFlag} --id ${castIdStr}`); + card += btnHtml('🔁 Loop', `${CMD_TOKEN} run ${sceneName} --loop ignore-selected${castFlag} --id ${castIdStr}`); + card += `
`; + const fakeMsg = { who: instance.who, playerid: instance.playerid }; + reply(fakeMsg, 'Choreograph', card, true); + }, 500); + } + emitSceneFinish(instance); + delete runningScenes[instanceId]; + return; + } + if (loop.unbounded) { + // Unbounded: sync then restart + fireSync(instance, () => { + instance.firedCommands = []; + instance.timers = []; + executeChunk(0); + }, syncTimeout); + } else if (loop.remaining > 0) { + instance.loop = Object.assign({}, loop, { remaining: loop.remaining - 1 }); + instance.firedCommands = []; + instance.timers = []; + if (loop.sync) { + // Bounded with sync: wait then restart + fireSync(instance, () => executeChunk(0), syncTimeout); + } else { + // Bounded without sync: immediate restart + executeChunk(0); + } + } else { + // Loops exhausted — show completion card (only for top-level scenes) + if (instance.playerid !== 'API' && !instance.parentId) { + setTimeout(() => { + const sceneName = instance.name; + const sceneHandout = scenes().find(sceneName); + const openLink = sceneHandout ? ` [open]` : ''; + const castIdStr = (instance.cast || []).map(t => t.get ? t.get('id') : t).join(' '); + const castFlag = instance.castName ? ` --cast ${instance.castName}` : ''; + let card = `
`; + card += `${escHtml(sceneName)}${openLink} — Finished

`; + card += btnHtml('▶ Replay', `${CMD_TOKEN} run ${sceneName} ignore-selected${castFlag} --id ${castIdStr}`); + card += btnHtml('🔁 Loop', `${CMD_TOKEN} run ${sceneName} --loop ignore-selected${castFlag} --id ${castIdStr}`); + card += `
`; + const fakeMsg = { who: instance.who, playerid: instance.playerid }; + reply(fakeMsg, 'Choreograph', card, true); + }, 500); + } + emitSceneFinish(instance); + delete runningScenes[instanceId]; + } + }; + + // Execute chunks — chain with sync between them + const syncTimeout = (runtimeOpts && runtimeOpts.syncTimeout) ? runtimeOpts.syncTimeout : 30000; + + // Execute one chunk, then fire sync and proceed to next + const executeChunk = (chunkIdx) => { + if (chunkIdx >= chunks.length) { + finishScene(); + return; + } + const chunk = chunks[chunkIdx]; + if (chunk.length === 0) { + if (chunkIdx < chunks.length - 1) { + fireSync(instance, () => executeChunk(chunkIdx + 1), syncTimeout); + } else { + finishScene(); + } + return; + } + + instance.startTime = Date.now(); + let i = 0; + while (i < chunk.length) { + const batchTime = chunk[i].time; + const batch = []; + while (i < chunk.length && chunk[i].time === batchTime) { + batch.push(chunk[i]); + i++; + } + const timer = setTimeout(() => { + const byCommand = {}; + batch.forEach(entry => { + if (!byCommand[entry.command]) byCommand[entry.command] = []; + byCommand[entry.command].push(entry.tokenId); + }); + dispatchCommands(byCommand, instance, sender); + }, batchTime); + instance.timers.push(timer); + } + + // After last entry in chunk fires, proceed to sync (or finish) + const maxTime = chunk[chunk.length - 1].time; + if (chunkIdx < chunks.length - 1) { + // There's a sync point after this chunk + const syncTimer = setTimeout(() => { + fireSync(instance, () => executeChunk(chunkIdx + 1), syncTimeout); + }, maxTime + 1); + instance.timers.push(syncTimer); + } else { + // Last chunk — finish (loop or cleanup) after it completes + const cleanup = setTimeout(() => { + finishScene(); + }, maxTime + 100); + instance.timers.push(cleanup); + } + }; + + executeChunk(0); + + return instanceId; + }; + + // ========================================================================= + // Command handler + // ========================================================================= + + const handleInput = (msg, invokeOpts) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD_TOKEN) return; + + // Delegate to ScriptKit framework for examples/guide commands + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + + // Permission check — GM or API always allowed + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') { + replyError(msg, 'Only the GM can use Choreograph commands.'); + return; + } + + const raw = msg.content.slice(CMD_TOKEN.length).trim().split(/\s+/).filter(Boolean); + const cmd = raw[0]; + const rest = raw.slice(1); + + // Parse flags and plain args + const flags = new Set(); + const args = []; + const opts = {}; + + rest.forEach((tok, i) => { + if (tok === 'ignore-selected') { flags.add('ignore-selected'); return; } + if (tok.startsWith('--')) { + const eqIdx = tok.indexOf('='); + if (eqIdx !== -1) { + opts[tok.slice(2, eqIdx)] = tok.slice(eqIdx + 1); + } else { + const key = tok.slice(2); + opts[key] = rest[i + 1] || true; + flags.add(key); + } + return; + } + args.push(tok); + }); + + // ---- new ---- + if (cmd === 'new') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'new', 'Missing scene name'); return; } + if (scenes().find(name)) { + replyError(msg, `A scene named "${name}" already exists.`); + return; + } + const handout = scenes().getOrCreate(name); + setHandoutNotes(handout, generateBlankScene(name)); + reply(msg, 'Choreograph', + `Created scene "${escHtml(name)}". ` + + `[Open Handout]`); + return; + } + + // ---- list ---- + if (cmd === 'list') { + let handouts = scenes().findAll(); + const query = args[0]; + if (query) { + const q = query.toLowerCase(); + handouts = handouts.filter(h => { + const n = scenes().handoutName(h.get('name')); + return n && n.toLowerCase().includes(q); + }); + } + if (handouts.length === 0) { + reply(msg, 'Choreograph', query + ? `No scenes matching "${escHtml(query)}" found.` + : 'No scenes found.'); + return; + } + let out = `${handouts.length} scene(s)${query ? ` matching "${escHtml(query)}"` : ''}:
`; + handouts.forEach(h => { + const sceneName = scenes().handoutName(h.get('name')); + out += `• ${escHtml(sceneName)} ` + + `[Open Handout]
`; + }); + reply(msg, 'Choreograph', out); + return; + } + + // ---- edit ---- + if (cmd === 'edit') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'edit', 'Missing scene name'); return; } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + reply(msg, 'Choreograph', + `Opening scene "${escHtml(name)}": ` + + `[Open Handout]`); + return; + } + + // ---- delete ---- + if (cmd === 'delete') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'delete', 'Missing scene name'); return; } + if (!flags.has('force')) { + reply(msg, 'Choreograph', + `Delete scene "${escHtml(name)}"? ` + + `Yes, delete`); + return; + } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + handout.remove(); + reply(msg, 'Choreograph', `Deleted scene "${escHtml(name)}".`); + return; + } + + // ---- stop ---- + if (cmd === 'stop') { + const name = args[0]; + if (name) { + // Stop by scene name or instance name + const matches = Object.entries(runningScenes) + .filter(([, s]) => s.name === name || s.instanceName === name); + if (matches.length === 0) { + replyError(msg, `No running scene named "${name}".`); + return; + } + matches.forEach(([id]) => stopScene(id)); + reply(msg, 'Choreograph', `Stopped ${matches.length} instance(s) of "${escHtml(name)}".`); + } else { + const count = Object.keys(runningScenes).length; + stopAll(); + reply(msg, 'Choreograph', count > 0 + ? `Stopped ${count} running scene(s).` + : 'No scenes running.'); + } + return; + } + + // ---- pause ---- + if (cmd === 'pause') { + const name = args[0]; + if (name) { + const matches = Object.entries(runningScenes) + .filter(([, s]) => (s.name === name || s.instanceName === name) && s.state === 'running'); + if (matches.length === 0) { replyError(msg, `No running scene named "${name}" to pause.`); return; } + matches.forEach(([id]) => pauseScene(id)); + reply(msg, 'Choreograph', `Paused ${matches.length} instance(s) of "${escHtml(name)}". ` + + btnHtml('▶ Resume', `${CMD_TOKEN} resume ${name}`) + + btnHtml('⏹ Stop', `${CMD_TOKEN} stop ${name}`)); + } else { + const running = Object.entries(runningScenes).filter(([, s]) => s.state === 'running'); + running.forEach(([id]) => pauseScene(id)); + reply(msg, 'Choreograph', running.length > 0 + ? `Paused ${running.length} running scene(s).` + : 'No scenes running to pause.'); + } + return; + } + + // ---- resume ---- + if (cmd === 'resume') { + const name = args[0]; + if (name) { + const matches = Object.entries(runningScenes) + .filter(([, s]) => (s.name === name || s.instanceName === name) && s.state === 'paused'); + if (matches.length === 0) { replyError(msg, `No paused scene named "${name}" to resume.`); return; } + matches.forEach(([id]) => resumeScene(id, msg)); + reply(msg, 'Choreograph', `Resumed ${matches.length} instance(s) of "${escHtml(name)}".`); + } else { + const paused = Object.entries(runningScenes).filter(([, s]) => s.state === 'paused'); + paused.forEach(([id]) => resumeScene(id, msg)); + reply(msg, 'Choreograph', paused.length > 0 + ? `Resumed ${paused.length} paused scene(s).` + : 'No scenes paused to resume.'); + } + return; + } + + // ---- refresh ---- + if (cmd === 'refresh') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'refresh', 'Missing scene name'); return; } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + delete scenes().cache[name]; + scenes().load(name, (scene) => { + if (!scene) { replyError(msg, `Could not parse scene "${name}".`); return; } + const html = generateSceneHtml(name, scene); + setHandoutNotes(handout, html); + reply(msg, 'Choreograph', `Refreshed "${escHtml(name)}" — ${scene.rows.length} row(s).`); + }); + return; + } + + // ---- add-row ---- + if (cmd === 'add-row') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'add-row', 'Missing scene name'); return; } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + delete scenes().cache[name]; + scenes().load(name, (scene) => { + if (!scene) { replyError(msg, `Could not parse scene "${name}".`); return; } + scene.rows.push({ filter: '*', delay: '0', command: '', notes: '' }); + const html = generateSceneHtml(name, scene); + setHandoutNotes(handout, html); + reply(msg, 'Choreograph', `Added row to "${escHtml(name)}".`); + }); + return; + } + + // ---- dump-html ---- + if (cmd === 'dump-html') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'dump-html', 'Missing scene name'); return; } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + getHandoutNotes(handout, (html) => { + const chunkSize = 1000; + for (let i = 0; i < html.length; i += chunkSize) { + log(`${SCRIPT_NAME} dump-html [${name}] chunk ${Math.floor(i/chunkSize)+1}: ` + + html.slice(i, i + chunkSize)); + } + reply(msg, 'Choreograph', + `Dumped HTML for "${escHtml(name)}" to API console (${html.length} chars).`); + }); + return; + } + + // Helper: parse --role flags from message content and merge into castData + const mergeRoleFlags = (content, castData, castIds) => { + const roleRegex = /--role\s+(\S+)((?:\s+-(?!-)[A-Za-z0-9_-]+)+)/g; + let roleMatch; + while ((roleMatch = roleRegex.exec(content)) !== null) { + const roleName = roleMatch[1]; + const roleIds = roleMatch[2].trim().split(/\s+/).filter(Boolean); + if (!castData.roles[roleName]) castData.roles[roleName] = []; + roleIds.forEach(id => { + castData.roles[roleName].push(id); + castIds.push(id); + }); + } + }; + + // ---- run ---- + if (cmd === 'run') { + const name = args[0]; + if (!name) { ScriptKit.usage(msg, 'run', 'Missing scene name'); return; } + const handout = scenes().find(name); + if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } + + // Gather cast IDs from all sources + const castIds = []; + if (!flags.has('ignore-selected')) { + (msg.selected || []).forEach(s => castIds.push(s._id)); + } + if (opts.id) { + const ids = Array.isArray(opts.id) ? opts.id : String(opts.id).split(/\s+/); + ids.forEach(id => { if (id) castIds.push(id); }); + } + if (flags.has('page')) { + let pageId; + if (typeof opts.page === 'string' && opts.page !== 'true') { + pageId = opts.page; + } else { + // Player: use their specific page if split, else ribbon page + const psp = Campaign().get('playerspecificpages') || {}; + pageId = (!playerIsGM(msg.playerid) && psp[msg.playerid]) + ? psp[msg.playerid] + : Campaign().get('playerpageid'); + } + findObjs({ _type: 'graphic', _pageid: pageId }) + .forEach(t => castIds.push(t.get('id'))); + } + args.slice(1).forEach(a => { + if (/^-[A-Za-z0-9_-]+$/.test(a)) castIds.push(a); + }); + + // --cast — merge IDs from cast handout + const runWithCast = (castData) => { + const cast = [...new Set(castIds)] + .map(id => getObj('graphic', id)) + .filter(Boolean); + + if (cast.length === 0) { + replyError(msg, 'No tokens in cast. Select tokens, use --id, or use --cast.'); + return; + } + + scenes().load(name, (scene) => { + if (!scene) { + replyError(msg, `Could not parse scene "${name}".`); + return; + } + + const knownFlags = new Set(['id', 'force', 'loop', 'depth', 'page', 'cast', 'sync', 'sync-timeout', 'role', 'parent']); + const params = {}; + Object.entries(opts).forEach(([k, v]) => { + if (!knownFlags.has(k) && typeof v === 'string') params[k] = v; + }); + + // Enforce max on roles + if (scene.roles && castData && castData.roles) { + scene.roles.forEach(roleDef => { + if (roleDef.max && castData.roles[roleDef.name]) { + const arr = castData.roles[roleDef.name]; + if (arr.length > roleDef.max) { + castData.roles[roleDef.name] = arr.slice(-roleDef.max); + } + } + }); + } + + // Parse loop options + let loopOpts = null; + if (flags.has('loop')) { + const loopVal = opts.loop; + if (loopVal === true || loopVal === 'true') { + // --loop (unbounded) + loopOpts = { unbounded: true, remaining: null, sync: true }; + } else { + const n = parseInt(loopVal, 10); + if (!isNaN(n) && n > 0) { + loopOpts = { unbounded: false, remaining: n - 1, sync: flags.has('sync') }; + } + } + } + + const runtimeOpts = { + parent: opts.parent || null, + depth: opts.depth !== undefined ? parseInt(opts.depth, 10) : 10, + syncTimeout: opts['sync-timeout'] ? parseInt(opts['sync-timeout'], 10) : 30000, + castName: opts.cast || null, + }; + const instanceId = executeScene(scene, cast, params, msg, castData || null, loopOpts, runtimeOpts); + if (!instanceId) return; // scene failed to start (missing params, role constraints, etc.) + const inst = runningScenes[instanceId]; + const iName = inst ? inst.instanceName : instanceId; + // Only show status card for user-initiated runs (not children/recursive) + if (msg.playerid !== 'API' && !runtimeOpts.parent) { + const sceneHandout = scenes().find(name); + const openLink = sceneHandout ? ` [open]` : ''; + let castInfo = ''; + if (inst && inst.castName) { + const castHandout = casts().find(inst.castName); + castInfo = castHandout + ? ` — ${escHtml(inst.castName)} [open]` + : ` — ${escHtml(inst.castName)}`; + } + const looseCt = (inst && inst.castName) ? 0 : cast.length; + if (looseCt > 0 && !(inst && inst.castName)) castInfo = ` — ${looseCt} token(s)`; + let card = `
`; + card += `${escHtml(name)}${openLink}${castInfo}
`; + card += `Instance: ${escHtml(iName)}

`; + card += btnHtml('⏸ Pause', `${CMD_TOKEN} pause ${iName}`); + card += btnHtml('⏹ Stop', `${CMD_TOKEN} stop ${iName}`); + card += btnHtml('🔄 Status', `${CMD_TOKEN} status`); + card += `
`; + reply(msg, 'Choreograph', card, true); + } + }); + }; + + if (opts.cast) { + casts().load(String(opts.cast), (castData) => { + if (!castData) { + replyError(msg, `No cast named "${opts.cast}" found.`); + return; + } + getAllCastIds(castData).forEach(id => castIds.push(id)); + // Merge --role into loaded cast if present + if (opts.role) { + mergeRoleFlags(msg.content, castData, castIds); + } + runWithCast(castData); + }); + } else if (opts.role) { + // --role — build ephemeral castData + const roleData = { roles: {} }; + mergeRoleFlags(msg.content, roleData, castIds); + runWithCast(roleData); + } else { + runWithCast(null); + } + return; + } + + // ---- cast ---- + if (cmd === 'cast') { + const subCmd = args[0]; + const castName = args[1]; + + if (subCmd === 'list') { + const handouts = casts().findAll(); + if (handouts.length === 0) { + reply(msg, 'Cast', 'No casts found.'); + return; + } + let out = `${handouts.length} cast(s):
`; + handouts.forEach(h => { + const n = casts().handoutName(h.get('name')); + out += `• ${escHtml(n)} ` + + `[Open]
`; + }); + reply(msg, 'Cast', out); + return; + } + + if (subCmd === 'show') { + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } + casts().load(castName, (cast) => { + if (!cast) { replyError(msg, `No cast named "${castName}" found.`); return; } + let out = `Cast: ${escHtml(castName)}
`; + Object.entries(cast.roles).forEach(([role, ids]) => { + const label = role || '(no role)'; + const names = ids.map(id => { + const obj = getObj('graphic', id); + return obj ? (obj.get('name') || id) : `${id} (missing)`; + }); + out += `${escHtml(label)}: ${names.join(', ')}
`; + }); + reply(msg, 'Cast', out); + }); + return; + } + + if (subCmd === 'add') { + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } + const role = opts.role || ''; + // Gather IDs from selection + --id + remaining args + const ids = []; + if (!flags.has('ignore-selected')) { + (msg.selected || []).forEach(s => ids.push(s._id)); + } + if (opts.id) String(opts.id).split(/\s+/).forEach(id => { if (id) ids.push(id); }); + args.slice(2).forEach(a => { if (/^-[A-Za-z0-9_-]+$/.test(a)) ids.push(a); }); + + if (ids.length === 0) { + replyError(msg, 'No tokens specified. Select tokens or use --id.'); + return; + } + + const handout = casts().getOrCreate(castName); + casts().load(castName, (cast) => { + if (!cast) cast = { roles: {} }; + if (!cast.roles[role]) cast.roles[role] = []; + ids.forEach(id => { + if (!cast.roles[role].includes(id)) cast.roles[role].push(id); + }); + casts().cache[castName] = cast; + setHandoutNotes(handout, generateCastHtml(castName, cast.roles)); + reply(msg, 'Cast', + `Added ${ids.length} token(s) to "${escHtml(castName)}"${role ? ` role "${escHtml(role)}"` : ''}.`); + }); + return; + } + + if (subCmd === 'remove') { + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } + const role = opts.role; + // Gather IDs to remove + const ids = []; + if (!flags.has('ignore-selected')) { + (msg.selected || []).forEach(s => ids.push(s._id)); + } + if (opts.id) String(opts.id).split(/\s+/).forEach(id => { if (id) ids.push(id); }); + args.slice(2).forEach(a => { if (/^-[A-Za-z0-9_-]+$/.test(a)) ids.push(a); }); + + if (ids.length === 0) { + replyError(msg, 'No tokens specified. Select tokens or use --id.'); + return; + } + + casts().load(castName, (cast) => { + if (!cast) { replyError(msg, `No cast named "${castName}" found.`); return; } + const handout = casts().find(castName); + if (role !== undefined) { + // Remove from specific role + if (cast.roles[role]) { + cast.roles[role] = cast.roles[role].filter(id => !ids.includes(id)); + if (cast.roles[role].length === 0) delete cast.roles[role]; + } + } else { + // Remove from all roles + Object.keys(cast.roles).forEach(r => { + cast.roles[r] = cast.roles[r].filter(id => !ids.includes(id)); + if (cast.roles[r].length === 0) delete cast.roles[r]; + }); + } + casts().cache[castName] = cast; + setHandoutNotes(handout, generateCastHtml(castName, cast.roles)); + reply(msg, 'Cast', + `Removed ${ids.length} token(s) from "${escHtml(castName)}"${role ? ` role "${escHtml(role)}"` : ''}.`); + }); + return; + } + + if (subCmd === 'delete') { + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } + if (!flags.has('force')) { + reply(msg, 'Cast', + `Delete cast "${escHtml(castName)}"? ` + + `Yes, delete`); + return; + } + const handout = casts().find(castName); + if (!handout) { replyError(msg, `No cast named "${castName}" found.`); return; } + handout.remove(); + delete casts().cache[castName]; + reply(msg, 'Cast', `Deleted cast "${escHtml(castName)}".`); + return; + } + + ScriptKit.usage(msg, 'cast', 'Unknown cast subcommand'); + return; + } + + // ---- example ---- + + + // ---- status ---- + if (cmd === 'status') { + const instances = Object.values(runningScenes); + if (instances.length === 0) { + reply(msg, 'Choreograph', 'No scenes running.'); + return; + } + let out = `${instances.length} running scene(s):
`; + instances.forEach(inst => { + const elapsed = Math.round((Date.now() - inst.startTime) / 1000); + out += `• ${escHtml(inst.instanceName)} — ${escHtml(inst.name)} ` + + `[${inst.state}] ${elapsed}s ` + + `(${inst.cast.length} tokens)
`; + }); + reply(msg, 'Choreograph', out); + return; + } + + + // ---- echo (debug/test) ---- + if (cmd === 'echo') { + const text = rest.join(' '); + const ts = Date.now() % 100000; + reply(msg, 'Echo', `[${ts}ms] ${text}`, true); + return; + } + + // ---- fx ---- + // Usage: !choreograph fx [ ] [pageId] + // Or: !choreograph fx [] + // Or with selected: !choreograph fx (one or two tokens selected) + // + // Auto-detects spawnFx vs spawnFxBetweenPoints based on: + // 1. If only one point/token provided → always spawnFx + // 2. If prefab type → hardcoded table determines between-points + // 3. If custom FX (by ID or name) → checks definition.angle === -1 + if (cmd === 'fx') { + const fxTypeArg = args[0]; + if (!fxTypeArg) { ScriptKit.usage(msg, 'fx', 'Missing FX type'); return; } + + // Parse points from args — strict mode separation + let p1, p2, pageId; + const remaining = args.slice(1); + + if (remaining.length === 0) { + // Selected mode: use selected tokens + if (msg.selected && msg.selected.length > 0) { + const t1 = getObj('graphic', msg.selected[0]._id); + if (t1) { p1 = { x: t1.get('left'), y: t1.get('top') }; pageId = t1.get('_pageid'); } + } + if (msg.selected && msg.selected.length >= 2) { + const t2 = getObj('graphic', msg.selected[1]._id); + if (t2) { p2 = { x: t2.get('left'), y: t2.get('top') }; } + } + } else if (!isNaN(parseFloat(remaining[0]))) { + // Coordinate mode: all args are numbers + p1 = { x: parseFloat(remaining[0]), y: parseFloat(remaining[1]) }; + if (remaining.length >= 4 && !isNaN(parseFloat(remaining[2])) && !isNaN(parseFloat(remaining[3]))) { + p2 = { x: parseFloat(remaining[2]), y: parseFloat(remaining[3]) }; + pageId = remaining[4] || Campaign().get('playerpageid'); + } else { + pageId = remaining[2] || Campaign().get('playerpageid'); + } + } else { + // Token ID mode: args are token IDs + const t1 = getObj('graphic', remaining[0]); + if (t1) { p1 = { x: t1.get('left'), y: t1.get('top') }; pageId = t1.get('_pageid'); } + if (remaining.length >= 2) { + const t2 = getObj('graphic', remaining[1]); + if (t2) { p2 = { x: t2.get('left'), y: t2.get('top') }; } + } + } + + if (!p1) return; // no valid point resolved + + // Resolve custom FX name to ID if needed + let fxType = fxTypeArg; + const prefabTypes = ['beam', 'bomb', 'breath', 'bubbling', 'burn', 'burst', 'explode', 'glow', 'missile', 'nova', 'splatter']; + const isPrefab = prefabTypes.some(t => fxTypeArg.startsWith(t + '-') || fxTypeArg === t); + if (!isPrefab) { + // Custom FX — resolve to ID + let fxObj; + if (fxTypeArg.startsWith('-')) { + fxObj = getObj('custfx', fxTypeArg); + } + if (!fxObj) { + const results = findObjs({ _type: 'custfx', name: fxTypeArg }); + if (results.length > 0) fxObj = results[0]; + } + if (fxObj) fxType = fxObj.get('_id'); + } + + // If two points are available, use spawnFxBetweenPoints (works for all types). + // If only one point, use spawnFx (works for all types except beam/missile). + if (p2) { + spawnFxBetweenPoints(p1, p2, fxType, pageId); + } else { + spawnFx(p1.x, p1.y, fxType, pageId); + } + return; + } + + if (typeof ScriptKit !== 'undefined') ScriptKit.usage(msg); + else replyError(msg, `Unknown command: ${cmd}. Commands: new, list, edit, delete, run, stop, refresh.`); + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + state[SCRIPT_NAME] = state[SCRIPT_NAME] || {}; + + // ── Register core token variables (eager) ───────────────────────── + [ + { name: 'id', fn: (t) => t.get('id') }, + { name: 'left', fn: (t) => t.get('left') }, + { name: 'top', fn: (t) => t.get('top') }, + { name: 'name', fn: (t) => t.get('name') || '' }, + { name: 'layer', fn: (t) => t.get('layer') }, + { name: 'width', fn: (t) => t.get('width') }, + { name: 'height', fn: (t) => t.get('height') }, + { name: 'rotation', fn: (t) => t.get('rotation') || 0 }, + { name: 'flipv', fn: (t) => t.get('flipv') }, + { name: 'fliph', fn: (t) => t.get('fliph') }, + { name: 'bar1_value', fn: (t) => parseFloat(t.get('bar1_value')) || 0 }, + { name: 'bar2_value', fn: (t) => parseFloat(t.get('bar2_value')) || 0 }, + { name: 'bar3_value', fn: (t) => parseFloat(t.get('bar3_value')) || 0 }, + { name: 'statusmarkers', fn: (t) => t.get('statusmarkers') || '' }, + { name: 'imgsrc', fn: (t) => t.get('imgsrc') || '' }, + { name: 'pageid', fn: (t) => t.get('_pageid') }, + ].forEach(def => addTokenVarDef({ name: def.name, namespace: 'core', fn: def.fn, evaluation: 'eager' })); + + // ── Register core constants ─────────────────────────────────────── + registerConstant(SCRIPT_NAME, { name: 'PI', namespace: 'core', value: Math.PI, description: 'π' }); + registerConstant(SCRIPT_NAME, { name: 'TAU', namespace: 'core', value: Math.PI * 2, description: '2π' }); + registerConstant(SCRIPT_NAME, { name: 'INF', namespace: 'core', value: Infinity, description: 'Infinity — skip token' }); + registerConstant(SCRIPT_NAME, { name: 'SKIP', namespace: 'core', value: Infinity, description: 'Alias for INF' }); + + // ── Register core functions ─────────────────────────────────────── + registerFunction(SCRIPT_NAME, { + name: 'distance', namespace: 'core', returns: 'number', + description: 'Pixel distance from (x,y) or token to current token.', + args: [{ name: 'x', type: 'number' }, { name: 'y', type: 'number' }], + fn: (token, filteredTokens, params, x, y) => { + if (typeof x === 'object' && x !== null) { + y = x.top !== undefined ? x.top : (x.get ? x.get('top') : 0); + x = x.left !== undefined ? x.left : (x.get ? x.get('left') : 0); + } + const dx = token.get('left') - x; + const dy = token.get('top') - y; + return Math.sqrt(dx * dx + dy * dy); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'propagate', namespace: 'core', returns: 'number', + description: 'dist / speed', + fn: (token, filteredTokens, params, dist, speed) => dist / speed, + }); + registerFunction(SCRIPT_NAME, { + name: 'stagger', namespace: 'core', returns: 'number', + description: 'rank * interval', + fn: (token, filteredTokens, params, rank, interval) => rank * interval, + }); + registerFunction(SCRIPT_NAME, { + name: 'wave', namespace: 'core', returns: 'number', + description: 'Wave offset: (pos % wavelength) / wavelength * duration', + fn: (token, filteredTokens, params, pos, wavelength, duration) => ((pos % wavelength) / wavelength) * (duration || wavelength), + }); + registerFunction(SCRIPT_NAME, { + name: 'rank', namespace: 'core', returns: 'number', + description: 'Sort position (0-based) within filtered set.', + fn: (token, filteredTokens, params, attr) => { + let sorted; + if (typeof attr === 'function') { + sorted = [...filteredTokens].sort((a, b) => attr(a) - attr(b)); + } else if (typeof attr === 'string') { + sorted = [...filteredTokens].sort((a, b) => (a.get(attr) || 0) - (b.get(attr) || 0)); + } else { + return filteredTokens.indexOf(token); + } + return sorted.indexOf(token); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'rand', namespace: 'core', returns: 'number', pure: false, + description: 'Random number between min and max.', + fn: (token, filteredTokens, params, min, max) => min + Math.random() * (max - min), + }); + registerFunction(SCRIPT_NAME, { + name: 'randInt', namespace: 'core', returns: 'number', pure: false, + description: 'Random integer between min and max (inclusive).', + fn: (token, filteredTokens, params, min, max) => Math.floor(min + Math.random() * (max + 1 - min)), + }); + registerFunction(SCRIPT_NAME, { + name: 'clamp', namespace: 'core', returns: 'number', + fn: (token, filteredTokens, params, v, lo, hi) => Math.min(Math.max(v, lo), hi), + }); + registerFunction(SCRIPT_NAME, { name: 'abs', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.abs(x) }); + registerFunction(SCRIPT_NAME, { name: 'round', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.round(x) }); + registerFunction(SCRIPT_NAME, { name: 'floor', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.floor(x) }); + registerFunction(SCRIPT_NAME, { name: 'ceil', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.ceil(x) }); + registerFunction(SCRIPT_NAME, { name: 'min', namespace: 'core', returns: 'number', fn: (t, f, p, ...args) => Math.min(...args) }); + registerFunction(SCRIPT_NAME, { name: 'max', namespace: 'core', returns: 'number', fn: (t, f, p, ...args) => Math.max(...args) }); + registerFunction(SCRIPT_NAME, { name: 'sqrt', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.sqrt(x) }); + registerFunction(SCRIPT_NAME, { name: 'pow', namespace: 'core', returns: 'number', fn: (t, f, p, x, y) => Math.pow(x, y) }); + registerFunction(SCRIPT_NAME, { name: 'sin', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.sin(x) }); + registerFunction(SCRIPT_NAME, { name: 'cos', namespace: 'core', returns: 'number', fn: (t, f, p, x) => Math.cos(x) }); + + // count — number of tokens in current filtered set (registered as function, 0 args) + registerFunction(SCRIPT_NAME, { + name: 'count', namespace: 'core', returns: 'number', + description: 'Number of tokens passing the current row filter.', + fn: (token, filteredTokens) => filteredTokens.length, + }); + + // actors / actor_ids — registered as functions returning token[] + registerFunction(SCRIPT_NAME, { + name: 'actors', namespace: 'core', returns: 'token[]', + description: 'Tokens sorted by distance from current token.', + fn: (token, filteredTokens, params, filterStr) => { + const cd = params.__ctx ? params.__ctx.castData : null; + const set = filterStr + ? filteredTokens.filter(t => evalFilter(filterStr, t, cd)) + : filteredTokens; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'actor_ids', namespace: 'core', returns: 'string[]', + description: 'Token IDs sorted by distance from current token.', + fn: (token, filteredTokens, params, filterStr) => { + const cd = params.__ctx ? params.__ctx.castData : null; + const set = filterStr + ? filteredTokens.filter(t => evalFilter(filterStr, t, cd)) + : filteredTokens; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }).map(t => t.get('id')); + }, + }); + + registerFunction(SCRIPT_NAME, { + name: 'cast', namespace: 'core', returns: 'token[]', + description: 'All tokens in the full cast (ignoring row filter), optionally filtered by role. Sorted by distance from current token.', + fn: (token, filteredTokens, params, filterStr) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = filterStr + ? all.filter(t => evalFilter(filterStr, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'cast_ids', namespace: 'core', returns: 'string[]', + description: 'Token IDs from the full cast (ignoring row filter), optionally filtered by role. Sorted by distance.', + fn: (token, filteredTokens, params, filterStr) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = filterStr + ? all.filter(t => evalFilter(filterStr, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }).map(t => t.get('id')); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'role', namespace: 'core', returns: 'token[]', + description: 'Shorthand for cast("role="). Returns tokens in the named role, sorted by distance.', + fn: (token, filteredTokens, params, roleName) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = roleName + ? all.filter(t => evalFilter(`role=${roleName}`, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }); + }, + }); + registerFunction(SCRIPT_NAME, { + name: 'role_ids', namespace: 'core', returns: 'string[]', + description: 'Shorthand for cast_ids("role="). Returns IDs of tokens in the named role, sorted by distance.', + fn: (token, filteredTokens, params, roleName) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = roleName + ? all.filter(t => evalFilter(`role=${roleName}`, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }).map(t => t.get('id')); + }, + }); + + // ── Built-in example scenes (via ScriptKit framework) ───────────────── + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + + ScriptKit.register(SCRIPT_NAME, { + command: CMD_TOKEN, + tag: 'Scene', + version: SCRIPT_VERSION, + newSince: '1.0.0', + motd: [ + 'Use `!choreograph examples` to browse interactive demos you can run immediately.', + 'The `sync` delay waits for animations to finish before continuing — great for phased effects.', + 'Use `--role caster ` to assign roles at run time without pre-saving a cast.', + 'Chain scenes recursively with `${self}` — combined with `when`, you can build bounce/jump effects.', + 'TokenProxy gives you `token.left`, `token.name`, etc. — no more `get()` calls in expressions.', + 'LINQ methods like `.first()`, `.without()`, `.orderBy()` chain on `actors()` and `role()` results.', + 'Use `!choreograph man ` to search help — it fuzzy-matches across topics and items.', + 'Chain scenes recursively and use `sync` delay to gate the next phase on child completion.', + ], + motdHeader: '🎬 **Choreograph** v' + SCRIPT_VERSION, + motdStyle: { borderLeft: '3px solid #7b1fa2' }, + help: { + description: 'Meta-sequencer for Roll20 tokens. Define scenes in handouts — filter tokens, compute per-token timing, and fire commands at the right moments.', + quickStart: [ + '`!choreograph new myScene` — creates a blank scene handout.', + 'Open the **[Scene] myScene** handout. Add rows to the Scene Table: set a Filter (e.g. `*`), a Delay expression (e.g. `stagger(rank("left"), 200)`), and a Command template (e.g. `!sequence play sparkle --target ${token.id}`).', + 'Select tokens and run `!choreograph run myScene`.', + ], + changelog: [ + { version: '1.0.0', date: '2026-08-11', changes: [ + 'Interactive tutorial series (6 guided walkthroughs building a ritual summoning scene)', + 'fx/fxbetween commands accept token IDs (no more manual coordinate lookup)', + 'sync delay rows now fire their command after sync resolves (not just barrier)', + 'Required parameter validation (missing params abort with error)', + 'onSceneStart/onSceneFinish signal system (public API)', + 'waitForScene() helper for tutorial/extension use', + 'castName scope variable for command templates', + 'Scene handout: section headers, empty row skipping, cell collapse fix', + 'Completion card delayed 500ms to not step on last command output', + 'Fix: actors()/actor_ids() now use castData for role-based filtering', + 'Revamped example command with fuzzy search, tiered sorting, and bold highlights', + 'Interactive setup guide wizard for examples (multi-step, roles, params)', + 'when field for conditional row execution', + '--role flag for ad-hoc role assignment at run time', + 'role()/role_ids()/cast()/cast_ids() expression functions', + 'token[]/path[] parameters enriched with TokenProxy', + ]}, + { version: '0.2', date: '2026-06-12', changes: [ + 'TokenProxy — dot-notation access to all token properties', + 'LINQ-style array methods (.from, .without, .where, .orderBy, .first, .last, .select)', + 'Dynamic man/help generation from registries', + 'role=X filter for cast roles', + ]}, + { version: '0.1', date: '2026-06-07', changes: [ + 'Initial release: run, new, list, edit, delete, stop, pause, resume, status', + 'Scene handout format (params, variables, rows)', + 'Cast system with roles', + 'Scene chaining, looping, sync system', + 'Extension API (registerFunction, registerTokenVariable, registerConstant, etc.)', + 'Filters, delay expressions, command templates', + ]}, + ], + commands: [ + { syntax: 'run [flags]', description: 'Execute a scene', version: '0.1', details: 'Runs the named scene on selected tokens (or cast).', items: [ + { name: '--loop', description: 'Loop indefinitely (sync between cycles)', version: '0.1' }, + { name: '--loop N', description: 'Loop N times (immediate restart)', version: '0.1' }, + { name: '--loop N --sync', description: 'Loop N times (sync between cycles)', version: '0.1' }, + { name: '--page [id]', description: 'Populate cast from all tokens on a page', version: '0.1' }, + { name: '--id ', description: 'Populate cast from explicit token IDs', version: '0.1' }, + { name: '--cast ', description: 'Populate cast from a saved cast', version: '0.1' }, + { name: 'ignore-selected', description: 'Don\'t include selected tokens in cast', version: '0.1' }, + { name: '--depth N', description: 'Max chaining depth (default: 10)', version: '0.1' }, + { name: '--sync-timeout ', description: 'Sync timeout in ms (default: 30000)', version: '0.1' }, + { name: '--role ', description: 'Assign tokens to a role at run time', version: '1.0.0' }, + { name: '-- ', description: 'Bind a scene parameter value', version: '0.1' }, + ]}, + { syntax: 'new ', description: 'Create blank scene handout', version: '0.1' }, + { syntax: 'list [query]', description: 'List scenes (fuzzy search)', version: '0.1' }, + { syntax: 'edit ', description: 'Open scene handout', version: '0.1' }, + { syntax: 'delete ', description: 'Delete a scene', version: '0.1' }, + { syntax: 'refresh ', description: 'Regenerate handout from cache', version: '0.1' }, + { syntax: 'add-row ', description: 'Add blank row to scene table', version: '0.1' }, + { syntax: 'dump-html ', description: 'Dump raw handout HTML to API console', version: '0.1' }, + { syntax: 'echo ', description: 'Debug: whisper text with timestamp', version: '0.1' }, + { syntax: 'fx [id2|x2 y2] [pageId]', description: 'Spawn FX (auto-detects point vs between-points)', version: '0.1', items: [ + { name: '', description: 'FX type (e.g. explode-fire, beam-magic) or custom FX ID/name', version: '0.1' }, + { name: ' [id2]', description: 'Token ID(s) — one for point FX, two for directional', version: '1.0.0' }, + { name: ' [x2 y2]', description: 'Coordinates (one or two points)', version: '0.1' }, + { name: 'auto-detect', description: 'beam/breath/splatter → between-points; others → single point; custom FX checks angle === -1', version: '1.0.0' }, + ]}, + { group: 'Playback', commands: [ + { syntax: 'stop [name]', description: 'Stop running scene(s)', version: '0.1' }, + { syntax: 'pause [name]', description: 'Pause running scene(s)', version: '0.1' }, + { syntax: 'resume [name]', description: 'Resume paused scene(s)', version: '0.1' }, + { syntax: 'status', description: 'Show all running scenes', version: '0.1' }, + ]}, + { group: 'Cast', commands: [ + { syntax: 'cast add/remove/list/show/delete', description: 'Manage casts', version: '0.1' }, + ]}, + ], + topics: { + handout: { + title: 'Scene Handout Structure', + description: 'How scene handouts are organized', + version: '0.1', + body: 'Each scene is stored in a `[Scene] ` handout with three HTML tables that Choreograph parses. You can edit them directly in the handout editor.', + items: [ + { name: 'Parameter Table', description: 'Name | Type | Default | Description — scene inputs bound at run time', version: '0.1' }, + { name: 'Variables Table', description: 'Variable | Expression — computed once per token before execution', version: '0.1' }, + { name: 'Scene Table', description: 'Filter | Delay | Command | Notes — the choreography rows', version: '0.1' }, + ], + }, + flow: { + title: 'How It All Connects', + description: 'The execution pipeline from run to command', + version: '0.1', + body: '**1. Cast assembly** — You run `!choreograph run myScene` with tokens selected. These become the *cast*. Parameters are bound from --flags.\n' + + '**2. Variables computed** — For each token in the cast, the Variables table is evaluated top-to-bottom. Each variable can reference params, earlier variables, and the token itself.\n' + + '**3. Row processing** — Each row in the Scene Table is processed:\n' + + ' • The **Filter** selects which cast members this row applies to.\n' + + ' • The **When** condition (if any) is checked per-token — falsy = skip.\n' + + ' • The **Delay** expression is evaluated per-token to compute milliseconds.\n' + + ' • After the delay fires, the **Command** template is evaluated per-token and sent to chat.\n' + + '**4. All rows fire in parallel** — rows don\'t wait for each other unless you use `sync` to create coordination points.\n\n' + + '**Example trace:** Scene has `speed` param (default 2). Variable `dist = distance(350, 350)` computes per-token. Delay `dist / speed` staggers by distance. Command `!sequence play sparkle --target ${token.id}` fires per-token when its delay expires.', + }, + example: { + title: 'Example Scene', + description: 'A complete scene showing all pieces working together', + version: '0.1', + body: '**Propagating burst** — a sparkle effect radiates outward from a center point, hitting nearby tokens first.\n\n' + + '**Parameters:**\n' + + ' `speed` — number, default `2` (pixels per ms)\n' + + ' `origin` — token, default `selected` (center point)\n\n' + + '**Variables:**\n' + + ' `dist` = `distance(origin.left, origin.top)`\n\n' + + '**Scene Table:**\n' + + ' Row 1: Filter `*` | Delay `dist / speed` | Command `!sequence play sparkle --target ${token.id}`\n' + + ' Row 2: Filter `*` | Delay `dist / speed + 500` | Command `!sequence play fade-out --target ${token.id}`\n\n' + + '**Result:** Tokens near the origin sparkle first, with the burst rippling outward. 500ms after each sparkle, that token fades out.', + }, + filters: { + title: 'Filters', + description: 'Filter syntax for selecting tokens', + version: '0.1', + details: 'Filters determine which tokens in the cast are affected by a scene row. Each row in the scene table has a filter column that selects a subset of the cast.', + body: 'Space-separated conditions within a cell are AND. Multiple rows provide OR. Empty filter = no tokens match.', + items: [ + { name: '*', description: 'All tokens', version: '0.1' }, + { name: 'layer=X', description: 'On layer X', version: '0.1' }, + { name: 'name=X*', description: 'Name glob match (supports * wildcard)', version: '0.1' }, + { name: 'id=-ABC123', description: 'Specific token ID', version: '0.1' }, + { name: 'role=X', description: 'Has role X in the cast', version: '0.2' }, + { name: 'status=X', description: 'Has status marker X', version: '0.1' }, + { name: '!prefix', description: 'Negation (e.g. !layer=gm)', version: '0.1' }, + ], + }, + delay: { + title: 'Delay Expressions', + description: 'Per-token timing expressions', + version: '0.1', + details: 'Each row has a delay column containing a JavaScript expression evaluated per-token. The expression must return a number (milliseconds), INF/SKIP to exclude a token, or sync to wait for all participants before continuing.', + body: () => 'Return: number (ms), INF/SKIP, or sync.\n\n' + + '**Token Variables:** ' + TOKEN_VAR_DEFS.filter(d => d.namespace === 'core').map(d => d.name).join(', ') + ', self, plus params/computed vars.\n' + + '**Constants:** ' + Object.values(EXT_CONSTANTS).filter(r => r.namespace === 'core').map(r => r.name).join(', '), + items: [ + { name: 'rank("attr")', description: 'Sort position of current token in filtered set', version: '0.1' }, + { name: 'distance(x, y)', description: 'Pixel distance from token to point (or `distance(orig)`)', version: '0.1' }, + { name: 'propagate(dist, speed)', description: 'dist / speed', version: '0.1' }, + { name: 'stagger(rank, interval)', description: 'rank × interval', version: '0.1' }, + { name: 'wave(pos, wavelength, duration)', description: 'Sinusoidal timing offset', version: '0.1' }, + { name: 'rand(min, max)', description: 'Random number in range', version: '0.1' }, + { name: 'randInt(min, max)', description: 'Random integer in range', version: '0.1' }, + { name: 'clamp(v, lo, hi)', description: 'Clamp value to range', version: '0.1' }, + { name: 'actors(filter?)', description: 'Tokens passing filter, sorted by distance', version: '0.1' }, + { name: 'actor_ids(filter?)', description: 'Token IDs passing filter, sorted by distance', version: '0.1' }, + { name: 'sync', description: 'Wait for all sync participants before continuing', version: '0.1' }, + { name: 'INF / SKIP', description: 'Skip this token (infinite delay)', version: '0.1' }, + ], + }, + commands: { + title: 'Command Templates', + description: 'How to write command templates in scene rows', + version: '0.1', + details: 'Each row in the scene table has a command column. Commands are API calls (starting with !) that fire when a token\'s delay expires. Template literals allow dynamic values computed per-token.', + body: 'Use `${expr}` for substitutions. Evaluated as JS template literals. All variables, params, computed variables, and functions are in scope.\n\nMultiple commands per cell: put each on a new line in the handout cell. They fire simultaneously for that token.', + items: [ + { name: '${token.id}', description: 'Current token ID', version: '0.1' }, + { name: '${token.left}', description: 'Token X position (TokenProxy)', version: '0.2' }, + { name: '${token.name}', description: 'Token display name', version: '0.2' }, + { name: '${self}', description: 'Current scene name (for recursion/chaining)', version: '0.1' }, + { name: '${castName}', description: 'Current cast name (pass to child scenes with --cast)', version: '1.0.0' }, + { name: '${count}', description: 'Number of tokens matching this row\'s filter', version: '0.1' }, + { name: '${myVar}', description: 'Any computed variable or parameter by name', version: '0.1' }, + { name: '${actors().first().id}', description: 'ID of the nearest other token in the filtered set', version: '0.1' }, + { name: '${role("targets").first().id}', description: 'ID of the nearest token in a role', version: '1.0.0' }, + { name: '${role_ids("targets").join(" ")}', description: 'Space-separated list of all target IDs', version: '1.0.0' }, + { name: '${Math.round(dist / speed)}', description: 'Any JS expression (computed inline)', version: '0.1' }, + ], + }, + cast: { + title: 'Cast Management', + description: 'Saving and managing token groups', + version: '0.1', + details: 'Casts are saved token groups stored in [Cast] handouts. They persist across sessions and can assign tokens to named roles for filtering. Use --cast in run to use a saved cast instead of selection.', + body: 'Stored in `[Cast] ` handouts. Use `--cast ` in run to load. Tokens default to selected if no --cast/--page/--id is given.', + items: [ + { name: 'cast add [--role R]', syntax: '!choreograph cast add [--role R]', description: 'Add selected tokens to cast (optionally to a role)', version: '0.1' }, + { name: 'cast remove [--role R]', syntax: '!choreograph cast remove [--role R]', description: 'Remove tokens from cast', version: '0.1' }, + { name: 'cast list', syntax: '!choreograph cast list', description: 'List all saved casts', version: '0.1' }, + { name: 'cast show ', syntax: '!choreograph cast show ', description: 'Show cast members and roles', version: '0.1' }, + { name: 'cast delete ', syntax: '!choreograph cast delete ', description: 'Delete a saved cast', version: '0.1' }, + ], + }, + castexpr: { + title: 'Cast Expressions', + description: 'Accessing cast and role data in expressions', + version: '1.0.0', + details: 'These functions are available in delay expressions and command templates. They return enriched arrays with LINQ methods for chaining.', + body: 'Use these in delay/command expressions to access the full cast or specific roles. All return arrays sorted by distance from the current token.', + items: [ + { name: 'cast()', description: 'Full cast array (all tokens in the scene run)', version: '1.0.0' }, + { name: 'cast_ids()', description: 'Full cast ID array', version: '1.0.0' }, + { name: 'role("name")', description: 'Tokens in a specific role (enriched array)', version: '1.0.0' }, + { name: 'role_ids("name")', description: 'Token IDs in a specific role', version: '1.0.0' }, + ], + }, + sync: { + title: 'Sync', + description: 'Waiting for participants to complete', + version: '0.1', + details: 'Sync creates coordination points within a scene. When a row uses sync as its delay, execution pauses until all registered sync participants (like Sequence animations) report completion. This gates phase transitions on actual animation end rather than estimated timing.', + body: 'Use `sync` as a delay value. Waits for all registered sync participants to signal completion before continuing.\n\nUseful for gating recursion or phase transitions on animation completion.', + }, + loop: { + title: 'Looping', + description: 'Repeating scene execution', + version: '0.1', + details: 'Looping repeats the entire scene. Only top-level scenes can loop indefinitely — child scenes spawned via chaining can use bounded loops (`--loop N`).', + items: [ + { name: '--loop', description: 'Loop indefinitely, sync between cycles', version: '0.1' }, + { name: '--loop N', description: 'Loop N times, immediate restart', version: '0.1' }, + { name: '--loop N --sync', description: 'Loop N times, sync between cycles', version: '0.1' }, + ], + body: 'Children can use bounded loops (`--loop N`). Infinite loops (`--loop`) are top-level only.', + }, + chain: { + title: 'Scene Chaining', + description: 'Recursion and scene composition', + version: '0.1', + details: 'Scenes can spawn other scenes (or themselves) via command templates. This enables recursive patterns like chain-lightning that bounce between targets. Depth is capped (default 10) to prevent infinite recursion.', + body: 'At depth 0, child spawns are skipped. Children can use bounded `--loop N` but not infinite `--loop`.', + items: [ + { name: 'self', description: 'Resolves to current scene name', version: '0.1' }, + { name: '--parent', description: 'Auto-injected parent scene reference', version: '0.1' }, + { name: '--depth N', description: 'Max chaining depth (default: 10)', version: '0.1' }, + ], + }, + when: { + title: 'Row Conditions', + description: 'Conditional row execution', + version: '1.0.0', + details: 'The when field is a JavaScript expression evaluated per-token. If it returns falsy, the row is skipped for that token. Combined with recursion, this enables patterns like "keep jumping until jumps runs out."', + body: 'Add a `when` expression to a scene row. The row only executes for tokens where the expression evaluates to truthy.\n\nExample: `jumps > 0 && next`', + }, + params: { + title: 'Parameter Types', + description: 'Types available for scene parameters', + version: '0.1', + details: 'Parameters are defined in the scene handout\'s Parameter table. They configure the scene at run time via --flags or the guide wizard. Type determines how values are resolved (e.g. token IDs are looked up as Roll20 objects).', + body: 'Append [] for arrays (e.g. token[], number[]). `cast` is built-in (token[], default: selected). Params without defaults are required at run time.', + items: [ + { name: 'number', description: 'Numeric value', version: '0.1' }, + { name: 'text', description: 'String value', version: '0.1' }, + { name: 'boolean', description: 'true/false', version: '0.1' }, + { name: 'token', description: 'Token reference (resolved from ID)', version: '0.1' }, + { name: 'path', description: 'Path reference', version: '0.1' }, + { name: 'sequence', description: 'Sequence recording name', version: '0.1' }, + { name: 'scene', description: 'Choreograph scene name', version: '0.1' }, + { name: 'role', description: 'Cast role name', version: '1.0.0' }, + ], + }, + vars: { + title: 'Variables', + description: 'Computed variables in scene tables', + version: '0.1', + details: 'Variables are computed once per token before any rows execute. They can reference parameters, other variables (defined earlier), and all built-in functions. Use them in delay expressions and command templates.', + body: 'Defined in the Variables table (Variable | Expression). Computed once per token before execution. Later variables can reference earlier ones. Available in all delay expressions and command templates.', + }, + tokenproxy: { + title: 'TokenProxy', + description: 'Dot-notation access to token properties', + version: '0.2', + details: 'TokenProxy wraps Roll20 graphic objects so you can access properties with dot notation in expressions instead of calling get(). Token parameters (type token) are also TokenProxy instances, so param.left works.', + body: 'The `token` object provides access to all token properties via dot notation. Token parameters (type `token`) are also TokenProxy instances.', + items: [ + { name: 'token.id', description: 'Token ID', version: '0.2' }, + { name: 'token.name', description: 'Token display name', version: '0.2' }, + { name: 'token.left / token.top', description: 'Token position', version: '0.2' }, + { name: 'token.width / token.height', description: 'Token dimensions', version: '0.2' }, + { name: 'token.rotation', description: 'Token rotation', version: '0.2' }, + { name: 'token.layer', description: 'Token layer', version: '0.2' }, + { name: 'token.pageid', description: 'Token page ID', version: '0.2' }, + { name: 'token.bar1_value', description: 'Bar values (bar1-3)', version: '0.2' }, + ], + }, + linq: { + title: 'Array Methods (LINQ)', + description: 'Chainable array operations on token sets', + version: '0.2', + details: 'Arrays returned by actors(), role(), cast(), and other set-returning functions are enriched with LINQ-style methods for filtering, sorting, and projecting without manual iteration.', + body: 'Arrays returned by `actors()`, `role()`, etc. have extra methods:', + items: [ + { name: '.from(other)', description: 'Intersection — keep only items in both arrays', version: '0.2' }, + { name: '.without(other)', description: 'Exclusion — remove items in other', version: '0.2' }, + { name: '.where(fn)', description: 'Filter (alias for .filter())', version: '0.2' }, + { name: '.orderBy(attr)', description: 'Sort by attribute name or function', version: '0.2' }, + { name: '.first(n?)', description: 'First element or first N elements', version: '0.2' }, + { name: '.last(n?)', description: 'Last element or last N elements', version: '0.2' }, + { name: '.any(fn?)', description: 'True if any match (or non-empty)', version: '0.2' }, + { name: '.count(fn?)', description: 'Count matching or total', version: '0.2' }, + { name: '.ids()', description: 'Get ID strings', version: '0.2' }, + { name: '.select(fn)', description: 'Map/project elements', version: '0.2' }, + ], + }, + roles: { + title: 'Roles', + description: 'Ad-hoc role assignment and filtering', + version: '1.0.0', + details: 'Roles are lightweight labels assigned to tokens at run time. Unlike casts (which are persisted), roles exist only for the duration of a scene run. They enable patterns like "caster hits targets" without pre-configuring casts.', + body: 'Assign tokens to roles at runtime with `--role `. Filter with `role=X`. Access in expressions with `role("name")` and `role_ids("name")`.', + items: [ + { name: '--role ', description: 'Assign tokens to a role at run time', version: '1.0.0' }, + { name: 'role("name")', description: 'Get tokens in role (returns enriched array)', version: '1.0.0' }, + { name: 'role_ids("name")', description: 'Get token IDs in role', version: '1.0.0' }, + ], + }, + troubleshooting: { + title: 'Troubleshooting', + description: 'Common issues and error behavior', + version: '0.1', + body: '**Expression errors** — If a delay or variable expression throws, that token is skipped for that row. An error is whispered to the GM with the expression and error message.\n\n' + + '**Empty filter match** — If a filter matches no tokens, the row does nothing (no error). This is intentional for conditional scenes.\n\n' + + '**Missing parameters** — If a required parameter (no default) is not provided at run time, the scene aborts with an error listing the missing params.\n\n' + + '**Depth limit reached** — At depth 0, any `!choreograph run` commands in the scene table are silently skipped. Increase `--depth` if legitimate recursion is being cut short.\n\n' + + '**Sync timeout** — If a sync participant doesn\'t signal completion within the timeout (default 30s), the scene continues without it. Adjust with `--sync-timeout`.\n\n' + + '**Scene not found** — Check that the handout is named exactly `[Scene] ` and hasn\'t been renamed. Use `!choreograph list` to see available scenes.\n\n' + + '**Tokens not moving/animating** — Choreograph only fires commands; it doesn\'t move tokens itself. Make sure the target script (e.g. Sequence) is installed and the command syntax is correct.', + }, + api: { + title: 'Extension API', + description: 'How to extend Choreograph from other scripts', + version: '0.1', + handouts: 'dev', + details: 'Other scripts can extend Choreograph by registering custom functions, token variables, constants, parameter types, lifecycle hooks, and sync participants. Extensions appear in man pages and the dev handout automatically.', + items: [ + { name: 'registerFunction(src, struct)', syntax: 'Choreograph.registerFunction(src, struct)', description: 'Add a function to delay/command expressions', version: '0.1' }, + { name: 'registerTokenVariable(src, struct)', syntax: 'Choreograph.registerTokenVariable(src, struct)', description: 'Add a per-token variable', version: '0.1' }, + { name: 'registerConstant(src, struct)', syntax: 'Choreograph.registerConstant(src, struct)', description: 'Add a constant', version: '0.1' }, + { name: 'registerParameterType(src, struct)', syntax: 'Choreograph.registerParameterType(src, struct)', description: 'Add a custom parameter type', version: '0.1' }, + { name: 'registerLifecycleHook(src, struct)', syntax: 'Choreograph.registerLifecycleHook(src, struct)', description: 'Hook into scene lifecycle events', version: '0.1' }, + { name: 'registerSyncParticipant(src, struct)', syntax: 'Choreograph.registerSyncParticipant(src, struct)', description: 'Register for sync coordination', version: '0.1' }, + { name: 'generateExtensionHandout(src, opts)', syntax: 'Choreograph.generateExtensionHandout(src, opts)', description: 'Generate developer docs handout', version: '0.1' }, + { name: 'onSceneStart(fn)', syntax: 'Choreograph.onSceneStart(fn)', description: 'Subscribe to scene start events. Returns unsubscribe function.', version: '1.0.0' }, + { name: 'onSceneFinish(fn)', syntax: 'Choreograph.onSceneFinish(fn)', description: 'Subscribe to scene finish events. Returns unsubscribe function.', version: '1.0.0' }, + { name: 'waitForScene(name)', syntax: 'Choreograph.waitForScene(name)', description: 'Returns {onEnter, onExit} for ScriptKit guides — auto-advances when named scene finishes', version: '1.0.0' }, + ], + body: 'Run `!choreograph gen-dev-docs` for the full developer guide.', + }, + func: { + title: 'Registered Functions', + description: 'Functions available in delay/command expressions', + version: '0.1', + body: () => { + const regs = Object.values(EXT_FUNCTIONS); + if (regs.length === 0) return '*No functions registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + const argList = (r.args || []).map(a => a.name).join(', '); + const purity = r.pure === false ? ' [unstable]' : ''; + out += ns + '**' + r.name + '(' + argList + ')** → *' + (r.returns || 'any') + '*' + purity + '\n'; + if (r.description) out += r.description + '\n'; + out += '\n'; + }); + return out; + }, + }, + tokenvar: { + title: 'Token Variables', + description: 'Registered per-token variables', + version: '0.1', + body: () => { + const regs = Object.values(EXT_TOKEN_VARS); + if (regs.length === 0) return '*No token variables registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + out += ns + '**' + r.name + '**'; + if (r.description) out += ' — ' + r.description; + out += '\n'; + }); + return out; + }, + }, + const: { + title: 'Constants', + description: 'Registered constants', + version: '0.1', + body: () => { + const regs = Object.values(EXT_CONSTANTS); + if (regs.length === 0) return '*No constants registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + out += ns + '**' + r.name + '** = `' + String(r.value) + '`'; + if (r.description) out += ' — ' + r.description; + out += '\n'; + }); + return out; + }, + }, + }, + }, + exampleHandler: (example, msg) => { + const sceneName = example.source + '/example-' + example.name; + const scene = Object.assign({ name: sceneName }, example.scene); + if (!scene.params) scene.params = []; + if (!scene.params.find(p => p.name === 'cast')) { + scene.params.unshift({ name: 'cast', type: 'token[]', default: 'selected', description: 'Tokens to run the scene on (built-in)' }); + } + if (!scene.variables) scene.variables = []; + if (!scene.rows) scene.rows = []; + const html = generateSceneHtml(sceneName, scene); + // Cache the scene so Choreograph can run it + scenes().cache[sceneName] = scene; + return { notes: html, archived: true }; + }, + onComplete: (ctx) => { + // Build cast from selections._roles and run the scene + const sceneName = ctx.example.source + '/example-' + ctx.example.name; + const roles = ctx.selections._roles || {}; + if (Object.keys(roles).length > 0) { + const castName = sceneName + '-cast'; + const castRoles = {}; + Object.entries(roles).forEach(([role, tokens]) => { + castRoles[role] = (Array.isArray(tokens) ? tokens : [tokens]).map(t => t.get('id')); + }); + const castHandout = casts().getOrCreate(castName); + casts().cache[castName] = { roles: castRoles }; + setHandoutNotes(castHandout, generateCastHtml(castName, castRoles)); + castHandout.set('archived', true); + // Build param flags + const paramFlags = Object.entries(ctx.params || {}) + .map(([k, v]) => '--' + k + ' ' + v) + .join(' '); + const syntheticMsg = Object.assign({}, ctx.msg, { + content: CMD_TOKEN + ' run ' + sceneName + ' ignore-selected --cast ' + castName + (paramFlags ? ' ' + paramFlags : ''), + selected: [], + }); + handleInput(syntheticMsg); + } + }, + }); + }; + + // Try immediately + listen for ready signal + registerWithScriptKit(); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') registerWithScriptKit(); + }); + + // ===================================================================== + // Tutorial Examples (via ScriptKit) + // ===================================================================== + + // Helper: generate a clickable handout link, or plain text if not found + const sceneLink = (name) => { + const h = scenes().find(name); + const display = `[Scene] ${name}`; + return h ? ScriptKit.html.handoutLink(display, h.get('id')) : `${display}`; + }; + const castLink = (name) => { + const h = casts().find(name); + const display = `[Cast] ${name}`; + return h ? ScriptKit.html.handoutLink(display, h.get('id')) : `${display}`; + }; + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'your-first-scene', + description: 'Create "The Summoning" — learn scenes, tables, filters, delay, and running.', + guide: [ + { prompt: '**Welcome to Choreograph!**\n\nOver the next few tutorials, you\'ll build a complete **ritual summoning** scene step by step. Cultists chant, energy gathers, and a creature is summoned.\n\nThis first tutorial covers the fundamentals: creating a scene, understanding the three tables, and running it.\n\nClick Continue to begin.' }, + { prompt: '**Setup: Place Your Tokens**\n\nBefore we create the scene, set up the stage. On your current page, place:\n\n• **At least 6 tokens** to serve as cultists (minimum 4, but 6+ recommended so later tutorials can split them into groups)\n• Give them names\n• Arrange them roughly in a circle\n• **Rotate each cultist to face generally toward the center** (select token, hold alt, and drag the rotation handle)\n\nThe rotation values will determine clockwise ordering later — this is important!\n\n**Select all your cultist tokens** then click Continue.', + select: 'token', min: 4, as: 'cultists', + }, + { prompt: '**Create the Scene**\n\nRun `!choreograph new summoning` to create the scene handout.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Run `!choreograph new summoning`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'The Three Tables

' + + 'Open ' + sceneLink('summoning') + ' from your journal. You\'ll see:

' + + '1. Parameters — inputs the scene accepts when run (e.g. --speed 2). The built-in cast parameter is your selected tokens.
' + + '2. Variables — computed values evaluated per token at runtime (e.g. distance from center).
' + + '3. Scene Table — the rows: Filter | Delay | When | Command | Notes

' + + 'Each row says: "for tokens matching this filter, after this delay, and when these conditions are met, fire this command (the Notes is just for you to keep track of what is going on)." All rows start simultaneously — the delay offsets them.

' + + 'Click Continue when you\'ve opened the handout.' + ) }, + { prompt: () => ScriptKit.html.raw( + 'Your First Rows

' + + 'In the Scene Table, replace the example row with three rows (leave When and Notes empty for now):

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', '0', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['*', '2000', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['*', '4000', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
What this does:
' + + '• Three phases of chanting, 2 seconds apart
' + + '• All cultists speak each line simultaneously (same delay per row)
' + + '• ${token.name} inserts each token\'s name

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Run It!**\n\nSelect your cultist tokens, then run:\n\n`!choreograph run summoning`\n\nYou should see whispered messages appear one by one, staggered left-to-right: *"Cultist begins chanting..."*', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**What Just Happened?**\n\nChoreograph:\n1. Collected your selected tokens as the **cast**\n2. Evaluated each row\'s filter (`*` = all tokens)\n3. Fired each row\'s command at its delay — 0ms, 2000ms, 4000ms\n4. Substituted `${token.name}` with each token\'s actual name\n\n**Key Concepts:**\n• All rows start their timers simultaneously — delays offset them\n• Fixed delays (`0`, `2000`, `4000`) create sequential phases\n• Within a row, all matching tokens fire at the same time\n• `${...}` expressions are evaluated per-token\n\nRight now all cultists chant the same lines in unison. In the next tutorial, we\'ll split them into groups so each group chants a different phrase.', + offerExamples: ['roles-and-casts'] + }, + ], + }); + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'roles-and-casts', + description: 'Split cultists into role groups — each group chants a different phrase.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Roles & Casts

' + + 'Right now all cultists chant the same three phrases in unison. Let\'s split them into three groups so each group speaks a different line of the incantation.

' + + 'Prerequisite: Complete the "Your First Scene" tutorial first. You should have ' + sceneLink('summoning') + ' and your cultist tokens.

' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the "Your First Scene" tutorial first.'; + } + }, + { prompt: '**What is a Cast?**\n\nA **Cast** is a saved group of tokens with named **roles**. Instead of selecting tokens every time you run a scene, you define the cast once and reference it.\n\nRoles let you target subsets of the cast in your scene rows using the `role=X` filter.\n\nLet\'s create a cast for our summoning ritual.' }, + { prompt: '**Create the Cast — Group 1**\n\nSelect roughly a third of your cultist tokens (the ones you want to chant the first phrase).\n\nRun: `!choreograph cast add summoning --role first`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Create the Cast — Group 2**\n\nSelect the next third of cultists.\n\nRun: `!choreograph cast add summoning --role second`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Create the Cast — Group 3**\n\nSelect the remaining cultists.\n\nRun: `!choreograph cast add summoning --role third`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: () => ScriptKit.html.raw( + 'Update the Scene

' + + 'Open ' + sceneLink('summoning') + ' and change the Filter column on each row to target a specific role:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', '0', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['role=second', '2000', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['role=third', '4000', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
Now each group chants its own phrase instead of everyone saying the same thing.

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Run with the Cast**\n\nInstead of selecting tokens manually, use the saved cast:\n\n`!choreograph run summoning --cast summoning`\n\nYou should see each group chant its own phrase at its scheduled time.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => ScriptKit.html.raw( + 'Key Concepts:

' + + '• !choreograph cast add <name> --role <role> — assign selected tokens to a named role
' + + '• role=X in the Filter column — only match tokens in that role
' + + '• --cast <name> on run — load a saved cast instead of using selected tokens
' + + '• Roles persist in the ' + castLink('summoning') + ' handout — edit it directly to reassign

' + + 'Useful commands:
' + + '• !choreograph cast show summoning — view current assignments
' + + '• !choreograph cast remove summoning --role first — remove tokens from a role

' + + 'In the next tutorial, we\'ll add timing expressions so the cultists within each group activate one at a time, clockwise around the circle.' + ), + offerExamples: ['filters-and-delay'] + }, + ], + }); + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'filters-and-delay', + description: 'Stagger cultists clockwise with timing expressions and add chaotic energy effects.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Filters & Delay

' + + 'The cultist groups chant their phrases, but within each group everyone speaks at the same instant. Let\'s make them activate one at a time, sweeping clockwise around the circle.

' + + 'Prerequisite: Complete "Roles & Casts" first. You should have ' + sceneLink('summoning') + ' with role-based filters and a ' + castLink('summoning') + '. At least one role needs 2+ tokens for staggering to be visible (6+ cultists total recommended).

' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Timing Expressions**\n\nSo far, delays have been fixed numbers (ms). But delays can be *expressions* — evaluated per-token, producing different values for each.\n\nKey functions:\n• `rank("attr")` — this token\'s sort position (0-based) among filtered tokens, sorted by attribute\n• `stagger(position, interval)` — `position * interval` (spaces out execution)\n• `rand(min, max)` — random number in range\n• `propagate(distance, speed)` — `distance / speed`\n\nSince your cultists face the center, their `rotation` values increase clockwise. So `rank("rotation")` gives clockwise order!\n\nClick Continue.' }, + { prompt: () => ScriptKit.html.raw( + 'Update the Delays

' + + 'Open ' + sceneLink('summoning') + ' and update the Delay column:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', 'stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['role=second', '2000 + stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['role=third', '4000 + stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
Each group still starts at its fixed offset (0/2000/4000), but within the group, tokens fire 500ms apart in clockwise order.

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Test the Stagger**\n\nRun: `!choreograph run summoning --cast summoning`\n\nYou should see each group\'s cultists chant one at a time, sweeping clockwise — first group, then second, then third.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => ScriptKit.html.raw( + 'Add a Chaotic Energy Row

' + + 'Add a 4th row to the scene — dark energy crackles at random intervals:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', 'rand(500, 5000)', '', '!choreograph fx explode-death ${token.left} ${token.top}', 'chaos'], + ]) + + '
* matches all tokens regardless of role
' + + '• rand(500, 5000) gives each token a random delay between 0.5s and 5s
' + + '• This row runs in parallel with the chanting rows — overlapping effects!

' + + 'Save and click Continue.' + ) }, + { prompt: '**Run the Full Scene**\n\nRun: `!choreograph run summoning --cast summoning`\n\nNow you should see the clockwise chanting *plus* dark energy explosions firing chaotically around the tokens.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => { + const helpHandout = ScriptKit.getHelpHandout(SCRIPT_NAME); + const hId = helpHandout ? helpHandout.get('id') : ''; + return ScriptKit.html.raw( + 'Key Takeaways

' + + 'In this tutorial you used:

' + + '• stagger(rank("rotation"), 500) — sequential timing sorted by token rotation (clockwise)
' + + '• rand(500, 5000) — randomized timing for chaotic effects
' + + '• Arithmetic in delays: 2000 + stagger(...) — offset a group while staggering within it
' + + '• * filter — target all tokens regardless of role
' + + '• Multiple rows with different filters run in parallel

' + + 'For the full list of filters and delay functions, see the help handout:
' + + (hId ? '• ' + ScriptKit.html.handoutLink('Filters', hId, null, 'Filters') + '
' : '') + + (hId ? '• ' + ScriptKit.html.handoutLink('Delay Expressions', hId, null, 'Delay Expressions') + '
' : '') + + '
Next: we\'ll add a sacrifice token and compute distances from it.' + ); + }, + offerExamples: ['variables-and-templates'] + }, + ], + }); + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'variables-and-templates', + description: 'Compute distance from the sacrifice and use it in delays and commands.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Variables & Templates

' + + 'The cultists chant in clockwise order, but the ritual should intensify based on proximity to the sacrifice at the center. Let\'s add computed variables that measure distance from the sacrifice token.

' + + 'Prerequisite: Complete "Filters & Delay". You need ' + sceneLink('summoning') + ' and ' + castLink('summoning') + ' with roles.

' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Add a Sacrifice Token**\n\nPlace a token in the center of the cultist circle. This is the sacrifice — the focal point of the ritual.\n\nAdd it to the cast with a new role:\n\n`!choreograph cast add summoning --role sacrifice`\n\n(Select the center token first.)', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Vary the Distances**\n\nFor this tutorial to look interesting, each cultist needs a slightly different distance from the sacrifice. Hold Alt and drag some cultists closer or farther from the center — make the circle a bit messy.\n\nThis ensures `propagate()` produces visibly different delays for each token.\n\nClick Continue when your cultists are at varied distances.' }, + { prompt: () => ScriptKit.html.raw( + 'The Parameters Table

' + + 'Open ' + sceneLink('summoning') + '. The first table is the Parameters table. It already has the built-in cast parameter.

' + + 'Parameters are inputs you can pass at runtime with --name value. Let\'s add a speed parameter to control how fast effects propagate.

' + + 'Add this row to the Parameters table (leave Default empty):

' + + ScriptKit.html.table( + ['Name', 'Type', 'Default', 'Description'], + [ + ['speed', 'number', '', 'Propagation speed (px/ms)'], + ]) + + '
Save the handout, then click Continue.' + ) }, + { prompt: '**Required Parameters**\n\nTry running the scene without providing `--speed`:\n\n`!choreograph run summoning --cast summoning`\n\nYou should get an error: *"Missing required parameter(s): speed"*\n\nParameters without a default are **required** — the scene won\'t run unless you provide them. This is useful for parameters that have no sensible default.', + ...ScriptKit.waitForCommand('!choreograph run') + }, + { prompt: () => ScriptKit.html.raw( + 'Add a Default

' + + 'Open ' + sceneLink('summoning') + ' and add a default value to the speed parameter:

' + + ScriptKit.html.table( + ['Name', 'Type', 'Default', 'Description'], + [ + ['speed', 'number', '0.2', 'Propagation speed (px/ms)'], + ]) + + '
Now the scene will use 0.2 unless overridden at runtime with --speed <value>.

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**The Variables Table**\n\nThe second table is the **Variables** table (two columns: **Variable** | **Expression**).\n\nVariables are computed *per token* before the scene runs. They can reference:\n• `token.left`, `token.top`, `token.name`, etc. — the current token\'s properties\n• Any registered function — `distance()`, `rank()`, `actors()`, `role_ids()`, etc.\n• Parameters passed at runtime (like `speed`)\n• Earlier variables (evaluated top-to-bottom)\n\nClick Continue.' }, + { prompt: () => ScriptKit.html.raw( + 'Add a Distance Variable

' + + 'In the Variables table, add these rows:

' + + ScriptKit.html.table( + ['Variable', 'Expression'], + [ + ['sacrifice', 'role("sacrifice")[0]'], + ['dist', 'distance(sacrifice)'], + ]) + + '
What this does:
' + + '• role("sacrifice")[0] — gets the nearest token in the "sacrifice" role
' + + '• distance(sacrifice) — computes pixel distance from the current token to the sacrifice
' + + '• Variables cascade: dist can reference the earlier sacrifice variable

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: () => ScriptKit.html.raw( + 'Use Distance in a Command

' + + 'Add a new row to the Scene Table that uses dist in the delay. This fires a breath of fire from the sacrifice to each cultist:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['!role=sacrifice', 'propagate(dist, speed)', '', '!choreograph fx breath-fire ${sacrifice.id} ${token.id}', 'fire breath'], + ]) + + '
What\'s new here:
' + + '• !role=sacrifice — negation filter: all tokens EXCEPT the sacrifice
' + + '• propagate(dist, speed) — delay = distance / speed, using the parameter we defined
' + + '• ${sacrifice.id} — use the computed variable to get the sacrifice token\'s ID

' + + 'Save and click Continue.' + ) }, + { prompt: '**Run It**\n\nRun: `!choreograph run summoning --cast summoning`\n\nYou should see:\n1. The chanting rows fire as before (clockwise stagger)\n2. Fire breath shoots from the sacrifice to each cultist, staggered by distance — closer ones first\n3. The dark energy explosions fire chaotically on top', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**Command Templates: Full Power**\n\nThe `${...}` syntax in commands is a full JavaScript template literal. You have access to:\n\n• All computed variables (`dist`, etc.)\n• All parameters (`speed`, etc.)\n• `token.left`, `token.top`, `token.id`, `token.name`, etc.\n• All functions: `rank()`, `distance()`, `rand()`, `actors()`, etc.\n• JS expressions: `${dist > 100 ? "far" : "close"}`\n• String methods: `${token.name.toUpperCase()}`\n\n**Key Takeaways:**\n• Parameters table = inputs passed at runtime with `--name value`\n• No default = required (scene aborts with error if missing)\n• Variables table = per-token computed values\n• Variables cascade top-to-bottom (later vars can use earlier ones)\n• `distance(target)` + `propagate(dist, speed)` = ripple-outward timing\n• `!filter` = negation (exclude a role/name/layer)\n• `${expr}` in commands = full JS evaluation\n\nNext: we\'ll make the chanting loop with escalating intensity.', + offerExamples: ['looping-and-when'] + }, + ], + }); + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'looping-and-when', + description: 'Make the ritual chanting loop with sync gating between cycles.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Looping & When

' + + 'The summoning ritual should repeat — cultists chanting in cycles, energy building with each repetition. Choreograph\'s loop system handles this.

' + + 'Prerequisite: Complete "Variables & Templates". You need ' + sceneLink('summoning') + ' with roles, stagger delays, and the distance variable.

' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'The When Column

' + + 'Before we loop, the echo commands from earlier will spam chat on every cycle. Let\'s disable them using the When column.

' + + 'The When column is a JS expression — if it evaluates to falsy, the row is skipped for that token. Open ' + sceneLink('summoning') + ' and put false in the When column on the three echo rows:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', '...', 'false', '!choreograph echo ...', ''], + ['role=second', '...', 'false', '!choreograph echo ...', ''], + ['role=third', '...', 'false', '!choreograph echo ...', ''], + ]) + + '
This leaves only the FX rows active — the fire breath and dark energy explosions.

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Loop Basics**\n\nLoop flags are added to the `run` command — they don\'t go in the handout:\n\n• `--loop` — repeat forever (until `!choreograph stop`)\n• `--loop 3` — repeat exactly 3 times, restart immediately\n• `--loop 3 --sync` — repeat 3 times, wait for ALL commands to finish before restarting\n\nClick Continue.' }, + { prompt: '**Try Looping**\n\nRun the scene with 3 loops and sync:\n\n`!choreograph run summoning --cast summoning --loop 3 --sync`\n\n`--sync` means each cycle waits for the previous one to fully complete before restarting. You should see the FX play out 3 times.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**Infinite Loop + Stop**\n\nFor ambience or sustained effects, use unbounded looping:\n\n`!choreograph run summoning --cast summoning --loop`\n\nThis loops forever. To stop it:\n\n`!choreograph stop`\n\n(Or click the stop button on the status card that appears.)\n\nTry running it in a loop, then stopping it after a few cycles.', + ...ScriptKit.waitForCommand('!choreograph stop') + }, + { prompt: '**Conditional When**\n\nYou can also use expressions in the When column to conditionally fire rows:\n\n`dist < 200`\n\nThis means: "Only fire this row for tokens closer than 200px to the sacrifice."\n\nThe When column has access to all the same variables and functions as the Delay column — computed variables, token properties, `rand()`, etc.\n\nClick Continue.' }, + { prompt: '**Key Takeaways:**\n\n• When column — JS expression that gates whether a row fires (falsy = skip)\n• `false` in When = disabled row (useful for muting rows without deleting them)\n• `--loop N --sync` — bounded loop with completion gating\n• `--loop` — infinite, stopped with `!choreograph stop`\n• Sync ensures all commands finish before the next cycle begins\n• Loop flags live on the run command, not in the handout — same scene can be run with or without looping\n\nNext: we\'ll split the climax into a separate child scene triggered by chaining.', + offerExamples: ['chaining-and-recursion'] + }, + ], + }); + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'chaining-and-recursion', + description: 'Create a climax scene triggered by chaining — FX explosion when the ritual completes.', + guide: [ + { prompt: '**Chaining & Recursion**\n\nThe ritual builds to a climax — but the climax is a separate effect. Choreograph lets one scene **chain** into another. We\'ll create a parent scene that orchestrates the full ritual: chanting loops, then the climax.\n\n**Prerequisite:** Complete "Looping & When".\n\nClick Continue to begin.', + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Create the Climax Scene**\n\nRun: `!choreograph new summoning-climax`\n\nThis will hold the dramatic finale — an explosion of energy at the sacrifice.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('summoning-climax')) return 'Scene "summoning-climax" not found. Run `!choreograph new summoning-climax`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'Fill in the Climax Scene

' + + 'Open ' + sceneLink('summoning-climax') + ' and set up an explosion effect:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=sacrifice', '0', '', '!choreograph fx nova-holy ${token.id}', 'explosion'], + ['!role=sacrifice', 'propagate(dist, 0.2)', '', '!choreograph fx explode-magic ${token.id}', 'ripple'], + ]) + + '
This needs the same variables as the summoning scene. Add to the Variables table:

' + + ScriptKit.html.table( + ['Variable', 'Expression'], + [ + ['sacrifice', 'role("sacrifice")[0]'], + ['dist', 'distance(sacrifice)'], + ]) + + '
The climax fires a nova at the sacrifice, then magic explosions ripple outward to each cultist.

' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Create the Parent Scene**\n\nRun: `!choreograph new ritual`\n\nThis will be the orchestrator — it chains the summoning loop into the climax.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('ritual')) return 'Scene "ritual" not found. Run `!choreograph new ritual`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'Fill in the Ritual Scene

' + + 'Open ' + sceneLink('ritual') + ' and set up two rows:

' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', '0', '', '!choreograph run summoning --cast ${castName} --loop 3', 'chanting x3'], + ['*', 'sync', '', '!choreograph run summoning-climax --cast ${castName}', 'climax'], + ]) + + '
What\'s happening:
' + + '• Row 1 runs the summoning scene 3 times (loops flow back-to-back)
' + + '• Row 2 has delay sync — it waits for all previous commands to finish, then fires the climax
' + + '• ${castName} passes the current cast name to child scenes so they can look up role assignments

' + + 'Save and click Continue.' + ) }, + { prompt: '**Run the Complete Ritual**\n\nRun: `!choreograph run ritual --cast summoning`\n\nYou should see:\n1. The summoning FX play 3 times (fire breath + dark energy each cycle)\n2. After all 3 cycles complete — the climax fires: nova at the sacrifice, then magic explosions ripple outward', + ...Choreograph.waitForScene('ritual'), + }, + { prompt: '**Chaining Concepts:**\n\n• Any `!choreograph run` in a command template chains to that scene\n• `sync` delay = wait for all previous commands to finish before this row fires\n• `${castName}` resolves to the current cast name — pass it to child scenes with `--cast`\n• Tokens matching the row\'s filter are auto-selected as the child scene\'s cast\n• `--depth 10` is the default max recursion depth (prevents infinite loops)\n• `${self}` in a command resolves to the current scene name (useful for recursive scenes)\n\n**Congratulations!** You\'ve built a complete multi-phase ritual with roles, timing, variables, looping, and chaining. From here, experiment with:\n• `!sequence play` commands for smooth animations (requires Sequence)\n• Custom FX types for unique visuals\n• More complex scene hierarchies with multiple children', + offerExamples: ['your-first-scene', 'roles-and-casts', 'filters-and-delay', 'variables-and-templates', 'looping-and-when'] + }, + ], + }); + + // Register Choreograph with itself for child cascading + registerLifecycleHook(SCRIPT_NAME, { + commands: [/^!choreograph run /], + start: (ctx) => { + // ctx is msg-shaped from Choreograph's execution engine + handleInput(ctx, { internal: true }); + }, + stop: (ctx) => { + Object.values(runningScenes) + .filter(s => s.parentId === ctx.sceneInfo.instanceId) + .forEach(s => stopScene(s.id)); + }, + pause: (ctx) => { + Object.values(runningScenes) + .filter(s => s.parentId === ctx.sceneInfo.instanceId) + .forEach(s => pauseScene(s.id)); + }, + resume: (ctx) => { + Object.values(runningScenes) + .filter(s => s.parentId === ctx.sceneInfo.instanceId) + .forEach(s => resumeScene(s.id)); + }, + }); + + // Register as sync participant — wait for children to finish + registerSyncParticipant(SCRIPT_NAME, { + commands: [/^!choreograph run /], + waiting: (ctx) => { + // Check immediately — child may already be registered (cached scene load is sync) + const immediateChildren = Object.values(runningScenes) + .filter(s => s.parentId === ctx.sceneInfo.instanceId); + let childrenSeen = immediateChildren.length > 0; + // If children already appeared and disappeared, we're done + // (shouldn't happen on immediate check, but guard anyway) + + // Poll for children to register and then finish + // (child scenes load async, so they may not be in runningScenes yet) + let attempts = 0; + const check = setInterval(() => { + const children = Object.values(runningScenes) + .filter(s => s.parentId === ctx.sceneInfo.instanceId); + if (children.length > 0) { + childrenSeen = true; + } else if (childrenSeen) { + // Children were running and are now gone — done + clearInterval(check); + ctx.done(); + return; + } else { + attempts++; + // Give children time to register (async handout load) + if (attempts > 20) { clearInterval(check); ctx.done(); } + return; + } + }, 100); + }, + }); + + + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('change:handout:notes', (handout) => { + const [tag, name] = HandoutCache.getHandoutTagAndName(handout.get('name')); + const cache = handoutCache[tag]; + if (cache !== undefined) { + delete cache.cache[name]; + cache.load(name, () => {}); + } + }); + on('destroy:handout', (handout) => { + const [tag, name] = HandoutCache.getHandoutTagAndName(handout.get('name')); + const cache = handoutCache[tag]; + if (cache !== undefined) { + delete cache.cache[name]; + } + }); + }; + + return { + checkInstall, + registerEventHandlers, + // Public Extension API + registerFunction, + registerTokenVariable, + registerParameterType, + registerConstant, + registerLifecycleHook, + registerSyncParticipant, + + generateExtensionHandout, + // Signals + onSceneStart, + onSceneFinish, + waitForScene, + // Introspection + getFunction: (name) => EXT_FUNCTIONS[name] || null, + getVariable: (name) => EXT_TOKEN_VARS[name] || null, + getConstant: (name) => EXT_CONSTANTS[name] || null, + getParameterType: (name) => EXT_PARAM_TYPES[name] || null, + }; +})(); + +on('ready', () => { + 'use strict'; + Choreograph.checkInstall(); + Choreograph.registerEventHandlers(); +}); diff --git a/Choreograph/Choreograph.js b/Choreograph/Choreograph.js index a0209051a4..99875063ab 100644 --- a/Choreograph/Choreograph.js +++ b/Choreograph/Choreograph.js @@ -1,6 +1,6 @@ // ============================================================================= -// Choreograph v0.2 -// Last Updated: 2026-06-12 +// Choreograph v1.0.0 +// Last Updated: 2026-08-11 // Author: Kenan Millet // // Description: @@ -17,17 +17,18 @@ // !choreograph delete [--force] Delete a scene // !choreograph stop [name] Stop running scene(s) // !choreograph refresh Regenerate handout from cache +// !choreograph fx ... Spawn FX (auto-detects point vs between) // ============================================================================= /* global state, on, sendChat, getObj, createObj, findObjs, Campaign, playerIsGM, log, _, setInterval, clearInterval, setTimeout, Date, - sendPing, spawnFx, spawnFxBetweenPoints */ + spawnFx, spawnFxBetweenPoints */ var Choreograph = Choreograph || (() => { 'use strict'; const SCRIPT_NAME = 'Choreograph'; - const SCRIPT_VERSION = '0.2'; + const SCRIPT_VERSION = '1.0.0'; const CMD_TOKEN = '!choreograph'; // ========================================================================= @@ -46,7 +47,13 @@ var Choreograph = Choreograph || (() => { const EXT_PARAM_TYPES = {}; // { 'typeName': { name, description, parse, validate } } const EXT_LIFECYCLE = []; // [{ source, commands: [RegExp], start, stop, pause, resume }] const EXT_SYNC = []; // [{ source, commands: [RegExp], waiting: fn }] - const EXT_EXAMPLES = {}; // { 'name': { name, description, source, scene } } + + // Schedule help handout regeneration after extensions register + const scheduleHandoutRegen = () => { + if (typeof ScriptKit === 'undefined') return; + if (typeof ScriptKit.updateHandout !== 'function') return; + ScriptKit.updateHandout(SCRIPT_NAME, 'usr'); + }; const validIdent = (s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s); @@ -68,6 +75,7 @@ var Choreograph = Choreograph || (() => { return false; } EXT_FUNCTIONS[key] = Object.assign({ namespace, source: src, pure: true, description: '', args: [], returns: 'any', examples: [] }, struct); + scheduleHandoutRegen(); return true; }; @@ -89,6 +97,7 @@ var Choreograph = Choreograph || (() => { return false; } EXT_TOKEN_VARS[key] = Object.assign({ namespace, source: src, description: '' }, struct); + scheduleHandoutRegen(); return true; }; @@ -109,6 +118,7 @@ var Choreograph = Choreograph || (() => { return false; } EXT_PARAM_TYPES[name] = Object.assign({ source: src, description: '', validate: null }, struct); + scheduleHandoutRegen(); return true; }; @@ -130,6 +140,7 @@ var Choreograph = Choreograph || (() => { return false; } EXT_CONSTANTS[key] = Object.assign({ namespace, source: src, description: '', type: typeof value }, struct); + scheduleHandoutRegen(); return true; }; @@ -249,17 +260,8 @@ var Choreograph = Choreograph || (() => { * @param {object} struct - { name, description, scene } * scene: { notes, params, variables, rows } (same shape as parseScene output) */ - const registerExample = (sourceId, struct) => { - const src = sourceId || SCRIPT_NAME; - const { name, description = '', scene } = struct; - if (!name || !scene) { - log(`${SCRIPT_NAME}: [${src}] registerExample — missing name or scene`); - return false; - } - if (EXT_EXAMPLES[name]) return false; // no-op on duplicate - EXT_EXAMPLES[name] = { name, description, source: src, scene, onGenerate: struct.onGenerate || null }; - return true; - }; + + const generateExtensionHandout = (sourceId, opts = {}) => { const src = sourceId || SCRIPT_NAME; @@ -269,7 +271,6 @@ var Choreograph = Choreograph || (() => { if (!hh) { hh = createObj('handout', { name: handoutName, - inplayerjournals: 'all', archived: false, }); } @@ -330,11 +331,33 @@ var Choreograph = Choreograph || (() => { const replyError = (msg, text) => reply(msg, 'Error', text); + // CSV-style array parser: splits on commas, respects double-quoted segments + const parseCSV = (str) => { + if (!str) return []; + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + if (ch === '"' && (i === 0 || str[i - 1] !== '\\')) { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + result.push(current.trim()); + return result.filter(s => s.length > 0); + }; + const escHtml = (str) => String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); + const cellHtml = (str) => escHtml(str) || '
'; // ========================================================================= // Handout helpers @@ -374,7 +397,6 @@ var Choreograph = Choreograph || (() => { if (existing) return existing; return createObj('handout', { name: HandoutCache.handoutNametag(this.tag, name), - inplayerjournals: '', archived: false, }); }; @@ -440,40 +462,59 @@ var Choreograph = Choreograph || (() => { html += ``; // Parameter table + html += `

Parameters

`; html += ``; html += ``; html += ``; html += ``; html += ``; (scene.params || []).forEach(p => { - html += ``; - html += ``; - html += ``; - html += ``; + html += ``; + html += ``; + html += ``; + html += ``; }); html += `
NameTypeDefaultDescription
${escHtml(p.name)}${escHtml(p.type)}${escHtml(p.default || '')}${escHtml(p.description)}
${cellHtml(p.name)}${cellHtml(p.type)}${cellHtml(p.default || '')}${cellHtml(p.description)}
`; // Variables table + html += `

Variables

`; html += ``; html += ``; html += ``; (scene.variables || []).forEach(v => { - html += ``; - html += ``; + html += ``; + html += ``; }); html += `
VariableExpression
${escHtml(v.name)}${escHtml(v.expression)}
${cellHtml(v.name)}${cellHtml(v.expression)}
`; + // Roles table + if (scene.roles && scene.roles.length > 0) { + html += ``; + html += ``; + html += ``; + html += ``; + scene.roles.forEach(r => { + html += ``; + html += ``; + html += ``; + }); + html += `
RoleMinMax
${cellHtml(r.name)}${r.min != null ? r.min : '
'}
${r.max != null ? r.max : '
'}
`; + } + // Scene table + html += `

Scene

`; html += ``; html += ``; html += ``; + html += ``; html += ``; html += ``; (scene.rows || []).forEach(row => { - html += ``; - html += ``; - html += ``; - html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; }); html += `
FilterDelay (ms)WhenCommandNotes
${escHtml(row.filter)}${escHtml(row.delay)}${escHtml((row.commands || [row.command]).join('\n'))}${escHtml(row.notes)}
${cellHtml(row.filter)}${cellHtml(row.delay)}${cellHtml(row.when || '')}${cellHtml((row.commands || [row.command]).join('\n'))}${cellHtml(row.notes)}
`; @@ -487,6 +528,9 @@ var Choreograph = Choreograph || (() => { params: [ { name: 'cast', type: 'token[]', default: 'selected', description: 'Tokens to run the scene on (built-in)' }, ], + variables: [ + { name: '', expression: '' }, + ], rows: [ { filter: '*', delay: '0', commands: [], notes: 'Example row — add your command here' }, ], @@ -555,6 +599,7 @@ var Choreograph = Choreograph || (() => { const isParamTable = headers.includes('name') && headers.includes('type'); const isSceneTable = headers.includes('filter') && headers.some(h => h.startsWith('delay')); const isVarTable = headers.includes('variable') && headers.includes('expression'); + const isRoleTable = headers.includes('role') && headers.includes('min'); // Parse rows const rowRe = /]*>([\s\S]*?)<\/tr>/gi; @@ -571,6 +616,7 @@ var Choreograph = Choreograph || (() => { } if (isParamTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; scene.params.push({ name: cells[0] || '', type: cells[1] || 'text', @@ -578,13 +624,19 @@ var Choreograph = Choreograph || (() => { description: cells[3] || '', }); } else if (isVarTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; scene.variables.push({ name: cells[0] || '', expression: cells[1] || '', }); } else if (isSceneTable && cells.length >= 2) { + if (cells.every(c => !c)) continue; + // Detect column layout by headers + const whenIdx = headers.indexOf('when'); + const cmdIdx = whenIdx >= 0 ? whenIdx + 1 : 2; + const notesIdx = cmdIdx + 1; // Parse command cell: split on

boundaries for multi-command cells - const rawCmd = rawCells[2] || ''; + const rawCmd = rawCells[cmdIdx] || ''; const commands = rawCmd .replace(/<\/p>\s*]*>/gi, '\n') .replace(/<\/?p[^>]*>/gi, '') @@ -593,12 +645,21 @@ var Choreograph = Choreograph || (() => { .split('\n') .map(s => s.trim()) .filter(Boolean); - scene.rows.push({ + const row = { filter: cells[0] || '', delay: cells[1] || '0', commands: commands, - notes: cells[3] || '', - }); + notes: cells[notesIdx] || '', + }; + if (whenIdx >= 0 && cells[whenIdx]) row.when = cells[whenIdx]; + scene.rows.push(row); + } else if (isRoleTable && cells.length >= 1) { + if (cells.every(c => !c)) continue; + const role = { name: cells[0] || '' }; + if (cells[1]) role.min = parseInt(cells[1], 10) || undefined; + if (cells[2]) role.max = parseInt(cells[2], 10) || undefined; + if (!scene.roles) scene.roles = []; + scene.roles.push(role); } } }); @@ -709,6 +770,45 @@ var Choreograph = Choreograph || (() => { // { instanceId: { id, name, queue, timers, cast, params, state, startTime, firedCommands, remaining } } const runningScenes = {}; + // ---- Scene signals ---- + const sceneStartListeners = []; + const sceneFinishListeners = []; + + const emitSceneStart = (instance) => { + const info = { name: instance.name, instanceId: instance.id, cast: instance.cast, params: instance.params }; + sceneStartListeners.forEach(fn => { try { fn(info); } catch (e) { log(`${SCRIPT_NAME}: onSceneStart listener error: ${e}`); } }); + }; + const emitSceneFinish = (instance) => { + const info = { name: instance.name, instanceId: instance.id, cast: instance.cast, params: instance.params }; + sceneFinishListeners.forEach(fn => { try { fn(info); } catch (e) { log(`${SCRIPT_NAME}: onSceneFinish listener error: ${e}`); } }); + }; + + const onSceneStart = (fn) => { + sceneStartListeners.push(fn); + return () => { const i = sceneStartListeners.indexOf(fn); if (i >= 0) sceneStartListeners.splice(i, 1); }; + }; + const onSceneFinish = (fn) => { + sceneFinishListeners.push(fn); + return () => { const i = sceneFinishListeners.indexOf(fn); if (i >= 0) sceneFinishListeners.splice(i, 1); }; + }; + + const waitForScene = (sceneName) => ({ + onEnter: (ctx, advance) => { + ctx._waitFired = false; + const unsubStart = onSceneStart((info) => { + if (ctx._waitFired || info.name !== sceneName) return; + const unsubFinish = onSceneFinish((finishInfo) => { + if (ctx._waitFired || finishInfo.instanceId !== info.instanceId) return; + ctx._waitFired = true; + unsubStart(); + unsubFinish(); + setTimeout(() => advance(), 750); + }); + }); + }, + onExit: (ctx) => { ctx._waitFired = true; }, + }); + let instanceCounter = 0; const genInstanceId = () => `${SCRIPT_NAME}-${++instanceCounter}-${Date.now()}`; @@ -799,7 +899,9 @@ var Choreograph = Choreograph || (() => { const eqIdx = c.indexOf('='); if (eqIdx !== -1) { const key = c.slice(0, eqIdx).toLowerCase(); - const val = c.slice(eqIdx + 1); + const raw = c.slice(eqIdx + 1); + const val = (raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")) + ? raw.slice(1, -1) : raw; if (key === 'layer') return token.get('layer') === val; if (key === 'id') return token.get('id') === val; @@ -955,6 +1057,41 @@ var Choreograph = Choreograph || (() => { const wrapToken = (rawToken, ctx) => rawToken ? new TokenProxy(rawToken, ctx) : null; const wrapTokens = (arr, ctx) => arr.map(t => wrapToken(t, ctx)); + // LINQ-inspired enriched array — returned by cast(), role(), and token[] params + const itemId = (t) => { + if (typeof t === 'string' || typeof t === 'number') return t; + if (t && t._id) return t._id; + if (t && typeof t.get === 'function') return t.get('id'); + return t; + }; + + const enrichArray = (arr) => { + arr.from = (other) => { + const ids = new Set((other || []).map(itemId)); + return enrichArray(arr.filter(t => ids.has(itemId(t)))); + }; + arr.without = (other) => { + const ids = new Set((other || []).map(itemId)); + return enrichArray(arr.filter(t => !ids.has(itemId(t)))); + }; + arr.where = (fn) => enrichArray(arr.filter(fn)); + arr.select = (fn) => enrichArray(arr.map(fn)); + arr.orderBy = (attr) => { + if (typeof attr === 'function') return enrichArray([...arr].sort((a, b) => attr(a) - attr(b))); + return enrichArray([...arr].sort((a, b) => { + const av = a && typeof a === 'object' ? (a[attr] !== undefined ? a[attr] : (a.get ? a.get(attr) : 0)) : a; + const bv = b && typeof b === 'object' ? (b[attr] !== undefined ? b[attr] : (b.get ? b.get(attr) : 0)) : b; + return (av || 0) - (bv || 0); + })); + }; + arr.first = (n) => n === undefined ? arr[0] : enrichArray(arr.slice(0, n)); + arr.last = (n) => n === undefined ? arr[arr.length - 1] : enrichArray(arr.slice(-n)); + arr.any = (fn) => fn ? arr.some(fn) : arr.length > 0; + arr.count = (fn) => fn ? arr.filter(fn).length : arr.length; + arr.ids = () => enrichArray(arr.map(itemId)); + return arr; + }; + // ========================================================================= // Delay expression evaluation // ========================================================================= @@ -976,41 +1113,7 @@ var Choreograph = Choreograph || (() => { // actors(filter?) — returns tokens sorted by distance from current token // actor_ids(filter?) — returns token ID strings - // LINQ-inspired enriched array — returned by actors() and similar - // Get a comparable identity from any item (token ID, or the value itself) - const itemId = (t) => { - if (typeof t === 'string' || typeof t === 'number') return t; - if (t && t._id) return t._id; - if (t && typeof t.get === 'function') return t.get('id'); - return t; - }; - - const enrichArray = (arr) => { - arr.from = (other) => { - const ids = new Set((other || []).map(itemId)); - return enrichArray(arr.filter(t => ids.has(itemId(t)))); - }; - arr.without = (other) => { - const ids = new Set((other || []).map(itemId)); - return enrichArray(arr.filter(t => !ids.has(itemId(t)))); - }; - arr.where = (fn) => enrichArray(arr.filter(fn)); - arr.select = (fn) => enrichArray(arr.map(fn)); - arr.orderBy = (attr) => { - if (typeof attr === 'function') return enrichArray([...arr].sort((a, b) => attr(a) - attr(b))); - return enrichArray([...arr].sort((a, b) => { - const av = a && typeof a === 'object' ? (a[attr] !== undefined ? a[attr] : (a.get ? a.get(attr) : 0)) : a; - const bv = b && typeof b === 'object' ? (b[attr] !== undefined ? b[attr] : (b.get ? b.get(attr) : 0)) : b; - return (av || 0) - (bv || 0); - })); - }; - arr.first = (n) => n === undefined ? arr[0] : enrichArray(arr.slice(0, n)); - arr.last = (n) => n === undefined ? arr[arr.length - 1] : enrichArray(arr.slice(-n)); - arr.any = (fn) => fn ? arr.some(fn) : arr.length > 0; - arr.count = (fn) => fn ? arr.filter(fn).length : arr.length; - arr.ids = () => enrichArray(arr.map(itemId)); - return arr; - }; + // LINQ-inspired enriched array — uses module-level enrichArray/itemId const ctx = { tokens: filteredTokens, params }; @@ -1076,6 +1179,22 @@ var Choreograph = Choreograph || (() => { } }; + // General-purpose expression eval — preserves any return type + const evalExpr = (expr, scope) => { + if (!expr || !expr.trim()) return undefined; + const trimmed = expr.trim(); + const decls = Object.keys(scope).map(k => + `var ${k} = __scope["${k}"];` + ).join(' '); + try { + const __scope = scope; + return eval(decls + '(' + trimmed + ')'); + } catch(e) { + log(`${SCRIPT_NAME}: expression error: ${e.message} (expr: "${trimmed}")`); + return undefined; + } + }; + // ========================================================================= // Command template evaluation // ========================================================================= @@ -1165,10 +1284,48 @@ var Choreograph = Choreograph || (() => { if (p.type === 'token' && val && typeof val === 'string') { const obj = getObj('graphic', val); if (obj) val = wrapToken(obj, { tokens: cast, params: resolvedParams }); + } else if (p.type === 'token[]' && val && typeof val === 'string') { + val = enrichArray(parseCSV(val) + .map(id => getObj('graphic', id.trim())) + .filter(Boolean) + .map(obj => wrapToken(obj, { tokens: cast, params: resolvedParams }))); + } else if (p.type === 'path' && val && typeof val === 'string') { + val = getObj('path', val) || val; + } else if (p.type === 'path[]' && val && typeof val === 'string') { + val = enrichArray(parseCSV(val) + .map(id => getObj('path', id.trim())) + .filter(Boolean)); } resolvedParams[p.name] = val; }); + // Attach execution context for registered functions that need full cast access + resolvedParams.__ctx = { allTokens: cast, castData }; + + // Validate required params (no default = required) + const missingParams = scene.params + .filter(p => p.name !== 'cast' && !p.default && resolvedParams[p.name] == null) + .map(p => p.name); + if (missingParams.length > 0) { + const errMsg = `Missing required parameter(s): ${missingParams.join(', ')}`; + if (msg) replyError(msg, errMsg); + else log(`${SCRIPT_NAME}: ${errMsg}`); + return null; + } + + // Validate role constraints (min/max) + if (scene.roles && scene.roles.length > 0 && castData && castData.roles) { + for (const roleDef of scene.roles) { + const assigned = (castData.roles[roleDef.name] || []).length; + if (roleDef.min && assigned < roleDef.min) { + const errMsg = `Role "${roleDef.name}" requires at least ${roleDef.min} token(s) (got ${assigned}).`; + if (msg) replyError(msg, errMsg); + else log(`${SCRIPT_NAME}: ${errMsg}`); + return null; + } + } + } + // Precompute variables per token const tokenVars = {}; if (scene.variables && scene.variables.length > 0) { @@ -1179,7 +1336,7 @@ var Choreograph = Choreograph || (() => { const vars = {}; scene.variables.forEach(v => { if (!v.name || !v.expression) return; - scope[v.name] = evalDelay(v.expression, scope); + scope[v.name] = evalExpr(v.expression, scope); vars[v.name] = scope[v.name]; }); tokenVars[token.get('id')] = vars; @@ -1188,9 +1345,53 @@ var Choreograph = Choreograph || (() => { // For each row, evaluate filter on all cast, then compute delays scene.rows.forEach((row, rowIndex) => { - // Check for sync delay — only one sync entry per row + // Check for sync delay — creates a chunk boundary if (row.delay.trim().toLowerCase() === 'sync') { queue.push({ time: -1, rowIndex, isSync: true }); + // If the sync row has commands, queue them to fire after the sync resolves (time 0 in next chunk) + const commands = row.commands || [row.command]; + const hasCommand = commands.some(c => c && c.trim()); + if (hasCommand) { + // Filter cast for this row + const filtered = cast.filter(token => { + const filterScope = buildTokenScope(token, cast, resolvedParams); + Object.assign(filterScope, resolvedParams); + Object.assign(filterScope, tokenVars[token.get('id')] || {}); + return evalFilter(row.filter, token, castData, filterScope); + }); + filtered.forEach(token => { + const scope = buildTokenScope(token, filtered, resolvedParams); + Object.assign(scope, resolvedParams); + Object.assign(scope, tokenVars[token.get('id')] || {}); + const tokenProxy = wrapToken(token, { tokens: filtered, params: resolvedParams }); + scope.token = tokenProxy; + scope.tokenId = token.get('id'); + scope.tokenName = token.get('name') || ''; + scope.pageId = token.get('_pageid'); + scope.self = scene.name; + scope.castName = (runtimeOpts && runtimeOpts.castName) || ''; + scope.__parent = instanceId; + scope.__depth = Math.max(0, ((runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10) - 1); + + // Evaluate 'when' condition + if (row.when) { + try { + const decls = Object.keys(scope).map(k => `var ${k} = __scope["${k}"];`).join(' '); + const __scope = scope; + if (!eval(decls + '(' + row.when + ')')) return; + } catch(e) { + log(`${SCRIPT_NAME}: when expression error: ${e.message} (expr: "${row.when}")`); + return; + } + } + + commands.forEach(cmdTemplate => { + const command = evalCommand(cmdTemplate, scope); + if (!command) return; + queue.push({ time: 0, rowIndex, tokenId: token.get('id'), command }); + }); + }); + } return; } @@ -1218,12 +1419,25 @@ var Choreograph = Choreograph || (() => { scope.tokenName = token.get('name') || ''; scope.pageId = token.get('_pageid'); scope.self = scene.name; + scope.castName = (runtimeOpts && runtimeOpts.castName) || ''; scope.__parent = instanceId; scope.__depth = Math.max(0, ((runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10) - 1); const delay = evalDelay(row.delay, scope); if (!isFinite(delay)) return; // INF/SKIP + // Evaluate 'when' condition — skip if false + if (row.when) { + try { + const decls = Object.keys(scope).map(k => `var ${k} = __scope["${k}"];`).join(' '); + const __scope = scope; + if (!eval(decls + '(' + row.when + ')')) return; + } catch(e) { + log(`${SCRIPT_NAME}: when expression error: ${e.message} (expr: "${row.when}")`); + return; + } + } + const commands = row.commands || [row.command]; commands.forEach(cmdTemplate => { const command = evalCommand(cmdTemplate, scope); @@ -1258,6 +1472,8 @@ var Choreograph = Choreograph || (() => { queue, timers: [], cast, + castName: (runtimeOpts && runtimeOpts.castName) || null, + castData: castData || null, params: resolvedParams, state: 'running', startTime: Date.now(), @@ -1270,6 +1486,7 @@ var Choreograph = Choreograph || (() => { depth: (runtimeOpts && runtimeOpts.depth !== undefined) ? runtimeOpts.depth : 10, }; runningScenes[instanceId] = instance; + emitSceneStart(instance); // Register as child of parent if (instance.parentId && runningScenes[instance.parentId]) { @@ -1280,6 +1497,24 @@ var Choreograph = Choreograph || (() => { const finishScene = () => { const loop = instance.loop; if (!loop) { + // Show completion card (only for top-level scenes) + if (instance.playerid !== 'API' && !instance.parentId) { + setTimeout(() => { + const sceneName = instance.name; + const sceneHandout = scenes().find(sceneName); + const openLink = sceneHandout ? ` [open]` : ''; + const castIdStr = (instance.cast || []).map(t => t.get ? t.get('id') : t).join(' '); + const castFlag = instance.castName ? ` --cast ${instance.castName}` : ''; + let card = `

`; + card += `${escHtml(sceneName)}${openLink} — Finished

`; + card += btnHtml('▶ Replay', `${CMD_TOKEN} run ${sceneName} ignore-selected${castFlag} --id ${castIdStr}`); + card += btnHtml('🔁 Loop', `${CMD_TOKEN} run ${sceneName} --loop ignore-selected${castFlag} --id ${castIdStr}`); + card += `
`; + const fakeMsg = { who: instance.who, playerid: instance.playerid }; + reply(fakeMsg, 'Choreograph', card, true); + }, 500); + } + emitSceneFinish(instance); delete runningScenes[instanceId]; return; } @@ -1302,6 +1537,24 @@ var Choreograph = Choreograph || (() => { executeChunk(0); } } else { + // Loops exhausted — show completion card (only for top-level scenes) + if (instance.playerid !== 'API' && !instance.parentId) { + setTimeout(() => { + const sceneName = instance.name; + const sceneHandout = scenes().find(sceneName); + const openLink = sceneHandout ? ` [open]` : ''; + const castIdStr = (instance.cast || []).map(t => t.get ? t.get('id') : t).join(' '); + const castFlag = instance.castName ? ` --cast ${instance.castName}` : ''; + let card = `
`; + card += `${escHtml(sceneName)}${openLink} — Finished

`; + card += btnHtml('▶ Replay', `${CMD_TOKEN} run ${sceneName} ignore-selected${castFlag} --id ${castIdStr}`); + card += btnHtml('🔁 Loop', `${CMD_TOKEN} run ${sceneName} --loop ignore-selected${castFlag} --id ${castIdStr}`); + card += `
`; + const fakeMsg = { who: instance.who, playerid: instance.playerid }; + reply(fakeMsg, 'Choreograph', card, true); + }, 500); + } + emitSceneFinish(instance); delete runningScenes[instanceId]; } }; @@ -1375,6 +1628,9 @@ var Choreograph = Choreograph || (() => { if (msg.type !== 'api') return; if (msg.content.split(' ')[0] !== CMD_TOKEN) return; + // Delegate to ScriptKit framework for examples/guide commands + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + // Permission check — GM or API always allowed if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') { replyError(msg, 'Only the GM can use Choreograph commands.'); @@ -1406,34 +1662,10 @@ var Choreograph = Choreograph || (() => { args.push(tok); }); - // ---- help / --help ---- - if (cmd === 'help' || cmd === '--help') { - reply(msg, SCRIPT_NAME, `${SCRIPT_NAME} v${SCRIPT_VERSION}

` - + `Scene commands:
` - + `${CMD_TOKEN} run <name> [flags] — execute a scene
` - + `${CMD_TOKEN} new <name> — create blank scene
` - + `${CMD_TOKEN} list [query] — list scenes
` - + `${CMD_TOKEN} edit <name> — open handout
` - + `${CMD_TOKEN} delete <name> — delete scene
` - + `${CMD_TOKEN} refresh <name> — regenerate handout
` - + `${CMD_TOKEN} add-row <name> — add blank row

` - + `Playback:
` - + `${CMD_TOKEN} stop [name] — stop scene(s)
` - + `${CMD_TOKEN} pause [name] — pause scene(s)
` - + `${CMD_TOKEN} resume [name] — resume scene(s)
` - + `${CMD_TOKEN} status — show running scenes

` - + `Cast:
` - + `${CMD_TOKEN} cast add/remove/list/show/delete

` - + `Help:
` - + `${CMD_TOKEN} man [topic] — detailed help by topic
` - + `${CMD_TOKEN} gen-dev-docs — generate extension guide
`); - return; - } - // ---- new ---- if (cmd === 'new') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph new '); return; } + if (!name) { ScriptKit.usage(msg, 'new', 'Missing scene name'); return; } if (scenes().find(name)) { replyError(msg, `A scene named "${name}" already exists.`); return; @@ -1476,7 +1708,7 @@ var Choreograph = Choreograph || (() => { // ---- edit ---- if (cmd === 'edit') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph edit '); return; } + if (!name) { ScriptKit.usage(msg, 'edit', 'Missing scene name'); return; } const handout = scenes().find(name); if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } reply(msg, 'Choreograph', @@ -1488,7 +1720,7 @@ var Choreograph = Choreograph || (() => { // ---- delete ---- if (cmd === 'delete') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph delete '); return; } + if (!name) { ScriptKit.usage(msg, 'delete', 'Missing scene name'); return; } if (!flags.has('force')) { reply(msg, 'Choreograph', `Delete scene "${escHtml(name)}"? ` @@ -1569,7 +1801,7 @@ var Choreograph = Choreograph || (() => { // ---- refresh ---- if (cmd === 'refresh') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph refresh '); return; } + if (!name) { ScriptKit.usage(msg, 'refresh', 'Missing scene name'); return; } const handout = scenes().find(name); if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } delete scenes().cache[name]; @@ -1585,7 +1817,7 @@ var Choreograph = Choreograph || (() => { // ---- add-row ---- if (cmd === 'add-row') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph add-row '); return; } + if (!name) { ScriptKit.usage(msg, 'add-row', 'Missing scene name'); return; } const handout = scenes().find(name); if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } delete scenes().cache[name]; @@ -1602,7 +1834,7 @@ var Choreograph = Choreograph || (() => { // ---- dump-html ---- if (cmd === 'dump-html') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph dump-html '); return; } + if (!name) { ScriptKit.usage(msg, 'dump-html', 'Missing scene name'); return; } const handout = scenes().find(name); if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } getHandoutNotes(handout, (html) => { @@ -1617,10 +1849,25 @@ var Choreograph = Choreograph || (() => { return; } + // Helper: parse --role flags from message content and merge into castData + const mergeRoleFlags = (content, castData, castIds) => { + const roleRegex = /--role\s+(\S+)((?:\s+-(?!-)[A-Za-z0-9_-]+)+)/g; + let roleMatch; + while ((roleMatch = roleRegex.exec(content)) !== null) { + const roleName = roleMatch[1]; + const roleIds = roleMatch[2].trim().split(/\s+/).filter(Boolean); + if (!castData.roles[roleName]) castData.roles[roleName] = []; + roleIds.forEach(id => { + castData.roles[roleName].push(id); + castIds.push(id); + }); + } + }; + // ---- run ---- if (cmd === 'run') { const name = args[0]; - if (!name) { replyError(msg, 'Usage: !choreograph run '); return; } + if (!name) { ScriptKit.usage(msg, 'run', 'Missing scene name'); return; } const handout = scenes().find(name); if (!handout) { replyError(msg, `No scene named "${name}" found.`); return; } @@ -1668,12 +1915,24 @@ var Choreograph = Choreograph || (() => { return; } - const knownFlags = new Set(['id', 'force', 'loop', 'depth', 'page', 'cast', 'sync', 'sync-timeout']); + const knownFlags = new Set(['id', 'force', 'loop', 'depth', 'page', 'cast', 'sync', 'sync-timeout', 'role', 'parent']); const params = {}; Object.entries(opts).forEach(([k, v]) => { if (!knownFlags.has(k) && typeof v === 'string') params[k] = v; }); + // Enforce max on roles + if (scene.roles && castData && castData.roles) { + scene.roles.forEach(roleDef => { + if (roleDef.max && castData.roles[roleDef.name]) { + const arr = castData.roles[roleDef.name]; + if (arr.length > roleDef.max) { + castData.roles[roleDef.name] = arr.slice(-roleDef.max); + } + } + }); + } + // Parse loop options let loopOpts = null; if (flags.has('loop')) { @@ -1693,15 +1952,27 @@ var Choreograph = Choreograph || (() => { parent: opts.parent || null, depth: opts.depth !== undefined ? parseInt(opts.depth, 10) : 10, syncTimeout: opts['sync-timeout'] ? parseInt(opts['sync-timeout'], 10) : 30000, + castName: opts.cast || null, }; - const instanceId = executeScene(scene, cast, params, msg, castData || null, loopOpts, runtimeOpts); + if (!instanceId) return; // scene failed to start (missing params, role constraints, etc.) const inst = runningScenes[instanceId]; const iName = inst ? inst.instanceName : instanceId; - // Only show status card for user-initiated runs - if (msg.playerid !== 'API') { + // Only show status card for user-initiated runs (not children/recursive) + if (msg.playerid !== 'API' && !runtimeOpts.parent) { + const sceneHandout = scenes().find(name); + const openLink = sceneHandout ? ` [open]` : ''; + let castInfo = ''; + if (inst && inst.castName) { + const castHandout = casts().find(inst.castName); + castInfo = castHandout + ? ` — ${escHtml(inst.castName)} [open]` + : ` — ${escHtml(inst.castName)}`; + } + const looseCt = (inst && inst.castName) ? 0 : cast.length; + if (looseCt > 0 && !(inst && inst.castName)) castInfo = ` — ${looseCt} token(s)`; let card = `
`; - card += `${escHtml(name)} — ${cast.length} token(s)
`; + card += `${escHtml(name)}${openLink}${castInfo}
`; card += `Instance: ${escHtml(iName)}

`; card += btnHtml('⏸ Pause', `${CMD_TOKEN} pause ${iName}`); card += btnHtml('⏹ Stop', `${CMD_TOKEN} stop ${iName}`); @@ -1719,8 +1990,17 @@ var Choreograph = Choreograph || (() => { return; } getAllCastIds(castData).forEach(id => castIds.push(id)); + // Merge --role into loaded cast if present + if (opts.role) { + mergeRoleFlags(msg.content, castData, castIds); + } runWithCast(castData); }); + } else if (opts.role) { + // --role — build ephemeral castData + const roleData = { roles: {} }; + mergeRoleFlags(msg.content, roleData, castIds); + runWithCast(roleData); } else { runWithCast(null); } @@ -1749,7 +2029,7 @@ var Choreograph = Choreograph || (() => { } if (subCmd === 'show') { - if (!castName) { replyError(msg, 'Usage: !choreograph cast show '); return; } + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } casts().load(castName, (cast) => { if (!cast) { replyError(msg, `No cast named "${castName}" found.`); return; } let out = `Cast: ${escHtml(castName)}
`; @@ -1767,7 +2047,7 @@ var Choreograph = Choreograph || (() => { } if (subCmd === 'add') { - if (!castName) { replyError(msg, 'Usage: !choreograph cast add [--role ]'); return; } + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } const role = opts.role || ''; // Gather IDs from selection + --id + remaining args const ids = []; @@ -1798,7 +2078,7 @@ var Choreograph = Choreograph || (() => { } if (subCmd === 'remove') { - if (!castName) { replyError(msg, 'Usage: !choreograph cast remove [--role ]'); return; } + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } const role = opts.role; // Gather IDs to remove const ids = []; @@ -1838,7 +2118,7 @@ var Choreograph = Choreograph || (() => { } if (subCmd === 'delete') { - if (!castName) { replyError(msg, 'Usage: !choreograph cast delete '); return; } + if (!castName) { ScriptKit.usage(msg, 'cast', 'Missing cast name'); return; } if (!flags.has('force')) { reply(msg, 'Cast', `Delete cast "${escHtml(castName)}"? ` @@ -1854,67 +2134,12 @@ var Choreograph = Choreograph || (() => { return; } - replyError(msg, 'Usage: !choreograph cast [name] [options]'); + ScriptKit.usage(msg, 'cast', 'Unknown cast subcommand'); return; } // ---- example ---- - if (cmd === 'example' || cmd === 'examples') { - const exName = args[0]; - if (!exName || exName === 'list') { - const examples = Object.values(EXT_EXAMPLES); - if (examples.length === 0) { - reply(msg, 'Examples', 'No examples registered.'); - return; - } - let out = `${examples.length} example(s) available:
`; - examples.forEach(ex => { - const sceneName = `example-${ex.name}`; - const exists = scenes().find(sceneName); - out += `• ${escHtml(ex.name)}`; - if (ex.description) out += ` — ${escHtml(ex.description)}`; - out += ` [${escHtml(ex.source)}] `; - out += btnHtml(exists ? '🔄 Regen' : '+ Generate', `${CMD_TOKEN} example ${ex.name}`); - if (exists) { - out += btnHtml('▶ Run', `${CMD_TOKEN} run ${sceneName}`); - out += ` [Open]`; - } - out += `
`; - }); - reply(msg, 'Examples', out); - return; - } - - const ex = EXT_EXAMPLES[exName]; - if (!ex) { - replyError(msg, `No example named "${exName}". Use ${CMD_TOKEN} example list.`); - return; - } - - // Generate the scene handout - const sceneName = `example-${exName}`; - const scene = Object.assign({ name: sceneName }, ex.scene); - // Ensure cast param - if (!scene.params) scene.params = []; - if (!scene.params.find(p => p.name === 'cast')) { - scene.params.unshift({ name: 'cast', type: 'token[]', default: 'selected', description: 'Tokens to run the scene on (built-in)' }); - } - if (!scene.variables) scene.variables = []; - if (!scene.rows) scene.rows = []; - const handout = scenes().getOrCreate(sceneName); - setHandoutNotes(handout, generateSceneHtml(sceneName, scene)); - scenes().cache[sceneName] = scene; - - // Call onGenerate hook if provided (e.g. to set up recordings) - if (typeof ex.onGenerate === 'function') ex.onGenerate(sceneName); - - reply(msg, 'Examples', - `Generated example scene "${escHtml(sceneName)}". ` - + `[Open Handout] ` - + btnHtml('▶ Run', `${CMD_TOKEN} run ${sceneName}`)); - return; - } // ---- status ---- if (cmd === 'status') { @@ -1934,367 +2159,6 @@ var Choreograph = Choreograph || (() => { return; } - // ---- man ---- - if (cmd === 'man') { - const topic = args[0] || ''; - - if (!topic) { - reply(msg, 'Man', 'Choreograph Help Topics:
' - + '• filters — filter syntax
' - + '• delay — delay expressions and functions
' - + '• commands — command template syntax
' - + '• cast — cast system
' - + '• sync — sync system
' - + '• loop — looping
' - + '• chain — scene chaining and recursion
' - + '• params — parameter types
' - + '• vars — variables and scope
' - + '• api — extension API
' - + '• func — registered functions
' - + '• tokenvar — registered token variables
' - + '• const — registered constants
'); - return; - } - - const c = (t) => `${t}`; - - if (topic === 'filters') { - reply(msg, 'Man', 'Filters
' - + `${c('*')} all tokens
` - + `${c('layer=X')} on layer X
` - + `${c('name=X*')} name glob
` - + `${c('id=-ABC')} specific ID
` - + `${c('role=X')} cast role
` - + `${c('status=X')} has status marker
` - + `${c('!prefix')} negation
` - + 'Space-separated = AND. Multiple rows = OR. Empty = no match.'); - return; - } - - if (topic === 'delay') { - reply(msg, 'Man', 'Delay Expressions
' - + 'Return: number (ms), INF/SKIP, or sync.

' - + 'Variables: ' + TOKEN_VAR_DEFS.filter(d => d.namespace === 'core').map(d => d.name).join(', ') + ', self, plus params/computed vars.
' - + 'Constants: ' + Object.values(EXT_CONSTANTS).filter(r => r.namespace === 'core').map(r => r.name).join(', ') + '
' - + 'Functions: ' + Object.values(EXT_FUNCTIONS).filter(r => r.namespace === 'core').map(r => r.name + '()').join(', ') + '
' - + 'Functions:
' - + `${c('rank("attr")')} — sort position in filtered set
` - + `${c('distance(x, y)')} — pixel distance (or ${c('distance(orig)')})
` - + `${c('propagate(dist, speed)')} — dist / speed
` - + `${c('stagger(rank, interval)')} — rank × interval
` - + `${c('rand(min, max)')} — random number
` - + `${c('randInt(min, max)')} — random integer
` - + `${c('clamp(v, lo, hi)')} — clamp
` - + `${c('actors(filter?)')} — tokens sorted by distance
` - + `${c('actor_ids(filter?)')} — token IDs sorted by distance
` - + '
Constants: PI, TAU'); - return; - } - - if (topic === 'commands' || topic === 'templates') { - reply(msg, 'Man', 'Command Templates
' - + `Use ${c('${expr}')} for substitutions. Evaluated as JS template literals.

` - + `Example: ${c('!sequence play ${anim} ignore-selected ${token.id}')}
` - + `Conditional: ${c('${counter > 1 ? "!choreograph run " + self : ""}')}

` - + 'All variables, params, computed variables, and functions are in scope.'); - return; - } - - if (topic === 'cast') { - reply(msg, 'Man', 'Cast System
' - + `Stored in ${c('[Cast] ')} handouts with roles.

` - + `${c('!choreograph cast add [--role R]')} — add tokens
` - + `${c('!choreograph cast remove [--role R]')} — remove
` - + `${c('!choreograph cast list')} / ${c('show')} / ${c('delete')}

` - + `Use ${c('--cast ')} in run. Filter with ${c('role=X')}.`); - return; - } - - if (topic === 'sync') { - reply(msg, 'Man', 'Sync
' - + `Use ${c('sync')} as a delay value. Waits for all registered sync participants to signal completion before continuing.

` - + 'Useful for gating recursion or phase transitions on animation completion.'); - return; - } - - if (topic === 'loop') { - reply(msg, 'Man', 'Looping
' - + `${c('--loop')} — infinite, sync each cycle
` - + `${c('--loop N')} — N times, immediate restart
` - + `${c('--loop N --sync')} — N times, sync between cycles

` - + 'Top-level only. Children cannot loop. Expressions re-evaluate each cycle.'); - return; - } - - if (topic === 'chain' || topic === 'recursion') { - reply(msg, 'Man', 'Scene Chaining
' - + `${c('self')} resolves to current scene name.
` - + `${c('--parent')} and ${c('--depth')} are auto-injected.
` - + `At depth 0, child spawns are skipped.
` - + `Children cannot use ${c('--loop')}.

` - + `Example: ${c('!choreograph run ${self} --counter ${counter - 1}')}`); - return; - } - - if (topic === 'params' || topic === 'parameters') { - reply(msg, 'Man', 'Parameter Types
' - + 'number, text, boolean, token, path, sequence, scene, role
' - + 'Append [] for arrays (e.g. token[], number[]).

' - + `${c('cast')} is built-in (token[], default: selected).
` - + 'Params without defaults are required at run time.'); - return; - } - - if (topic === 'vars' || topic === 'variables') { - reply(msg, 'Man', 'Variables
' - + 'Defined in the Variables table (Variable | Expression).
' - + 'Computed once per token before execution.
' - + 'Later variables can reference earlier ones.
' - + 'Available in all delay expressions and command templates.'); - return; - } - - if (topic === 'api' || topic === 'extension') { - reply(msg, 'Man', 'Extension API
' - + `${c('Choreograph.registerFunction(src, struct)')}
` - + `${c('Choreograph.registerTokenVariable(src, struct)')}
` - + `${c('Choreograph.registerConstant(src, struct)')}
` - + `${c('Choreograph.registerParameterType(src, struct)')}
` - + `${c('Choreograph.registerLifecycleHook(src, struct)')}
` - + `${c('Choreograph.registerSyncParticipant(src, struct)')}
` - + `${c('Choreograph.generateExtensionHandout(src, opts)')}

` - + 'Run !choreograph gen-dev-docs for the full developer guide.'); - return; - } - - if (topic === 'func' || topic === 'functions') { - const regs = Object.values(EXT_FUNCTIONS); - if (regs.length === 0) { reply(msg, 'Man', 'No functions registered.'); return; } - let out = `Registered Functions (${regs.length}):
`; - regs.forEach(r => { - const ns = r.namespace === 'core' ? '' : `${escHtml(r.namespace)}.`; - const argList = (r.args || []).map(a => a.name).join(', '); - const purity = r.pure === false ? ' [unstable]' : ''; - out += `${ns}${escHtml(r.name)}(${argList})${escHtml(r.returns || 'any')}${purity}
`; - if (r.description) out += `${escHtml(r.description)}
`; - out += '
'; - }); - reply(msg, 'Man', out); - return; - } - - if (topic === 'tokenvar' || topic === 'tokenvars') { - const regs = Object.values(EXT_TOKEN_VARS); - if (regs.length === 0) { reply(msg, 'Man', 'No token variables registered.'); return; } - let out = `Registered Token Variables (${regs.length}):
`; - regs.forEach(r => { - const ns = r.namespace === 'core' ? '' : `${escHtml(r.namespace)}.`; - out += `${ns}${escHtml(r.name)}`; - if (r.description) out += ` — ${escHtml(r.description)}`; - out += '
'; - }); - reply(msg, 'Man', out); - return; - } - - if (topic === 'const' || topic === 'constants') { - const regs = Object.values(EXT_CONSTANTS); - if (regs.length === 0) { reply(msg, 'Man', 'No constants registered.'); return; } - let out = `Registered Constants (${regs.length}):
`; - regs.forEach(r => { - const ns = r.namespace === 'core' ? '' : `${escHtml(r.namespace)}.`; - out += `${ns}${escHtml(r.name)} = ${escHtml(String(r.value))}`; - if (r.description) out += ` — ${escHtml(r.description)}`; - out += '
'; - }); - reply(msg, 'Man', out); - return; - } - - replyError(msg, `Unknown topic "${topic}". Use !choreograph man for a list.`); - return; - } - - // ---- gen-dev-docs ---- - if (cmd === 'gen-dev-docs') { - const handoutName = `Help: ${SCRIPT_NAME}/Extending Choreograph`; - let hh = findObjs({ type: 'handout', name: handoutName })[0]; - if (!hh) { - hh = createObj('handout', { name: handoutName, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); - } - - const h = (n, t) => `${t}`; - const p = (t) => `

${t}

`; - const c = (t) => `${t}`; - const b = (t) => `${t}`; - const li = (t) => `
  • ${t}
  • `; - const ul = (...items) => `
      ${items.join('')}
    `; - const pre = (t) => `
    ${t}
    `; - - let html = ''; - html += h(1, 'Extending Choreograph'); - html += p('Guide for script developers adding custom functions, variables, and integrations to Choreograph.'); - - html += h(2, 'Signal Pattern'); - html += p(`Choreograph emits ${c('!choreograph-ready')} on startup. Register in response:`); - html += pre( -`on('chat:message', (msg) => { - if (msg.content === '!choreograph-ready') doRegister(); -}); -// Also register immediately if already loaded: -if (typeof Choreograph !== 'undefined') doRegister();`); - - html += h(2, 'registerFunction(sourceId, struct)'); - html += p('Add a function to the delay/filter/command expression scope.'); - html += pre( -`Choreograph.registerFunction('MyScript', { - name: 'inRange', - namespace: 'mymod', - description: 'Check if token is within range of a point', - args: [{ name: 'range', type: 'number' }], - returns: 'boolean', - pure: true, // default true; false for impure/stateful - fn: (token, filteredTokens, params, range) => { - // token = current Roll20 graphic - // filteredTokens = tokens passing the current row filter - // params = resolved scene parameters - return someCheck(token, range); - }, -});`); - - html += h(2, 'registerTokenVariable(sourceId, struct)'); - html += p('Add a per-token variable. Appears as a getter on TokenProxy objects.'); - html += p('Namespace determines access path: ' + c('token.dnd.hp') + ' for namespace ' + c('"dnd"') + ', or ' + c('token.hp') + ' for namespace ' + c('"core"') + '.'); - html += pre( -`Choreograph.registerTokenVariable('MyScript', { - name: 'hp', - namespace: 'dnd', - description: 'Current hit points from bar1', - evaluation: 'lazy', // 'eager' | 'lazy' | 'computed' - returns: 'number', // 'token' or 'token[]' for auto-wrapping - fn: (token, ctx) => parseInt(token.get('bar1_value')) || 0, - // ctx: { tokens, params } -}); - -// Evaluation modes: -// eager — computed once upfront for all tokens (default for core vars) -// lazy — computed on first access, cached (default for extensions) -// computed — re-evaluated every access (no cache)`); - - html += h(2, 'registerFunction(sourceId, struct)'); - html += p('Add a function to the expression scope. Namespace determines access: ' - + c('dnd.roll()') + ' for namespace ' + c('"dnd"') + ', or ' + c('roll()') + ' for ' + c('"core"') + '.'); - html += p('Functions with ' + c('returns: "token"') + ' or ' + c('"token[]"') + ' auto-wrap results as TokenProxy/enriched arrays.'); - html += pre( -`Choreograph.registerFunction('MyScript', { - name: 'allies', - namespace: 'dnd', - description: 'Tokens on same team', - returns: 'token[]', // auto-wrapped as enriched TokenProxy array - pure: true, - fn: (token, filteredTokens, params) => { - // Return raw Roll20 objects — they get auto-wrapped - return filteredTokens.filter(t => t.get('bar3_value') === token.get('bar3_value')); - }, -});`); - - html += h(2, 'TokenProxy & LINQ Arrays'); - html += p('All tokens in scope are wrapped as TokenProxy objects with getters for registered token variables. ' - + 'Extension namespaces appear as sub-objects: ' + c('token.dnd.hp') + '.'); - html += p('Arrays returned by functions with ' + c('returns: "token[]"') + ' are enriched with LINQ-inspired methods:'); - html += ul( - li(c('.from(other)') + ' — intersection'), - li(c('.without(other)') + ' — exclusion'), - li(c('.where(fn)') + ' — filter alias'), - li(c('.select(fn)') + ' — map alias'), - li(c('.orderBy(attr)') + ' — sort by attribute or function'), - li(c('.first(n?)') + ' / ' + c('.last(n?)') + ' — first/last element(s)'), - li(c('.any(fn?)') + ' — existence check'), - li(c('.count(fn?)') + ' — count'), - li(c('.ids()') + ' — get ID strings') - ); - - html += h(2, 'registerConstant(sourceId, struct)'); - html += p('Add a named constant to the expression scope.'); - html += pre( -`Choreograph.registerConstant('MyScript', { - name: 'GRID_SIZE', - namespace: 'mymod', - value: 70, - description: 'Grid square size in pixels', -});`); - - html += h(2, 'registerParameterType(sourceId, struct)'); - html += p('Add a custom parameter type for scene handouts.'); - html += pre( -`Choreograph.registerParameterType('MyScript', { - name: 'character', - description: 'A Roll20 character by name or ID', - parse: (rawValue) => { - const char = findObjs({type:'character', name:rawValue})[0]; - if (!char) throw new Error('Character not found: ' + rawValue); - return char; - }, - validate: (rawValue) => null, // return error string or null -});`); - - html += h(2, 'registerLifecycleHook(sourceId, struct)'); - html += p('React to scene lifecycle events. ' + c('commands') + ' filters which fired commands trigger your hooks. Source-deduplicated — same sourceId cannot register twice.'); - html += pre( -`Choreograph.registerLifecycleHook('MyScript', { - commands: [/^!myscript\\b/], - start: (ctx) => { /* msg-shaped context — pass to your handleInput */ }, - stop: (ctx) => { /* same shape */ }, - pause: (ctx) => { /* same shape */ }, - resume: (ctx) => { /* same shape */ }, -}); - -// ctx shape (msg-shaped with sceneInfo): -// { -// type: 'api', -// content: '!myscript ...', -// who: 'PlayerName (GM)', -// playerid: '-ABC123', -// selected: [{ _id, _type }], -// sceneInfo: { instanceId, sceneName, instanceName }, -// }`); - html += p(`The ${c('start')} hook receives commands directly (bypassing sendChat). The context is msg-shaped so you can pass it directly to your command handler. ${c('sceneInfo.instanceId')} enables correlation with stop/pause/resume events.`); - - html += h(2, 'registerSyncParticipant(sourceId, struct)'); - html += p('Participate in sync resolution. Only called when fired commands match your patterns. Source-deduplicated.'); - html += pre( -`Choreograph.registerSyncParticipant('MyScript', { - commands: [/^!myscript\\b/], - waiting: (ctx) => { - // ctx.entries — array of msg-shaped contexts (filtered to your commands) - // ctx.sceneInfo — { instanceId, sceneName, instanceName } - // ctx.done() — call when finished (idempotent) - setTimeout(() => ctx.done(), 1000); - }, -});`); - html += p(`${c('done()')} is idempotent — safe to call multiple times. Sync times out after 30s by default. Each participant only receives entries matching their registered command patterns.`); - - html += h(2, 'generateExtensionHandout(sourceId, opts)'); - html += p('Generate a help handout documenting your registered items.'); - html += pre( -`Choreograph.generateExtensionHandout('MyScript', { - name: 'My Extension', - description: 'Adds DnD-specific features.', - sections: [{ namespace: 'dnd', description: '...' }], -});`); - - html += h(2, 'Introspection'); - html += ul( - li(`${c('Choreograph.getFunction(key)')} — ${c("'namespace/name'")} or null`), - li(`${c('Choreograph.getVariable(key)')} — or null`), - li(`${c('Choreograph.getConstant(key)')} — or null`), - li(`${c('Choreograph.getParameterType(name)')} — or null`) - ); - - hh.set('notes', html); - reply(msg, 'Choreograph', `Generated ${b('Help: Choreograph/Extending Choreograph')} — check your journal.`); - return; - } // ---- echo (debug/test) ---- if (cmd === 'echo') { @@ -2304,81 +2168,83 @@ if (typeof Choreograph !== 'undefined') doRegister();`); return; } - // ---- ping ---- - // Usage: !choreograph ping [pageId] [moveAll] - // Or with selected token: !choreograph ping (pings selected token location) - if (cmd === 'ping') { - let x, y, pageId, moveAll = false; - if (args.length >= 2) { - x = parseFloat(args[0]); - y = parseFloat(args[1]); - pageId = args[2] || Campaign().get('playerpageid'); - moveAll = args[3] === 'true'; - } else if (msg.selected && msg.selected.length > 0) { - const tok = getObj('graphic', msg.selected[0]._id); - if (tok) { x = tok.get('left'); y = tok.get('top'); pageId = tok.get('_pageid'); } - } - if (x !== undefined && y !== undefined) { - sendPing(x, y, msg.playerid, pageId, moveAll); - } - return; - } - // ---- fx ---- - // Usage: !choreograph fx [pageId] - // Or with selected: !choreograph fx (at selected token location) + // Usage: !choreograph fx [ ] [pageId] + // Or: !choreograph fx [] + // Or with selected: !choreograph fx (one or two tokens selected) + // + // Auto-detects spawnFx vs spawnFxBetweenPoints based on: + // 1. If only one point/token provided → always spawnFx + // 2. If prefab type → hardcoded table determines between-points + // 3. If custom FX (by ID or name) → checks definition.angle === -1 if (cmd === 'fx') { - const fxType = args[0]; - if (!fxType) { replyError(msg, 'Usage: !choreograph fx [x y [pageId]] or with token selected'); return; } - let x, y, pageId; - if (args.length >= 3) { - x = parseFloat(args[1]); - y = parseFloat(args[2]); - pageId = args[3] || undefined; - } - if (x === undefined || y === undefined) { + const fxTypeArg = args[0]; + if (!fxTypeArg) { ScriptKit.usage(msg, 'fx', 'Missing FX type'); return; } + + // Parse points from args — strict mode separation + let p1, p2, pageId; + const remaining = args.slice(1); + + if (remaining.length === 0) { + // Selected mode: use selected tokens if (msg.selected && msg.selected.length > 0) { - const tok = getObj('graphic', msg.selected[0]._id); - if (tok) { x = tok.get('left'); y = tok.get('top'); pageId = pageId || tok.get('_pageid'); } + const t1 = getObj('graphic', msg.selected[0]._id); + if (t1) { p1 = { x: t1.get('left'), y: t1.get('top') }; pageId = t1.get('_pageid'); } + } + if (msg.selected && msg.selected.length >= 2) { + const t2 = getObj('graphic', msg.selected[1]._id); + if (t2) { p2 = { x: t2.get('left'), y: t2.get('top') }; } + } + } else if (!isNaN(parseFloat(remaining[0]))) { + // Coordinate mode: all args are numbers + p1 = { x: parseFloat(remaining[0]), y: parseFloat(remaining[1]) }; + if (remaining.length >= 4 && !isNaN(parseFloat(remaining[2])) && !isNaN(parseFloat(remaining[3]))) { + p2 = { x: parseFloat(remaining[2]), y: parseFloat(remaining[3]) }; + pageId = remaining[4] || Campaign().get('playerpageid'); + } else { + pageId = remaining[2] || Campaign().get('playerpageid'); + } + } else { + // Token ID mode: args are token IDs + const t1 = getObj('graphic', remaining[0]); + if (t1) { p1 = { x: t1.get('left'), y: t1.get('top') }; pageId = t1.get('_pageid'); } + if (remaining.length >= 2) { + const t2 = getObj('graphic', remaining[1]); + if (t2) { p2 = { x: t2.get('left'), y: t2.get('top') }; } } } - if (!pageId && msg.selected && msg.selected.length > 0) { - const tok = getObj('graphic', msg.selected[0]._id); - if (tok) pageId = tok.get('_pageid'); - } - if (x !== undefined && y !== undefined) { - spawnFx(x, y, fxType, pageId); - } - return; - } - // ---- fxbetween ---- - // Usage: !choreograph fxbetween [pageId] - // Or with 2 selected: !choreograph fxbetween - if (cmd === 'fxbetween') { - const fxType = args[0]; - if (!fxType) { replyError(msg, 'Usage: !choreograph fxbetween [x1 y1 x2 y2]'); return; } - let p1, p2, pageId; - if (args.length >= 5) { - p1 = { x: parseFloat(args[1]), y: parseFloat(args[2]) }; - p2 = { x: parseFloat(args[3]), y: parseFloat(args[4]) }; - pageId = args[5] || Campaign().get('playerpageid'); - } else if (msg.selected && msg.selected.length >= 2) { - const t1 = getObj('graphic', msg.selected[0]._id); - const t2 = getObj('graphic', msg.selected[1]._id); - if (t1 && t2) { - p1 = { x: t1.get('left'), y: t1.get('top') }; - p2 = { x: t2.get('left'), y: t2.get('top') }; - pageId = t1.get('_pageid'); + if (!p1) return; // no valid point resolved + + // Resolve custom FX name to ID if needed + let fxType = fxTypeArg; + const prefabTypes = ['beam', 'bomb', 'breath', 'bubbling', 'burn', 'burst', 'explode', 'glow', 'missile', 'nova', 'splatter']; + const isPrefab = prefabTypes.some(t => fxTypeArg.startsWith(t + '-') || fxTypeArg === t); + if (!isPrefab) { + // Custom FX — resolve to ID + let fxObj; + if (fxTypeArg.startsWith('-')) { + fxObj = getObj('custfx', fxTypeArg); + } + if (!fxObj) { + const results = findObjs({ _type: 'custfx', name: fxTypeArg }); + if (results.length > 0) fxObj = results[0]; } + if (fxObj) fxType = fxObj.get('_id'); } - if (p1 && p2) { + + // If two points are available, use spawnFxBetweenPoints (works for all types). + // If only one point, use spawnFx (works for all types except beam/missile). + if (p2) { spawnFxBetweenPoints(p1, p2, fxType, pageId); + } else { + spawnFx(p1.x, p1.y, fxType, pageId); } return; } - replyError(msg, `Unknown command: ${cmd}. Commands: new, list, edit, delete, run, stop, refresh.`); + if (typeof ScriptKit !== 'undefined') ScriptKit.usage(msg); + else replyError(msg, `Unknown command: ${cmd}. Commands: new, list, edit, delete, run, stop, refresh.`); }; // ========================================================================= @@ -2496,8 +2362,9 @@ if (typeof Choreograph !== 'undefined') doRegister();`); name: 'actors', namespace: 'core', returns: 'token[]', description: 'Tokens sorted by distance from current token.', fn: (token, filteredTokens, params, filterStr) => { + const cd = params.__ctx ? params.__ctx.castData : null; const set = filterStr - ? filteredTokens.filter(t => evalFilter(filterStr, t, null)) + ? filteredTokens.filter(t => evalFilter(filterStr, t, cd)) : filteredTokens; const tx = token.get('left'), ty = token.get('top'); return [...set].sort((a, b) => { @@ -2511,8 +2378,9 @@ if (typeof Choreograph !== 'undefined') doRegister();`); name: 'actor_ids', namespace: 'core', returns: 'string[]', description: 'Token IDs sorted by distance from current token.', fn: (token, filteredTokens, params, filterStr) => { + const cd = params.__ctx ? params.__ctx.castData : null; const set = filterStr - ? filteredTokens.filter(t => evalFilter(filterStr, t, null)) + ? filteredTokens.filter(t => evalFilter(filterStr, t, cd)) : filteredTokens; const tx = token.get('left'), ty = token.get('top'); return [...set].sort((a, b) => { @@ -2523,223 +2391,927 @@ if (typeof Choreograph !== 'undefined') doRegister();`); }, }); - // ── Built-in example scenes ─────────────────────────────────────── - registerExample(SCRIPT_NAME, { - name: 'shockwave', - description: 'Propagates an echo outward from the nearest neighbor.', - scene: { - notes: 'Each token fires based on its distance rank from the nearest neighbor (actors()[1]).', - params: [ - { name: 'interval', type: 'number', default: '500', description: 'Ms between each token' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: ['!choreograph echo 💥 Shockwave hits ${token.name}! (${actors().length} actors nearby)'], notes: 'Propagate' }, - ], + registerFunction(SCRIPT_NAME, { + name: 'cast', namespace: 'core', returns: 'token[]', + description: 'All tokens in the full cast (ignoring row filter), optionally filtered by role. Sorted by distance from current token.', + fn: (token, filteredTokens, params, filterStr) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = filterStr + ? all.filter(t => evalFilter(filterStr, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }); }, }); - - registerExample(SCRIPT_NAME, { - name: 'roll-call', - description: 'Tokens announce themselves one by one, sorted left to right.', - scene: { - notes: 'A simple stagger demo — each token echoes its name in order.', - params: [], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), 800)', commands: ['!choreograph echo ${token.name} reporting in!'], notes: '' }, - ], + registerFunction(SCRIPT_NAME, { + name: 'cast_ids', namespace: 'core', returns: 'string[]', + description: 'Token IDs from the full cast (ignoring row filter), optionally filtered by role. Sorted by distance.', + fn: (token, filteredTokens, params, filterStr) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = filterStr + ? all.filter(t => evalFilter(filterStr, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }).map(t => t.get('id')); }, }); - - registerExample(SCRIPT_NAME, { - name: 'countdown', - description: 'Recursive countdown — echoes a number, then calls itself with n-1.', - scene: { - notes: 'Demonstrates scene chaining and recursion with sync.', - params: [ - { name: 'n', type: 'number', default: '5', description: 'Countdown start' }, - ], - variables: [], - rows: [ - { filter: '*', delay: '0', commands: ['!choreograph echo ${n}...'], notes: 'Echo current count' }, - { filter: '*', delay: 'sync', commands: [], notes: 'Wait' }, - { filter: '*', delay: '500', commands: ['${n > 1 ? "!choreograph run " + self + " --n " + (n - 1) : "!choreograph echo Liftoff!"}'], notes: 'Recurse or finish' }, - ], + registerFunction(SCRIPT_NAME, { + name: 'role', namespace: 'core', returns: 'token[]', + description: 'Shorthand for cast("role="). Returns tokens in the named role, sorted by distance.', + fn: (token, filteredTokens, params, roleName) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = roleName + ? all.filter(t => evalFilter(`role=${roleName}`, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }); }, }); - - registerExample(SCRIPT_NAME, { - name: 'spotlight', - description: 'Each token gets a moment in the spotlight — fires one at a time with a pause between.', - scene: { - notes: 'Uses sync to wait between each token\'s turn.', - params: [], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), 2000)', commands: ['!choreograph echo ✨ ${token.name} takes the spotlight! ✨'], notes: 'Staggered spotlight' }, - ], + registerFunction(SCRIPT_NAME, { + name: 'role_ids', namespace: 'core', returns: 'string[]', + description: 'Shorthand for cast_ids("role="). Returns IDs of tokens in the named role, sorted by distance.', + fn: (token, filteredTokens, params, roleName) => { + const ctx = params.__ctx || {}; + const all = ctx.allTokens || filteredTokens; + const cd = ctx.castData || null; + const set = roleName + ? all.filter(t => evalFilter(`role=${roleName}`, t, cd)) + : all; + const tx = token.get('left'), ty = token.get('top'); + return [...set].sort((a, b) => { + const da = Math.pow(a.get('left') - tx, 2) + Math.pow(a.get('top') - ty, 2); + const db = Math.pow(b.get('left') - tx, 2) + Math.pow(b.get('top') - ty, 2); + return da - db; + }).map(t => t.get('id')); }, }); - registerExample(SCRIPT_NAME, { - name: 'elites-only', - description: 'Only tokens wider than 70px (large tokens) get the effect — demonstrates expression filters.', - scene: { - notes: 'Uses an expression filter: width > 70. Only large tokens fire.', - params: [], - variables: [], - rows: [ - { filter: 'width > 70', delay: '0', commands: ['!choreograph echo 🏆 ${token.name} is an elite! (width=${token.width})'], notes: 'Expression filter' }, - { filter: 'width <= 70', delay: '0', commands: ['!choreograph echo 🐜 ${token.name} is too small (width=${token.width})'], notes: 'Inverse' }, + // ── Built-in example scenes (via ScriptKit framework) ───────────────── + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + + ScriptKit.register(SCRIPT_NAME, { + command: CMD_TOKEN, + tag: 'Scene', + version: SCRIPT_VERSION, + newSince: '1.0.0', + motd: [ + 'Use `!choreograph examples` to browse interactive demos you can run immediately.', + 'The `sync` delay waits for animations to finish before continuing — great for phased effects.', + 'Use `--role caster ` to assign roles at run time without pre-saving a cast.', + 'Chain scenes recursively with `${self}` — combined with `when`, you can build bounce/jump effects.', + 'TokenProxy gives you `token.left`, `token.name`, etc. — no more `get()` calls in expressions.', + 'LINQ methods like `.first()`, `.without()`, `.orderBy()` chain on `actors()` and `role()` results.', + 'Use `!choreograph man ` to search help — it fuzzy-matches across topics and items.', + 'Chain scenes recursively and use `sync` delay to gate the next phase on child completion.', ], - }, - }); + motdHeader: '🎬 **Choreograph** v' + SCRIPT_VERSION, + motdStyle: { borderLeft: '3px solid #7b1fa2' }, + help: { + description: 'Meta-sequencer for Roll20 tokens. Define scenes in handouts — filter tokens, compute per-token timing, and fire commands at the right moments.', + quickStart: [ + '`!choreograph new myScene` — creates a blank scene handout.', + 'Open the **[Scene] myScene** handout. Add rows to the Scene Table: set a Filter (e.g. `*`), a Delay expression (e.g. `stagger(rank("left"), 200)`), and a Command template (e.g. `!sequence play sparkle --target ${token.id}`).', + 'Select tokens and run `!choreograph run myScene`.', + ], + changelog: [ + { version: '1.0.0', date: '2026-08-11', changes: [ + 'Interactive tutorial series (6 guided walkthroughs building a ritual summoning scene)', + 'fx/fxbetween commands accept token IDs (no more manual coordinate lookup)', + 'sync delay rows now fire their command after sync resolves (not just barrier)', + 'Required parameter validation (missing params abort with error)', + 'onSceneStart/onSceneFinish signal system (public API)', + 'waitForScene() helper for tutorial/extension use', + 'castName scope variable for command templates', + 'Scene handout: section headers, empty row skipping, cell collapse fix', + 'Completion card delayed 500ms to not step on last command output', + 'Fix: actors()/actor_ids() now use castData for role-based filtering', + 'Revamped example command with fuzzy search, tiered sorting, and bold highlights', + 'Interactive setup guide wizard for examples (multi-step, roles, params)', + 'when field for conditional row execution', + '--role flag for ad-hoc role assignment at run time', + 'role()/role_ids()/cast()/cast_ids() expression functions', + 'token[]/path[] parameters enriched with TokenProxy', + ]}, + { version: '0.2', date: '2026-06-12', changes: [ + 'TokenProxy — dot-notation access to all token properties', + 'LINQ-style array methods (.from, .without, .where, .orderBy, .first, .last, .select)', + 'Dynamic man/help generation from registries', + 'role=X filter for cast roles', + ]}, + { version: '0.1', date: '2026-06-07', changes: [ + 'Initial release: run, new, list, edit, delete, stop, pause, resume, status', + 'Scene handout format (params, variables, rows)', + 'Cast system with roles', + 'Scene chaining, looping, sync system', + 'Extension API (registerFunction, registerTokenVariable, registerConstant, etc.)', + 'Filters, delay expressions, command templates', + ]}, + ], + commands: [ + { syntax: 'run [flags]', description: 'Execute a scene', version: '0.1', details: 'Runs the named scene on selected tokens (or cast).', items: [ + { name: '--loop', description: 'Loop indefinitely (sync between cycles)', version: '0.1' }, + { name: '--loop N', description: 'Loop N times (immediate restart)', version: '0.1' }, + { name: '--loop N --sync', description: 'Loop N times (sync between cycles)', version: '0.1' }, + { name: '--page [id]', description: 'Populate cast from all tokens on a page', version: '0.1' }, + { name: '--id ', description: 'Populate cast from explicit token IDs', version: '0.1' }, + { name: '--cast ', description: 'Populate cast from a saved cast', version: '0.1' }, + { name: 'ignore-selected', description: 'Don\'t include selected tokens in cast', version: '0.1' }, + { name: '--depth N', description: 'Max chaining depth (default: 10)', version: '0.1' }, + { name: '--sync-timeout ', description: 'Sync timeout in ms (default: 30000)', version: '0.1' }, + { name: '--role ', description: 'Assign tokens to a role at run time', version: '1.0.0' }, + { name: '-- ', description: 'Bind a scene parameter value', version: '0.1' }, + ]}, + { syntax: 'new ', description: 'Create blank scene handout', version: '0.1' }, + { syntax: 'list [query]', description: 'List scenes (fuzzy search)', version: '0.1' }, + { syntax: 'edit ', description: 'Open scene handout', version: '0.1' }, + { syntax: 'delete ', description: 'Delete a scene', version: '0.1' }, + { syntax: 'refresh ', description: 'Regenerate handout from cache', version: '0.1' }, + { syntax: 'add-row ', description: 'Add blank row to scene table', version: '0.1' }, + { syntax: 'dump-html ', description: 'Dump raw handout HTML to API console', version: '0.1' }, + { syntax: 'echo ', description: 'Debug: whisper text with timestamp', version: '0.1' }, + { syntax: 'fx [id2|x2 y2] [pageId]', description: 'Spawn FX (auto-detects point vs between-points)', version: '0.1', items: [ + { name: '', description: 'FX type (e.g. explode-fire, beam-magic) or custom FX ID/name', version: '0.1' }, + { name: ' [id2]', description: 'Token ID(s) — one for point FX, two for directional', version: '1.0.0' }, + { name: ' [x2 y2]', description: 'Coordinates (one or two points)', version: '0.1' }, + { name: 'auto-detect', description: 'beam/breath/splatter → between-points; others → single point; custom FX checks angle === -1', version: '1.0.0' }, + ]}, + { group: 'Playback', commands: [ + { syntax: 'stop [name]', description: 'Stop running scene(s)', version: '0.1' }, + { syntax: 'pause [name]', description: 'Pause running scene(s)', version: '0.1' }, + { syntax: 'resume [name]', description: 'Resume paused scene(s)', version: '0.1' }, + { syntax: 'status', description: 'Show all running scenes', version: '0.1' }, + ]}, + { group: 'Cast', commands: [ + { syntax: 'cast add/remove/list/show/delete', description: 'Manage casts', version: '0.1' }, + ]}, + ], + topics: { + handout: { + title: 'Scene Handout Structure', + description: 'How scene handouts are organized', + version: '0.1', + body: 'Each scene is stored in a `[Scene] ` handout with three HTML tables that Choreograph parses. You can edit them directly in the handout editor.', + items: [ + { name: 'Parameter Table', description: 'Name | Type | Default | Description — scene inputs bound at run time', version: '0.1' }, + { name: 'Variables Table', description: 'Variable | Expression — computed once per token before execution', version: '0.1' }, + { name: 'Scene Table', description: 'Filter | Delay | Command | Notes — the choreography rows', version: '0.1' }, + ], + }, + flow: { + title: 'How It All Connects', + description: 'The execution pipeline from run to command', + version: '0.1', + body: '**1. Cast assembly** — You run `!choreograph run myScene` with tokens selected. These become the *cast*. Parameters are bound from --flags.\n' + + '**2. Variables computed** — For each token in the cast, the Variables table is evaluated top-to-bottom. Each variable can reference params, earlier variables, and the token itself.\n' + + '**3. Row processing** — Each row in the Scene Table is processed:\n' + + ' • The **Filter** selects which cast members this row applies to.\n' + + ' • The **When** condition (if any) is checked per-token — falsy = skip.\n' + + ' • The **Delay** expression is evaluated per-token to compute milliseconds.\n' + + ' • After the delay fires, the **Command** template is evaluated per-token and sent to chat.\n' + + '**4. All rows fire in parallel** — rows don\'t wait for each other unless you use `sync` to create coordination points.\n\n' + + '**Example trace:** Scene has `speed` param (default 2). Variable `dist = distance(350, 350)` computes per-token. Delay `dist / speed` staggers by distance. Command `!sequence play sparkle --target ${token.id}` fires per-token when its delay expires.', + }, + example: { + title: 'Example Scene', + description: 'A complete scene showing all pieces working together', + version: '0.1', + body: '**Propagating burst** — a sparkle effect radiates outward from a center point, hitting nearby tokens first.\n\n' + + '**Parameters:**\n' + + ' `speed` — number, default `2` (pixels per ms)\n' + + ' `origin` — token, default `selected` (center point)\n\n' + + '**Variables:**\n' + + ' `dist` = `distance(origin.left, origin.top)`\n\n' + + '**Scene Table:**\n' + + ' Row 1: Filter `*` | Delay `dist / speed` | Command `!sequence play sparkle --target ${token.id}`\n' + + ' Row 2: Filter `*` | Delay `dist / speed + 500` | Command `!sequence play fade-out --target ${token.id}`\n\n' + + '**Result:** Tokens near the origin sparkle first, with the burst rippling outward. 500ms after each sparkle, that token fades out.', + }, + filters: { + title: 'Filters', + description: 'Filter syntax for selecting tokens', + version: '0.1', + details: 'Filters determine which tokens in the cast are affected by a scene row. Each row in the scene table has a filter column that selects a subset of the cast.', + body: 'Space-separated conditions within a cell are AND. Multiple rows provide OR. Empty filter = no tokens match.', + items: [ + { name: '*', description: 'All tokens', version: '0.1' }, + { name: 'layer=X', description: 'On layer X', version: '0.1' }, + { name: 'name=X*', description: 'Name glob match (supports * wildcard)', version: '0.1' }, + { name: 'id=-ABC123', description: 'Specific token ID', version: '0.1' }, + { name: 'role=X', description: 'Has role X in the cast', version: '0.2' }, + { name: 'status=X', description: 'Has status marker X', version: '0.1' }, + { name: '!prefix', description: 'Negation (e.g. !layer=gm)', version: '0.1' }, + ], + }, + delay: { + title: 'Delay Expressions', + description: 'Per-token timing expressions', + version: '0.1', + details: 'Each row has a delay column containing a JavaScript expression evaluated per-token. The expression must return a number (milliseconds), INF/SKIP to exclude a token, or sync to wait for all participants before continuing.', + body: () => 'Return: number (ms), INF/SKIP, or sync.\n\n' + + '**Token Variables:** ' + TOKEN_VAR_DEFS.filter(d => d.namespace === 'core').map(d => d.name).join(', ') + ', self, plus params/computed vars.\n' + + '**Constants:** ' + Object.values(EXT_CONSTANTS).filter(r => r.namespace === 'core').map(r => r.name).join(', '), + items: [ + { name: 'rank("attr")', description: 'Sort position of current token in filtered set', version: '0.1' }, + { name: 'distance(x, y)', description: 'Pixel distance from token to point (or `distance(orig)`)', version: '0.1' }, + { name: 'propagate(dist, speed)', description: 'dist / speed', version: '0.1' }, + { name: 'stagger(rank, interval)', description: 'rank × interval', version: '0.1' }, + { name: 'wave(pos, wavelength, duration)', description: 'Sinusoidal timing offset', version: '0.1' }, + { name: 'rand(min, max)', description: 'Random number in range', version: '0.1' }, + { name: 'randInt(min, max)', description: 'Random integer in range', version: '0.1' }, + { name: 'clamp(v, lo, hi)', description: 'Clamp value to range', version: '0.1' }, + { name: 'actors(filter?)', description: 'Tokens passing filter, sorted by distance', version: '0.1' }, + { name: 'actor_ids(filter?)', description: 'Token IDs passing filter, sorted by distance', version: '0.1' }, + { name: 'sync', description: 'Wait for all sync participants before continuing', version: '0.1' }, + { name: 'INF / SKIP', description: 'Skip this token (infinite delay)', version: '0.1' }, + ], + }, + commands: { + title: 'Command Templates', + description: 'How to write command templates in scene rows', + version: '0.1', + details: 'Each row in the scene table has a command column. Commands are API calls (starting with !) that fire when a token\'s delay expires. Template literals allow dynamic values computed per-token.', + body: 'Use `${expr}` for substitutions. Evaluated as JS template literals. All variables, params, computed variables, and functions are in scope.\n\nMultiple commands per cell: put each on a new line in the handout cell. They fire simultaneously for that token.', + items: [ + { name: '${token.id}', description: 'Current token ID', version: '0.1' }, + { name: '${token.left}', description: 'Token X position (TokenProxy)', version: '0.2' }, + { name: '${token.name}', description: 'Token display name', version: '0.2' }, + { name: '${self}', description: 'Current scene name (for recursion/chaining)', version: '0.1' }, + { name: '${castName}', description: 'Current cast name (pass to child scenes with --cast)', version: '1.0.0' }, + { name: '${count}', description: 'Number of tokens matching this row\'s filter', version: '0.1' }, + { name: '${myVar}', description: 'Any computed variable or parameter by name', version: '0.1' }, + { name: '${actors().first().id}', description: 'ID of the nearest other token in the filtered set', version: '0.1' }, + { name: '${role("targets").first().id}', description: 'ID of the nearest token in a role', version: '1.0.0' }, + { name: '${role_ids("targets").join(" ")}', description: 'Space-separated list of all target IDs', version: '1.0.0' }, + { name: '${Math.round(dist / speed)}', description: 'Any JS expression (computed inline)', version: '0.1' }, + ], + }, + cast: { + title: 'Cast Management', + description: 'Saving and managing token groups', + version: '0.1', + details: 'Casts are saved token groups stored in [Cast] handouts. They persist across sessions and can assign tokens to named roles for filtering. Use --cast in run to use a saved cast instead of selection.', + body: 'Stored in `[Cast] ` handouts. Use `--cast ` in run to load. Tokens default to selected if no --cast/--page/--id is given.', + items: [ + { name: 'cast add [--role R]', syntax: '!choreograph cast add [--role R]', description: 'Add selected tokens to cast (optionally to a role)', version: '0.1' }, + { name: 'cast remove [--role R]', syntax: '!choreograph cast remove [--role R]', description: 'Remove tokens from cast', version: '0.1' }, + { name: 'cast list', syntax: '!choreograph cast list', description: 'List all saved casts', version: '0.1' }, + { name: 'cast show ', syntax: '!choreograph cast show ', description: 'Show cast members and roles', version: '0.1' }, + { name: 'cast delete ', syntax: '!choreograph cast delete ', description: 'Delete a saved cast', version: '0.1' }, + ], + }, + castexpr: { + title: 'Cast Expressions', + description: 'Accessing cast and role data in expressions', + version: '1.0.0', + details: 'These functions are available in delay expressions and command templates. They return enriched arrays with LINQ methods for chaining.', + body: 'Use these in delay/command expressions to access the full cast or specific roles. All return arrays sorted by distance from the current token.', + items: [ + { name: 'cast()', description: 'Full cast array (all tokens in the scene run)', version: '1.0.0' }, + { name: 'cast_ids()', description: 'Full cast ID array', version: '1.0.0' }, + { name: 'role("name")', description: 'Tokens in a specific role (enriched array)', version: '1.0.0' }, + { name: 'role_ids("name")', description: 'Token IDs in a specific role', version: '1.0.0' }, + ], + }, + sync: { + title: 'Sync', + description: 'Waiting for participants to complete', + version: '0.1', + details: 'Sync creates coordination points within a scene. When a row uses sync as its delay, execution pauses until all registered sync participants (like Sequence animations) report completion. This gates phase transitions on actual animation end rather than estimated timing.', + body: 'Use `sync` as a delay value. Waits for all registered sync participants to signal completion before continuing.\n\nUseful for gating recursion or phase transitions on animation completion.', + }, + loop: { + title: 'Looping', + description: 'Repeating scene execution', + version: '0.1', + details: 'Looping repeats the entire scene. Only top-level scenes can loop indefinitely — child scenes spawned via chaining can use bounded loops (`--loop N`).', + items: [ + { name: '--loop', description: 'Loop indefinitely, sync between cycles', version: '0.1' }, + { name: '--loop N', description: 'Loop N times, immediate restart', version: '0.1' }, + { name: '--loop N --sync', description: 'Loop N times, sync between cycles', version: '0.1' }, + ], + body: 'Children can use bounded loops (`--loop N`). Infinite loops (`--loop`) are top-level only.', + }, + chain: { + title: 'Scene Chaining', + description: 'Recursion and scene composition', + version: '0.1', + details: 'Scenes can spawn other scenes (or themselves) via command templates. This enables recursive patterns like chain-lightning that bounce between targets. Depth is capped (default 10) to prevent infinite recursion.', + body: 'At depth 0, child spawns are skipped. Children can use bounded `--loop N` but not infinite `--loop`.', + items: [ + { name: 'self', description: 'Resolves to current scene name', version: '0.1' }, + { name: '--parent', description: 'Auto-injected parent scene reference', version: '0.1' }, + { name: '--depth N', description: 'Max chaining depth (default: 10)', version: '0.1' }, + ], + }, + when: { + title: 'Row Conditions', + description: 'Conditional row execution', + version: '1.0.0', + details: 'The when field is a JavaScript expression evaluated per-token. If it returns falsy, the row is skipped for that token. Combined with recursion, this enables patterns like "keep jumping until jumps runs out."', + body: 'Add a `when` expression to a scene row. The row only executes for tokens where the expression evaluates to truthy.\n\nExample: `jumps > 0 && next`', + }, + params: { + title: 'Parameter Types', + description: 'Types available for scene parameters', + version: '0.1', + details: 'Parameters are defined in the scene handout\'s Parameter table. They configure the scene at run time via --flags or the guide wizard. Type determines how values are resolved (e.g. token IDs are looked up as Roll20 objects).', + body: 'Append [] for arrays (e.g. token[], number[]). `cast` is built-in (token[], default: selected). Params without defaults are required at run time.', + items: [ + { name: 'number', description: 'Numeric value', version: '0.1' }, + { name: 'text', description: 'String value', version: '0.1' }, + { name: 'boolean', description: 'true/false', version: '0.1' }, + { name: 'token', description: 'Token reference (resolved from ID)', version: '0.1' }, + { name: 'path', description: 'Path reference', version: '0.1' }, + { name: 'sequence', description: 'Sequence recording name', version: '0.1' }, + { name: 'scene', description: 'Choreograph scene name', version: '0.1' }, + { name: 'role', description: 'Cast role name', version: '1.0.0' }, + ], + }, + vars: { + title: 'Variables', + description: 'Computed variables in scene tables', + version: '0.1', + details: 'Variables are computed once per token before any rows execute. They can reference parameters, other variables (defined earlier), and all built-in functions. Use them in delay expressions and command templates.', + body: 'Defined in the Variables table (Variable | Expression). Computed once per token before execution. Later variables can reference earlier ones. Available in all delay expressions and command templates.', + }, + tokenproxy: { + title: 'TokenProxy', + description: 'Dot-notation access to token properties', + version: '0.2', + details: 'TokenProxy wraps Roll20 graphic objects so you can access properties with dot notation in expressions instead of calling get(). Token parameters (type token) are also TokenProxy instances, so param.left works.', + body: 'The `token` object provides access to all token properties via dot notation. Token parameters (type `token`) are also TokenProxy instances.', + items: [ + { name: 'token.id', description: 'Token ID', version: '0.2' }, + { name: 'token.name', description: 'Token display name', version: '0.2' }, + { name: 'token.left / token.top', description: 'Token position', version: '0.2' }, + { name: 'token.width / token.height', description: 'Token dimensions', version: '0.2' }, + { name: 'token.rotation', description: 'Token rotation', version: '0.2' }, + { name: 'token.layer', description: 'Token layer', version: '0.2' }, + { name: 'token.pageid', description: 'Token page ID', version: '0.2' }, + { name: 'token.bar1_value', description: 'Bar values (bar1-3)', version: '0.2' }, + ], + }, + linq: { + title: 'Array Methods (LINQ)', + description: 'Chainable array operations on token sets', + version: '0.2', + details: 'Arrays returned by actors(), role(), cast(), and other set-returning functions are enriched with LINQ-style methods for filtering, sorting, and projecting without manual iteration.', + body: 'Arrays returned by `actors()`, `role()`, etc. have extra methods:', + items: [ + { name: '.from(other)', description: 'Intersection — keep only items in both arrays', version: '0.2' }, + { name: '.without(other)', description: 'Exclusion — remove items in other', version: '0.2' }, + { name: '.where(fn)', description: 'Filter (alias for .filter())', version: '0.2' }, + { name: '.orderBy(attr)', description: 'Sort by attribute name or function', version: '0.2' }, + { name: '.first(n?)', description: 'First element or first N elements', version: '0.2' }, + { name: '.last(n?)', description: 'Last element or last N elements', version: '0.2' }, + { name: '.any(fn?)', description: 'True if any match (or non-empty)', version: '0.2' }, + { name: '.count(fn?)', description: 'Count matching or total', version: '0.2' }, + { name: '.ids()', description: 'Get ID strings', version: '0.2' }, + { name: '.select(fn)', description: 'Map/project elements', version: '0.2' }, + ], + }, + roles: { + title: 'Roles', + description: 'Ad-hoc role assignment and filtering', + version: '1.0.0', + details: 'Roles are lightweight labels assigned to tokens at run time. Unlike casts (which are persisted), roles exist only for the duration of a scene run. They enable patterns like "caster hits targets" without pre-configuring casts.', + body: 'Assign tokens to roles at runtime with `--role `. Filter with `role=X`. Access in expressions with `role("name")` and `role_ids("name")`.', + items: [ + { name: '--role ', description: 'Assign tokens to a role at run time', version: '1.0.0' }, + { name: 'role("name")', description: 'Get tokens in role (returns enriched array)', version: '1.0.0' }, + { name: 'role_ids("name")', description: 'Get token IDs in role', version: '1.0.0' }, + ], + }, + troubleshooting: { + title: 'Troubleshooting', + description: 'Common issues and error behavior', + version: '0.1', + body: '**Expression errors** — If a delay or variable expression throws, that token is skipped for that row. An error is whispered to the GM with the expression and error message.\n\n' + + '**Empty filter match** — If a filter matches no tokens, the row does nothing (no error). This is intentional for conditional scenes.\n\n' + + '**Missing parameters** — If a required parameter (no default) is not provided at run time, the scene aborts with an error listing the missing params.\n\n' + + '**Depth limit reached** — At depth 0, any `!choreograph run` commands in the scene table are silently skipped. Increase `--depth` if legitimate recursion is being cut short.\n\n' + + '**Sync timeout** — If a sync participant doesn\'t signal completion within the timeout (default 30s), the scene continues without it. Adjust with `--sync-timeout`.\n\n' + + '**Scene not found** — Check that the handout is named exactly `[Scene] ` and hasn\'t been renamed. Use `!choreograph list` to see available scenes.\n\n' + + '**Tokens not moving/animating** — Choreograph only fires commands; it doesn\'t move tokens itself. Make sure the target script (e.g. Sequence) is installed and the command syntax is correct.', + }, + api: { + title: 'Extension API', + description: 'How to extend Choreograph from other scripts', + version: '0.1', + handouts: 'dev', + details: 'Other scripts can extend Choreograph by registering custom functions, token variables, constants, parameter types, lifecycle hooks, and sync participants. Extensions appear in man pages and the dev handout automatically.', + items: [ + { name: 'registerFunction(src, struct)', syntax: 'Choreograph.registerFunction(src, struct)', description: 'Add a function to delay/command expressions', version: '0.1' }, + { name: 'registerTokenVariable(src, struct)', syntax: 'Choreograph.registerTokenVariable(src, struct)', description: 'Add a per-token variable', version: '0.1' }, + { name: 'registerConstant(src, struct)', syntax: 'Choreograph.registerConstant(src, struct)', description: 'Add a constant', version: '0.1' }, + { name: 'registerParameterType(src, struct)', syntax: 'Choreograph.registerParameterType(src, struct)', description: 'Add a custom parameter type', version: '0.1' }, + { name: 'registerLifecycleHook(src, struct)', syntax: 'Choreograph.registerLifecycleHook(src, struct)', description: 'Hook into scene lifecycle events', version: '0.1' }, + { name: 'registerSyncParticipant(src, struct)', syntax: 'Choreograph.registerSyncParticipant(src, struct)', description: 'Register for sync coordination', version: '0.1' }, + { name: 'generateExtensionHandout(src, opts)', syntax: 'Choreograph.generateExtensionHandout(src, opts)', description: 'Generate developer docs handout', version: '0.1' }, + { name: 'onSceneStart(fn)', syntax: 'Choreograph.onSceneStart(fn)', description: 'Subscribe to scene start events. Returns unsubscribe function.', version: '1.0.0' }, + { name: 'onSceneFinish(fn)', syntax: 'Choreograph.onSceneFinish(fn)', description: 'Subscribe to scene finish events. Returns unsubscribe function.', version: '1.0.0' }, + { name: 'waitForScene(name)', syntax: 'Choreograph.waitForScene(name)', description: 'Returns {onEnter, onExit} for ScriptKit guides — auto-advances when named scene finishes', version: '1.0.0' }, + ], + body: 'Run `!choreograph gen-dev-docs` for the full developer guide.', + }, + func: { + title: 'Registered Functions', + description: 'Functions available in delay/command expressions', + version: '0.1', + body: () => { + const regs = Object.values(EXT_FUNCTIONS); + if (regs.length === 0) return '*No functions registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + const argList = (r.args || []).map(a => a.name).join(', '); + const purity = r.pure === false ? ' [unstable]' : ''; + out += ns + '**' + r.name + '(' + argList + ')** → *' + (r.returns || 'any') + '*' + purity + '\n'; + if (r.description) out += r.description + '\n'; + out += '\n'; + }); + return out; + }, + }, + tokenvar: { + title: 'Token Variables', + description: 'Registered per-token variables', + version: '0.1', + body: () => { + const regs = Object.values(EXT_TOKEN_VARS); + if (regs.length === 0) return '*No token variables registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + out += ns + '**' + r.name + '**'; + if (r.description) out += ' — ' + r.description; + out += '\n'; + }); + return out; + }, + }, + const: { + title: 'Constants', + description: 'Registered constants', + version: '0.1', + body: () => { + const regs = Object.values(EXT_CONSTANTS); + if (regs.length === 0) return '*No constants registered.*'; + let out = ''; + regs.forEach(r => { + const ns = r.namespace === 'core' ? '' : '**' + r.namespace + '.**'; + out += ns + '**' + r.name + '** = `' + String(r.value) + '`'; + if (r.description) out += ' — ' + r.description; + out += '\n'; + }); + return out; + }, + }, + }, + }, + exampleHandler: (example, msg) => { + const sceneName = example.source + '/example-' + example.name; + const scene = Object.assign({ name: sceneName }, example.scene); + if (!scene.params) scene.params = []; + if (!scene.params.find(p => p.name === 'cast')) { + scene.params.unshift({ name: 'cast', type: 'token[]', default: 'selected', description: 'Tokens to run the scene on (built-in)' }); + } + if (!scene.variables) scene.variables = []; + if (!scene.rows) scene.rows = []; + const html = generateSceneHtml(sceneName, scene); + // Cache the scene so Choreograph can run it + scenes().cache[sceneName] = scene; + return { notes: html, archived: true }; + }, + onComplete: (ctx) => { + // Build cast from selections._roles and run the scene + const sceneName = ctx.example.source + '/example-' + ctx.example.name; + const roles = ctx.selections._roles || {}; + if (Object.keys(roles).length > 0) { + const castName = sceneName + '-cast'; + const castRoles = {}; + Object.entries(roles).forEach(([role, tokens]) => { + castRoles[role] = (Array.isArray(tokens) ? tokens : [tokens]).map(t => t.get('id')); + }); + const castHandout = casts().getOrCreate(castName); + casts().cache[castName] = { roles: castRoles }; + setHandoutNotes(castHandout, generateCastHtml(castName, castRoles)); + castHandout.set('archived', true); + // Build param flags + const paramFlags = Object.entries(ctx.params || {}) + .map(([k, v]) => '--' + k + ' ' + v) + .join(' '); + const syntheticMsg = Object.assign({}, ctx.msg, { + content: CMD_TOKEN + ' run ' + sceneName + ' ignore-selected --cast ' + castName + (paramFlags ? ' ' + paramFlags : ''), + selected: [], + }); + handleInput(syntheticMsg); + } + }, + }); + }; - registerExample(SCRIPT_NAME, { - name: 'tidal-wave', - description: 'Tokens fire in a wave pattern based on horizontal position.', - scene: { - notes: 'Uses wave() for sinusoidal timing offset.', - params: [ - { name: 'wavelength', type: 'number', default: '500', description: 'Wave period in pixels' }, - { name: 'duration', type: 'number', default: '2000', description: 'Total wave duration in ms' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'wave(left, wavelength, duration)', commands: ['!choreograph echo 🌊 ${token.name} hit by wave at ${Math.round(wave(left, wavelength, duration))}ms'], notes: 'Wave timing' }, - ], - }, + // Try immediately + listen for ready signal + registerWithScriptKit(); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') registerWithScriptKit(); }); - registerExample(SCRIPT_NAME, { - name: 'fireball', - description: 'Explosion FX propagates outward from the leftmost token.', - scene: { - notes: 'Fire explosions staggered by position — looks like a spreading fireball.', - params: [ - { name: 'interval', type: 'number', default: '200', description: 'Ms between each explosion' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: ['!choreograph fx explode-fire ${token.left} ${token.top} ${token.pageid}'], notes: '' }, - ], - }, - }); + // ===================================================================== + // Tutorial Examples (via ScriptKit) + // ===================================================================== - registerExample(SCRIPT_NAME, { - name: 'chain-lightning', - description: 'Lightning beam jumps from each token to the next nearest.', - scene: { - notes: 'Beams connect tokens in order of proximity using fxbetween.', - params: [ - { name: 'interval', type: 'number', default: '300', description: 'Ms between each bolt' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: [ - '!choreograph fx burst-magic ${token.left} ${token.top} ${token.pageid}', - '${actors().length > 1 ? "!choreograph fxbetween beam-magic " + left + " " + top + " " + actors()[1].get("left") + " " + actors()[1].get("top") : ""}', - ], notes: 'Bolt + beam to nearest neighbor' }, - ], - }, + // Helper: generate a clickable handout link, or plain text if not found + const sceneLink = (name) => { + const h = scenes().find(name); + const display = `[Scene] ${name}`; + return h ? ScriptKit.html.handoutLink(display, h.get('id')) : `${display}`; + }; + const castLink = (name) => { + const h = casts().find(name); + const display = `[Cast] ${name}`; + return h ? ScriptKit.html.handoutLink(display, h.get('id')) : `${display}`; + }; + + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'your-first-scene', + description: 'Create "The Summoning" — learn scenes, tables, filters, delay, and running.', + guide: [ + { prompt: '**Welcome to Choreograph!**\n\nOver the next few tutorials, you\'ll build a complete **ritual summoning** scene step by step. Cultists chant, energy gathers, and a creature is summoned.\n\nThis first tutorial covers the fundamentals: creating a scene, understanding the three tables, and running it.\n\nClick Continue to begin.' }, + { prompt: '**Setup: Place Your Tokens**\n\nBefore we create the scene, set up the stage. On your current page, place:\n\n• **At least 6 tokens** to serve as cultists (minimum 4, but 6+ recommended so later tutorials can split them into groups)\n• Give them names\n• Arrange them roughly in a circle\n• **Rotate each cultist to face generally toward the center** (select token, hold alt, and drag the rotation handle)\n\nThe rotation values will determine clockwise ordering later — this is important!\n\n**Select all your cultist tokens** then click Continue.', + select: 'token', min: 4, as: 'cultists', + }, + { prompt: '**Create the Scene**\n\nRun `!choreograph new summoning` to create the scene handout.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Run `!choreograph new summoning`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'The Three Tables

    ' + + 'Open ' + sceneLink('summoning') + ' from your journal. You\'ll see:

    ' + + '1. Parameters — inputs the scene accepts when run (e.g. --speed 2). The built-in cast parameter is your selected tokens.
    ' + + '2. Variables — computed values evaluated per token at runtime (e.g. distance from center).
    ' + + '3. Scene Table — the rows: Filter | Delay | When | Command | Notes

    ' + + 'Each row says: "for tokens matching this filter, after this delay, and when these conditions are met, fire this command (the Notes is just for you to keep track of what is going on)." All rows start simultaneously — the delay offsets them.

    ' + + 'Click Continue when you\'ve opened the handout.' + ) }, + { prompt: () => ScriptKit.html.raw( + 'Your First Rows

    ' + + 'In the Scene Table, replace the example row with three rows (leave When and Notes empty for now):

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', '0', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['*', '2000', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['*', '4000', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
    What this does:
    ' + + '• Three phases of chanting, 2 seconds apart
    ' + + '• All cultists speak each line simultaneously (same delay per row)
    ' + + '• ${token.name} inserts each token\'s name

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Run It!**\n\nSelect your cultist tokens, then run:\n\n`!choreograph run summoning`\n\nYou should see whispered messages appear one by one, staggered left-to-right: *"Cultist begins chanting..."*', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**What Just Happened?**\n\nChoreograph:\n1. Collected your selected tokens as the **cast**\n2. Evaluated each row\'s filter (`*` = all tokens)\n3. Fired each row\'s command at its delay — 0ms, 2000ms, 4000ms\n4. Substituted `${token.name}` with each token\'s actual name\n\n**Key Concepts:**\n• All rows start their timers simultaneously — delays offset them\n• Fixed delays (`0`, `2000`, `4000`) create sequential phases\n• Within a row, all matching tokens fire at the same time\n• `${...}` expressions are evaluated per-token\n\nRight now all cultists chant the same lines in unison. In the next tutorial, we\'ll split them into groups so each group chants a different phrase.', + offerExamples: ['roles-and-casts'] + }, + ], }); - registerExample(SCRIPT_NAME, { - name: 'battle-cry', - description: 'Tokens rally one by one with a ping, glow, and announcement.', - scene: { - notes: 'Staggered rally effect — each token pings, glows, and announces.', - params: [ - { name: 'interval', type: 'number', default: '800', description: 'Ms between each token' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: [ - '!choreograph ping ${token.left} ${token.top} ${token.pageid}', - '!choreograph fx glow-holy ${token.left} ${token.top} ${token.pageid}', - '!choreograph echo ⚔️ ${token.name} rallies!', - ], notes: 'Ping + glow + announce' }, - ], - }, + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'roles-and-casts', + description: 'Split cultists into role groups — each group chants a different phrase.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Roles & Casts

    ' + + 'Right now all cultists chant the same three phrases in unison. Let\'s split them into three groups so each group speaks a different line of the incantation.

    ' + + 'Prerequisite: Complete the "Your First Scene" tutorial first. You should have ' + sceneLink('summoning') + ' and your cultist tokens.

    ' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the "Your First Scene" tutorial first.'; + } + }, + { prompt: '**What is a Cast?**\n\nA **Cast** is a saved group of tokens with named **roles**. Instead of selecting tokens every time you run a scene, you define the cast once and reference it.\n\nRoles let you target subsets of the cast in your scene rows using the `role=X` filter.\n\nLet\'s create a cast for our summoning ritual.' }, + { prompt: '**Create the Cast — Group 1**\n\nSelect roughly a third of your cultist tokens (the ones you want to chant the first phrase).\n\nRun: `!choreograph cast add summoning --role first`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Create the Cast — Group 2**\n\nSelect the next third of cultists.\n\nRun: `!choreograph cast add summoning --role second`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Create the Cast — Group 3**\n\nSelect the remaining cultists.\n\nRun: `!choreograph cast add summoning --role third`', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: () => ScriptKit.html.raw( + 'Update the Scene

    ' + + 'Open ' + sceneLink('summoning') + ' and change the Filter column on each row to target a specific role:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', '0', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['role=second', '2000', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['role=third', '4000', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
    Now each group chants its own phrase instead of everyone saying the same thing.

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Run with the Cast**\n\nInstead of selecting tokens manually, use the saved cast:\n\n`!choreograph run summoning --cast summoning`\n\nYou should see each group chant its own phrase at its scheduled time.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => ScriptKit.html.raw( + 'Key Concepts:

    ' + + '• !choreograph cast add <name> --role <role> — assign selected tokens to a named role
    ' + + '• role=X in the Filter column — only match tokens in that role
    ' + + '• --cast <name> on run — load a saved cast instead of using selected tokens
    ' + + '• Roles persist in the ' + castLink('summoning') + ' handout — edit it directly to reassign

    ' + + 'Useful commands:
    ' + + '• !choreograph cast show summoning — view current assignments
    ' + + '• !choreograph cast remove summoning --role first — remove tokens from a role

    ' + + 'In the next tutorial, we\'ll add timing expressions so the cultists within each group activate one at a time, clockwise around the circle.' + ), + offerExamples: ['filters-and-delay'] + }, + ], }); - registerExample(SCRIPT_NAME, { - name: 'fireball', - description: 'Explosion FX propagates outward from the leftmost token.', - scene: { - notes: 'Fire explosions staggered by position.', - params: [ - { name: 'interval', type: 'number', default: '200', description: 'Ms between each explosion' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: ['!choreograph fx explode-fire ${token.left} ${token.top} ${token.pageid}'], notes: '' }, - ], - }, + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'filters-and-delay', + description: 'Stagger cultists clockwise with timing expressions and add chaotic energy effects.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Filters & Delay

    ' + + 'The cultist groups chant their phrases, but within each group everyone speaks at the same instant. Let\'s make them activate one at a time, sweeping clockwise around the circle.

    ' + + 'Prerequisite: Complete "Roles & Casts" first. You should have ' + sceneLink('summoning') + ' with role-based filters and a ' + castLink('summoning') + '. At least one role needs 2+ tokens for staggering to be visible (6+ cultists total recommended).

    ' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Timing Expressions**\n\nSo far, delays have been fixed numbers (ms). But delays can be *expressions* — evaluated per-token, producing different values for each.\n\nKey functions:\n• `rank("attr")` — this token\'s sort position (0-based) among filtered tokens, sorted by attribute\n• `stagger(position, interval)` — `position * interval` (spaces out execution)\n• `rand(min, max)` — random number in range\n• `propagate(distance, speed)` — `distance / speed`\n\nSince your cultists face the center, their `rotation` values increase clockwise. So `rank("rotation")` gives clockwise order!\n\nClick Continue.' }, + { prompt: () => ScriptKit.html.raw( + 'Update the Delays

    ' + + 'Open ' + sceneLink('summoning') + ' and update the Delay column:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', 'stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Liviate Viiopur Turola Ravla...', ''], + ['role=second', '2000 + stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Insus Antioiauernus Lobaitis Broalgia...', ''], + ['role=third', '4000 + stagger(rank("rotation"), 500)', '', '!choreograph echo ${token.name}: Idishelligio Labyouin Vararum...', ''], + ]) + + '
    Each group still starts at its fixed offset (0/2000/4000), but within the group, tokens fire 500ms apart in clockwise order.

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Test the Stagger**\n\nRun: `!choreograph run summoning --cast summoning`\n\nYou should see each group\'s cultists chant one at a time, sweeping clockwise — first group, then second, then third.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => ScriptKit.html.raw( + 'Add a Chaotic Energy Row

    ' + + 'Add a 4th row to the scene — dark energy crackles at random intervals:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', 'rand(500, 5000)', '', '!choreograph fx explode-death ${token.left} ${token.top}', 'chaos'], + ]) + + '
    * matches all tokens regardless of role
    ' + + '• rand(500, 5000) gives each token a random delay between 0.5s and 5s
    ' + + '• This row runs in parallel with the chanting rows — overlapping effects!

    ' + + 'Save and click Continue.' + ) }, + { prompt: '**Run the Full Scene**\n\nRun: `!choreograph run summoning --cast summoning`\n\nNow you should see the clockwise chanting *plus* dark energy explosions firing chaotically around the tokens.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: () => { + const helpHandout = ScriptKit.getHelpHandout(SCRIPT_NAME); + const hId = helpHandout ? helpHandout.get('id') : ''; + return ScriptKit.html.raw( + 'Key Takeaways

    ' + + 'In this tutorial you used:

    ' + + '• stagger(rank("rotation"), 500) — sequential timing sorted by token rotation (clockwise)
    ' + + '• rand(500, 5000) — randomized timing for chaotic effects
    ' + + '• Arithmetic in delays: 2000 + stagger(...) — offset a group while staggering within it
    ' + + '• * filter — target all tokens regardless of role
    ' + + '• Multiple rows with different filters run in parallel

    ' + + 'For the full list of filters and delay functions, see the help handout:
    ' + + (hId ? '• ' + ScriptKit.html.handoutLink('Filters', hId, null, 'Filters') + '
    ' : '') + + (hId ? '• ' + ScriptKit.html.handoutLink('Delay Expressions', hId, null, 'Delay Expressions') + '
    ' : '') + + '
    Next: we\'ll add a sacrifice token and compute distances from it.' + ); + }, + offerExamples: ['variables-and-templates'] + }, + ], }); - registerExample(SCRIPT_NAME, { - name: 'chain-lightning', - description: 'Lightning beam jumps from each token to the next nearest.', - scene: { - notes: 'Beams connect tokens in order of proximity.', - params: [ - { name: 'interval', type: 'number', default: '300', description: 'Ms between each bolt' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: [ - '!choreograph fx burst-magic ${token.left} ${token.top} ${token.pageid}', - '${actors().length > 1 ? "!choreograph fxbetween beam-magic " + left + " " + top + " " + actors()[1].get("left") + " " + actors()[1].get("top") : ""}', - ], notes: 'Bolt + beam to nearest' }, - ], - }, + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'variables-and-templates', + description: 'Compute distance from the sacrifice and use it in delays and commands.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Variables & Templates

    ' + + 'The cultists chant in clockwise order, but the ritual should intensify based on proximity to the sacrifice at the center. Let\'s add computed variables that measure distance from the sacrifice token.

    ' + + 'Prerequisite: Complete "Filters & Delay". You need ' + sceneLink('summoning') + ' and ' + castLink('summoning') + ' with roles.

    ' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Add a Sacrifice Token**\n\nPlace a token in the center of the cultist circle. This is the sacrifice — the focal point of the ritual.\n\nAdd it to the cast with a new role:\n\n`!choreograph cast add summoning --role sacrifice`\n\n(Select the center token first.)', + ...ScriptKit.waitForCommand('!choreograph cast') + }, + { prompt: '**Vary the Distances**\n\nFor this tutorial to look interesting, each cultist needs a slightly different distance from the sacrifice. Hold Alt and drag some cultists closer or farther from the center — make the circle a bit messy.\n\nThis ensures `propagate()` produces visibly different delays for each token.\n\nClick Continue when your cultists are at varied distances.' }, + { prompt: () => ScriptKit.html.raw( + 'The Parameters Table

    ' + + 'Open ' + sceneLink('summoning') + '. The first table is the Parameters table. It already has the built-in cast parameter.

    ' + + 'Parameters are inputs you can pass at runtime with --name value. Let\'s add a speed parameter to control how fast effects propagate.

    ' + + 'Add this row to the Parameters table (leave Default empty):

    ' + + ScriptKit.html.table( + ['Name', 'Type', 'Default', 'Description'], + [ + ['speed', 'number', '', 'Propagation speed (px/ms)'], + ]) + + '
    Save the handout, then click Continue.' + ) }, + { prompt: '**Required Parameters**\n\nTry running the scene without providing `--speed`:\n\n`!choreograph run summoning --cast summoning`\n\nYou should get an error: *"Missing required parameter(s): speed"*\n\nParameters without a default are **required** — the scene won\'t run unless you provide them. This is useful for parameters that have no sensible default.', + ...ScriptKit.waitForCommand('!choreograph run') + }, + { prompt: () => ScriptKit.html.raw( + 'Add a Default

    ' + + 'Open ' + sceneLink('summoning') + ' and add a default value to the speed parameter:

    ' + + ScriptKit.html.table( + ['Name', 'Type', 'Default', 'Description'], + [ + ['speed', 'number', '0.2', 'Propagation speed (px/ms)'], + ]) + + '
    Now the scene will use 0.2 unless overridden at runtime with --speed <value>.

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**The Variables Table**\n\nThe second table is the **Variables** table (two columns: **Variable** | **Expression**).\n\nVariables are computed *per token* before the scene runs. They can reference:\n• `token.left`, `token.top`, `token.name`, etc. — the current token\'s properties\n• Any registered function — `distance()`, `rank()`, `actors()`, `role_ids()`, etc.\n• Parameters passed at runtime (like `speed`)\n• Earlier variables (evaluated top-to-bottom)\n\nClick Continue.' }, + { prompt: () => ScriptKit.html.raw( + 'Add a Distance Variable

    ' + + 'In the Variables table, add these rows:

    ' + + ScriptKit.html.table( + ['Variable', 'Expression'], + [ + ['sacrifice', 'role("sacrifice")[0]'], + ['dist', 'distance(sacrifice)'], + ]) + + '
    What this does:
    ' + + '• role("sacrifice")[0] — gets the nearest token in the "sacrifice" role
    ' + + '• distance(sacrifice) — computes pixel distance from the current token to the sacrifice
    ' + + '• Variables cascade: dist can reference the earlier sacrifice variable

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: () => ScriptKit.html.raw( + 'Use Distance in a Command

    ' + + 'Add a new row to the Scene Table that uses dist in the delay. This fires a breath of fire from the sacrifice to each cultist:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['!role=sacrifice', 'propagate(dist, speed)', '', '!choreograph fx breath-fire ${sacrifice.id} ${token.id}', 'fire breath'], + ]) + + '
    What\'s new here:
    ' + + '• !role=sacrifice — negation filter: all tokens EXCEPT the sacrifice
    ' + + '• propagate(dist, speed) — delay = distance / speed, using the parameter we defined
    ' + + '• ${sacrifice.id} — use the computed variable to get the sacrifice token\'s ID

    ' + + 'Save and click Continue.' + ) }, + { prompt: '**Run It**\n\nRun: `!choreograph run summoning --cast summoning`\n\nYou should see:\n1. The chanting rows fire as before (clockwise stagger)\n2. Fire breath shoots from the sacrifice to each cultist, staggered by distance — closer ones first\n3. The dark energy explosions fire chaotically on top', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**Command Templates: Full Power**\n\nThe `${...}` syntax in commands is a full JavaScript template literal. You have access to:\n\n• All computed variables (`dist`, etc.)\n• All parameters (`speed`, etc.)\n• `token.left`, `token.top`, `token.id`, `token.name`, etc.\n• All functions: `rank()`, `distance()`, `rand()`, `actors()`, etc.\n• JS expressions: `${dist > 100 ? "far" : "close"}`\n• String methods: `${token.name.toUpperCase()}`\n\n**Key Takeaways:**\n• Parameters table = inputs passed at runtime with `--name value`\n• No default = required (scene aborts with error if missing)\n• Variables table = per-token computed values\n• Variables cascade top-to-bottom (later vars can use earlier ones)\n• `distance(target)` + `propagate(dist, speed)` = ripple-outward timing\n• `!filter` = negation (exclude a role/name/layer)\n• `${expr}` in commands = full JS evaluation\n\nNext: we\'ll make the chanting loop with escalating intensity.', + offerExamples: ['looping-and-when'] + }, + ], }); - registerExample(SCRIPT_NAME, { - name: 'battle-cry', - description: 'Tokens rally one by one with a ping, glow, and announcement.', - scene: { - notes: 'Staggered rally effect.', - params: [ - { name: 'interval', type: 'number', default: '800', description: 'Ms between each token' }, - ], - variables: [], - rows: [ - { filter: '*', delay: 'stagger(rank("left"), interval)', commands: [ - '!choreograph ping ${token.left} ${token.top} ${token.pageid}', - '!choreograph fx glow-holy ${token.left} ${token.top} ${token.pageid}', - '!choreograph echo ⚔️ ${token.name} rallies!', - ], notes: 'Ping + glow + announce' }, - ], - }, + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'looping-and-when', + description: 'Make the ritual chanting loop with sync gating between cycles.', + guide: [ + { prompt: () => ScriptKit.html.raw( + 'Looping & When

    ' + + 'The summoning ritual should repeat — cultists chanting in cycles, energy building with each repetition. Choreograph\'s loop system handles this.

    ' + + 'Prerequisite: Complete "Variables & Templates". You need ' + sceneLink('summoning') + ' with roles, stagger delays, and the distance variable.

    ' + + 'Click Continue to begin.' + ), + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'The When Column

    ' + + 'Before we loop, the echo commands from earlier will spam chat on every cycle. Let\'s disable them using the When column.

    ' + + 'The When column is a JS expression — if it evaluates to falsy, the row is skipped for that token. Open ' + sceneLink('summoning') + ' and put false in the When column on the three echo rows:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=first', '...', 'false', '!choreograph echo ...', ''], + ['role=second', '...', 'false', '!choreograph echo ...', ''], + ['role=third', '...', 'false', '!choreograph echo ...', ''], + ]) + + '
    This leaves only the FX rows active — the fire breath and dark energy explosions.

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Loop Basics**\n\nLoop flags are added to the `run` command — they don\'t go in the handout:\n\n• `--loop` — repeat forever (until `!choreograph stop`)\n• `--loop 3` — repeat exactly 3 times, restart immediately\n• `--loop 3 --sync` — repeat 3 times, wait for ALL commands to finish before restarting\n\nClick Continue.' }, + { prompt: '**Try Looping**\n\nRun the scene with 3 loops and sync:\n\n`!choreograph run summoning --cast summoning --loop 3 --sync`\n\n`--sync` means each cycle waits for the previous one to fully complete before restarting. You should see the FX play out 3 times.', + ...Choreograph.waitForScene('summoning'), + }, + { prompt: '**Infinite Loop + Stop**\n\nFor ambience or sustained effects, use unbounded looping:\n\n`!choreograph run summoning --cast summoning --loop`\n\nThis loops forever. To stop it:\n\n`!choreograph stop`\n\n(Or click the stop button on the status card that appears.)\n\nTry running it in a loop, then stopping it after a few cycles.', + ...ScriptKit.waitForCommand('!choreograph stop') + }, + { prompt: '**Conditional When**\n\nYou can also use expressions in the When column to conditionally fire rows:\n\n`dist < 200`\n\nThis means: "Only fire this row for tokens closer than 200px to the sacrifice."\n\nThe When column has access to all the same variables and functions as the Delay column — computed variables, token properties, `rand()`, etc.\n\nClick Continue.' }, + { prompt: '**Key Takeaways:**\n\n• When column — JS expression that gates whether a row fires (falsy = skip)\n• `false` in When = disabled row (useful for muting rows without deleting them)\n• `--loop N --sync` — bounded loop with completion gating\n• `--loop` — infinite, stopped with `!choreograph stop`\n• Sync ensures all commands finish before the next cycle begins\n• Loop flags live on the run command, not in the handout — same scene can be run with or without looping\n\nNext: we\'ll split the climax into a separate child scene triggered by chaining.', + offerExamples: ['chaining-and-recursion'] + }, + ], }); - registerExample(SCRIPT_NAME, { - name: 'ripple-ping', - description: 'Cascading pings that propagate outward and decay in speed over distance.', - scene: { - notes: 'Pings the origin point, then recursively pings outward with decreasing speed. Pass --px/--py to set origin (defaults to center of cast).', - params: [ - { name: 'px', type: 'number', default: '0', description: 'Origin X (0 = auto-center)' }, - { name: 'py', type: 'number', default: '0', description: 'Origin Y (0 = auto-center)' }, - { name: 'speed', type: 'number', default: '0.4', description: 'Propagation speed (px/ms)' }, - { name: 'decay', type: 'number', default: '0.6', description: 'Speed multiplier each hop' }, - { name: 'minSpeed', type: 'number', default: '0.05', description: 'Stop when speed drops below this' }, - ], - variables: [ - { name: 'cx', expression: 'px > 0 ? px : actors().reduce((s,t) => s + t.get("left"), 0) / count' }, - { name: 'cy', expression: 'py > 0 ? py : actors().reduce((s,t) => s + t.get("top"), 0) / count' }, - ], - rows: [ - { filter: '*', delay: 'propagate(distance(cx, cy), speed)', commands: [ - '!choreograph ping ${token.left} ${token.top} ${token.pageid}', - '!choreograph fx nova-holy ${token.left} ${token.top} ${token.pageid}', - '${speed * decay >= minSpeed ? "!choreograph run " + self + " --px " + left + " --py " + top + " --speed " + (speed * decay) + " --decay " + decay + " --minSpeed " + minSpeed : ""}', - ], notes: 'Ping + FX + recurse with decay' }, - ], - }, + ScriptKit.Choreograph.registerExample(SCRIPT_NAME, { + name: 'chaining-and-recursion', + description: 'Create a climax scene triggered by chaining — FX explosion when the ritual completes.', + guide: [ + { prompt: '**Chaining & Recursion**\n\nThe ritual builds to a climax — but the climax is a separate effect. Choreograph lets one scene **chain** into another. We\'ll create a parent scene that orchestrates the full ritual: chanting loops, then the climax.\n\n**Prerequisite:** Complete "Looping & When".\n\nClick Continue to begin.', + onContinue: () => { + if (!scenes().find('summoning')) return 'Scene "summoning" not found. Complete the previous tutorials first.'; + } + }, + { prompt: '**Create the Climax Scene**\n\nRun: `!choreograph new summoning-climax`\n\nThis will hold the dramatic finale — an explosion of energy at the sacrifice.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('summoning-climax')) return 'Scene "summoning-climax" not found. Run `!choreograph new summoning-climax`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'Fill in the Climax Scene

    ' + + 'Open ' + sceneLink('summoning-climax') + ' and set up an explosion effect:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['role=sacrifice', '0', '', '!choreograph fx nova-holy ${token.id}', 'explosion'], + ['!role=sacrifice', 'propagate(dist, 0.2)', '', '!choreograph fx explode-magic ${token.id}', 'ripple'], + ]) + + '
    This needs the same variables as the summoning scene. Add to the Variables table:

    ' + + ScriptKit.html.table( + ['Variable', 'Expression'], + [ + ['sacrifice', 'role("sacrifice")[0]'], + ['dist', 'distance(sacrifice)'], + ]) + + '
    The climax fires a nova at the sacrifice, then magic explosions ripple outward to each cultist.

    ' + + 'Save the handout, then click Continue.' + ) }, + { prompt: '**Create the Parent Scene**\n\nRun: `!choreograph new ritual`\n\nThis will be the orchestrator — it chains the summoning loop into the climax.', + ...ScriptKit.waitForCommand('!choreograph new'), + onContinue: () => { + if (!scenes().find('ritual')) return 'Scene "ritual" not found. Run `!choreograph new ritual`.'; + } + }, + { prompt: () => ScriptKit.html.raw( + 'Fill in the Ritual Scene

    ' + + 'Open ' + sceneLink('ritual') + ' and set up two rows:

    ' + + ScriptKit.html.table( + ['Filter', 'Delay', 'When', 'Command', 'Notes'], + [ + ['*', '0', '', '!choreograph run summoning --cast ${castName} --loop 3', 'chanting x3'], + ['*', 'sync', '', '!choreograph run summoning-climax --cast ${castName}', 'climax'], + ]) + + '
    What\'s happening:
    ' + + '• Row 1 runs the summoning scene 3 times (loops flow back-to-back)
    ' + + '• Row 2 has delay sync — it waits for all previous commands to finish, then fires the climax
    ' + + '• ${castName} passes the current cast name to child scenes so they can look up role assignments

    ' + + 'Save and click Continue.' + ) }, + { prompt: '**Run the Complete Ritual**\n\nRun: `!choreograph run ritual --cast summoning`\n\nYou should see:\n1. The summoning FX play 3 times (fire breath + dark energy each cycle)\n2. After all 3 cycles complete — the climax fires: nova at the sacrifice, then magic explosions ripple outward', + ...Choreograph.waitForScene('ritual'), + }, + { prompt: '**Chaining Concepts:**\n\n• Any `!choreograph run` in a command template chains to that scene\n• `sync` delay = wait for all previous commands to finish before this row fires\n• `${castName}` resolves to the current cast name — pass it to child scenes with `--cast`\n• Tokens matching the row\'s filter are auto-selected as the child scene\'s cast\n• `--depth 10` is the default max recursion depth (prevents infinite loops)\n• `${self}` in a command resolves to the current scene name (useful for recursive scenes)\n\n**Congratulations!** You\'ve built a complete multi-phase ritual with roles, timing, variables, looping, and chaining. From here, experiment with:\n• `!sequence play` commands for smooth animations (requires Sequence)\n• Custom FX types for unique visuals\n• More complex scene hierarchies with multiple children', + offerExamples: ['your-first-scene', 'roles-and-casts', 'filters-and-delay', 'variables-and-templates', 'looping-and-when'] + }, + ], }); // Register Choreograph with itself for child cascading @@ -2770,129 +3342,38 @@ if (typeof Choreograph !== 'undefined') doRegister();`); registerSyncParticipant(SCRIPT_NAME, { commands: [/^!choreograph run /], waiting: (ctx) => { - const children = Object.values(runningScenes) + // Check immediately — child may already be registered (cached scene load is sync) + const immediateChildren = Object.values(runningScenes) .filter(s => s.parentId === ctx.sceneInfo.instanceId); - if (children.length === 0) { ctx.done(); return; } - // Poll for children to finish + let childrenSeen = immediateChildren.length > 0; + // If children already appeared and disappeared, we're done + // (shouldn't happen on immediate check, but guard anyway) + + // Poll for children to register and then finish + // (child scenes load async, so they may not be in runningScenes yet) + let attempts = 0; const check = setInterval(() => { - const remaining = Object.values(runningScenes) + const children = Object.values(runningScenes) .filter(s => s.parentId === ctx.sceneInfo.instanceId); - if (remaining.length === 0) { + if (children.length > 0) { + childrenSeen = true; + } else if (childrenSeen) { + // Children were running and are now gone — done clearInterval(check); ctx.done(); + return; + } else { + attempts++; + // Give children time to register (async handout load) + if (attempts > 20) { clearInterval(check); ctx.done(); } + return; } }, 100); }, }); - // Generate Help: Choreograph handout - (() => { - const helpName = `Help: ${SCRIPT_NAME}`; - let hh = findObjs({ type: 'handout', name: helpName })[0]; - if (!hh) { - hh = createObj('handout', { name: helpName, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); - } - - const h = (n, t) => `${t}`; - const p = (t) => `

    ${t}

    `; - const c = (t) => `${t}`; - const b = (t) => `${t}`; - const li = (t) => `
  • ${t}
  • `; - const ul = (...items) => `
      ${items.join('')}
    `; - - let html = ''; - html += h(1, `${SCRIPT_NAME} v${SCRIPT_VERSION}`); - html += p('A meta-sequencer for Roll20 tokens. Define scenes in handouts — filter tokens, compute per-token timing, and fire commands at the right moments.'); - - html += h(2, 'Commands'); - html += ul( - li(`${c('!choreograph run [flags]')} — Execute a scene`), - li(`${c('!choreograph new ')} — Create blank scene`), - li(`${c('!choreograph list [query]')} — List scenes`), - li(`${c('!choreograph edit ')} — Open handout`), - li(`${c('!choreograph delete ')} — Delete scene`), - li(`${c('!choreograph stop [name]')} — Stop scene(s)`), - li(`${c('!choreograph pause [name]')} — Pause scene(s)`), - li(`${c('!choreograph resume [name]')} — Resume scene(s)`), - li(`${c('!choreograph status')} — Show running scenes`), - li(`${c('!choreograph refresh ')} — Regenerate handout`), - li(`${c('!choreograph cast ...')} — Manage casts`) - ); - - html += h(2, 'Run Flags'); - html += ul( - li(`${c('--loop')} / ${c('--loop N')} / ${c('--loop N --sync')} — Looping`), - li(`${c('--page [id]')} — All tokens on a page`), - li(`${c('--id ')} — Explicit token IDs`), - li(`${c('--cast ')} — Use a saved cast`), - li(`${c('ignore-selected')} — Skip selected tokens`), - li(`${c('--depth N')} — Max chaining depth (default: 10)`), - li(`${c('-- ')} — Bind scene parameters`) - ); - - html += h(2, 'Scene Handout'); - html += p(`Scenes are stored in ${c('[Scene] ')} handouts with three tables:`); - html += ul( - li(`${b('Parameter Table')} (Name | Type | Default | Description) — scene inputs`), - li(`${b('Variables Table')} (Variable | Expression) — computed per-token before execution`), - li(`${b('Scene Table')} (Filter | Delay | Command | Notes) — the choreography`) - ); - - html += h(2, 'Filters'); - html += ul( - li(`${c('*')} — all tokens`), - li(`${c('layer=X')} — on layer X`), - li(`${c('name=X*')} — name glob`), - li(`${c('id=-ABC')} — specific ID`), - li(`${c('role=X')} — cast role`), - li(`${c('status=X')} — has status marker`), - li(`${c('!prefix')} — negation`), - li('Space-separated = AND. Multiple rows = OR.') - ); - - html += h(2, 'Delay Expressions'); - html += p('Evaluated per-token. Return ms, INF/SKIP, or sync.'); - html += p(b('Variables:') + ' ' + TOKEN_VAR_DEFS.filter(d => d.namespace === 'core').map(d => d.name).join(', ') + ', self, plus params and computed variables.'); - html += p(b('Constants:') + ' ' + Object.values(EXT_CONSTANTS).filter(r => r.namespace === 'core').map(r => r.name).join(', ')); - html += p(b('Functions:') + ' ' + Object.values(EXT_FUNCTIONS).filter(r => r.namespace === 'core').map(r => r.name + '()').join(', ')); - html += p(b('Token proxy:') + ' ' + c('token.left') + ', ' + c('token.name') + ', ' + c('token.id') + ', ' + c('token.pageid') + ' etc. Extension namespaces: ' + c('token.namespace.variable') + '.'); - html += p(b('LINQ arrays:') + ' ' + c('actors()') + ' returns enriched arrays with ' + c('.from()') + ', ' + c('.without()') + ', ' + c('.where()') + ', ' + c('.select()') + ', ' + c('.orderBy()') + ', ' + c('.first()') + ', ' + c('.last()') + ', ' + c('.count()') + ', ' + c('.ids()') + '.'); - html += p(b('Functions:') + ` rank("attr"), distance(x,y), propagate(dist,speed), stagger(rank,interval), rand(min,max), randInt(min,max), clamp(v,lo,hi), actors(filter?), actor_ids(filter?), plus math.`); - html += p(b('Constants:') + ' PI, TAU'); - - html += h(2, 'Command Templates'); - html += p(`Use ${c('${expr}')} for substitutions. Evaluated as JS template literals.`); - html += p(`Example: ${c('!sequence play ${anim} ignore-selected ${token.id}')}`); - - html += h(2, 'Cast System'); - html += p(`Casts are saved token groups in ${c('[Cast] ')} handouts with optional roles.`); - html += ul( - li(`${c('!choreograph cast add [--role R]')} — add tokens`), - li(`${c('!choreograph cast remove [--role R]')} — remove tokens`), - li(`${c('!choreograph cast list')} / ${c('show ')} / ${c('delete ')}`), - li(`Use ${c('--cast ')} in run, filter with ${c('role=X')}`) - ); - - html += h(2, 'Sync'); - html += p(`Use ${c('sync')} as a delay value to wait for all registered sync participants before continuing.`); - - html += h(2, 'Scene Chaining'); - html += p(`Use ${c('self')} in commands to reference the current scene. Recursion is depth-limited (${c('--depth')}).`); - - html += h(2, 'Looping'); - html += ul( - li(`${c('--loop')} — infinite, sync each cycle`), - li(`${c('--loop N')} — N times, immediate restart`), - li(`${c('--loop N --sync')} — N times, sync between cycles`) - ); - - hh.set('notes', html); - })(); log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); - - // Signal extensions that Choreograph is ready - sendChat('', `!${SCRIPT_NAME.toLowerCase()}-ready`, null, { noarchive: true }); }; const registerEventHandlers = () => { @@ -2924,8 +3405,12 @@ if (typeof Choreograph !== 'undefined') doRegister();`); registerConstant, registerLifecycleHook, registerSyncParticipant, - registerExample, + generateExtensionHandout, + // Signals + onSceneStart, + onSceneFinish, + waitForScene, // Introspection getFunction: (name) => EXT_FUNCTIONS[name] || null, getVariable: (name) => EXT_TOKEN_VARS[name] || null, diff --git a/Choreograph/README.md b/Choreograph/README.md index 4a1296d60f..4e791217bd 100644 --- a/Choreograph/README.md +++ b/Choreograph/README.md @@ -38,6 +38,7 @@ Install from the Roll20 One-Click Script Library, or paste `Choreograph.js` into | `!choreograph dump-html ` | Dump raw handout HTML to console | | `!choreograph cast ...` | Manage casts (see Cast System) | | `!choreograph echo ` | Debug: whisper text with timestamp | +| `!choreograph fx [id2\|x2 y2]` | Spawn FX (auto-detects point vs between) | ### Run Flags @@ -80,12 +81,12 @@ Computed once per token before execution. Later variables can reference earlier ### Scene Table -| Filter | Delay (ms) | Command | Notes | -|--------|-----------|---------|-------| -| `*` | `stagger(rank("left"), 200)` | `!sequence play ${anim} ignore-selected ${token.id}` | Main wave | -| `layer=gm` | `INF` | | Skip GM tokens | -| `role=hero` | `0` | `!sequence play charge ignore-selected ${token.id}` | Heroes react immediately | -| `*` | `sync` | | Wait for all participants | +| Filter | Delay (ms) | When | Command | Notes | +|--------|-----------|------|---------|-------| +| `*` | `stagger(rank("left"), 200)` | | `!sequence play ${anim} ignore-selected ${token.id}` | Main wave | +| `layer=gm` | `INF` | | | Skip GM tokens | +| `role=hero` | `0` | | `!sequence play charge ignore-selected ${token.id}` | Heroes react immediately | +| `*` | `sync` | | `!choreograph run climax --cast ${castName}` | Wait then chain | ## Filters @@ -104,7 +105,16 @@ Space-separated conditions within a cell are AND. Multiple rows provide OR. ## Delay Expressions -Evaluated per-token. Must return a number (ms), `INF`/`SKIP` (skip this token), or `sync` (wait for all participants before continuing). +Evaluated per-token. Must return a number (ms), `INF`/`SKIP` (skip this token), or `sync` (wait for all participants before continuing). A `sync` row with a command fires the command after the sync resolves. + +## When (Row Conditions) + +The When column is a JavaScript expression evaluated per-token. If it returns falsy, the row is skipped for that token. + +- `false` — disable a row entirely (useful for muting without deleting) +- `dist < 200` — only fire for tokens within 200px +- `token.name === "Leader"` — only fire for a specific token +- Has access to all variables, params, and functions available in Delay expressions ### Token Variables @@ -120,6 +130,7 @@ Evaluated per-token. Must return a number (ms), `INF`/`SKIP` (skip this token), | `count` | Tokens passing this row's filter | | `INF` / `SKIP` | Infinity — skip this token | | `self` | Current scene name | +| `castName` | Current cast name (for passing to child scenes) | | `tokenId` | *(deprecated)* Use `token.id` | | `tokenName` | *(deprecated)* Use `token.name` | @@ -211,21 +222,31 @@ Scenes can call other scenes (or themselves) via the command column: ``` - `self` resolves to the current scene name +- `castName` resolves to the current cast name - `--parent` and `--depth` are auto-injected by the engine - At depth 0, child scene spawns are silently skipped -- Child scenes cannot loop (`--loop` is top-level only) +- Child scenes can use bounded loops (`--loop N`) but not infinite loops ## Sync -Use `sync` as a delay value to wait for all registered sync participants to signal completion before continuing. Useful for waiting on animations to finish before the next phase of a scene. +Use `sync` as a delay value to create a coordination point. The scene waits for all registered sync participants to signal completion before continuing. + +A `sync` row can also have a command — the command fires *after* the sync resolves. This makes it easy to gate a phase transition: + +``` +Row 1: Filter * | Delay 0 | Command: !choreograph run summoning --loop 3 +Row 2: Filter * | Delay sync | Command: !choreograph run climax +``` + +Row 2 waits for Row 1's child scene to finish, then fires the climax. ## Looping -- `--loop` — repeat indefinitely, syncing before each restart +- `--loop` — repeat indefinitely, syncing before each restart (top-level only) - `--loop 5` — repeat 5 times, immediate restart between cycles - `--loop 5 --sync` — repeat 5 times, sync between cycles -Expressions re-evaluate fresh each cycle. +Child scenes can use bounded loops (`--loop N`). Infinite loops are top-level only. ## SelectManager Integration @@ -243,6 +264,11 @@ Choreograph.registerParameterType(sourceId, struct) Choreograph.registerLifecycleHook(sourceId, struct) Choreograph.registerSyncParticipant(sourceId, struct) Choreograph.generateExtensionHandout(sourceId, opts) + +// Signals +Choreograph.onSceneStart(fn) // Subscribe to scene start. Returns unsubscribe fn. +Choreograph.onSceneFinish(fn) // Subscribe to scene finish. Returns unsubscribe fn. +Choreograph.waitForScene(name) // For ScriptKit guides: {onEnter, onExit} that auto-advances when scene finishes ``` All registrations are source-deduplicated — calling the same registration from the same `sourceId` twice is a silent no-op. @@ -294,6 +320,32 @@ Choreograph.registerSyncParticipant('MyScript', { Each participant only receives entries matching their registered command patterns. If none match, the participant is not called. +## Changelog + +### v1.0.0 +- Interactive tutorial series (6 guided walkthroughs building a ritual summoning scene) +- Unified `fx` command: auto-detects spawnFx vs spawnFxBetweenPoints (accepts token IDs, coords, or selected) +- `sync` delay rows now fire their command after sync resolves +- Required parameter validation (missing params abort with error) +- `onSceneStart`/`onSceneFinish` signal system (public API) +- `waitForScene()` helper for ScriptKit guides +- `castName` scope variable for command templates +- `when` field for conditional row execution +- `--role` flag for ad-hoc role assignment at run time +- `role()`/`role_ids()`/`cast()`/`cast_ids()` expression functions +- Scene handout: section headers, empty row skipping, cell collapse fix +- TokenProxy dot-notation access for all token properties +- LINQ-style array methods on `actors()`, `role()`, `cast()` results +- Revamped `!choreograph example` command with fuzzy search +- Interactive setup guide wizard for examples +- Extension API: registerFunction, registerTokenVariable, registerConstant, registerParameterType, registerLifecycleHook, registerSyncParticipant + +### v0.2 +- TokenProxy, LINQ arrays, dynamic help generation + +### v0.1 +- Initial release + ## License MIT diff --git a/Choreograph/script.json b/Choreograph/script.json index 7eada9948c..fd2a0bf716 100644 --- a/Choreograph/script.json +++ b/Choreograph/script.json @@ -1,13 +1,16 @@ { "name": "Choreograph", "script": "Choreograph.js", - "version": "0.2", - "previousversions": ["0.1"], - "description": "Meta-sequencer for Roll20 tokens. Define scenes in handouts -- filter tokens, compute per-token timing, and fire commands at the right moments. Designed to orchestrate animations and effects across groups of tokens with spatial propagation, staggering, and parameterizable timing.\n\nRequires: SelectManager.\n\nScenes are stored in [Scene] handouts, casts in [Cast] handouts.\n\nCommands:\n- `!choreograph run ` -- execute a scene on selected tokens\n- `!choreograph new ` -- create a blank scene handout\n- `!choreograph list` -- list all scenes\n- `!choreograph edit ` -- open scene handout\n- `!choreograph delete ` -- delete a scene\n- `!choreograph stop` -- stop all running scenes\n\nSee the Help: Choreograph handout (generated on startup) for full documentation.", + "version": "1.0.0", + "previousversions": ["0.2", "0.1"], + "description": "[Wiki Page](https://wiki.roll20.net/Script:Choreograph)\n\nMeta-sequencer for Roll20 tokens. Define scenes in handouts -- filter tokens, compute per-token timing, and fire commands at the right moments. Designed to orchestrate animations and effects across groups of tokens with spatial propagation, staggering, and parameterizable timing.\n\nRequires: SelectManager.\n\nScenes are stored in [Scene] handouts, casts in [Cast] handouts.\n\n**Features:**\n- Interactive tutorial series (6 guided walkthroughs)\n- Scene chaining and recursion with sync gating\n- Cast system with named roles\n- Parameters, variables, and full JS expression evaluation\n- When column for conditional row execution\n- Built-in FX commands (fx, fxbetween) with token ID support\n- Extension API for other scripts to register functions, variables, hooks\n- Signal system (onSceneStart/onSceneFinish)\n\nCommands:\n- `!choreograph run [flags]` -- Execute a scene\n- `!choreograph new ` -- Create blank scene handout\n- `!choreograph list [query]` -- List scenes (fuzzy search)\n- `!choreograph edit ` -- Open scene handout\n- `!choreograph delete ` -- Delete a scene\n- `!choreograph stop [name]` -- Stop running scene(s)\n- `!choreograph pause [name]` -- Pause running scene(s)\n- `!choreograph resume [name]` -- Resume paused scene(s)\n- `!choreograph status` -- Show all running scenes\n- `!choreograph fx [id2|x2 y2]` -- Spawn FX (auto-detects point vs between)\n- `!choreograph cast add/remove/list/show/delete` -- Manage casts\n\nSee the Help: Choreograph handout (generated on startup) for full documentation.", "authors": "Kenan Millet", "roll20userid": "2614613", - "useroptions": {}, - "dependencies": ["SelectManager"], + "dependencies": [ + "SelectManager", + "ScriptKit" + ], "modifies": {}, - "conflicts": [] + "conflicts": [], + "useroptions": {} }