/** * COMMS MODEL — the pure, Node-testable rules of data/comms.json. * * A comm is an EVENT with a sender: { id, from, type, trigger, text, * audio, once, delayMs }. This module normalizes the config (dropping — * with a console warning — anything that can't be shown) and answers * the two questions the hub asks: * * - `commsForTrigger(cfg, trigger, played)` — which comms are DUE for * an event (already-played `once` comms don't re-fire; a fresh run's * empty played set is what lets the new-game welcome play); * - `commAssets(cfg, comm)` — the deferred-load spec: the sender's * character clip (by character id, so every comm from OAC shares one * cached clip) and the speech clip (by file name, so a clip shared * by several comms loads once). * * Pure: it takes the `comms` config SECTION (the object from * data/comms.json) — never the `config` singleton — so the dev tests * (dev/comms.test.mjs) can feed it plain JSON. * * import { config } from '../config/Config.js'; * const cfg = config.section('comms', {}); * const due = commsForTrigger(cfg, 'newGame', playedSet); */ export const COMM_TYPES = ['ack', 'visual', 'audio']; /** The scene-loader cache key for a character's clip: comms_vid_. */ export function videoKeyFor(charId) { return `comms_vid_${String(charId)}`; } /** * The scene-loader cache key for a speech clip, derived from the FILE. * The key is the folder + basename (two comms sharing a clip share one * key; 'assets/speech/oac/intro-01.mp3' → 'comms_au_oac-intro-01'). The * folder is part of the key because TWO CHARACTERS may both ship an * 'intro-01.mp3' — the basename alone would make Trestle speak OAC's * clip (the loader would see the key as already loaded). */ export function audioKeyFor(file) { const parts = String(file ?? '') .split('/') .filter(Boolean); const base = (parts.pop() ?? '').replace(/\.[a-z0-9]+$/i, ''); const dir = parts.length ? parts[parts.length - 1] : null; const safe = `${dir ? dir + '-' : ''}${base}`.replace(/[^a-z0-9_-]+/gi, '-'); return `comms_au_${safe || 'speech'}`; } /** @returns {boolean} the master switch (a config predating comms defaults on). */ export function commsEnabled(cfg) { return cfg?.enabled !== false; } /** * The sender's entry (characters.) or null (a comm with an unknown * sender is dropped by listComms — the box has no face to put on it). */ export function charEntry(cfg, from) { const chars = cfg?.characters; if (!chars || typeof chars !== 'object' || from == null) return null; const entry = chars[from]; return entry && typeof entry === 'object' ? entry : null; } /** * Normalize one comm entry: returns a clean object, or null when the * entry can't be shown (with a console warning — the rest of the list * still plays). * * Rules: * - type in ack|visual|audio (else the comm is unplayable); * - trigger: a non-empty string (a comm that can't fire is dead weight); * - from: must resolve to a character (the box needs its face); * - text: required for ack|visual (a box with no words is noise) — * optional for 'audio' (voice only); * - audio: required for 'audio' (voice is the whole comm) — optional * for ack|visual (a text-only comm is a valid comm); * - once defaults true (comms don't repeat unless the author says so); * - delayMs defaults 0 (clamped to >= 0). */ export function normalizeComm(raw) { if (!raw || typeof raw !== 'object') return null; const id = String(raw.id ?? '').trim(); if (!id) return null; const type = String(raw.type ?? '').trim(); if (!COMM_TYPES.includes(type)) { console.warn(`[comms] "${id}" has unknown type "${type}" (expected ${COMM_TYPES.join(' | ')}) — dropped`); return null; } const trigger = String(raw.trigger ?? '').trim(); if (!trigger) { console.warn(`[comms] "${id}" has no trigger — dropped`); return null; } const from = String(raw.from ?? '').trim(); const text = String(raw.text ?? '').trim(); const audio = String(raw.audio ?? '').trim(); if (!text && type !== 'audio') { console.warn(`[comms] "${id}" (${type}) has no text — dropped`); return null; } if (!audio && type === 'audio') { console.warn(`[comms] "${id}" (audio) has no speech clip — dropped`); return null; } return { id, from, type, trigger, text, audio: audio || null, once: raw.once !== false, delayMs: Math.max(0, Number(raw.delayMs) || 0), }; } /** * The playable roster: the normalized comms in config order, minus the * ones whose sender has no character entry (warned per id). * @returns {Array} */ export function listComms(cfg) { const list = Array.isArray(cfg?.comms) ? cfg.comms : []; const out = []; const seen = new Set(); for (const raw of list) { const comm = normalizeComm(raw); if (!comm) continue; if (!charEntry(cfg, comm.from)) { console.warn(`[comms] "${comm.id}" speaks for unknown sender "${comm.from}" — dropped`); continue; } if (seen.has(comm.id)) { console.warn(`[comms] duplicate id "${comm.id}" — the first entry wins`); continue; } seen.add(comm.id); out.push(comm); } return out; } /** * The comm's deferred-load spec. The character clip is keyed BY CHARACTER * (every OAC comm shares one 185 KB clip); the speech clip BY FILE (a clip * reused across comms loads once). * * @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}} */ export function commAssets(cfg, comm) { const entry = charEntry(cfg, comm.from); const videoUrl = String(entry?.video ?? '').trim(); const audioUrl = String(comm.audio ?? '').trim(); return { video: videoUrl ? { key: videoKeyFor(comm.from), url: videoUrl } : null, audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null, }; } /** * The comms due for a trigger: the roster entries bound to it, in config * order, minus the ones already played (`once` comms the run has heard; * `once: false` comms re-arm on every trigger — the author's repeat). * * @param {object} cfg the comms config section * @param {string} trigger e.g. 'newGame' * @param {Set} [played] the run's already-played ids (a save's ledger) * @returns {Array} the normalized comms */ export function commsForTrigger(cfg, trigger, played = new Set()) { const name = String(trigger ?? '').trim(); if (!name) return []; return listComms(cfg).filter((c) => c.trigger === name && !(c.once && played.has(c.id))); } /** The set of trigger names the roster uses (the dev tests + docs). */ export function triggersUsed(cfg) { return new Set(listComms(cfg).map((c) => c.trigger)); }