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