332 lines
17 KiB
JavaScript
332 lines
17 KiB
JavaScript
/**
|
||
* 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);
|