179 lines
6.3 KiB
JavaScript
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));
|
|
}
|