orbit/js/vignettes/VignetteModel.js

285 lines
11 KiB
JavaScript

/**
* VIGNETTE MODEL — the pure, Node-testable rules of data/vignettes.json.
*
* A vignette is a DIALOGUE SCENE that plays on a planet's surface (the
* SurfaceScene), triggered by the world's location buttons (the EXPLORE
* drawer's Bar, the SHOP deck slot): a sequence of lines, each with a
* speaker + text + an optional speech clip, played in a window centered
* on the screen over the location's looping clip (js/ui/VignetteWindow.js).
*
* { id, location, quest?, step?, once, lines: [{ from, text, audio? }] }
*
* This module normalizes the config (dropping — with a console warning —
* anything that can't be shown) and answers the questions the hub asks:
*
* - `vignettesForLocation(cfg, location)` — which scenes are bound to a
* world slot ('bar', 'shop', …), in config order;
* - `vignetteAvailable(vig, state)` — is this one playable NOW, against
* a state snapshot the scene builds from the sleeping GameScene (the
* run's played ledger + the gate's quest facts — see below);
* - `lineAssets(cfg, line)` / `vignetteAssets(cfg, vig)` — the
* deferred-load specs: the speaker's PLANET clip (by character id, so
* every line from Carol shares one cached clip) and each speech clip
* (by file, so a clip shared by several lines loads once).
*
* THE GATE (vignettes are quest steps): a vignette names the quest it is
* a step of (`quest` + the step's index `step`). It is AVAILABLE while
* that quest is granted and unclaimed and the step is still OPEN — and
* finishing the scene is the step's WITNESS (GameScene._questCheckEval's
* 'vignettePlayed' check reads the run's played ledger — the same shape
* as the comms' trestleTalked/trestleMet). A vignette with no gate plays
* on every visit (until its ledger mark).
*
* Pure: it takes the `vignettes` config SECTION (the object from
* data/vignettes.json) — never the `config` singleton — so the dev tests
* (dev/vignettes.test.mjs) can feed it plain JSON.
*
* import { config } from '../config/Config.js';
* const cfg = config.section('vignettes', {});
* const scenes = vignettesForLocation(cfg, 'bar');
*/
/** The scene-loader cache key for a character's PLANET clip: vign_vid_<id>. */
export function videoKeyFor(charId) {
return `vign_vid_${String(charId)}`;
}
/**
* A character's PLANET clip URL — the planet cut of the comms' space cut:
* assets/videos/characters/planet-<id>.mp4 (planet-trestle.mp4 vs the
* comms' trestle.mp4). A character entry may override it with its own
* `video` (lineAssets applies the override).
*/
export function videoUrlFor(charId) {
return `assets/videos/characters/planet-${String(charId)}.mp4`;
}
/**
* The scene-loader cache key for a speech clip, derived from the FILE
* (the comms' audioKeyFor recipe, the vignettes' prefix): the key is the
* folder + basename (two lines sharing a clip share one key; the folder
* is part of the key because TWO CHARACTERS may both ship an
* 'intro-01.mp3' — the basename alone would make Carol speak Trestle'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 `vign_au_${safe || 'speech'}`;
}
/** @returns {boolean} the master switch (a config predating vignettes defaults on). */
export function vignettesEnabled(cfg) {
return cfg?.enabled !== false;
}
/**
* The speaker's entry (characters.<from>) or null (a line from an unknown
* speaker makes its vignette unplayable — the window 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 line: a clean object, or null when it can't be spoken
* (with a console warning).
*
* Rules:
* - from: must name a speaker (the caller checks the character entry —
* a faceless line makes the whole scene unplayable);
* - text: required (a line with no words is noise);
* - audio: an optional speech clip (a text-only line is a valid line).
*/
export function normalizeLine(raw) {
if (!raw || typeof raw !== 'object') return null;
const from = String(raw.from ?? '').trim();
if (!from) {
console.warn('[vignettes] a line has no speaker — dropped');
return null;
}
const text = String(raw.text ?? '').trim();
if (!text) {
console.warn(`[vignettes] a line from "${from}" has no text — dropped`);
return null;
}
const audio = String(raw.audio ?? '').trim();
return { from, text, audio: audio || null };
}
/**
* Normalize one vignette entry: returns a clean object, or null when the
* scene can't be shown (with a console warning — the rest of the roster
* still plays).
*
* Rules:
* - id: required (the played ledger + the quest's 'vignettePlayed'
* witness both key off it);
* - location: a non-empty string, lowercased (the world slot that
* triggers it — 'bar', 'shop', …);
* - lines: at least ONE playable line with a known speaker (a half
* scene is worse than no scene — one bad line drops the whole
* vignette, warned per line);
* - quest: optional (no gate = the scene plays on every visit); when
* present it must name a non-empty quest id;
* - step: the gate's step index (default 0, clamped to >= 0);
* - once defaults true (a scene plays once per run; `once: false`
* re-arms on every visit — the author's repeat).
*
* @param {object} raw the config entry (data/vignettes.json → vignettes[])
* @param {object} [cfg] the vignettes config section (for the characters
* table — the line speaker check; plain JSON in the dev tests)
*/
export function normalizeVignette(raw, cfg) {
if (!raw || typeof raw !== 'object') return null;
const id = String(raw.id ?? '').trim();
if (!id) {
console.warn('[vignettes] a scene has no id — dropped');
return null;
}
const location = String(raw.location ?? '').trim().toLowerCase();
if (!location) {
console.warn(`[vignettes] "${id}" has no location — dropped`);
return null;
}
const quest = String(raw.quest ?? '').trim();
const step = Math.max(0, Math.round(Number(raw.step) || 0));
const rawLines = Array.isArray(raw.lines) ? raw.lines : [];
if (rawLines.length === 0) {
console.warn(`[vignettes] "${id}" has no lines — dropped`);
return null;
}
const lines = [];
for (const rl of rawLines) {
const line = normalizeLine(rl);
if (!line) return null; // one bad line drops the whole scene (warned above)
if (!charEntry(cfg, line.from)) {
console.warn(`[vignettes] "${id}" has a line for unknown speaker "${line.from}" — dropped`);
return null;
}
lines.push(line);
}
return {
id,
location,
quest: quest || null,
step: quest ? step : null,
once: raw.once !== false,
lines,
};
}
/**
* The playable roster: the normalized vignettes in config order, minus
* the unplayable ones (warned per id). The characters table rides in
* `cfg.characters` (data/vignettes.json).
* @returns {Array<object>}
*/
export function listVignettes(cfg) {
const list = Array.isArray(cfg?.vignettes) ? cfg.vignettes : [];
const out = [];
const seen = new Set();
for (const raw of list) {
if (typeof raw === 'string' || raw == null) continue; // $comment strings, etc.
const vig = normalizeVignette(raw, cfg);
if (!vig) continue;
if (seen.has(vig.id)) {
console.warn(`[vignettes] duplicate id "${vig.id}" — the first entry wins`);
continue;
}
seen.add(vig.id);
out.push(vig);
}
return out;
}
/**
* The roster's scenes bound to a location, in config order.
* @param {string} location the world slot id ('bar', 'shop', … — case-insensitive)
* @returns {Array<object>} the normalized vignettes
*/
export function vignettesForLocation(cfg, location) {
const name = String(location ?? '').trim().toLowerCase();
if (!name) return [];
return listVignettes(cfg).filter((v) => v.location === name);
}
/**
* Is the scene playable NOW? Pure against a state snapshot the scene
* builds (the hub adds the run's played ledger; the scene supplies the
* gate's facts from the sleeping GameScene):
*
* state = {
* played: Set, the run's already-played ids (registry 'vignettesPlayed')
* gate: null | { null = no gate (an ungated scene) / the scene
* granted: boolean, couldn't read the ledger — a gated scene
* claimed: boolean, then stays UNAVAILABLE (a gate it can't
* stepExists: boolean, verify must not open a scene)
* stepDone: boolean,
* },
* }
*
* Available = not already played (for `once` scenes) AND (no gate, or
* the gate is held: the quest is granted, unclaimed, its step exists and
* is still open).
*
* @returns {boolean}
*/
export function vignetteAvailable(vig, state = {}) {
if (!vig) return false;
const played = state.played instanceof Set ? state.played : new Set();
if (vig.once && played.has(vig.id)) return false;
if (!vig.quest) return true; // ungated — the location plays it (once not played)
const gate = state.gate;
if (!gate || typeof gate !== 'object') return false; // gated + unreadable → unavailable
return (
gate.granted === true &&
gate.claimed !== true &&
gate.stepExists === true &&
gate.stepDone !== true
);
}
/**
* The line's deferred-load spec. The character clip is keyed BY CHARACTER
* (every Carol line shares one cached clip — her planet cut); the speech
* clip BY FILE (a clip reused across lines loads once).
*
* @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}}
*/
export function lineAssets(cfg, line) {
const entry = charEntry(cfg, line.from);
const videoUrl = String(entry?.video ?? '').trim() || videoUrlFor(line.from);
const audioUrl = String(line?.audio ?? '').trim();
return {
video: { key: videoKeyFor(line.from), url: videoUrl },
audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null,
};
}
/**
* The vignette's full deferred-load spec (the hub's one load pass): every
* distinct character clip + speech file its lines need, deduped by cache
* key (a two-beat Carol scene fetches her clip once).
*
* @returns {{videos: Array<{key:string,url:string}>, audios: Array<{key:string,url:string}>}}
*/
export function vignetteAssets(cfg, vig) {
const videos = new Map();
const audios = new Map();
for (const line of vig.lines ?? []) {
const a = lineAssets(cfg, line);
if (a.video) videos.set(a.video.key, a.video);
if (a.audio) audios.set(a.audio.key, a.audio);
}
return { videos: [...videos.values()], audios: [...audios.values()] };
}