orbit/js/comms/CommsModel.js

198 lines
7.5 KiB
JavaScript

/**
* 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, audioPool, 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_<id>. */
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.<from>) 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: a single speech clip — optional for ack|visual (a text-only
* comm is a valid comm); for 'audio' it satisfies the voice (or the
* pool below does);
* - audioPool: an optional LIST of speech clips — the comm speaks one
* of them at RANDOM per arm (the pick happens in commAssets, when the
* deferred-load spec is built, so only the chosen clip is fetched and
* cached); for 'audio' comms it is an alternative to `audio`;
* - 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();
const pool = Array.isArray(raw.audioPool)
? [...new Set(raw.audioPool.map((f) => String(f ?? '').trim()).filter(Boolean))]
: [];
if (!text && type !== 'audio') {
console.warn(`[comms] "${id}" (${type}) has no text — dropped`);
return null;
}
if (!audio && pool.length === 0 && type === 'audio') {
console.warn(`[comms] "${id}" (audio) has no speech clip (audio or audioPool) — dropped`);
return null;
}
return {
id,
from,
type,
trigger,
text,
audio: audio || null,
audioPool: pool.length ? pool : 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<object>}
*/
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). A comm with an audioPool speaks ONE
* pool file at RANDOM — the pick is made HERE, at arm time, because the
* deferred load is keyed to the file (only the chosen clip is fetched and
* cached; the next arm rolls again).
*
* @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 pool = Array.isArray(comm.audioPool) ? comm.audioPool : [];
const audioUrl = String(
pool.length ? pool[(Math.random() * pool.length) | 0] : (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<string>} [played] the run's already-played ids (a save's ledger)
* @returns {Array<object>} 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));
}