/** * 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, * effects?: { cargoHold? }, // the DONE press's story beat (pure data — GameScene applies it) * 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_. */ 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-.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.) 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); * - effects: the story beat the DONE press runs (a plain object — * normalizeEffects keeps the safe entries: cargoHold = a positive * bay count; the run's ship starts with cargoHold 0 (data/ship.json) * and the scene's lines are the truth — Carol's crew builds the bays). * GameScene.onVignetteComplete applies it, BEFORE the scene's quest * package handoff (the hold must fit the package); * - 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, effects: normalizeEffects(raw.effects), lines, }; } /** * The scene's story effects (data/vignettes.json → vignettes[].effects) — * the beat the DONE press runs BEFORE the location resumes. Pure data: * the model keeps the safe entries, GameScene.onVignetteComplete applies * them. * * cargoHold — the ship's unlocked cargo bays (a positive integer; the * run's ship starts with 0 — data/ship.json — and the * application is MAX-wins, never a downgrade). * * @returns {object|null} the clean effects, or null when none survived */ export function normalizeEffects(raw) { if (!raw || typeof raw !== 'object') return null; const out = {}; const hold = Number(raw.cargoHold); if (Number.isFinite(hold) && hold > 0) out.cargoHold = Math.floor(hold); if (Object.keys(out).length === 0) return null; return out; } /** * 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} */ 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} 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()] }; }