/** * Dev-only VIGNETTE flow test: boots straight into the GameScene (smoke * style), lands on the home world, then drives the SHIPPED bar scene — * 'Special Delivery' step 1 (data/vignettes.json → special_delivery_bar_meeting) * — end-to-end. The dialogue, the voices, and the quest gate are the * authored ones; the test injects only the one thing the home world lacks * by design — a BAR clip (the bar service normally rides the neutral * capital's frame, where the scene's home is). * * node dev/server.mjs 8124 * node dev/cdp-firefox.mjs http://127.0.0.1:8124/dev/vignette-flow.html \ * 'return window.__VIGNETTE_TEST__;' * * Steps: * 1. boot + the fixtures (the bar clip on the home frame; the quest * granted — 'Leaving the Nest' is claimed in the story chain, which * the smoke boot does not walk) * 2. land (the real entry) → force the surface stage * 3. the hub is live + the scene is DUE (the gate's facts hold: the * quest's step 0 is the 'vignettePlayed' witness — open until the * scene completes) * 4. enter the bar — the scene ARMS (pending), not open yet (the * opening beat: the location's clip reads first) * 5. the beat lands — the window opens on TRESTLE's line (the deck * stood down); the feed is the FULL 256×256 planet cut and the * window sizes itself to the line (button below the text, inside * the window — the probe measurement matches the engine's live one) * 6. NEXT ×4 — each of Carol's four beats (the face stays CAROL; the * voice is the line's own clip, ducking the hum), verbatim words, * and the window re-sizes to each (taller) line * 7. the last line reads DONE; the press completes: the STORY BEAT * runs (the hold builds out to 4 bays — the scene's effects — and * the 'special-package' joins the hold; its presence stands the * 'cargoUpgraded' step done), the ledger marks (completing), the * quest's bar step stands DONE (the witness), the quest is not yet * claimable (its delivery step still stands), the bar's own lock * returns — and LEAVE BAR, which the scene's lock had disabled, * re-arms (the press leaves the bar), and the music duck is * restored (it was ducked per-sound under every voice) * 8. re-enter the bar — the scene stays spent (the once-ledger), a * plain visit with no window */ import Phaser from '../js/vendor/phaser.js'; import { config } from '../js/config/Config.js'; import { ConfigLoader } from '../js/config/ConfigLoader.js'; import { createGameConfig } from '../js/config/GameConfig.js'; import { GameScene } from '../js/scenes/GameScene.js'; import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; import { musicKey, musicVolumeFor } from '../js/utils/Music.js'; window.__VIGNETTE_ERRORS__ = []; window.addEventListener('error', (e) => { window.__VIGNETTE_ERRORS__ = (window.__VIGNETTE_ERRORS__ ?? []).concat(String(e?.message ?? e)); }); const fail = (msg, extra = {}) => { window.__VIGNETTE_TEST__ = { pass: false, error: String(msg), steps: window.__STEPS__ ?? [], errors: window.__VIGNETTE_ERRORS__, diag: extra, }; console.error('[vignette-flow] FAIL:', msg, extra); throw new Error(String(msg)); }; window.__FLOW_STARTED__ = true; // module graph loaded — for debugging const step = (name, cond, extra = {}) => { if (!cond) fail(`${name}: ${JSON.stringify(extra).slice(0, 300)}`, extra); window.__STEPS__ = (window.__STEPS__ ?? []).concat(name); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const T0 = Date.now(); const trace = (m) => { window.__VIG_TRACE__ = (window.__VIG_TRACE__ ?? []).concat([`${((Date.now() - T0) / 1000).toFixed(1)}s ${m}`]); }; window.__VIG_TRACE__ = []; // Headless boots are flaky (a fresh profile can stall a fetch); the config // load gets a few independent attempts, each time-boxed. const withTimeout = (p, ms) => Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error('config load timeout')), ms)), ]); const data = await (async () => { let last; for (let i = 0; i < 4; i++) { try { return await withTimeout(ConfigLoader.load(), 25000); } catch (e) { last = e; trace(`config-retry-${i + 1}: ${e?.message ?? e}`); await new Promise((r) => setTimeout(r, 1500)); } } throw last ?? new Error('config load failed'); })(); const ID = 'special_delivery_bar_meeting'; (async () => { trace('iife-start'); config.init(data); const gameConfig = createGameConfig(); gameConfig.scene = [GameScene, SurfaceScene]; const game = new Phaser.Game(gameConfig); window.game = game; trace('game-created'); // 1 — wait for the flight scene to be live (v4 boots asynchronously — // headless boots can be slow; a real-time deadline, not a poll count). let gs = null; const bootDeadline = Date.now() + 150000; while (Date.now() < bootDeadline) { gs = game.scene?.getScene?.('GameScene') ?? null; if (gs && gs.ship) break; await sleep(100); } if (!gs || !gs.ship) fail('GameScene never came up', { gs: String(gs), trace: window.__VIG_TRACE__ }); trace('boot-found'); step('boot', true, { frame: gs.planet?.sheetFrame }); // The fixtures — the home world's missing pieces: a BAR clip (the bar // service is a NEUTRAL CAPITAL thing, this is its test bed) + the // drawer's BAR item (normally a capitalNeutral-clause item — the test // world is terran) + the quest grant (the story chain's 'Leaving the // Nest' claim, which the smoke boot does not walk). The drawer's BAR // button is what LEAVE BAR lives on — the button the scene's lock has // to re-arm on DONE. const frame = gs.planet.sheetFrame; const videos = config.get('landing.videos') ?? []; videos[frame] = videos[frame] ?? {}; videos[frame].bar = 'capital-bar-neutral.mp4'; const exploreItems = config.get('actionbar.surface.explore.items'); let barItem = null; if (Array.isArray(exploreItems)) { barItem = exploreItems.find((i) => i && i.id === 'bar') ?? null; if (barItem) barItem.where = null; // this test bed is a bar world, full stop } gs.questState?.give?.('special_delivery'); const scene = (config.get('vignettes')?.vignettes ?? []).find((v) => v?.id === ID); step( 'fixtures', !!videos[frame]?.bar && !!barItem && gs.questState?.isGranted?.('special_delivery') === true && !!scene && scene.lines?.length === 5, { frame, granted: gs.questState?.isGranted?.('special_delivery'), scene: !!scene, barItem: !!barItem }, ); // 2 — the real entry + the surface stage (the test does not wait out // the landing clip). const target = gs.commsTargetFor(gs.planet); gs.startLanding(target); await sleep(400); const ss = game.scene.getScene('SurfaceScene'); if (!ss) fail('SurfaceScene never launched'); ss.startSurface(); // idempotent await sleep(300); step('surface-stage', ss.phase === 'surface', { phase: ss.phase }); trace('surface-stage'); // 3 — the hub (the scene's vignette manager) is live + the scene is DUE. const hub = ss.vignetteHub; step('hub', !!hub && hub.enabled === true, { hub: !!hub, enabled: hub?.enabled }); const roster = hub?.cfg?.vignettes ?? null; const dueDiag = (() => { const raw = Array.isArray(roster) ? roster : null; if (!raw || raw.length === 0) return { roster: 'empty/missing' }; const first = raw[0]; let gate = 'n/a'; try { gate = hub._gate(first); } catch (e) { gate = `threw: ${e?.message ?? e}`; } const g = ss.gameScene; return { roster: raw.map((v) => v.id), firstQuest: first.quest, gate, gameScene: g ? { questState: !!g.questState, snap: typeof g.questSnapshot, granted: g.questState?.isGranted?.('special_delivery') } : null, }; })(); step('due', hub?._dueAt?.('bar')?.id === ID, { due: hub?._dueAt?.('bar')?.id ?? null, ...dueDiag }); trace('due-ok'); // 4 — enter the bar: the scene ARMS (pending) — not open yet (the // opening beat: the location's clip reads first). ss.toggleBar(); step('bar-mode', ss.barMode === true, { barMode: ss.barMode }); step('armed', hub.busy === true, { pending: !!hub.pending, active: hub.active }); // THE SHIP BEFORE THE SCENE (the beat's ground truth): the run's ship // starts with cargoHold 0 (data/ship.json) and the package is NOT // aboard yet — both ride the DONE press (onVignetteComplete). step( 'hold-0-before', gs.ship?.stats?.cargoHold === 0 && gs.ship?.cargo?.has?.('special-package') === false, { hold: gs.ship?.stats?.cargoHold, items: [...(gs.ship?.cargo?.items ?? [])] }, ); await sleep(150); step('beat-holds', hub.active === false, { active: hub.active }); // 5 — the beat lands (the assets are local files): the window opens on // Trestle's line (the deck stood down — the window's NEXT is the only // live control). const openDeadline = Date.now() + 60000; while (Date.now() < openDeadline) { if (hub.active) break; await sleep(100); } step('scene-open', hub.active === true, { pending: !!hub.pending, busy: hub.busy }); trace('scene-open'); const win = hub.window; step('line-0-trestle', win?.name?.text === 'TRESTLE' && win?._textValue === scene.lines[0].text && win?.button?.labelText?.text === 'NEXT', { name: win?.name?.text, label: win?.button?.labelText?.text, text: (win?._textValue ?? '').slice(0, 60) }); step('voice-0', hub._voiceKey !== null, { voiceKey: hub._voiceKey }); // THE NEW GEOMETRY: the feed is the FULL planet cut (256×256, no // downscale) in a wider window (the text column is 502px). step('feed-256', win?.videoSize === 256 && win?.W >= 800 && win?.textMaxW >= 400, { W: win?.W, videoSize: win?.videoSize, textMaxW: win?.textMaxW }); // THE WINDOW SIZES TO THE LINE: for every line, H = the layout formula // (feed row vs. text+button row, whichever is taller), the button seats // below that line's OWN text (not the first line's), and it stays inside // the window. One source of truth: the window's own textHeightFor probe. const gap0 = config.get('vignettes.window.button.gap', 10); const layoutOf = (line) => { const th = win.textHeightFor(line.text); return { th, H: win._contentTop() + Math.max(win.videoSize, th + gap0 + win.buttonH) + win.pad, buttonTop: win._contentTop() + th + gap0, }; }; const fitsLine = (i) => { const e = layoutOf(scene.lines[i]); return Math.abs(win.H - e.H) < 0.01 && win.button.y - win.button.height / 2 >= e.buttonTop - 0.01 && win.button.y + win.button.height / 2 <= win.H - 1; }; step('layout-line-0', fitsLine(0), { H: win.H, expected: layoutOf(scene.lines[0]).H, btnTop: win.button.y - win.button.height / 2 }); // The PROBE (the layout's source of truth) must match the engine's // LIVE measurement of the same line once the decode has landed (the // decode caps at 880ms). const decDeadline = Date.now() + 3000; while (Date.now() < decDeadline && !win?._decFinished) await sleep(50); step('decode-landed', win?._decFinished === true, { decFinished: win?._decFinished }); step( 'probe-matches-live', win?.textHeightFor !== undefined && Math.abs(win.text.height - win.textHeightFor(win._textValue)) <= 1.5, { live: win?.text?.height, probe: win?.textHeightFor?.(win?._textValue) }, ); // THE DUCK (the comms' behavior): while the voice talks, the surface // loop is ducked PER-SOUND to its own volume × musicDuck — and back // when the voice ends. const mKey = musicKey(`music.frames.${frame}`); const mBase = musicVolumeFor(`frames.${frame}`); const mDuck = config.get('vignettes.audio.musicDuck', 0.3); const mEps = Math.max(1e-6, mBase * 0.005); // the WebAudio gain is float32 — allow its quantization const mBody = () => (ss.sound?.getAllPlaying?.() || []).find((b) => b && b.key === mKey); step( 'duck-under-voice', (() => { const b = mBody(); return !!b && typeof b.volume === 'number' && b.volume > 0 && Math.abs(b.volume - mBase * mDuck) <= mEps; })(), { music: mBody()?.volume ?? null, base: mBase, duck: mDuck, key: mKey }, ); step( 'deck-locked', (ss.actionBar?.slots ?? []).filter((s) => s.live).every((s) => s.disabled === true) === true, { slots: (ss.actionBar?.slots ?? []).map((s) => `${s.id}:${s.disabled}`) }, ); // 6 — the four Carol beats: the NEXT press each time (the face stays // CAROL from line two on; each voice is the line's own clip). const press = (w) => w.button.panel.emit('pointerdown'); for (let i = 1; i < scene.lines.length; i++) { press(win); await sleep(200); const last = i === scene.lines.length - 1; step(`line-${i}-carol`, hub.lineIndex === i && win?.name?.text === 'CAROL' && win?._textValue === scene.lines[i].text && win?.button?.labelText?.text === (last ? 'DONE' : 'NEXT') && hub._voiceKey !== null, { lineIndex: hub.lineIndex, name: win?.name?.text, label: win?.button?.labelText?.text, text: (win?._textValue ?? '').slice(0, 60), voiceKey: hub._voiceKey, }); // THE WINDOW RE-SIZED TO THIS LINE (a taller line must not run into // the button or the window's edge — the old first-line height is gone). step(`layout-line-${i}`, fitsLine(i), { H: win.H, expected: layoutOf(scene.lines[i]).H, btnTop: win.button.y - win.button.height / 2 }); } // 7 — DONE: the scene completes. The ledger marks NOW (completing), // and the quest's bar step — the 'vignettePlayed' witness keyed on // this scene's id — stands DONE. The quest itself is not yet claimable // (the hold + delivery still stand). press(win); await sleep(200); step('done', hub.active === false && hub.window === null, { active: hub.active }); step( 'duck-restored', (() => { const b = mBody(); return !!b && Math.abs((b.volume ?? -1) - mBase) <= mEps; })(), { music: mBody()?.volume ?? null, base: mBase }, ); step('ledger', hub.played.has(ID) === true, { played: [...hub.played] }); step('registry', ss.registry?.get?.('vignettesPlayed')?.has?.(ID) === true); const quest = (gs.questSnapshot()?.quests ?? []).find((q) => q.id === 'special_delivery'); step( 'quest-step-done', quest?.checks?.[0]?.done === true && quest?.complete === false && quest?.claimed === false, { checks: quest?.checks?.map((c) => c.done), complete: quest?.complete, claimed: quest?.claimed, }, ); step('gate-consumed', hub._dueAt('bar') === null, { due: hub?._dueAt?.('bar')?.id ?? null }); step('bar-still-mode', ss.barMode === true, { barMode: ss.barMode }); // THE STORY BEAT (the DONE press ran it, in order — upgrade, then // load): the ship's hold is BUILT OUT to the scene's effects // (cargoHold 4 — the run's ship started at 0) and the quest's // 'special-package' is ABOARD; its presence is the 'cargoUpgraded' // step's witness, so the cargo step stands done too. The quest is // still not claimable (the delivery leg stands open). step( 'hold-upgraded', gs.ship?.stats?.cargoHold === 4 && gs.ship?.cargo?.capacity === 4, { hold: gs.ship?.stats?.cargoHold, capacity: gs.ship?.cargo?.capacity }, ); step('package-aboard', gs.ship?.cargo?.has?.('special-package') === true, { items: [...(gs.ship?.cargo?.items ?? [])] }); step( 'quest-cargo-step-done', (() => { const c = quest?.checks?.find((x) => x?.label === 'Upgrade Ship to hold Cargo'); return !!c && c.done === true && quest?.complete === false; })(), { checks: quest?.checks?.map((c) => `${c.label}:${c.done}`), complete: quest?.complete }, ); // THE WAY-OUT COMES BACK: the scene's lock disabled LEAVE BAR (the // drawer's bar item) along with everything else; DONE must re-enable // it — the bar's OWN lock returns (deck stays standby, LEAVE BAR is // the one live control on the surface again). const barBtn = ss.exploreSubBar?.buttons?.find((b) => b.id === 'bar'); step( 'wayout-back', !!barBtn && barBtn.btn.disabled === false, { barDisabled: barBtn?.btn?.disabled ?? 'n/a', buttons: (ss.exploreSubBar?.buttons ?? []).map((b) => b.id), }, ); // And LEAVE BAR still WORKS — the press leaves the bar (previously // the dead button swallowed the click and stranded the player). barBtn?.btn?.panel?.emit('pointerdown'); await sleep(250); step('wayout-leaves', ss.barMode === false, { barMode: ss.barMode }); // 8 — re-enter the bar: the scene stays spent (the once-ledger; the // quest step stands done behind it) — a normal bar visit, no window. ss.toggleBar(); await sleep(250); step( 'spent-on-revisit', ss.barMode === true && hub.active === false && hub.busy === false, { barMode: ss.barMode, active: hub.active, busy: hub.busy }, ); ss.leaveBar(); window.__VIGNETTE_TEST__ = { pass: true, steps: window.__STEPS__ ?? [], id: ID, errors: window.__VIGNETTE_ERRORS__ }; })().catch((e) => { fail(e?.message ?? String(e), {}); });