orbit/js/comms/CommsModel.js

179 lines
6.3 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, 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
* (two comms sharing a clip share one key; 'assets/speech/oac/intro-01.mp3'
* → 'comms_au_intro-01').
*/
export function audioKeyFor(file) {
const base = String(file ?? '')
.split('/')
.pop()
.replace(/\.[a-z0-9]+$/i, '');
const safe = 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: 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<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).
*
* @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<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));
}