orbit/dev/class-shot.mjs

332 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/class-shot.mjs — the CLASS LAYER in a REAL BROWSER (screenshots + the
* behaviour the shots alone cannot prove).
*
* node dev/server.mjs 8123 & # the static dev server
* node dev/class-shot.mjs # → /tmp/orbit-class/*.png
*
* Runs index.html?level5 (the dev door) and pins, in the real Phaser runtime:
* a) the INTRO state — Trestle's copy complete on the plate, his clip playing
* with the other beds ducked, classes.mp4 live + MUTED + looping;
* b) the VANGUARD tab (red theme, frame 0 of assets/images/classes.png);
* c) the WRAITH tab (purple theme, frame 1);
* d) the LOCK IN confirm dialog, and after CONFIRM: the ledger (class + perk
* + 5 banked skill points), the ship console's class plate, the world
* RESUMED, and every ducked bed restored.
* It also proves the freeze contract: while the console is up the ship's body
* is parked (velocity/acceleration zero) and the combat drivers stand down.
*/
import path from 'node:path';
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(
pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href
);
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8123';
const OUT = process.env.OUT_DIR || '/tmp/orbit-class';
fs.mkdirSync(OUT, { recursive: true });
let fails = 0;
const check = (cond, msg) => {
console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}`);
if (!cond) fails++;
};
const browser = await chromium.launch({
headless: true,
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const errors = [];
page.on('console', (m) => {
if (m.type() !== 'error') return;
// Resource 404s are the RESPONSE handler's job — it sees the URL, Chrome's
// console line for them carries none (favicon.ico lives here unclaimed).
if (/Failed to load resource/i.test(m.text())) return;
errors.push(m.text());
});
page.on('pageerror', (e) => errors.push('[pageerror] ' + e.message));
page.on('response', (r) => {
if (r.status() >= 400 && !/favicon/.test(r.url())) errors.push(`[http ${r.status()}] ${r.url()}`);
});
page.on('requestfailed', (rq) => {
if (!/favicon/.test(rq.url())) console.log(' · request failed:', rq.url(), rq.failure()?.errorText);
});
console.log(`== the class console — ${BASE}/index.html?level5 ==`);
await page.goto(`${BASE}/index.html?level5`, { waitUntil: 'load' });
// ?level5 walks through the menu on its own (MenuScene's dev door) into a
// fresh run, where GameScene arms the same param as the class console's trigger.
await page.waitForTimeout(1000);
await page.evaluate(() => {
const m = window.game?.scene?.getScene?.('MenuScene');
if (m && !m.dead) m.startNewGame(); // no double-start once the autostart landed
});
await page.waitForFunction(() => {
const g = window.game?.scene?.getScene?.('GameScene');
return !!(g && g.ship && g.classState);
}, null, { timeout: 90000 });
// The console opens on its own (the dev door), once its media pass lands.
// Baseline of the audio beds BEFORE the console can open (the media pass takes
// a few seconds): what each bed's own volume is, so "nothing left ducked" is
// measured against the game's own levels rather than a guessed number.
const bedsBefore = await page.evaluate(() => {
const out = {};
for (const s of window.game.scene.getScene('GameScene').sound?.getAllPlaying?.() ?? []) {
out[s.key] = s.currentConfig?.volume ?? s.volume;
}
return out;
});
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow?.isOpen === true, null, { timeout: 30000 });
console.log(' ✔ ?level5 opened the class console');
const gs = () => page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const cw = g.classWindow;
const v = g.ship?.body?.velocity;
return {
frozen: !!g.actionBar && g.actionBar.enabled === false,
shipVel: v ? { x: v.x, y: v.y } : null,
openState: cw.openState,
tab: cw._tab,
depth: cw.depth,
briefPlaying: g.sound?.isPlaying?.('class_brief') === true,
hasIcons: !!cw._tabs.find((t) => t.chip),
tabs: cw._tabs.map((t) => ({
id: t.id,
label: t.txt.text,
tex: t.chip ? t.chip.texture.key : null,
frame: t.chip ? String(t.chip.frame.name ?? '') : null,
w: Math.round(t.w),
})),
video: (() => {
const el = cw.video?.video;
return el ? { muted: el.muted === true || cw.video.volume === 0, loop: !!el.loop, ready: el.readyState >= 2, t: Math.round(el.currentTime * 10) / 10 } : null;
})(),
introText: cw._introText?.text ?? '',
classOnFile: g.classState.toJSON(),
beds: (g.sound?.getAllPlaying?.() ?? []).map((s) => ({ key: s.key, vol: Math.round((s.currentConfig?.volume ?? 1) * 100) / 100 })),
plate: g.classPlateSnapshot(),
combatActors: Array.isArray(g.combat?.active) ? g.combat.active.length : (g.combat?.active?.size ?? 0),
};
});
// (This headless box renders at a few frames per second, so every animation
// wait below is generous — the assertions are state-based, not clock-based.)
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'open', null, { timeout: 40000 });
await page.waitForTimeout(3000);
let st = await gs();
check(st.openState === 'open', `the console reports open (state=${st.openState})`);
check(st.frozen === true, 'the deck is stood down (the action bar is disabled through _frozen)');
check(st.shipVel && Math.abs(st.shipVel.x) < 0.01 && Math.abs(st.shipVel.y) < 0.01, `the ship is parked (vel ${JSON.stringify(st.shipVel)})`);
check(st.briefPlaying === true, "Trestle's clip is playing (assets/speech/trestle/classes.mp3)");
check((st.beds ?? []).every((b) => b.key === 'class_brief' || b.vol <= 0.26), `the other beds are ducked (${JSON.stringify(st.beds)})`);
check(st.hasIcons === true, 'the tabs draw their insignia from the class sheet');
check(st.tabs.length === 2 && st.tabs[0].frame === '0' && st.tabs[1].frame === '1', `tab frames 0/1 off ${st.tabs[0].tex} (${JSON.stringify(st.tabs.map((t) => t.frame))})`);
if (st.video?.t !== undefined) {
const t1 = st.video.t;
await page.waitForTimeout(900);
const st2 = await gs();
check(st.video.muted === true && st.video.loop === true, `the feed is muted + looping (${JSON.stringify(st.video)})`);
check(st2.video && st2.video.t > t1, `the feed is actually playing (t ${t1} → ${st2.video?.t})`);
}
// The intro shot with the copy COMPLETE: fast-forward the typing (it is paced
// by the clip; a screenshot must not wait 20 s for it).
await page.evaluate(() => {
const cw = window.game.scene.getScene('GameScene').classWindow;
if (cw._typing) cw._typing.t0 -= 60000;
});
await page.waitForTimeout(2500);
st = await gs();
const wanted = [
'You made it kid, level 5 is no joke.',
'pick the one that best suits you when',
"you're ready.",
];
check(wanted.every((frag) => st.introText.includes(frag)), 'the plate carries Trestle’s copy verbatim');
check(st.tab === null, 'the intro state has no tab picked yet');
await page.screenshot({ path: path.join(OUT, 'a-intro.png') });
// The tabs.
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._setTab('vanguard'));
await page.waitForTimeout(2500);
await page.screenshot({ path: path.join(OUT, 'b-vanguard.png') });
let vanguard = await page.evaluate(() => {
const cw = window.game.scene.getScene('GameScene').classWindow;
return { tab: cw._tab, text: cw._kids.filter((k) => k.type === 'Text').map((t) => t.text).join('\n') };
});
check(vanguard.tab === 'vanguard', 'the Vanguard tab opened');
check(/DUAL CANNON/i.test(vanguard.text) && /\+5 SKILL POINTS/.test(vanguard.text), 'its plate lists the dual cannon + 5 skill points');
check(/PICK THIS CLASS/i.test(vanguard.text), 'its plate offers PICK');
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._setTab('wraith'));
await page.waitForTimeout(2500);
await page.screenshot({ path: path.join(OUT, 'c-wraith.png') });
const wraith = await page.evaluate(() => {
const cw = window.game.scene.getScene('GameScene').classWindow;
return { tab: cw._tab, text: cw._kids.filter((k) => k.type === 'Text').map((t) => t.text).join('\n') };
});
check(/TARGETING COMPUTER/i.test(wraith.text) && /\+5 SKILL POINTS/.test(wraith.text), 'its plate lists the targeting computer + 5 skill points');
// The confirm dialog.
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._askPick('wraith'));
await page.waitForTimeout(3000);
const dlg = await page.evaluate(() => {
const d = window.game.scene.getScene('GameScene').classWindow.dialog;
return { open: d.isOpen, title: d.title.text, body: d.bodyTexts.map((t) => t.text).filter(Boolean) };
});
check(dlg.open === true && /LOCK IN WRAITH/i.test(dlg.title), `the dialog asks before anything is written (${dlg.title})`);
check(dlg.body.some((b) => /later/i.test(b) && /price/i.test(b)), 'it says the class can change later for a price');
await page.screenshot({ path: path.join(OUT, 'd-confirm.png') });
// CONFIRM → the ledger, and the world resumes.
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.dialog.fireConfirm());
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
await page.waitForTimeout(2500);
st = await gs();
check(st.classOnFile.id === 'wraith', `the class is on file (${JSON.stringify(st.classOnFile)})`);
check(st.classOnFile.perks.includes('targetingComputer') && st.classOnFile.skillPoints === 5, 'its bonus was granted (perk + 5 skill points)');
check(st.briefPlaying !== true, 'the brief is not left playing');
const stuckDucks = (st.beds ?? []).filter((b) => {
const base = bedsBefore[b.key];
return Number.isFinite(base) && base > 0 && b.vol < base * 0.9; // still under its own level
});
check(stuckDucks.length === 0, `nothing is left ducked (${JSON.stringify({ now: st.beds, before: bedsBefore })})`);
check(!!st.plate && st.plate.name === 'WRAITH' && st.plate.insignia === 1, `the ship console's class plate reads ${JSON.stringify(st.plate)}`);
// The deck steers again (the freeze released) — real input, W held.
check(st.frozen !== true, 'the deck came back off standby when the console closed');
// …with a real helm order (click-to-move is the game's own control) measured
// against where the ship actually ends up.
const before = await page.evaluate(() => {
const s = window.game.scene.getScene('GameScene').ship;
return { x: Math.round(s.x), y: Math.round(s.y) };
});
await page.mouse.click(300, 660); // an empty patch of the play field
await page.waitForTimeout(5000);
const after = await page.evaluate(() => {
const s = window.game.scene.getScene('GameScene').ship;
return { x: Math.round(s.x), y: Math.round(s.y), v: Math.round(Math.hypot(s.body.velocity.x, s.body.velocity.y)) };
});
const moved = Math.hypot(after.x - before.x, after.y - before.y);
check(moved > 3 || after.v > 1, `the world resumed — a helm order moves the ship (${JSON.stringify({ before, after })})`);
// The door is spent: the console never auto-fires again for this run.
const reopen = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
g._classPrompted = false; // pretend the flag was cleared…
g._classBeat();
return g.classWindow.isOpen;
});
check(reopen === false, 'a run with a class on file is never asked again');
// The ship console shows the class plate (its own door is gone).
await page.evaluate(() => window.game.scene.getScene('GameScene').shipWindow.open());
await page.waitForTimeout(4000);
const consoleState = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const texts = [];
const walk = (c) => {
for (const o of c?.list ?? []) {
if (o.type === 'Text') texts.push(o.text);
else if (o.list) walk(o);
}
};
walk(g.shipWindow);
return { joined: texts.join('\n'), frozen: g.actionBar?.enabled === false };
});
check(/VANGUARD|WRAITH/.test(consoleState.joined) && !/CHOOSE YOUR CLASS/.test(consoleState.joined), 'the ship console shows the class plate, not the door');
check(/SKILL POINTS BANKED/i.test(consoleState.joined), 'the class plate banks the skill points');
await page.screenshot({ path: path.join(OUT, 'e-ship-console.png') });
// ───────────────────────────────────────────────────────── SCENARIO 2
// The REAL door: no ?level5. A run that crosses combat level 5 while combat is
// quiet gets asked once; dismissing DEFERS (the ship console's door takes over)
// and the prompt never re-fires on its own.
console.log('== scenario 2 — the real level-5 crossing (no dev param) ==');
// ?noclass closes BOTH dev doors (the menu's walk-in and the class trigger), so
// this run is entered the way a player enters it — the menu's NEW GAME.
await page.goto(`${BASE}/index.html?noclass`, { waitUntil: 'load' });
await page.waitForTimeout(1500);
await page.evaluate(() => {
const m = window.game?.scene?.getScene?.('MenuScene');
if (m && !m.dead) m.startNewGame();
});
await page.waitForFunction(() => {
const g = window.game?.scene?.getScene?.('GameScene');
return !!(g && g.ship && g.classState);
}, null, { timeout: 90000 });
// The intro comm owns the screen until somebody presses ACKNOWLEDGE — so press
// it (the box's button calls CommsHub._ack; from out here that IS the click).
await page.waitForFunction(
() => {
const g = window.game.scene.getScene('GameScene');
if (g.comms?.paused) g.comms._ack(); // the ACKNOWLEDGE press itself
return !g.comms?.paused && !g.comms?.active;
},
null,
{ timeout: 120000, polling: 500 }
);
check(true, 'a plain boot is never asked for a class (?noclass + level 0)');
const beforeGrant = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
return { cw: !!g.classWindow, open: g.classWindow?.isOpen === true, prompted: g._classPrompted };
});
check(beforeGrant.cw === false && beforeGrant.prompted === false, 'nothing armed below the level');
await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
// straight to the door: enough XP to clear the curve's first five levels
for (let i = 0; i < 8; i++) g.combat.xp.grant(500, 'test');
});
await page.waitForFunction(
() => {
const g = window.game.scene.getScene('GameScene');
if (g.comms?.paused) g.comms._ack(); // whatever brief lands mid-flight, the player presses through
return g.classWindow?.isOpen === true;
},
null,
{ timeout: 150000, polling: 500 }
);
check(true, 'crossing level 5 in a quiet sky opened the console on its own');
await page.screenshot({ path: path.join(OUT, 'f-real-trigger.png') });
const armed = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
return { level: g.combat.xp.level, prompted: g._classPrompted, marquee: !!g.levelMarquee?.active };
});
check(armed.level >= 5 && armed.prompted === true, `the prompt is spent (level ${armed.level})`);
// DEFER: ESC closes it without a class, the ship console's door is the way back.
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
const deferred = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
return { hasClass: g.classState.hasClass, plate: g.classPlateSnapshot(), frozen: g.actionBar?.enabled === false };
});
check(deferred.hasClass === false && deferred.plate === null, 'closing without picking files no class');
check(deferred.frozen === false, 'the world runs again on the deferral');
await page.waitForTimeout(6000);
const nag = await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.isOpen);
check(nag === false, 'a deferred pick is not nagged — the console stays shut');
// …and the ship console's CHOOSE YOUR CLASS door re-opens it (no dead click).
const door = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const snap = g.shipScreenSnapshot();
// The console's own journey rule: no class on file + level ≥ classAt → 'choose'.
const mode = snap.playerClass ? 'class' : snap.level?.level >= 5 ? 'choose' : 'timeline';
g.openClassWindow({ source: 'shipConsole' });
return { mode };
});
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.isOpen, null, { timeout: 40000 });
check(door.mode === 'choose', 'the ship console still shows the CHOOSE YOUR CLASS door while nothing is filed');
check(true, 'that door re-opens the class console');
const real = errors.filter((e) => !/favicon|AudioContext|autoplay|WebGL|GPU lockout|swiftshader|SoundManager|Unlock/i.test(e));
check(real.length === 0, `no console errors${real.length ? ': ' + real.slice(0, 4).join(' | ') : ''}`);
await browser.close();
console.log(fails ? `\n${fails} FAILURE(S) — shots in ${OUT}` : `\nall checks passed — shots in ${OUT}`);
process.exit(fails ? 1 : 0);