393 lines
17 KiB
JavaScript
393 lines
17 KiB
JavaScript
/**
|
||
* 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), {});
|
||
});
|