orbit/dev/vignette-flow.mjs

393 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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), {});
});